fix(mem0-plugin): v0.2.2 (#5234)

This commit is contained in:
Kartik
2026-05-22 21:47:14 +05:30
committed by GitHub
parent 53a3998873
commit 16a7702d09
41 changed files with 889 additions and 675 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"name": "mem0",
"source": "./mem0-plugin",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
"version": "0.2.1"
"version": "0.2.2"
}
]
}
+1 -1
View File
@@ -12,7 +12,7 @@
"name": "mem0",
"source": "./mem0-plugin",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.",
"version": "0.2.1"
"version": "0.2.2"
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0",
"version": "0.2.1",
"version": "0.2.2",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows using the Mem0 Platform MCP server.",
"author": {
"name": "Mem0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0",
"version": "0.2.1",
"version": "0.2.2",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Codex workflows using the Mem0 Platform MCP server.",
"author": {
"name": "Mem0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0",
"version": "0.2.1",
"version": "0.2.2",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.",
"author": {
"name": "Mem0",
+13 -6
View File
@@ -1,5 +1,17 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "mcp__mem0__add_memory|mcp__plugin_mem0_mem0__add_memory",
"hooks": [
{
"type": "command",
"command": "${CODEX_PLUGIN_ROOT}/scripts/enforce_metadata_defaults.sh",
"timeout": 3
}
]
}
],
"SessionStart": [
{
"matcher": "startup|resume",
@@ -42,12 +54,7 @@
"type": "command",
"command": "${CODEX_PLUGIN_ROOT}/scripts/on_post_commit.sh",
"timeout": 5
}
]
},
{
"matcher": "Bash",
"hooks": [
},
{
"type": "command",
"command": "${CODEX_PLUGIN_ROOT}/scripts/on_bash_output.sh",
+5
View File
@@ -10,6 +10,11 @@
{
"command": "${CURSOR_PLUGIN_ROOT}/scripts/block_memory_write_cursor.sh",
"matcher": "Write|Edit"
},
{
"command": "${CURSOR_PLUGIN_ROOT}/scripts/enforce_metadata_defaults.sh",
"matcher": "mcp__mem0__add_memory|mcp__plugin_mem0_mem0__add_memory",
"timeout": 3
}
],
"postToolUse": [
+11 -6
View File
@@ -54,6 +54,16 @@
"timeout": 5
}
]
},
{
"matcher": "mcp__mem0__add_memory|mcp__plugin_mem0_mem0__add_memory",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/enforce_metadata_defaults.sh",
"timeout": 3
}
]
}
],
"PostToolUse": [
@@ -74,12 +84,7 @@
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_post_commit.sh",
"timeout": 5
}
]
},
{
"matcher": "Bash",
"hooks": [
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_bash_output.sh",
+75
View File
@@ -0,0 +1,75 @@
"""Shared content-chunking utilities for mem0-plugin import scripts."""
from __future__ import annotations
MIN_CHUNK_CHARS = 50
MAX_CHUNK_CHARS = 10_000
def split_by_headers(content: str, header_prefix: str = "## ") -> list[str]:
"""Split content by Markdown header lines (e.g. '## ').
The header line is included at the start of each chunk.
Returns a list of non-empty chunk strings.
"""
chunks: list[str] = []
current_lines: list[str] = []
for line in content.splitlines(keepends=True):
if line.startswith(header_prefix) and current_lines:
chunk = "".join(current_lines).strip()
if chunk:
chunks.append(chunk)
current_lines = [line]
else:
current_lines.append(line)
if current_lines:
chunk = "".join(current_lines).strip()
if chunk:
chunks.append(chunk)
return chunks
def split_by_hr_or_headers(content: str) -> list[str]:
"""Split content by '---' horizontal rules or '## ' headers.
Used for .continue/rules.md which may use either convention.
"""
import re
# Split on lines that are exactly "---" or start with "## "
chunks: list[str] = []
current_lines: list[str] = []
for line in content.splitlines(keepends=True):
is_hr = re.match(r"^---\s*$", line)
is_h2 = line.startswith("## ")
if (is_hr or is_h2) and current_lines:
chunk = "".join(current_lines).strip()
if chunk:
chunks.append(chunk)
current_lines = [] if is_hr else [line]
else:
current_lines.append(line)
if current_lines:
chunk = "".join(current_lines).strip()
if chunk:
chunks.append(chunk)
return chunks
def filter_and_truncate(chunks: list[str]) -> list[str]:
"""Filter out chunks shorter than MIN_CHUNK_CHARS, truncate long chunks."""
result: list[str] = []
for chunk in chunks:
if len(chunk) < MIN_CHUNK_CHARS:
continue
if len(chunk) > MAX_CHUNK_CHARS:
chunk = chunk[:MAX_CHUNK_CHARS]
result.append(chunk)
return result
+46 -1
View File
@@ -3,6 +3,7 @@
Resolution priority (project_id):
1. MEM0_PROJECT_ID env var (explicit override)
2. ~/.mem0/project_map.json lookup by cwd
2b. ~/.mem0/project_map.json lookup by remote hash (self-healing fallback)
3. Git remote slug: strip protocol/prefix, strip .git, replace / and : with -
e.g. git@github.com:mem0ai/mem0.git -> mem0ai-mem0
4. Fallback: basename of cwd
@@ -10,6 +11,7 @@ Resolution priority (project_id):
from __future__ import annotations
import hashlib
import json
import os
import re
@@ -34,6 +36,19 @@ def resolve_project_id(cwd: str | None = None) -> str:
mapped = project_map.get(cwd, "").strip()
if mapped:
return mapped
# 2b. Remote hash fallback (self-healing when folder is moved/renamed)
remote_key = _remote_hash_key(cwd)
if remote_key:
mapped = project_map.get(remote_key, "").strip()
if mapped:
# Self-heal: write the new CWD key so future lookups are fast
project_map[cwd] = mapped
try:
with open(map_path, "w") as f:
json.dump(project_map, f, indent=2)
except OSError:
pass
return mapped
except (OSError, json.JSONDecodeError, AttributeError):
pass
@@ -76,7 +91,7 @@ def resolve_branch(cwd: str | None = None) -> str:
def save_project_mapping(cwd: str, project_id: str) -> None:
"""Write cwd -> project_id into ~/.mem0/project_map.json."""
"""Write cwd -> project_id (and remote hash key -> project_id) into ~/.mem0/project_map.json."""
mem0_dir = os.path.expanduser("~/.mem0")
os.makedirs(mem0_dir, exist_ok=True)
map_path = os.path.join(mem0_dir, "project_map.json")
@@ -88,10 +103,40 @@ def save_project_mapping(cwd: str, project_id: str) -> None:
except (OSError, json.JSONDecodeError):
project_map = {}
project_map[cwd] = project_id
# Also write the remote hash key so the mapping survives folder moves/renames
remote_key = _remote_hash_key(cwd)
if remote_key:
project_map[remote_key] = project_id
with open(map_path, "w") as f:
json.dump(project_map, f, indent=2)
def _remote_hash_key(cwd: str | None = None) -> str:
"""Return a stable key derived from the git remote URL.
Runs ``git config --get remote.origin.url`` in *cwd* and returns a string
of the form ``remote:<sha256(url)[:16]>``. Returns an empty string when
the directory is not a git repo or has no remote configured.
"""
if cwd is None:
cwd = os.getcwd()
try:
result = subprocess.run(
["git", "config", "--get", "remote.origin.url"],
capture_output=True,
text=True,
check=True,
cwd=cwd,
)
url = result.stdout.strip()
if not url:
return ""
digest = hashlib.sha256(url.encode()).hexdigest()[:16]
return f"remote:{digest}"
except (subprocess.CalledProcessError, OSError):
return ""
def _remote_url_to_slug(url: str) -> str:
"""Convert a git remote URL to a deterministic slug.
+17 -2
View File
@@ -21,6 +21,7 @@ import urllib.error
import urllib.request
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _chunking import filter_and_truncate, split_by_headers
from _identity import resolve_api_key, resolve_user_id
from _project import resolve_branch, resolve_project_id
@@ -198,10 +199,24 @@ def main() -> None:
log.debug("Cannot read %s: %s", filename, e)
continue
if post_memory(api_key, content, user_id, filename, project_id, branch):
is_markdown = filename.endswith(".md")
if is_markdown:
chunks = filter_and_truncate(split_by_headers(content))
else:
chunks = filter_and_truncate([content])
if not chunks:
chunks = [content[:10000]]
success = True
for i, chunk in enumerate(chunks):
chunk_name = f"{filename}[{i+1}/{len(chunks)}]" if len(chunks) > 1 else filename
if not post_memory(api_key, chunk, user_id, chunk_name, project_id, branch):
success = False
if success:
hashes[hash_key] = current_hash
updated = True
# on API failure we don't update the hash — retry next session
if updated:
save_hashes(hashes)
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# PreToolUse hook for mcp__mem0__add_memory.
# Injects default metadata fields (confidence, files, source, type) when the
# agent omits them. Uses the hookSpecificOutput.updatedInput contract to
# actually modify the tool call parameters.
#
# Hook contract:
# exit 0 = allow. If stdout contains {"hookSpecificOutput": {"updatedInput": ...}},
# the updatedInput replaces the tool's input parameters.
# exit 2 = block (stderr shown as rejection reason).
set -euo pipefail
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null)
case "$TOOL_NAME" in
mcp__mem0__add_memory|mcp__plugin_mem0_mem0__add_memory) ;;
*) exit 0 ;;
esac
TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input // "{}"' 2>/dev/null)
PATCHED=$(python3 -c "
import json, sys
raw = sys.stdin.read()
try:
inp = json.loads(raw)
except Exception:
sys.exit(0)
meta = inp.get('metadata') or {}
changed = False
if 'confidence' not in meta:
meta['confidence'] = 0.7
changed = True
if 'files' not in meta:
meta['files'] = ['*']
changed = True
if 'source' not in meta:
meta['source'] = 'auto_capture'
changed = True
if 'type' not in meta:
meta['type'] = 'task_learning'
changed = True
if changed:
inp['metadata'] = meta
print(json.dumps(inp))
" <<< "$TOOL_INPUT" 2>/dev/null || true)
if [ -n "$PATCHED" ]; then
# Use hookSpecificOutput.updatedInput to actually modify the tool call
jq -n --argjson updated "$PATCHED" '{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": $updated
}
}'
fi
exit 0
+25 -7
View File
@@ -11,6 +11,8 @@ REQ_STAMP="${DATA_DIR}/requirements.txt"
mkdir -p "${DATA_DIR}"
LOCKDIR="${DATA_DIR}/.install-lock"
needs_install=false
if [ ! -f "${VENV_DIR}/bin/python3" ]; then
@@ -20,13 +22,29 @@ elif ! diff -q "${REQ_SRC}" "${REQ_STAMP}" >/dev/null 2>&1; then
fi
if [ "${needs_install}" = "true" ]; then
python3 -m venv "${VENV_DIR}" 2>/dev/null || python -m venv "${VENV_DIR}"
"${VENV_DIR}/bin/pip" install --quiet --upgrade pip >/dev/null 2>&1 || true
if "${VENV_DIR}/bin/pip" install --quiet -r "${REQ_SRC}" 2>/dev/null; then
cp "${REQ_SRC}" "${REQ_STAMP}"
if mkdir "${LOCKDIR}" 2>/dev/null; then
# We acquired the lock — proceed with installation
trap 'rmdir "${LOCKDIR}" 2>/dev/null || true' EXIT
python3 -m venv "${VENV_DIR}" 2>/dev/null || python -m venv "${VENV_DIR}"
"${VENV_DIR}/bin/pip" install --quiet --upgrade pip >/dev/null 2>&1 || true
if "${VENV_DIR}/bin/pip" install --quiet -r "${REQ_SRC}" 2>/dev/null; then
cp "${REQ_SRC}" "${REQ_STAMP}"
rm -f "${DATA_DIR}/.install-failed"
else
rm -f "${REQ_STAMP}"
touch "${DATA_DIR}/.install-failed"
echo "mem0 plugin: failed to install Python dependencies" >&2
exit 0
fi
else
rm -f "${REQ_STAMP}"
echo "mem0 plugin: failed to install Python dependencies" >&2
exit 0
# Another process holds the lock — wait up to 60s for it to finish
for i in $(seq 1 60); do
[ ! -d "${LOCKDIR}" ] && break
sleep 1
done
# Check if the other process's install failed
if [ -f "${DATA_DIR}/.install-failed" ]; then
echo "mem0 plugin: dependency installation failed (by another session)" >&2
fi
fi
fi
+5 -77
View File
@@ -24,87 +24,15 @@ import urllib.error
import urllib.request
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _chunking import (
filter_and_truncate,
split_by_headers,
split_by_hr_or_headers,
)
from _identity import resolve_api_key, resolve_user_id
from _project import resolve_branch, resolve_project_id
API_URL = "https://api.mem0.ai"
MIN_CHUNK_CHARS = 50
MAX_CHUNK_CHARS = 10_000
# ---------------------------------------------------------------------------
# Content splitting utilities
# ---------------------------------------------------------------------------
def split_by_headers(content: str, header_prefix: str = "## ") -> list[str]:
"""Split content by Markdown header lines (e.g. '## ').
The header line is included at the start of each chunk.
Returns a list of non-empty chunk strings.
"""
chunks: list[str] = []
current_lines: list[str] = []
for line in content.splitlines(keepends=True):
if line.startswith(header_prefix) and current_lines:
chunk = "".join(current_lines).strip()
if chunk:
chunks.append(chunk)
current_lines = [line]
else:
current_lines.append(line)
if current_lines:
chunk = "".join(current_lines).strip()
if chunk:
chunks.append(chunk)
return chunks
def split_by_hr_or_headers(content: str) -> list[str]:
"""Split content by '---' horizontal rules or '## ' headers.
Used for .continue/rules.md which may use either convention.
"""
import re
# Split on lines that are exactly "---" or start with "## "
chunks: list[str] = []
current_lines: list[str] = []
for line in content.splitlines(keepends=True):
is_hr = re.match(r"^---\s*$", line)
is_h2 = line.startswith("## ")
if (is_hr or is_h2) and current_lines:
chunk = "".join(current_lines).strip()
if chunk:
chunks.append(chunk)
current_lines = [] if is_hr else [line]
else:
current_lines.append(line)
if current_lines:
chunk = "".join(current_lines).strip()
if chunk:
chunks.append(chunk)
return chunks
def filter_and_truncate(chunks: list[str]) -> list[str]:
"""Filter out chunks shorter than MIN_CHUNK_CHARS, truncate long chunks."""
result: list[str] = []
for chunk in chunks:
if len(chunk) < MIN_CHUNK_CHARS:
continue
if len(chunk) > MAX_CHUNK_CHARS:
chunk = chunk[:MAX_CHUNK_CHARS]
result.append(chunk)
return result
# ---------------------------------------------------------------------------
# API helpers
+3 -1
View File
@@ -33,7 +33,9 @@ esac
# Detect stack traces and error patterns in command output
HAS_ERROR=""
if echo "$TOOL_RESULT" | grep -qiE '(Traceback \(most recent|Error:|Exception:|panic:|FAILED|fatal:|FAIL:| at .+\.[a-z]+:[0-9]+|error\[E[0-9]+\])'; then
if echo "$TOOL_RESULT" | grep -qE '(Traceback \(most recent call last\)|panic: |FATAL:|error\[E[0-9]+\])'; then
HAS_ERROR="true"
elif [ "$(echo "$TOOL_RESULT" | grep -cE '(Error:|Exception:)')" -ge 2 ]; then
HAS_ERROR="true"
fi
+2 -3
View File
@@ -1,9 +1,8 @@
#!/usr/bin/env bash
# Hook: PreToolUse (matcher: Bash)
#
# Detects `git commit` commands and:
# 1. Fires on_pre_commit.py in the background to capture staged changes as memory
# 2. Searches for relevant memories about the changed files and surfaces them
# Detects `git commit` commands and searches for relevant memories
# about the changed files, surfacing them as pre-commit context.
#
# Input: JSON on stdin with tool_name, tool_input
# Output: JSON with additionalContext (relevant memories for the commit)
+8 -1
View File
@@ -44,8 +44,15 @@ if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
# C5: commit prompts default OFF — only prompt if explicitly enabled in mem0.md
MEM0_CWD=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")
COMMIT_PROMPTS=$(python3 "$SCRIPT_DIR/parse_mem0_config.py" --key settings.commit_prompts "$MEM0_CWD" 2>/dev/null || echo "")
if [ "$COMMIT_PROMPTS" != "true" ]; then
exit 0
fi
# Extract changed files from commit
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD 2>/dev/null | head -10 || echo "")
CHANGED_FILES=$(git diff-tree --no-commit-id --name-only -r HEAD 2>/dev/null | head -10 || echo "")
if [ -z "$CHANGED_FILES" ]; then
exit 0
fi
-135
View File
@@ -1,135 +0,0 @@
#!/usr/bin/env python3
"""Pre-commit memory capture.
Captures a summary of staged changes as a mem0 memory before each commit.
Runs as a background fire-and-forget call — never blocks the commit.
Usage:
git diff --cached --stat | python3 on_pre_commit.py
# or with full diff:
git diff --cached | python3 on_pre_commit.py --full
Env vars required: MEM0_API_KEY (or CLAUDE_PLUGIN_OPTION_MEM0_API_KEY)
Env vars optional: MEM0_RESOLVED_USER_ID, MEM0_PROJECT_ID, MEM0_BRANCH
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
sys.path.insert(0, os.path.dirname(__file__))
from _identity import resolve_api_key, resolve_user_id
try:
from _project import resolve_branch, resolve_project_id
except ImportError:
def resolve_project_id() -> str:
return os.path.basename(os.getcwd())
def resolve_branch() -> str:
return "unknown"
def get_commit_message() -> str:
"""Read the current commit message from COMMIT_EDITMSG (pre-commit context).
Falls back to HEAD's message if COMMIT_EDITMSG doesn't exist yet
(e.g., when invoked outside the git hook context).
"""
try:
git_dir = subprocess.run(
["git", "rev-parse", "--git-dir"],
capture_output=True, text=True, timeout=5,
)
if git_dir.returncode == 0:
editmsg = os.path.join(git_dir.stdout.strip(), "COMMIT_EDITMSG")
if os.path.isfile(editmsg):
with open(editmsg) as f:
first_line = f.readline().strip()
if first_line and not first_line.startswith("#"):
return first_line
except Exception:
pass
try:
result = subprocess.run(
["git", "log", "-1", "--format=%s", "HEAD"],
capture_output=True, text=True, timeout=5,
)
return result.stdout.strip() if result.returncode == 0 else ""
except Exception:
return ""
def get_staged_summary() -> str:
try:
result = subprocess.run(
["git", "diff", "--cached", "--stat"],
capture_output=True, text=True, timeout=10,
)
return result.stdout.strip() if result.returncode == 0 else ""
except Exception:
return ""
def main() -> int:
api_key = resolve_api_key()
if not api_key:
return 0
diff_input = sys.stdin.read().strip() if not sys.stdin.isatty() else ""
staged = diff_input or get_staged_summary()
if not staged or len(staged) < 10:
return 0
user_id = os.environ.get("MEM0_RESOLVED_USER_ID") or resolve_user_id()
project_id = os.environ.get("MEM0_PROJECT_ID") or resolve_project_id()
branch = os.environ.get("MEM0_BRANCH") or resolve_branch()
commit_msg = get_commit_message()
lines = staged.splitlines()
if len(lines) > 30:
staged = "\n".join(lines[:30]) + f"\n... ({len(lines) - 30} more lines)"
content = f"## Commit Context\n\nBranch: {branch}\n"
if commit_msg:
content += f"Message: {commit_msg}\n"
content += f"\n### Staged Changes\n```\n{staged}\n```"
body = json.dumps({
"messages": [{"role": "user", "content": content}],
"user_id": user_id,
"app_id": project_id,
"metadata": {
"type": "commit_context",
"branch": branch,
"source": "pre-commit",
},
"infer": False,
}).encode()
req = urllib.request.Request(
"https://api.mem0.ai/v3/memories/add/",
data=body,
headers={
"Authorization": f"Token {api_key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10):
pass
except Exception:
pass
return 0
if __name__ == "__main__":
sys.exit(main())
+34 -3
View File
@@ -41,6 +41,15 @@ if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
# Check for failed dependency installation and warn the user
_DATA_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.mem0/plugin-data}"
if [ -f "${_DATA_DIR}/.install-failed" ]; then
echo ""
echo "⚠️ mem0 SDK installation failed. Some features may not work."
echo "Run: ${CLAUDE_PLUGIN_ROOT:-$SCRIPT_DIR/..}/scripts/ensure_deps.sh"
echo ""
fi
# Fetch project-scoped memory count (best-effort, don't block on failure, 5s timeout)
MEM0_COUNT="?"
if command -v python3 >/dev/null 2>&1; then
@@ -96,10 +105,14 @@ if command -v python3 >/dev/null 2>&1; then
MEM0_PROJECT_CONFIG=$(python3 "$SCRIPT_DIR/parse_mem0_config.py" --full "$MEM0_CWD_RESOLVED" 2>/dev/null || echo "{}")
fi
if [ -n "$MEM0_PROJECT_CONFIG" ] && [ "$MEM0_PROJECT_CONFIG" != "{}" ]; then
_CONFIG_KEYS=$(echo "$MEM0_PROJECT_CONFIG" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d))" 2>/dev/null || echo "?")
echo "### Project Config (mem0.md)"
echo "\`\`\`json"
echo "$MEM0_PROJECT_CONFIG"
echo "\`\`\`"
echo "\`mem0.md loaded (${_CONFIG_KEYS} sections configured)\`"
if [ -n "${MEM0_DEBUG:-}" ]; then
echo "\`\`\`json"
echo "$MEM0_PROJECT_CONFIG"
echo "\`\`\`"
fi
echo ""
fi
@@ -108,6 +121,8 @@ if [ "$SOURCE" = "startup" ]; then
_SAFE_PID=$(printf '%s' "$MEM0_PROJECT_ID" | tr '/:' '--')
_ONBOARD_MARKER="$HOME/.mem0/.onboarded_${_SAFE_PID}"
if [ ! -f "$_ONBOARD_MARKER" ]; then
mkdir -p "$HOME/.mem0" 2>/dev/null || true
touch "$_ONBOARD_MARKER"
cat <<'EOF'
## Mem0 First Run — Automatic Onboarding
@@ -135,6 +150,22 @@ IMPORTANT: Do NOT skip this step. Always bootstrap context first.
EOF
fi
# Detect native Claude Code auto-memory for THIS project
_PROJ_KEY=$(printf '%s' "$MEM0_CWD_RESOLVED" | tr '/' '-')
_MEMORY_MD="$HOME/.claude/projects/${_PROJ_KEY}/memory/MEMORY.md"
if [ -f "$_MEMORY_MD" ] && [ -s "$_MEMORY_MD" ]; then
cat <<MEMEOF
### Native auto-memory detected
Found \`$_MEMORY_MD\`. The mem0 plugin handles all memory storage.
To avoid two parallel memory systems:
- Add \`"autoMemoryEnabled": false\` to \`~/.claude/settings.json\`
- Or run \`/mem0:import\` to migrate existing MEMORY.md content into mem0
MEMEOF
fi
# Auto-import declarative project files in background
MEM0_CWD="$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")" \
python3 "$SCRIPT_DIR/auto_import.py" 2>/dev/null &
+9 -1
View File
@@ -24,9 +24,17 @@ if [ ${#RESULT_SUMMARY} -lt 50 ]; then
exit 0
fi
# Read subagent skip list from config, default to Explore|Plan
_SKIP_CSV=$(python3 "$SCRIPT_DIR/parse_mem0_config.py" --key settings.subagent_skip "$(git rev-parse --show-toplevel 2>/dev/null || echo ".")" 2>/dev/null || echo "")
if [ -n "$_SKIP_CSV" ]; then
_SKIP_PATTERN=$(echo "$_SKIP_CSV" | tr -d ' ' | tr ',' '|')
else
_SKIP_PATTERN="Explore|Plan"
fi
# Skip explorer/plan agents — read-only, rarely produce storable learnings
case "$AGENT_TYPE" in
Explore|Plan)
$_SKIP_PATTERN)
exit 0
;;
esac
+5 -1
View File
@@ -31,7 +31,11 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Detect stack traces and error patterns in the prompt (no API needed)
HAS_ERROR=""
if echo "$PROMPT" | grep -qiE '(Traceback|Error:|Exception:|panic:|FAILED|fatal:| at .+\.[a-z]+:[0-9]+)'; then
if echo "$PROMPT" | grep -qE '(Traceback|panic:)'; then
HAS_ERROR="true"
elif echo "$PROMPT" | grep -qE '^\s*fatal: '; then
HAS_ERROR="true"
elif [ "$(echo "$PROMPT" | grep -cE '(Error:|Exception:|FAIL:)')" -ge 2 ]; then
HAS_ERROR="true"
fi
+40 -2
View File
@@ -199,6 +199,10 @@ def load_full_config(cwd: str | None = None) -> dict:
if ignore:
config["ignore"] = ignore
settings = parse_section_kv(content, "Settings")
if settings:
config["settings"] = settings
return config
@@ -229,10 +233,44 @@ def main() -> int:
With ``--full``, prints the complete config. Without it, prints only
retention policies (backward-compatible).
With ``--key <dotted.path>``, prints the scalar value at that path in the
full config (e.g. ``--key settings.commit_prompts``). Prints an empty
string when the key is absent. Exits 1 only on unexpected errors.
"""
full_mode = "--full" in sys.argv
args = [a for a in sys.argv[1:] if not a.startswith("--")]
cwd = args[0] if args else os.getcwd()
# Extract --key <value>
key_path: str | None = None
raw_args = sys.argv[1:]
filtered_args: list[str] = []
i = 0
while i < len(raw_args):
if raw_args[i] == "--key" and i + 1 < len(raw_args):
key_path = raw_args[i + 1]
i += 2
elif raw_args[i].startswith("--key="):
key_path = raw_args[i][len("--key="):]
i += 1
elif raw_args[i].startswith("--"):
i += 1 # skip other flags like --full
else:
filtered_args.append(raw_args[i])
i += 1
cwd = filtered_args[0] if filtered_args else os.getcwd()
if key_path is not None:
config = load_full_config(cwd)
# Traverse dotted path
value: object = config
for part in key_path.split("."):
if not isinstance(value, dict):
value = None
break
value = value.get(part)
print(value if value is not None else "")
return 0
if full_mode:
config = load_full_config(cwd)
+13 -4
View File
@@ -27,7 +27,16 @@ import sys
import urllib.error
import urllib.request
PLUGIN_VERSION = "0.2.1"
def _load_plugin_version() -> str:
try:
plugin_json = os.path.join(os.path.dirname(__file__), "..", ".claude-plugin", "plugin.json")
with open(plugin_json) as f:
return json.load(f).get("version", "unknown")
except (OSError, json.JSONDecodeError, KeyError):
return "unknown"
PLUGIN_VERSION = _load_plugin_version()
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/"
@@ -42,10 +51,10 @@ def _sha256(value: str) -> str:
def _distinct_id() -> str:
"""Stable anonymous ID: MD5 of API key if available, else SHA-256 of username."""
"""Stable anonymous ID: SHA-256 of API key if available, else SHA-256 of username."""
api_key = os.environ.get("MEM0_API_KEY") or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY") or ""
if api_key:
return hashlib.md5(api_key.encode()).hexdigest()
return hashlib.sha256(api_key.encode()).hexdigest()[:32]
user_id = os.environ.get("MEM0_RESOLVED_USER_ID") or os.environ.get("USER") or "unknown"
return _sha256(user_id)
@@ -75,6 +84,7 @@ def build_posthog_payload(event_name: str, properties: dict | None = None) -> di
"distinct_id": _distinct_id(),
"event": event_name,
"properties": {
**(properties or {}),
"source": "plugin",
"platform": detect_platform(),
"plugin_version": PLUGIN_VERSION,
@@ -84,7 +94,6 @@ def build_posthog_payload(event_name: str, properties: dict | None = None) -> di
"sample_rate": SAMPLE_RATE,
"$process_person_profile": False,
"$lib": "posthog-python",
**(properties or {}),
},
}
-123
View File
@@ -1,123 +0,0 @@
---
name: mem0-digest
description: >
Summarize recent memory activity for the current project. Shows new memories,
categories touched, and growth trends over the past 7 days.
TRIGGER: user runs /mem0:digest, or asks "weekly summary", "what's new in memory",
"mem0 digest", "memory recap".
---
# Mem0 Weekly Digest
Summarize recent memory activity for the current project.
## Execution
### Step 1: Fetch recent memories
Call `search_memories` in parallel with different time-scoped queries:
1. `query="decisions made this week"`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}, {"created_at": {"gte": "<7 days ago YYYY-MM-DD>"}}]}`, `limit=20`
2. `query="bugs errors fixes"`, same time filter, `limit=20`
3. `query="patterns conventions learnings"`, same time filter, `limit=20`
Also call `get_memories` with `user_id` + `app_id` to get the full count for comparison.
### Step 2: Deduplicate and analyze
Merge results by memory ID. For each memory, extract:
- `created_at` date
- `categories[0]` or `metadata.type`
- First 100 chars of content
Group into:
- **New this week** (created in last 7 days)
- **Older** (created before, but surfaced by search)
Calculate:
- Total memories in project
- Memories added in last 7 days
- Most active categories this week
- Days with most activity
### Step 3: Display
```
## mem0 Weekly Digest — <project_id>
Period: <start_date> to <today>
### New Memories This Week: <N>
<category>: <count>
- <memory summary, 80 chars> (<date>)
- ...
<category>: <count>
- ...
### Activity Pattern
Most active day: <day> (<N> memories)
Categories touched: <list>
### Project Totals
Total memories: <N> (up <N> from last week)
Top categories: <top 3 by count>
### Highlights
<2-3 sentence summary of the most important decisions, learnings, or patterns stored this week>
```
### Step 4: Write digest to file
After displaying, write the digest to `~/.mem0/weekly-digest.md` for persistence
and external consumption (email, Slack, etc.):
```bash
mkdir -p ~/.mem0
```
Write the full digest output (same markdown shown in terminal) to `~/.mem0/weekly-digest.md`
using the Write tool. **Overwrite** the file each time — it always contains the latest digest.
Also append a one-line summary to `~/.mem0/digest-history.log` for trend tracking:
```bash
echo "<YYYY-MM-DD> | <project_id> | +<new_count> memories | top: <top_category>" >> ~/.mem0/digest-history.log
```
Print at the end:
```
Digest saved to ~/.mem0/weekly-digest.md
```
### Step 5: Schedule recurring digests
When invoked with `--schedule` (e.g., `/mem0:digest --schedule weekly`), register
a cloud routine via Claude Code's `/schedule` command:
```
/schedule <frequency> /mem0:digest
```
For example:
- `/schedule weekly on Monday 9am /mem0:digest` — digest every Monday morning
- `/schedule daily at 8am /mem0:digest` — daily digest
Print:
```
Digest scheduled: <frequency>
Manage at: https://claude.ai/code/routines
```
If `/schedule` is unavailable, print a cron one-liner the user can install manually:
```bash
# macOS/Linux — weekly Monday 9am
(crontab -l 2>/dev/null; echo "0 9 * * 1 cd PROJECT_DIR && claude -p '/mem0:digest' >> /tmp/mem0-digest.log 2>&1") | crontab -
```
### Step 6: Empty state
If no memories in the last 7 days:
```
No new memories in the past week for <project_id>.
Total project memories: <N>.
Tip: mem0 captures learnings automatically as you work. Start coding!
```
+52
View File
@@ -235,6 +235,58 @@ In auto mode:
)
```
## Forget mode (targeted deletion)
When invoked with `--forget` (e.g., `/mem0:dream --forget auth module decisions`
or `/mem0:dream --forget <memory_id>`), skip consolidation and go straight to
search-confirm-delete:
### F1: Parse input
The argument after `--forget` is either:
- A search query: `/mem0:dream --forget auth module decisions`
- A memory ID: `/mem0:dream --forget <memory_id>`
If no argument after `--forget`, ask: "What should I forget? Provide a search query or memory ID."
### F2: Find memories
**If memory ID provided** (looks like a UUID or hex string):
- Call `get_memory` with the ID to verify it exists.
- Show: `Found: "<memory content first 120 chars>" (created <date>)`
**If search query provided:**
- Call `search_memories` with:
- `query=<user's query>`
- `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<project_id>"}]}`
- `limit=10`
- Show numbered list:
```
Found <N> memories matching "<query>":
1. <content, 120 chars> (type: <type>, created: <date>) [ID: <short_id>]
2. ...
```
### F3: Confirm
Ask: "Delete which memories? Enter numbers (e.g., 1,3,5), 'all', or 'cancel'."
For a single memory ID: "Delete this memory? [y/N]"
**Never delete without confirmation.**
### F4: Delete and report
Call `delete_memory` for each confirmed entry. Report: `Deleted <N> memories.`
### Undo recent writes
If the user says "undo last N memories" or "undo last write":
1. Run `python3 "$SCRIPT_DIR/session_stats.py" peek` to get `recent_ids`.
2. Show last N entries, ask for confirmation.
3. Delete confirmed entries via `delete_memory`.
---
## Scheduling recurring dreams
When invoked with `--schedule` (e.g., `/mem0:dream --schedule weekly`), register a
-76
View File
@@ -1,76 +0,0 @@
---
name: mem0-forget
description: >
Delete memories by search query or memory ID. Shows matches for confirmation
before deleting. Safe — always confirms before destructive action.
TRIGGER: user runs /mem0:forget <query>, or says "forget this", "delete memory",
"remove that memory about X".
---
# Mem0 Forget
Delete specific memories from mem0.
## Execution
### Step 1: Parse input
The user provides either:
- A search query: `/mem0:forget auth module decisions`
- A memory ID: `/mem0:forget <memory_id>`
If no argument, ask: "What should I forget? Provide a search query or memory ID."
### Step 2: Find memories
**If memory ID provided** (looks like a UUID or hex string):
- Call `get_memory` with the ID to verify it exists.
- Show: `Found: "<memory content first 120 chars>" (created <date>)`
**If search query provided:**
- Call `search_memories` with:
- `query=<user's query>`
- `user_id=<active_user_id>`
- `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<project_id>"}]}`
- `limit=10`
- Show numbered list:
```
Found <N> memories matching "<query>":
1. <content, 120 chars> (type: <type>, created: <date>) [ID: <short_id>]
2. ...
```
### Step 3: Confirm
Ask: "Delete which memories? Enter numbers (e.g., 1,3,5), 'all', or 'cancel'."
For a single memory ID, ask: "Delete this memory? [y/N]"
**Never delete without confirmation.** This is destructive.
### Step 4: Delete
For each confirmed memory, call `delete_memory` with the memory ID.
### Step 5: Report
```
Deleted <N> memories.
```
If any deletions failed, report which ones and why.
## Undo recent writes
If the user says "undo last N memories" or "undo last write":
1. Read session stats to get recently written memory IDs:
```bash
SCRIPT_DIR="${CLAUDE_PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}}/scripts"
python3 "$SCRIPT_DIR/session_stats.py" peek
```
2. Parse the `recent_ids` array from the JSON output. Each entry has `id`, `category`, `ts`.
3. Show the last N entries (default 1) and ask for confirmation.
4. Delete confirmed entries via `delete_memory`.
If `recent_ids` is empty, tell the user: "No recent memory IDs tracked this session. Use a search query instead."
@@ -1,106 +0,0 @@
---
name: mem0-import-tools
description: >
Import memories from competing AI tool configuration files into mem0.
Supports Cursor (.cursorrules), GitHub Copilot (.github/copilot-instructions.md),
Cline (memory-bank/), and Continue (.continue/rules.md).
TRIGGER: user runs /mem0:import-tools, or asks "import from cursor",
"import cursorrules", "import from cline", "import from copilot",
"import from continue", "migrate from cursor", "migrate memories".
---
# Mem0 Import from Competing Tools
Import configuration and memory files from other AI coding tools into mem0.
## Supported Tools
| Tool | Default file/directory |
|------|----------------------|
| Cursor | `.cursorrules` |
| GitHub Copilot | `.github/copilot-instructions.md` |
| Cline | `memory-bank/` (directory of `.md` files) |
| Continue | `.continue/rules.md` |
## Execution
### Step 1: Detect which tool files exist
Check for the presence of each tool's file/directory in the current working directory:
```bash
# Check each location
test -f .cursorrules && echo "cursor: .cursorrules"
test -f .github/copilot-instructions.md && echo "copilot: .github/copilot-instructions.md"
test -d memory-bank/ && echo "cline: memory-bank/"
test -f .continue/rules.md && echo "continue: .continue/rules.md"
```
### Step 2: Report findings and ask user
List all found files to the user. For example:
```
Found the following tool configuration files:
[1] Cursor rules: .cursorrules
[2] Cline memory bank: memory-bank/
Which would you like to import? (enter numbers, comma-separated, or "all"):
```
If no files are found, print:
```
No competing tool configuration files found in the current directory.
Checked: .cursorrules, .github/copilot-instructions.md, memory-bank/, .continue/rules.md
```
and stop.
### Step 3: Run the import script for each selected tool
Determine the plugin root. Use the appropriate variable for the current platform:
- Claude Code: `${CLAUDE_PLUGIN_ROOT}`
- Codex: `${CODEX_PLUGIN_ROOT}`
- Cursor: `${CURSOR_PLUGIN_ROOT}`
For each tool the user selected, run the corresponding sub-command:
**Cursor (.cursorrules):**
```bash
python3 "<PLUGIN_ROOT>/scripts/import_competing_tools.py" cursorrules --path .cursorrules
```
**GitHub Copilot:**
```bash
python3 "<PLUGIN_ROOT>/scripts/import_competing_tools.py" copilot --path .github/copilot-instructions.md
```
**Cline:**
```bash
python3 "<PLUGIN_ROOT>/scripts/import_competing_tools.py" cline --path memory-bank/
```
**Continue:**
```bash
python3 "<PLUGIN_ROOT>/scripts/import_competing_tools.py" continue --path .continue/rules.md
```
### Step 4: Report results
After each script runs, echo its output to the user. Then print a combined summary:
```
Import complete.
Cursor: <N> memories
Copilot: <N> memories
Total: <N> memories imported into project <project_id>
```
Adjust the summary to reflect only the tools that were actually imported.
## Notes
- Memories are imported with `infer=False` — no AI inference is applied, content is stored as-is.
- Each section or file becomes a separate memory tagged with `metadata.source=<tool>-import` and `metadata.type=project_profile`.
- Sections shorter than 50 characters are automatically skipped (too short to be useful).
- Content longer than 10,000 characters is automatically truncated per chunk.
- You can re-run this skill safely — duplicate content will be caught by mem0's deduplication.
+81
View File
@@ -98,6 +98,87 @@ If any failed:
Imported <N>/<total> memories into project <project_id> (<failed> failed)
```
## Importing from competing AI tools (`--tools`)
When invoked with `--tools` (e.g., `/mem0:import --tools`), detect and import
from competing AI tool configuration files:
### Supported tools
| Tool | File/directory |
|------|---------------|
| Cursor | `.cursorrules` |
| GitHub Copilot | `.github/copilot-instructions.md` |
| Cline | `memory-bank/` (directory of `.md` files) |
| Continue | `.continue/rules.md` |
### T1: Detect
```bash
test -f .cursorrules && echo "cursor: .cursorrules"
test -f .github/copilot-instructions.md && echo "copilot: .github/copilot-instructions.md"
test -d memory-bank/ && echo "cline: memory-bank/"
test -f .continue/rules.md && echo "continue: .continue/rules.md"
```
### T2: Ask user
List found files, ask which to import (numbers, comma-separated, or "all").
If none found:
```
No competing tool configuration files found.
Checked: .cursorrules, .github/copilot-instructions.md, memory-bank/, .continue/rules.md
```
### T3: Run import
For each selected tool:
```bash
python3 "<PLUGIN_ROOT>/scripts/import_competing_tools.py" <tool> --path <file>
```
Tools: `cursorrules`, `copilot`, `cline`, `continue`.
### T4: Report
```
Import complete.
Cursor: <N> memories
Copilot: <N> memories
Total: <N> memories imported into project <project_id>
```
Notes: `infer=False`, tagged `metadata.source=<tool>-import`, sections <50 chars
skipped, chunks >10k chars truncated, safe to re-run (deduplication handles it).
---
## Importing Claude Code's native MEMORY.md
When invoked with a path to Claude Code's native `MEMORY.md` file (typically
`~/.claude/projects/<proj-key>/memory/MEMORY.md`), or when `on_session_start.sh`
detects native auto-memory and the user chooses to import:
1. Read the file. It contains newline-separated memory entries (one fact per line,
sometimes with `- ` bullet prefix).
2. Split by non-empty lines. Each line becomes one memory.
3. Skip lines shorter than 20 characters or lines that are just headers (`#`).
4. For each line, call `add_memory` with:
- `messages=[{"role": "user", "content": "<line>"}]`
- `user_id=<active_user_id>`
- `app_id=<active_project_id>`
- `metadata={"type": "task_learning", "source": "memory-md-import", "confidence": 0.8}`
- `infer=False`
5. Report: `Imported <N> memories from MEMORY.md into project <project_id>`
6. Suggest disabling native auto-memory:
```
To avoid duplicate memory systems, add to ~/.claude/settings.json:
"autoMemoryEnabled": false
```
This handles the cold-start gap when a user has been using Claude Code's native
memory and switches to mem0.
## Error Handling
- If the parser script is not found at `<PLUGIN_ROOT>/scripts/parse_export_file.py`, print an error and stop.
@@ -0,0 +1,71 @@
---
name: mem0-list-projects
description: >
List all project scopes (app_ids) that have stored memories for the current
user. Essential for /mem0:switch-project discoverability and cross-project
workflows.
TRIGGER: user runs /mem0:list-projects, or asks "what projects does mem0 know",
"list my mem0 projects", "show all projects", "which app_ids exist".
---
# Mem0 List Projects
Show all known project scopes for the current user.
## Execution
### Step 1: Fetch memories to discover app_ids
There is no dedicated "list projects" API endpoint. Discover projects by fetching
the user's memories and extracting distinct `app_id` values.
Call `get_memories` with:
- `user_id=<active_user_id>`
- `page_size=200`
Do NOT pass `app_id` — we want memories across ALL projects.
If the response indicates more pages, paginate until all are fetched (up to 1000
memories max to avoid excessive API calls).
### Step 2: Extract distinct projects
For each memory, read the `app_id` field (may also appear as `metadata.app_id`
on older memories). Collect distinct values.
For each project, count:
- Total memories
- Most recent `created_at` date
- Top 3 `metadata.type` values by frequency
### Step 3: Display
```
## mem0 Projects for <user_id>
| Project | Memories | Last Active | Top Categories |
|---------|----------|-------------|---------------|
| <app_id_1> | <count> | <date> | decision, convention, anti_pattern |
| <app_id_2> | <count> | <date> | task_learning, environmental |
| ... | | | |
Active project: <current_project_id> ← (current)
Total: <N> projects, <M> total memories
```
Mark the current project with `← (current)`.
### Step 4: Empty state
If zero memories found:
```
No projects found for user <user_id>.
Run /mem0:onboard in a project directory to get started.
```
### Step 5: Suggest next actions
```
Switch project: /mem0:switch-project <name>
Search across all: /mem0:tour --all-projects
```
+40
View File
@@ -49,6 +49,21 @@ filters={"AND": [
Empty results are normal. Proceed without context — they don't mean the system is broken.
### Contradiction detection at search time
After receiving search results, scan for contradictions before using them:
1. If 2+ results address the **same topic** (same `metadata.type`, overlapping file paths or entity names) but assert **opposing facts**, surface BOTH to the user instead of silently picking one.
2. Format:
```
⚠️ Conflicting memories found:
- [mem0:<id1>] "<content1>" (confidence: <score1>, <date1>)
- [mem0:<id2>] "<content2>" (confidence: <score2>, <date2>)
Which is current?
```
3. After the user resolves: update the loser via `update_memory` to mark it superseded, or delete it. Store the winner's fact as authoritative if not already.
4. If the user doesn't resolve, default to the more recent memory with higher confidence, but note the ambiguity in your response.
### How to search well
When you do search, run **2–4 parallel** `search_memories` calls at different angles instead of one query echoing the user's prompt.
@@ -130,6 +145,31 @@ search_memories(query="auth",
Extract key learnings and store them using the `add_memory` tool:
### REQUIRED metadata fields
Every `add_memory` call MUST include these metadata fields. Do NOT omit them:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | YES | Memory category: `decision`, `task_learning`, `anti_pattern`, `convention`, `user_preference`, `environmental`, `session_state`, `compact_summary` |
| `confidence` | float | YES | 0.0–1.0 confidence score. Default: `0.7` for inferred learnings, `0.9` for explicit user statements |
| `files` | list[str] | YES | File paths relevant to this memory. Use `["*"]` for project-wide learnings |
| `source` | string | YES | How the memory was captured: `user_request`, `auto_capture`, `post_commit`, `error_recovery` |
**Example:**
```json
{
"metadata": {
"type": "decision",
"confidence": 0.9,
"files": ["src/auth/login.py", "src/auth/middleware.py"],
"source": "user_request"
}
}
```
**If you omit `confidence` or `files`, the memory will be harder to rank and retrieve later.**
- **Decisions made** -> Include metadata `{"type": "decision"}`
- **Strategies that worked** -> Include metadata `{"type": "task_learning"}`
- **Failed approaches** -> Include metadata `{"type": "anti_pattern"}`
+1 -1
View File
@@ -82,7 +82,7 @@ Then retry the categories script.
## Step 5: Mark project as onboarded
Create a marker file so SessionStart won't re-trigger onboarding next session:
The marker file is already created by `on_session_start.sh` on first display of the onboard prompt. Touch it here for idempotency:
```bash
_SAFE_PID=$(printf '%s' "<active_project_id>" | tr '/:' '--')
-46
View File
@@ -1,46 +0,0 @@
---
name: mem0-peek
description: >
Quick search — compact one-liner results. Faster and lighter than /mem0:tour.
Takes a search query as argument.
TRIGGER: user runs /mem0:peek <query>, or says "quick search mem0",
"what do we know about X", "peek at memories about Y".
---
# Mem0 Peek
Quick search with compact output. Lighter than `/mem0:tour`.
## Execution
### Step 1: Parse query
The user provides a search query: `/mem0:peek auth middleware`
If no query provided, ask: "What should I search for?"
### Step 2: Search
Run 2 parallel `search_memories` calls:
1. Broad: `query=<user's query>`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}]}`, `limit=10`
2. Targeted: `query=<user's query>`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}, {"metadata": {"type": "decision"}}]}`, `limit=5`
### Step 3: Display
Deduplicate by ID, then show compact results:
```
## mem0 peek: "<query>" (<N> results)
1. [decision] Auth module uses JWT with RS256 keys (2025-05-15) [mem0:a3f8b2c1]
2. [anti_pattern] Don't use symmetric HS256 — leaked in env (2025-05-10) [mem0:7e2d9f4a]
3. [convention] All middleware in src/middleware/ (2025-05-08) [mem0:c4d5e6f7]
```
Format: `<number>. [<type>] <content, 80 chars> (<date>) [mem0:<short_id>]`
If no results:
```
No memories matching "<query>" for project <project_id>.
```
+34 -5
View File
@@ -1,10 +1,12 @@
---
name: mem0-pin
description: >
Pin important memories so they surface prominently. Updates metadata to mark
a memory as pinned. Pinned memories should be prioritized during search.
TRIGGER: user runs /mem0:pin <query or ID>, or says "pin this memory",
"mark as important", "always remember this".
Pin important memories so they surface prominently and are protected from
consolidation. New pins use immutable: true (SDK v2.1.33+) to prevent
deduplication from modifying or removing the memory. Existing memories get
metadata.pinned: true as a search-time filter; full consolidation protection
requires delete + re-add. TRIGGER: user runs /mem0:pin <query or ID>, or
says "pin this memory", "mark as important", "always remember this".
---
# Mem0 Pin
@@ -35,12 +37,28 @@ This is required because `update_memory` replaces the full memory — a metadata
### Step 3: Pin it
There are two sub-cases depending on whether the user wants to pin an **existing** memory or create a **new** pinned memory from scratch.
#### 3a: Pinning a new memory (preferred path for full protection)
Use `add_memory` with `immutable: true` as a top-level parameter. The `immutable` flag is a first-class platform parameter (added in SDK v2.1.33) that prevents the memory from being modified or removed by consolidation/deduplication. `metadata.pinned: true` is also included as a search-time filter aid.
```python
add_memory(
messages=[{"role": "user", "content": "<text to pin>"}],
user_id=<user_id>,
immutable=True,
metadata={"pinned": True},
)
```
#### 3b: Pinning an existing memory
Call `update_memory` with:
- `memory_id=<selected_id>`
- `data=<original_text>` (preserve the existing content)
- `metadata=` merge `original_metadata` with `{"pinned": true}`
Example:
```python
updated_meta = {**original_metadata, "pinned": True}
update_memory(memory_id=<selected_id>, data=<original_text>, metadata=updated_meta)
@@ -48,12 +66,23 @@ update_memory(memory_id=<selected_id>, data=<original_text>, metadata=updated_me
**Important:** `update_memory` requires the `data` (text) parameter. Passing only metadata may error or wipe content. Always read first, then update with the full text and explicit metadata.
**Consolidation-protection limitation:** `update_memory` cannot set the `immutable` flag on an existing memory. If the user needs full consolidation protection for an existing memory, they must delete it and re-add it using `add_memory` with `immutable: true` (path 3a above). In the confirm step (Step 4), note this limitation if the user pinned an existing memory via `update_memory`.
### Step 4: Confirm
For new pins created via `add_memory` with `immutable: true`:
```
Pinned: "<memory content, first 80 chars>..."
Memory ID: <id>
Pinned with consolidation protection (immutable). This memory will not be modified or removed by deduplication.
```
For existing memories pinned via `update_memory`:
```
Pinned: "<memory content, first 80 chars>..."
Memory ID: <id>
Pinned memories surface first when relevant to a search.
Note: consolidation protection (immutable flag) requires delete + re-add. Say "re-pin this with full protection" to do that.
```
### Unpin
+53
View File
@@ -94,3 +94,56 @@ Print a compact dashboard with an ASCII histogram for category distribution:
- **Access count buckets:** Read `metadata.access_count` (default 0 if absent). Buckets: 0, 1–5, 6–20, 20+.
Skip any section with zero data.
## Weekly digest mode
When invoked with `--weekly` (e.g., `/mem0:stats --weekly`), append a weekly
activity digest after the standard stats dashboard:
### W1: Fetch recent memories
Call `search_memories` in parallel with time-scoped queries:
1. `query="decisions made this week"`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}, {"created_at": {"gte": "<7 days ago YYYY-MM-DD>"}}]}`, `limit=20`
2. `query="bugs errors fixes"`, same time filter, `limit=20`
3. `query="patterns conventions learnings"`, same time filter, `limit=20`
### W2: Analyze
Merge by ID. Group into "New this week" by `categories[0]` or `metadata.type`.
Calculate: memories added last 7 days, most active categories, most active day.
### W3: Display
Append after the standard stats:
```
### Weekly Digest (<start_date> to <today>)
New Memories This Week: <N>
<category>: <count>
- <memory summary, 80 chars> (<date>)
- ...
Activity Pattern
Most active day: <day> (<N> memories)
Categories touched: <list>
Highlights
<2-3 sentence summary of most important decisions/learnings this week>
```
### W4: Write digest file
Write to `~/.mem0/weekly-digest.md` (overwrite). Append one-line to
`~/.mem0/digest-history.log`:
```
<YYYY-MM-DD> | <project_id> | +<new_count> memories | top: <top_category>
```
### W5: Empty state
If no new memories in 7 days:
```
No new memories in the past week for <project_id>.
Total project memories: <N>.
```
+49
View File
@@ -11,6 +11,55 @@ description: >
Show the user what mem0 has stored for the current project.
## Cross-project mode
When invoked with `--all-projects` (e.g., `/mem0:tour --all-projects` or
`/mem0:tour --all-projects auth middleware`), search across ALL projects:
1. Call `get_memories` with `user_id=<active_user_id>`, `page_size=200` — **no `app_id` filter**.
2. If a search query was also provided, run `search_memories` with `query=<query>`,
`filters={"AND": [{"user_id": "<id>"}]}`, `limit=20` — again no `app_id`.
3. Group results by `app_id` first, then by category within each project.
4. Display:
```
## Cross-Project Tour for <user_id>
### <app_id_1> (<N> memories)
#### Architecture Decisions
- <memory content>
...
### <app_id_2> (<N> memories)
...
---
Total: <N> memories across <M> projects
```
5. Mark the current project with `← (current)` in the heading.
If `--all-projects` is NOT present, use the standard single-project flow below.
## Peek mode (compact search)
When `/mem0:tour` receives a search query argument (e.g., `/mem0:tour auth middleware`)
WITHOUT `--all-projects`, run in **peek mode** — compact one-liner results:
1. Run 2 parallel `search_memories` calls:
- Broad: `query=<query>`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}]}`, `limit=10`
- Targeted: `query=<query>`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}, {"metadata": {"type": "decision"}}]}`, `limit=5`
2. Deduplicate by ID, display compact results:
```
## mem0 search: "<query>" (<N> results)
1. [decision] Auth module uses JWT with RS256 keys (2025-05-15) [mem0:a3f8b2c1]
2. [anti_pattern] Don't use symmetric HS256 — leaked in env (2025-05-10) [mem0:7e2d9f4a]
3. [convention] All middleware in src/middleware/ (2025-05-08) [mem0:c4d5e6f7]
```
Format: `<number>. [<type>] <content, 80 chars> (<date>) [mem0:<short_id>]`
3. If no results: `No memories matching "<query>" for project <project_id>.`
If no query argument and no `--all-projects` flag, use the full tour flow below.
## Execution
### Step 1: Fetch ALL memories for this project
+11
View File
@@ -22,6 +22,17 @@ def _scripts_on_path():
sys.path.remove(abs_scripts)
@pytest.fixture(autouse=True)
def _clean_project_map():
"""Remove project_map.json before each test to prevent cross-test pollution."""
map_path = os.path.expanduser("~/.mem0/project_map.json")
if os.path.isfile(map_path):
os.remove(map_path)
yield
if os.path.isfile(map_path):
os.remove(map_path)
@pytest.fixture()
def tmp_git_repo(tmp_path):
"""Create a temp dir with a git repo and HTTPS remote."""
@@ -17,7 +17,7 @@ SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
def test_split_by_headers_cursorrules():
"""split_by_headers correctly splits .cursorrules content on ## headers."""
from import_competing_tools import split_by_headers
from _chunking import split_by_headers
content = """\
# My Cursor Rules
@@ -57,7 +57,7 @@ Write tests for all utility functions.
def test_split_by_headers_copilot():
"""split_by_headers correctly splits copilot-instructions.md on ## headers."""
from import_competing_tools import split_by_headers
from _chunking import split_by_headers
content = """\
## Code Style
@@ -78,7 +78,7 @@ Follow clean architecture principles. Keep business logic in domain layer.
def test_split_by_headers_no_headers():
"""split_by_headers returns entire content as one chunk if no headers found."""
from import_competing_tools import split_by_headers
from _chunking import split_by_headers
content = "This file has no headers at all. Just plain text."
chunks = split_by_headers(content, "## ")
@@ -88,7 +88,7 @@ def test_split_by_headers_no_headers():
def test_split_cline_multiple_md_files(tmp_path):
"""cmd_cline processes multiple .md files from memory-bank/ directory."""
from import_competing_tools import filter_and_truncate
from _chunking import filter_and_truncate
# Create a temporary memory-bank directory with .md files
mb_dir = tmp_path / "memory-bank"
@@ -124,7 +124,7 @@ def test_split_cline_multiple_md_files(tmp_path):
def test_split_by_hr_or_headers_continue():
"""split_by_hr_or_headers correctly splits .continue/rules.md."""
from import_competing_tools import split_by_hr_or_headers
from _chunking import split_by_hr_or_headers
content = """\
## First Section
@@ -150,7 +150,7 @@ Third section without a header (just after HR).
def test_filter_and_truncate_skips_short():
"""filter_and_truncate skips chunks shorter than MIN_CHUNK_CHARS (50)."""
from import_competing_tools import filter_and_truncate
from _chunking import filter_and_truncate
chunks = [
"Short", # < 50 chars, should be filtered
@@ -165,7 +165,7 @@ def test_filter_and_truncate_skips_short():
def test_filter_and_truncate_truncates_long():
"""filter_and_truncate truncates chunks over MAX_CHUNK_CHARS (10000)."""
from import_competing_tools import MAX_CHUNK_CHARS, filter_and_truncate
from _chunking import MAX_CHUNK_CHARS, filter_and_truncate
long_chunk = "X" * (MAX_CHUNK_CHARS + 500)
result = filter_and_truncate([long_chunk])
@@ -422,3 +422,18 @@ def test_cli_full_flag(tmp_path):
data = json.loads(result.stdout)
assert "retention" in data
assert "search" in data
def test_load_full_config_settings_section(tmp_path):
"""Settings section is parsed by load_full_config."""
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text("""\
## Settings
commit_prompts: true
subagent_skip: Explore, Plan, code-reviewer
""")
sys.path.insert(0, SCRIPTS_DIR)
from parse_mem0_config import load_full_config
config = load_full_config(str(tmp_path))
assert config.get("settings", {}).get("commit_prompts") == "true"
assert config.get("settings", {}).get("subagent_skip") == "Explore, Plan, code-reviewer"
-54
View File
@@ -1,54 +0,0 @@
"""Tests for on_pre_commit.py — pre-commit memory capture."""
from __future__ import annotations
import os
from unittest.mock import MagicMock
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
def test_import_succeeds():
"""on_pre_commit module can be imported."""
import on_pre_commit
assert hasattr(on_pre_commit, "main")
def test_no_api_key_exits_zero(monkeypatch):
"""main() exits 0 when no API key is set."""
import on_pre_commit
monkeypatch.delenv("MEM0_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
monkeypatch.setattr("sys.stdin", MagicMock(isatty=lambda: True))
assert on_pre_commit.main() == 0
def test_empty_diff_exits_zero(monkeypatch):
"""main() exits 0 when stdin diff is empty."""
from io import StringIO
import on_pre_commit
monkeypatch.setenv("MEM0_API_KEY", "test-key")
monkeypatch.setattr("sys.stdin", StringIO(""))
assert on_pre_commit.main() == 0
def test_get_staged_summary_runs():
"""get_staged_summary doesn't crash even outside a git repo."""
import on_pre_commit
result = on_pre_commit.get_staged_summary()
assert isinstance(result, str)
def test_get_commit_message_runs():
"""get_commit_message doesn't crash even outside a git repo."""
import on_pre_commit
result = on_pre_commit.get_commit_message()
assert isinstance(result, str)
+64
View File
@@ -105,3 +105,67 @@ def test_resolve_project_id_priority_order(tmp_git_repo, monkeypatch):
# Env var overrides everything
monkeypatch.setenv("MEM0_PROJECT_ID", "from-env")
assert resolve_project_id(str(tmp_git_repo)) == "from-env"
def test_remote_hash_key_format(tmp_git_repo):
"""_remote_hash_key() returns 'remote:<16-char-hex>' for a repo with a remote."""
import re
from _project import _remote_hash_key
key = _remote_hash_key(str(tmp_git_repo))
assert re.fullmatch(r"remote:[0-9a-f]{16}", key), (
f"Expected 'remote:<16-char-hex>', got {key!r}"
)
def test_remote_hash_key_no_git(tmp_no_git):
"""_remote_hash_key() returns empty string when not in a git repo."""
from _project import _remote_hash_key
key = _remote_hash_key(str(tmp_no_git))
assert key == ""
def test_save_project_mapping_writes_remote_key(tmp_git_repo):
"""save_project_mapping() writes both the CWD key and the remote hash key."""
import re
from _project import save_project_mapping
save_project_mapping(str(tmp_git_repo), "my-project")
map_path = os.path.expanduser("~/.mem0/project_map.json")
with open(map_path) as f:
data = json.load(f)
assert data[str(tmp_git_repo)] == "my-project"
remote_keys = [k for k in data if re.fullmatch(r"remote:[0-9a-f]{16}", k)]
assert remote_keys, "Expected at least one remote:<hash> key in project_map.json"
assert data[remote_keys[0]] == "my-project"
def test_resolve_project_id_remote_hash_fallback(tmp_git_repo, tmp_path):
"""Moving the project folder: remote hash key is used as fallback."""
from _project import resolve_project_id, save_project_mapping
# Save mapping for original location
save_project_mapping(str(tmp_git_repo), "stable-project")
# Simulate folder move: resolve using a different CWD path that shares the same remote.
# We use a second tmp_git_repo with the same remote URL to mimic a renamed directory.
import subprocess as _sp
new_repo = tmp_path / "moved_repo"
new_repo.mkdir()
_sp.run(["git", "init"], cwd=new_repo, capture_output=True, check=True)
_sp.run(
["git", "remote", "add", "origin", "https://github.com/mem0ai/mem0.git"],
cwd=new_repo,
capture_output=True,
check=True,
)
# The new CWD is NOT in project_map, but remote hash should match
pid = resolve_project_id(str(new_repo))
assert pid == "stable-project", (
f"Expected 'stable-project' via remote hash fallback, got {pid!r}"
)
+30 -2
View File
@@ -57,7 +57,8 @@ def test_posthog_payload_structure(monkeypatch):
assert payload["event"] == "plugin.session_start"
assert "distinct_id" in payload
assert payload["properties"]["source"] == "plugin"
assert payload["properties"]["plugin_version"] == "0.2.1"
assert isinstance(payload["properties"]["plugin_version"], str)
assert payload["properties"]["plugin_version"] != ""
assert payload["properties"]["memory_count"] == 5
assert payload["properties"]["$process_person_profile"] is False
@@ -66,13 +67,40 @@ def test_posthog_payload_structure(monkeypatch):
assert "test-project" not in raw
def test_system_props_override_caller_props(monkeypatch):
"""H8: system properties must win over caller-supplied properties."""
import telemetry
monkeypatch.setenv("MEM0_RESOLVED_USER_ID", "testuser")
monkeypatch.setenv("MEM0_PROJECT_ID", "test-project")
monkeypatch.delenv("MEM0_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
# Caller tries to override system-controlled properties
caller_props = {
"source": "CALLER_OVERRIDE",
"platform": "CALLER_OVERRIDE",
"plugin_version": "CALLER_OVERRIDE",
"memory_count": 42,
}
payload = telemetry.build_posthog_payload("plugin.test", caller_props)
props = payload["properties"]
# System props must win
assert props["source"] == "plugin"
assert props["platform"] == telemetry.detect_platform()
assert props["plugin_version"] == telemetry.PLUGIN_VERSION
# Caller-only props still present
assert props["memory_count"] == 42
def test_distinct_id_from_api_key(monkeypatch):
import hashlib
import telemetry
monkeypatch.setenv("MEM0_API_KEY", "m0-testkey123")
expected = hashlib.md5(b"m0-testkey123").hexdigest()
expected = hashlib.sha256(b"m0-testkey123").hexdigest()[:32]
assert telemetry._distinct_id() == expected