diff --git a/mem0-plugin/README.md b/mem0-plugin/README.md index 12845318f..732c44681 100644 --- a/mem0-plugin/README.md +++ b/mem0-plugin/README.md @@ -157,6 +157,32 @@ After installing, confirm the MCP server is connected: - **Mem0 SDK Skill** — Guides the AI on how to integrate the Mem0 SDK (Python & TypeScript) into your applications. - **Memory Protocol Skill** — Codex-specific skill that instructs the agent to retrieve relevant memories at task start, store learnings on completion, and capture session state before context loss. Complements the lifecycle hooks on Codex. +## Updating the plugin + +When the plugin updates (new version pulled from the marketplace, or a fresh local install), the MCP server connection in your existing Claude Code / Cursor / Codex session is left holding a stale handle and stops responding. **Restart your client to reconnect:** + +- **Claude Code:** run `/restart` in the prompt, or close and reopen the CLI. +- **Cursor:** quit and relaunch. +- **Codex:** restart the editor session. + +Your `MEM0_API_KEY` doesn't need to be re-entered — the auth header is re-read from your environment on the new session. The plugin's MCP config uses `${MEM0_API_KEY}` interpolation at session start, not at install time, so as long as the env var is set persistently (in your shell profile or `~/.claude/settings.json` `env` block), reconnection is automatic on restart. + +If reconnection still fails after a restart, check that `MEM0_API_KEY` is reachable in the new shell (`echo $MEM0_API_KEY`) and confirm you're using a key that starts with `m0-` (from https://app.mem0.ai/dashboard/api-keys, not a legacy token). + +## Optional: tune categories for coding workflows + +mem0 auto-tags every memory with one or more `categories` from a project-level list. The default list is consumer-oriented (`food`, `hobbies`, `music` …) — useful for chat assistants, less so for code. A one-shot script in this plugin replaces it with a coding-focused taxonomy: + +```bash +# Dry-run first -- prints current vs proposed, no changes: +python mem0-plugin/scripts/setup_coding_categories.py + +# Actually write: +python mem0-plugin/scripts/setup_coding_categories.py --apply +``` + +Requires the `mem0ai` Python SDK (`pip install mem0ai`) and `MEM0_API_KEY` set. New memories will then auto-tag against `architecture_decisions`, `anti_patterns`, `task_learnings`, `tooling_setup`, `bug_fixes`, `coding_conventions`, `user_preferences`. Re-run with a different list any time; `project.update(custom_categories=[...])` always replaces. + ## MCP Tools Once installed, the following tools are available: diff --git a/mem0-plugin/hooks/cursor-hooks.json b/mem0-plugin/hooks/cursor-hooks.json index 442d3718e..124148bea 100644 --- a/mem0-plugin/hooks/cursor-hooks.json +++ b/mem0-plugin/hooks/cursor-hooks.json @@ -15,10 +15,6 @@ "preCompact": [ { "command": "${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact.sh" - }, - { - "command": "python3 ${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact.py", - "timeout": 30 } ], "stop": [ diff --git a/mem0-plugin/hooks/hooks.json b/mem0-plugin/hooks/hooks.json index 897a978d9..2c98eb9d3 100644 --- a/mem0-plugin/hooks/hooks.json +++ b/mem0-plugin/hooks/hooks.json @@ -30,12 +30,6 @@ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_pre_compact.sh", "statusMessage": "Preparing pre-compaction summary..." - }, - { - "type": "command", - "command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/on_pre_compact.py", - "statusMessage": "Saving session state to mem0...", - "timeout": 30 } ] } diff --git a/mem0-plugin/scripts/_identity.py b/mem0-plugin/scripts/_identity.py new file mode 100644 index 000000000..0725be58e --- /dev/null +++ b/mem0-plugin/scripts/_identity.py @@ -0,0 +1,59 @@ +"""Resolve mem0 user_id with deterministic priority. + +Resolution priority: + 1. MEM0_USER_ID env var (explicit override) + 2. ~/.mem0/identity.json cache (pinned to current MEM0_API_KEY fingerprint) + 3. Derived: "mem0-" + sha256(MEM0_API_KEY)[:12] + 4. Fallback: $USER, else "default" + +Same MEM0_API_KEY across machines yields the same user_id, which fixes +the "47 user buckets per account" symptom from running on multiple +laptops with different $USER values. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from datetime import datetime, timezone + +_CACHE_PATH = os.path.expanduser("~/.mem0/identity.json") + + +def resolve_user_id() -> str: + explicit = os.environ.get("MEM0_USER_ID", "").strip() + if explicit: + return explicit + + api_key = os.environ.get("MEM0_API_KEY", "").strip() + if api_key: + digest = hashlib.sha256(api_key.encode("utf-8")).hexdigest() + fingerprint = digest[:8] + + try: + with open(_CACHE_PATH, "r") as f: + cached = json.load(f) + if cached.get("api_key_fingerprint") == fingerprint and cached.get("user_id"): + return cached["user_id"] + except (OSError, json.JSONDecodeError): + pass + + derived = "mem0-" + digest[:12] + try: + os.makedirs(os.path.dirname(_CACHE_PATH), exist_ok=True) + with open(_CACHE_PATH, "w") as f: + json.dump( + { + "user_id": derived, + "source": "api_key", + "api_key_fingerprint": fingerprint, + "resolved_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + f, + ) + except OSError: + pass + return derived + + return os.environ.get("USER") or "default" diff --git a/mem0-plugin/scripts/_identity.sh b/mem0-plugin/scripts/_identity.sh new file mode 100644 index 000000000..3e0b62a17 --- /dev/null +++ b/mem0-plugin/scripts/_identity.sh @@ -0,0 +1,57 @@ +# Source this file. Sets MEM0_RESOLVED_USER_ID. +# +# Resolution priority: +# 1. MEM0_USER_ID env var (explicit override) +# 2. ~/.mem0/identity.json cache (pinned to current MEM0_API_KEY fingerprint) +# 3. Derived: "mem0-" + sha256(MEM0_API_KEY)[:12] +# 4. Fallback: $USER, else "default" +# +# Same MEM0_API_KEY across machines yields the same user_id, which fixes +# the "47 user buckets per account" symptom from running on multiple +# laptops with different $USER values. + +_mem0_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | cut -d' ' -f1 + else + shasum -a 256 | cut -d' ' -f1 + fi +} + +_mem0_resolve_identity() { + if [ -n "${MEM0_USER_ID:-}" ]; then + printf '%s' "$MEM0_USER_ID" + return + fi + + local api_key="${MEM0_API_KEY:-}" + local cache="$HOME/.mem0/identity.json" + + if [ -n "$api_key" ]; then + local digest + digest=$(printf '%s' "$api_key" | _mem0_sha256) + local fp="${digest:0:8}" + + if [ -f "$cache" ]; then + local cached_fp cached_id + cached_fp=$(jq -r '.api_key_fingerprint // ""' "$cache" 2>/dev/null) + cached_id=$(jq -r '.user_id // ""' "$cache" 2>/dev/null) + if [ "$cached_fp" = "$fp" ] && [ -n "$cached_id" ]; then + printf '%s' "$cached_id" + return + fi + fi + + local derived="mem0-${digest:0:12}" + mkdir -p "$HOME/.mem0" 2>/dev/null && \ + printf '{"user_id":"%s","source":"api_key","api_key_fingerprint":"%s","resolved_at":"%s"}\n' \ + "$derived" "$fp" "$(date -u +%FT%TZ)" > "$cache" 2>/dev/null + printf '%s' "$derived" + return + fi + + printf '%s' "${USER:-default}" +} + +MEM0_RESOLVED_USER_ID="$(_mem0_resolve_identity)" +export MEM0_RESOLVED_USER_ID diff --git a/mem0-plugin/scripts/block_memory_write.sh b/mem0-plugin/scripts/block_memory_write.sh index 0cf6dbc84..bb38686d6 100755 --- a/mem0-plugin/scripts/block_memory_write.sh +++ b/mem0-plugin/scripts/block_memory_write.sh @@ -13,6 +13,10 @@ set -euo pipefail +if [ -n "${MEM0_DEBUG:-}" ]; then + mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" +fi + INPUT=$(cat) FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""' 2>/dev/null || echo "") @@ -22,7 +26,7 @@ if [ -z "$FILE_PATH" ]; then fi case "$FILE_PATH" in - */MEMORY.md|*/memory/*.md|*/.claude/*/memory/*) + */MEMORY.md|*/.claude/memory/*) echo "BLOCKED: Do not write to $FILE_PATH. Use the mem0 MCP \`add_memory\` tool instead to persist memories. This project uses mem0 for all memory storage." >&2 exit 2 ;; diff --git a/mem0-plugin/scripts/capture_compact_summary.py b/mem0-plugin/scripts/capture_compact_summary.py new file mode 100644 index 000000000..b6df7c42e --- /dev/null +++ b/mem0-plugin/scripts/capture_compact_summary.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Capture the post-compaction summary into mem0. + +PreCompact hooks fire BEFORE the summary is generated, so they can't +store the actual compact-summary text. This script runs at +SessionStart with source=compact, reads the transcript, finds the +most recent entry flagged isCompactSummary=true, and stores it as a +memory tagged metadata.type=compact_summary. + +Input: JSON on stdin with transcript_path, session_id, source +Output: stderr logs only (exit 0 always -- must not block) + +Spawned in the background by on_session_start.sh; the user-facing +bootstrap text continues without waiting on the network. +""" + +from __future__ import annotations + +import json +import logging +import os +import sys +import urllib.error +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 + +log = logging.getLogger("mem0-compact-summary") +log.setLevel(logging.DEBUG) +_handler = logging.StreamHandler(sys.stderr) +_handler.setFormatter(logging.Formatter("[mem0-compact-summary] %(message)s")) +log.addHandler(_handler) + +if os.environ.get("MEM0_DEBUG"): + _log_dir = os.path.expanduser("~/.mem0") + try: + os.makedirs(_log_dir, exist_ok=True) + _file_handler = logging.FileHandler(os.path.join(_log_dir, "hooks.log")) + _file_handler.setFormatter(logging.Formatter("[mem0-compact-summary] %(asctime)s %(message)s")) + log.addHandler(_file_handler) + except OSError: + pass + +API_URL = "https://api.mem0.ai" +MAX_TAIL_LINES = 2000 +MAX_SUMMARY_CHARS = 50000 +# Compact summaries describe a single session's state -- stale after a quarter. +COMPACT_SUMMARY_EXPIRY_DAYS = 90 + + +def tail_lines(filepath: str, n: int) -> list[str]: + try: + with open(filepath, "rb") as f: + f.seek(0, 2) + file_size = f.tell() + if file_size == 0: + return [] + chunk_size = min(file_size, n * 4096) + f.seek(max(0, file_size - chunk_size)) + data = f.read().decode("utf-8", errors="replace") + return data.splitlines()[-n:] + except OSError: + return [] + + +def find_compact_summary(lines: list[str]) -> str: + """Walk transcript backwards, return text content of the most recent + entry flagged isCompactSummary=true. Empty string if none found.""" + for line in reversed(lines): + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if not entry.get("isCompactSummary"): + continue + + message = entry.get("message", {}) + content = message.get("content", []) + if isinstance(content, str): + return content[:MAX_SUMMARY_CHARS] + if isinstance(content, list): + parts = [] + for block in content: + if isinstance(block, str): + parts.append(block) + elif isinstance(block, dict) and block.get("type") == "text": + parts.append(block.get("text", "")) + return "\n".join(parts).strip()[:MAX_SUMMARY_CHARS] + return "" + + +def store_summary(api_key: str, summary: str, user_id: str, session_id: str) -> bool: + expires = (date.today() + timedelta(days=COMPACT_SUMMARY_EXPIRY_DAYS)).isoformat() + body = { + "messages": [{"role": "user", "content": summary}], + "user_id": user_id, + "metadata": { + "type": "compact_summary", + "source": "session-start-compact", + "session_id": session_id, + }, + "infer": False, + "expiration_date": expires, + } + + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request( + f"{API_URL}/v1/memories/", + data=data, + headers={ + "Content-Type": "application/json", + "Authorization": f"Token {api_key}", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + if resp.status in (200, 201): + log.info("Compact summary stored") + return True + log.warning("API returned status %d", resp.status) + return False + except urllib.error.URLError as e: + log.warning("API call failed: %s", e) + return False + + +def main(): + api_key = os.environ.get("MEM0_API_KEY", "") + if not api_key: + log.debug("MEM0_API_KEY not set, skipping capture") + return + + try: + hook_input = json.loads(sys.stdin.read()) + except (json.JSONDecodeError, OSError): + log.debug("No valid JSON on stdin") + return + + transcript_path = hook_input.get("transcript_path", "") + if not transcript_path: + log.debug("No transcript_path provided") + return + + session_id = hook_input.get("session_id", "") + user_id = resolve_user_id() + + lines = tail_lines(transcript_path, MAX_TAIL_LINES) + if not lines: + log.debug("Transcript empty or unreadable: %s", transcript_path) + return + + summary = find_compact_summary(lines) + if not summary: + log.debug("No isCompactSummary entry found") + return + + log.info("Capturing compact summary (%d chars)", len(summary)) + store_summary(api_key, summary, user_id, session_id) + + +if __name__ == "__main__": + try: + main() + except Exception as e: + log.error("Unexpected error: %s", e) + sys.exit(0) diff --git a/mem0-plugin/scripts/on_pre_compact.py b/mem0-plugin/scripts/on_pre_compact.py index fb59e8d5c..6f280842e 100755 --- a/mem0-plugin/scripts/on_pre_compact.py +++ b/mem0-plugin/scripts/on_pre_compact.py @@ -18,8 +18,12 @@ import json import logging import os import sys -import urllib.request import urllib.error +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 log = logging.getLogger("mem0-capture") log.setLevel(logging.DEBUG) @@ -27,11 +31,25 @@ _handler = logging.StreamHandler(sys.stderr) _handler.setFormatter(logging.Formatter("[mem0-capture] %(message)s")) log.addHandler(_handler) +if os.environ.get("MEM0_DEBUG"): + _log_dir = os.path.expanduser("~/.mem0") + try: + os.makedirs(_log_dir, exist_ok=True) + _file_handler = logging.FileHandler(os.path.join(_log_dir, "hooks.log")) + _file_handler.setFormatter(logging.Formatter("[mem0-capture] %(asctime)s %(message)s")) + log.addHandler(_file_handler) + except OSError: + pass + API_URL = "https://api.mem0.ai" MAX_TAIL_LINES = 500 MAX_USER_MESSAGES = 30 MAX_BASH_COMMANDS = 20 MAX_ASSISTANT_TEXT = 10000 +# session_state captures churn fast (active codebase, files in flight). Past +# ~3 months they're stale noise. Durable facts (decisions, conventions) are +# stored separately by the agent without an expiration_date. +SESSION_STATE_EXPIRY_DAYS = 90 def tail_lines(filepath: str, n: int) -> list[str]: @@ -149,8 +167,9 @@ def build_content(state: dict, source: str) -> str: return "\n".join(parts) -def store_memory(api_key: str, content: str, user_id: str, source: str) -> bool: +def store_memory(api_key: str, content: str, user_id: str, source: str, session_id: str = "") -> bool: """Store session state as a memory via the Mem0 REST API.""" + expires = (date.today() + timedelta(days=SESSION_STATE_EXPIRY_DAYS)).isoformat() body = { "messages": [ {"role": "user", "content": content} @@ -159,7 +178,9 @@ def store_memory(api_key: str, content: str, user_id: str, source: str) -> bool: "metadata": { "type": "session_state", "source": source, + "session_id": session_id, }, + "expiration_date": expires, } data = json.dumps(body).encode("utf-8") @@ -207,7 +228,8 @@ def main(): log.debug("No transcript_path provided") return - user_id = os.environ.get("MEM0_USER_ID", os.environ.get("USER", "default")) + session_id = hook_input.get("session_id", "") + user_id = resolve_user_id() lines = tail_lines(transcript_path, MAX_TAIL_LINES) if not lines: @@ -228,7 +250,7 @@ def main(): len(state["bash_commands"]), ) - store_memory(api_key, content, user_id, source) + store_memory(api_key, content, user_id, source, session_id) if __name__ == "__main__": diff --git a/mem0-plugin/scripts/on_pre_compact.sh b/mem0-plugin/scripts/on_pre_compact.sh index 0cd1dead2..a3dffd5eb 100755 --- a/mem0-plugin/scripts/on_pre_compact.sh +++ b/mem0-plugin/scripts/on_pre_compact.sh @@ -5,12 +5,16 @@ # the full context before it gets compressed. # # Output: Text instructions injected into Claude's context. -# Claude still has the full conversation and can write an accurate summary. -# A companion Python script (on_pre_compact.py) also runs to capture -# transcript state directly via the Mem0 REST API as a safety net. +# Claude still has the full conversation and can write an accurate summary, +# which it stores via add_memory(infer=False) so the platform preserves +# the structure verbatim instead of running a second extraction pass. set -euo pipefail +if [ -n "${MEM0_DEBUG:-}" ]; then + mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" +fi + cat <<'EOF' ## CRITICAL: Pre-Compaction Session Summary @@ -18,7 +22,9 @@ Context compaction is about to happen. You are about to lose most of your conver ### Step 1: Store session summary -Call `add_memory` with a thorough summary covering ALL of the following: +Call `add_memory` with `infer=False` and a thorough summary covering ALL of the following. + +`infer=False` is critical here: you've already done the extraction work yourself using full context. Without it, the platform runs a second LLM pass that loses your structure and pulls fragmented facts. With it, your summary is preserved verbatim. ``` ## Session Summary (Pre-Compaction) @@ -44,11 +50,19 @@ Call `add_memory` with a thorough summary covering ALL of the following: the post-compaction agent continue without asking redundant questions] ``` -Include metadata: `{"type": "session_state", "source": "pre-compaction"}` +Tool call shape: +``` +add_memory( + messages=[{"role":"user","content":""}], + user_id="", + metadata={"type":"session_state","source":"pre-compaction"}, + infer=False, +) +``` ### Step 2: Store any unstored learnings -If there are learnings from this session that you haven't stored yet, store them as separate memories: +If there are learnings from this session that you haven't stored yet, store them as separate memories with `infer=False` (same reasoning -- you've already extracted the fact, don't re-extract): - Failed approaches -> metadata `{"type": "anti_pattern"}` - Successful strategies -> metadata `{"type": "task_learning"}` - Architecture decisions -> metadata `{"type": "decision"}` diff --git a/mem0-plugin/scripts/on_session_start.sh b/mem0-plugin/scripts/on_session_start.sh index 353a17130..cd31eb39a 100755 --- a/mem0-plugin/scripts/on_session_start.sh +++ b/mem0-plugin/scripts/on_session_start.sh @@ -11,9 +11,34 @@ # even if jq is missing or stdin is malformed. set -uo pipefail +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" + INPUT=$(cat) SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup") +# Identity line is emitted before every bootstrap variant so the agent +# uses the same user_id the hooks resolved. Without this, the agent's +# search_memories/add_memory MCP calls may bind to a different bucket +# than what the hooks write to. +echo "## Mem0 Identity" +echo "" +echo "Active user_id: \`$MEM0_RESOLVED_USER_ID\`" +echo "" +echo "Always include \`{\"user_id\": \"$MEM0_RESOLVED_USER_ID\"}\` (wrapped in an \`AND\` clause) in every \`search_memories\` filter and as \`user_id\` on every \`add_memory\` call. This keeps memories under one bucket regardless of which machine you're on." +echo "" + if [ "$SOURCE" = "startup" ]; then cat <<'EOF' ## Mem0 Session Bootstrap @@ -40,14 +65,22 @@ Continue where you left off. EOF elif [ "$SOURCE" = "compact" ]; then + # Capture the just-generated compact summary in the background. + # PreCompact fires too early to see this entry; SessionStart-compact + # is the first place isCompactSummary=true is in the transcript. + echo "$INPUT" | python3 "$SCRIPT_DIR/capture_compact_summary.py" 2>/dev/null & + cat <<'EOF' ## Mem0 Post-Compaction Recovery -Context was just compacted. You may have lost important session context. +Context was just compacted. The Claude Code-generated compact summary +is being captured to mem0 in the background as `metadata.type=compact_summary`. -1. Call `search_memories` with queries related to what you were working on to reload relevant knowledge. -2. Check for any session state memories that were saved before compaction. -3. Continue working based on the recovered context. +1. Call `search_memories` to reload context, layering up to three angles: + - `metadata.type=session_state` -- the rich pre-compaction summary you wrote + - `metadata.type=compact_summary` -- the platform-generated condensed summary just now + - `metadata.type=decision` / `anti_pattern` -- specific facts you stored during the session +2. Continue working from the recovered context. EOF fi diff --git a/mem0-plugin/scripts/on_stop.sh b/mem0-plugin/scripts/on_stop.sh index 5dd088f69..848ab9906 100755 --- a/mem0-plugin/scripts/on_stop.sh +++ b/mem0-plugin/scripts/on_stop.sh @@ -12,6 +12,10 @@ set -euo 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) diff --git a/mem0-plugin/scripts/on_stop_codex.sh b/mem0-plugin/scripts/on_stop_codex.sh index 80c16d311..de80b0fed 100755 --- a/mem0-plugin/scripts/on_stop_codex.sh +++ b/mem0-plugin/scripts/on_stop_codex.sh @@ -17,6 +17,10 @@ set -uo pipefail +if [ -n "${MEM0_DEBUG:-}" ]; then + mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" +fi + INPUT=$(cat) STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false") diff --git a/mem0-plugin/scripts/on_task_completed.sh b/mem0-plugin/scripts/on_task_completed.sh index befc1b34a..57a568027 100755 --- a/mem0-plugin/scripts/on_task_completed.sh +++ b/mem0-plugin/scripts/on_task_completed.sh @@ -9,6 +9,10 @@ set -euo pipefail +if [ -n "${MEM0_DEBUG:-}" ]; then + mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" +fi + INPUT=$(cat) TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // "unknown task"' 2>/dev/null || echo "unknown task") diff --git a/mem0-plugin/scripts/on_user_prompt.sh b/mem0-plugin/scripts/on_user_prompt.sh index 70f8eaa44..50906a1d9 100755 --- a/mem0-plugin/scripts/on_user_prompt.sh +++ b/mem0-plugin/scripts/on_user_prompt.sh @@ -13,6 +13,10 @@ # must never block the user's prompt. set -uo pipefail +if [ -n "${MEM0_DEBUG:-}" ]; then + mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" +fi + INPUT=$(cat) PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""' 2>/dev/null || echo "") @@ -26,7 +30,10 @@ if [ -z "${MEM0_API_KEY:-}" ]; then exit 0 fi -USER_ID="${MEM0_USER_ID:-${USER:-default}}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=_identity.sh +. "$SCRIPT_DIR/_identity.sh" +USER_ID="$MEM0_RESOLVED_USER_ID" cat < int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument( + "--apply", + action="store_true", + help="Actually call project.update(). Without this flag, runs in dry-run mode.", + ) + 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) + return 1 + + 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.", + file=sys.stderr, + ) + return 1 + + try: + client = MemoryClient() + except Exception as e: + print( + f"ERROR initialising MemoryClient: {e}\n" + "Most commonly this is an invalid MEM0_API_KEY -- check the key at " + "https://app.mem0.ai/dashboard/api-keys", + file=sys.stderr, + ) + return 1 + + try: + current = client.project.get(fields=["custom_categories"]) + current_cats = current.get("custom_categories") if isinstance(current, dict) else None + except Exception as e: + print(f"ERROR fetching current categories: {e}", file=sys.stderr) + return 1 + + _print_categories("Current project categories", current_cats) + _print_categories("Proposed coding categories", CODING_CATEGORIES) + + if not args.apply: + print("Dry-run only -- no changes made. Re-run with --apply to write.") + return 0 + + print("Applying coding categories...") + try: + response = client.project.update(custom_categories=CODING_CATEGORIES) + except Exception as e: + print(f"ERROR applying update: {e}", file=sys.stderr) + return 1 + + print("Done.", response if response else "") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/mem0-plugin/skills/mem0-mcp/SKILL.md b/mem0-plugin/skills/mem0-mcp/SKILL.md index 11331f19a..bfdd15789 100644 --- a/mem0-plugin/skills/mem0-mcp/SKILL.md +++ b/mem0-plugin/skills/mem0-mcp/SKILL.md @@ -97,8 +97,47 @@ Extract key learnings and store them using the `add_memory` tool: - **Environment/setup discoveries** -> Include metadata `{"type": "environmental"}` - **Conventions established** -> Include metadata `{"type": "convention"}` +> `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 + +Some memory types are state snapshots that go stale fast; others are durable facts that should outlive the session that created them. Mark the difference with `expiration_date` on writes. + +| Type | Expiration | Why | +|---|---|---| +| `session_state`, `compact_summary` | `expiration_date` ≈ today + 90 days | Describe a single moment of project state. Useless after a quarter; clutter the recall surface. | +| `decision`, `anti_pattern`, `convention`, `user_preference`, `task_learning`, `environmental` | omit `expiration_date` | Durable facts. A decision made last year is still a decision; same for a convention or a user preference. | + +`add_memory` accepts `expiration_date` as a string (`"YYYY-MM-DD"`). The two server-side hooks (`on_pre_compact.py`, `capture_compact_summary.py`) already set this for the types they write. When you write directly via the MCP tool, follow the same rule. + +### Recency filter on recall + +When the user is asking about *current* state ("where were we", "what's the active task", "the latest decision on X"), filter recall to recent memories so stale snapshots don't surface: + +```python +# Last 90 days only +{"AND": [{"user_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. + 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. +### Use `infer=False` for already-structured content + +When you've done the extraction work yourself — pre-compaction summaries, decisions, anti-patterns, conventions you've explicitly identified — pass `infer=False` so the platform stores your text verbatim instead of running a second extraction pass over it. + +```python +add_memory( + messages=[{"role": "user", "content": ""}], + user_id="", + metadata={"type": "decision"}, + infer=False, +) +``` + +Stick to one mode per distinct piece of content — don't mix `infer=True` (default) and `infer=False` for the same fact, you'll get duplicates. Default (`infer=True`) is right for raw conversational signal you want extracted; `infer=False` is right for pre-extracted structure. + ## Before losing context If context is about to be compacted or the session is ending, store a comprehensive session summary: diff --git a/pyproject.toml b/pyproject.toml index e905f57d2..3565ff22f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -154,3 +154,4 @@ known-first-party = ["mem0", "mem0_cli"] profile = "black" known_first_party = ["mem0", "mem0_cli"] # isort scope kept aligned with [tool.ruff.lint.isort] above. +# black-equivalent profile here matches the formatter behaviour ruff applies.