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