feat(mem0-plugin): plugin v0.2.1 — Tiers 1-8 + PostHog telemetry + review fixes (#5215)

This commit is contained in:
Kartik
2026-05-22 19:01:14 +05:30
committed by GitHub
parent 58696e4bd4
commit 0d61af60c2
58 changed files with 5293 additions and 172 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mem0", "name": "mem0",
"version": "0.2.0", "version": "0.2.1",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows using the Mem0 Platform MCP server.", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows using the Mem0 Platform MCP server.",
"author": { "author": {
"name": "Mem0", "name": "Mem0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mem0", "name": "mem0",
"version": "0.2.0", "version": "0.2.1",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Codex workflows using the Mem0 Platform MCP server.", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Codex workflows using the Mem0 Platform MCP server.",
"author": { "author": {
"name": "Mem0", "name": "Mem0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mem0", "name": "mem0",
"version": "0.2.0", "version": "0.2.1",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.",
"author": { "author": {
"name": "Mem0", "name": "Mem0",
+20
View File
@@ -34,6 +34,26 @@
"timeout": 3 "timeout": 3
} }
] ]
},
{
"matcher": "Bash",
"hooks": [
{
"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",
"timeout": 5
}
]
} }
], ],
"Stop": [ "Stop": [
+10
View File
@@ -17,6 +17,16 @@
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_post_tool_use_cursor.sh", "command": "${CURSOR_PLUGIN_ROOT}/scripts/on_post_tool_use_cursor.sh",
"matcher": "mcp__mem0__", "matcher": "mcp__mem0__",
"timeout": 3 "timeout": 3
},
{
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_post_commit.sh",
"matcher": "Bash",
"timeout": 5
},
{
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_bash_output.sh",
"matcher": "Bash",
"timeout": 5
} }
], ],
"preCompact": [ "preCompact": [
+99
View File
@@ -1,6 +1,29 @@
{ {
"hooks": { "hooks": {
"Setup": [
{
"matcher": "init|maintenance",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/ensure_deps.sh",
"statusMessage": "Installing mem0 SDK...",
"timeout": 120
}
]
}
],
"SessionStart": [ "SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "diff -q \"${CLAUDE_PLUGIN_ROOT}/requirements.txt\" \"${CLAUDE_PLUGIN_DATA}/requirements.txt\" >/dev/null 2>&1 || \"${CLAUDE_PLUGIN_ROOT}/scripts/ensure_deps.sh\"",
"statusMessage": "Installing mem0 SDK...",
"timeout": 60
}
]
},
{ {
"matcher": "startup|resume|compact", "matcher": "startup|resume|compact",
"hooks": [ "hooks": [
@@ -21,6 +44,16 @@
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/block_memory_write.sh" "command": "${CLAUDE_PLUGIN_ROOT}/scripts/block_memory_write.sh"
} }
] ]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_git_commit_capture.sh",
"timeout": 5
}
]
} }
], ],
"PostToolUse": [ "PostToolUse": [
@@ -33,6 +66,26 @@
"timeout": 3 "timeout": 3
} }
] ]
},
{
"matcher": "Bash",
"hooks": [
{
"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",
"timeout": 5
}
]
} }
], ],
"PreCompact": [ "PreCompact": [
@@ -79,6 +132,52 @@
} }
] ]
} }
],
"PostToolUseFailure": [
{
"matcher": "mcp__mem0__",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_tool_failure.sh",
"timeout": 5
}
]
}
],
"PostCompact": [
{
"matcher": "manual|auto",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_post_compact.sh",
"timeout": 10
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_session_end.sh",
"timeout": 15
}
]
}
],
"SubagentStop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_subagent_stop.sh",
"timeout": 5
}
]
}
] ]
} }
} }
+1
View File
@@ -0,0 +1 @@
mem0ai
+16 -5
View File
@@ -1,6 +1,10 @@
"""Resolve mem0 user_id. """Resolve mem0 identity: API key and user_id.
Resolution priority: API key resolution (first non-empty wins):
1. MEM0_API_KEY env var (explicit / shell profile)
2. CLAUDE_PLUGIN_OPTION_MEM0_API_KEY (set by Claude Code userConfig)
User ID resolution:
1. MEM0_USER_ID env var (explicit override) 1. MEM0_USER_ID env var (explicit override)
2. $USER, else "default" 2. $USER, else "default"
""" """
@@ -10,6 +14,13 @@ from __future__ import annotations
import os import os
def resolve_api_key() -> str:
key = os.environ.get("MEM0_API_KEY", "").strip()
if key:
return key
return os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", "").strip()
def resolve_user_id() -> str: def resolve_user_id() -> str:
explicit = os.environ.get("MEM0_USER_ID", "").strip() explicit = os.environ.get("MEM0_USER_ID", "").strip()
if explicit: if explicit:
@@ -20,10 +31,10 @@ def resolve_user_id() -> str:
try: try:
from _project import resolve_branch, resolve_project_id, save_project_mapping from _project import resolve_branch, resolve_project_id, save_project_mapping
except ImportError: except ImportError:
def resolve_project_id() -> str: def resolve_project_id(cwd: str | None = None) -> str:
return os.path.basename(os.getcwd()) return os.path.basename(cwd or os.getcwd())
def resolve_branch() -> str: def resolve_branch(cwd: str | None = None) -> str:
return "unknown" return "unknown"
def save_project_mapping(cwd: str, project_id: str) -> None: def save_project_mapping(cwd: str, project_id: str) -> None:
+12 -2
View File
@@ -1,9 +1,19 @@
# Source this file. Sets MEM0_RESOLVED_USER_ID. # Source this file. Sets MEM0_API_KEY and MEM0_RESOLVED_USER_ID.
# #
# Resolution priority: # API key resolution (first non-empty wins):
# 1. MEM0_API_KEY env var (explicit / shell profile)
# 2. CLAUDE_PLUGIN_OPTION_MEM0_API_KEY (set by Claude Code userConfig)
#
# User ID resolution:
# 1. MEM0_USER_ID env var (explicit override) # 1. MEM0_USER_ID env var (explicit override)
# 2. $USER, else "default" # 2. $USER, else "default"
# Resolve API key from userConfig fallback
if [ -z "${MEM0_API_KEY:-}" ] && [ -n "${CLAUDE_PLUGIN_OPTION_MEM0_API_KEY:-}" ]; then
MEM0_API_KEY="$CLAUDE_PLUGIN_OPTION_MEM0_API_KEY"
export MEM0_API_KEY
fi
_mem0_resolve_identity() { _mem0_resolve_identity() {
if [ -n "${MEM0_USER_ID:-}" ]; then if [ -n "${MEM0_USER_ID:-}" ]; then
printf '%s' "$MEM0_USER_ID" printf '%s' "$MEM0_USER_ID"
+17 -13
View File
@@ -21,8 +21,8 @@ import urllib.error
import urllib.request import urllib.request
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _identity import resolve_user_id from _identity import resolve_api_key, resolve_user_id
from _project import resolve_project_id from _project import resolve_branch, resolve_project_id
log = logging.getLogger("mem0-auto-import") log = logging.getLogger("mem0-auto-import")
log.setLevel(logging.DEBUG) log.setLevel(logging.DEBUG)
@@ -77,8 +77,15 @@ def save_hashes(hashes: dict[str, str]) -> None:
log.warning("Could not save hash store: %s", e) log.warning("Could not save hash store: %s", e)
def post_memory(api_key: str, content: str, user_id: str, filename: str, project_id: str) -> bool: def post_memory(api_key: str, content: str, user_id: str, filename: str, project_id: str, branch: str = "") -> bool:
"""POST a project profile memory to the Mem0 REST API.""" """POST a project profile memory to the Mem0 REST API."""
metadata = {
"type": "project_profile",
"file": filename,
"source": "auto-import",
}
if branch:
metadata["branch"] = branch
body = { body = {
"messages": [ "messages": [
{ {
@@ -87,18 +94,14 @@ def post_memory(api_key: str, content: str, user_id: str, filename: str, project
} }
], ],
"user_id": user_id, "user_id": user_id,
"metadata": { "app_id": project_id,
"type": "project_profile", "metadata": metadata,
"file": filename,
"project_id": project_id,
"source": "auto-import",
},
"infer": False, "infer": False,
} }
data = json.dumps(body).encode("utf-8") data = json.dumps(body).encode("utf-8")
req = urllib.request.Request( req = urllib.request.Request(
f"{API_URL}/v1/memories/", f"{API_URL}/v3/memories/add/",
data=data, data=data,
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -120,7 +123,7 @@ def post_memory(api_key: str, content: str, user_id: str, filename: str, project
def main() -> None: def main() -> None:
api_key = os.environ.get("MEM0_API_KEY", "") api_key = resolve_api_key()
if not api_key: if not api_key:
log.debug("MEM0_API_KEY not set, skipping auto-import") log.debug("MEM0_API_KEY not set, skipping auto-import")
return return
@@ -128,8 +131,9 @@ def main() -> None:
cwd = os.environ.get("MEM0_CWD", "").strip() or os.getcwd() cwd = os.environ.get("MEM0_CWD", "").strip() or os.getcwd()
user_id = resolve_user_id() user_id = resolve_user_id()
project_id = resolve_project_id(cwd) project_id = resolve_project_id(cwd)
branch = resolve_branch(cwd)
log.debug("Auto-import started: cwd=%s project=%s user=%s", cwd, project_id, user_id) log.debug("Auto-import started: cwd=%s project=%s user=%s branch=%s", cwd, project_id, user_id, branch)
hashes = load_hashes() hashes = load_hashes()
updated = False updated = False
@@ -169,7 +173,7 @@ def main() -> None:
log.debug("Cannot read %s: %s", filename, e) log.debug("Cannot read %s: %s", filename, e)
continue continue
if post_memory(api_key, content, user_id, filename, project_id): if post_memory(api_key, content, user_id, filename, project_id, branch):
hashes[hash_key] = current_hash hashes[hash_key] = current_hash
updated = True updated = True
# on API failure we don't update the hash — retry next session # on API failure we don't update the hash — retry next session
+15 -12
View File
@@ -25,7 +25,7 @@ import urllib.request
from datetime import date, timedelta from datetime import date, timedelta
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _identity import resolve_user_id from _identity import resolve_api_key, resolve_user_id
from _project import resolve_branch, resolve_project_id from _project import resolve_branch, resolve_project_id
log = logging.getLogger("mem0-compact-summary") log = logging.getLogger("mem0-compact-summary")
@@ -97,23 +97,25 @@ def find_compact_summary(lines: list[str]) -> str:
def store_summary(api_key: str, summary: str, user_id: str, session_id: str, project_id: str = "", branch: str = "") -> bool: def store_summary(api_key: str, summary: str, user_id: str, session_id: str, project_id: str = "", branch: str = "") -> bool:
expires = (date.today() + timedelta(days=COMPACT_SUMMARY_EXPIRY_DAYS)).isoformat() expires = (date.today() + timedelta(days=COMPACT_SUMMARY_EXPIRY_DAYS)).isoformat()
metadata = {
"type": "compact_summary",
"source": "session-start-compact",
"session_id": session_id,
}
if branch:
metadata["branch"] = branch
body = { body = {
"messages": [{"role": "user", "content": summary}], "messages": [{"role": "user", "content": summary}],
"user_id": user_id, "user_id": user_id,
"metadata": { "app_id": project_id,
"type": "compact_summary", "metadata": metadata,
"source": "session-start-compact",
"session_id": session_id,
"project_id": project_id,
"branch": branch,
},
"infer": False, "infer": False,
"expiration_date": expires, "expiration_date": expires,
} }
data = json.dumps(body).encode("utf-8") data = json.dumps(body).encode("utf-8")
req = urllib.request.Request( req = urllib.request.Request(
f"{API_URL}/v1/memories/", f"{API_URL}/v3/memories/add/",
data=data, data=data,
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -134,7 +136,7 @@ def store_summary(api_key: str, summary: str, user_id: str, session_id: str, pro
def main(): def main():
api_key = os.environ.get("MEM0_API_KEY", "") api_key = resolve_api_key()
if not api_key: if not api_key:
log.debug("MEM0_API_KEY not set, skipping capture") log.debug("MEM0_API_KEY not set, skipping capture")
return return
@@ -151,9 +153,10 @@ def main():
return return
session_id = hook_input.get("session_id", "") session_id = hook_input.get("session_id", "")
cwd = hook_input.get("cwd") or None
user_id = resolve_user_id() user_id = resolve_user_id()
project_id = resolve_project_id() project_id = resolve_project_id(cwd)
branch = resolve_branch() branch = resolve_branch(cwd)
lines = tail_lines(transcript_path, MAX_TAIL_LINES) lines = tail_lines(transcript_path, MAX_TAIL_LINES)
if not lines: if not lines:
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Install mem0ai SDK into a persistent venv inside CLAUDE_PLUGIN_DATA.
# Runs on SessionStart; skips if requirements.txt hasn't changed.
set -euo pipefail
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
DATA_DIR="${CLAUDE_PLUGIN_DATA:-${HOME}/.mem0/plugin-data}"
VENV_DIR="${DATA_DIR}/venv"
REQ_SRC="${PLUGIN_ROOT}/requirements.txt"
REQ_STAMP="${DATA_DIR}/requirements.txt"
mkdir -p "${DATA_DIR}"
needs_install=false
if [ ! -f "${VENV_DIR}/bin/python3" ]; then
needs_install=true
elif ! diff -q "${REQ_SRC}" "${REQ_STAMP}" >/dev/null 2>&1; then
needs_install=true
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}"
else
rm -f "${REQ_STAMP}"
echo "mem0 plugin: failed to install Python dependencies" >&2
exit 0
fi
fi
@@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""Import memories from competing AI tool configuration files into mem0.
Sub-commands (via sys.argv[1]):
cursorrules [--path .cursorrules]
copilot [--path .github/copilot-instructions.md]
cline [--path memory-bank/]
continue [--path .continue/rules.md]
Each sub-command reads configuration files from competing tools,
splits them into chunks, and POSTs each chunk to the mem0 API as a
project_profile memory.
Output: progress messages to stdout, errors to stderr
Exit: 0 always
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
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
# ---------------------------------------------------------------------------
def post_memory(api_key: str, content: str, user_id: str, project_id: str, branch: str, source: str) -> bool:
"""POST a single memory chunk to the mem0 API."""
metadata: dict = {
"type": "project_profile",
"source": source,
}
if branch:
metadata["branch"] = branch
body = {
"messages": [{"role": "user", "content": content}],
"user_id": user_id,
"app_id": project_id,
"metadata": metadata,
"infer": False,
}
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
f"{API_URL}/v3/memories/add/",
data=data,
headers={
"Content-Type": "application/json",
"Authorization": f"Token {api_key}",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=20) as resp:
return resp.status in (200, 201)
except urllib.error.URLError as e:
print(f" [warn] API call failed: {e}", file=sys.stderr)
return False
def import_chunks(chunks: list[str], api_key: str, user_id: str, project_id: str, branch: str, source: str) -> int:
"""Import a list of content chunks; return number of successful imports."""
success = 0
for chunk in chunks:
if post_memory(api_key, chunk, user_id, project_id, branch, source):
success += 1
return success
# ---------------------------------------------------------------------------
# Sub-command implementations
# ---------------------------------------------------------------------------
def _parse_path_arg(args: list[str], flag: str, default: str) -> str:
"""Extract --path <value> from args list, falling back to default."""
for i, arg in enumerate(args):
if arg == flag and i + 1 < len(args):
return args[i + 1]
if arg.startswith(f"{flag}="):
return arg[len(flag) + 1:]
return default
def cmd_cursorrules(args: list[str]) -> None:
path = _parse_path_arg(args, "--path", ".cursorrules")
source = "cursor-import"
api_key = resolve_api_key()
user_id = resolve_user_id()
project_id = resolve_project_id()
branch = resolve_branch()
if not api_key:
print("Error: MEM0_API_KEY not set", file=sys.stderr)
return
if not os.path.isfile(path):
print(f"File not found: {path}", file=sys.stderr)
return
with open(path, encoding="utf-8", errors="replace") as f:
content = f.read()
raw_chunks = split_by_headers(content, "## ")
# Fall back to treating the whole file as one chunk if no headers found
if not raw_chunks:
raw_chunks = [content.strip()] if content.strip() else []
chunks = filter_and_truncate(raw_chunks)
n = import_chunks(chunks, api_key, user_id, project_id, branch, source)
print(f"Imported {n} memories from {source} ({path})")
def cmd_copilot(args: list[str]) -> None:
path = _parse_path_arg(args, "--path", ".github/copilot-instructions.md")
source = "copilot-import"
api_key = resolve_api_key()
user_id = resolve_user_id()
project_id = resolve_project_id()
branch = resolve_branch()
if not api_key:
print("Error: MEM0_API_KEY not set", file=sys.stderr)
return
if not os.path.isfile(path):
print(f"File not found: {path}", file=sys.stderr)
return
with open(path, encoding="utf-8", errors="replace") as f:
content = f.read()
raw_chunks = split_by_headers(content, "## ")
if not raw_chunks:
raw_chunks = [content.strip()] if content.strip() else []
chunks = filter_and_truncate(raw_chunks)
n = import_chunks(chunks, api_key, user_id, project_id, branch, source)
print(f"Imported {n} memories from {source} ({path})")
def cmd_cline(args: list[str]) -> None:
dir_path = _parse_path_arg(args, "--path", "memory-bank/")
source = "cline-import"
api_key = resolve_api_key()
user_id = resolve_user_id()
project_id = resolve_project_id()
branch = resolve_branch()
if not api_key:
print("Error: MEM0_API_KEY not set", file=sys.stderr)
return
if not os.path.isdir(dir_path):
print(f"Directory not found: {dir_path}", file=sys.stderr)
return
md_files = sorted(
f for f in os.listdir(dir_path) if f.endswith(".md")
)
if not md_files:
print(f"No .md files found in {dir_path}", file=sys.stderr)
return
total = 0
for filename in md_files:
filepath = os.path.join(dir_path, filename)
with open(filepath, encoding="utf-8", errors="replace") as f:
content = f.read().strip()
if not content:
continue
chunks = filter_and_truncate([content])
n = import_chunks(chunks, api_key, user_id, project_id, branch, source)
total += n
print(f"Imported {total} memories from {source} ({dir_path})")
def cmd_continue(args: list[str]) -> None:
path = _parse_path_arg(args, "--path", ".continue/rules.md")
source = "continue-import"
api_key = resolve_api_key()
user_id = resolve_user_id()
project_id = resolve_project_id()
branch = resolve_branch()
if not api_key:
print("Error: MEM0_API_KEY not set", file=sys.stderr)
return
if not os.path.isfile(path):
print(f"File not found: {path}", file=sys.stderr)
return
with open(path, encoding="utf-8", errors="replace") as f:
content = f.read()
raw_chunks = split_by_hr_or_headers(content)
if not raw_chunks:
raw_chunks = [content.strip()] if content.strip() else []
chunks = filter_and_truncate(raw_chunks)
n = import_chunks(chunks, api_key, user_id, project_id, branch, source)
print(f"Imported {n} memories from {source} ({path})")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
COMMANDS = {
"cursorrules": cmd_cursorrules,
"copilot": cmd_copilot,
"cline": cmd_cline,
"continue": cmd_continue,
}
def main() -> None:
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
available = ", ".join(COMMANDS.keys())
print("Usage: import_competing_tools.py <subcommand> [--path <path>]", file=sys.stderr)
print(f"Subcommands: {available}", file=sys.stderr)
sys.exit(0)
subcommand = sys.argv[1]
remaining_args = sys.argv[2:]
COMMANDS[subcommand](remaining_args)
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"Unexpected error: {e}", file=sys.stderr)
sys.exit(0)
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Hook: PostToolUse (matcher: Bash)
#
# Scans bash command output for stack traces and error patterns.
# When found, injects a search rubric telling the agent to check mem0
# for prior occurrences of the same error.
#
# This complements on_user_prompt.sh (which catches errors in the user's
# typed message). This hook catches errors in COMMAND OUTPUT — e.g.,
# when `npm test` or `python script.py` fails with a traceback.
#
# Input: JSON on stdin with tool_name, tool_input, tool_output
# Output: Context injected into Claude's next response (exit 0)
set -uo pipefail
INPUT=$(cat)
TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_output // ""' 2>/dev/null || echo "")
# Skip short output (< 50 chars unlikely to contain a real stack trace)
if [ ${#TOOL_RESULT} -lt 50 ]; then
exit 0
fi
# Skip if this is a git commit (handled by on_post_commit.sh)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "")
case "$COMMAND" in
*"git commit"*|*"git merge"*|*"git rebase"*)
exit 0
;;
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
HAS_ERROR="true"
fi
if [ -z "$HAS_ERROR" ]; then
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
# Extract error class/message (first matching line)
ERROR_LINE=$(echo "$TOOL_RESULT" | grep -iE '(Error:|Exception:|panic:|FAIL:|fatal:)' | head -1 | sed 's/^[[:space:]]*//' | cut -c1-120)
# Extract file paths from stack trace frames
TRACE_FILES=$(echo "$TOOL_RESULT" | grep -oE '([a-zA-Z0-9_./-]+\.(py|ts|tsx|js|jsx|rs|go|rb|java|sh))(:[0-9]+)?' | head -5 | sort -u)
# Build file list for display
FILE_DISPLAY=""
if [ -n "$TRACE_FILES" ]; then
FILE_DISPLAY=$(echo "$TRACE_FILES" | sed 's/^/ - /')
fi
USER_ID="$MEM0_RESOLVED_USER_ID"
cat <<EOF
## Error detected in command output
\`$COMMAND\` produced an error:
> $ERROR_LINE
EOF
if [ -n "$FILE_DISPLAY" ]; then
cat <<EOF
**Files in stack trace:**
$FILE_DISPLAY
EOF
fi
cat <<EOF
Search mem0 for prior occurrences — this error may have been seen before:
- \`search_memories(query="$(echo "$ERROR_LINE" | cut -c1-60)", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "anti_pattern"}}]})\`
- \`search_memories(query="$(echo "$ERROR_LINE" | cut -c1-60)", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "bug_fix"}}]})\`
EOF
if [ -n "$TRACE_FILES" ]; then
FIRST_FILE=$(echo "$TRACE_FILES" | head -1 | sed 's/:[0-9]*//')
cat <<EOF
- \`search_memories(query="$FIRST_FILE", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}]})\`
EOF
fi
cat <<EOF
If mem0 returns relevant context, use it to debug faster.
If you solve this, store the fix as an \`anti_pattern\` or \`bug_fix\` memory for next time.
EOF
# Telemetry
python3 "$SCRIPT_DIR/telemetry.py" bash_error --error_detected 2>/dev/null &
exit 0
+92
View File
@@ -0,0 +1,92 @@
#!/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
#
# Input: JSON on stdin with tool_name, tool_input
# Output: JSON with additionalContext (relevant memories for the commit)
set -uo pipefail
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "")
if [ -z "$COMMAND" ]; then
exit 0
fi
case "$COMMAND" in
*"git commit"*|*"git merge"*|*"git rebase"*)
;;
*)
exit 0
;;
esac
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
if [ ! -f "$SCRIPT_DIR/on_pre_commit.py" ]; then
exit 0
fi
API_KEY="${MEM0_API_KEY:-${CLAUDE_PLUGIN_OPTION_MEM0_API_KEY:-}}"
if [ -z "$API_KEY" ]; then
exit 0
fi
# Background: capture staged changes as memory
git diff --cached --stat 2>/dev/null | python3 "$SCRIPT_DIR/on_pre_commit.py" &
# Foreground: search for relevant memories about changed files
CHANGED_FILES=$(git diff --cached --name-only 2>/dev/null | head -10 | tr '\n' ', ' | sed 's/,$//')
if [ -z "$CHANGED_FILES" ]; then
exit 0
fi
USER_ID="${MEM0_RESOLVED_USER_ID:-$USER}"
PROJECT_ID="${MEM0_PROJECT_ID:-unknown}"
CONTEXT=$(python3 -c "
import json, urllib.request, os
api_key = os.environ.get('MEM0_API_KEY', os.environ.get('CLAUDE_PLUGIN_OPTION_MEM0_API_KEY', ''))
user_id = '$USER_ID'
app_id = '$PROJECT_ID'
files = '$CHANGED_FILES'
first_file = files.split(',')[0].strip()
body = json.dumps({
'query': f'changes to {files}',
'filters': {'AND': [{'user_id': user_id}, {'app_id': app_id}]},
'top_k': 3,
}).encode()
req = urllib.request.Request(
'https://api.mem0.ai/v3/memories/search/',
data=body,
headers={'Authorization': f'Token {api_key}', 'Content-Type': 'application/json'},
method='POST',
)
try:
with urllib.request.urlopen(req, timeout=5) as r:
results = json.loads(r.read())
memories = results if isinstance(results, list) else results.get('results', [])
if memories:
lines = ['## Pre-Commit Memory Check', '', 'Relevant memories for files being committed (' + files + '):', '']
for m in memories[:3]:
mid = m.get('id', '?')[:8]
text = m.get('memory', '')[:200]
cat = (m.get('metadata') or {}).get('type', 'unknown')
lines.append(f'- [{cat}] {text} [mem0:{mid}]')
lines.append('')
lines.append('Consider: does this commit introduce a learning worth saving? If so, suggest storing it after the commit completes.')
print('\\n'.join(lines))
except Exception:
pass
" 2>/dev/null || true)
if [ -n "$CONTEXT" ]; then
jq -nc --arg ctx "$CONTEXT" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$ctx}}'
fi
exit 0
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Hook: PostToolUse (matcher: Bash)
#
# Fires AFTER a Bash tool call completes. When a git commit/merge/rebase
# just succeeded, surfaces 1-3 relevant memories from the changed files
# and prompts Claude to ask the user if this change should be stored as
# a learning.
#
# Input: JSON on stdin with tool_name, tool_input, tool_output
# Output: Context injected into Claude's next response (exit 0)
#
# This implements Spec #28 — interactive pre-commit memory check.
set -uo pipefail
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "")
if [ -z "$COMMAND" ]; then
exit 0
fi
# Only trigger on git commit/merge/rebase commands
case "$COMMAND" in
*"git commit"*|*"git merge"*|*"git rebase"*)
;;
*)
exit 0
;;
esac
# Check if the command actually succeeded (look for commit hash in output)
TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_output // ""' 2>/dev/null || echo "")
if echo "$TOOL_RESULT" | grep -qiE '(error|fatal|abort|nothing to commit|no changes)'; then
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
# No API key — skip interactive prompt
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
# Extract changed files from commit
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD 2>/dev/null | head -10 || echo "")
if [ -z "$CHANGED_FILES" ]; then
exit 0
fi
# Get the commit message
COMMIT_MSG=$(git log -1 --format='%s' HEAD 2>/dev/null || echo "")
# Build file list for display
FILE_LIST=$(echo "$CHANGED_FILES" | sed 's/^/ - /' | head -5)
FILE_COUNT=$(echo "$CHANGED_FILES" | wc -l | tr -d ' ')
cat <<EOF
## Commit captured — save as memory?
**Commit:** $COMMIT_MSG
**Files changed ($FILE_COUNT):**
$FILE_LIST
This commit may contain learnings worth preserving. Ask the user:
> **Save this change as a memory?** I can store it as one of:
> - \`decision\` — if an architectural or design choice was made
> - \`task_learning\` — if a strategy or approach worked well
> - \`anti_pattern\` — if this fixes a bug or avoids a known pitfall
> - \`convention\` — if this establishes a new pattern
>
> Type the category, or **skip** to move on.
If the user provides a category (or says yes/sure/go ahead):
1. Summarize the commit: what changed and why (from the diff + commit message)
2. Call \`add_memory\` with:
- \`messages=[{"role": "user", "content": "<your summary>"}]\`
- \`user_id="$MEM0_RESOLVED_USER_ID"\`
- \`app_id="$MEM0_PROJECT_ID"\`
- \`metadata={"type": "<chosen_category>", "branch": "$MEM0_BRANCH", "confidence": 0.8, "files": [<changed files>], "source": "post-commit"}\`
- \`infer=False\`
If the user says skip/no/nothing: proceed normally. Do NOT ask again for the same commit.
EOF
# Telemetry
python3 "$SCRIPT_DIR/telemetry.py" post_commit --files_count="$FILE_COUNT" 2>/dev/null &
exit 0
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Hook: PostCompact (matcher: manual|auto)
#
# Fires after context compaction completes. Injects a recovery prompt
# telling the agent to reload context from mem0.
#
# Input: JSON on stdin with trigger, messages_retained, messages_removed
# Output: Context injected into Claude's post-compaction context (exit 0)
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
INPUT=$(cat)
TRIGGER=$(echo "$INPUT" | jq -r '.trigger // "auto"' 2>/dev/null || echo "auto")
RETAINED=$(echo "$INPUT" | jq -r '.messages_retained // "?"' 2>/dev/null || echo "?")
REMOVED=$(echo "$INPUT" | jq -r '.messages_removed // "?"' 2>/dev/null || echo "?")
# Telemetry (background)
python3 "$SCRIPT_DIR/telemetry.py" post_compact --trigger="$TRIGGER" --retained="$RETAINED" --removed="$REMOVED" 2>/dev/null &
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
USER_ID="${MEM0_RESOLVED_USER_ID:-$USER}"
PROJECT_ID="${MEM0_PROJECT_ID:-unknown}"
cat <<EOF
## Mem0 Post-Compaction Recovery
Compaction complete ($TRIGGER). $REMOVED messages removed, $RETAINED retained.
You lost most conversation history. Recover context NOW:
1. \`search_memories(query="session state current task", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$PROJECT_ID"}, {"metadata": {"type": "session_state"}}]})\`
2. \`search_memories(query="recent decisions and learnings", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$PROJECT_ID"}, {"metadata": {"type": "decision"}}]})\`
3. \`search_memories(query="compact summary", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$PROJECT_ID"}, {"metadata": {"type": "compact_summary"}}]})\`
Run all 3 in parallel. Use results to resume work without asking user to repeat context.
EOF
exit 0
+9 -1
View File
@@ -5,7 +5,7 @@
# mcp__mem0__add_memory → record an add # mcp__mem0__add_memory → record an add
# mcp__mem0__search_memories → record a search # mcp__mem0__search_memories → record a search
# #
# Input: JSON on stdin with tool_name, tool_input, tool_result # Input: JSON on stdin with tool_name, tool_input, tool_output
# Output: none (exit 0, non-blocking) # Output: none (exit 0, non-blocking)
set -uo pipefail set -uo pipefail
@@ -19,9 +19,17 @@ case "$TOOL_NAME" in
mcp__mem0__add_memory) mcp__mem0__add_memory)
CATEGORY=$(echo "$INPUT" | jq -r '.tool_input.metadata.type // .tool_input.metadata.category // ""' 2>/dev/null || echo "") CATEGORY=$(echo "$INPUT" | jq -r '.tool_input.metadata.type // .tool_input.metadata.category // ""' 2>/dev/null || echo "")
python3 "$SCRIPT_DIR/session_stats.py" add "$CATEGORY" 2>/dev/null || true python3 "$SCRIPT_DIR/session_stats.py" add "$CATEGORY" 2>/dev/null || true
python3 "$SCRIPT_DIR/telemetry.py" tool_use --tool=add_memory 2>/dev/null &
;; ;;
mcp__mem0__search_memories|mcp__mem0__get_memories) mcp__mem0__search_memories|mcp__mem0__get_memories)
python3 "$SCRIPT_DIR/session_stats.py" search 2>/dev/null || true python3 "$SCRIPT_DIR/session_stats.py" search 2>/dev/null || true
python3 "$SCRIPT_DIR/telemetry.py" tool_use --tool=search_memories 2>/dev/null &
;;
mcp__mem0__delete_memory)
python3 "$SCRIPT_DIR/telemetry.py" tool_use --tool=delete_memory 2>/dev/null &
;;
mcp__mem0__update_memory)
python3 "$SCRIPT_DIR/telemetry.py" tool_use --tool=update_memory 2>/dev/null &
;; ;;
esac esac
+135
View File
@@ -0,0 +1,135 @@
#!/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())
+16 -12
View File
@@ -23,7 +23,7 @@ import urllib.request
from datetime import date, timedelta from datetime import date, timedelta
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _identity import resolve_user_id from _identity import resolve_api_key, resolve_user_id
from _project import resolve_branch, resolve_project_id from _project import resolve_branch, resolve_project_id
log = logging.getLogger("mem0-capture") log = logging.getLogger("mem0-capture")
@@ -171,24 +171,27 @@ def build_content(state: dict, source: str) -> str:
def store_memory(api_key: str, content: str, user_id: str, source: str, session_id: str = "", project_id: str = "", branch: str = "") -> bool: def store_memory(api_key: str, content: str, user_id: str, source: str, session_id: str = "", project_id: str = "", branch: str = "") -> bool:
"""Store session state as a memory via the Mem0 REST API.""" """Store session state as a memory via the Mem0 REST API."""
expires = (date.today() + timedelta(days=SESSION_STATE_EXPIRY_DAYS)).isoformat() expires = (date.today() + timedelta(days=SESSION_STATE_EXPIRY_DAYS)).isoformat()
metadata = {
"type": "session_state",
"source": source,
"session_id": session_id,
}
if branch:
metadata["branch"] = branch
body = { body = {
"messages": [ "messages": [
{"role": "user", "content": content} {"role": "user", "content": content}
], ],
"user_id": user_id, "user_id": user_id,
"metadata": { "app_id": project_id,
"type": "session_state", "metadata": metadata,
"source": source,
"session_id": session_id,
"project_id": project_id,
"branch": branch,
},
"expiration_date": expires, "expiration_date": expires,
"infer": False,
} }
data = json.dumps(body).encode("utf-8") data = json.dumps(body).encode("utf-8")
req = urllib.request.Request( req = urllib.request.Request(
f"{API_URL}/v1/memories/", f"{API_URL}/v3/memories/add/",
data=data, data=data,
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -215,7 +218,7 @@ def main():
if arg.startswith("--source="): if arg.startswith("--source="):
source = arg.split("=", 1)[1] source = arg.split("=", 1)[1]
api_key = os.environ.get("MEM0_API_KEY", "") api_key = resolve_api_key()
if not api_key: if not api_key:
log.debug("MEM0_API_KEY not set, skipping capture") log.debug("MEM0_API_KEY not set, skipping capture")
return return
@@ -232,9 +235,10 @@ def main():
return return
session_id = hook_input.get("session_id", "") session_id = hook_input.get("session_id", "")
cwd = hook_input.get("cwd") or None
user_id = resolve_user_id() user_id = resolve_user_id()
project_id = resolve_project_id() project_id = resolve_project_id(cwd)
branch = resolve_branch() branch = resolve_branch(cwd)
lines = tail_lines(transcript_path, MAX_TAIL_LINES) lines = tail_lines(transcript_path, MAX_TAIL_LINES)
if not lines: if not lines:
+10 -1
View File
@@ -15,6 +15,14 @@ if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INPUT=$(cat)
# Fire REST API transcript backup in background (safety net if agent
# can't complete the add_memory call before compaction finishes)
echo "$INPUT" | python3 "$SCRIPT_DIR/on_pre_compact.py" --source=pre-compaction 2>/dev/null &
python3 "$SCRIPT_DIR/telemetry.py" pre_compact 2>/dev/null &
cat <<'EOF' cat <<'EOF'
## CRITICAL: Pre-Compaction Session Summary ## CRITICAL: Pre-Compaction Session Summary
@@ -55,7 +63,8 @@ Tool call shape:
add_memory( add_memory(
messages=[{"role":"user","content":"<the summary above>"}], messages=[{"role":"user","content":"<the summary above>"}],
user_id="<the active user_id from the SessionStart bootstrap>", user_id="<the active user_id from the SessionStart bootstrap>",
metadata={"type":"session_state","source":"pre-compaction","project_id":"<the active project_id>","branch":"<the active branch>"}, app_id="<the active project_id / app_id from SessionStart>",
metadata={"type":"session_state","source":"pre-compaction","branch":"<active branch>"},
infer=False, infer=False,
) )
``` ```
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Hook: SessionEnd
#
# Fires when session actually terminates (after Stop).
# Last-chance capture: if on_stop.sh background REST call didn't complete,
# this fires a synchronous capture attempt.
#
# Input: JSON on stdin with session_id, transcript_path, cwd, reason
# Output: ignored (session is ending)
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INPUT=$(cat)
REASON=$(echo "$INPUT" | jq -r '.reason // "other"' 2>/dev/null || echo "other")
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "")
# Telemetry (fire-and-forget — session dying, best-effort)
python3 "$SCRIPT_DIR/telemetry.py" session_end --reason="$REASON" 2>/dev/null &
# Check if on_stop.sh already captured this session (avoid duplicates)
CAPTURE_MARKER="$HOME/.mem0/.captured_${SESSION_ID}"
if [ -n "$SESSION_ID" ] && [ -f "$CAPTURE_MARKER" ]; then
exit 0
fi
# Last-chance transcript capture (synchronous — session is ending anyway)
if [ -n "${MEM0_API_KEY:-}" ]; then
echo "$INPUT" | python3 "$SCRIPT_DIR/on_pre_compact.py" --source=session-end 2>/dev/null || true
# Mark as captured to prevent duplicate if Stop also ran
if [ -n "$SESSION_ID" ]; then
mkdir -p "$HOME/.mem0" 2>/dev/null || true
touch "$CAPTURE_MARKER" 2>/dev/null || true
# Clean up old markers (> 7 days)
find "$HOME/.mem0" -name ".captured_*" -mtime +7 -delete 2>/dev/null || true
fi
fi
exit 0
+70 -23
View File
@@ -15,12 +15,6 @@ if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi fi
# Skip the bootstrap entirely if no API key is configured -- the agent
# would otherwise be told to call mem0 MCP tools that will all fail.
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=_identity.sh # shellcheck source=_identity.sh
. "$SCRIPT_DIR/_identity.sh" . "$SCRIPT_DIR/_identity.sh"
@@ -31,6 +25,22 @@ python3 "$SCRIPT_DIR/session_stats.py" init 2>/dev/null || true
INPUT=$(cat) INPUT=$(cat)
SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup") SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup")
# Skip bootstrap if no API key — still show banner but with "no key" status.
if [ -z "${MEM0_API_KEY:-}" ]; then
echo "## Mem0 Inactive"
echo ""
echo "\`user=${MEM0_RESOLVED_USER_ID:-$USER} | project=${MEM0_PROJECT_ID:-unknown} | branch=${MEM0_BRANCH:-unknown} | api_key=NOT_SET\`"
echo ""
echo "IMPORTANT: In your FIRST response to the user, you MUST display the status banner exactly as shown below (copy-paste it as your opening line before any other output):"
echo ""
echo '```'
echo "Mem0 Inactive | user=${MEM0_RESOLVED_USER_ID:-$USER} | project=${MEM0_PROJECT_ID:-unknown} | branch=${MEM0_BRANCH:-unknown} | api_key=NOT_SET"
echo '```'
echo ""
echo "Set MEM0_API_KEY to enable persistent memory. Get a key at https://app.mem0.ai/dashboard/api-keys"
exit 0
fi
# Fetch project-scoped memory count (best-effort, don't block on failure, 5s timeout) # Fetch project-scoped memory count (best-effort, don't block on failure, 5s timeout)
MEM0_COUNT="?" MEM0_COUNT="?"
if command -v python3 >/dev/null 2>&1; then if command -v python3 >/dev/null 2>&1; then
@@ -38,25 +48,21 @@ if command -v python3 >/dev/null 2>&1; then
import json, os, urllib.request, urllib.error import json, os, urllib.request, urllib.error
api_key = os.environ.get('MEM0_API_KEY', '') api_key = os.environ.get('MEM0_API_KEY', '')
user_id = os.environ.get('MEM0_RESOLVED_USER_ID', 'default') user_id = os.environ.get('MEM0_RESOLVED_USER_ID', 'default')
project_id = os.environ.get('MEM0_PROJECT_ID', '') app_id = os.environ.get('MEM0_PROJECT_ID', '')
body = json.dumps({ body = json.dumps({'filters': {'user_id': user_id, 'app_id': app_id}}).encode()
'query': 'project context',
'user_id': user_id,
'filters': {'AND': [{'user_id': user_id}, {'metadata': {'project_id': project_id}}]},
'limit': 100,
}).encode()
req = urllib.request.Request( req = urllib.request.Request(
'https://api.mem0.ai/v2/memories/search/', 'https://api.mem0.ai/v3/memories/?page=1&page_size=1',
data=body,
headers={'Authorization': f'Token {api_key}', 'Content-Type': 'application/json'}, headers={'Authorization': f'Token {api_key}', 'Content-Type': 'application/json'},
data=body,
method='POST', method='POST',
) )
try: try:
with urllib.request.urlopen(req, timeout=5) as r: with urllib.request.urlopen(req, timeout=5) as r:
results = json.loads(r.read()) data = json.loads(r.read())
if isinstance(results, list): if isinstance(data, dict) and 'count' in data:
n = len(results) print(data['count'])
print(f'{n}+' if n >= 100 else str(n)) elif isinstance(data, list):
print(len(data))
else: else:
print('?') print('?')
except Exception: except Exception:
@@ -72,14 +78,51 @@ echo "## Mem0 Active"
echo "" echo ""
echo "\`user=$MEM0_RESOLVED_USER_ID | project=$MEM0_PROJECT_ID | branch=$MEM0_BRANCH | memories=$MEM0_COUNT\`" echo "\`user=$MEM0_RESOLVED_USER_ID | project=$MEM0_PROJECT_ID | branch=$MEM0_BRANCH | memories=$MEM0_COUNT\`"
echo "" echo ""
echo "Always include \`user_id\` + \`metadata.project_id\` in every \`search_memories\` filter and \`add_memory\` call:" echo "IMPORTANT: In your FIRST response to the user, you MUST display the identity banner exactly as shown below (copy-paste it as your opening line before any other output):"
echo ""
echo '```'
echo "Mem0 Active | user=$MEM0_RESOLVED_USER_ID | project=$MEM0_PROJECT_ID | branch=$MEM0_BRANCH | memories=$MEM0_COUNT"
echo '```'
echo ""
echo "Always include \`user_id\` + \`app_id\` in every \`search_memories\` filter and \`add_memory\` call:"
echo "- user_id: \`$MEM0_RESOLVED_USER_ID\`" echo "- user_id: \`$MEM0_RESOLVED_USER_ID\`"
echo "- project_id: \`$MEM0_PROJECT_ID\`" echo "- app_id: \`$MEM0_PROJECT_ID\` (project scope — passed as top-level \`app_id\`, NOT in metadata)"
echo "- branch: \`$MEM0_BRANCH\` (include in session-state / compact-summary metadata)"
echo "" echo ""
# Load mem0.md project config if present (best-effort, non-blocking)
MEM0_PROJECT_CONFIG=""
MEM0_CWD_RESOLVED=$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")
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
echo "### Project Config (mem0.md)"
echo "\`\`\`json"
echo "$MEM0_PROJECT_CONFIG"
echo "\`\`\`"
echo ""
fi
if [ "$SOURCE" = "startup" ]; then if [ "$SOURCE" = "startup" ]; then
cat <<'EOF' # First-run detection: auto-trigger onboarding for new projects
_SAFE_PID=$(printf '%s' "$MEM0_PROJECT_ID" | tr '/:' '--')
_ONBOARD_MARKER="$HOME/.mem0/.onboarded_${_SAFE_PID}"
if [ ! -f "$_ONBOARD_MARKER" ]; then
cat <<'EOF'
## Mem0 First Run — Automatic Onboarding
This is the first time mem0 is running for this project. You MUST run the onboarding wizard NOW before doing anything else.
Execute the `/mem0:onboard` skill immediately. This will:
1. Verify the API key
2. Detect and import project files (CLAUDE.md, AGENTS.md, .cursorrules, etc.)
3. Install coding categories optimized for development
4. Show a summary of what was set up
Do NOT skip this. Do NOT ask the user if they want to onboard. Just run `/mem0:onboard` now.
EOF
else
cat <<'EOF'
## Mem0 Session Bootstrap ## Mem0 Session Bootstrap
You have access to persistent memory via the mem0 MCP tools. Before doing anything else: You have access to persistent memory via the mem0 MCP tools. Before doing anything else:
@@ -90,6 +133,7 @@ You have access to persistent memory via the mem0 MCP tools. Before doing anythi
IMPORTANT: Do NOT skip this step. Always bootstrap context first. IMPORTANT: Do NOT skip this step. Always bootstrap context first.
EOF EOF
fi
# Auto-import declarative project files in background # Auto-import declarative project files in background
MEM0_CWD="$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")" \ MEM0_CWD="$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")" \
@@ -127,4 +171,7 @@ is being captured to mem0 in the background as `metadata.type=compact_summary`.
EOF EOF
fi fi
# Telemetry (background, fire-and-forget)
python3 "$SCRIPT_DIR/telemetry.py" session_start --source="$SOURCE" --memory_count="${MEM0_COUNT:-0}" 2>/dev/null &
exit 0 exit 0
+15 -3
View File
@@ -10,7 +10,8 @@
# #
# IMPORTANT: Check stop_hook_active to avoid infinite loops. # IMPORTANT: Check stop_hook_active to avoid infinite loops.
set -euo pipefail # Intentionally omit -e so the reminder always emits even if session_stats fails.
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
@@ -25,6 +26,10 @@ if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
exit 0 exit 0
fi fi
# Telemetry: fire before report() deletes stats file
_TELEM_CAT=$(python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('categories',[])))" 2>/dev/null || echo "0")
python3 "$SCRIPT_DIR/telemetry.py" stop --categories_count="$_TELEM_CAT" 2>/dev/null &
# Print session-end report # Print session-end report
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "") REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
if [ -n "$REPORT" ]; then if [ -n "$REPORT" ]; then
@@ -35,7 +40,7 @@ if [ -n "$REPORT" ]; then
echo "" echo ""
fi fi
# Append to persistent session log (guarded — on_stop.sh uses set -euo pipefail) # Append to persistent session log
if [ -n "$REPORT" ]; then if [ -n "$REPORT" ]; then
mkdir -p "$HOME/.mem0" 2>/dev/null || true mkdir -p "$HOME/.mem0" 2>/dev/null || true
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $REPORT" >> "$HOME/.mem0/session-log.md" 2>/dev/null || true echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $REPORT" >> "$HOME/.mem0/session-log.md" 2>/dev/null || true
@@ -54,10 +59,17 @@ Memories can be as detailed as needed — include full context, reasoning, code
If nothing notable happened in this interaction, it's fine to skip. Only store genuinely useful learnings. If nothing notable happened in this interaction, it's fine to skip. Only store genuinely useful learnings.
Always include `"project_id"` in the metadata of any memory you store. Always include `app_id` (the active project_id from SessionStart) as a top-level parameter in every `add_memory` call.
EOF EOF
# Capture transcript state in the background via Mem0 REST API # Capture transcript state in the background via Mem0 REST API
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "")
echo "$INPUT" | python3 "$SCRIPT_DIR/on_pre_compact.py" --source=session-end 2>/dev/null & echo "$INPUT" | python3 "$SCRIPT_DIR/on_pre_compact.py" --source=session-end 2>/dev/null &
# Mark session as captured so SessionEnd hook can skip duplicate capture
if [ -n "$SESSION_ID" ]; then
mkdir -p "$HOME/.mem0" 2>/dev/null || true
touch "$HOME/.mem0/.captured_${SESSION_ID}" 2>/dev/null || true
fi
exit 0 exit 0
+7 -1
View File
@@ -22,6 +22,8 @@ if [ -n "${MEM0_DEBUG:-}" ]; then
fi fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=_identity.sh
. "$SCRIPT_DIR/_identity.sh"
INPUT=$(cat) INPUT=$(cat)
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false") STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false")
@@ -31,6 +33,10 @@ if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
exit 0 exit 0
fi fi
# Telemetry: fire before report() deletes stats file
_TELEM_CAT=$(python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('categories',[])))" 2>/dev/null || echo "0")
python3 "$SCRIPT_DIR/telemetry.py" stop --categories_count="$_TELEM_CAT" 2>/dev/null &
# Session-end report (best-effort, must not break JSON output) # Session-end report (best-effort, must not break JSON output)
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "") REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
REPORT_BLOCK="" REPORT_BLOCK=""
@@ -51,7 +57,7 @@ ${REPORT_BLOCK}Before finishing, check if there are important learnings from thi
Memories can be as detailed as needed — include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners. Memories can be as detailed as needed — include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners.
Always include \`"project_id"\` in the metadata of any memory you store. Always include \`app_id\` (the active project_id from SessionStart) as a top-level parameter in every \`add_memory\` call.
If nothing notable happened in this interaction, it's fine to skip. Only store genuinely useful learnings. If nothing notable happened in this interaction, it's fine to skip. Only store genuinely useful learnings.
EOF EOF
+15 -1
View File
@@ -14,9 +14,23 @@ if [ -n "${MEM0_DEBUG:-}" ]; then
fi fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=_identity.sh
. "$SCRIPT_DIR/_identity.sh"
INPUT=$(cat) INPUT=$(cat)
# Guard against infinite loops: if this is a re-entry after a prior followup,
# let the turn end. Cursor exposes loop_count in the input JSON.
LOOP_COUNT=$(echo "$INPUT" | jq -r '.loop_count // 0' 2>/dev/null || echo "0")
if [ "$LOOP_COUNT" -gt 1 ]; then
echo '{}'
exit 0
fi
# Telemetry: fire before report() deletes stats file
_TELEM_CAT=$(python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('categories',[])))" 2>/dev/null || echo "0")
python3 "$SCRIPT_DIR/telemetry.py" stop --categories_count="$_TELEM_CAT" 2>/dev/null &
# Session-end report (best-effort) # Session-end report (best-effort)
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "") REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
REPORT_BLOCK="" REPORT_BLOCK=""
@@ -35,7 +49,7 @@ ${REPORT_BLOCK}Before finishing, check if there are important learnings from thi
4. Did you learn anything about the user's preferences? -> Store with metadata \`{"type": "user_preference"}\` 4. Did you learn anything about the user's preferences? -> Store with metadata \`{"type": "user_preference"}\`
5. Were there environment/setup discoveries? -> Store with metadata \`{"type": "environmental"}\` 5. Were there environment/setup discoveries? -> Store with metadata \`{"type": "environmental"}\`
Always include \`"project_id"\` in the metadata of any memory you store. Always include \`app_id\` (the active project_id from session start) as a top-level parameter in every \`add_memory\` call.
If nothing notable happened, it's fine to skip. Only store genuinely useful learnings. If nothing notable happened, it's fine to skip. Only store genuinely useful learnings.
EOF EOF
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Hook: SubagentStop
#
# Fires when a subagent finishes. Injects a reminder to capture any
# learnings the subagent produced that the parent agent should store.
#
# Input: JSON on stdin with agent_type, result_summary
# Output: Context injected into parent agent's context (exit 0)
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INPUT=$(cat)
AGENT_TYPE=$(echo "$INPUT" | jq -r '.agent_type // ""' 2>/dev/null || echo "")
RESULT_SUMMARY=$(echo "$INPUT" | jq -r '.result_summary // ""' 2>/dev/null || echo "")
# Skip short/empty results — nothing worth capturing
if [ ${#RESULT_SUMMARY} -lt 50 ]; then
exit 0
fi
# Skip explorer/plan agents — read-only, rarely produce storable learnings
case "$AGENT_TYPE" in
Explore|Plan)
exit 0
;;
esac
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
cat <<EOF
## Subagent completed: $AGENT_TYPE
Review the subagent result for learnings worth persisting to mem0.
If the subagent discovered something reusable (a fix, pattern, decision, or anti-pattern),
store it via \`add_memory\` with appropriate metadata type.
Only store if genuinely valuable — skip trivial subagent results.
EOF
exit 0
+9 -3
View File
@@ -7,18 +7,21 @@
# Input: JSON on stdin with task_id, task_subject, task_description # Input: JSON on stdin with task_id, task_subject, task_description
# Output: Text that becomes feedback to the model (exit 0) # Output: Text that becomes feedback to the model (exit 0)
set -euo pipefail # Intentionally omit -e so the reminder always emits even if identity resolution fails.
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/_identity.sh" || true . "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
INPUT=$(cat) INPUT=$(cat)
TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // "unknown task"' 2>/dev/null || echo "unknown task") TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // "unknown task"' 2>/dev/null || echo "unknown task")
_PROJECT="${MEM0_PROJECT_ID:-unknown}"
cat <<EOF cat <<EOF
Task completed: "$TASK_SUBJECT" Task completed: "$TASK_SUBJECT"
@@ -31,7 +34,10 @@ Extract key learnings from this completed task and store them using the mem0 \`a
Memories can be as detailed as needed — include full context, reasoning, code snippets, and examples. Memories can be as detailed as needed — include full context, reasoning, code snippets, and examples.
Only store genuinely useful learnings — skip if the task was trivial. Only store genuinely useful learnings — skip if the task was trivial.
Include \`"project_id": "$MEM0_PROJECT_ID"\` in metadata for all memories. Include \`app_id\` = \`"$_PROJECT"\` as a top-level parameter in every \`add_memory\` call (not in metadata).
EOF EOF
# Telemetry (background, fire-and-forget)
python3 "$SCRIPT_DIR/telemetry.py" task_completed 2>/dev/null &
exit 0 exit 0
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Hook: PostToolUseFailure (matcher: mcp__mem0__)
#
# Fires when a mem0 MCP tool call fails. Logs the failure, bumps telemetry,
# and injects a retry hint so Claude can recover.
#
# Input: JSON on stdin with tool_name, tool_input, tool_error
# Output: Context injected into Claude's next response (exit 0)
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null || echo "")
TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_error // ""' 2>/dev/null || echo "")
TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}' 2>/dev/null || echo "{}")
# Extract the short tool name (strip mcp__mem0__ prefix)
SHORT_NAME="${TOOL_NAME#mcp__mem0__}"
# Log failure to persistent file for debugging
mkdir -p "$HOME/.mem0" 2>/dev/null || true
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) FAIL $TOOL_NAME: $TOOL_RESULT" >> "$HOME/.mem0/tool-failures.log" 2>/dev/null || true
# Telemetry (background, fire-and-forget)
python3 "$SCRIPT_DIR/telemetry.py" tool_failure --tool="$SHORT_NAME" 2>/dev/null &
# Classify the error
IS_AUTH_ERROR=""
IS_RATE_LIMIT=""
IS_NETWORK_ERROR=""
if echo "$TOOL_RESULT" | grep -qiE '(401|403|unauthorized|forbidden|invalid.*token|invalid.*key)'; then
IS_AUTH_ERROR="true"
elif echo "$TOOL_RESULT" | grep -qiE '(429|rate.?limit|too many requests|quota)'; then
IS_RATE_LIMIT="true"
elif echo "$TOOL_RESULT" | grep -qiE '(timeout|connect|ECONNREFUSED|network|DNS|resolve)'; then
IS_NETWORK_ERROR="true"
fi
cat <<EOF
## mem0 tool failure: \`$SHORT_NAME\`
**Error:** $TOOL_RESULT
**Input:** \`$TOOL_INPUT\`
EOF
if [ -n "$IS_AUTH_ERROR" ]; then
cat <<'EOF'
**Cause:** Authentication failure. MEM0_API_KEY may be invalid or expired.
**Action:** Tell the user their API key needs to be checked. Get a new key at https://app.mem0.ai/dashboard/api-keys
Do NOT retry — it will fail again with the same key.
EOF
elif [ -n "$IS_RATE_LIMIT" ]; then
cat <<'EOF'
**Cause:** Rate limit hit.
**Action:** Wait a few seconds, then retry the same call. If it fails again, reduce the number of parallel mem0 calls.
EOF
elif [ -n "$IS_NETWORK_ERROR" ]; then
cat <<'EOF'
**Cause:** Network connectivity issue reaching mem0 API.
**Action:** Retry once. If it fails again, inform the user and continue without memory context.
EOF
else
cat <<EOF
**Action:** Retry the \`$SHORT_NAME\` call once. If it fails again, continue without memory context and inform the user.
EOF
fi
exit 0
+57 -11
View File
@@ -25,14 +25,35 @@ if [ ${#PROMPT} -lt 20 ]; then
exit 0 exit 0
fi fi
# No API key means the agent can't search anyway
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=_identity.sh # shellcheck source=_identity.sh
. "$SCRIPT_DIR/_identity.sh" . "$SCRIPT_DIR/_identity.sh"
# 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
HAS_ERROR="true"
fi
# Detect file paths in the prompt (no API needed)
FILE_PATHS=$(echo "$PROMPT" | grep -oE '([a-zA-Z0-9_./-]+\.(py|ts|tsx|js|jsx|rs|go|rb|java|sh|yaml|yml|json|toml|md|sql|css|html))\b' 2>/dev/null | head -5 || echo "")
# Telemetry (background, fire-and-forget)
_TELEM_ARGS=""
[ -n "$HAS_ERROR" ] && _TELEM_ARGS="$_TELEM_ARGS --error_detected"
[ -n "$FILE_PATHS" ] && _TELEM_ARGS="$_TELEM_ARGS --file_paths_detected"
python3 "$SCRIPT_DIR/telemetry.py" user_prompt $_TELEM_ARGS 2>/dev/null &
# No API key — emit detections only, skip search rubric
if [ -z "${MEM0_API_KEY:-}" ]; then
if [ -n "$HAS_ERROR" ]; then
echo "**ERROR DETECTED in prompt.** Set MEM0_API_KEY to search past debugging context."
fi
if [ -n "$FILE_PATHS" ]; then
echo "**FILE PATHS detected:** \`$FILE_PATHS\`"
fi
exit 0
fi
USER_ID="$MEM0_RESOLVED_USER_ID" USER_ID="$MEM0_RESOLVED_USER_ID"
cat <<EOF cat <<EOF
@@ -53,6 +74,30 @@ improve your answer. The agent -- not this hook -- owns this decision.
- the user is *stating* new info -- that's a write trigger (\`add_memory\`), not a search - the user is *stating* new info -- that's a write trigger (\`add_memory\`), not a search
- it's a pure syntax / factual question answerable from general knowledge - it's a pure syntax / factual question answerable from general knowledge
- you already searched this scope earlier in the turn - you already searched this scope earlier in the turn
EOF
if [ -n "$HAS_ERROR" ]; then
cat <<EOF
**ERROR DETECTED in prompt.** You SHOULD search mem0 for prior occurrences:
- \`search_memories(query="<error class or message>", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "anti_pattern"}}]})\`
- \`search_memories(query="<module or file from stack trace>", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "task_learning"}}]})\`
This surfaces past debugging context and known failure modes.
EOF
fi
if [ -n "$FILE_PATHS" ]; then
cat <<EOF
**FILE PATHS detected:** \`$FILE_PATHS\`
Search mem0 for context about these files using the \`contains\` operator on \`metadata.files\`:
- \`search_memories(query="<filename>", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata.files": {"contains": "<filename>"}}]})\`
- Also run a broader text search without the files filter as fallback:
- \`search_memories(query="<filename without extension>", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}]})\`
EOF
fi
cat <<EOF
**If searching, do it well:** **If searching, do it well:**
- Run **2-4 parallel** \`search_memories\` calls with different angles, not one - Run **2-4 parallel** \`search_memories\` calls with different angles, not one
@@ -60,12 +105,13 @@ improve your answer. The agent -- not this hook -- owns this decision.
- Phrase queries as **nouns** ("auth module decisions"), not full sentences. - Phrase queries as **nouns** ("auth module decisions"), not full sentences.
- Filter shape: the root must be a logical operator (\`AND\` / \`OR\` / \`NOT\`) - Filter shape: the root must be a logical operator (\`AND\` / \`OR\` / \`NOT\`)
with an array, and metadata uses a **nested** object (not dotted keys). with an array, and metadata uses a **nested** object (not dotted keys).
Combine \`user_id\` with one \`metadata.type\` clause per call: Combine \`user_id\` + \`app_id\` with one \`metadata.type\` clause per call:
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"project_id": "$MEM0_PROJECT_ID"}}, {"metadata": {"type": "decision"}}]}\` -- design / architecture - \`{"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "decision"}}]}\` -- design / architecture
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"project_id": "$MEM0_PROJECT_ID"}}, {"metadata": {"type": "anti_pattern"}}]}\` -- debugging, error handling - \`{"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "anti_pattern"}}]}\` -- debugging, error handling
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"project_id": "$MEM0_PROJECT_ID"}}, {"metadata": {"type": "user_preference"}}]}\` -- tooling, stack, style - \`{"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "user_preference"}}]}\` -- tooling, stack, style
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"project_id": "$MEM0_PROJECT_ID"}}, {"metadata": {"type": "convention"}}]}\` -- established patterns - \`{"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "convention"}}]}\` -- established patterns
- Or scope with just \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"project_id": "$MEM0_PROJECT_ID"}}]}\` when no metadata filter fits. - Or scope with just \`{"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}]}\` when no metadata filter fits.
- **Recency boost:** For state-related queries ("where were we", "current task", "latest"), add a \`created_at\` filter: \`{"created_at": {"gte": "<90 days ago YYYY-MM-DD>"}}\`. Skip recency for durable facts (conventions, decisions).
- Empty results are normal -- proceed without context. - Empty results are normal -- proceed without context.
EOF EOF
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Parse a mem0 export file and output JSON.
Input: path to a mem0-export-*.md file (sys.argv[1])
Output: JSON array of memory records to stdout
Exit: 0 always
Each block in the file is delimited by lines containing exactly "---".
Blocks have a YAML-like frontmatter section (key: value lines) followed
by a blank line and the memory content text.
Example block format:
---
id: abc123
created_at: 2024-01-01T00:00:00Z
type: task_learnings
confidence: 0.9
branch: main
files: src/foo.py, src/bar.py
categories: coding_conventions, task_learnings
---
The actual memory content text goes here.
"""
from __future__ import annotations
import json
import re
import sys
def parse_blocks(content: str) -> list[dict]:
"""Split content on '---' boundaries and parse each block.
Returns a list of dicts with keys:
id, type, confidence, branch, files (list), categories (list), content (str)
Blocks with empty content are skipped.
Missing optional fields default to "" (scalar) or [] (list fields).
"""
# Normalise line endings
content = content.replace("\r\n", "\n").replace("\r", "\n")
# Split on lines that are exactly "---"
raw_blocks = re.split(r"(?m)^---\s*$", content)
# After splitting on "---", the structure for each memory is:
# raw_blocks[0] = preamble (before first ---, typically empty)
# raw_blocks[1] = frontmatter for block 1
# raw_blocks[2] = content for block 1
# raw_blocks[3] = frontmatter for block 2
# raw_blocks[4] = content for block 2
# ...
# So frontmatter blocks are at odd indices (1, 3, 5, ...) and
# content blocks at even indices (2, 4, 6, ...).
results: list[dict] = []
# Pair up frontmatter + content starting at index 1
i = 1
while i < len(raw_blocks):
frontmatter_raw = raw_blocks[i]
content_raw = raw_blocks[i + 1] if i + 1 < len(raw_blocks) else ""
# Parse the frontmatter key-value pairs
fm = _parse_frontmatter(frontmatter_raw)
# Strip leading/trailing whitespace from content
memory_content = content_raw.strip()
# Skip blocks with empty content
if not memory_content:
i += 2
continue
record = {
"id": fm.get("id", ""),
"type": fm.get("type", ""),
"confidence": fm.get("confidence", ""),
"branch": fm.get("branch", ""),
"files": _parse_list_field(fm.get("files", "")),
"categories": _parse_list_field(fm.get("categories", "")),
"content": memory_content,
}
# Include created_at if present
if "created_at" in fm:
record["created_at"] = fm["created_at"]
results.append(record)
i += 2
return results
def _parse_frontmatter(text: str) -> dict[str, str]:
"""Parse simple 'key: value' lines from frontmatter text.
Only the first colon is used as the delimiter — values may contain colons.
Lines not matching 'key: value' are ignored.
"""
result: dict[str, str] = {}
for line in text.splitlines():
line = line.strip()
if not line:
continue
match = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$", line)
if match:
key = match.group(1).strip()
value = match.group(2).strip()
result[key] = value
return result
def _parse_list_field(value: str) -> list[str]:
"""Split a comma-separated value into a list, stripping whitespace.
Returns [] for empty/whitespace-only input.
"""
if not value or not value.strip():
return []
return [item.strip() for item in value.split(",") if item.strip()]
def main() -> None:
if len(sys.argv) < 2:
print("Usage: parse_export_file.py <path-to-export-file>", file=sys.stderr)
print("[]")
sys.exit(0)
filepath = sys.argv[1]
try:
if filepath == "-":
content = sys.stdin.read()
else:
with open(filepath, encoding="utf-8", errors="replace") as f:
content = f.read()
except OSError as e:
print(f"Error reading file: {e}", file=sys.stderr)
print("[]")
sys.exit(0)
records = parse_blocks(content)
print(json.dumps(records, ensure_ascii=False, indent=2))
sys.exit(0)
if __name__ == "__main__":
main()
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""Parse mem0.md project configuration file.
Reads the optional ``mem0.md`` file in a project directory and extracts
retention policies from a ``## Retention`` section.
Retention format (inside the section):
<category>: <N>d — keep for N days
<category>: forever — never prune (returned as None)
Usage (CLI):
python3 parse_mem0_config.py [<cwd>]
Prints a JSON object mapping category names to day counts (int) or null
(forever) on stdout. Prints ``{}`` when no mem0.md or no ## Retention
section is found.
"""
from __future__ import annotations
import json
import os
import re
import sys
def find_mem0_config(cwd: str) -> str | None:
"""Look for ``mem0.md`` in *cwd*.
Returns the absolute path to ``mem0.md`` if found, else ``None``.
"""
candidate = os.path.join(cwd, "mem0.md")
return candidate if os.path.isfile(candidate) else None
def parse_retention(content: str) -> dict[str, int | None]:
"""Parse the ``## Retention`` section of *content*.
Scans for a heading that matches ``## Retention`` (case-insensitive),
then reads lines until the next ``##``-level heading or end of string.
Each non-blank, non-comment line inside the section is expected to be::
<category>: <N>d → days=N (int)
<category>: forever → days=None
Malformed lines are silently skipped.
Args:
content: Full text of a mem0.md file.
Returns:
Dict mapping category name (str) to day count (int) or ``None``
(forever). Empty dict when no ``## Retention`` section is found.
"""
# Find the ## Retention section (allow any amount of trailing whitespace /
# extra words, but the heading must start with "## Retention").
section_match = re.search(
r"^##\s+Retention[^\n]*\n(.*?)(?=^##\s|\Z)",
content,
flags=re.MULTILINE | re.DOTALL | re.IGNORECASE,
)
if not section_match:
return {}
section_text = section_match.group(1)
policies: dict[str, int | None] = {}
for line in section_text.splitlines():
# Strip comments and whitespace
line = re.sub(r"#.*$", "", line).strip()
if not line:
continue
# Match "<category>: <value>"
line_match = re.match(r"^([^:]+):\s*(.+)$", line)
if not line_match:
continue
category = line_match.group(1).strip()
value = line_match.group(2).strip().lower()
if value == "forever":
policies[category] = None
else:
days_match = re.match(r"^(\d+)d$", value)
if days_match:
policies[category] = int(days_match.group(1))
# else: malformed value — skip silently
return policies
def parse_section_kv(content: str, heading: str) -> dict[str, str]:
"""Parse a key-value section from mem0.md.
Looks for ``## <heading>`` (case-insensitive) and reads ``key: value``
lines until the next ``##``-level heading or end of string.
"""
pattern = rf"^##\s+{re.escape(heading)}[^\n]*\n(.*?)(?=^##\s|\Z)"
match = re.search(pattern, content, flags=re.MULTILINE | re.DOTALL | re.IGNORECASE)
if not match:
return {}
result: dict[str, str] = {}
for line in match.group(1).splitlines():
line = re.sub(r"#.*$", "", line).strip()
if not line:
continue
m = re.match(r"^([^:]+):\s*(.+)$", line)
if m:
result[m.group(1).strip()] = m.group(2).strip()
return result
def parse_section_list(content: str, heading: str) -> list[str]:
"""Parse a list section from mem0.md.
Looks for ``## <heading>`` and reads ``- item`` or bare lines.
"""
pattern = rf"^##\s+{re.escape(heading)}[^\n]*\n(.*?)(?=^##\s|\Z)"
match = re.search(pattern, content, flags=re.MULTILINE | re.DOTALL | re.IGNORECASE)
if not match:
return []
items: list[str] = []
for line in match.group(1).splitlines():
line = re.sub(r"#.*$", "", line).strip()
line = re.sub(r"^[-*]\s+", "", line).strip()
if line:
items.append(line)
return items
def parse_ignore_patterns(content: str) -> list[str]:
"""Parse the ``## Ignore`` section of *content*.
Each non-blank line is a glob pattern (e.g., ``node_modules``, ``*.lock``).
Lines starting with ``#`` are comments and skipped.
"""
pattern = r"^##\s+Ignore[^\n]*\n(.*?)(?=^##\s|\Z)"
match = re.search(pattern, content, flags=re.MULTILINE | re.DOTALL | re.IGNORECASE)
if not match:
return []
patterns: list[str] = []
for line in match.group(1).splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
line = re.sub(r"^[-*]\s+", "", line).strip()
if line:
patterns.append(line)
return patterns
def load_full_config(cwd: str | None = None) -> dict:
"""Load all config sections from mem0.md.
Returns a dict with keys: retention, search, categories, identity,
ignore, project_id.
Each is populated only if the corresponding ``##`` section exists.
"""
if cwd is None:
cwd = os.getcwd()
config_path = find_mem0_config(cwd)
if config_path is None:
return {}
try:
with open(config_path, encoding="utf-8") as fh:
content = fh.read()
except OSError:
return {}
config: dict = {}
retention = parse_retention(content)
if retention:
config["retention"] = retention
search = parse_section_kv(content, "Search")
if search:
config["search"] = search
categories = parse_section_list(content, "Categories")
if categories:
config["categories"] = categories
config["default_categories"] = categories
identity = parse_section_kv(content, "Identity")
if identity:
config["identity"] = identity
if "project_id" in identity:
config["project_id"] = identity["project_id"]
ignore = parse_ignore_patterns(content)
if ignore:
config["ignore"] = ignore
return config
def load_retention_policies(cwd: str | None = None) -> dict[str, int | None]:
"""Load retention policies from the mem0.md in *cwd*.
Combines :func:`find_mem0_config` and :func:`parse_retention` into a
single convenience function.
"""
if cwd is None:
cwd = os.getcwd()
config_path = find_mem0_config(cwd)
if config_path is None:
return {}
try:
with open(config_path, encoding="utf-8") as fh:
content = fh.read()
except OSError:
return {}
return parse_retention(content)
def main() -> int:
"""CLI entry point.
With ``--full``, prints the complete config. Without it, prints only
retention policies (backward-compatible).
"""
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()
if full_mode:
config = load_full_config(cwd)
else:
config = load_retention_policies(cwd)
print(json.dumps(config))
return 0
if __name__ == "__main__":
sys.exit(main())
+45 -7
View File
@@ -28,7 +28,13 @@ def _load() -> dict:
return json.load(f) return json.load(f)
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
pass pass
return {"adds": 0, "searches": 0, "categories": [], "started": datetime.now().isoformat()} return {
"adds": 0,
"searches": 0,
"categories": [],
"category_counts": {},
"started": datetime.now().isoformat(),
}
def _save(stats: dict) -> None: def _save(stats: dict) -> None:
@@ -36,15 +42,33 @@ def _save(stats: dict) -> None:
json.dump(stats, f) json.dump(stats, f)
MAX_RECENT_IDS = 50
def init() -> None: def init() -> None:
_save({"adds": 0, "searches": 0, "categories": [], "started": datetime.now().isoformat()}) _save({
"adds": 0,
"searches": 0,
"categories": [],
"category_counts": {},
"recent_ids": [],
"started": datetime.now().isoformat(),
})
def record_add(category: str = "") -> None: def record_add(category: str = "", memory_id: str = "") -> None:
stats = _load() stats = _load()
stats["adds"] = stats.get("adds", 0) + 1 stats["adds"] = stats.get("adds", 0) + 1
if category and category not in stats.get("categories", []): if category:
stats.setdefault("categories", []).append(category) if category not in stats.get("categories", []):
stats.setdefault("categories", []).append(category)
counts = stats.setdefault("category_counts", {})
counts[category] = counts.get(category, 0) + 1
if memory_id:
recent = stats.setdefault("recent_ids", [])
recent.append({"id": memory_id, "category": category, "ts": datetime.now().isoformat()})
if len(recent) > MAX_RECENT_IDS:
stats["recent_ids"] = recent[-MAX_RECENT_IDS:]
_save(stats) _save(stats)
@@ -54,6 +78,12 @@ def record_search() -> None:
_save(stats) _save(stats)
def peek() -> str:
"""Return current stats as JSON without clearing the file."""
stats = _load()
return json.dumps(stats)
def report() -> str: def report() -> str:
stats = _load() stats = _load()
adds = stats.get("adds", 0) adds = stats.get("adds", 0)
@@ -70,7 +100,12 @@ def report() -> str:
return "" return ""
parts = [] parts = []
parts.append(f"Session: wrote {adds} memories, retrieved {searches}") category_counts = stats.get("category_counts", {})
if category_counts:
breakdown = ", ".join(f"{c} {n}" for c, n in sorted(category_counts.items(), key=lambda x: -x[1]))
parts.append(f"Session: wrote {adds} memories ({breakdown}), retrieved {searches}")
else:
parts.append(f"Session: wrote {adds} memories, retrieved {searches}")
if categories: if categories:
parts.append(f"Categories touched: {', '.join(categories)}") parts.append(f"Categories touched: {', '.join(categories)}")
@@ -87,9 +122,12 @@ def main() -> int:
init() init()
elif cmd == "add": elif cmd == "add":
category = sys.argv[2] if len(sys.argv) > 2 else "" category = sys.argv[2] if len(sys.argv) > 2 else ""
record_add(category) memory_id = sys.argv[3] if len(sys.argv) > 3 else ""
record_add(category, memory_id)
elif cmd == "search": elif cmd == "search":
record_search() record_search()
elif cmd == "peek":
print(peek())
elif cmd == "report": elif cmd == "report":
result = report() result = report()
if result: if result:
+84 -10
View File
@@ -5,15 +5,14 @@ mem0 auto-tags every memory with one or more `categories`. By default the list
is consumer-oriented (food, hobbies, music, ...), which is meaningless for code. is consumer-oriented (food, hobbies, music, ...), which is meaningless for code.
This script replaces the project's category list with a coding-focused one. This script replaces the project's category list with a coding-focused one.
The change is project-level (per the platform docs, per-request overrides are Uses the mem0ai SDK (client.project.update). The SDK is installed into a
not supported on the managed API). Run once per project; future memories will persistent venv at ${CLAUDE_PLUGIN_DATA}/venv by the ensure_deps.sh hook.
be tagged using the new list automatically.
Usage: Usage:
python setup_coding_categories.py # dry-run: show current vs proposed, no changes python setup_coding_categories.py # dry-run: show current vs proposed
python setup_coding_categories.py --apply # actually call project.update() python setup_coding_categories.py --apply # actually call project.update()
Requires the mem0ai Python SDK and MEM0_API_KEY to be set. Requires MEM0_API_KEY (or CLAUDE_PLUGIN_OPTION_MEM0_API_KEY).
""" """
from __future__ import annotations from __future__ import annotations
@@ -23,6 +22,19 @@ import json
import os import os
import sys import sys
_script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _script_dir)
from _identity import resolve_api_key # noqa: E402
_plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT", os.path.join(_script_dir, ".."))
_data_dir = os.environ.get("CLAUDE_PLUGIN_DATA", os.path.join(os.path.expanduser("~"), ".mem0", "plugin-data"))
_venv_site = os.path.join(_data_dir, "venv", "lib")
if os.path.isdir(_venv_site):
for d in sorted(os.listdir(_venv_site)):
sp = os.path.join(_venv_site, d, "site-packages")
if os.path.isdir(sp) and sp not in sys.path:
sys.path.insert(1, sp)
CODING_CATEGORIES = [ CODING_CATEGORIES = [
{ {
"architecture_decisions": ( "architecture_decisions": (
@@ -66,6 +78,66 @@ CODING_CATEGORIES = [
"and ways of working." "and ways of working."
) )
}, },
{
"dependency_decisions": (
"Why specific libraries, frameworks, or package versions were chosen or replaced, "
"including the alternatives considered and the reasoning behind the selection."
)
},
{
"performance_findings": (
"Profiling results, bottlenecks identified, optimisations applied, and measurable "
"improvements achieved -- useful for avoiding regressions and guiding future work."
)
},
{
"security_constraints": (
"Security requirements, authentication and authorisation rules, data-handling "
"constraints, compliance obligations, and known threat mitigations in effect."
)
},
{
"testing_patterns": (
"Test strategies, frameworks chosen, coverage targets, fixture patterns, mocking "
"approaches, and how the test suite is structured for this project."
)
},
{
"data_model": (
"Schema definitions, database column semantics, domain object relationships, "
"field constraints, and how data flows between storage and application layers."
)
},
{
"api_contracts": (
"API endpoint shapes, request and response schemas, authentication requirements, "
"versioning policy, and any breaking-change commitments or deprecation timelines."
)
},
{
"deployment_runbook": (
"How to build, release, deploy, and roll back the project. CI/CD pipeline steps, "
"environment-specific configuration, and on-call runbook entries."
)
},
{
"team_norms": (
"Team working agreements, PR review etiquette, branching strategy, on-call "
"rotation, and other social or process conventions the team has agreed on."
)
},
{
"domain_glossary": (
"Domain-specific terms, abbreviations, and acronyms with their precise meanings "
"in this project -- prevents misunderstandings across code, docs, and discussion."
)
},
{
"experiment_results": (
"Results from A/B tests, feature-flag experiments, spikes, or proof-of-concept "
"work -- what was tried, what was measured, and what conclusion was reached."
)
},
] ]
@@ -87,17 +159,19 @@ def main() -> int:
) )
args = ap.parse_args() args = ap.parse_args()
if not os.environ.get("MEM0_API_KEY"): api_key = resolve_api_key()
print("ERROR: MEM0_API_KEY is not set. Export it and try again.", file=sys.stderr) if not api_key:
print("ERROR: MEM0_API_KEY is not set. Export it or configure it via plugin userConfig.", file=sys.stderr)
return 1 return 1
os.environ["MEM0_API_KEY"] = api_key
try: try:
from mem0 import MemoryClient from mem0 import MemoryClient
except ImportError: except ImportError:
print( print(
"ERROR: the mem0ai Python SDK is not installed.\n" "ERROR: mem0ai SDK not found. The plugin's ensure_deps.sh hook should\n"
"Install with: pip install mem0ai\n" "install it automatically on session start. Try restarting Claude Code,\n"
"Then re-run this script.", "or run manually: pip install mem0ai",
file=sys.stderr, file=sys.stderr,
) )
return 1 return 1
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Lightweight fire-and-forget telemetry for the mem0 plugin.
Sends anonymous usage events to PostHog using the same project key and
endpoint as the mem0 Python SDK and CLI. No posthog library dependency —
uses stdlib urllib directly (same pattern as cli/python telemetry_sender.py).
CLI usage (called from hooks as a background subprocess):
python3 telemetry.py <event_type> [--memory_count=N] [--categories_count=N]
[--error_detected] [--file_paths_detected]
[--source=<src>] [--tool=<name>]
Opt-out: set MEM0_TELEMETRY=false (or 0/no/off) to disable all telemetry.
Never sends: user content, memory content, API keys, raw user/project IDs.
Only sends: event type, platform, plugin version, anonymized hashes, counts.
"""
from __future__ import annotations
import hashlib
import json
import os
import platform
import random
import sys
import urllib.error
import urllib.request
PLUGIN_VERSION = "0.2.1"
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/"
REQUEST_TIMEOUT = 2
# All events sampled at 10% to keep PostHog costs predictable.
SAMPLE_RATE = 0.1
def _sha256(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def _distinct_id() -> str:
"""Stable anonymous ID: MD5 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()
user_id = os.environ.get("MEM0_RESOLVED_USER_ID") or os.environ.get("USER") or "unknown"
return _sha256(user_id)
def detect_platform() -> str:
if os.environ.get("CLAUDECODE") or os.environ.get("CLAUDE_PLUGIN_ROOT"):
return "claude-code"
if os.environ.get("CURSOR_PLUGIN_ROOT"):
return "cursor"
if os.environ.get("CODEX_PLUGIN_ROOT"):
return "codex"
return "unknown"
def is_enabled() -> bool:
return os.environ.get("MEM0_TELEMETRY", "true").lower() not in ("false", "0", "no", "off")
def _should_sample() -> bool:
return random.random() < SAMPLE_RATE
def build_posthog_payload(event_name: str, properties: dict | None = None) -> dict:
project_id = os.environ.get("MEM0_PROJECT_ID") or "unknown"
return {
"api_key": POSTHOG_API_KEY,
"distinct_id": _distinct_id(),
"event": event_name,
"properties": {
"source": "plugin",
"platform": detect_platform(),
"plugin_version": PLUGIN_VERSION,
"project_hash": _sha256(project_id),
"os": sys.platform,
"os_version": platform.version(),
"sample_rate": SAMPLE_RATE,
"$process_person_profile": False,
"$lib": "posthog-python",
**(properties or {}),
},
}
def send(payload: dict) -> None:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
POSTHOG_HOST,
data=data,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT):
pass
except Exception:
pass
def emit(event_type: str, properties: dict | None = None) -> None:
if not is_enabled():
return
event_name = f"plugin.{event_type}"
if not _should_sample():
return
send(build_posthog_payload(event_name, properties))
def main() -> int:
if not is_enabled():
return 0
if len(sys.argv) < 2:
return 1
event_type = sys.argv[1]
properties: dict = {}
for arg in sys.argv[2:]:
if arg.startswith("--memory_count="):
try:
properties["memory_count"] = int(arg.split("=", 1)[1])
except ValueError:
pass
elif arg.startswith("--categories_count="):
try:
properties["categories_count"] = int(arg.split("=", 1)[1])
except ValueError:
pass
elif arg == "--error_detected":
properties["error_detected"] = True
elif arg == "--file_paths_detected":
properties["file_paths_detected"] = True
elif arg.startswith("--source="):
properties["source_detail"] = arg.split("=", 1)[1]
elif arg.startswith("--tool="):
properties["tool"] = arg.split("=", 1)[1]
elif arg.startswith("--files_count="):
try:
properties["files_count"] = int(arg.split("=", 1)[1])
except ValueError:
pass
emit(event_type, properties)
return 0
if __name__ == "__main__":
sys.exit(main())
+123
View File
@@ -0,0 +1,123 @@
---
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!
```
+344
View File
@@ -0,0 +1,344 @@
---
name: mem0-dream
description: >
Memory consolidation pass. Fetches all project memories, finds near-duplicates,
merges them, flags contradictions, prunes stale entries per retention policy.
Outputs a diff for user approval before applying changes.
TRIGGER: user runs /mem0:dream, or asks "consolidate memories", "clean up memories",
"merge duplicate memories", "run dream".
---
# Mem0 Dream — Memory Consolidation
This skill performs a memory consolidation pass: it fetches all project memories,
identifies near-duplicates, flags contradictions, and prunes stale entries based on
configured retention policies. All proposed changes are shown as a diff for user
approval before anything is modified.
---
## Step 1: Load Retention Policies
Determine the active retention policy by running the parser script. Use the
appropriate `PLUGIN_ROOT` variable for the current platform (`${CLAUDE_PLUGIN_ROOT}`,
`${CODEX_PLUGIN_ROOT}`, or `${CURSOR_PLUGIN_ROOT}`):
```bash
python3 "<PLUGIN_ROOT>/scripts/parse_mem0_config.py" "<cwd>"
```
Parse the JSON output (a dict of `category → days | null`). If the script fails
or returns `{}`, fall back to these built-in defaults:
| `metadata.type` | Default retention |
|---|---|
| `session_state` | 90 days |
| `compact_summary` | 90 days |
| all others | no pruning |
Store the resolved policies for use in Step 3.
---
## Step 2: Fetch ALL Project Memories
Call `get_memories` to retrieve every memory for the active project:
```python
get_memories(
user_id="<active_user_id>",
app_id="<active_project_id>",
page_size=200,
)
```
If the response indicates more pages exist, paginate until all memories are fetched.
Collect the full list before proceeding. If zero memories are found, print:
```
No memories found for project <project_id>. Nothing to consolidate.
```
…and stop.
---
## Step 3: Analyze — Find Issues
Work entirely in-memory; do not modify anything yet.
Group memories by `metadata.type` (use `"unknown"` when the field is absent).
For each group, identify the following:
### 3a. Near-duplicate pairs (merge candidates)
Two memories are near-duplicates when they express the same fact or decision but
phrased differently (e.g., "Use PostgreSQL for auth" and "Auth DB is PostgreSQL").
Heuristics — two memories are near-duplicates if **all** of these hold:
- Similarity threshold: estimated cosine similarity > 0.9 (use noun/keyword overlap as proxy — if >60% of significant nouns overlap, treat as >0.9 similarity).
- Same `metadata.type`.
- Neither memory is pinned (`metadata.pinned != true`).
For each qualifying pair, draft a merged version that is more complete and specific
than either original.
### 3b. Contradictions
Two memories contradict when they assert opposing facts about the same topic
(e.g., "Deploy to ECS" vs. "Deploy to Vercel").
Identify the likely winner: the more recent memory with higher confidence wins.
Store both IDs and their content for user review.
### 3c. Prune candidates
A memory is a prune candidate when **any** of the following is true:
1. Its `metadata.type` has a retention policy and the memory is older than the
configured number of days (compare `created_at` to today).
2. Its confidence score is below 0.3 AND it contains no information unique to
this project (no file paths, identifiers, or domain-specific nouns).
**Always skip memories where `metadata.pinned == true`**, regardless of age or
confidence.
---
## Step 4: Print Diff Report (item 15)
Print a structured diff to the terminal before making any changes. Use exactly
this format:
```
## Dream — Memory Consolidation Report
### Merge proposals (<N> pairs)
MERGE [mem0:<id1>] + [mem0:<id2>] → NEW
- Original 1: "<content of memory 1, truncated to 120 chars>"
- Original 2: "<content of memory 2, truncated to 120 chars>"
- Merged: "<drafted merged content>"
### Contradictions (<N> pairs)
CONFLICT [mem0:<idA>] vs [mem0:<idB>]
- A: "<content>" (<created_at date>, confidence: <score>)
- B: "<content>" (<created_at date>, confidence: <score>)
Which is current? [A/B/skip]
### Prune candidates (<N> memories)
PRUNE [mem0:<id>] — <metadata.type>, <age>d old (policy: <policy_days>d)
---
Proposed: <N> merges, <N> prunes, <N> conflicts
Apply? [Y/n]
```
If there are zero items in any category, omit that section entirely.
If there are zero total proposals (no merges, no prunes, no conflicts), print:
```
Dream complete. No duplicate, contradictory, or stale memories found.
```
…and stop.
---
## Step 5: Wait for User Input and Apply
### 5a. Contradictions
For each `CONFLICT` pair in the report, wait for the user to type `A`, `B`, or
`skip` (case-insensitive). If they enter nothing (empty), treat as `skip`.
Record the winner for each pair before proceeding to the final apply confirmation.
### 5b. Final confirmation
After all conflict resolutions are collected, prompt:
```
Apply? [Y/n]
```
If the user types `n` or `no` (case-insensitive), print `Cancelled. No changes made.`
and stop.
If the user confirms (`Y`, `yes`, or empty / Enter), apply all changes in this order:
#### Merges
For each approved merge pair:
1. `delete_memory(<id1>)`
2. `delete_memory(<id2>)`
3. `add_memory` with:
- `messages=[{"role": "user", "content": "<merged content>"}]`
- `user_id=<active_user_id>`
- `app_id=<active_project_id>` (top-level, not in metadata)
- `metadata={"type": "<original type>", "branch": "<active_branch>", "confidence": <higher of the two original scores>, "source": "mem0-dream"}`
- `infer=False`
#### Contradictions (resolved)
For each resolved conflict where the user chose A or B:
- Identify the loser (the non-chosen memory).
- First call `get_memory(<loser_id>)` to read its current text content.
- Then call `update_memory(<loser_id>, data=<original_text_content>)` to preserve the text while updating it.
- **Important:** `update_memory` requires the `data` (text) parameter. A metadata-only call may error or wipe the content. Always read first, then update with the original text.
Contradictions where the user chose `skip` are left untouched.
#### Prunes
For each prune candidate:
- `delete_memory(<memory_id>)`
---
## Step 6: Print Summary
After all changes are applied, print:
```
Dream complete.
Merged: <N> pairs → <N> new memories
Pruned: <N> memories deleted
Flagged: <N> contradictions resolved, <N> skipped
```
---
## Auto mode
When invoked with `--auto` (e.g., `/mem0:dream --auto`), run non-interactively:
- **Merges**: applied automatically (no contradiction, both are compatible).
- **Prunes**: applied automatically (age/confidence-based, no ambiguity).
- **Contradictions**: skipped — they require human judgment.
In auto mode:
1. Load policies and fetch memories (Steps 1–3) as normal.
2. Apply merges and prunes silently without printing the diff or prompting.
3. Print a compact summary:
```
[mem0-dream --auto] project=<id> merged=<N> pruned=<N> conflicts_skipped=<N>
```
4. If contradictions were detected but skipped, store a reminder memory:
```python
add_memory(
messages=[{"role": "user", "content": "mem0-dream detected <N> contradiction(s) requiring manual review. Run /mem0:dream to resolve them interactively."}],
user_id="<active_user_id>",
app_id="<active_project_id>",
metadata={"type": "task_learning", "source": "mem0-dream-auto", "branch": "<active_branch>"},
infer=False,
)
```
## Scheduling recurring dreams
When invoked with `--schedule` (e.g., `/mem0:dream --schedule weekly`), register a
cloud routine via Claude Code's built-in `/schedule` command so the dream runs
automatically without any local cron or launchd setup.
### Step S1: Parse schedule frequency
Accept natural-language frequency after `--schedule`:
| User input | Cron equivalent | Description |
|---|---|---|
| `weekly` or `--schedule weekly` | Every Sunday 3:00 AM local | Default weekly consolidation |
| `daily` | Every day 3:00 AM local | For high-volume projects |
| `biweekly` | Every other Sunday 3:00 AM local | Lower frequency option |
| Custom (e.g., `"every Monday 9am"`) | Pass verbatim to `/schedule` | Let Claude Code resolve it |
### Step S2: Create the routine
Use Claude Code's `/schedule` command to create a cloud routine. The routine runs
`/mem0:dream --auto` on the specified schedule against the current repository:
```
/schedule <frequency> /mem0:dream --auto
```
For example:
- `/schedule weekly /mem0:dream --auto` — runs every week
- `/schedule daily at 3am /mem0:dream --auto` — runs every day at 3 AM
- `/schedule every Monday 9am /mem0:dream --auto` — runs every Monday at 9 AM
The `/schedule` command handles all the cloud infrastructure: repository cloning,
environment setup, and cron scheduling. The routine runs as a full Claude Code
cloud session with access to the mem0 MCP tools.
### Step S3: Confirm to user
After the routine is created, print:
```
Dream scheduled: <frequency>
Routine name: mem0-dream-<project_id>
Next run: <next scheduled time>
Manage at: https://claude.ai/code/routines
Edit: /schedule list → /schedule update
Cancel: /schedule list → delete the routine
```
### Managing scheduled dreams
| Action | Command |
|---|---|
| List all routines | `/schedule list` |
| Run dream now | `/schedule run` (select the dream routine) |
| Change frequency | `/schedule update` (select the dream routine) |
| Pause | Toggle off at claude.ai/code/routines |
| Delete | Delete at claude.ai/code/routines or `/schedule update` |
### Fallback for non-cloud users
If `/schedule` is unavailable (API key auth, no claude.ai subscription), fall back
to local options:
1. **macOS launchd plist** — generate and install:
```bash
cat > ~/Library/LaunchAgents/com.mem0.dream.plist << 'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.mem0.dream</string>
<key>ProgramArguments</key>
<array>
<string>claude</string>
<string>-p</string>
<string>/mem0:dream --auto</string>
<string>--allowedTools</string>
<string>mcp__mem0__*</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Weekday</key><integer>0</integer>
<key>Hour</key><integer>3</integer>
<key>Minute</key><integer>0</integer>
</dict>
<key>StandardOutPath</key><string>/tmp/mem0-dream.log</string>
<key>StandardErrorPath</key><string>/tmp/mem0-dream.err</string>
<key>WorkingDirectory</key><string>PROJECT_DIR</string>
</dict>
</plist>
PLIST
launchctl load ~/Library/LaunchAgents/com.mem0.dream.plist
```
Replace `PROJECT_DIR` with the actual project path.
2. **Linux cron** — add entry:
```bash
(crontab -l 2>/dev/null; echo "0 3 * * 0 cd PROJECT_DIR && claude -p '/mem0:dream --auto' >> /tmp/mem0-dream.log 2>&1") | crontab -
```
Print which method was used and how to verify:
```
Dream scheduled (local: launchd/cron): weekly Sundays 3am
Verify: launchctl list | grep mem0 # macOS
crontab -l | grep mem0 # Linux
```
+81
View File
@@ -0,0 +1,81 @@
---
name: mem0-export
description: >
Export all memories for the current project to a local Markdown file.
Each memory is written as a YAML-frontmatter block that can be re-imported later.
TRIGGER: user runs /mem0:export, or asks "export memories", "backup memories",
"download my memories", "save memories to file".
---
# Mem0 Export
Export all memories for the current project to a portable Markdown file.
## Execution
### Step 1: Resolve identity
Determine the active identity:
- `user_id` from `MEM0_USER_ID` env var, else `$USER`, else `"default"`
- `project_id` (used as `app_id`) from `MEM0_PROJECT_ID` env var, or via the project resolver
### Step 2: Fetch all memories
Call `get_memories` with:
- `user_id=<active_user_id>`
- `app_id=<active_project_id>`
- `page_size=200`
If the response is paginated (i.e. the result contains a `next` cursor or the count equals `page_size`), continue fetching pages until all memories are retrieved.
### Step 3: Format each memory as a YAML-frontmatter block
For each memory record, produce a block in this exact format:
```
---
id: <memory.id>
created_at: <memory.created_at>
type: <memory.metadata.type or "">
confidence: <memory.metadata.confidence or "">
branch: <memory.metadata.branch or "">
files: <memory.metadata.files joined with ", " or "">
categories: <memory.categories joined with ", " or "">
---
<memory.memory or memory content string>
```
Notes:
- The `---` delimiters must be on their own lines with no extra whitespace.
- `files` and `categories` are written as comma-separated values on a single line.
- Leave a blank line after the content before the next `---` (for readability).
- If a field is missing or null, write an empty string (not "null").
### Step 4: Write the export file
Determine the output filename:
```
mem0-export-<project_id>-<YYYY-MM-DD>.md
```
Where `<YYYY-MM-DD>` is today's date in UTC.
Write all formatted blocks to this file using the Write tool (or equivalent). The file is written to the current working directory.
### Step 5: Print summary
```
Exported <N> memories to <filename>
```
Where `<N>` is the total number of memory blocks written.
## Error Handling
- If `get_memories` returns an error or zero memories, print:
```
No memories found for project <project_id>. Nothing exported.
```
- If the write fails, report the error to the user.
+76
View File
@@ -0,0 +1,76 @@
---
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."
+171
View File
@@ -0,0 +1,171 @@
---
name: mem0-health
description: >
Diagnostic health check for the mem0 plugin. Verifies API key, MCP server
connectivity, identity resolution, and memory read/write capability.
TRIGGER: user runs /mem0:health, or asks "is mem0 working", "mem0 health",
"check mem0 connection", "debug mem0".
---
# Mem0 Health Check
Run a diagnostic check on the mem0 plugin. Useful for troubleshooting.
## Execution
Run ALL checks, then display a single summary. Do not stop on the first failure.
### Check 1: API key
```bash
echo "${MEM0_API_KEY:-${CLAUDE_PLUGIN_OPTION_MEM0_API_KEY:-NOT_SET}}"
```
- If `NOT_SET`: FAIL — "No API key configured"
- If set: PASS — show first 6 chars + `...` (never print the full key)
### Check 2: Identity resolution
Read the active identity from the SessionStart banner or resolve manually:
- `user_id`: from `MEM0_RESOLVED_USER_ID` or `$USER`
- `project_id`: from `MEM0_PROJECT_ID` or current directory name
- `branch`: from `MEM0_BRANCH` or `git rev-parse --abbrev-ref HEAD`
PASS if all three are non-empty. WARN if any falls back to defaults.
### Check 3: MCP server connectivity
Call `search_memories` with:
- `query="health check"`, `user_id=<id>`, `limit=1`
- `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<project_id>"}]}`
- If returns successfully (even empty): PASS
- If errors: FAIL — show the error message
### Check 4: Memory write capability
Call `add_memory` with:
- `messages=[{"role": "user", "content": "Health check probe — safe to delete."}]`
- `user_id=<id>`, `app_id=<project_id>`
- `metadata={"type": "health_check", "probe": true}`
If it returns a memory ID: PASS — then immediately call `delete_memory` with that ID to clean up.
If it errors: FAIL — show the error.
### Check 5: Session stats tracker
Check if the session stats file exists and is readable:
```bash
STATS_FILE="/tmp/mem0_session_stats_${USER}.json"
if [ -f "$STATS_FILE" ] && python3 -c "import json; json.load(open('$STATS_FILE'))" 2>/dev/null; then
echo "OK"
else
echo "FAIL"
fi
```
This file is created by the SessionStart hook and updated by PostToolUse hooks throughout the session. If it doesn't exist, the session hooks may not have fired yet — try sending a message first, then recheck.
### Display
```
## mem0 Health Check
| Check | Status | Detail |
|--------------------|--------|-------------------------------|
| API Key | PASS | m0-dVe... |
| Identity | PASS | user=kartik, project=mem0 |
| MCP Connectivity | PASS | 142ms round-trip |
| Memory Write/Read | PASS | write + delete OK |
| Session Tracker | PASS | stats file active |
All checks passed. mem0 is healthy.
```
If any check fails, add a `## Troubleshooting` section with specific fix steps for each failure.
## Extended mode: Memory Quality Analysis
When invoked with `--deep` (e.g., `/mem0:health --deep`) or `--fix` (e.g., `/mem0:health --fix`), run the standard 5 checks above **plus** a memory quality scan.
`--fix` implies `--deep` and automatically applies safe fixes after showing the analysis (see bottom of this section).
### Quality Check 1: Duplicates
Call `get_memories` with `user_id`, `app_id`, `page_size=200`. Compare all pairs within the same `metadata.type` group for high textual overlap (shared nouns/keywords > 60%). Report:
```
Potential duplicates: <N> pairs
[mem0:<id1>] ≈ [mem0:<id2>] — both about "<shared topic>"
```
### Quality Check 2: Stale memories
Flag memories where:
- `metadata.type` is `session_state` or `compact_summary` AND older than 90 days
- `metadata.confidence` < 0.3 AND older than 30 days
```
Stale candidates: <N>
[mem0:<id>] — session_state, 142d old
```
### Quality Check 2b: Low-confidence memories
Flag memories where `metadata.confidence` < 0.5 (regardless of age). Report separately from stale:
```
Low-confidence memories: <N>
[mem0:<id>] — confidence=0.3, "<content preview>"
```
### Quality Check 3: Contradictions
Within each `metadata.type` group, flag pairs that assert opposing facts about the same topic. Use semantic judgment — look for negation patterns, conflicting tool/framework choices, or reversed decisions.
```
Possible contradictions: <N>
[mem0:<idA>] vs [mem0:<idB>] — conflicting on "<topic>"
```
### Quality Check 4: Orphan memories
Memories with no `metadata.type` set, or with `metadata.type` not in the 17 known coding categories. These were likely written without proper tagging.
```
Untagged/orphan memories: <N>
```
### Quality summary
```
## Memory Quality
| Metric | Count | Action |
|----------------|-------|---------------------------------|
| Duplicates | <N> | Run /mem0:dream to merge |
| Stale | <N> | Run /mem0:dream to prune |
| Contradictions | <N> | Run /mem0:dream to resolve |
| Orphans | <N> | Consider retagging via MCP |
```
If all counts are 0: `Memory quality: clean. No duplicates, stale entries, or contradictions found.`
### Auto-fix mode (`--fix`)
When `--fix` is passed, apply these safe fixes automatically after displaying the quality summary:
1. **Orphans:** For each untagged memory, infer a `metadata.type` from content and call `update_memory` to set it. If inference is uncertain, skip.
2. **Stale `session_state`/`compact_summary` > 90d:** Delete them via `delete_memory`. These are ephemeral by design.
3. **Duplicates:** Do NOT auto-merge — print "Run `/mem0:dream` to merge duplicates" instead.
4. **Contradictions:** Do NOT auto-resolve — print "Run `/mem0:dream` to resolve contradictions" instead.
5. **Low-confidence < 0.3 AND > 30d old:** Delete them via `delete_memory`.
Print a summary of actions taken:
```
## Auto-fix Results
Deleted: <N> stale, <N> low-confidence
Retagged: <N> orphans
Skipped: <N> duplicates (use /mem0:dream), <N> contradictions (use /mem0:dream)
```
@@ -0,0 +1,106 @@
---
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.
+104
View File
@@ -0,0 +1,104 @@
---
name: mem0-import
description: >
Import memories from a mem0 export file back into the current project.
Reads a YAML-frontmatter Markdown file produced by /mem0:export and
adds each memory block to mem0.
TRIGGER: user runs /mem0:import, or asks "import memories", "restore memories",
"load memories from file", "reimport backup".
---
# Mem0 Import
Import memories from a mem0 export file into the current project.
## Execution
### Step 1: Determine the export file to import
If the user provided a filename as an argument to `/mem0:import <filename>`, use that file.
Otherwise, list `.md` files in the current directory whose names contain `mem0-export`:
```bash
ls -1 *.md 2>/dev/null | grep mem0-export || echo "No export files found"
```
If multiple files are found, ask the user which one to import. If none are found, print:
```
No mem0-export files found in the current directory.
Run /mem0:export first, or provide the filename: /mem0:import <path-to-file>
```
### Step 2: Parse the export file
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}`
Run the parser script to extract memory records as JSON:
```bash
python3 "<PLUGIN_ROOT>/scripts/parse_export_file.py" "<path-to-export-file>"
```
This outputs a JSON array where each element has:
- `id` — original memory ID (for reference only; a new ID will be assigned on import)
- `type` — metadata type
- `confidence` — metadata confidence value
- `branch` — metadata branch
- `files` — list of associated files
- `categories` — list of categories
- `content` — the memory text
If the script fails or outputs `[]`, print:
```
Failed to parse <filename> or file contains no valid memory blocks.
```
and stop.
### Step 3: Resolve identity
Determine the active identity:
- `user_id` from `MEM0_USER_ID` env var, else `$USER`, else `"default"`
- `project_id` (used as `app_id`) from `MEM0_PROJECT_ID` env var, or via the project resolver
### Step 4: Import each memory
For each record in the parsed JSON array, call `add_memory` with:
- `messages=[{"role": "user", "content": "<record.content>"}]`
- `user_id=<active_user_id>`
- `app_id=<active_project_id>`
- `metadata={`
- `"type": "<record.type>"` (if non-empty)
- `"confidence": "<record.confidence>"` (if non-empty)
- `"branch": "<record.branch>"` (if non-empty)
- `"files": <record.files>` (the list, if non-empty)
- `"source": "import"`
- `}`
- `infer=False`
Notes:
- Do NOT pass the original `id` — the platform assigns a new ID.
- Skip records where `content` is empty (the parser already filters these, but be defensive).
- Continue importing even if individual records fail; track the count of successes.
### Step 5: Print results
```
Imported <N> memories into project <project_id>
```
Where `<N>` is the number of successfully imported memories.
If any failed:
```
Imported <N>/<total> memories into project <project_id> (<failed> failed)
```
## Error Handling
- If the parser script is not found at `<PLUGIN_ROOT>/scripts/parse_export_file.py`, print an error and stop.
- If `add_memory` calls fail consistently (e.g. auth error), report the issue and stop early.
+147 -14
View File
@@ -18,17 +18,16 @@ Decide whether persistent memory context would improve your response, then act a
## Project scoping ## Project scoping
Every memory operation MUST be scoped to the current project: Every memory operation MUST be scoped to the current project using `app_id` (entity-scoped memory):
- **On `add_memory`:** Always include `metadata.project_id` (the active project_id from SessionStart). - **On `add_memory`:** Always pass `app_id=<active_project_id>` as a **top-level parameter** (not in metadata).
- **On `search_memories`:** Always include `{"metadata": {"project_id": "<your_project_id>"}}` in the AND filter. - **On `search_memories`:** Always include `{"app_id": "<your_project_id>"}` in the AND filter.
- **Session-state memories:** Also include `metadata.branch` (the active branch from SessionStart).
Full filter template: Full filter template:
```python ```python
filters={"AND": [ filters={"AND": [
{"user_id": "<your_user_id>"}, {"user_id": "<your_user_id>"},
{"metadata": {"project_id": "<your_project_id>"}}, {"app_id": "<your_project_id>"},
{"metadata": {"type": "decision"}} {"metadata": {"type": "decision"}}
]} ]}
``` ```
@@ -67,7 +66,7 @@ Two rules from the v2 filter spec:
1. The root **must** be a logical operator (`AND` / `OR` / `NOT`) with an array. A bare `{"user_id": "..."}` won't work. 1. The root **must** be a logical operator (`AND` / `OR` / `NOT`) with an array. A bare `{"user_id": "..."}` won't work.
2. Metadata uses a **nested** object, not a dotted key. `{"metadata": {"type": "decision"}}`, never `{"metadata.type": "decision"}`. Only top-level metadata keys are filterable. 2. Metadata uses a **nested** object, not a dotted key. `{"metadata": {"type": "decision"}}`, never `{"metadata.type": "decision"}`. Only top-level metadata keys are filterable.
Combine `user_id` with one metadata clause per call: Combine `user_id` + `app_id` with one metadata clause per call:
| `metadata.type` clause | Use for | | `metadata.type` clause | Use for |
|--------|---------| |--------|---------|
@@ -76,9 +75,33 @@ Combine `user_id` with one metadata clause per call:
| `{"metadata": {"type": "user_preference"}}` | tooling, stack, style — always include for code work | | `{"metadata": {"type": "user_preference"}}` | tooling, stack, style — always include for code work |
| `{"metadata": {"type": "convention"}}` | established patterns in this project | | `{"metadata": {"type": "convention"}}` | established patterns in this project |
### Which categories to search by query intent
When a query clearly maps to one of the platform's custom categories, fan-out to 2–3 parallel `search_memories` calls scoped to those categories so recall is precise without being noisy. Use the `metadata.type` filter as your primary discriminator; treat the category column below as the semantic lens to pick the right query nouns.
| User intent / signal | Primary categories to search | Example query nouns |
|---|---|---|
| Design or architecture question | `architecture_decisions`, `api_contracts`, `data_model` | `"architecture decision"`, `"API schema"`, `"data model"` |
| Something failed / debugging | `anti_patterns`, `bug_fixes`, `security_constraints` | `"bug root cause"`, `"failure pattern"`, `"security constraint"` |
| How do we do X here? | `coding_conventions`, `team_norms`, `testing_patterns` | `"code convention"`, `"team norm"`, `"test strategy"` |
| Which library / version to use | `dependency_decisions`, `tooling_setup`, `architecture_decisions` | `"dependency choice"`, `"library version"`, `"tooling setup"` |
| Performance or scale concern | `performance_findings`, `architecture_decisions`, `data_model` | `"performance bottleneck"`, `"profiling result"`, `"optimisation"` |
| Security / auth / compliance | `security_constraints`, `api_contracts`, `coding_conventions` | `"auth rule"`, `"security requirement"`, `"compliance"` |
| Test strategy or coverage | `testing_patterns`, `coding_conventions`, `anti_patterns` | `"test framework"`, `"coverage target"`, `"fixture pattern"` |
| Schema / DB / domain object | `data_model`, `api_contracts`, `domain_glossary` | `"schema"`, `"column"`, `"domain object"` |
| API shape or versioning | `api_contracts`, `data_model`, `architecture_decisions` | `"endpoint"`, `"request schema"`, `"versioning"` |
| How to deploy / release / rollback | `deployment_runbook`, `tooling_setup`, `team_norms` | `"deploy step"`, `"rollback"`, `"CI pipeline"` |
| Team process / branching / PRs | `team_norms`, `coding_conventions`, `deployment_runbook` | `"branching strategy"`, `"PR review"`, `"working agreement"` |
| What does this term mean? | `domain_glossary`, `data_model`, `api_contracts` | `"glossary"`, `"abbreviation"`, `"domain term"` |
| Experiment / spike / A-B test | `experiment_results`, `performance_findings`, `anti_patterns` | `"experiment result"`, `"A/B test"`, `"spike outcome"` |
| User's tool / language preferences | `user_preferences`, `tooling_setup`, `coding_conventions` | `"user preference"`, `"preferred tool"`, `"language choice"` |
| Past task strategies that worked | `task_learnings`, `anti_patterns`, `coding_conventions` | `"task strategy"`, `"approach that worked"` |
| Environment / setup question | `tooling_setup`, `deployment_runbook`, `dependency_decisions` | `"environment setup"`, `"build tool"`, `"install step"` |
| Anything related to current state | `task_learnings`, `architecture_decisions`, `anti_patterns` | (combine with recency filter — see below) |
Full filter (replace `<your_user_id>` and `<your_project_id>` with the active values from SessionStart): Full filter (replace `<your_user_id>` and `<your_project_id>` with the active values from SessionStart):
```python ```python
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"project_id": "<your_project_id>"}}, {"metadata": {"type": "decision"}}]} filters={"AND": [{"user_id": "<your_user_id>"}, {"app_id": "<your_project_id>"}, {"metadata": {"type": "decision"}}]}
``` ```
### Worked example ### Worked example
@@ -91,16 +114,16 @@ search_memories(query="Refactor the auth module to use JWT")
# Hits whatever shares words. Misses prior decisions and preferences. # Hits whatever shares words. Misses prior decisions and preferences.
``` ```
Do (parallel — substitute the active `user_id` and `project_id` for the placeholders): Do (parallel — substitute the active `user_id` and `app_id` for the placeholders):
```python ```python
search_memories(query="auth module decisions", search_memories(query="auth module decisions",
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"project_id": "<your_project_id>"}}, {"metadata": {"type": "decision"}}]}) filters={"AND": [{"user_id": "<your_user_id>"}, {"app_id": "<your_project_id>"}, {"metadata": {"type": "decision"}}]})
search_memories(query="JWT", search_memories(query="JWT",
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"project_id": "<your_project_id>"}}]}) filters={"AND": [{"user_id": "<your_user_id>"}, {"app_id": "<your_project_id>"}]})
search_memories(query="auth refactor failures", search_memories(query="auth refactor failures",
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"project_id": "<your_project_id>"}}, {"metadata": {"type": "anti_pattern"}}]}) filters={"AND": [{"user_id": "<your_user_id>"}, {"app_id": "<your_project_id>"}, {"metadata": {"type": "anti_pattern"}}]})
search_memories(query="auth", search_memories(query="auth",
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"project_id": "<your_project_id>"}}, {"metadata": {"type": "user_preference"}}]}) filters={"AND": [{"user_id": "<your_user_id>"}, {"app_id": "<your_project_id>"}, {"metadata": {"type": "user_preference"}}]})
``` ```
## After completing significant work ## After completing significant work
@@ -114,6 +137,8 @@ Extract key learnings and store them using the `add_memory` tool:
- **Environment/setup discoveries** -> Include metadata `{"type": "environmental"}` - **Environment/setup discoveries** -> Include metadata `{"type": "environmental"}`
- **Conventions established** -> Include metadata `{"type": "convention"}` - **Conventions established** -> Include metadata `{"type": "convention"}`
Always include `"branch": "<active_branch>"` in the metadata object alongside `type`. The active branch is shown in the SessionStart banner. This enables branch-scoped filtering later (e.g., "what did we do on feature/auth-rewrite?").
> `metadata.type` (which you set explicitly) and `categories` (which the platform auto-tags after the project's custom-category list — see `scripts/setup_coding_categories.py`) are complementary. Always set `metadata.type` for explicit filtering; the platform fills in `categories` on its own. Don't try to set `categories` on `add_memory` calls — per-request overrides aren't supported on the managed API. > `metadata.type` (which you set explicitly) and `categories` (which the platform auto-tags after the project's custom-category list — see `scripts/setup_coding_categories.py`) are complementary. Always set `metadata.type` for explicit filtering; the platform fills in `categories` on its own. Don't try to set `categories` on `add_memory` calls — per-request overrides aren't supported on the managed API.
### Expiration: high-churn vs durable ### Expiration: high-churn vs durable
@@ -133,7 +158,7 @@ When the user is asking about *current* state ("where were we", "what's the acti
```python ```python
# Last 90 days only # Last 90 days only
{"AND": [{"user_id": "<id>"}, {"metadata": {"project_id": "<your_project_id>"}}, {"metadata": {"type": "session_state"}}, {"created_at": {"gte": "<90 days ago, YYYY-MM-DD>"}}]} {"AND": [{"user_id": "<id>"}, {"app_id": "<your_project_id>"}, {"metadata": {"type": "session_state"}}, {"created_at": {"gte": "<90 days ago, YYYY-MM-DD>"}}]}
``` ```
Skip the recency filter when the user is asking about durable facts ("what conventions does this project use", "have we hit this bug before") — those are timeless and recency would hide them. Skip the recency filter when the user is asking about durable facts ("what conventions does this project use", "have we hit this bug before") — those are timeless and recency would hide them.
@@ -148,7 +173,8 @@ When you've done the extraction work yourself — pre-compaction summaries, deci
add_memory( add_memory(
messages=[{"role": "user", "content": "<your structured fact>"}], messages=[{"role": "user", "content": "<your structured fact>"}],
user_id="<active user_id>", user_id="<active user_id>",
metadata={"type": "decision"}, app_id="<active project_id>",
metadata={"type": "decision", "branch": "<active branch>"},
infer=False, infer=False,
) )
``` ```
@@ -180,8 +206,115 @@ If context is about to be compacted or the session is ending, store a comprehens
Include metadata: `{"type": "session_state"}` Include metadata: `{"type": "session_state"}`
## Inline citations
When your response is informed by specific memories, cite them so the user can trace provenance. Use the memory ID returned by `search_memories`.
Format: `[mem0:<short_id>]` where `<short_id>` is the first 8 characters of the memory ID.
Example:
> We chose Postgres over SQLite for production [mem0:a3f8b2c1] and the auth module uses JWT tokens [mem0:7e2d9f4a].
Rules:
- Only cite when the memory **directly informed** your answer. Don't cite for general knowledge.
- Place citations inline, at the end of the relevant sentence.
- If multiple memories support the same point, cite all: `[mem0:abc12345][mem0:def67890]`.
- Don't cite `session_state` or `compact_summary` memories — those are internal bookkeeping.
- Keep it subtle. One or two citations per response is typical. Don't over-cite.
## Memory hygiene ## Memory hygiene
- Do NOT write to MEMORY.md or any file-based memory. Use mem0 MCP tools exclusively. - Do NOT write to MEMORY.md or any file-based memory. Use mem0 MCP tools exclusively.
- Only store genuinely useful learnings. Skip trivial interactions. - Only store genuinely useful learnings. Skip trivial interactions.
- Use specific, searchable language in memory content. - Use specific, searchable language in memory content.
### Confidence scoring on every add_memory
Every `add_memory` call MUST include a `confidence` field in its `metadata` object. This captures how certain the stored fact is, so downstream callers can filter out speculation.
| `metadata.confidence` value | Meaning | When to use |
|---|---|---|
| `1.0` | User explicitly stated it | User said "we use Postgres", "always lint before commit", "never use floats for currency" |
| `0.8` | Observed directly in code / config | You read it from a file, migration, or config — not inferred |
| `0.5` | Inferred from context | You derived it from surrounding evidence but the user didn't confirm it |
| `0.3` | Guessed / low-signal | Extrapolated from a single weak signal; treat as a tentative hypothesis |
Example:
```python
add_memory(
messages=[{"role": "user", "content": "We always use Postgres — never SQLite in production."}],
user_id="<active user_id>",
app_id="<active project_id>",
metadata={"type": "architecture_decisions", "branch": "<active branch>", "confidence": 1.0},
infer=False,
)
```
**Search guidance:** When recalling actionable facts (decisions, conventions, security constraints), optionally apply a confidence threshold of 0.6 or above to avoid surfacing low-confidence guesses. Only top-level metadata keys are filterable, so `confidence` filtering requires SDK-side post-filtering or a dedicated high-confidence write path — for now, include the confidence value in every write and document it in the memory content so it is searchable via text.
### File path tagging on every add_memory
Every `add_memory` call that is associated with specific files MUST include a `files` key in its `metadata` object. The value is an array of affected file paths relative to the project root.
```python
add_memory(
messages=[{"role": "user", "content": "The auth middleware lives in src/middleware/auth.ts and validates JWTs using the shared key in config/secrets.ts."}],
user_id="<active user_id>",
app_id="<active project_id>",
metadata={
"type": "architecture_decisions",
"branch": "<active branch>",
"confidence": 0.8,
"files": ["src/middleware/auth.ts", "config/secrets.ts"],
},
infer=False,
)
```
**Filtering by files:** Use the `contains` operator to filter by `metadata.files` at search time:
```python
search_memories(
query="auth middleware",
filters={
"AND": [
{"user_id": "<id>"},
{"app_id": "<project_id>"},
{"metadata.files": {"contains": "src/middleware/auth.ts"}},
]
},
limit=5,
)
```
Also embed bare filenames in the memory content text as a fallback — the vector search will surface them even if the structured filter misses.
### Access counter: track memory usage
When you retrieve a memory via `search_memories` and **actually use it** in your response (i.e., it informed your answer or you cited it), increment its access counter and update the last-accessed timestamp by calling:
```python
# 1. Read current state
mem = get_memory(memory_id=<id>)
current_text = mem["content"] # or mem["memory"], depending on response shape
current_meta = mem.get("metadata", {})
# 2. Bump access_count and set last_accessed
import datetime
current_meta["access_count"] = current_meta.get("access_count", 0) + 1
current_meta["last_accessed"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
# 3. Update with preserved content and bumped metadata
update_memory(
memory_id=<id>,
data=current_text, # preserve original text — required parameter
metadata=current_meta, # pass updated access_count and last_accessed
)
```
**Important:** `update_memory` requires the `data` (text) parameter. Always `get_memory` first to read the current content, then pass it back unchanged. A metadata-only update may error or wipe the content.
**When to increment:** Only when you actually used the memory to answer. Don't bump on every search hit — that inflates counts for memories that were returned but irrelevant. Aim for 1-3 bumps per response at most.
**Why:** `access_count` and `last_accessed` feed into `/mem0:dream` pruning decisions. Memories that are never accessed after creation are candidates for cleanup. Frequently accessed memories are protected from pruning regardless of age.
+43 -24
View File
@@ -11,27 +11,31 @@ description: >
Run this wizard to set up the mem0 plugin for the current project. Complete in ~30 seconds. Run this wizard to set up the mem0 plugin for the current project. Complete in ~30 seconds.
## Step 1: Verify API key ## Step 0: Ensure mem0ai SDK is installed
Check if `MEM0_API_KEY` is set in the current environment: The plugin installs the `mem0ai` Python SDK automatically on session start via a venv in `${CLAUDE_PLUGIN_DATA}/venv`. If Step 4 (categories) fails with an import error, run:
```bash ```bash
echo "${MEM0_API_KEY:+SET}" || echo "NOT_SET" "${CLAUDE_PLUGIN_ROOT}/scripts/ensure_deps.sh"
``` ```
- If **NOT set**: This is silent and idempotent — safe to run anytime.
1. Ask the user: "No MEM0_API_KEY found. Do you have one, or need to create one?"
2. If they need one, provide two options: ## Step 1: Verify API key and MCP connection
- **Browser**: Go to https://app.mem0.ai/dashboard/api-keys and copy the key
- **CLI**: Run `pip install mem0-cli && mem0 init --agent --json` to mint a key without email Check that mem0 MCP tools are available. Use ToolSearch with query `"mem0 search_memories"` — the exact tool name varies by install method (may be `mcp__mem0__search_memories` or `mcp__plugin_mem0_mem0__search_memories`).
3. Once they have the key, tell them to run: `export MEM0_API_KEY="m0-..."` in their terminal, then restart this Claude Code session (the env var must be set before Claude Code starts).
4. **STOP here.** Do not proceed until the key is confirmed set. - If **any mem0 search tool found**: Proceed to Step 2. The API key is working.
- If **SET**: Proceed to Step 2. - If **NOT found**: The MCP server failed to connect. Tell the user:
1. "MCP server not connected. Make sure `MEM0_API_KEY` is exported in your shell."
2. Show: `export MEM0_API_KEY="m0-your-key-here"` then restart Claude Code.
3. If they need a key: https://app.mem0.ai/dashboard/api-keys or `mem0 init --agent --json`
4. **STOP here.** Do not proceed — all other steps need MCP tools.
## Step 2: Show identity ## Step 2: Show identity
Report the active identity to the user: Report the active identity to the user:
- Call `search_memories` with `query="project setup"`, `user_id=<active_user_id>`, `filters={"AND": [{"user_id": "<active_user_id>"}, {"metadata": {"project_id": "<active_project_id>"}}]}`, `limit=1` to verify connectivity. - Call `search_memories` with `query="project setup"`, `user_id=<active_user_id>`, `filters={"AND": [{"user_id": "<active_user_id>"}, {"app_id": "<active_project_id>"}]}`, `limit=1` to verify connectivity.
- Print: `Connected. user=<user_id>, project=<project_id>, branch=<branch>` - Print: `Connected. user=<user_id>, project=<project_id>, branch=<branch>`
- If the search fails, troubleshoot the API key. - If the search fails, troubleshoot the API key.
@@ -51,34 +55,49 @@ If user says yes (or default):
- Call `add_memory` with: - Call `add_memory` with:
- `messages=[{"role": "user", "content": "## Project Profile: <filename>\n\nProject: <project_id>\n\n<file_content>"}]` - `messages=[{"role": "user", "content": "## Project Profile: <filename>\n\nProject: <project_id>\n\n<file_content>"}]`
- `user_id=<active_user_id>` - `user_id=<active_user_id>`
- `metadata={"type": "project_profile", "file": "<filename>", "project_id": "<active_project_id>", "source": "onboard"}` - `app_id=<active_project_id>`
- `metadata={"type": "project_profile", "file": "<filename>", "source": "onboard", "branch": "<active_branch>"}`
- `infer=False` - `infer=False`
## Step 4: Install coding categories ## Step 4: Install coding categories
Ask: "Install coding categories optimized for development workflows? [Y/n]" Ask: "Install coding categories optimized for development workflows? [Y/n]"
If yes, run the script directly (no external dependencies required — uses stdlib only). If yes, run the setup script using the plugin's venv python:
The script lives at `scripts/setup_coding_categories.py` relative to the plugin root. Use the appropriate plugin root variable for the current platform:
- Claude Code: `${CLAUDE_PLUGIN_ROOT}`
- Codex: `${CODEX_PLUGIN_ROOT}`
- Cursor: `${CURSOR_PLUGIN_ROOT}`
```bash ```bash
python3 "<PLUGIN_ROOT>/scripts/setup_coding_categories.py" --apply VENV_PY="${CLAUDE_PLUGIN_DATA}/venv/bin/python3"
if [ -x "${VENV_PY}" ]; then
"${VENV_PY}" "${CLAUDE_PLUGIN_ROOT}/scripts/setup_coding_categories.py" --apply
else
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/setup_coding_categories.py" --apply
fi
``` ```
If the script reports an error, show the error message and suggest checking the API key. If the script fails with "mem0ai SDK not found", run the dependency installer first:
```bash
"${CLAUDE_PLUGIN_ROOT}/scripts/ensure_deps.sh"
```
Then retry the categories script.
## Step 5: Summary ## Step 5: Mark project as onboarded
Create a marker file so SessionStart won't re-trigger onboarding next session:
```bash
_SAFE_PID=$(printf '%s' "<active_project_id>" | tr '/:' '--')
mkdir -p ~/.mem0 && touch ~/.mem0/.onboarded_${_SAFE_PID}
```
This is silent — no user-facing output needed.
## Step 6: Summary
Print a summary: Print a summary:
``` ```
Onboarding complete. Onboarding complete.
user_id: <user_id> user_id: <user_id>
project_id: <project_id> project_id: <project_id> (app_id)
branch: <branch>
imported: <N> files imported: <N> files
categories: <installed or skipped> categories: <installed or skipped>
+46
View File
@@ -0,0 +1,46 @@
---
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>.
```
+68
View File
@@ -0,0 +1,68 @@
---
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".
---
# Mem0 Pin
Pin a memory to mark it as high-priority.
## Execution
### Step 1: Find the memory
The user provides either a search query or memory ID.
**If memory ID:**
- Call `get_memory` with the ID.
**If search query:**
- Call `search_memories` with the query, `user_id`, `app_id`, `limit=5`.
- Show numbered list with content previews.
- Ask: "Which memory to pin? Enter a number."
### Step 2: Read current content
Call `get_memory` with the selected memory ID. Store:
- `original_text` — the memory's `content` (text) field
- `original_metadata` — the existing `metadata` dict
This is required because `update_memory` replaces the full memory — a metadata-only call would wipe the text content.
### Step 3: Pin it
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)
```
**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.
### Step 4: Confirm
```
Pinned: "<memory content, first 80 chars>..."
Memory ID: <id>
Pinned memories surface first when relevant to a search.
```
### Unpin
If the user says "unpin" or `/mem0:unpin`:
1. Call `get_memory` to read current content and metadata.
2. Set `metadata.pinned = false` explicitly:
```python
updated_meta = {**original_metadata, "pinned": False}
update_memory(memory_id=<id>, data=<original_text>, metadata=updated_meta)
```
3. Print: `Unpinned: "<content>..."`
+53
View File
@@ -0,0 +1,53 @@
---
name: mem0-remember
description: >
Quick-add a memory from the user's input. No extraction pass — stores verbatim.
TRIGGER: user runs /mem0:remember <text>, or says "remember this", "save this",
"store this fact", "don't forget that".
---
# Mem0 Remember
Store a fact or learning directly into mem0.
## Execution
### Step 1: Extract the content
The user provides the content as an argument: `/mem0:remember <text>`
If no text was provided, ask: "What should I remember?"
### Step 2: Classify the memory
Based on the content, pick the best `metadata.type`:
| Content signal | Type |
|---|---|
| "we decided...", "always use...", "never..." | `decision` |
| "X doesn't work because...", "don't try..." | `anti_pattern` |
| "I prefer...", "use X instead of Y" | `user_preference` |
| "the convention is...", "we always..." | `convention` |
| "learned that...", "figured out..." | `task_learning` |
| setup, env, tooling, config | `environmental` |
| anything else | `task_learning` |
### Step 3: Store
Call `add_memory` with:
- `messages=[{"role": "user", "content": "<the user's text>"}]`
- `user_id=<active_user_id>`
- `app_id=<active_project_id>`
- `metadata={"type": "<classified_type>", "branch": "<active_branch>", "confidence": 1.0, "source": "remember_command"}`
- `infer=False`
`infer=False` because the user stated the fact explicitly — no extraction needed.
`confidence=1.0` because the user explicitly asked to store this.
### Step 4: Confirm
Print:
```
Remembered as <type>: "<first 80 chars of content>..."
Memory ID: <id>
```
+96
View File
@@ -0,0 +1,96 @@
---
name: mem0-stats
description: >
Show memory statistics for the current session and project lifetime.
Combines local session counters with API-fetched totals.
TRIGGER: user runs /mem0:stats, or asks "how many memories", "mem0 stats",
"memory usage", "show memory count".
---
# Mem0 Stats
Show session and lifetime memory statistics.
## Execution
### Step 1: Gather session stats
Run the session stats reporter:
```bash
SCRIPT_DIR="${CLAUDE_PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}}/scripts"
python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null || echo "{}"
```
The `peek` command returns JSON without clearing the stats file (unlike `report`).
If the script returns empty or errors, note "No session data available" and continue.
### Step 2: Fetch lifetime stats from API
Call `get_memories` with:
- `user_id=<active_user_id>`
- `app_id=<active_project_id>`
- `page_size=100`
Count the returned memories. Group them by:
1. `categories[0]` (platform-assigned) — primary grouping
2. `metadata.type` (agent-assigned) — secondary if no categories
3. `created_at` date — for age analysis
Also run a `search_memories` call with `query="project"`, `limit=1` to measure round-trip latency (time the call).
### Step 3: Display
Print a compact dashboard with an ASCII histogram for category distribution:
```
## mem0 Stats
### This Session
Memories written: <N>
Searches run: <N>
Categories touched: <list>
### Project Lifetime (<project_id>)
Total memories: <N>
By category:
decision ████████████████ 24
convention ██████████░░░░░░ 15
anti_pattern ████░░░░░░░░░░░░ 6
task_learning ███░░░░░░░░░░░░░ 5
user_preference ██░░░░░░░░░░░░░░ 3
session_state █░░░░░░░░░░░░░░░ 2
By age:
< 7 days ████████████████ 5
7–30 days ██████████░░░░░░ 12
30–90 days ████░░░░░░░░░░░░ 10
> 90 days ██░░░░░░░░░░░░░░ 8
By access count:
Never accessed ████████████████ 18
1–5 accesses ████████░░░░░░░░ 10
6–20 accesses ████░░░░░░░░░░░░ 4
20+ accesses █░░░░░░░░░░░░░░░ 3
Oldest memory: <date>
Newest memory: <date>
### Health
API latency: <N>ms
User: <user_id>
Project: <project_id>
Branch: <branch>
```
**Histogram rules:**
- Max bar width: 16 characters. Scale all bars relative to the highest count.
- Use `█` for filled and `░` for empty. Right-align the count number.
- Sort categories by count descending. Omit categories with 0 memories.
- If only 1-2 categories exist, still show the histogram — it provides visual context.
- **Age buckets:** Compute from `created_at`. Buckets: <7d, 7–30d, 30–90d, >90d.
- **Access count buckets:** Read `metadata.access_count` (default 0 if absent). Buckets: 0, 1–5, 6–20, 20+.
Skip any section with zero data.
@@ -40,7 +40,7 @@ The user provides a project name as an argument: `/mem0:switch-project <project-
(Replace `<PROJECT_NAME>` with the user's chosen project name.) (Replace `<PROJECT_NAME>` with the user's chosen project name.)
3. Verify by searching for existing memories: 3. Verify by searching for existing memories:
- Call `search_memories` with `query="project"`, `filters={"AND": [{"user_id": "<id>"}, {"metadata": {"project_id": "<PROJECT_NAME>"}}]}`, `limit=1` - Call `search_memories` with `query="project"`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<PROJECT_NAME>"}]}`, `limit=1`
4. Print: 4. Print:
``` ```
+70 -25
View File
@@ -13,34 +13,79 @@ Show the user what mem0 has stored for the current project.
## Execution ## Execution
1. Run the following `search_memories` calls in parallel (all with the active `user_id` and `metadata.project_id`): ### Step 1: Fetch ALL memories for this project
- `query="architecture decisions"`, `filters={"AND": [{"user_id": "<id>"}, {"metadata": {"project_id": "<pid>"}}, {"metadata": {"type": "decision"}}]}`, `limit=5` Call `get_memories` with:
- `query="anti patterns failures"`, `filters={"AND": [{"user_id": "<id>"}, {"metadata": {"project_id": "<pid>"}}, {"metadata": {"type": "anti_pattern"}}]}`, `limit=5` - `user_id=<active_user_id>`
- `query="task learnings strategies"`, `filters={"AND": [{"user_id": "<id>"}, {"metadata": {"project_id": "<pid>"}}, {"metadata": {"type": "task_learning"}}]}`, `limit=5` - `app_id=<active_project_id>`
- `query="coding conventions"`, `filters={"AND": [{"user_id": "<id>"}, {"metadata": {"project_id": "<pid>"}}, {"metadata": {"type": "convention"}}]}`, `limit=5`
- `query="user preferences"`, `filters={"AND": [{"user_id": "<id>"}, {"metadata": {"project_id": "<pid>"}}, {"metadata": {"type": "user_preference"}}]}`, `limit=5`
- `query="project profile"`, `filters={"AND": [{"user_id": "<id>"}, {"metadata": {"project_id": "<pid>"}}, {"metadata": {"type": "project_profile"}}]}`, `limit=5`
- `query="tooling setup environment"`, `filters={"AND": [{"user_id": "<id>"}, {"metadata": {"project_id": "<pid>"}}, {"metadata": {"type": "environmental"}}]}`, `limit=5`
2. Group results by category. For each category with results, print: This returns every memory scoped to the project — no semantic filtering, no missed results.
``` If `get_memories` doesn't support `app_id` as a direct parameter, use:
## <category_name> (<count> memories) - `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}]}`
- <memory_content_truncated_to_100_chars> (score: <similarity_score>)
- ...
```
3. For categories with zero results, print: `<category_name>: (empty)` Pass `page_size=100` (or the maximum allowed) to get a full picture.
4. Print totals at the end: ### Step 2: Run supplementary semantic searches
```
---
Total: <N> memories across <M> categories for project <project_id>
```
5. If ALL categories are empty, print: In parallel, run these `search_memories` calls to get relevance-ranked results for key topics:
```
No memories stored yet for project <project_id>. - `query="architecture decisions design choices"`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}]}`, `limit=10`
Run /mem0:onboard to import project files, or start working — mem0 captures learnings automatically. - `query="bugs errors failures anti-patterns"`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}]}`, `limit=10`
``` - `query="project setup tooling conventions preferences"`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}]}`, `limit=10`
**Do NOT filter by `metadata.type` in these calls.** The platform auto-assigns `categories` — filtering on `metadata.type` misses memories that were auto-categorized but don't have an explicit `metadata.type`.
### Step 3: Merge and group
Merge all results by memory ID (deduplicate). For each memory, determine its group using this priority:
1. **Platform `categories` field** (array on each memory, auto-assigned by Mem0). Use the first category value.
2. **`metadata.type` field** (if present, set explicitly by hooks/agent). Use as fallback if no `categories`.
3. **"other"** bucket for memories with neither.
Map category names to display names:
| Platform category / metadata.type | Display name |
|---|---|
| `architecture decisions`, `architecture_decisions`, `decision` | Architecture Decisions |
| `anti patterns`, `anti_patterns`, `anti_pattern` | Anti-Patterns |
| `task learnings`, `task_learnings`, `task_learning` | Task Learnings |
| `coding conventions`, `coding_conventions`, `convention` | Coding Conventions |
| `user preferences`, `user_preferences`, `user_preference` | User Preferences |
| `project profile`, `project_profile` | Project Profile |
| `tooling setup`, `tooling_setup`, `environmental` | Tooling & Setup |
| `technology`, `professional_details` | Tooling & Setup |
| `session_state` | Session State |
| `compact_summary` | Compact Summaries |
| anything else | Other |
### Step 4: Display results
For each group that has results, print:
```
## <display_name> (<count> memories)
- <full_memory_content> (score: <similarity_score_if_available>)
- ...
```
Show the **full memory text** for each entry — do NOT truncate. If a group has more than 10 entries, show top 10 by recency (or similarity score if from a search call) and note `... and <N> more`.
For groups with zero results, skip them entirely — don't print empty groups.
### Step 5: Print totals
```
---
Total: <N> unique memories across <M> categories for project <project_id>
Branch: <active_branch>
```
### Step 6: Empty state
If zero memories found for this project, print:
```
No memories stored yet for project <project_id>.
Run /mem0:onboard to import project files, or start working — mem0 captures learnings automatically.
```
@@ -0,0 +1,88 @@
"""Tests for setup_coding_categories.py -- CODING_CATEGORIES list completeness."""
from __future__ import annotations
import importlib
import os
import sys
import pytest
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
EXPECTED_KEYS = [
"architecture_decisions",
"anti_patterns",
"task_learnings",
"tooling_setup",
"bug_fixes",
"coding_conventions",
"user_preferences",
"dependency_decisions",
"performance_findings",
"security_constraints",
"testing_patterns",
"data_model",
"api_contracts",
"deployment_runbook",
"team_norms",
"domain_glossary",
"experiment_results",
]
@pytest.fixture()
def coding_categories():
"""Import CODING_CATEGORIES from setup_coding_categories, ensuring scripts/ is on path."""
abs_scripts = os.path.abspath(SCRIPTS_DIR)
inserted = False
if abs_scripts not in sys.path:
sys.path.insert(0, abs_scripts)
inserted = True
# Force re-import in case another test already loaded a stale version
mod_name = "setup_coding_categories"
if mod_name in sys.modules:
del sys.modules[mod_name]
mod = importlib.import_module(mod_name)
yield mod.CODING_CATEGORIES
if inserted and abs_scripts in sys.path:
sys.path.remove(abs_scripts)
def test_total_count(coding_categories):
"""CODING_CATEGORIES must contain exactly 17 entries."""
assert len(coding_categories) == 17, (
f"Expected 17 categories, found {len(coding_categories)}: "
f"{[list(c.keys())[0] for c in coding_categories]}"
)
def test_all_expected_keys_present(coding_categories):
"""Every expected category key must appear exactly once."""
actual_keys = [list(cat.keys())[0] for cat in coding_categories]
for key in EXPECTED_KEYS:
assert key in actual_keys, f"Missing expected category key: '{key}'"
def test_no_duplicate_keys(coding_categories):
"""No category key may appear more than once."""
actual_keys = [list(cat.keys())[0] for cat in coding_categories]
seen = set()
duplicates = []
for key in actual_keys:
if key in seen:
duplicates.append(key)
seen.add(key)
assert not duplicates, f"Duplicate category keys found: {duplicates}"
def test_each_description_is_non_empty_string(coding_categories):
"""Every category must have a non-empty string description."""
for cat in coding_categories:
assert len(cat) == 1, f"Category dict should have exactly one key, got: {cat}"
key = list(cat.keys())[0]
description = cat[key]
assert isinstance(description, str), (
f"Category '{key}' description is not a string: {type(description)}"
)
assert description.strip(), f"Category '{key}' has an empty description"
@@ -0,0 +1,355 @@
"""Tests for import_competing_tools.py — competing tool file importers."""
from __future__ import annotations
import json
import os
import sys
from unittest import mock
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
# ---------------------------------------------------------------------------
# split_sections tests (unit tests on the splitter functions)
# ---------------------------------------------------------------------------
def test_split_by_headers_cursorrules():
"""split_by_headers correctly splits .cursorrules content on ## headers."""
from import_competing_tools import split_by_headers
content = """\
# My Cursor Rules
Some preamble text that belongs to the first section.
## TypeScript Conventions
Always use strict mode. Prefer const over let.
Never use var.
## React Patterns
Use functional components with hooks.
Avoid class components.
## Testing
Write tests for all utility functions.
"""
chunks = split_by_headers(content, "## ")
assert len(chunks) == 4 # preamble + 3 sections
# First chunk is the preamble (before any ## header)
assert "preamble text" in chunks[0]
# Remaining chunks start with their header
assert chunks[1].startswith("## TypeScript Conventions")
assert "strict mode" in chunks[1]
assert chunks[2].startswith("## React Patterns")
assert "functional components" in chunks[2]
assert chunks[3].startswith("## Testing")
assert "utility functions" in chunks[3]
def test_split_by_headers_copilot():
"""split_by_headers correctly splits copilot-instructions.md on ## headers."""
from import_competing_tools import split_by_headers
content = """\
## Code Style
Use 2-space indentation. Always add trailing commas.
## Architecture
Follow clean architecture principles. Keep business logic in domain layer.
"""
chunks = split_by_headers(content, "## ")
assert len(chunks) == 2
assert chunks[0].startswith("## Code Style")
assert "2-space indentation" in chunks[0]
assert chunks[1].startswith("## Architecture")
assert "clean architecture" in chunks[1]
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
content = "This file has no headers at all. Just plain text."
chunks = split_by_headers(content, "## ")
assert len(chunks) == 1
assert "Just plain text" in chunks[0]
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
# Create a temporary memory-bank directory with .md files
mb_dir = tmp_path / "memory-bank"
mb_dir.mkdir()
(mb_dir / "architecture.md").write_text(
"# Architecture Decisions\n\nUse microservices architecture with event sourcing."
)
(mb_dir / "conventions.md").write_text(
"# Code Conventions\n\nAll functions must have type hints. Use black formatter."
)
(mb_dir / "empty.md").write_text("") # empty file should be skipped
# Read and verify we can split the files
md_files = sorted(f for f in os.listdir(str(mb_dir)) if f.endswith(".md"))
assert "architecture.md" in md_files
assert "conventions.md" in md_files
assert "empty.md" in md_files
non_empty = []
for filename in md_files:
filepath = os.path.join(str(mb_dir), filename)
with open(filepath) as f:
content = f.read().strip()
if content:
chunks = filter_and_truncate([content])
non_empty.extend(chunks)
assert len(non_empty) == 2
assert any("microservices" in c for c in non_empty)
assert any("type hints" in c for c in non_empty)
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
content = """\
## First Section
Content of first section.
---
## Second Section
Content of second section.
---
Third section without a header (just after HR).
"""
chunks = split_by_hr_or_headers(content)
# Should split into meaningful chunks
assert len(chunks) >= 2
assert any("First Section" in c for c in chunks)
assert any("Second Section" in c for c in chunks)
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
chunks = [
"Short", # < 50 chars, should be filtered
"A" * 49, # exactly 49 chars, should be filtered
"A" * 50, # exactly 50 chars, should be kept
"A long enough chunk that definitely passes the minimum length filter.",
]
result = filter_and_truncate(chunks)
assert len(result) == 2
assert all(len(c) >= 50 for c in result)
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
long_chunk = "X" * (MAX_CHUNK_CHARS + 500)
result = filter_and_truncate([long_chunk])
assert len(result) == 1
assert len(result[0]) == MAX_CHUNK_CHARS
# ---------------------------------------------------------------------------
# Mock API tests
# ---------------------------------------------------------------------------
def _make_mock_response(status: int = 201, body: dict | None = None) -> mock.MagicMock:
"""Create a mock HTTP response object."""
if body is None:
body = {"id": "new-mem-id", "memory": "test"}
resp = mock.MagicMock()
resp.status = status
resp.read.return_value = json.dumps(body).encode()
resp.__enter__ = lambda s: s
resp.__exit__ = mock.MagicMock(return_value=False)
return resp
def test_cursorrules_import_api_call(tmp_path):
"""cmd_cursorrules calls the API with correct app_id (top-level), infer=False, and correct source."""
from import_competing_tools import cmd_cursorrules
# Create a .cursorrules file with enough content
cursorrules = tmp_path / ".cursorrules"
cursorrules.write_text(
"## TypeScript Rules\n\nAlways use strict TypeScript. Never use 'any' type. "
"Prefer interfaces over type aliases for object shapes."
)
captured_requests: list[dict] = []
def mock_urlopen(req, timeout=None):
body = json.loads(req.data.decode())
captured_requests.append(body)
return _make_mock_response(201)
with mock.patch("import_competing_tools.resolve_api_key", return_value="m0-testkey"), \
mock.patch("import_competing_tools.resolve_user_id", return_value="testuser"), \
mock.patch("import_competing_tools.resolve_project_id", return_value="my-project"), \
mock.patch("import_competing_tools.resolve_branch", return_value="main"), \
mock.patch("urllib.request.urlopen", side_effect=mock_urlopen):
original_cwd = os.getcwd()
os.chdir(str(tmp_path))
try:
cmd_cursorrules(["--path", str(cursorrules)])
finally:
os.chdir(original_cwd)
assert len(captured_requests) >= 1
req_body = captured_requests[0]
# app_id must be top-level (not inside metadata)
assert req_body["app_id"] == "my-project", f"Expected app_id at top level, got: {req_body}"
# infer must be False (not "false", but the boolean False)
assert req_body["infer"] is False, f"Expected infer=False, got: {req_body['infer']}"
# source must be cursor-import
assert req_body["metadata"]["source"] == "cursor-import", (
f"Expected source=cursor-import, got: {req_body['metadata'].get('source')}"
)
# user_id must be set
assert req_body["user_id"] == "testuser"
# messages must be a list with role/content
assert isinstance(req_body["messages"], list)
assert req_body["messages"][0]["role"] == "user"
assert len(req_body["messages"][0]["content"]) > 0
def test_copilot_import_api_call(tmp_path):
"""cmd_copilot calls the API with source=copilot-import."""
from import_competing_tools import cmd_copilot
copilot_dir = tmp_path / ".github"
copilot_dir.mkdir()
copilot_file = copilot_dir / "copilot-instructions.md"
copilot_file.write_text(
"## Code Style\n\nUse 2-space indentation. Always add trailing commas in multi-line structures. "
"Prefer const over let. Never use var in JavaScript code."
)
captured: list[dict] = []
def mock_urlopen(req, timeout=None):
captured.append(json.loads(req.data.decode()))
return _make_mock_response(201)
with mock.patch("import_competing_tools.resolve_api_key", return_value="m0-key"), \
mock.patch("import_competing_tools.resolve_user_id", return_value="user1"), \
mock.patch("import_competing_tools.resolve_project_id", return_value="proj1"), \
mock.patch("import_competing_tools.resolve_branch", return_value="main"), \
mock.patch("urllib.request.urlopen", side_effect=mock_urlopen):
cmd_copilot(["--path", str(copilot_file)])
assert len(captured) >= 1
assert captured[0]["metadata"]["source"] == "copilot-import"
assert captured[0]["app_id"] == "proj1"
assert captured[0]["infer"] is False
def test_cline_import_multiple_files(tmp_path):
"""cmd_cline imports one memory per non-empty .md file."""
from import_competing_tools import cmd_cline
mb = tmp_path / "memory-bank"
mb.mkdir()
(mb / "arch.md").write_text(
"Architecture: microservices with event-sourcing. Each service owns its database. "
"Communication via message bus only. No direct service-to-service HTTP calls."
)
(mb / "style.md").write_text(
"Code style: PEP 8 for Python. Black formatter. isort for imports. "
"Line length 120. Type hints required on all public functions and methods."
)
(mb / "empty.md").write_text("")
captured: list[dict] = []
def mock_urlopen(req, timeout=None):
captured.append(json.loads(req.data.decode()))
return _make_mock_response(201)
with mock.patch("import_competing_tools.resolve_api_key", return_value="m0-key"), \
mock.patch("import_competing_tools.resolve_user_id", return_value="user1"), \
mock.patch("import_competing_tools.resolve_project_id", return_value="proj1"), \
mock.patch("import_competing_tools.resolve_branch", return_value="main"), \
mock.patch("urllib.request.urlopen", side_effect=mock_urlopen):
cmd_cline(["--path", str(mb)])
# Should have exactly 2 imports (empty.md skipped)
assert len(captured) == 2
sources = {r["metadata"]["source"] for r in captured}
assert sources == {"cline-import"}
for r in captured:
assert r["infer"] is False
assert r["app_id"] == "proj1"
def test_no_api_key_does_not_call_api(tmp_path):
"""When no API key is set, no HTTP call is made."""
from import_competing_tools import cmd_cursorrules
cursorrules = tmp_path / ".cursorrules"
cursorrules.write_text("## Rules\n\n" + "x" * 100)
with mock.patch("import_competing_tools.resolve_api_key", return_value=""), \
mock.patch("urllib.request.urlopen") as mock_url:
cmd_cursorrules(["--path", str(cursorrules)])
mock_url.assert_not_called()
def test_missing_file_does_not_call_api(tmp_path):
"""When the source file doesn't exist, no HTTP call is made."""
from import_competing_tools import cmd_cursorrules
with mock.patch("import_competing_tools.resolve_api_key", return_value="m0-key"), \
mock.patch("urllib.request.urlopen") as mock_url:
cmd_cursorrules(["--path", str(tmp_path / "nonexistent.cursorrules")])
mock_url.assert_not_called()
def test_main_unknown_subcommand_exits_zero():
"""Calling main() with an unknown subcommand exits 0."""
import subprocess
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "import_competing_tools.py"), "unknown"],
capture_output=True,
text=True,
env={**os.environ, "MEM0_API_KEY": ""},
)
assert result.returncode == 0
+298
View File
@@ -0,0 +1,298 @@
"""Tests for parse_export_file.py — mem0 export file parser."""
from __future__ import annotations
import json
import os
import subprocess
import sys
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
# ---------------------------------------------------------------------------
# Direct function tests
# ---------------------------------------------------------------------------
def test_parse_blocks_single_valid_block():
"""parse_blocks returns one record for a single valid block."""
from parse_export_file import parse_blocks
content = """\
---
id: abc123
created_at: 2024-01-15T10:00:00Z
type: task_learnings
confidence: 0.85
branch: main
files: src/foo.py, src/bar.py
categories: coding_conventions, task_learnings
---
Always use context managers when opening files.
"""
records = parse_blocks(content)
assert len(records) == 1
r = records[0]
assert r["id"] == "abc123"
assert r["type"] == "task_learnings"
assert r["confidence"] == "0.85"
assert r["branch"] == "main"
assert r["files"] == ["src/foo.py", "src/bar.py"]
assert r["categories"] == ["coding_conventions", "task_learnings"]
assert "Always use context managers" in r["content"]
def test_parse_blocks_multiple_blocks():
"""parse_blocks returns the correct number of records for multiple blocks."""
from parse_export_file import parse_blocks
content = """\
---
id: mem001
type: architecture_decisions
confidence: 0.9
branch: main
files:
categories: architecture_decisions
---
Use hexagonal architecture for the core domain.
---
id: mem002
type: anti_patterns
confidence: 0.75
branch: feat/refactor
files: src/legacy.py
categories: anti_patterns
---
Avoid direct database calls from view layer.
---
id: mem003
type: coding_conventions
confidence: 0.8
branch: main
files: src/utils.py, src/helpers.py
categories:
---
Use snake_case for all Python identifiers.
"""
records = parse_blocks(content)
assert len(records) == 3
assert records[0]["id"] == "mem001"
assert records[0]["categories"] == ["architecture_decisions"]
assert "hexagonal architecture" in records[0]["content"]
assert records[1]["id"] == "mem002"
assert records[1]["files"] == ["src/legacy.py"]
assert "direct database calls" in records[1]["content"]
assert records[2]["id"] == "mem003"
assert records[2]["files"] == ["src/utils.py", "src/helpers.py"]
assert records[2]["categories"] == []
assert "snake_case" in records[2]["content"]
def test_parse_blocks_missing_optional_fields():
"""parse_blocks uses defaults when optional fields are absent."""
from parse_export_file import parse_blocks
# confidence, branch, files, categories all absent
content = """\
---
id: xyz789
type: task_learnings
---
Run tests before committing.
"""
records = parse_blocks(content)
assert len(records) == 1
r = records[0]
assert r["id"] == "xyz789"
assert r["confidence"] == "" # default empty string
assert r["branch"] == "" # default empty string
assert r["files"] == [] # default empty list
assert r["categories"] == [] # default empty list
assert "Run tests" in r["content"]
def test_parse_blocks_filters_empty_content():
"""parse_blocks skips blocks whose content is empty or whitespace-only."""
from parse_export_file import parse_blocks
content = """\
---
id: empty1
type: task_learnings
---
---
id: real1
type: task_learnings
---
This block has real content.
---
id: empty2
type: coding_conventions
---
"""
records = parse_blocks(content)
# Only the block with actual content should be returned
assert len(records) == 1
assert records[0]["id"] == "real1"
assert "real content" in records[0]["content"]
def test_parse_blocks_round_trip():
"""Content formatted by export matches what parse_blocks expects."""
from parse_export_file import parse_blocks
# Simulate the exact format produced by the export skill
memory_id = "test-id-001"
created_at = "2024-06-01T12:00:00Z"
mem_type = "architecture_decisions"
confidence = "0.92"
branch = "feat/new-feature"
files = ["src/main.py", "tests/test_main.py"]
categories = ["architecture_decisions", "coding_conventions"]
memory_content = "Use dependency injection for all service classes."
# Format exactly as the export skill would
block = (
"---\n"
f"id: {memory_id}\n"
f"created_at: {created_at}\n"
f"type: {mem_type}\n"
f"confidence: {confidence}\n"
f"branch: {branch}\n"
f"files: {', '.join(files)}\n"
f"categories: {', '.join(categories)}\n"
"---\n"
f"{memory_content}\n"
"\n"
)
records = parse_blocks(block)
assert len(records) == 1
r = records[0]
assert r["id"] == memory_id
assert r["type"] == mem_type
assert r["confidence"] == confidence
assert r["branch"] == branch
assert r["files"] == files
assert r["categories"] == categories
assert r["content"] == memory_content
def test_parse_blocks_multiline_content():
"""parse_blocks correctly captures multi-line memory content."""
from parse_export_file import parse_blocks
content = """\
---
id: multi001
type: task_learnings
---
Line one of the memory.
Line two of the memory.
Line four after blank line.
"""
records = parse_blocks(content)
assert len(records) == 1
assert "Line one" in records[0]["content"]
assert "Line two" in records[0]["content"]
assert "Line four" in records[0]["content"]
def test_parse_blocks_empty_input():
"""parse_blocks returns empty list for empty input."""
from parse_export_file import parse_blocks
assert parse_blocks("") == []
assert parse_blocks(" \n ") == []
def test_parse_blocks_no_blocks():
"""parse_blocks returns empty list for content without any --- delimiters."""
from parse_export_file import parse_blocks
assert parse_blocks("Just some text without any delimiters.") == []
def test_parse_blocks_value_with_colon():
"""parse_blocks handles values that themselves contain colons."""
from parse_export_file import parse_blocks
content = """\
---
id: colon-test
type: task_learnings
created_at: 2024-01-01T10:00:00Z
---
Timestamp values contain colons and should parse correctly.
"""
records = parse_blocks(content)
assert len(records) == 1
assert records[0]["id"] == "colon-test"
# created_at field should be captured (it's in the record if present)
assert "2024-01-01T10:00:00Z" in records[0].get("created_at", "")
# ---------------------------------------------------------------------------
# CLI / subprocess tests
# ---------------------------------------------------------------------------
def test_main_cli_outputs_json(tmp_path):
"""Running parse_export_file.py as a script outputs valid JSON."""
export_file = tmp_path / "mem0-export-test.md"
export_file.write_text("""\
---
id: cli-test-001
type: task_learnings
confidence: 0.8
branch: main
files:
categories: task_learnings
---
Prefer composition over inheritance.
""")
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_export_file.py"), str(export_file)],
capture_output=True,
text=True,
)
assert result.returncode == 0
records = json.loads(result.stdout)
assert isinstance(records, list)
assert len(records) == 1
assert records[0]["id"] == "cli-test-001"
def test_main_cli_no_args_exits_zero():
"""Running parse_export_file.py with no arguments exits 0 and prints []."""
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_export_file.py")],
capture_output=True,
text=True,
)
assert result.returncode == 0
assert result.stdout.strip() == "[]"
def test_main_cli_missing_file_exits_zero(tmp_path):
"""Running parse_export_file.py with a missing file exits 0 and prints []."""
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_export_file.py"),
str(tmp_path / "nonexistent.md")],
capture_output=True,
text=True,
)
assert result.returncode == 0
assert result.stdout.strip() == "[]"
+424
View File
@@ -0,0 +1,424 @@
"""Tests for parse_mem0_config.py — mem0.md retention policy parser."""
from __future__ import annotations
import json
import os
import subprocess
import sys
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
# ---------------------------------------------------------------------------
# parse_retention — unit tests
# ---------------------------------------------------------------------------
def test_parse_retention_valid_section():
"""parse_retention extracts day-count policies correctly."""
from parse_mem0_config import parse_retention
content = """\
# Project Config
## Retention
session_state: 90d
compact_summary: 60d
decision: 180d
"""
result = parse_retention(content)
assert result == {
"session_state": 90,
"compact_summary": 60,
"decision": 180,
}
def test_parse_retention_forever_returns_none():
"""parse_retention maps 'forever' to None."""
from parse_mem0_config import parse_retention
content = """\
## Retention
user_preference: forever
anti_pattern: forever
session_state: 30d
"""
result = parse_retention(content)
assert result["user_preference"] is None
assert result["anti_pattern"] is None
assert result["session_state"] == 30
def test_parse_retention_no_section_returns_empty():
"""parse_retention returns {} when there is no ## Retention heading."""
from parse_mem0_config import parse_retention
content = """\
# Project Config
## Some Other Section
key: value
"""
result = parse_retention(content)
assert result == {}
def test_parse_retention_stops_at_next_heading():
"""parse_retention stops reading at the next ## heading."""
from parse_mem0_config import parse_retention
content = """\
## Retention
session_state: 7d
## Other Section
other_key: 999d
"""
result = parse_retention(content)
assert "session_state" in result
assert "other_key" not in result
def test_parse_retention_malformed_lines_skipped():
"""Malformed lines (no colon, bad day format) are silently ignored."""
from parse_mem0_config import parse_retention
content = """\
## Retention
session_state: 90d
bad_line_no_colon
another: badvalue
decision: 30d
"""
result = parse_retention(content)
assert result == {"session_state": 90, "decision": 30}
def test_parse_retention_comments_ignored():
"""Inline # comments are stripped before parsing."""
from parse_mem0_config import parse_retention
content = """\
## Retention
session_state: 90d # rolling 90-day window
user_preference: forever # never prune preferences
"""
result = parse_retention(content)
assert result["session_state"] == 90
assert result["user_preference"] is None
def test_parse_retention_case_insensitive_heading():
"""## retention (lowercase) is matched the same as ## Retention."""
from parse_mem0_config import parse_retention
content = """\
## retention
session_state: 14d
"""
result = parse_retention(content)
assert result == {"session_state": 14}
def test_parse_retention_empty_section_returns_empty():
"""A ## Retention section with no valid lines returns {}."""
from parse_mem0_config import parse_retention
content = """\
## Retention
# only comments here
## Next Section
"""
result = parse_retention(content)
assert result == {}
# ---------------------------------------------------------------------------
# load_retention_policies — integration tests with tmp files
# ---------------------------------------------------------------------------
def test_load_retention_policies_with_tmp_file(tmp_path):
"""load_retention_policies reads a real mem0.md from disk."""
from parse_mem0_config import load_retention_policies
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text(
"""\
# My Project
## Retention
session_state: 90d
compact_summary: 60d
decision: forever
""",
encoding="utf-8",
)
result = load_retention_policies(str(tmp_path))
assert result == {
"session_state": 90,
"compact_summary": 60,
"decision": None,
}
def test_load_retention_policies_no_mem0_md_returns_empty(tmp_path):
"""load_retention_policies returns {} when no mem0.md exists."""
from parse_mem0_config import load_retention_policies
result = load_retention_policies(str(tmp_path))
assert result == {}
def test_load_retention_policies_no_retention_section_returns_empty(tmp_path):
"""load_retention_policies returns {} when mem0.md has no ## Retention."""
from parse_mem0_config import load_retention_policies
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text(
"""\
# My Project
Some general project notes here.
No retention section.
""",
encoding="utf-8",
)
result = load_retention_policies(str(tmp_path))
assert result == {}
def test_load_retention_policies_defaults_to_cwd(tmp_path, monkeypatch):
"""load_retention_policies uses os.getcwd() when cwd is None."""
from parse_mem0_config import load_retention_policies
monkeypatch.chdir(tmp_path)
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text("## Retention\nsession_state: 45d\n", encoding="utf-8")
result = load_retention_policies() # no cwd arg
assert result == {"session_state": 45}
# ---------------------------------------------------------------------------
# CLI / main() — subprocess test
# ---------------------------------------------------------------------------
def test_cli_main_prints_json(tmp_path):
"""CLI: python parse_mem0_config.py <cwd> prints valid JSON."""
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text(
"## Retention\nsession_state: 90d\nuser_preference: forever\n",
encoding="utf-8",
)
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_mem0_config.py"), str(tmp_path)],
capture_output=True,
text=True,
)
assert result.returncode == 0
data = json.loads(result.stdout)
assert data["session_state"] == 90
assert data["user_preference"] is None
def test_cli_main_no_file_prints_empty_json(tmp_path):
"""CLI: prints '{}' when no mem0.md exists."""
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_mem0_config.py"), str(tmp_path)],
capture_output=True,
text=True,
)
assert result.returncode == 0
assert json.loads(result.stdout) == {}
# ---------------------------------------------------------------------------
# parse_section_kv — unit tests
# ---------------------------------------------------------------------------
def test_parse_section_kv_basic():
"""parse_section_kv extracts key-value pairs from a named section."""
from parse_mem0_config import parse_section_kv
content = """\
## Search
default_limit: 10
boost_recency: true
"""
result = parse_section_kv(content, "Search")
assert result == {"default_limit": "10", "boost_recency": "true"}
def test_parse_section_kv_missing_section():
"""parse_section_kv returns {} when section doesn't exist."""
from parse_mem0_config import parse_section_kv
result = parse_section_kv("## Other\nfoo: bar\n", "Search")
assert result == {}
def test_parse_section_kv_stops_at_next_heading():
"""parse_section_kv stops at the next ## heading."""
from parse_mem0_config import parse_section_kv
content = """\
## Identity
user_id: kartik
project_id: mem0
## Other
ignored: yes
"""
result = parse_section_kv(content, "Identity")
assert result == {"user_id": "kartik", "project_id": "mem0"}
assert "ignored" not in result
# ---------------------------------------------------------------------------
# parse_section_list — unit tests
# ---------------------------------------------------------------------------
def test_parse_section_list_basic():
"""parse_section_list extracts list items from a named section."""
from parse_mem0_config import parse_section_list
content = """\
## Categories
- architecture_decisions
- bug_fixes
- coding_conventions
"""
result = parse_section_list(content, "Categories")
assert result == ["architecture_decisions", "bug_fixes", "coding_conventions"]
def test_parse_section_list_bare_lines():
"""parse_section_list works with bare lines (no bullet prefix)."""
from parse_mem0_config import parse_section_list
content = """\
## Categories
architecture_decisions
bug_fixes
"""
result = parse_section_list(content, "Categories")
assert result == ["architecture_decisions", "bug_fixes"]
def test_parse_section_list_missing_section():
"""parse_section_list returns [] when section doesn't exist."""
from parse_mem0_config import parse_section_list
result = parse_section_list("## Other\n- foo\n", "Categories")
assert result == []
# ---------------------------------------------------------------------------
# load_full_config — integration tests
# ---------------------------------------------------------------------------
def test_load_full_config_all_sections(tmp_path):
"""load_full_config extracts all sections from mem0.md."""
from parse_mem0_config import load_full_config
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text(
"""\
# My Project
## Retention
session_state: 90d
decision: forever
## Search
default_limit: 20
boost_recency: true
## Categories
- architecture_decisions
- bug_fixes
- security_constraints
## Identity
user_id: kartik
project_id: my-project
""",
encoding="utf-8",
)
config = load_full_config(str(tmp_path))
assert config["retention"] == {"session_state": 90, "decision": None}
assert config["search"] == {"default_limit": "20", "boost_recency": "true"}
assert config["categories"] == ["architecture_decisions", "bug_fixes", "security_constraints"]
assert config["identity"] == {"user_id": "kartik", "project_id": "my-project"}
def test_load_full_config_partial_sections(tmp_path):
"""load_full_config only includes sections that exist."""
from parse_mem0_config import load_full_config
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text("## Retention\nsession_state: 30d\n", encoding="utf-8")
config = load_full_config(str(tmp_path))
assert "retention" in config
assert "search" not in config
assert "categories" not in config
assert "identity" not in config
def test_load_full_config_no_file(tmp_path):
"""load_full_config returns {} when no mem0.md exists."""
from parse_mem0_config import load_full_config
config = load_full_config(str(tmp_path))
assert config == {}
def test_cli_full_flag(tmp_path):
"""CLI: --full prints all sections as JSON."""
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text(
"## Retention\nsession_state: 90d\n\n## Search\nlimit: 10\n",
encoding="utf-8",
)
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_mem0_config.py"), "--full", str(tmp_path)],
capture_output=True,
text=True,
)
assert result.returncode == 0
data = json.loads(result.stdout)
assert "retention" in data
assert "search" in data
+54
View File
@@ -0,0 +1,54 @@
"""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
View File
@@ -141,3 +141,108 @@ def test_cli_report_no_data(tmp_path):
env=env, env=env,
) )
assert result.returncode == 0 assert result.returncode == 0
def test_peek_returns_json_without_clearing(_isolate_stats_file):
"""peek returns JSON stats without deleting the stats file."""
import session_stats
session_stats.init()
session_stats.record_add("decisions")
session_stats.record_add("decisions")
session_stats.record_search()
result = session_stats.peek()
data = json.loads(result)
assert data["adds"] == 2
assert data["searches"] == 1
assert os.path.isfile(_isolate_stats_file)
def test_category_counts_tracked(_isolate_stats_file):
"""category_counts tracks per-category add counts."""
import session_stats
session_stats.init()
session_stats.record_add("bug_fixes")
session_stats.record_add("bug_fixes")
session_stats.record_add("bug_fixes")
session_stats.record_add("decisions")
with open(_isolate_stats_file) as f:
data = json.load(f)
assert data["category_counts"]["bug_fixes"] == 3
assert data["category_counts"]["decisions"] == 1
def test_category_counts_empty_category_not_tracked(_isolate_stats_file):
"""Empty category string doesn't appear in category_counts."""
import session_stats
session_stats.init()
session_stats.record_add("")
session_stats.record_add()
with open(_isolate_stats_file) as f:
data = json.load(f)
assert data["category_counts"] == {}
def test_recent_ids_tracked(_isolate_stats_file):
"""record_add with memory_id stores ID in recent_ids."""
import session_stats
session_stats.init()
session_stats.record_add("decision", "abc-123")
session_stats.record_add("convention", "def-456")
with open(_isolate_stats_file) as f:
data = json.load(f)
assert len(data["recent_ids"]) == 2
assert data["recent_ids"][0]["id"] == "abc-123"
assert data["recent_ids"][1]["id"] == "def-456"
assert data["recent_ids"][0]["category"] == "decision"
def test_recent_ids_capped(_isolate_stats_file):
"""recent_ids list is capped at MAX_RECENT_IDS."""
import session_stats
session_stats.init()
for i in range(60):
session_stats.record_add("test", f"id-{i}")
with open(_isolate_stats_file) as f:
data = json.load(f)
assert len(data["recent_ids"]) == session_stats.MAX_RECENT_IDS
assert data["recent_ids"][0]["id"] == f"id-{60 - session_stats.MAX_RECENT_IDS}"
def test_recent_ids_empty_without_memory_id(_isolate_stats_file):
"""record_add without memory_id doesn't add to recent_ids."""
import session_stats
session_stats.init()
session_stats.record_add("decision")
session_stats.record_add("convention", "")
with open(_isolate_stats_file) as f:
data = json.load(f)
assert data["recent_ids"] == []
def test_cli_peek(tmp_path):
"""Test CLI invocation: session_stats.py peek outputs JSON."""
env = {**os.environ, "USER": "test"}
subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "session_stats.py"), "init"],
capture_output=True, text=True, env=env,
)
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "session_stats.py"), "peek"],
capture_output=True, text=True, env=env,
)
assert result.returncode == 0
data = json.loads(result.stdout)
assert "adds" in data
+163
View File
@@ -0,0 +1,163 @@
"""Tests for telemetry.py — fire-and-forget PostHog plugin telemetry."""
from __future__ import annotations
import json
import os
import sys
import urllib.error
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
sys.path.insert(0, os.path.abspath(SCRIPTS_DIR))
def test_import_succeeds():
import telemetry
assert hasattr(telemetry, "emit")
assert hasattr(telemetry, "main")
def test_opt_out_skips_send(monkeypatch):
import telemetry
monkeypatch.setenv("MEM0_TELEMETRY", "false")
sent = []
monkeypatch.setattr(telemetry, "send", lambda p: sent.append(p))
telemetry.emit("session_start")
assert sent == []
def test_opt_out_variants(monkeypatch):
import telemetry
for val in ("0", "no", "off", "FALSE", "No"):
monkeypatch.setenv("MEM0_TELEMETRY", val)
assert not telemetry.is_enabled()
def test_enabled_by_default(monkeypatch):
import telemetry
monkeypatch.delenv("MEM0_TELEMETRY", raising=False)
assert telemetry.is_enabled()
def test_posthog_payload_structure(monkeypatch):
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)
payload = telemetry.build_posthog_payload("plugin.session_start", {"memory_count": 5})
assert payload["api_key"] == telemetry.POSTHOG_API_KEY
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 payload["properties"]["memory_count"] == 5
assert payload["properties"]["$process_person_profile"] is False
raw = json.dumps(payload)
assert "testuser" not in raw
assert "test-project" not in raw
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()
assert telemetry._distinct_id() == expected
def test_distinct_id_fallback_no_key(monkeypatch):
import telemetry
monkeypatch.delenv("MEM0_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
monkeypatch.setenv("MEM0_RESOLVED_USER_ID", "kartik")
assert telemetry._distinct_id() == telemetry._sha256("kartik")
def test_hash_deterministic():
import telemetry
h1 = telemetry._sha256("same-value")
h2 = telemetry._sha256("same-value")
assert h1 == h2
assert h1 != telemetry._sha256("different-value")
def test_platform_claude_code(monkeypatch):
import telemetry
monkeypatch.setenv("CLAUDECODE", "1")
monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False)
monkeypatch.delenv("CODEX_PLUGIN_ROOT", raising=False)
assert telemetry.detect_platform() == "claude-code"
def test_platform_cursor(monkeypatch):
import telemetry
monkeypatch.delenv("CLAUDECODE", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/path")
monkeypatch.delenv("CODEX_PLUGIN_ROOT", raising=False)
assert telemetry.detect_platform() == "cursor"
def test_platform_codex(monkeypatch):
import telemetry
monkeypatch.delenv("CLAUDECODE", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False)
monkeypatch.setenv("CODEX_PLUGIN_ROOT", "/path")
assert telemetry.detect_platform() == "codex"
def test_sampling_drops_at_high_random(monkeypatch):
import telemetry
monkeypatch.setattr(telemetry.random, "random", lambda: 0.5)
assert telemetry._should_sample() is False
def test_sampling_sends_at_low_random(monkeypatch):
import telemetry
monkeypatch.setattr(telemetry.random, "random", lambda: 0.05)
assert telemetry._should_sample() is True
def test_send_fails_silently(monkeypatch):
import telemetry
def raise_error(req, timeout):
raise urllib.error.URLError("connection refused")
monkeypatch.setattr(telemetry.urllib.request, "urlopen", raise_error)
telemetry.send({"event": "test"})
def test_cli_exits_zero_when_disabled(monkeypatch):
import telemetry
monkeypatch.setenv("MEM0_TELEMETRY", "false")
monkeypatch.setattr(sys, "argv", ["telemetry.py", "session_start"])
assert telemetry.main() == 0
def test_cli_no_args_exits_nonzero(monkeypatch):
import telemetry
monkeypatch.delenv("MEM0_TELEMETRY", raising=False)
monkeypatch.setattr(sys, "argv", ["telemetry.py"])
assert telemetry.main() == 1
+194
View File
@@ -0,0 +1,194 @@
"""Tests for write-path app_id migration and API key resolution.
Verifies that all scripts writing to the Mem0 API:
1. Pass app_id as a top-level parameter (not in metadata)
2. Do NOT include project_id in metadata
3. Include branch in metadata when available
4. Use resolve_api_key() for key resolution with userConfig fallback
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
def test_auto_import_post_memory_uses_app_id():
"""auto_import.post_memory sends app_id top-level, not metadata.project_id."""
from auto_import import post_memory
captured = {}
def mock_urlopen(req, timeout=None):
body = json.loads(req.data.decode("utf-8"))
captured.update(body)
resp = MagicMock()
resp.status = 200
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
result = post_memory(
api_key="test-key",
content="test content",
user_id="testuser",
filename="CLAUDE.md",
project_id="my-project",
branch="main",
)
assert result is True
assert captured["app_id"] == "my-project"
assert captured["user_id"] == "testuser"
assert "project_id" not in captured.get("metadata", {})
assert captured["metadata"]["type"] == "project_profile"
assert captured["metadata"]["branch"] == "main"
assert captured["infer"] is False
def test_auto_import_post_memory_omits_empty_branch():
"""auto_import.post_memory skips branch in metadata when empty."""
from auto_import import post_memory
captured = {}
def mock_urlopen(req, timeout=None):
body = json.loads(req.data.decode("utf-8"))
captured.update(body)
resp = MagicMock()
resp.status = 200
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
post_memory("key", "content", "user", "FILE.md", "proj", branch="")
assert "branch" not in captured.get("metadata", {})
def test_on_pre_compact_store_memory_uses_app_id():
"""on_pre_compact.store_memory sends app_id top-level."""
from on_pre_compact import store_memory
captured = {}
def mock_urlopen(req, timeout=None):
body = json.loads(req.data.decode("utf-8"))
captured.update(body)
resp = MagicMock()
resp.status = 200
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
result = store_memory(
api_key="test-key",
content="session state content",
user_id="testuser",
source="pre-compaction",
session_id="sess-123",
project_id="my-project",
branch="feat/auth",
)
assert result is True
assert captured["app_id"] == "my-project"
assert captured["user_id"] == "testuser"
assert "project_id" not in captured.get("metadata", {})
assert captured["metadata"]["type"] == "session_state"
assert captured["metadata"]["source"] == "pre-compaction"
assert captured["metadata"]["branch"] == "feat/auth"
assert "expiration_date" in captured
def test_capture_compact_summary_store_uses_app_id():
"""capture_compact_summary.store_summary sends app_id top-level."""
from capture_compact_summary import store_summary
captured = {}
def mock_urlopen(req, timeout=None):
body = json.loads(req.data.decode("utf-8"))
captured.update(body)
resp = MagicMock()
resp.status = 200
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
result = store_summary(
api_key="test-key",
summary="compact summary text",
user_id="testuser",
session_id="sess-456",
project_id="my-project",
branch="main",
)
assert result is True
assert captured["app_id"] == "my-project"
assert captured["user_id"] == "testuser"
assert "project_id" not in captured.get("metadata", {})
assert captured["metadata"]["type"] == "compact_summary"
assert captured["metadata"]["branch"] == "main"
assert captured["infer"] is False
assert "expiration_date" in captured
def test_no_metadata_project_id_anywhere():
"""Ensure none of the write functions put project_id in metadata."""
from auto_import import post_memory
from capture_compact_summary import store_summary
from on_pre_compact import store_memory
bodies = []
def mock_urlopen(req, timeout=None):
body = json.loads(req.data.decode("utf-8"))
bodies.append(body)
resp = MagicMock()
resp.status = 200
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
post_memory("k", "c", "u", "f", "proj", "br")
store_memory("k", "c", "u", "src", "sid", "proj", "br")
store_summary("k", "s", "u", "sid", "proj", "br")
for i, body in enumerate(bodies):
metadata = body.get("metadata", {})
assert "project_id" not in metadata, f"Write function #{i} still has metadata.project_id"
assert body.get("app_id") == "proj", f"Write function #{i} missing app_id top-level"
def test_resolve_api_key_prefers_env_var(monkeypatch):
"""resolve_api_key returns MEM0_API_KEY when both are set."""
from _identity import resolve_api_key
monkeypatch.setenv("MEM0_API_KEY", "direct-key")
monkeypatch.setenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", "fallback-key")
assert resolve_api_key() == "direct-key"
def test_resolve_api_key_falls_back_to_plugin_option(monkeypatch):
"""resolve_api_key falls back to CLAUDE_PLUGIN_OPTION_MEM0_API_KEY."""
from _identity import resolve_api_key
monkeypatch.delenv("MEM0_API_KEY", raising=False)
monkeypatch.setenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", "fallback-key")
assert resolve_api_key() == "fallback-key"
def test_resolve_api_key_returns_empty_when_neither_set(monkeypatch):
"""resolve_api_key returns empty string when no key is available."""
from _identity import resolve_api_key
monkeypatch.delenv("MEM0_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
assert resolve_api_key() == ""