fix(mem0-plugin): v0.2.2 (#5234)
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,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,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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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 &
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {}),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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!
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"}`
|
||||
|
||||
@@ -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 '/:' '--')
|
||||
|
||||
@@ -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>.
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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>.
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user