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