diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ffd139b12..3646c9399 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "mem0", "source": "./mem0-plugin", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.", - "version": "0.2.4" + "version": "0.2.5" } ] } diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 68a8d34a5..632174afb 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "mem0", "source": "./mem0-plugin", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.", - "version": "0.2.4" + "version": "0.2.5" } ] } diff --git a/docs/integrations/codex.mdx b/docs/integrations/codex.mdx index 85d24680d..5203825ac 100644 --- a/docs/integrations/codex.mdx +++ b/docs/integrations/codex.mdx @@ -180,7 +180,7 @@ The Codex plugin manifest (`.codex-plugin/plugin.json`) follows the Codex plugin ```json { "name": "mem0", - "version": "0.1.0", + "version": "0.2.5", "description": "Mem0 memory layer for AI applications.", "skills": "./skills/", "mcpServers": "./.codex-mcp.json", diff --git a/mem0-plugin/.claude-plugin/plugin.json b/mem0-plugin/.claude-plugin/plugin.json index 68da6ebc6..117901d6a 100644 --- a/mem0-plugin/.claude-plugin/plugin.json +++ b/mem0-plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.2.4", + "version": "0.2.5", "description": "Persistent memory for Claude Code. Remembers decisions, patterns, and preferences across sessions.", "author": { "name": "Mem0", diff --git a/mem0-plugin/.codex-plugin/plugin.json b/mem0-plugin/.codex-plugin/plugin.json index e7191d879..a331d3de5 100644 --- a/mem0-plugin/.codex-plugin/plugin.json +++ b/mem0-plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.2.4", + "version": "0.2.5", "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 43a7bfd0e..9451a419e 100644 --- a/mem0-plugin/.cursor-plugin/plugin.json +++ b/mem0-plugin/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.2.4", + "version": "0.2.5", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.", "author": { "name": "Mem0", diff --git a/mem0-plugin/CHANGELOG.md b/mem0-plugin/CHANGELOG.md index eb3a1559b..a502546df 100644 --- a/mem0-plugin/CHANGELOG.md +++ b/mem0-plugin/CHANGELOG.md @@ -2,6 +2,50 @@ All notable changes to the Mem0 plugin will be documented in this file. +## 0.2.5 + +### Fixed + +- **PostToolUse field name: `tool_output` → `tool_response`:** All three PostToolUse scripts (`on_bash_output.sh`, `on_post_commit.sh`, `on_post_tool_use.sh`) were reading `.tool_output` from stdin JSON — a field that never existed in the Claude Code hooks spec. The correct field is `.tool_response` (confirmed via official docs at code.claude.com/docs/en/hooks). This was silently `null` on every invocation, meaning bash error detection and post-commit checks never actually fired. +- **Stop hook invalid `hookSpecificOutput`:** `on_stop.sh` returned `hookSpecificOutput` with `hookEventName: "Stop"` — but `Stop` is not a valid `hookEventName` discriminant. Claude Code rejected the JSON with "Hook JSON output validation failed". Replaced with spec-compliant `{ decision: "block", reason: "..." }`. +- **SessionStart banner invisible:** Switched from JSON `hookSpecificOutput.additionalContext` (discrete/hidden system reminder) back to raw text `cat <)` to actually remove the losing memory. +- **`/mem0:health` Check 3 `search_memories` top-level `user_id`:** Removed top-level `user_id` param; identity only in `filters.AND[]`. Changed `limit` to `top_k`. +- **`/mem0:health` Check 4 `add_memory` missing `infer=False`:** Health probe wasted LLM tokens on extraction. Added `infer=False`. Also fixed: was expecting `memory_id` in response but v3 returns `event_id`. Now uses `get_event_status` to get memory ID for cleanup. +- **`/mem0:tour` `get_memories` top-level identity:** Both standard and cross-project modes passed `user_id`/`app_id` as top-level params. Moved to `filters.AND[]`. +- **`/mem0:onboard` `search_memories` top-level `user_id`:** Removed extra top-level `user_id` param from connectivity check. +- **`/mem0:context-loader` incomplete filter table:** Filter examples showed only `metadata.type` without `user_id`/`app_id`. Now shows full `AND` filter structure. +- **7 skills used `limit` instead of `top_k` for `search_memories`:** MCP tool param is `top_k`, not `limit`. Fixed in: health, onboard, tour (3 places), switch-project, stats (weekly mode + latency probe). +- **`/mem0:stats` latency probe missing `filters`:** `search_memories` call had no identity filters. Added `user_id`/`app_id` in `filters.AND[]`. + +### Added + +- **`stop_hook_check.py`:** Pure-stdlib transcript analyzer for the Stop hook. Reads last 500 lines of transcript JSONL, parses tool calls, file modifications, and git commands. Returns `{"should_block": bool, "context": "..."}`. Trivial sessions (< 3 tool calls, no file edits) skip capture entirely. +- **Checklist for `/mem0:dream`:** 6-step progress tracker per Claude skill best practices for complex multi-step workflows. +- **Checklist for `/mem0:onboard`:** 7-step progress tracker for onboarding wizard. +- **Expanded hook matcher (all 3 configs):** `enforce_metadata_defaults.sh` now triggers on `add_memory`, `search_memories`, `get_memories`, `get_memory`, `update_memory`, and `delete_all_memories` (12 tool name variants covering both MCP naming conventions). + +### Changed + +- **Stop hook uses MCP-driven capture:** When meaningful work detected and no memories stored, returns `decision: "block"` asking Claude to call `add_memory` via MCP. One-shot flag prevents infinite loops. REST API capture runs in background as fallback. +- **SessionStart banner uses raw text stdout:** Replaced JSON `additionalContext` with `cat < bool: + """Try to acquire a file lock. Returns False if another instance is running.""" + try: + os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True) + fd = os.open(LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.write(fd, str(os.getpid()).encode()) + os.close(fd) + return True + except FileExistsError: + try: + mtime = os.path.getmtime(LOCK_FILE) + import time + if time.time() - mtime > 120: + os.unlink(LOCK_FILE) + return _acquire_lock() + except OSError: + pass + return False + + +def _release_lock() -> None: + try: + os.unlink(LOCK_FILE) + except OSError: + pass def _git_root(cwd: str) -> str: @@ -103,7 +131,7 @@ def already_imported(api_key: str, user_id: str, project_id: str, filename: str) {"metadata": {"source": "auto-import"}}, ] }, - "top_k": 3, + "top_k": 10, "threshold": 0.0, }).encode() req = urllib.request.Request( @@ -118,13 +146,70 @@ def already_imported(api_key: str, user_id: str, project_id: str, filename: str) results = data if isinstance(data, list) else data.get("results", []) for result in results: meta = result.get("metadata", {}) if isinstance(result, dict) else {} - if filename in meta.get("file", ""): + file_field = meta.get("file", "") + if file_field == filename or file_field.startswith(f"{filename}["): return True return False except Exception: return False +def _delete_stale_chunks(api_key: str, user_id: str, project_id: str, filename: str) -> int: + """Find and delete existing chunks for a file before re-import. Returns count deleted.""" + body = json.dumps({ + "query": filename, + "filters": { + "AND": [ + {"user_id": user_id}, + {"app_id": project_id}, + {"metadata": {"source": "auto-import"}}, + ] + }, + "top_k": 20, + "threshold": 0.0, + }).encode() + req = urllib.request.Request( + f"{API_URL}/v3/memories/search/", + data=body, + headers={"Content-Type": "application/json", "Authorization": f"Token {api_key}"}, + method="POST", + ) + ids_to_delete = [] + try: + with urllib.request.urlopen(req, timeout=10) as r: + data = json.loads(r.read()) + results = data if isinstance(data, list) else data.get("results", []) + for result in results: + if not isinstance(result, dict): + continue + meta = result.get("metadata", {}) + file_field = meta.get("file", "") + if file_field == filename or file_field.startswith(f"{filename}["): + mid = result.get("id") + if mid: + ids_to_delete.append(mid) + except Exception as e: + log.warning("Failed to search for stale chunks of %s: %s", filename, e) + return 0 + + deleted = 0 + for mid in ids_to_delete: + try: + del_req = urllib.request.Request( + f"{API_URL}/v3/memories/{mid}/", + headers={"Authorization": f"Token {api_key}"}, + method="DELETE", + ) + with urllib.request.urlopen(del_req, timeout=10): + deleted += 1 + except Exception as e: + log.warning("Failed to delete stale chunk %s: %s", mid, e) + + if deleted: + log.info("Deleted %d stale chunk(s) for %s before re-import", deleted, filename) + return deleted + + 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 = { @@ -229,12 +314,14 @@ def main() -> None: continue seen_content_hashes.add(current_hash) - hash_key = f"{project_id}:{filename}" + hash_key = f"{project_id}:{branch}:{filename}" if branch else f"{project_id}:{filename}" if hashes.get(hash_key) == current_hash: - log.debug("Unchanged, skipping: %s", filename) - continue + if already_imported(api_key, user_id, project_id, filename): + log.debug("Unchanged and still in mem0, skipping: %s", filename) + continue + log.info("Hash matches but memories missing server-side, re-importing: %s", filename) - if already_imported(api_key, user_id, project_id, filename): + elif already_imported(api_key, user_id, project_id, filename): log.debug("Already in mem0, updating hash store: %s", filename) hashes[hash_key] = current_hash updated = True @@ -247,6 +334,8 @@ def main() -> None: log.debug("Cannot read %s: %s", filename, e) continue + _delete_stale_chunks(api_key, user_id, project_id, filename) + is_markdown = filename.endswith(".md") if is_markdown: chunks = filter_and_truncate(split_by_headers(content)) @@ -273,8 +362,13 @@ def main() -> None: if __name__ == "__main__": + if not _acquire_lock(): + log.debug("Another auto_import instance is running — skipping") + sys.exit(0) try: main() except Exception as e: log.error("Unexpected error: %s", e) + finally: + _release_lock() sys.exit(0) diff --git a/mem0-plugin/scripts/capture_compact_summary.py b/mem0-plugin/scripts/capture_compact_summary.py index 28ac038f1..d6e70b356 100644 --- a/mem0-plugin/scripts/capture_compact_summary.py +++ b/mem0-plugin/scripts/capture_compact_summary.py @@ -168,8 +168,25 @@ def main(): log.debug("No isCompactSummary entry found") return + if len(summary.strip()) < 100: + log.debug("Compact summary too short (%d chars) — skipping", len(summary.strip())) + return + + marker_dir = os.path.expanduser("~/.mem0") + marker_file = os.path.join(marker_dir, f"compact_captured_{session_id}") + if session_id and os.path.isfile(marker_file): + log.info("Compact summary already captured for session %s — skipping", session_id) + return + log.info("Capturing compact summary (%d chars)", len(summary)) - store_summary(api_key, summary, user_id, session_id, project_id, branch) + if store_summary(api_key, summary, user_id, session_id, project_id, branch): + if session_id: + try: + os.makedirs(marker_dir, exist_ok=True) + with open(marker_file, "w") as f: + f.write("") + except OSError: + pass if __name__ == "__main__": diff --git a/mem0-plugin/scripts/enforce_metadata_defaults.sh b/mem0-plugin/scripts/enforce_metadata_defaults.sh index 7f196e104..fe0fdfff1 100755 --- a/mem0-plugin/scripts/enforce_metadata_defaults.sh +++ b/mem0-plugin/scripts/enforce_metadata_defaults.sh @@ -1,8 +1,14 @@ #!/usr/bin/env bash -# PreToolUse hook for mcp__mem0__add_memory. -# Injects default metadata fields (confidence, files, source, type) when the -# agent omits them. Uses the hookSpecificOutput.updatedInput contract to -# actually modify the tool call parameters. +# PreToolUse hook for mem0 MCP tools. +# Injects identity (user_id, app_id) and metadata defaults when the agent +# omits them. Uses the hookSpecificOutput.updatedInput contract to modify +# tool call parameters before execution. +# +# Handles: +# add_memory — top-level user_id, app_id, metadata defaults +# search_memories — user_id/app_id into filters.AND[] +# get_memories — user_id/app_id into filters.AND[] +# delete_all_memories — top-level user_id, app_id # # Hook contract: # exit 0 = allow. If stdout contains {"hookSpecificOutput": {"updatedInput": ...}}, @@ -11,18 +17,35 @@ set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true + INPUT=$(cat) TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null) + +# Determine which handler to use based on tool name +HANDLER="" case "$TOOL_NAME" in - mcp__mem0__add_memory|mcp__plugin_mem0_mem0__add_memory) ;; + mcp__mem0__add_memory|mcp__plugin_mem0_mem0__add_memory) + HANDLER="add_memory" ;; + mcp__mem0__search_memories|mcp__plugin_mem0_mem0__search_memories) + HANDLER="search_memories" ;; + mcp__mem0__get_memories|mcp__plugin_mem0_mem0__get_memories) + HANDLER="get_memories" ;; + mcp__mem0__delete_all_memories|mcp__plugin_mem0_mem0__delete_all_memories) + HANDLER="delete_all" ;; *) exit 0 ;; esac TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input // "{}"' 2>/dev/null) _PATCH_OUT="/tmp/mem0_enforce_$$" -_MEM0_TOOL_INPUT="$TOOL_INPUT" python3 <<'PYEOF' > "$_PATCH_OUT" 2>/dev/null || true +_MEM0_TOOL_INPUT="$TOOL_INPUT" \ +_MEM0_USER_ID="${MEM0_RESOLVED_USER_ID:-}" \ +_MEM0_APP_ID="${MEM0_PROJECT_ID:-}" \ +_MEM0_HANDLER="$HANDLER" \ +python3 <<'PYEOF' > "$_PATCH_OUT" 2>/dev/null || true import json, os, sys raw = os.environ.get("_MEM0_TOOL_INPUT", "{}") @@ -31,40 +54,130 @@ try: except Exception: sys.exit(0) -meta = inp.get("metadata") or {} +handler = os.environ.get("_MEM0_HANDLER", "") +resolved_uid = os.environ.get("_MEM0_USER_ID", "") +resolved_aid = os.environ.get("_MEM0_APP_ID", "") changed = False -if "confidence" not in meta: - meta["confidence"] = 0.7 - changed = True -if "files" not in meta: - meta["files"] = ["*"] - changed = True -if "source" not in meta: - meta["source"] = "auto_capture" - changed = True -if "type" not in meta: - meta["type"] = "task_learning" - changed = True -if meta.get("confidence", 0) >= 1.0 and "infer" not in inp: - inp["infer"] = False - changed = True +def inject_top_level_identity(inp, uid, aid): + """Inject user_id/app_id as top-level params (for add_memory, delete_all).""" + changed = False + if uid and not inp.get("user_id"): + inp["user_id"] = uid + changed = True + if aid and not inp.get("app_id"): + inp["app_id"] = aid + changed = True + return changed -if "run_id" not in inp: - session_file = "/tmp/mem0_session_id_" + os.environ.get("USER", "default") - if os.path.isfile(session_file): - try: - with open(session_file) as f: - sid = f.read().strip() - if sid: - inp["run_id"] = sid - changed = True - except OSError: - pass + +def inject_filter_identity(inp, uid, aid): + """Inject user_id/app_id into filters.AND[] (for search/get_memories).""" + changed = False + if not uid and not aid: + return False + + filters = inp.get("filters") + + if filters is None: + # No filters at all — create from scratch + and_clauses = [] + if uid: + and_clauses.append({"user_id": uid}) + if aid: + and_clauses.append({"app_id": aid}) + inp["filters"] = {"AND": and_clauses} + return True + + if not isinstance(filters, dict): + return False + + # Check if filters already contain user_id/app_id + and_clauses = filters.get("AND") + if and_clauses is None: + # Filters exist but no AND — could be flat like {"user_id": "x"} + has_uid = "user_id" in filters + has_aid = "app_id" in filters + if has_uid and has_aid: + return False + # Convert flat filters to AND format and add missing identity + existing = [] + for k, v in list(filters.items()): + existing.append({k: v}) + if uid and not has_uid: + existing.append({"user_id": uid}) + changed = True + if aid and not has_aid: + existing.append({"app_id": aid}) + changed = True + if changed: + inp["filters"] = {"AND": existing} + return changed + + if not isinstance(and_clauses, list): + return False + + # AND array exists — check for existing user_id/app_id + has_uid = any("user_id" in c for c in and_clauses if isinstance(c, dict)) + has_aid = any("app_id" in c for c in and_clauses if isinstance(c, dict)) + + if uid and not has_uid: + and_clauses.append({"user_id": uid}) + changed = True + if aid and not has_aid: + and_clauses.append({"app_id": aid}) + changed = True + + return changed + + +if handler == "add_memory": + changed = inject_top_level_identity(inp, resolved_uid, resolved_aid) + + meta = inp.get("metadata") or {} + + if "confidence" not in meta: + meta["confidence"] = 0.7 + changed = True + if "files" not in meta: + meta["files"] = ["*"] + changed = True + if "source" not in meta: + meta["source"] = "auto_capture" + changed = True + if "type" not in meta: + meta["type"] = "task_learning" + changed = True + + if meta.get("confidence", 0) >= 1.0 and "infer" not in inp: + inp["infer"] = False + changed = True + + if "run_id" not in inp: + sid = os.environ.get("MEM0_SESSION_ID", "") + if not sid: + session_file = "/tmp/mem0_session_id_" + os.environ.get("USER", "default") + if os.path.isfile(session_file): + try: + with open(session_file) as f: + sid = f.read().strip() + except OSError: + pass + if sid: + inp["run_id"] = sid + changed = True + + if changed: + inp["metadata"] = meta + +elif handler in ("search_memories", "get_memories"): + changed = inject_filter_identity(inp, resolved_uid, resolved_aid) + +elif handler == "delete_all": + changed = inject_top_level_identity(inp, resolved_uid, resolved_aid) if changed: - inp["metadata"] = meta print(json.dumps(inp)) PYEOF PATCHED=$(cat "$_PATCH_OUT" 2>/dev/null) @@ -77,7 +190,7 @@ if [ -n "$PATCHED" ] && echo "$PATCHED" | jq empty 2>/dev/null; then "permissionDecision": "allow", "updatedInput": $updated } - }' + }' 2>/dev/null || true fi exit 0 diff --git a/mem0-plugin/scripts/import_competing_tools.py b/mem0-plugin/scripts/import_competing_tools.py index 2fd86b6de..968bbbcd2 100644 --- a/mem0-plugin/scripts/import_competing_tools.py +++ b/mem0-plugin/scripts/import_competing_tools.py @@ -17,6 +17,7 @@ Exit: 0 always from __future__ import annotations +import hashlib import json import os import sys @@ -33,6 +34,31 @@ from _identity import resolve_api_key, resolve_user_id from _project import resolve_branch, resolve_project_id API_URL = "https://api.mem0.ai" +HASH_STORE = os.path.expanduser("~/.mem0/import_hashes.json") + + +def _load_hashes() -> dict[str, str]: + if not os.path.isfile(HASH_STORE): + return {} + try: + with open(HASH_STORE) as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + return {} + + +def _save_hashes(hashes: dict[str, str]) -> None: + os.makedirs(os.path.dirname(HASH_STORE), exist_ok=True) + try: + with open(HASH_STORE, "w") as f: + json.dump(hashes, f, indent=2) + except OSError: + pass + + +def _content_hash(content: str) -> str: + return hashlib.sha256(content.encode("utf-8")).hexdigest() + # --------------------------------------------------------------------------- # API helpers @@ -73,12 +99,30 @@ def post_memory(api_key: str, content: str, user_id: str, project_id: str, branc 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.""" +def import_chunks(chunks: list[str], api_key: str, user_id: str, project_id: str, branch: str, source: str, hash_key: str = "") -> int: + """Import a list of content chunks; return number of successful imports. + + Skips import if content hash matches a previous run for the same hash_key.""" + if hash_key: + combined = "\n".join(chunks) + current_hash = _content_hash(combined) + hashes = _load_hashes() + if hashes.get(hash_key) == current_hash: + print(f"Already imported (unchanged) -- skipping: {hash_key}") + return 0 + else: + current_hash = "" + hashes = {} + success = 0 for chunk in chunks: if post_memory(api_key, chunk, user_id, project_id, branch, source): success += 1 + + if success > 0 and hash_key and current_hash: + hashes[hash_key] = current_hash + _save_hashes(hashes) + return success @@ -123,7 +167,7 @@ def cmd_cursorrules(args: list[str]) -> None: 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) + n = import_chunks(chunks, api_key, user_id, project_id, branch, source, hash_key=f"{project_id}:{source}:{path}") print(f"Imported {n} memories from {source} ({path})") @@ -152,7 +196,7 @@ def cmd_copilot(args: list[str]) -> None: 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) + n = import_chunks(chunks, api_key, user_id, project_id, branch, source, hash_key=f"{project_id}:{source}:{path}") print(f"Imported {n} memories from {source} ({path})") @@ -188,7 +232,7 @@ def cmd_cline(args: list[str]) -> None: if not content: continue chunks = filter_and_truncate([content]) - n = import_chunks(chunks, api_key, user_id, project_id, branch, source) + n = import_chunks(chunks, api_key, user_id, project_id, branch, source, hash_key=f"{project_id}:{source}:{filepath}") total += n print(f"Imported {total} memories from {source} ({dir_path})") @@ -219,7 +263,7 @@ def cmd_continue(args: list[str]) -> None: 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) + n = import_chunks(chunks, api_key, user_id, project_id, branch, source, hash_key=f"{project_id}:{source}:{path}") print(f"Imported {n} memories from {source} ({path})") diff --git a/mem0-plugin/scripts/install_codex_hooks.py b/mem0-plugin/scripts/install_codex_hooks.py index f3164287f..ff2ba671f 100755 --- a/mem0-plugin/scripts/install_codex_hooks.py +++ b/mem0-plugin/scripts/install_codex_hooks.py @@ -4,7 +4,7 @@ Codex discovers hooks only at ~/.codex/hooks.json or /.codex/hooks.json, and has no plugin-host mechanism for auto-wiring hooks from an installed plugin. This installer reads the template at hooks/codex-hooks.json, rewrites -the ${CODEX_PLUGIN_ROOT} placeholder to the absolute install path of this +the ${PLUGIN_ROOT} placeholder to the absolute install path of this plugin, then merges the entries into ~/.codex/hooks.json. Re-running is idempotent: existing Mem0 entries (identified by the plugin @@ -44,7 +44,7 @@ OWNER_MARKER = "mem0-plugin" def load_template() -> dict: raw = TEMPLATE_FILE.read_text() - raw = raw.replace("${CODEX_PLUGIN_ROOT}", str(PLUGIN_ROOT)) + raw = raw.replace("${PLUGIN_ROOT}", str(PLUGIN_ROOT)) return json.loads(raw) diff --git a/mem0-plugin/scripts/on_bash_output.sh b/mem0-plugin/scripts/on_bash_output.sh index 61f0654d1..bb28d32eb 100755 --- a/mem0-plugin/scripts/on_bash_output.sh +++ b/mem0-plugin/scripts/on_bash_output.sh @@ -9,14 +9,14 @@ # 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 +# Input: JSON on stdin with tool_name, tool_input, tool_response # 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 "") +TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_response // ""' 2>/dev/null || echo "") # Skip short output (< 50 chars unlikely to contain a real stack trace) if [ ${#TOOL_RESULT} -lt 50 ]; then @@ -98,20 +98,25 @@ for m in r1 + r2: print(format_results_for_context(combined, heading='Prior error memories'), end='') " 2>/dev/null || echo "") -# Output error header -printf '\n## Error detected in command output\n\n' -printf '`%s` produced an error:\n> %s\n\n' "$COMMAND" "$ERROR_LINE" +# Build context string for JSON output +CTX="Error detected in command output\n\n" +CTX="${CTX}\`${COMMAND}\` produced an error:\n> ${ERROR_LINE}\n" if [ -n "$FILE_DISPLAY" ]; then - printf '**Files in stack trace:**\n%s\n\n' "$FILE_DISPLAY" + CTX="${CTX}\nFiles in stack trace:\n${FILE_DISPLAY}\n" fi if [ -n "$RESULTS" ]; then - printf '%s\n' "$RESULTS" -else - printf 'No prior memories found for this error.\n\n' + CTX="${CTX}\n${RESULTS}\n" fi -printf 'If you solve this, store the fix as an `anti_pattern` or `bug_fix` memory for next time.\n' +CTX="${CTX}\nResolved errors are stored as anti_pattern or bug_fix memories for future reference." + +jq -cn --arg ctx "$CTX" '{ + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext: $ctx + } +}' 2>/dev/null || true exit 0 diff --git a/mem0-plugin/scripts/on_git_commit_capture.sh b/mem0-plugin/scripts/on_git_commit_capture.sh index a1fcabd3f..f676627a9 100755 --- a/mem0-plugin/scripts/on_git_commit_capture.sh +++ b/mem0-plugin/scripts/on_git_commit_capture.sh @@ -41,12 +41,12 @@ fi USER_ID="${MEM0_RESOLVED_USER_ID:-$USER}" PROJECT_ID="${MEM0_PROJECT_ID:-unknown}" -CONTEXT=$(python3 -c " +CONTEXT=$(_MEM0_UID="$USER_ID" _MEM0_AID="$PROJECT_ID" _MEM0_FILES="$CHANGED_FILES" 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' +user_id = os.environ.get('_MEM0_UID', '') +app_id = os.environ.get('_MEM0_AID', '') +files = os.environ.get('_MEM0_FILES', '') first_file = files.split(',')[0].strip() body = json.dumps({ 'query': f'changes to {files}', diff --git a/mem0-plugin/scripts/on_post_commit.sh b/mem0-plugin/scripts/on_post_commit.sh index bb5f968ee..aa4cc2441 100755 --- a/mem0-plugin/scripts/on_post_commit.sh +++ b/mem0-plugin/scripts/on_post_commit.sh @@ -6,7 +6,7 @@ # 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 +# Input: JSON on stdin with tool_name, tool_input, tool_response # Output: Context injected into Claude's next response (exit 0) # # This implements Spec #28 — interactive pre-commit memory check. @@ -31,7 +31,7 @@ case "$COMMAND" in 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 "") +TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_response // ""' 2>/dev/null || echo "") if echo "$TOOL_RESULT" | grep -qiE '(error|fatal|abort|nothing to commit|no changes)'; then exit 0 fi @@ -52,47 +52,26 @@ if [ "$COMMIT_PROMPTS" != "true" ]; then fi # Extract changed files from commit -CHANGED_FILES=$(git diff-tree --no-commit-id --name-only -r HEAD 2>/dev/null | head -10 || echo "") +CHANGED_FILES=$(git -C "$MEM0_CWD" diff-tree --no-commit-id --name-only -r 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 "") +COMMIT_MSG=$(git -C "$MEM0_CWD" 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 +jq -cn --arg ctx "$CTX" '{ + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext: $ctx + } +}' 2>/dev/null || true # Telemetry python3 "$SCRIPT_DIR/telemetry.py" post_commit --files_count="$FILE_COUNT" 2>/dev/null & diff --git a/mem0-plugin/scripts/on_post_compact.sh b/mem0-plugin/scripts/on_post_compact.sh index b910c5d9d..75d88b7b1 100755 --- a/mem0-plugin/scripts/on_post_compact.sh +++ b/mem0-plugin/scripts/on_post_compact.sh @@ -1,11 +1,8 @@ #!/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) +# Fires after context compaction completes. Runs silently — recovery +# is handled by SessionStart hook with source=compact. set -uo pipefail @@ -24,24 +21,4 @@ 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 < str: """ parts = [] - if state["user_messages"]: - parts.append(f"Working on: {state['user_messages'][-1][:300]}") - if state["files_modified"]: parts.append(f"Files touched: {', '.join(state['files_modified'][:15])}") + if state["bash_commands"]: + git_cmds = [c for c in state["bash_commands"] if "git " in c] + if git_cmds: + parts.append(f"Git operations: {len(git_cmds)}") + return "\n".join(parts) @@ -200,15 +202,51 @@ def store_memory(api_key: str, content: str, user_id: str, source: str, session_ return False +def format_status(state: dict, source: str, stored: bool, skipped_reason: str = "") -> str: + """Build a clean, readable status line for terminal display.""" + files_count = len(state.get("files_modified", [])) + git_cmds = [c for c in state.get("bash_commands", []) if "git " in c] + user_msgs = len(state.get("user_messages", [])) + + parts = [] + if files_count: + parts.append(f"{files_count} file{'s' if files_count != 1 else ''} touched") + if git_cmds: + parts.append(f"{len(git_cmds)} git op{'s' if len(git_cmds) != 1 else ''}") + if user_msgs: + parts.append(f"{user_msgs} exchange{'s' if user_msgs != 1 else ''}") + + activity = ", ".join(parts) if parts else "minimal activity" + + if source == "pre-compaction": + icon = "✨" # ✨ + label = "Pre-compaction snapshot" + else: + icon = "\U0001f4be" # 💾 + label = "Session-end snapshot" + + if skipped_reason: + return f"{icon} Mem0 {label} — {activity} — {skipped_reason}" + elif stored: + return f"{icon} Mem0 {label} — {activity} — saved to mem0" + else: + return f"{icon} Mem0 {label} — {activity} — nothing to capture" + + def main(): source = "pre-compaction" + show_status = False for arg in sys.argv[1:]: if arg.startswith("--source="): source = arg.split("=", 1)[1] + elif arg == "--status": + show_status = True api_key = resolve_api_key() if not api_key: log.debug("MEM0_API_KEY not set, skipping capture") + if show_status: + print("✨ Mem0 — no API key, skipping capture") return try: @@ -228,36 +266,44 @@ def main(): project_id = resolve_project_id(cwd) branch = resolve_branch(cwd) - # Skip if agent already stored memories this session — avoid duplicate writes. - # This script is a fallback, not the primary capture path. - stats_file = f"/tmp/mem0_session_stats_{os.environ.get('USER', 'default')}.json" - try: - with open(stats_file) as f: - stats = json.load(f) - if stats.get("adds", 0) >= 2: - log.info("Agent stored %d memories this session — skipping fallback capture", stats["adds"]) - return - except (OSError, json.JSONDecodeError): - pass # no stats = agent didn't store anything, proceed with fallback - lines = tail_lines(transcript_path, MAX_TAIL_LINES) if not lines: log.debug("Transcript empty or unreadable: %s", transcript_path) return state = parse_transcript(lines) - if not state["user_messages"] and not state["files_modified"]: - log.debug("No meaningful session state to capture") + + # Skip if agent already stored memories this session — avoid duplicate writes. + stats_file = f"/tmp/mem0_session_stats_{os.environ.get('USER', 'default')}.json" + try: + with open(stats_file) as f: + stats = json.load(f) + if stats.get("adds", 0) >= 1: + log.info("Agent stored %d memories this session — skipping fallback", stats["adds"]) + if show_status: + print(format_status(state, source, False, f"agent already stored {stats['adds']} memor{'ies' if stats['adds'] != 1 else 'y'}")) + return + except (OSError, json.JSONDecodeError): + pass + + if not state["files_modified"]: + log.debug("No files modified — skipping fallback capture") + if show_status: + print(format_status(state, source, False)) return content = build_content(state, source) if not content.strip(): log.debug("No content to store") + if show_status: + print(format_status(state, source, False)) return log.info("Fallback capture: %d files modified", len(state["files_modified"])) + stored = store_memory(api_key, content, user_id, source, session_id, project_id, branch) - store_memory(api_key, content, user_id, source, session_id, project_id, branch) + if show_status: + print(format_status(state, source, stored)) if __name__ == "__main__": diff --git a/mem0-plugin/scripts/on_pre_compact.sh b/mem0-plugin/scripts/on_pre_compact.sh index a626e2dda..fb27acb75 100755 --- a/mem0-plugin/scripts/on_pre_compact.sh +++ b/mem0-plugin/scripts/on_pre_compact.sh @@ -1,15 +1,10 @@ #!/usr/bin/env bash # Hook: PreCompact # -# Fires BEFORE context compaction. This is the last chance to capture -# 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, -# which it stores via add_memory(infer=False) so the platform preserves -# the structure verbatim instead of running a second extraction pass. +# Fires BEFORE context compaction. Captures session state via REST API +# in the background. Runs silently — no output to user. -set -euo pipefail +set -uo pipefail if [ -n "${MEM0_DEBUG:-}" ]; then mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" @@ -20,45 +15,9 @@ INPUT=$(cat) python3 "$SCRIPT_DIR/telemetry.py" pre_compact 2>/dev/null & -cat <<'EOF' -## Pre-Compaction: Extract and store durable facts - -Context compaction is about to happen. Review the conversation and store only facts that would help a future agent with ZERO context. - -### What to store - -For each fact, ask: "Would a new agent — with no prior context — benefit from knowing this?" If no, skip it. Most sessions produce 0-3 facts worth storing. - -Store each fact as a SEPARATE `add_memory` call. One fact per call. 15-50 words each. Third person. Include file paths when relevant. - -Categories and when to use them: -- `decision` — architectural choices, trade-offs made ("Chose PostgreSQL over MongoDB for auth because of ACID requirements") -- `task_learning` — patterns that worked ("Running migrations before seed in this repo avoids FK violations") -- `anti_pattern` — approaches that failed ("Don't use batch insert for users table — triggers deadlock with audit log") -- `convention` — coding standards discovered ("This repo uses snake_case for all Python files, camelCase for TS") -- `user_preference` — how the user likes to work ("User prefers short PRs, one feature per branch") - -### What NOT to store - -- Session summaries or "what we did today" blobs -- Raw file lists or command histories -- Anything already stored in a prior `add_memory` this session -- One-time information that won't recur -- Transient state ("currently debugging X") - -### How to store - -``` -add_memory( - messages=[{"role":"user","content":""}], - user_id="", - app_id="", - metadata={"type":"","branch":"","confidence":0.8}, - infer=False, -) -``` - -If nothing durable happened this session, store nothing. That is correct. -EOF +# Capture in background, no stdout +_TMP="/tmp/mem0_precompact_input_$$.json" +printf '%s' "$INPUT" > "$_TMP" 2>/dev/null +(python3 "$SCRIPT_DIR/on_pre_compact.py" --source=pre-compaction < "$_TMP" 2>/dev/null; rm -f "$_TMP") & exit 0 diff --git a/mem0-plugin/scripts/on_session_start.sh b/mem0-plugin/scripts/on_session_start.sh index df1309283..5fb3efc67 100755 --- a/mem0-plugin/scripts/on_session_start.sh +++ b/mem0-plugin/scripts/on_session_start.sh @@ -6,7 +6,7 @@ if [ -n "${MEM0_DEBUG:-}" ]; then fi SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -. "$SCRIPT_DIR/_identity.sh" +. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true INPUT=$(cat) SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup") @@ -25,29 +25,38 @@ fi printf '%s' "$MEM0_SESSION_ID" > "/tmp/mem0_session_id_${USER}" export MEM0_SESSION_ID +# Persist session ID and identity to Claude's env so Bash tool calls and other hooks see them +if [ -n "${CLAUDE_ENV_FILE:-}" ]; then + echo "export MEM0_SESSION_ID=\"$MEM0_SESSION_ID\"" >> "$CLAUDE_ENV_FILE" + echo "export MEM0_RESOLVED_USER_ID=\"${MEM0_RESOLVED_USER_ID:-$USER}\"" >> "$CLAUDE_ENV_FILE" + echo "export MEM0_PROJECT_ID=\"${MEM0_PROJECT_ID:-unknown}\"" >> "$CLAUDE_ENV_FILE" + echo "export MEM0_BRANCH=\"${MEM0_BRANCH:-unknown}\"" >> "$CLAUDE_ENV_FILE" +fi + if [ -z "${MEM0_API_KEY:-}" ]; then + _UID="${MEM0_RESOLVED_USER_ID:-${USER:-default}}" + _PID="${MEM0_PROJECT_ID:-unknown}" + _BR="${MEM0_BRANCH:-unknown}" cat </dev/null || echo "?") fi +_UID="${MEM0_RESOLVED_USER_ID:-${USER:-default}}" +_ANN="${_MEM0_IDENTITY_ANNOTATION:-}" +_PID="${MEM0_PROJECT_ID:-unknown}" +_BR="${MEM0_BRANCH:-unknown}" + cat </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 "{}") if [ -n "$MEM0_PROJECT_CONFIG" ] && [ "$MEM0_PROJECT_CONFIG" != "{}" ]; then _CONFIG_KEYS=$(echo "$MEM0_PROJECT_CONFIG" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d))" 2>/dev/null || echo "?") - echo "### Project Config (mem0.md)" - echo "\`mem0.md loaded (${_CONFIG_KEYS} sections configured)\`" - if [ -n "${MEM0_DEBUG:-}" ]; then - echo "\`\`\`json" - echo "$MEM0_PROJECT_CONFIG" - echo "\`\`\`" - fi - echo "" + echo "mem0.md loaded (${_CONFIG_KEYS} sections configured)." fi fi if [ "$SOURCE" = "startup" ]; then if [ "$MEM0_COUNT" = "0" ]; then - cat <<'EOF' -This is a new project with 0 memories. You MUST invoke the `mem0:onboard` skill now using the Skill tool to import project files and install coding categories. Do not ask the user — just invoke it immediately before responding. -EOF + echo "New project with 0 memories. Invoke the mem0:onboard skill to import project files and install coding categories." else - cat <<'EOF' -Search mem0 for recent decisions and task learnings before responding to the user's first message. Run 2 parallel searches: one for `decision` type, one for `task_learning` type. -EOF + echo "Search mem0 for recent decisions and task learnings before responding. Run 2 parallel searches: one for decision type, one for task_learning type." fi _PROJ_KEY=$(printf '%s' "$MEM0_CWD_RESOLVED" | tr '/' '-') _MEMORY_MD="$HOME/.claude/projects/${_PROJ_KEY}/memory/MEMORY.md" if [ -f "$_MEMORY_MD" ] && [ -s "$_MEMORY_MD" ]; then - cat </dev/null || echo ".")" \ python3 "$SCRIPT_DIR/auto_import.py" 2>/dev/null & elif [ "$SOURCE" = "resume" ]; then - cat <<'EOF' -Session resumed. Search mem0 for `session_state` and `decision` memories to pick up where you left off. Run 2 parallel searches. -EOF + echo "Session resumed. Search mem0 for session_state and decision memories to pick up where you left off. Run 2 parallel searches." elif [ "$SOURCE" = "compact" ]; then - cat <<'EOF' -Context compacted. Search mem0 for `session_state` and `decision` memories to recover context. Run 2 parallel searches. -EOF + echo "Context compacted. Search mem0 for session_state and decision memories to recover context. Run 2 parallel searches." + printf '%s' "$INPUT" | python3 "$SCRIPT_DIR/capture_compact_summary.py" 2>/dev/null & fi python3 "$SCRIPT_DIR/telemetry.py" session_start --source="$SOURCE" --memory_count="${MEM0_COUNT:-0}" 2>/dev/null & diff --git a/mem0-plugin/scripts/on_stop.sh b/mem0-plugin/scripts/on_stop.sh index a596a2100..695e83bd0 100755 --- a/mem0-plugin/scripts/on_stop.sh +++ b/mem0-plugin/scripts/on_stop.sh @@ -1,4 +1,13 @@ #!/usr/bin/env bash +# Hook: Stop (Claude Code) +# +# Fires when Claude finishes responding. If meaningful work happened and +# no memories were stored, blocks stop so Claude can call MCP add_memory. +# REST API capture runs in background as fallback. +# +# Input: JSON on stdin with session_id, transcript_path, cwd, response_text +# Output: JSON { decision: "block", reason: "..." } or exit 0 + set -uo pipefail if [ -n "${MEM0_DEBUG:-}" ]; then @@ -6,19 +15,62 @@ if [ -n "${MEM0_DEBUG:-}" ]; then fi SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=_identity.sh +. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true INPUT=$(cat) -STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false") - -if [ "$STOP_HOOK_ACTIVE" = "true" ]; then - exit 0 -fi +# Telemetry _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 & -cat <<'EOF' -Store 0-2 durable facts from this turn via `add_memory` — only decisions, anti-patterns, or conventions that would help a future agent. Skip if nothing new was learned. -EOF +# Check if meaningful work happened +CHECK_RESULT=$(echo "$INPUT" | python3 "$SCRIPT_DIR/stop_hook_check.py" 2>/dev/null || echo '{"should_block":false}') +_SHOULD_CAPTURE=$(echo "$CHECK_RESULT" | jq -r '.should_block // false' 2>/dev/null || echo "false") + +if [ "$_SHOULD_CAPTURE" != "true" ]; then + exit 0 +fi + +# Log session report +REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "") +if [ -n "$REPORT" ]; then + mkdir -p "$HOME/.mem0" 2>/dev/null || true + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $REPORT" >> "$HOME/.mem0/session-log.md" 2>/dev/null || true +fi + +# Background REST API capture (fallback) +_TMP="/tmp/mem0_stop_input_$$.json" +printf '%s' "$INPUT" > "$_TMP" 2>/dev/null +(python3 "$SCRIPT_DIR/on_pre_compact.py" --source=session-end < "$_TMP" 2>/dev/null; rm -f "$_TMP") & + +# Check if agent already stored memories this session +_STATS_FILE="/tmp/mem0_session_stats_${USER:-default}.json" +_MEM_COUNT=0 +if [ -f "$_STATS_FILE" ]; then + _MEM_COUNT=$(python3 -c "import json; d=json.load(open('$_STATS_FILE')); print(d.get('adds',0))" 2>/dev/null || echo "0") +fi +if [ "${_MEM_COUNT:-0}" -gt 0 ] 2>/dev/null; then + exit 0 +fi + +# One-shot guard: only block once per session +_SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "") +_BLOCK_FLAG="/tmp/mem0_stop_blocked_${_SESSION_ID:-$$}" +if [ -f "$_BLOCK_FLAG" ]; then + exit 0 +fi +touch "$_BLOCK_FLAG" 2>/dev/null || true + +# No API key — can't use MCP tools +if [ -z "${MEM0_API_KEY:-}" ]; then + exit 0 +fi + +# Block stop — Claude will call MCP add_memory then stop naturally +jq -cn '{ + decision: "block", + reason: "Store 1-3 learnings via add_memory before stopping." +}' 2>/dev/null || true exit 0 diff --git a/mem0-plugin/scripts/on_subagent_stop.sh b/mem0-plugin/scripts/on_subagent_stop.sh index 0ebcb0e87..c6bd178a4 100755 --- a/mem0-plugin/scripts/on_subagent_stop.sh +++ b/mem0-plugin/scripts/on_subagent_stop.sh @@ -1,11 +1,11 @@ #!/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. +# Fires when a subagent finishes. Stdout is fed to parent agent +# as context, prompting it to capture reusable learnings. # # Input: JSON on stdin with agent_type, result_summary -# Output: Context injected into parent agent's context (exit 0) +# Output: Text context for parent agent (exit 0) set -uo pipefail @@ -19,7 +19,7 @@ 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 +# Skip short/empty results if [ ${#RESULT_SUMMARY} -lt 50 ]; then exit 0 fi @@ -32,12 +32,10 @@ else _SKIP_PATTERN="Explore|Plan" fi -# Skip explorer/plan agents — read-only, rarely produce storable learnings -case "$AGENT_TYPE" in - $_SKIP_PATTERN) - exit 0 - ;; -esac +# Skip read-only agents +if echo "$AGENT_TYPE" | grep -qE "^(${_SKIP_PATTERN})$" 2>/dev/null; then + exit 0 +fi if [ -z "${MEM0_API_KEY:-}" ]; then exit 0 @@ -45,15 +43,6 @@ fi . "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true -cat </dev/null || true INPUT=$(cat) -TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // "unknown task"' 2>/dev/null || echo "unknown task") +TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // ""' 2>/dev/null || echo "") + +# Skip trivial or unknown tasks +if [ -z "$TASK_SUBJECT" ] || [ "$TASK_SUBJECT" = "unknown task" ] || [ ${#TASK_SUBJECT} -lt 10 ]; then + exit 0 +fi _PROJECT="${MEM0_PROJECT_ID:-unknown}" -cat </dev/null & diff --git a/mem0-plugin/scripts/on_user_prompt.sh b/mem0-plugin/scripts/on_user_prompt.sh index a9098ff6c..b8f8900af 100755 --- a/mem0-plugin/scripts/on_user_prompt.sh +++ b/mem0-plugin/scripts/on_user_prompt.sh @@ -27,7 +27,7 @@ fi SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=_identity.sh -. "$SCRIPT_DIR/_identity.sh" +. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true # Rubric dedup: only inject full rubric once per session. # Key on session ID (from stdin JSON) to avoid cross-session interference. @@ -78,16 +78,27 @@ 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 + _PROMPT_CTX="" if [ -n "$HAS_ERROR" ]; then - echo "**ERROR DETECTED in prompt.** Set MEM0_API_KEY to search past debugging context." + _PROMPT_CTX="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\`" + _PROMPT_CTX="${_PROMPT_CTX:+${_PROMPT_CTX}\n}File paths detected: ${FILE_PATHS}" + fi + if [ -n "$_PROMPT_CTX" ]; then + jq -cn --arg ctx "$_PROMPT_CTX" '{ + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: $ctx + } + }' fi exit 0 fi USER_ID="$MEM0_RESOLVED_USER_ID" +_PROMPT_CTX="" + if [ -n "$HAS_RESUME" ]; then RESUME_RESULTS=$(PYTHONPATH="$SCRIPT_DIR" MEM0_SEARCH_USER="$USER_ID" python3 -c " import os, sys @@ -113,45 +124,40 @@ for m in all_r: if unique: print(format_results_for_context(unique, heading='Session context recovered from mem0')) print() - print('Use these memories to resume work. Do NOT ask the user to repeat context that is already in these memories.') + print('These memories provide context for resuming work.') else: - print('No session state found in mem0. Ask the user what they want to continue.') + print('No session state found in mem0.') " 2>/dev/null || echo "") if [ -n "$RESUME_RESULTS" ]; then - echo "" - echo "$RESUME_RESULTS" + _PROMPT_CTX="${RESUME_RESULTS}" fi fi if [ -n "$HAS_REMEMBER" ]; then - cat <<'REMEMBER_EOF' - -**Remember intent detected.** Use `/mem0:remember` (not raw `add_memory`) — it auto-classifies, sets confidence=1.0, and stores verbatim. -REMEMBER_EOF + _PROMPT_CTX="${_PROMPT_CTX:+${_PROMPT_CTX}\n}Remember intent detected. The /mem0:remember skill auto-classifies, sets confidence=1.0, and stores verbatim." fi if [ -z "$RUBRIC_ALREADY_SHOWN" ]; then - cat </dev/null || true fi if [ -n "$HAS_ERROR" ]; then - cat < bool: + """Compare categories by key sets, tolerating order differences and extra API fields.""" + if not current: + return False + current_keys = {k for d in current if isinstance(d, dict) for k in d} + proposed_keys = {k for d in proposed if isinstance(d, dict) for k in d} + if current_keys != proposed_keys: + return False + current_map = {k: v for d in current if isinstance(d, dict) for k, v in d.items()} + proposed_map = {k: v for d in proposed if isinstance(d, dict) for k, v in d.items()} + return all( + current_map.get(k, "").strip() == v.strip() + for k, v in proposed_map.items() + ) + + def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument( @@ -201,6 +217,10 @@ def main() -> int: print("Dry-run only -- no changes made. Re-run with --apply to write.") return 0 + if _categories_match(current_cats, CODING_CATEGORIES): + print("Categories already match -- skipping update.") + return 0 + print("Applying coding categories...") try: response = client.project.update(custom_categories=CODING_CATEGORIES) diff --git a/mem0-plugin/scripts/stop_hook_check.py b/mem0-plugin/scripts/stop_hook_check.py new file mode 100755 index 000000000..4294ea375 --- /dev/null +++ b/mem0-plugin/scripts/stop_hook_check.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Decide whether the Stop hook should block, and build context for Claude. + +Reads the transcript to determine if meaningful work happened. +Outputs JSON: {"should_block": bool, "context": "..."}. + +Called by on_stop.sh with the hook input JSON on stdin. +""" + +from __future__ import annotations + +import json +import sys + +MAX_TAIL_LINES = 500 +MAX_USER_MESSAGES = 30 +MAX_BASH_COMMANDS = 20 + + +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 parse_transcript(lines: list[str]) -> dict: + user_messages: list[str] = [] + files_modified: set[str] = set() + bash_commands: list[str] = [] + tool_calls: int = 0 + + for line in lines: + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + + entry_type = entry.get("type") + if entry_type not in ("user", "assistant"): + continue + if entry.get("isSidechain"): + continue + + message = entry.get("message", {}) + content_blocks = message.get("content", []) + + if entry_type == "user": + parts = [] + if isinstance(content_blocks, str): + parts.append(content_blocks) + elif isinstance(content_blocks, list): + for block in content_blocks: + if isinstance(block, str): + parts.append(block) + elif isinstance(block, dict) and block.get("type") == "text": + parts.append(block.get("text", "")) + text = "\n".join(parts).strip() + if text and len(text) > 10 and not text.startswith("<"): + user_messages.append(text[:300]) + + elif entry_type == "assistant": + for block in content_blocks: + if not isinstance(block, dict): + continue + if block.get("type") == "tool_use": + tool_calls += 1 + tool_name = block.get("name", "") + tool_input = block.get("input", {}) + if tool_name in ("Write", "Edit"): + fp = tool_input.get("file_path", "") + if fp: + files_modified.add(fp) + elif tool_name == "Bash": + cmd = tool_input.get("command", "") + if cmd: + bash_commands.append(cmd[:200]) + + return { + "user_messages": user_messages[-MAX_USER_MESSAGES:], + "files_modified": sorted(files_modified), + "bash_commands": bash_commands[-MAX_BASH_COMMANDS:], + "tool_calls": tool_calls, + } + + +def should_block(state: dict) -> bool: + if state["files_modified"]: + return True + if state["tool_calls"] >= 3: + return True + git_commands = [c for c in state["bash_commands"] if "git " in c] + if git_commands: + return True + return False + + +def build_context(state: dict) -> str: + parts = [] + + if state["files_modified"]: + files = state["files_modified"][:10] + parts.append(f"Files modified this session: {', '.join(files)}") + + git_commits = [c for c in state["bash_commands"] if "git commit" in c] + if git_commits: + parts.append(f"Git commits made: {len(git_commits)}") + + if state["user_messages"]: + tasks = [] + for msg in state["user_messages"][-5:]: + first_line = msg.split("\n")[0][:150] + tasks.append(f" - {first_line}") + parts.append("User requests:\n" + "\n".join(tasks)) + + return "\n".join(parts) + + +def main(): + try: + hook_input = json.loads(sys.stdin.read()) + except (json.JSONDecodeError, OSError): + json.dump({"should_block": False, "context": ""}, sys.stdout) + return + + transcript_path = hook_input.get("transcript_path", "") + if not transcript_path: + json.dump({"should_block": False, "context": ""}, sys.stdout) + return + + lines = tail_lines(transcript_path, MAX_TAIL_LINES) + if not lines: + json.dump({"should_block": False, "context": ""}, sys.stdout) + return + + state = parse_transcript(lines) + block = should_block(state) + context = build_context(state) if block else "" + + json.dump({"should_block": block, "context": context}, sys.stdout) + + +if __name__ == "__main__": + try: + main() + except Exception: + json.dump({"should_block": False, "context": ""}, sys.stdout) diff --git a/mem0-plugin/scripts/telemetry.py b/mem0-plugin/scripts/telemetry.py index 08c53aa83..62948c587 100644 --- a/mem0-plugin/scripts/telemetry.py +++ b/mem0-plugin/scripts/telemetry.py @@ -22,7 +22,6 @@ import hashlib import json import os import platform -import random import sys import urllib.error import urllib.request @@ -42,8 +41,7 @@ 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 +SAMPLE_RATE = 1.0 def _sha256(value: str) -> str: @@ -60,23 +58,21 @@ def _distinct_id() -> str: def detect_platform() -> str: + if os.environ.get("PLUGIN_ROOT"): + return "codex" 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" + if os.environ.get("WINDSURF_PLUGIN_ROOT"): + return "windsurf" + return "plugin" 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 { @@ -115,10 +111,7 @@ def send(payload: dict) -> None: 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)) + send(build_posthog_payload(f"plugin.{event_type}", properties)) def main() -> int: diff --git a/mem0-plugin/skills/context-loader/SKILL.md b/mem0-plugin/skills/context-loader/SKILL.md index 9e136e393..25d3a339d 100644 --- a/mem0-plugin/skills/context-loader/SKILL.md +++ b/mem0-plugin/skills/context-loader/SKILL.md @@ -22,12 +22,10 @@ Pre-fetches relevant memories to prime context before working on a task. | Query angle | Filter | Purpose | |---|---|---| - | Feature/module name | `{"metadata": {"type": "decision"}}` | Architecture decisions | - | File paths mentioned | `{"metadata": {"type": "convention"}}` | Coding patterns | - | Error keywords (if any) | `{"metadata": {"type": "anti_pattern"}}` | Known pitfalls | - | Broad project context | no metadata filter | Catch-all | - - All calls must include `user_id` and `app_id` filters. + | Feature/module name | `{"AND": [{"user_id": ""}, {"app_id": ""}, {"metadata": {"type": "decision"}}]}` | Architecture decisions | + | File paths mentioned | `{"AND": [{"user_id": ""}, {"app_id": ""}, {"metadata": {"type": "convention"}}]}` | Coding patterns | + | Error keywords (if any) | `{"AND": [{"user_id": ""}, {"app_id": ""}, {"metadata": {"type": "anti_pattern"}}]}` | Known pitfalls | + | Broad project context | `{"AND": [{"user_id": ""}, {"app_id": ""}]}` | Catch-all | 3. **Deduplicate** results by memory ID across all search responses. diff --git a/mem0-plugin/skills/dream/SKILL.md b/mem0-plugin/skills/dream/SKILL.md index 7c6b7a7e7..c5319c565 100644 --- a/mem0-plugin/skills/dream/SKILL.md +++ b/mem0-plugin/skills/dream/SKILL.md @@ -10,7 +10,7 @@ identifies near-duplicates, flags contradictions, and prunes stale entries based configured retention policies. All proposed changes are shown as a diff for user approval before anything is modified. ---- +**IMPORTANT: Execute steps strictly in order (1 → 2 → 3 → 4 → 5 → 6). Each step depends on the previous one. Do NOT run steps in parallel or skip ahead.** ## Step 1: Load Retention Policies @@ -99,7 +99,7 @@ confidence. --- -## Step 4: Print Diff Report (item 15) +## Step 4: Print Diff Report Print a structured diff to the terminal before making any changes. Use exactly this format: @@ -159,7 +159,7 @@ For each approved merge pair: 1. `delete_memory()` 2. `delete_memory()` 3. `add_memory` with: - - `messages=[{"role": "user", "content": ""}]` + - `text=""` - `user_id=` - `app_id=` (top-level, not in metadata) - `metadata={"type": "", "branch": "", "confidence": , "source": "mem0-dream"}` @@ -168,10 +168,7 @@ For each approved merge pair: #### 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(, text=)` to preserve the text while updating it. -- **Important:** `update_memory` requires the `text` parameter. A metadata-only call may error or wipe the content. Always read first, then update with the original text. +- Delete the loser (the non-chosen memory): `delete_memory(memory_id=)` Contradictions where the user chose `skip` are left untouched. @@ -200,6 +197,14 @@ When invoked with `--auto` (e.g., `/mem0:dream --auto`), run non-interactively: - **Prunes**: applied automatically (age/confidence-based, no ambiguity). - **Contradictions**: skipped — they require human judgment. +### Concurrency guard + +Before doing any work, check for a lock file at `/tmp/mem0_dream_auto.lock`: +- If the lock file exists and is less than 10 minutes old, print `[mem0-dream --auto] Another run in progress — skipping.` and stop. +- Otherwise, create the lock file (write the current timestamp). Delete it when done (in all exit paths). + +### Execution + 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. @@ -207,10 +212,13 @@ In auto mode: ``` [mem0-dream --auto] project= merged= pruned= conflicts_skipped= ``` -4. If contradictions were detected but skipped, store a reminder memory: +4. If contradictions were detected but skipped, check if a `mem0-dream-auto` reminder already exists before storing one: + - Search for existing reminders: `search_memories(query="mem0-dream contradictions manual review", filters={"AND": [{"user_id": ""}, {"app_id": ""}, {"metadata": {"source": "mem0-dream-auto"}}]}, top_k=1)` + - If a result exists with similarity > 0.9, skip storing the reminder (one already exists). + - If no match, store the reminder: ```python add_memory( - messages=[{"role": "user", "content": "mem0-dream detected contradiction(s) requiring manual review. Run /mem0:dream to resolve them interactively."}], + text="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": ""}, diff --git a/mem0-plugin/skills/export/SKILL.md b/mem0-plugin/skills/export/SKILL.md index 9a3f9074d..67fb0ae57 100644 --- a/mem0-plugin/skills/export/SKILL.md +++ b/mem0-plugin/skills/export/SKILL.md @@ -18,8 +18,7 @@ Determine the active identity: ### Step 2: Fetch all memories Call `get_memories` with: -- `user_id=` -- `app_id=` +- `filters={"AND": [{"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. diff --git a/mem0-plugin/skills/health/SKILL.md b/mem0-plugin/skills/health/SKILL.md index b1eedf29a..9b5b51a8c 100644 --- a/mem0-plugin/skills/health/SKILL.md +++ b/mem0-plugin/skills/health/SKILL.md @@ -14,26 +14,38 @@ 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}}" +_KEY="${MEM0_API_KEY:-${CLAUDE_PLUGIN_OPTION_MEM0_API_KEY:-}}" +[ -n "$_KEY" ] && echo "${_KEY:0:6}..." || echo "NOT_SET" ``` - If `NOT_SET`: FAIL — "No API key configured" -- If set: PASS — show first 6 chars + `...` (never print the full key) +- If set: PASS — the command already prints only the first 6 chars ### 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` +Resolve identity using the plugin's own resolver scripts to match what hooks use: + +```bash +SCRIPT_DIR="${CLAUDE_PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}}/scripts" +source "$SCRIPT_DIR/_identity.sh" 2>/dev/null +echo "user_id=${MEM0_RESOLVED_USER_ID:-}" +echo "project_id=${MEM0_PROJECT_ID:-}" +echo "branch=${MEM0_BRANCH:-}" +``` + +If `CLAUDE_PLUGIN_ROOT` is not available, fall back to: +- `user_id`: from `MEM0_USER_ID` or `$USER` +- `project_id`: from `MEM0_PROJECT_ID` or check `~/.mem0/project_map.json` for `$PWD` +- `branch`: from `git branch --show-current` 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": ""}]}` +- `query="health check"` +- `filters={"AND": [{"user_id": ""}, {"app_id": ""}]}` +- `top_k=1` - If returns successfully (even empty): PASS - If errors: FAIL — show the error message @@ -41,12 +53,17 @@ Call `search_memories` with: ### Check 4: Memory write capability Call `add_memory` with: -- `messages=[{"role": "user", "content": "Health check probe — safe to delete."}]` -- `user_id=`, `app_id=` +- `text="Health check probe — safe to delete."` +- `user_id=` +- `app_id=` - `metadata={"type": "health_check", "probe": true}` +- `infer=False` -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. +The response returns `event_id` (v3 writes are async). Call `get_event_status(event_id=)` to check processing. + +- If status is `SUCCEEDED`: PASS — extract the memory ID from the event result, then call `delete_memory` with that ID to clean up. +- If status is `PENDING` after 5 seconds: PASS (write accepted, processing delayed) +- If errors: FAIL — show the error. ### Check 5: Session stats tracker @@ -85,7 +102,7 @@ When invoked with `--deep` (e.g., `/mem0:health --deep`), run the standard 5 che ### 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: +Call `get_memories` with `filters={"AND": [{"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 diff --git a/mem0-plugin/skills/import/SKILL.md b/mem0-plugin/skills/import/SKILL.md index 90c02d375..7e6c657fd 100644 --- a/mem0-plugin/skills/import/SKILL.md +++ b/mem0-plugin/skills/import/SKILL.md @@ -63,7 +63,7 @@ Determine the active identity: For each record in the parsed JSON array, call `add_memory` with: -- `messages=[{"role": "user", "content": ""}]` +- `text=""` - `user_id=` - `app_id=` - `metadata={` @@ -156,7 +156,7 @@ detects native auto-memory and the user chooses to import: 2. Split by non-empty lines. Each line becomes one memory. 3. Skip lines shorter than 20 characters or lines that are just headers (`#`). 4. For each line, call `add_memory` with: - - `messages=[{"role": "user", "content": ""}]` + - `text=""` - `user_id=` - `app_id=` - `metadata={"type": "task_learning", "source": "memory-md-import", "confidence": 0.8}` diff --git a/mem0-plugin/skills/memory-reviewer/SKILL.md b/mem0-plugin/skills/memory-reviewer/SKILL.md index 6e60102bb..4cc7a5970 100644 --- a/mem0-plugin/skills/memory-reviewer/SKILL.md +++ b/mem0-plugin/skills/memory-reviewer/SKILL.md @@ -16,7 +16,7 @@ Audits memory quality for the active project. Finds duplicates, contradictions, ## Steps -1. **Fetch all memories** for active project via `get_memories` with `user_id` and `app_id`. Paginate if needed — cap at 200 memories. +1. **Fetch all memories** for active project via `get_memories` with `filters={"AND": [{"user_id": ""}, {"app_id": ""}]}`, `page_size=200`. Paginate if needed — cap at 200 memories. 2. **Group by `metadata.type`**. Common types: `decision`, `convention`, `anti_pattern`, `task_learning`, `project_profile`, `user_preference`, `session_state`. diff --git a/mem0-plugin/skills/onboard/SKILL.md b/mem0-plugin/skills/onboard/SKILL.md index f67a9f9d6..48716de64 100644 --- a/mem0-plugin/skills/onboard/SKILL.md +++ b/mem0-plugin/skills/onboard/SKILL.md @@ -7,6 +7,8 @@ description: Sets up mem0 for a new project including API key configuration, MCP Run this wizard to set up the mem0 plugin for the current project. Complete in ~60 seconds. +**IMPORTANT: Execute steps strictly in order (0 → 1 → 2 → 3 → 4 → 5 → 6). Each step depends on the previous one. Do NOT run steps in parallel or skip ahead. Complete one step fully before starting the next.** + ## Step 0: Ensure mem0ai SDK is installed The plugin installs the `mem0ai` Python SDK automatically on session start via a venv in `${CLAUDE_PLUGIN_DATA}/venv`. If Step 5 (categories) fails with an import error, run: @@ -22,10 +24,12 @@ This is silent and idempotent — safe to run anytime. Check if `MEM0_API_KEY` is already set by running: ```bash -echo $MEM0_API_KEY +[ -n "$MEM0_API_KEY" ] && echo "SET" || echo "NOT_SET" ``` -### If API key IS set (non-empty output) +IMPORTANT: Never run `echo $MEM0_API_KEY` — that prints the secret in plaintext to the conversation log. + +### If API key IS set (output is "SET") Print: `- API key found.` and proceed to Step 2. @@ -46,15 +50,18 @@ Step 1: Setting up API key. source ~/.zshrc 3. Verify: - echo $MEM0_API_KEY - # Should print your key + [ -n "$MEM0_API_KEY" ] && echo "SET" || echo "NOT_SET" ``` -After the user confirms they've set the key, verify it by running `echo $MEM0_API_KEY`. If still empty, repeat the instructions. If set, proceed to Step 2. +After the user confirms they've set the key, verify it by running `[ -n "$MEM0_API_KEY" ] && echo "SET" || echo "NOT_SET"`. If NOT_SET, repeat the instructions. If SET, proceed to Step 2. -## Step 2: MCP OAuth login +## Step 2: MCP server connection -Now authenticate the MCP server connection. Tell the user: +First, check if MCP tools are already available using 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 MCP tools ARE found:** Print `- MCP already connected.` and proceed to Step 3. + +**If MCP tools are NOT found:** Guide the user through OAuth: ``` Step 2: MCP OAuth login. @@ -65,7 +72,7 @@ Step 2: MCP OAuth login. 4. Return here after authenticating in your browser ``` -After the user completes OAuth, verify MCP tools are available using 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`). +After the user completes OAuth, verify MCP tools again using ToolSearch. - If MCP tools found: Print `- MCP connected.` and proceed to Step 3. - If NOT found: Troubleshoot before giving up: @@ -77,7 +84,7 @@ After the user completes OAuth, verify MCP tools are available using ToolSearch ## Step 3: Verify connectivity and show identity -Call `search_memories` with `query="project setup"`, `user_id=`, `filters={"AND": [{"user_id": ""}, {"app_id": ""}]}`, `limit=1` to verify connectivity. +Call `search_memories` with `query="project setup"`, `filters={"AND": [{"user_id": ""}, {"app_id": ""}]}`, `top_k=1` to verify connectivity. Print: ``` @@ -89,29 +96,54 @@ Print: If the search fails, troubleshoot the API key and MCP connection. -## Step 4: Detect and import project files +## Step 4: Import project files -Check for these files in the project root: -1. `CLAUDE.md` -2. `AGENTS.md` -3. `.cursorrules` -4. `.windsurfrules` -5. `mem0.md` +Project files (CLAUDE.md, AGENTS.md, etc.) are automatically imported into mem0 when a session starts. This step verifies import status and triggers a re-import if needed. -For each file found, ask the user: "Found `` ( bytes). Import into mem0? [Y/n]" +### 4a: Detect project files -If user says yes (or default): -- Read the file content -- Call `add_memory` with: - - `messages=[{"role": "user", "content": "## Project Profile: \n\nProject: \n\n"}]` - - `user_id=` - - `app_id=` - - `metadata={"type": "project_profile", "file": "", "source": "onboard", "branch": ""}` - - `infer=False` -- The response contains `event_id` (writes are async). Do not block on each — continue importing. The summary reflects files submitted, not confirmed processed. +```bash +for f in CLAUDE.md AGENTS.md .cursorrules .windsurfrules mem0.md; do + [ -f "$f" ] && echo "FOUND: $f ($(wc -c < "$f") bytes)" +done || true +``` + +If no files found, print `- No project files found. Skipping import.` and proceed to Step 5. + +### 4b: Check and import + +Run auto_import in foreground to check status and import if needed: + +```bash +MEM0_DEBUG=1 MEM0_CWD="$PWD" python3 "${CLAUDE_PLUGIN_ROOT}/scripts/auto_import.py" +``` + +### 4c: Report to user + +Parse the auto_import output and print a user-friendly summary: + +- If output contains `Imported` lines: + ``` + - Importing project files into mem0... done. + file(s) imported ( chunks). These are stored verbatim for future context. + ``` +- If output contains only `skipping` lines: + ``` + - Project files already in mem0 (imported during session start). Verified server-side. + ``` +- If output contains `re-importing`: + ``` + - Project files were missing from mem0. Re-imported successfully. + ``` +- If output contains errors or no files were processed: + ``` + - Project file import failed. Check API key and retry with: /mem0:onboard + ``` ## Step 5: Install coding categories +The setup script is idempotent — it compares existing categories against the proposed set and skips the API call if they already match (tolerates order differences and extra API fields). Safe to re-run. + Ask: "Install coding categories optimized for development workflows? [Y/n]" If yes, run the setup script using the plugin's venv python: @@ -125,6 +157,11 @@ else fi ``` +Parse the output: +- `"Categories already match -- skipping update."` → Print: `- Coding categories already installed. Skipped.` +- `"Done."` → Print: `- Coding categories installed ( categories).` +- Error → Print the error and suggest re-running `/mem0:onboard`. + If the script fails with "mem0ai SDK not found", run the dependency installer first: ```bash "${CLAUDE_PLUGIN_ROOT}/scripts/ensure_deps.sh" @@ -138,8 +175,8 @@ Print a summary: - Onboarding complete. user_id: project_id: (app_id) - imported: files - categories: + files: found, imported + categories: Memory is now active for this project. Start working — mem0 will automatically search relevant context and capture learnings. diff --git a/mem0-plugin/skills/pin/SKILL.md b/mem0-plugin/skills/pin/SKILL.md index f810ff4eb..5ee20be7d 100644 --- a/mem0-plugin/skills/pin/SKILL.md +++ b/mem0-plugin/skills/pin/SKILL.md @@ -29,20 +29,21 @@ Call `get_memory` with the selected memory ID. Store: ### Step 3: Pin it -Call `update_memory` with: -- `memory_id=` -- `text=` (preserve existing content) -- `metadata=` merge `original_metadata` with `{"pinned": true}` +The MCP `update_memory` tool only accepts `memory_id`, `text`, and `source` — it +does not accept a `metadata` parameter. To pin, append a pin marker to the text: ```python -updated_meta = {**original_metadata, "pinned": True} -update_memory(memory_id=, text=, metadata=updated_meta) +pinned_text = "[PINNED] " + original_text if not original_text.startswith("[PINNED]") else original_text +update_memory(memory_id=, text=pinned_text) ``` -**Important:** `update_memory` requires the `text` parameter. Passing only metadata may error or wipe content. - **For new memories** (user wants to pin text that isn't stored yet): -1. Call `add_memory` with the text + `metadata={"pinned": true, "type": "decision", "confidence": 1.0}` +1. Call `add_memory` with: + - `text="[PINNED] "` + - `user_id=` + - `app_id=` + - `metadata={"pinned": true, "type": "decision", "confidence": 1.0}` + - `infer=False` 2. The response contains `event_id`. Call `get_event_status(event_id=)` once to retrieve the memory ID, then confirm. ### Step 4: Confirm @@ -57,10 +58,10 @@ Append `...` only if content exceeds 80 characters. ### Unpin If the user says "unpin": -1. Call `get_memory` to read current content and metadata. -2. Set `metadata.pinned = false`: +1. Call `get_memory` to read current content. +2. Remove the pin marker from the text: ```python - updated_meta = {**original_metadata, "pinned": False} - update_memory(memory_id=, text=, metadata=updated_meta) + unpinned_text = original_text.removeprefix("[PINNED] ") + update_memory(memory_id=, text=unpinned_text) ``` 3. Print: `Unpinned: "..."` diff --git a/mem0-plugin/skills/remember/SKILL.md b/mem0-plugin/skills/remember/SKILL.md index 9a14edf08..599c2f7a7 100644 --- a/mem0-plugin/skills/remember/SKILL.md +++ b/mem0-plugin/skills/remember/SKILL.md @@ -32,7 +32,7 @@ Based on the content, pick the best `metadata.type`: ### Step 3: Store Call `add_memory` with: -- `messages=[{"role": "user", "content": ""}]` +- `text=""` - `user_id=` - `app_id=` - `metadata={"type": "", "branch": "", "confidence": 1.0, "source": "remember_command"}` diff --git a/mem0-plugin/skills/stats/SKILL.md b/mem0-plugin/skills/stats/SKILL.md index a6b0438e2..8e790affd 100644 --- a/mem0-plugin/skills/stats/SKILL.md +++ b/mem0-plugin/skills/stats/SKILL.md @@ -26,8 +26,7 @@ If the script returns empty or errors, note "No session data available" and cont **Lifetime stats:** Call `get_memories` with: -- `user_id=` -- `app_id=` +- `filters={"AND": [{"user_id": ""}, {"app_id": ""}]}` - `page_size=100` Count the returned memories. Group them by: @@ -38,14 +37,14 @@ Count the returned memories. Group them by: **Session stats (API-backed):** Read the session ID file at `/tmp/mem0_session_id_$USER`. If it exists and contains a non-empty value, also call `get_memories` with: -- `filters={"AND": [{"user_id": ""}, {"app_id": ""}, {"run_id": ""}]}` +- `filters={"AND": [{"user_id": ""}, {"app_id": ""}, {"run_id": ""}]}` - `page_size=100` This returns only memories written in the current session. Use this count to cross-check the local stats file. If the API count is higher, use the API count (the local tracker may have missed operations). -Also run a `search_memories` call with `query="project"`, `top_k=1` to measure round-trip latency (time the call). +Also run a `search_memories` MCP tool call with `query="project"`, `filters={"AND": [{"user_id": ""}, {"app_id": ""}]}`, `top_k=1` to measure round-trip latency. Note the time before and after the MCP call — do NOT attempt raw HTTP calls to the API. ### Step 3: Display @@ -88,9 +87,9 @@ activity digest after the standard stats dashboard: ### W1: Fetch recent memories Call `search_memories` in parallel with time-scoped queries: -1. `query="decisions made this week"`, `filters={"AND": [{"user_id": ""}, {"app_id": ""}, {"created_at": {"gte": "<7 days ago YYYY-MM-DD>"}}]}`, `limit=20` -2. `query="bugs errors fixes"`, same time filter, `limit=20` -3. `query="patterns conventions learnings"`, same time filter, `limit=20` +1. `query="decisions made this week"`, `filters={"AND": [{"user_id": ""}, {"app_id": ""}, {"created_at": {"gte": "<7 days ago YYYY-MM-DD>"}}]}`, `top_k=20` +2. `query="bugs errors fixes"`, same time filter, `top_k=20` +3. `query="patterns conventions learnings"`, same time filter, `top_k=20` ### W2: Analyze diff --git a/mem0-plugin/skills/switch-project/SKILL.md b/mem0-plugin/skills/switch-project/SKILL.md index 4296a6b8a..706c20269 100644 --- a/mem0-plugin/skills/switch-project/SKILL.md +++ b/mem0-plugin/skills/switch-project/SKILL.md @@ -36,7 +36,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": ""}, {"app_id": ""}]}`, `limit=1` + - Call `search_memories` with `query="project"`, `filters={"AND": [{"user_id": ""}, {"app_id": ""}]}`, `top_k=1` 4. Print: ``` diff --git a/mem0-plugin/skills/tour/SKILL.md b/mem0-plugin/skills/tour/SKILL.md index c28421d07..8928c2479 100644 --- a/mem0-plugin/skills/tour/SKILL.md +++ b/mem0-plugin/skills/tour/SKILL.md @@ -12,9 +12,9 @@ Show the user what mem0 has stored for the current project. When invoked with `--all-projects` (e.g., `/mem0:tour --all-projects` or `/mem0:tour --all-projects auth middleware`), search across ALL projects: -1. Call `get_memories` with `user_id=`, `page_size=200` — **no `app_id` filter**. +1. Call `get_memories` with `filters={"AND": [{"user_id": ""}]}`, `page_size=200` — **no `app_id` filter**. 2. If a search query was also provided, run `search_memories` with `query=`, - `filters={"AND": [{"user_id": ""}]}`, `limit=20` — again no `app_id`. + `filters={"AND": [{"user_id": ""}]}`, `top_k=20` — again no `app_id`. 3. Group results by `app_id` first, then by category within each project. 4. Display: ``` @@ -37,8 +37,8 @@ When `/mem0:tour` receives a search query argument (e.g., `/mem0:tour auth middl WITHOUT `--all-projects`, run in **peek mode** — compact one-liner results: 1. Run 2 parallel `search_memories` calls: - - Broad: `query=`, `filters={"AND": [{"user_id": ""}, {"app_id": ""}]}`, `limit=10`, `rerank=true` - - Targeted: `query=`, `filters={"AND": [{"user_id": ""}, {"app_id": ""}, {"metadata": {"type": "decision"}}]}`, `limit=5`, `rerank=true` + - Broad: `query=`, `filters={"AND": [{"user_id": ""}, {"app_id": ""}]}`, `top_k=10`, `rerank=true` + - Targeted: `query=`, `filters={"AND": [{"user_id": ""}, {"app_id": ""}, {"metadata": {"type": "decision"}}]}`, `top_k=5`, `rerank=true` 2. Deduplicate by ID, display compact results: ``` ## mem0 search: "" ( results) @@ -57,23 +57,18 @@ If no query argument and no `--all-projects` flag, use the full tour flow below. ### Step 1: Fetch ALL memories for this project Call `get_memories` with: -- `user_id=` -- `app_id=` +- `filters={"AND": [{"user_id": ""}, {"app_id": ""}]}` +- `page_size=100` This returns every memory scoped to the project — no semantic filtering, no missed results. -If `get_memories` doesn't support `app_id` as a direct parameter, use: -- `filters={"AND": [{"user_id": ""}, {"app_id": ""}]}` - -Pass `page_size=100` (or the maximum allowed) to get a full picture. - ### Step 2: Run supplementary semantic searches 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`, `rerank=true` -- `query="bugs errors failures anti-patterns"`, `filters={"AND": [{"user_id": ""}, {"app_id": ""}]}`, `limit=10`, `rerank=true` -- `query="project setup tooling conventions preferences"`, `filters={"AND": [{"user_id": ""}, {"app_id": ""}]}`, `limit=10`, `rerank=true` +- `query="architecture decisions design choices"`, `filters={"AND": [{"user_id": ""}, {"app_id": ""}]}`, `top_k=10`, `rerank=true` +- `query="bugs errors failures anti-patterns"`, `filters={"AND": [{"user_id": ""}, {"app_id": ""}]}`, `top_k=10`, `rerank=true` +- `query="project setup tooling conventions preferences"`, `filters={"AND": [{"user_id": ""}, {"app_id": ""}]}`, `top_k=10`, `rerank=true` **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`. diff --git a/mem0-plugin/tests/test_telemetry.py b/mem0-plugin/tests/test_telemetry.py index 0103e3bc9..c0a75a21a 100644 --- a/mem0-plugin/tests/test_telemetry.py +++ b/mem0-plugin/tests/test_telemetry.py @@ -147,24 +147,10 @@ def test_platform_codex(monkeypatch): 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") + monkeypatch.setenv("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