diff --git a/mem0-plugin/.claude-plugin/plugin.json b/mem0-plugin/.claude-plugin/plugin.json index a91dd5d30..d82722125 100644 --- a/mem0-plugin/.claude-plugin/plugin.json +++ b/mem0-plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.1.3", + "version": "0.2.0", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows using the Mem0 Platform MCP server.", "author": { "name": "Mem0", diff --git a/mem0-plugin/.codex-plugin/plugin.json b/mem0-plugin/.codex-plugin/plugin.json index 7cd1e89a3..89d01c3be 100644 --- a/mem0-plugin/.codex-plugin/plugin.json +++ b/mem0-plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.1.1", + "version": "0.2.0", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Codex workflows using the Mem0 Platform MCP server.", "author": { "name": "Mem0", diff --git a/mem0-plugin/.cursor-plugin/plugin.json b/mem0-plugin/.cursor-plugin/plugin.json index 5c0cefd9e..153b73786 100644 --- a/mem0-plugin/.cursor-plugin/plugin.json +++ b/mem0-plugin/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.1.1", + "version": "0.2.0", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.", "author": { "name": "Mem0", diff --git a/mem0-plugin/CHANGELOG.md b/mem0-plugin/CHANGELOG.md index 6690a1b14..d6f437a90 100644 --- a/mem0-plugin/CHANGELOG.md +++ b/mem0-plugin/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to the Mem0 plugin will be documented in this file. +## 0.2.0 + +### Added + +- **Project-scoped memories:** Deterministic `project_id` from git remote (`_project.sh` / `_project.py`). Memories are now isolated per-repo via `metadata.project_id` on every `add_memory` and `search_memories` call. Same repo cloned twice → same `project_id`. +- **Branch-aware tagging:** `metadata.branch` stamped on session-state and compact-summary memories. Enables branch-scoped recall (e.g. "what was I doing on feature/auth-rewrite?"). +- **Auto-import of project files:** SessionStart detects CLAUDE.md, AGENTS.md, .cursorrules, .windsurfrules, mem0.md — hashes them (SHA-256), imports changed files as `project_profile` memories. Idempotent across sessions. +- **Active identity banner:** SessionStart now prints `user=X | project=Y | branch=Z | memories=N` instead of a silent bootstrap. +- **Session-end report:** Stop hook prints `Session: wrote N memories, retrieved M. Categories touched: ...` and appends to `~/.mem0/session-log.md`. +- **`/mem0:onboard` skill:** Post-install wizard — verifies API key, detects and imports project files, installs coding categories, prints setup summary. 30 seconds to value. +- **`/mem0:tour` skill:** Shows all memories for the current project grouped by category. Proof-of-value demo. +- **`/mem0:switch-project` skill:** Manual `project_id` override for monorepos and non-git directories. Persists to `~/.mem0/project_map.json`. +- **Session stats tracker** (`session_stats.py`): Tracks memory adds/searches per session for the end-of-session report. + +### Changed + +- All hooks and `mem0-mcp/SKILL.md` now include `project_id` in every filter and metadata example. +- SessionStart banner replaces the previous "## Mem0 Identity" block with a compact one-liner. +- `on_pre_compact.py` and `capture_compact_summary.py` now include `project_id` and `branch` in stored metadata. + ## 0.1.3 ### Fixed diff --git a/mem0-plugin/hooks/codex-hooks.json b/mem0-plugin/hooks/codex-hooks.json index 23e6dbd32..6676d32f3 100644 --- a/mem0-plugin/hooks/codex-hooks.json +++ b/mem0-plugin/hooks/codex-hooks.json @@ -24,6 +24,18 @@ ] } ], + "PostToolUse": [ + { + "matcher": "mcp__mem0__", + "hooks": [ + { + "type": "command", + "command": "${CODEX_PLUGIN_ROOT}/scripts/on_post_tool_use.sh", + "timeout": 3 + } + ] + } + ], "Stop": [ { "hooks": [ diff --git a/mem0-plugin/hooks/cursor-hooks.json b/mem0-plugin/hooks/cursor-hooks.json index 124148bea..0ec8f68ad 100644 --- a/mem0-plugin/hooks/cursor-hooks.json +++ b/mem0-plugin/hooks/cursor-hooks.json @@ -2,30 +2,37 @@ "hooks": { "sessionStart": [ { - "command": "${CURSOR_PLUGIN_ROOT}/scripts/on_session_start.sh", + "command": "${CURSOR_PLUGIN_ROOT}/scripts/on_session_start_cursor.sh", "matcher": "startup|resume|compact" } ], "preToolUse": [ { - "command": "${CURSOR_PLUGIN_ROOT}/scripts/block_memory_write.sh", + "command": "${CURSOR_PLUGIN_ROOT}/scripts/block_memory_write_cursor.sh", "matcher": "Write|Edit" } ], + "postToolUse": [ + { + "command": "${CURSOR_PLUGIN_ROOT}/scripts/on_post_tool_use_cursor.sh", + "matcher": "mcp__mem0__", + "timeout": 3 + } + ], "preCompact": [ { - "command": "${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact.sh" + "command": "${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact_cursor.sh" } ], "stop": [ { - "command": "${CURSOR_PLUGIN_ROOT}/scripts/on_stop.sh", + "command": "${CURSOR_PLUGIN_ROOT}/scripts/on_stop_cursor.sh", "timeout": 10 } ], "beforeSubmitPrompt": [ { - "command": "${CURSOR_PLUGIN_ROOT}/scripts/on_user_prompt.sh", + "command": "${CURSOR_PLUGIN_ROOT}/scripts/on_user_prompt_cursor.sh", "timeout": 5 } ] diff --git a/mem0-plugin/hooks/hooks.json b/mem0-plugin/hooks/hooks.json index 2c98eb9d3..5f8fad83c 100644 --- a/mem0-plugin/hooks/hooks.json +++ b/mem0-plugin/hooks/hooks.json @@ -23,6 +23,18 @@ ] } ], + "PostToolUse": [ + { + "matcher": "mcp__mem0__", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_post_tool_use.sh", + "timeout": 3 + } + ] + } + ], "PreCompact": [ { "hooks": [ diff --git a/mem0-plugin/scripts/_identity.py b/mem0-plugin/scripts/_identity.py index a80f4e058..e2e88fe42 100644 --- a/mem0-plugin/scripts/_identity.py +++ b/mem0-plugin/scripts/_identity.py @@ -15,3 +15,16 @@ def resolve_user_id() -> str: if explicit: return explicit return os.environ.get("USER") or "default" + + +try: + from _project import resolve_branch, resolve_project_id, save_project_mapping +except ImportError: + def resolve_project_id() -> str: + return os.path.basename(os.getcwd()) + + def resolve_branch() -> str: + return "unknown" + + def save_project_mapping(cwd: str, project_id: str) -> None: + pass diff --git a/mem0-plugin/scripts/_identity.sh b/mem0-plugin/scripts/_identity.sh index cf4ae756c..e032d2bec 100644 --- a/mem0-plugin/scripts/_identity.sh +++ b/mem0-plugin/scripts/_identity.sh @@ -14,3 +14,6 @@ _mem0_resolve_identity() { MEM0_RESOLVED_USER_ID="$(_mem0_resolve_identity)" export MEM0_RESOLVED_USER_ID + +# Also resolve project context +. "$( cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd )/_project.sh" diff --git a/mem0-plugin/scripts/_project.py b/mem0-plugin/scripts/_project.py new file mode 100644 index 000000000..692922588 --- /dev/null +++ b/mem0-plugin/scripts/_project.py @@ -0,0 +1,130 @@ +"""Resolve mem0 project_id and branch. + +Resolution priority (project_id): + 1. MEM0_PROJECT_ID env var (explicit override) + 2. ~/.mem0/project_map.json lookup by cwd + 3. Git remote slug: strip protocol/prefix, strip .git, replace / and : with - + e.g. git@github.com:mem0ai/mem0.git -> mem0ai-mem0 + 4. Fallback: basename of cwd +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess + + +def resolve_project_id(cwd: str | None = None) -> str: + if cwd is None: + cwd = os.getcwd() + + # 1. Explicit override + explicit = os.environ.get("MEM0_PROJECT_ID", "").strip() + if explicit: + return explicit + + # 2. project_map.json lookup + map_path = os.path.expanduser("~/.mem0/project_map.json") + if os.path.isfile(map_path): + try: + with open(map_path) as f: + project_map = json.load(f) + mapped = project_map.get(cwd, "").strip() + if mapped: + return mapped + except (OSError, json.JSONDecodeError, AttributeError): + pass + + # 3. Git remote slug + try: + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + capture_output=True, + text=True, + check=True, + cwd=cwd, + ) + remote_url = result.stdout.strip() + if remote_url: + slug = _remote_url_to_slug(remote_url) + if slug: + return slug + except (subprocess.CalledProcessError, OSError): + pass + + # 4. Fallback: basename of cwd + return os.path.basename(cwd) or "unknown" + + +def resolve_branch(cwd: str | None = None) -> str: + if cwd is None: + cwd = os.getcwd() + try: + result = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + text=True, + check=True, + cwd=cwd, + ) + branch = result.stdout.strip() + return branch if branch else "unknown" + except (subprocess.CalledProcessError, OSError): + return "unknown" + + +def save_project_mapping(cwd: str, project_id: str) -> None: + """Write cwd -> project_id into ~/.mem0/project_map.json.""" + mem0_dir = os.path.expanduser("~/.mem0") + os.makedirs(mem0_dir, exist_ok=True) + map_path = os.path.join(mem0_dir, "project_map.json") + project_map: dict[str, str] = {} + if os.path.isfile(map_path): + try: + with open(map_path) as f: + project_map = json.load(f) + except (OSError, json.JSONDecodeError): + project_map = {} + project_map[cwd] = project_id + with open(map_path, "w") as f: + json.dump(project_map, f, indent=2) + + +def _remote_url_to_slug(url: str) -> str: + """Convert a git remote URL to a deterministic slug. + + Handles: + - HTTPS: https://github.com/owner/repo.git + - SSH: git@github.com:owner/repo.git + - SSH: git@github.com-alias:owner/repo.git (custom host aliases) + - ssh://: ssh://git@github.com/owner/repo.git + - git://: git://github.com/owner/repo.git + """ + slug = url.strip() + # Strip .git suffix + if slug.endswith(".git"): + slug = slug[:-4] + # Strip protocol prefixes + for prefix in ("https://", "http://", "ssh://", "git://"): + if slug.startswith(prefix): + slug = slug[len(prefix):] + break + else: + # Handle git@ style (no protocol prefix matched) + slug = re.sub(r"^git@", "", slug) + # Replace the first colon (SSH host:path separator) with / + slug = slug.replace(":", "/", 1) + # Split on / and take last two components (owner, repo) + parts = [p for p in slug.split("/") if p] + if len(parts) >= 2: + owner, repo = parts[-2], parts[-1] + slug = f"{owner}-{repo}" + elif parts: + slug = parts[-1] + else: + return "" + # Replace any remaining / and : with - + slug = slug.replace("/", "-").replace(":", "-") + return slug diff --git a/mem0-plugin/scripts/_project.sh b/mem0-plugin/scripts/_project.sh new file mode 100755 index 000000000..f08c58197 --- /dev/null +++ b/mem0-plugin/scripts/_project.sh @@ -0,0 +1,71 @@ +# Source this file. Sets MEM0_PROJECT_ID and MEM0_BRANCH. +# +# Resolution priority (project_id): +# 1. MEM0_PROJECT_ID env var (explicit override) +# 2. ~/.mem0/project_map.json lookup by $PWD (requires jq) +# 3. Git remote slug: strip protocol/prefix, strip .git, replace / and : with - +# e.g. git@github.com:mem0ai/mem0.git -> mem0ai-mem0 +# 4. Fallback: basename of $PWD +# +# Branch resolution: +# git branch --show-current, fallback "unknown" + +_mem0_resolve_project_id() { + # 1. Explicit override + if [ -n "${MEM0_PROJECT_ID:-}" ]; then + printf '%s' "$MEM0_PROJECT_ID" + return + fi + + # 2. project_map.json lookup by $PWD + _mem0_map="$HOME/.mem0/project_map.json" + if [ -f "$_mem0_map" ] && command -v jq >/dev/null 2>&1; then + _mem0_mapped=$(jq -r --arg cwd "$PWD" '.[$cwd] // empty' "$_mem0_map" 2>/dev/null) + if [ -n "$_mem0_mapped" ]; then + printf '%s' "$_mem0_mapped" + return + fi + fi + + # 3. Git remote slug + _mem0_remote_url=$(git remote get-url origin 2>/dev/null) + if [ -n "$_mem0_remote_url" ]; then + _mem0_slug="$_mem0_remote_url" + # Strip .git suffix + _mem0_slug="${_mem0_slug%.git}" + # Strip protocol prefixes + _mem0_slug="${_mem0_slug#https://}" + _mem0_slug="${_mem0_slug#http://}" + _mem0_slug="${_mem0_slug#ssh://}" + _mem0_slug="${_mem0_slug#git://}" + _mem0_slug="${_mem0_slug#git@}" + # Replace first colon (SSH host:path separator) with / + # shellcheck disable=SC2039 + _mem0_slug="${_mem0_slug/://}" + # Keep only the last two path components (owner/repo) + _mem0_owner=$(printf '%s' "$_mem0_slug" | awk -F'/' '{print $(NF-1)}') + _mem0_repo=$(printf '%s' "$_mem0_slug" | awk -F'/' '{print $NF}') + _mem0_slug="${_mem0_owner}-${_mem0_repo}" + # Replace any remaining / and : with - + # shellcheck disable=SC2039 + _mem0_slug="${_mem0_slug//\//-}" + # shellcheck disable=SC2039 + _mem0_slug="${_mem0_slug//:/-}" + if [ -n "$_mem0_slug" ]; then + printf '%s' "$_mem0_slug" + return + fi + fi + + # 4. Fallback: basename of $PWD + printf '%s' "$(basename "$PWD")" +} + +_mem0_resolve_branch() { + git branch --show-current 2>/dev/null || printf 'unknown' +} + +MEM0_PROJECT_ID="$(_mem0_resolve_project_id)" +MEM0_BRANCH="$(_mem0_resolve_branch)" +export MEM0_PROJECT_ID +export MEM0_BRANCH diff --git a/mem0-plugin/scripts/auto_import.py b/mem0-plugin/scripts/auto_import.py new file mode 100644 index 000000000..4be62bd40 --- /dev/null +++ b/mem0-plugin/scripts/auto_import.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Auto-import declarative project files into mem0. + +Runs in the background from the SessionStart hook (startup only). +Imports CLAUDE.md, AGENTS.md, .cursorrules, .windsurfrules, mem0.md +into mem0 as project profile memories, skipping unchanged files via +SHA-256 hashing. + +Input: MEM0_CWD env var (optional, defaults to os.getcwd()) +Output: stderr logs only (exit 0 always — must not block) +""" + +from __future__ import annotations + +import hashlib +import json +import logging +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_user_id +from _project import resolve_project_id + +log = logging.getLogger("mem0-auto-import") +log.setLevel(logging.DEBUG) +_handler = logging.StreamHandler(sys.stderr) +_handler.setFormatter(logging.Formatter("[mem0-auto-import] %(message)s")) +log.addHandler(_handler) + +if os.environ.get("MEM0_DEBUG"): + _log_dir = os.path.expanduser("~/.mem0") + try: + os.makedirs(_log_dir, exist_ok=True) + _file_handler = logging.FileHandler(os.path.join(_log_dir, "hooks.log")) + _file_handler.setFormatter(logging.Formatter("[mem0-auto-import] %(asctime)s %(message)s")) + log.addHandler(_file_handler) + except OSError: + pass + +API_URL = "https://api.mem0.ai" +MAX_FILE_SIZE = 100_000 # skip files over 100 KB +TARGET_FILES = ["CLAUDE.md", "AGENTS.md", ".cursorrules", ".windsurfrules", "mem0.md"] +HASH_STORE = os.path.expanduser("~/.mem0/file_hashes.json") + + +def sha256_file(path: str) -> str: + """Return the hex SHA-256 digest of a file.""" + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def load_hashes() -> dict[str, str]: + """Load the hash store from disk; return empty dict on any error.""" + if not os.path.isfile(HASH_STORE): + return {} + try: + with open(HASH_STORE) as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + return {} + + +def save_hashes(hashes: dict[str, str]) -> None: + """Persist the hash store to disk.""" + mem0_dir = os.path.expanduser("~/.mem0") + os.makedirs(mem0_dir, exist_ok=True) + try: + with open(HASH_STORE, "w") as f: + json.dump(hashes, f, indent=2) + except OSError as 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: + """POST a project profile memory to the Mem0 REST API.""" + body = { + "messages": [ + { + "role": "user", + "content": f"## Project Profile: {filename}\n\nProject: {project_id}\n\n{content}", + } + ], + "user_id": user_id, + "metadata": { + "type": "project_profile", + "file": filename, + "project_id": project_id, + "source": "auto-import", + }, + "infer": False, + } + + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request( + f"{API_URL}/v1/memories/", + data=data, + headers={ + "Content-Type": "application/json", + "Authorization": f"Token {api_key}", + }, + method="POST", + ) + + try: + with urllib.request.urlopen(req, timeout=15) as resp: + if resp.status in (200, 201): + log.info("Imported %s (project=%s)", filename, project_id) + return True + log.warning("API returned status %d for %s", resp.status, filename) + return False + except urllib.error.URLError as e: + log.warning("API call failed for %s: %s", filename, e) + return False + + +def main() -> None: + api_key = os.environ.get("MEM0_API_KEY", "") + if not api_key: + log.debug("MEM0_API_KEY not set, skipping auto-import") + return + + cwd = os.environ.get("MEM0_CWD", "").strip() or os.getcwd() + user_id = resolve_user_id() + project_id = resolve_project_id(cwd) + + log.debug("Auto-import started: cwd=%s project=%s user=%s", cwd, project_id, user_id) + + hashes = load_hashes() + updated = False + + for filename in TARGET_FILES: + filepath = os.path.join(cwd, filename) + + if not os.path.isfile(filepath): + log.debug("Not found, skipping: %s", filename) + continue + + try: + file_size = os.path.getsize(filepath) + except OSError: + log.debug("Cannot stat %s, skipping", filename) + continue + + if file_size > MAX_FILE_SIZE: + log.debug("Skipping %s: size %d exceeds %d bytes", filename, file_size, MAX_FILE_SIZE) + continue + + try: + current_hash = sha256_file(filepath) + except OSError as e: + log.debug("Cannot hash %s: %s", filename, e) + continue + + hash_key = f"{project_id}:{filename}" + if hashes.get(hash_key) == current_hash: + log.debug("Unchanged, skipping: %s", filename) + continue + + try: + with open(filepath, encoding="utf-8", errors="replace") as f: + content = f.read() + except OSError as e: + log.debug("Cannot read %s: %s", filename, e) + continue + + if post_memory(api_key, content, user_id, filename, project_id): + hashes[hash_key] = current_hash + updated = True + # on API failure we don't update the hash — retry next session + + if updated: + save_hashes(hashes) + else: + log.debug("No files imported this run") + + +if __name__ == "__main__": + try: + main() + except Exception as e: + log.error("Unexpected error: %s", e) + sys.exit(0) diff --git a/mem0-plugin/scripts/block_memory_write_cursor.sh b/mem0-plugin/scripts/block_memory_write_cursor.sh new file mode 100755 index 000000000..21b2cf0f8 --- /dev/null +++ b/mem0-plugin/scripts/block_memory_write_cursor.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Hook: preToolUse (Cursor) — blocks writes to MEMORY.md +# +# Cursor variant of block_memory_write.sh. Returns JSON: +# {"permission":"deny","agent_message":"..."} on block, +# {"permission":"allow"} on pass. + +set -uo pipefail + +if [ -n "${MEM0_DEBUG:-}" ]; then + mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" +fi + +INPUT=$(cat) + +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""' 2>/dev/null || echo "") + +if [ -z "$FILE_PATH" ]; then + jq -cn '{permission:"allow"}' + exit 0 +fi + +case "$FILE_PATH" in + */MEMORY.md|*/.claude/memory/*|*/.cursor/memory/*) + jq -cn --arg msg "Do not write to $FILE_PATH. Use the mem0 MCP add_memory tool instead to persist memories." \ + '{permission:"deny", agent_message:$msg}' + exit 0 + ;; + *) + jq -cn '{permission:"allow"}' + exit 0 + ;; +esac diff --git a/mem0-plugin/scripts/capture_compact_summary.py b/mem0-plugin/scripts/capture_compact_summary.py index b6df7c42e..1f3960a61 100644 --- a/mem0-plugin/scripts/capture_compact_summary.py +++ b/mem0-plugin/scripts/capture_compact_summary.py @@ -26,6 +26,7 @@ from datetime import date, timedelta sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from _identity import resolve_user_id +from _project import resolve_branch, resolve_project_id log = logging.getLogger("mem0-compact-summary") log.setLevel(logging.DEBUG) @@ -94,7 +95,7 @@ def find_compact_summary(lines: list[str]) -> str: return "" -def store_summary(api_key: str, summary: str, user_id: str, session_id: 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() body = { "messages": [{"role": "user", "content": summary}], @@ -103,6 +104,8 @@ def store_summary(api_key: str, summary: str, user_id: str, session_id: str) -> "type": "compact_summary", "source": "session-start-compact", "session_id": session_id, + "project_id": project_id, + "branch": branch, }, "infer": False, "expiration_date": expires, @@ -149,6 +152,8 @@ def main(): session_id = hook_input.get("session_id", "") user_id = resolve_user_id() + project_id = resolve_project_id() + branch = resolve_branch() lines = tail_lines(transcript_path, MAX_TAIL_LINES) if not lines: @@ -161,7 +166,7 @@ def main(): return log.info("Capturing compact summary (%d chars)", len(summary)) - store_summary(api_key, summary, user_id, session_id) + store_summary(api_key, summary, user_id, session_id, project_id, branch) if __name__ == "__main__": diff --git a/mem0-plugin/scripts/on_post_tool_use.sh b/mem0-plugin/scripts/on_post_tool_use.sh new file mode 100755 index 000000000..fc1d3ad17 --- /dev/null +++ b/mem0-plugin/scripts/on_post_tool_use.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Hook: PostToolUse — track mem0 MCP tool usage for session stats +# +# Fires after any tool call. We only care about mem0 MCP tools: +# mcp__mem0__add_memory → record an add +# mcp__mem0__search_memories → record a search +# +# Input: JSON on stdin with tool_name, tool_input, tool_result +# Output: none (exit 0, non-blocking) + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +INPUT=$(cat) +TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null || echo "") + +case "$TOOL_NAME" in + mcp__mem0__add_memory) + 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 + ;; + mcp__mem0__search_memories|mcp__mem0__get_memories) + python3 "$SCRIPT_DIR/session_stats.py" search 2>/dev/null || true + ;; +esac + +exit 0 diff --git a/mem0-plugin/scripts/on_post_tool_use_cursor.sh b/mem0-plugin/scripts/on_post_tool_use_cursor.sh new file mode 100755 index 000000000..b0a5ca4d1 --- /dev/null +++ b/mem0-plugin/scripts/on_post_tool_use_cursor.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Hook: postToolUse (Cursor) — track mem0 MCP tool usage for session stats +# +# Wraps on_post_tool_use.sh. Cursor expects JSON output but PostToolUse +# has no meaningful return value, so we output {} after tracking. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Run the shared tracker (output is ignored) +"$SCRIPT_DIR/on_post_tool_use.sh" 2>/dev/null || true + +echo '{}' +exit 0 diff --git a/mem0-plugin/scripts/on_pre_compact.py b/mem0-plugin/scripts/on_pre_compact.py index 6f280842e..737cbb928 100755 --- a/mem0-plugin/scripts/on_pre_compact.py +++ b/mem0-plugin/scripts/on_pre_compact.py @@ -24,6 +24,7 @@ from datetime import date, timedelta sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from _identity import resolve_user_id +from _project import resolve_branch, resolve_project_id log = logging.getLogger("mem0-capture") log.setLevel(logging.DEBUG) @@ -167,7 +168,7 @@ def build_content(state: dict, source: str) -> str: return "\n".join(parts) -def store_memory(api_key: str, content: str, user_id: str, source: str, session_id: 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.""" expires = (date.today() + timedelta(days=SESSION_STATE_EXPIRY_DAYS)).isoformat() body = { @@ -179,6 +180,8 @@ def store_memory(api_key: str, content: str, user_id: str, source: str, session_ "type": "session_state", "source": source, "session_id": session_id, + "project_id": project_id, + "branch": branch, }, "expiration_date": expires, } @@ -230,6 +233,8 @@ def main(): session_id = hook_input.get("session_id", "") user_id = resolve_user_id() + project_id = resolve_project_id() + branch = resolve_branch() lines = tail_lines(transcript_path, MAX_TAIL_LINES) if not lines: @@ -250,7 +255,7 @@ def main(): len(state["bash_commands"]), ) - store_memory(api_key, content, user_id, source, session_id) + store_memory(api_key, content, user_id, source, session_id, project_id, branch) if __name__ == "__main__": diff --git a/mem0-plugin/scripts/on_pre_compact.sh b/mem0-plugin/scripts/on_pre_compact.sh index a3dffd5eb..38a503c5d 100755 --- a/mem0-plugin/scripts/on_pre_compact.sh +++ b/mem0-plugin/scripts/on_pre_compact.sh @@ -55,7 +55,7 @@ Tool call shape: add_memory( messages=[{"role":"user","content":""}], user_id="", - metadata={"type":"session_state","source":"pre-compaction"}, + metadata={"type":"session_state","source":"pre-compaction","project_id":"","branch":""}, infer=False, ) ``` diff --git a/mem0-plugin/scripts/on_pre_compact_cursor.sh b/mem0-plugin/scripts/on_pre_compact_cursor.sh new file mode 100755 index 000000000..74cf77a14 --- /dev/null +++ b/mem0-plugin/scripts/on_pre_compact_cursor.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Hook: preCompact (Cursor) +# +# Wraps on_pre_compact.sh and converts plain-text output to Cursor's +# expected JSON format: {"user_message":""} + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +TEXT=$("$SCRIPT_DIR/on_pre_compact.sh" 2>/dev/null || echo "") + +if [ -z "$TEXT" ]; then + echo '{}' + exit 0 +fi + +jq -cn --arg msg "$TEXT" '{user_message:$msg}' +exit 0 diff --git a/mem0-plugin/scripts/on_session_start.sh b/mem0-plugin/scripts/on_session_start.sh index f47b42e1c..927b0c868 100755 --- a/mem0-plugin/scripts/on_session_start.sh +++ b/mem0-plugin/scripts/on_session_start.sh @@ -25,18 +25,57 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=_identity.sh . "$SCRIPT_DIR/_identity.sh" +# Initialize session stats tracker +python3 "$SCRIPT_DIR/session_stats.py" init 2>/dev/null || true + INPUT=$(cat) SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup") +# Fetch project-scoped memory count (best-effort, don't block on failure, 5s timeout) +MEM0_COUNT="?" +if command -v python3 >/dev/null 2>&1; then + MEM0_COUNT=$(python3 -c " +import json, os, urllib.request, urllib.error +api_key = os.environ.get('MEM0_API_KEY', '') +user_id = os.environ.get('MEM0_RESOLVED_USER_ID', 'default') +project_id = os.environ.get('MEM0_PROJECT_ID', '') +body = json.dumps({ + '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( + 'https://api.mem0.ai/v2/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()) + if isinstance(results, list): + n = len(results) + print(f'{n}+' if n >= 100 else str(n)) + else: + print('?') +except Exception: + print('?') +" 2>/dev/null || echo "?") +fi + # Identity line is emitted before every bootstrap variant so the agent # uses the same user_id the hooks resolved. Without this, the agent's # search_memories/add_memory MCP calls may bind to a different bucket # than what the hooks write to. -echo "## Mem0 Identity" +echo "## Mem0 Active" echo "" -echo "Active user_id: \`$MEM0_RESOLVED_USER_ID\`" +echo "\`user=$MEM0_RESOLVED_USER_ID | project=$MEM0_PROJECT_ID | branch=$MEM0_BRANCH | memories=$MEM0_COUNT\`" echo "" -echo "Always include \`{\"user_id\": \"$MEM0_RESOLVED_USER_ID\"}\` (wrapped in an \`AND\` clause) in every \`search_memories\` filter and as \`user_id\` on every \`add_memory\` call. This keeps the agent's MCP calls aligned with the bucket the hooks write to." +echo "Always include \`user_id\` + \`metadata.project_id\` in every \`search_memories\` filter and \`add_memory\` call:" +echo "- user_id: \`$MEM0_RESOLVED_USER_ID\`" +echo "- project_id: \`$MEM0_PROJECT_ID\`" +echo "- branch: \`$MEM0_BRANCH\` (include in session-state / compact-summary metadata)" echo "" if [ "$SOURCE" = "startup" ]; then @@ -52,6 +91,10 @@ You have access to persistent memory via the mem0 MCP tools. Before doing anythi IMPORTANT: Do NOT skip this step. Always bootstrap context first. EOF + # Auto-import declarative project files in background + MEM0_CWD="$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")" \ + python3 "$SCRIPT_DIR/auto_import.py" 2>/dev/null & + elif [ "$SOURCE" = "resume" ]; then cat <<'EOF' ## Mem0 Session Resumed diff --git a/mem0-plugin/scripts/on_session_start_cursor.sh b/mem0-plugin/scripts/on_session_start_cursor.sh new file mode 100755 index 000000000..46ffb8006 --- /dev/null +++ b/mem0-plugin/scripts/on_session_start_cursor.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Hook: sessionStart (Cursor) +# +# Wraps on_session_start.sh and converts plain-text output to Cursor's +# expected JSON format: {"additional_context":""} + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +TEXT=$("$SCRIPT_DIR/on_session_start.sh" 2>/dev/null || echo "") + +if [ -z "$TEXT" ]; then + echo '{}' + exit 0 +fi + +jq -cn --arg ctx "$TEXT" '{additional_context:$ctx}' +exit 0 diff --git a/mem0-plugin/scripts/on_stop.sh b/mem0-plugin/scripts/on_stop.sh index 848ab9906..a555b8c33 100755 --- a/mem0-plugin/scripts/on_stop.sh +++ b/mem0-plugin/scripts/on_stop.sh @@ -25,6 +25,22 @@ if [ "$STOP_HOOK_ACTIVE" = "true" ]; then exit 0 fi +# Print session-end report +REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "") +if [ -n "$REPORT" ]; then + echo "" + echo "---" + echo "**mem0 $REPORT**" + echo "---" + echo "" +fi + +# Append to persistent session log (guarded — on_stop.sh uses set -euo pipefail) +if [ -n "$REPORT" ]; then + 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 +fi + cat <<'EOF' Before finishing, check if there are important learnings from this interaction that should be persisted using the mem0 `add_memory` tool: @@ -37,6 +53,8 @@ Before finishing, check if there are important learnings from this interaction t Memories can be as detailed as needed — include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners. If nothing notable happened in this interaction, it's fine to skip. Only store genuinely useful learnings. + +Always include `"project_id"` in the metadata of any memory you store. EOF # Capture transcript state in the background via Mem0 REST API diff --git a/mem0-plugin/scripts/on_stop_codex.sh b/mem0-plugin/scripts/on_stop_codex.sh index de80b0fed..645b471a6 100755 --- a/mem0-plugin/scripts/on_stop_codex.sh +++ b/mem0-plugin/scripts/on_stop_codex.sh @@ -21,6 +21,8 @@ if [ -n "${MEM0_DEBUG:-}" ]; then mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" fi +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + INPUT=$(cat) STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false") @@ -29,20 +31,35 @@ if [ "$STOP_HOOK_ACTIVE" = "true" ]; then exit 0 fi -REASON=$(cat <<'EOF' -Before finishing, check if there are important learnings from this interaction that should be persisted using the mem0 `add_memory` tool: +# Session-end report (best-effort, must not break JSON output) +REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "") +REPORT_BLOCK="" +if [ -n "$REPORT" ]; then + REPORT_BLOCK="---\nmem0 $REPORT\n---\n\n" + 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 +fi -1. Were any significant decisions made? -> Store with metadata `{"type": "decision"}` -2. Were any new patterns or strategies discovered? -> Store with metadata `{"type": "task_learning"}` -3. Did any approach fail? -> Store with metadata `{"type": "anti_pattern"}` -4. Did you learn anything about the user's preferences? -> Store with metadata `{"type": "user_preference"}` -5. Were there environment/setup discoveries? -> Store with metadata `{"type": "environmental"}` +REASON=$(cat < Store with metadata \`{"type": "decision"}\` +2. Were any new patterns or strategies discovered? -> Store with metadata \`{"type": "task_learning"}\` +3. Did any approach fail? -> Store with metadata \`{"type": "anti_pattern"}\` +4. Did you learn anything about the user's preferences? -> Store with metadata \`{"type": "user_preference"}\` +5. Were there environment/setup discoveries? -> Store with metadata \`{"type": "environmental"}\` Memories can be as detailed as needed — include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners. +Always include \`"project_id"\` in the metadata of any memory you store. + If nothing notable happened in this interaction, it's fine to skip. Only store genuinely useful learnings. EOF ) jq -cn --arg reason "$REASON" '{decision:"block", reason:$reason}' + +# Capture transcript state in the background via Mem0 REST API +echo "$INPUT" | python3 "$SCRIPT_DIR/on_pre_compact.py" --source=session-end 2>/dev/null & + exit 0 diff --git a/mem0-plugin/scripts/on_stop_cursor.sh b/mem0-plugin/scripts/on_stop_cursor.sh new file mode 100755 index 000000000..90c41b2fe --- /dev/null +++ b/mem0-plugin/scripts/on_stop_cursor.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Hook: Stop (Cursor) +# +# Fires when Cursor agent completes a turn. Wraps the same logic as +# on_stop.sh but outputs JSON (Cursor expects {"followup_message":"..."}). +# +# Input: JSON on stdin with status, loop_count, conversation_id, etc. +# Output: JSON on stdout: {"followup_message":""} + +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) + +# Session-end report (best-effort) +REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "") +REPORT_BLOCK="" +if [ -n "$REPORT" ]; then + REPORT_BLOCK="---\nmem0 $REPORT\n---\n\n" + 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 +fi + +MESSAGE=$(cat < Store with metadata \`{"type": "decision"}\` +2. Were any new patterns or strategies discovered? -> Store with metadata \`{"type": "task_learning"}\` +3. Did any approach fail? -> Store with metadata \`{"type": "anti_pattern"}\` +4. Did you learn anything about the user's preferences? -> Store with metadata \`{"type": "user_preference"}\` +5. Were there environment/setup discoveries? -> Store with metadata \`{"type": "environmental"}\` + +Always include \`"project_id"\` in the metadata of any memory you store. + +If nothing notable happened, it's fine to skip. Only store genuinely useful learnings. +EOF +) + +jq -cn --arg msg "$MESSAGE" '{followup_message:$msg}' + +# Capture transcript state in the background via Mem0 REST API +echo "$INPUT" | python3 "$SCRIPT_DIR/on_pre_compact.py" --source=session-end 2>/dev/null & + +exit 0 diff --git a/mem0-plugin/scripts/on_task_completed.sh b/mem0-plugin/scripts/on_task_completed.sh index 57a568027..7d839cdb2 100755 --- a/mem0-plugin/scripts/on_task_completed.sh +++ b/mem0-plugin/scripts/on_task_completed.sh @@ -13,6 +13,9 @@ 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" || true + INPUT=$(cat) TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // "unknown task"' 2>/dev/null || echo "unknown task") @@ -28,6 +31,7 @@ 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. Only store genuinely useful learnings — skip if the task was trivial. +Include \`"project_id": "$MEM0_PROJECT_ID"\` in metadata for all memories. EOF exit 0 diff --git a/mem0-plugin/scripts/on_user_prompt.sh b/mem0-plugin/scripts/on_user_prompt.sh index 50906a1d9..2e7fc15e9 100755 --- a/mem0-plugin/scripts/on_user_prompt.sh +++ b/mem0-plugin/scripts/on_user_prompt.sh @@ -61,11 +61,11 @@ improve your answer. The agent -- not this hook -- owns this decision. - Filter shape: the root must be a logical operator (\`AND\` / \`OR\` / \`NOT\`) with an array, and metadata uses a **nested** object (not dotted keys). Combine \`user_id\` with one \`metadata.type\` clause per call: - - \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "decision"}}]}\` -- design / architecture - - \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "anti_pattern"}}]}\` -- debugging, error handling - - \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "user_preference"}}]}\` -- tooling, stack, style - - \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "convention"}}]}\` -- established patterns -- Or scope with just \`{"AND": [{"user_id": "$USER_ID"}]}\` when no metadata filter fits. + - \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"project_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"}, {"metadata": {"project_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 + - Or scope with just \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"project_id": "$MEM0_PROJECT_ID"}}]}\` when no metadata filter fits. - Empty results are normal -- proceed without context. EOF diff --git a/mem0-plugin/scripts/on_user_prompt_cursor.sh b/mem0-plugin/scripts/on_user_prompt_cursor.sh new file mode 100755 index 000000000..bb56c52c6 --- /dev/null +++ b/mem0-plugin/scripts/on_user_prompt_cursor.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Hook: beforeSubmitPrompt (Cursor) +# +# Wraps on_user_prompt.sh and converts plain-text output to Cursor's +# expected JSON format: {"continue":true,"user_message":""} + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +TEXT=$("$SCRIPT_DIR/on_user_prompt.sh" 2>/dev/null || echo "") + +if [ -z "$TEXT" ]; then + jq -cn '{continue:true}' + exit 0 +fi + +jq -cn --arg msg "$TEXT" '{continue:true, user_message:$msg}' +exit 0 diff --git a/mem0-plugin/scripts/session_stats.py b/mem0-plugin/scripts/session_stats.py new file mode 100644 index 000000000..b8fb1f08e --- /dev/null +++ b/mem0-plugin/scripts/session_stats.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Session stats tracker for mem0 plugin. + +Tracks memory adds/searches per session. +Uses /tmp/mem0_session_stats_$USER.json (single file per user, reset on init). + +Usage: + python session_stats.py init # reset for new session + python session_stats.py add # record a memory write + python session_stats.py search # record a search + python session_stats.py report # print summary, clean up temp file +""" + +from __future__ import annotations + +import json +import os +import sys +from datetime import datetime + +STATS_FILE = f"/tmp/mem0_session_stats_{os.environ.get('USER', 'default')}.json" + + +def _load() -> dict: + if os.path.isfile(STATS_FILE): + try: + with open(STATS_FILE) as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + pass + return {"adds": 0, "searches": 0, "categories": [], "started": datetime.now().isoformat()} + + +def _save(stats: dict) -> None: + with open(STATS_FILE, "w") as f: + json.dump(stats, f) + + +def init() -> None: + _save({"adds": 0, "searches": 0, "categories": [], "started": datetime.now().isoformat()}) + + +def record_add(category: str = "") -> None: + stats = _load() + stats["adds"] = stats.get("adds", 0) + 1 + if category and category not in stats.get("categories", []): + stats.setdefault("categories", []).append(category) + _save(stats) + + +def record_search() -> None: + stats = _load() + stats["searches"] = stats.get("searches", 0) + 1 + _save(stats) + + +def report() -> str: + stats = _load() + adds = stats.get("adds", 0) + searches = stats.get("searches", 0) + categories = stats.get("categories", []) + + # Clean up temp file after reading + try: + os.unlink(STATS_FILE) + except OSError: + pass + + if adds == 0 and searches == 0: + return "" + + parts = [] + parts.append(f"Session: wrote {adds} memories, retrieved {searches}") + if categories: + parts.append(f"Categories touched: {', '.join(categories)}") + + return ". ".join(parts) + "." + + +def main() -> int: + if len(sys.argv) < 2: + print("Usage: session_stats.py [init|add|search|report]", file=sys.stderr) + return 1 + + cmd = sys.argv[1] + if cmd == "init": + init() + elif cmd == "add": + category = sys.argv[2] if len(sys.argv) > 2 else "" + record_add(category) + elif cmd == "search": + record_search() + elif cmd == "report": + result = report() + if result: + print(result) + else: + print("Session: no memory operations.") + else: + print(f"Unknown command: {cmd}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/mem0-plugin/skills/mem0-mcp/SKILL.md b/mem0-plugin/skills/mem0-mcp/SKILL.md index bfdd15789..b3c5a6387 100644 --- a/mem0-plugin/skills/mem0-mcp/SKILL.md +++ b/mem0-plugin/skills/mem0-mcp/SKILL.md @@ -16,6 +16,23 @@ You have access to persistent memory via the mem0 MCP tools. Follow this protoco Decide whether persistent memory context would improve your response, then act accordingly. Don't search by default — search deliberately. +## Project scoping + +Every memory operation MUST be scoped to the current project: + +- **On `add_memory`:** Always include `metadata.project_id` (the active project_id from SessionStart). +- **On `search_memories`:** Always include `{"metadata": {"project_id": ""}}` in the AND filter. +- **Session-state memories:** Also include `metadata.branch` (the active branch from SessionStart). + +Full filter template: +```python +filters={"AND": [ + {"user_id": ""}, + {"metadata": {"project_id": ""}}, + {"metadata": {"type": "decision"}} +]} +``` + ### Decide: search or skip? **Search WHEN** the user: @@ -59,9 +76,9 @@ Combine `user_id` with one metadata clause per call: | `{"metadata": {"type": "user_preference"}}` | tooling, stack, style — always include for code work | | `{"metadata": {"type": "convention"}}` | established patterns in this project | -Full filter (replace `` with the active user_id from your runtime): +Full filter (replace `` and `` with the active values from SessionStart): ```python -filters={"AND": [{"user_id": ""}, {"metadata": {"type": "decision"}}]} +filters={"AND": [{"user_id": ""}, {"metadata": {"project_id": ""}}, {"metadata": {"type": "decision"}}]} ``` ### Worked example @@ -74,16 +91,16 @@ search_memories(query="Refactor the auth module to use JWT") # Hits whatever shares words. Misses prior decisions and preferences. ``` -Do (parallel — substitute the active `user_id` for ``): +Do (parallel — substitute the active `user_id` and `project_id` for the placeholders): ```python search_memories(query="auth module decisions", - filters={"AND": [{"user_id": ""}, {"metadata": {"type": "decision"}}]}) + filters={"AND": [{"user_id": ""}, {"metadata": {"project_id": ""}}, {"metadata": {"type": "decision"}}]}) search_memories(query="JWT", - filters={"AND": [{"user_id": ""}]}) + filters={"AND": [{"user_id": ""}, {"metadata": {"project_id": ""}}]}) search_memories(query="auth refactor failures", - filters={"AND": [{"user_id": ""}, {"metadata": {"type": "anti_pattern"}}]}) + filters={"AND": [{"user_id": ""}, {"metadata": {"project_id": ""}}, {"metadata": {"type": "anti_pattern"}}]}) search_memories(query="auth", - filters={"AND": [{"user_id": ""}, {"metadata": {"type": "user_preference"}}]}) + filters={"AND": [{"user_id": ""}, {"metadata": {"project_id": ""}}, {"metadata": {"type": "user_preference"}}]}) ``` ## After completing significant work @@ -116,7 +133,7 @@ When the user is asking about *current* state ("where were we", "what's the acti ```python # Last 90 days only -{"AND": [{"user_id": ""}, {"metadata": {"type": "session_state"}}, {"created_at": {"gte": "<90 days ago, YYYY-MM-DD>"}}]} +{"AND": [{"user_id": ""}, {"metadata": {"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. diff --git a/mem0-plugin/skills/mem0-onboard/SKILL.md b/mem0-plugin/skills/mem0-onboard/SKILL.md new file mode 100644 index 000000000..001abead6 --- /dev/null +++ b/mem0-plugin/skills/mem0-onboard/SKILL.md @@ -0,0 +1,89 @@ +--- +name: mem0-onboard +description: > + Post-install onboarding wizard for the mem0 plugin. + Detects CLAUDE.md, AGENTS.md, .cursorrules, .windsurfrules, mem0.md + and offers to import them. Installs coding categories. Shows active identity. + TRIGGER: user runs /mem0:onboard, or mentions "setup mem0", "configure mem0 plugin". +--- + +# Mem0 Onboarding Wizard + +Run this wizard to set up the mem0 plugin for the current project. Complete in ~30 seconds. + +## Step 1: Verify API key + +Check if `MEM0_API_KEY` is set in the current environment: + +```bash +echo "${MEM0_API_KEY:+SET}" || echo "NOT_SET" +``` + +- If **NOT set**: + 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: + - **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 + 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 **SET**: Proceed to Step 2. + +## Step 2: Show identity + +Report the active identity to the user: +- Call `search_memories` with `query="project setup"`, `user_id=`, `filters={"AND": [{"user_id": ""}, {"metadata": {"project_id": ""}}]}`, `limit=1` to verify connectivity. +- Print: `Connected. user=, project=, branch=` +- If the search fails, troubleshoot the API key. + +## Step 3: Detect and import project files + +Check for these files in the project root: +1. `CLAUDE.md` +2. `AGENTS.md` +3. `.cursorrules` +4. `.windsurfrules` +5. `mem0.md` + +For each file found, ask the user: "Found `` ( bytes). Import into mem0? [Y/n]" + +If user says yes (or default): +- Read the file content +- Call `add_memory` with: + - `messages=[{"role": "user", "content": "## Project Profile: \n\nProject: \n\n"}]` + - `user_id=` + - `metadata={"type": "project_profile", "file": "", "project_id": "", "source": "onboard"}` + - `infer=False` + +## Step 4: Install coding categories + +Ask: "Install coding categories optimized for development workflows? [Y/n]" + +If yes, run the script directly (no external dependencies required — uses stdlib only). + +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 +python3 "/scripts/setup_coding_categories.py" --apply +``` + +If the script reports an error, show the error message and suggest checking the API key. + +## Step 5: Summary + +Print a summary: +``` +Onboarding complete. + user_id: + project_id: + branch: + imported: files + categories: + +Memory is now active for this project. Start working — mem0 will +automatically search relevant context and capture learnings. + +Run /mem0:tour to see what mem0 already knows about this project. +``` diff --git a/mem0-plugin/skills/mem0-switch-project/SKILL.md b/mem0-plugin/skills/mem0-switch-project/SKILL.md new file mode 100644 index 000000000..8ed2415f2 --- /dev/null +++ b/mem0-plugin/skills/mem0-switch-project/SKILL.md @@ -0,0 +1,50 @@ +--- +name: mem0-switch-project +description: > + Manually override project_id for the current directory. + Useful for monorepos, nested git dirs, or non-git directories. + TRIGGER: user runs /mem0:switch-project , or asks "switch mem0 project", + "change project scope", "override project_id". +--- + +# Mem0 Switch Project + +Override the automatic project_id detection for the current directory. + +## Usage + +The user provides a project name as an argument: `/mem0:switch-project ` + +## Execution + +1. If no project name was given, ask: "What project_id should this directory use?" + +2. Write the mapping to `~/.mem0/project_map.json` using the Bash tool: + + ```bash + python3 -c " + import json, os + map_file = os.path.expanduser('~/.mem0/project_map.json') + mapping = {} + if os.path.isfile(map_file): + with open(map_file) as f: + mapping = json.load(f) + mapping[os.getcwd()] = '' + os.makedirs(os.path.dirname(map_file), exist_ok=True) + with open(map_file, 'w') as f: + json.dump(mapping, f, indent=2) + print(f'Mapped {os.getcwd()} -> ') + " + ``` + + (Replace `` with the user's chosen project name.) + +3. Verify by searching for existing memories: + - Call `search_memories` with `query="project"`, `filters={"AND": [{"user_id": ""}, {"metadata": {"project_id": ""}}]}`, `limit=1` + +4. Print: + ``` + Switched to project . + memories found for this project. + Note: This override persists across sessions for this directory. + ``` diff --git a/mem0-plugin/skills/mem0-tour/SKILL.md b/mem0-plugin/skills/mem0-tour/SKILL.md new file mode 100644 index 000000000..2efc216bc --- /dev/null +++ b/mem0-plugin/skills/mem0-tour/SKILL.md @@ -0,0 +1,46 @@ +--- +name: mem0-tour +description: > + Show what mem0 knows about the current project. Dumps top memories + grouped by category. Power-user-friendly proof of value. + TRIGGER: user runs /mem0:tour, or asks "what do you know about this project", + "show me my memories", "what has mem0 stored". +--- + +# Mem0 Project Tour + +Show the user what mem0 has stored for the current project. + +## Execution + +1. Run the following `search_memories` calls in parallel (all with the active `user_id` and `metadata.project_id`): + + - `query="architecture decisions"`, `filters={"AND": [{"user_id": ""}, {"metadata": {"project_id": ""}}, {"metadata": {"type": "decision"}}]}`, `limit=5` + - `query="anti patterns failures"`, `filters={"AND": [{"user_id": ""}, {"metadata": {"project_id": ""}}, {"metadata": {"type": "anti_pattern"}}]}`, `limit=5` + - `query="task learnings strategies"`, `filters={"AND": [{"user_id": ""}, {"metadata": {"project_id": ""}}, {"metadata": {"type": "task_learning"}}]}`, `limit=5` + - `query="coding conventions"`, `filters={"AND": [{"user_id": ""}, {"metadata": {"project_id": ""}}, {"metadata": {"type": "convention"}}]}`, `limit=5` + - `query="user preferences"`, `filters={"AND": [{"user_id": ""}, {"metadata": {"project_id": ""}}, {"metadata": {"type": "user_preference"}}]}`, `limit=5` + - `query="project profile"`, `filters={"AND": [{"user_id": ""}, {"metadata": {"project_id": ""}}, {"metadata": {"type": "project_profile"}}]}`, `limit=5` + - `query="tooling setup environment"`, `filters={"AND": [{"user_id": ""}, {"metadata": {"project_id": ""}}, {"metadata": {"type": "environmental"}}]}`, `limit=5` + +2. Group results by category. For each category with results, print: + + ``` + ## ( memories) + - (score: ) + - ... + ``` + +3. For categories with zero results, print: `: (empty)` + +4. Print totals at the end: + ``` + --- + Total: memories across categories for project + ``` + +5. If ALL categories are empty, print: + ``` + No memories stored yet for project . + Run /mem0:onboard to import project files, or start working — mem0 captures learnings automatically. + ``` diff --git a/mem0-plugin/tests/conftest.py b/mem0-plugin/tests/conftest.py new file mode 100644 index 000000000..d760e5882 --- /dev/null +++ b/mem0-plugin/tests/conftest.py @@ -0,0 +1,54 @@ +"""Shared fixtures for mem0-plugin tests.""" + +from __future__ import annotations + +import os +import subprocess +import sys + +import pytest + +SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts") + + +@pytest.fixture(autouse=True) +def _scripts_on_path(): + """Ensure scripts/ is on sys.path so we can import _project, session_stats, etc.""" + abs_scripts = os.path.abspath(SCRIPTS_DIR) + if abs_scripts not in sys.path: + sys.path.insert(0, abs_scripts) + yield + if abs_scripts in sys.path: + sys.path.remove(abs_scripts) + + +@pytest.fixture() +def tmp_git_repo(tmp_path): + """Create a temp dir with a git repo and HTTPS remote.""" + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + subprocess.run( + ["git", "remote", "add", "origin", "https://github.com/mem0ai/mem0.git"], + cwd=tmp_path, + capture_output=True, + check=True, + ) + return tmp_path + + +@pytest.fixture() +def tmp_git_repo_ssh(tmp_path): + """Create a temp dir with a git repo and SSH remote.""" + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + subprocess.run( + ["git", "remote", "add", "origin", "git@github.com:acme/cool-project.git"], + cwd=tmp_path, + capture_output=True, + check=True, + ) + return tmp_path + + +@pytest.fixture() +def tmp_no_git(tmp_path): + """Temp dir with no git repo.""" + return tmp_path diff --git a/mem0-plugin/tests/test_project.py b/mem0-plugin/tests/test_project.py new file mode 100644 index 000000000..058ceee9f --- /dev/null +++ b/mem0-plugin/tests/test_project.py @@ -0,0 +1,107 @@ +"""Tests for _project.py — project_id + branch resolver.""" + +from __future__ import annotations + +import json +import os +import subprocess + + +def test_resolve_project_id_from_https_remote(tmp_git_repo): + from _project import resolve_project_id + + pid = resolve_project_id(str(tmp_git_repo)) + assert pid == "mem0ai-mem0" + + +def test_resolve_project_id_from_ssh_remote(tmp_git_repo_ssh): + from _project import resolve_project_id + + pid = resolve_project_id(str(tmp_git_repo_ssh)) + assert pid == "acme-cool-project" + + +def test_resolve_project_id_fallback_basename(tmp_no_git): + from _project import resolve_project_id + + pid = resolve_project_id(str(tmp_no_git)) + assert pid == os.path.basename(str(tmp_no_git)) + + +def test_resolve_project_id_from_env(tmp_no_git, monkeypatch): + from _project import resolve_project_id + + monkeypatch.setenv("MEM0_PROJECT_ID", "my-override") + pid = resolve_project_id(str(tmp_no_git)) + assert pid == "my-override" + + +def test_resolve_project_id_from_project_map(tmp_no_git): + from _project import resolve_project_id, save_project_mapping + + save_project_mapping(str(tmp_no_git), "custom-project") + pid = resolve_project_id(str(tmp_no_git)) + assert pid == "custom-project" + + +def test_save_project_mapping_creates_file(tmp_no_git): + from _project import save_project_mapping + + save_project_mapping(str(tmp_no_git), "test-proj") + map_path = os.path.expanduser("~/.mem0/project_map.json") + assert os.path.isfile(map_path) + with open(map_path) as f: + data = json.load(f) + assert data[str(tmp_no_git)] == "test-proj" + + +def test_resolve_branch_in_git_repo(tmp_git_repo): + from _project import resolve_branch + + subprocess.run( + ["git", "checkout", "-b", "feat/test-branch"], + cwd=tmp_git_repo, + capture_output=True, + check=True, + ) + branch = resolve_branch(str(tmp_git_repo)) + assert branch == "feat/test-branch" + + +def test_resolve_branch_no_git(tmp_no_git): + from _project import resolve_branch + + branch = resolve_branch(str(tmp_no_git)) + assert branch == "unknown" + + +def test_remote_url_to_slug_various_formats(): + from _project import _remote_url_to_slug + + assert _remote_url_to_slug("https://github.com/mem0ai/mem0.git") == "mem0ai-mem0" + assert _remote_url_to_slug("git@github.com:mem0ai/mem0.git") == "mem0ai-mem0" + assert _remote_url_to_slug("ssh://git@github.com/acme/app.git") == "acme-app" + assert _remote_url_to_slug("https://gitlab.com/org/sub/repo.git") == "sub-repo" + assert _remote_url_to_slug("git@bitbucket.org:team/project.git") == "team-project" + + +def test_remote_url_to_slug_no_git_suffix(): + from _project import _remote_url_to_slug + + assert _remote_url_to_slug("https://github.com/foo/bar") == "foo-bar" + + +def test_resolve_project_id_priority_order(tmp_git_repo, monkeypatch): + """Env var > project_map > git remote > basename.""" + from _project import resolve_project_id, save_project_mapping + + # Git remote gives "mem0ai-mem0" + assert resolve_project_id(str(tmp_git_repo)) == "mem0ai-mem0" + + # project_map overrides git remote + save_project_mapping(str(tmp_git_repo), "from-map") + assert resolve_project_id(str(tmp_git_repo)) == "from-map" + + # Env var overrides everything + monkeypatch.setenv("MEM0_PROJECT_ID", "from-env") + assert resolve_project_id(str(tmp_git_repo)) == "from-env" diff --git a/mem0-plugin/tests/test_session_stats.py b/mem0-plugin/tests/test_session_stats.py new file mode 100644 index 000000000..94ae55e14 --- /dev/null +++ b/mem0-plugin/tests/test_session_stats.py @@ -0,0 +1,143 @@ +"""Tests for session_stats.py — session-level memory operation tracker.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys + +import pytest + +SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts") + + +@pytest.fixture(autouse=True) +def _isolate_stats_file(tmp_path, monkeypatch): + """Point STATS_FILE to a temp location so tests don't interfere.""" + stats_file = str(tmp_path / "test_stats.json") + monkeypatch.setattr("session_stats.STATS_FILE", stats_file) + yield stats_file + + +def test_init_creates_file(_isolate_stats_file): + import session_stats + + session_stats.init() + assert os.path.isfile(_isolate_stats_file) + with open(_isolate_stats_file) as f: + data = json.load(f) + assert data["adds"] == 0 + assert data["searches"] == 0 + assert data["categories"] == [] + + +def test_record_add_increments(_isolate_stats_file): + import session_stats + + session_stats.init() + 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["adds"] == 3 + assert set(data["categories"]) == {"bug_fixes", "decisions"} + + +def test_record_search_increments(_isolate_stats_file): + import session_stats + + session_stats.init() + session_stats.record_search() + session_stats.record_search() + + with open(_isolate_stats_file) as f: + data = json.load(f) + assert data["searches"] == 2 + + +def test_report_returns_summary(_isolate_stats_file): + import session_stats + + session_stats.init() + session_stats.record_add("architecture_decisions") + session_stats.record_add("task_learnings") + session_stats.record_search() + session_stats.record_search() + session_stats.record_search() + + result = session_stats.report() + assert "wrote 2 memories" in result + assert "retrieved 3" in result + assert "architecture_decisions" in result + assert "task_learnings" in result + + +def test_report_empty_session(_isolate_stats_file): + import session_stats + + session_stats.init() + result = session_stats.report() + assert result == "" + + +def test_report_cleans_up_file(_isolate_stats_file): + import session_stats + + session_stats.init() + session_stats.record_add() + session_stats.report() + assert not os.path.isfile(_isolate_stats_file) + + +def test_record_add_no_category(_isolate_stats_file): + 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["adds"] == 2 + assert data["categories"] == [] + + +def test_duplicate_categories_not_added(_isolate_stats_file): + import session_stats + + session_stats.init() + session_stats.record_add("bug_fixes") + session_stats.record_add("bug_fixes") + session_stats.record_add("bug_fixes") + + with open(_isolate_stats_file) as f: + data = json.load(f) + assert data["adds"] == 3 + assert data["categories"] == ["bug_fixes"] + + +def test_cli_init(tmp_path): + """Test CLI invocation: session_stats.py init.""" + env = {**os.environ, "USER": "test"} + result = subprocess.run( + [sys.executable, os.path.join(SCRIPTS_DIR, "session_stats.py"), "init"], + capture_output=True, + text=True, + env=env, + ) + assert result.returncode == 0 + + +def test_cli_report_no_data(tmp_path): + """Test CLI invocation: report with no prior init prints fallback.""" + env = {**os.environ, "USER": f"test_{os.getpid()}"} + result = subprocess.run( + [sys.executable, os.path.join(SCRIPTS_DIR, "session_stats.py"), "report"], + capture_output=True, + text=True, + env=env, + ) + assert result.returncode == 0