diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3646c9399..ce959d3be 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.5" + "version": "0.2.6" } ] } diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 632174afb..baa8b0e29 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.5" + "version": "0.2.6" } ] } diff --git a/mem0-plugin/.claude-plugin/plugin.json b/mem0-plugin/.claude-plugin/plugin.json index 117901d6a..1bd3778b9 100644 --- a/mem0-plugin/.claude-plugin/plugin.json +++ b/mem0-plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.2.5", + "version": "0.2.6", "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 a331d3de5..2a5d4d368 100644 --- a/mem0-plugin/.codex-plugin/plugin.json +++ b/mem0-plugin/.codex-plugin/plugin.json @@ -1,26 +1,26 @@ { "name": "mem0", - "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.", + "version": "0.2.6", + "description": "Persistent memory for Codex. Remembers decisions, patterns, and preferences across sessions.", "author": { "name": "Mem0", - "email": "support@mem0.ai" + "email": "support@mem0.ai", + "url": "https://mem0.ai" }, "homepage": "https://mem0.ai", "repository": "https://github.com/mem0ai/mem0", "license": "Apache-2.0", + "keywords": ["memory", "personalization", "mcp", "semantic-search"], "skills": "./skills/", "mcpServers": "./.codex-mcp.json", + "hooks": "./hooks/codex-hooks.json", "interface": { "displayName": "Mem0", "shortDescription": "Persistent memory layer for AI coding workflows", "longDescription": "Mem0 adds long-term memory to Codex. Store decisions, user preferences, project context, and session state across conversations. Memories are automatically retrieved via semantic search so Codex always has the right context.", "developerName": "Mem0", "category": "Productivity", - "capabilities": [ - "Read", - "Write" - ], + "capabilities": ["Read", "Write"], "websiteURL": "https://mem0.ai", "privacyPolicyURL": "https://mem0.ai/privacy", "termsOfServiceURL": "https://mem0.ai/terms", diff --git a/mem0-plugin/.cursor-plugin/plugin.json b/mem0-plugin/.cursor-plugin/plugin.json index 9451a419e..90b1b4802 100644 --- a/mem0-plugin/.cursor-plugin/plugin.json +++ b/mem0-plugin/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.2.5", + "version": "0.2.6", "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 a502546df..781408085 100644 --- a/mem0-plugin/CHANGELOG.md +++ b/mem0-plugin/CHANGELOG.md @@ -2,6 +2,41 @@ All notable changes to the Mem0 plugin will be documented in this file. +## 0.2.6 + +### Fixed + +- **Memory count zero / stats and tour showing 0 memories:** The `run_id: "*"` wildcard filter — added in the initial v0.2.6 fix — returns 0 on both the v3 list endpoint (`/v3/memories/`) and search endpoint (`/v3/memories/search/`) when memories were written without a `run_id` (which is all memories since v0.2.6 stopped setting `run_id` on `add_memory`). Removed `run_id: "*"` from: `on_session_start.sh` count queries, `enforce_metadata_defaults.sh` hook injection (was injecting into every `search_memories` and `get_memories` call), `_search.py` search payload, `/mem0:stats` and `/mem0:tour` skill instructions. All read paths now use simple `user_id` + `app_id` filters without `run_id`, matching how v0.2.3 worked. +- **`add_memory` no longer sets `run_id`:** Session tracking moved from top-level `run_id` (which creates a separate API partition) to `metadata.session_id`. New memories land in the default partition and are visible to all queries. +- **`enforce_metadata_defaults.sh` no longer injects `run_id`:** The hook was appending `{"run_id": "*"}` to every `search_memories` and `get_memories` filter, which broke both endpoints. Removed entirely — identity injection (`user_id`/`app_id`) still works. +- **`_search.py` simplified:** Removed `run_id: "*"` from search payload. Uses plain `user_id` + `app_id` filters. +- **`auto_import.py` delete endpoint 404:** Stale chunk deletion used `DELETE /v3/memories/{id}/` which returns 404 (v3 is ADD-only). Changed to `DELETE /v1/memories/{id}/`. +- **Banner count accurate:** `on_session_start.sh` count query uses `user_id` + `app_id` filters without `run_id`. Shows total count only (removed noisy auto-import breakdown). +- **`quickstart.md` wrong add endpoint:** cURL example used `POST /v1/memories/` (v1 add is removed). Fixed to `POST /v3/memories/add/`. +- **Session stats always 0:** PostToolUse hooks never fire for plugin MCP tools (confirmed via debug logs — only SessionStart, UserPromptSubmit, PreToolUse, and Stop fire). Moved session stats tracking (`session_stats.py add/search`) into `enforce_metadata_defaults.sh` (PreToolUse), which does fire on every MCP tool call. +- **Periodic nudge never firing:** Message count file used session UUID in filename (`/tmp/mem0_msg_count_${SESSION_ID}`) which was cleared on session start. Changed to `$USER`-keyed filename, matching the session stats file convention. +- **`/mem0:stats` session query returning 0:** Stats skill attempted API queries with `run_id` and `metadata.session_id` filters that return empty results. Session stats now come exclusively from the local stats file (which is accurate now that PreToolUse tracks adds). +- **Auto-capture stops working mid-session:** After the initial rubric injection (first message), subsequent messages got zero context from the UserPromptSubmit hook. The banner instruction to "proactively store learnings" fades as conversation grows and Claude forgets. Two-pronged fix: (1) **Direct API auto-capture hook** (`auto_capture.py`): every 3rd message, `on_user_prompt.sh` spawns a background Python script that reads the last 3 exchanges from the transcript JSONL and sends them directly to `POST /v3/memories/add/` with `infer=True`. No reliance on Claude calling `add_memory`. (2) **Proportional prompt nudge** as fallback: starting from 3rd message, if Claude has stored fewer than 1 memory per 3 messages, a brief "store learnings via add_memory" directive is injected. +- **Desktop app: API key not found:** Claude Code Desktop does not inherit shell environment variables — only `PATH` is read from shell profiles. Users who set `export MEM0_API_KEY=m0-...` in `~/.zshrc` or `~/.bashrc` got "Setup Required" on Desktop while CLI worked fine. Added grep-based shell profile extraction as a 4th fallback in both `_identity.sh` (bash) and `_identity.py` (Python). Scans `~/.zshrc`, `~/.bashrc`, `~/.zprofile`, `~/.bash_profile`, `~/.profile` for `MEM0_API_KEY=` assignments. Skips variable references (`$OTHER_VAR`), commented-out lines, and strips quotes/inline comments. +- **Desktop app: zero memories added over multi-day usage:** Agent never proactively called `add_memory` — only `search_memories` and `get_all`. Root cause: session banner instruction was passive ("before finishing a session, store learnings") and easily ignored. No mechanism existed to re-prompt the agent mid-session. Fixed with a periodic nudge in `on_user_prompt.sh`: every 5th substantial message, the hook checks `session_stats` for add count; if fewer than 2 memories stored, injects a directive into Claude's context via `additionalContext` telling it to store learnings immediately. Counter resets on session start. +- **Setup Required banner missing Desktop instructions:** Updated no-API-key banner with Desktop-specific setup paths: `claude plugin configure mem0`, Desktop app environment editor (Settings > Environment), and CLI `export` as fallback. + +### Removed + +- **Stop hook (all 3 editors):** Removed from `hooks.json`, `cursor-hooks.json`, `codex-hooks.json`. Deleted `on_stop.sh`, `on_stop_cursor.sh`, `on_stop_codex.sh`, `stop_hook_check.py`. The Stop hook could not reliably feed context back to Claude (command-type hooks' `reason` field is user-facing only, not injected into Claude's context). Auto-capture handled by PreCompact hook instead. +- **SessionEnd hook:** Removed from `hooks.json`. Deleted `on_session_end.sh`. Redundant with PreCompact auto-capture. +- **5 redundant hook scripts:** `on_git_commit_capture.sh` (fired on every Bash command containing "git"), `on_post_commit.sh` (fired on every Bash command), `on_task_completed.sh`, `on_post_compact.sh`, `on_subagent_stop.sh`. These were already removed from Claude's `hooks.json` in v0.2.5 but script files remained on disk. Also removed `on_post_commit.sh` references from `cursor-hooks.json` and `codex-hooks.json`. +- **Dead settings:** Removed `output_style`, `skip_tools`, `capture_tools` from `load_settings.py` defaults. The `output-styles/` directory and `on_tool_failure.sh` script were already deleted. +- **`test_on_file_read.py`:** Removed test file for deleted `on_file_read.sh` hook. + +### Changed + +- **`/mem0:stats` lifetime query:** Single `get_memories` call with `user_id` + `app_id` filters (no `run_id`). +- **`/mem0:tour` full fetch:** Single `get_memories` call with `user_id` + `app_id` filters (no `run_id`). +- **API key resolution order (4 fallbacks):** `MEM0_API_KEY` env var > `CLAUDE_PLUGIN_OPTION_API_KEY` (plugin configure) > `CLAUDE_PLUGIN_OPTION_MEM0_API_KEY` (legacy userConfig) > shell profile extraction. Applies to both `_identity.sh` and `_identity.py`. +- **Session start banner:** Proactive memory instruction changed from passive "before finishing a session" to active "proactively store learnings incrementally as work progresses. Do NOT wait until the session ends." +- **Message counter on session start:** `on_session_start.sh` now resets `/tmp/mem0_msg_count_*` files to ensure nudge counter starts fresh each session. + ## 0.2.5 ### Fixed diff --git a/mem0-plugin/hooks/codex-hooks.json b/mem0-plugin/hooks/codex-hooks.json index 3d3914252..c9293822b 100644 --- a/mem0-plugin/hooks/codex-hooks.json +++ b/mem0-plugin/hooks/codex-hooks.json @@ -1,16 +1,6 @@ { "hooks": { "PreToolUse": [ - { - "matcher": "Read", - "hooks": [ - { - "type": "command", - "command": "${PLUGIN_ROOT}/scripts/on_file_read.sh", - "timeout": 8 - } - ] - }, { "matcher": "mcp__mem0__add_memory|mcp__plugin_mem0_mem0__add_memory|mcp__mem0__search_memories|mcp__plugin_mem0_mem0__search_memories|mcp__mem0__get_memories|mcp__plugin_mem0_mem0__get_memories|mcp__mem0__delete_all_memories|mcp__plugin_mem0_mem0__delete_all_memories", "hooks": [ @@ -24,7 +14,7 @@ ], "SessionStart": [ { - "matcher": "startup|resume", + "matcher": "startup|resume|compact", "hooks": [ { "type": "command", @@ -48,7 +38,7 @@ ], "PostToolUse": [ { - "matcher": "mcp__mem0__", + "matcher": "mcp__mem0__.*|mcp__plugin_mem0_mem0__.*", "hooks": [ { "type": "command", @@ -60,11 +50,6 @@ { "matcher": "Bash", "hooks": [ - { - "type": "command", - "command": "${PLUGIN_ROOT}/scripts/on_post_commit.sh", - "timeout": 5 - }, { "type": "command", "command": "${PLUGIN_ROOT}/scripts/on_bash_output.sh", @@ -72,17 +57,6 @@ } ] } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "${PLUGIN_ROOT}/scripts/on_stop_codex.sh", - "timeout": 10 - } - ] - } ] } } diff --git a/mem0-plugin/hooks/cursor-hooks.json b/mem0-plugin/hooks/cursor-hooks.json index 15bd8fe5d..e5737ac37 100644 --- a/mem0-plugin/hooks/cursor-hooks.json +++ b/mem0-plugin/hooks/cursor-hooks.json @@ -1,4 +1,5 @@ { + "version": 1, "hooks": { "sessionStart": [ { @@ -9,12 +10,7 @@ "preToolUse": [ { "command": "${CURSOR_PLUGIN_ROOT}/scripts/block_memory_write_cursor.sh", - "matcher": "Write|Edit" - }, - { - "command": "${CURSOR_PLUGIN_ROOT}/scripts/on_file_read.sh", - "matcher": "Read", - "timeout": 8 + "matcher": "Write|Edit|MultiEdit" }, { "command": "${CURSOR_PLUGIN_ROOT}/scripts/enforce_metadata_defaults.sh", @@ -25,14 +21,9 @@ "postToolUse": [ { "command": "${CURSOR_PLUGIN_ROOT}/scripts/on_post_tool_use_cursor.sh", - "matcher": "mcp__mem0__", + "matcher": "mcp__mem0__.*|mcp__plugin_mem0_mem0__.*", "timeout": 3 }, - { - "command": "${CURSOR_PLUGIN_ROOT}/scripts/on_post_commit.sh", - "matcher": "Bash", - "timeout": 5 - }, { "command": "${CURSOR_PLUGIN_ROOT}/scripts/on_bash_output.sh", "matcher": "Bash", @@ -44,12 +35,6 @@ "command": "${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact_cursor.sh" } ], - "stop": [ - { - "command": "${CURSOR_PLUGIN_ROOT}/scripts/on_stop_cursor.sh", - "timeout": 10 - } - ], "beforeSubmitPrompt": [ { "command": "${CURSOR_PLUGIN_ROOT}/scripts/on_user_prompt_cursor.sh", diff --git a/mem0-plugin/hooks/hooks.json b/mem0-plugin/hooks/hooks.json index 6f9dd5638..b4f5648aa 100644 --- a/mem0-plugin/hooks/hooks.json +++ b/mem0-plugin/hooks/hooks.json @@ -18,7 +18,7 @@ "hooks": [ { "type": "command", - "command": "diff -q \"${CLAUDE_PLUGIN_ROOT}/requirements.txt\" \"${CLAUDE_PLUGIN_DATA}/requirements.txt\" >/dev/null 2>&1 || \"${CLAUDE_PLUGIN_ROOT}/scripts/ensure_deps.sh\"", + "command": "diff -q \"${CLAUDE_PLUGIN_ROOT}/requirements.txt\" \"${CLAUDE_PLUGIN_DATA:-$HOME/.mem0/plugin-data}/requirements.txt\" >/dev/null 2>&1 || \"${CLAUDE_PLUGIN_ROOT}/scripts/ensure_deps.sh\"", "statusMessage": "Installing mem0 SDK...", "timeout": 60 } @@ -46,27 +46,7 @@ ] }, { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_git_commit_capture.sh", - "timeout": 5 - } - ] - }, - { - "matcher": "Read", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_file_read.sh", - "timeout": 8 - } - ] - }, - { - "matcher": "mcp__mem0__add_memory|mcp__plugin_mem0_mem0__add_memory|mcp__mem0__search_memories|mcp__plugin_mem0_mem0__search_memories|mcp__mem0__get_memories|mcp__plugin_mem0_mem0__get_memories|mcp__mem0__get_memory|mcp__plugin_mem0_mem0__get_memory|mcp__mem0__update_memory|mcp__plugin_mem0_mem0__update_memory|mcp__mem0__delete_all_memories|mcp__plugin_mem0_mem0__delete_all_memories", + "matcher": "mcp__mem0__add_memory|mcp__plugin_mem0_mem0__add_memory|mcp__mem0__search_memories|mcp__plugin_mem0_mem0__search_memories|mcp__mem0__get_memories|mcp__plugin_mem0_mem0__get_memories|mcp__mem0__delete_all_memories|mcp__plugin_mem0_mem0__delete_all_memories", "hooks": [ { "type": "command", @@ -90,15 +70,10 @@ { "matcher": "Bash", "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_post_commit.sh", - "timeout": 5 - }, { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_bash_output.sh", - "timeout": 12 + "timeout": 5 } ] } @@ -114,17 +89,6 @@ ] } ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_stop.sh", - "timeout": 30 - } - ] - } - ], "UserPromptSubmit": [ { "hooks": [ @@ -132,52 +96,7 @@ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_user_prompt.sh", "statusMessage": "Checking memory relevance...", - "timeout": 12 - } - ] - } - ], - "TaskCompleted": [ - { - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_task_completed.sh", - "timeout": 10 - } - ] - } - ], - "PostCompact": [ - { - "matcher": "manual|auto", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_post_compact.sh", - "timeout": 10 - } - ] - } - ], - "SessionEnd": [ - { - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_session_end.sh", - "timeout": 15 - } - ] - } - ], - "SubagentStop": [ - { - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_subagent_stop.sh", - "timeout": 5 + "timeout": 8 } ] } diff --git a/mem0-plugin/output-styles/compact-memory.md b/mem0-plugin/output-styles/compact-memory.md deleted file mode 100644 index aeaf655e4..000000000 --- a/mem0-plugin/output-styles/compact-memory.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -name: compact-memory -description: Terse one-liner memory display — type, content, and ID. ---- - -# Compact Memory Output Style - -When this style is active, all memory-related output uses this one-liner format: - -``` -[] [mem0:] -``` - -## Examples - -``` -[decision] Auth module uses JWT with RS256 signing keys [mem0:a3f8b2c1] -[convention] All API routes live in src/routes/ with kebab-case filenames [mem0:7e2d9f4a] -[anti_pattern] Don't use raw SQL — always go through the ORM layer [mem0:c4d5e6f7] -[task_learning] Redis cache TTL should be 300s for user sessions [mem0:d8e9f0a1] -``` - -## Rules - -- Type in brackets, lowercase, from `metadata.type` -- Content truncated at 80 chars with no trailing ellipsis -- Short ID = first 8 chars of memory ID -- One memory per line, no extra formatting -- No headers or separators between memories unless grouped -- When grouped by type, use a blank line between groups - -## When to use - -- `/mem0:peek` results -- `/mem0:tour` memory listings -- Search results from `search_memories` -- Any context where memories are displayed inline diff --git a/mem0-plugin/scripts/_identity.py b/mem0-plugin/scripts/_identity.py index 59f7ec498..e25da8916 100644 --- a/mem0-plugin/scripts/_identity.py +++ b/mem0-plugin/scripts/_identity.py @@ -4,6 +4,9 @@ API key resolution (first non-empty wins): 1. MEM0_API_KEY env var (explicit / shell profile) 2. CLAUDE_PLUGIN_OPTION_API_KEY (set by `claude plugin configure mem0`) 3. CLAUDE_PLUGIN_OPTION_MEM0_API_KEY (legacy userConfig) + 4. Extract from shell profile files (~/.zshrc, ~/.bashrc, etc.) + Desktop app doesn't inherit shell env — this covers users who + set MEM0_API_KEY in their profile but use the Desktop app. User ID resolution: 1. MEM0_USER_ID env var (explicit override) @@ -16,6 +19,37 @@ Settings resolution: from __future__ import annotations import os +import re +from pathlib import Path + + +def _extract_key_from_shell_profiles() -> str: + """Extract MEM0_API_KEY from shell profile files. + + The Desktop app only reads PATH from shell profiles — env vars like + MEM0_API_KEY are not inherited. This handles the common + ``export MEM0_API_KEY=...`` pattern without sourcing the full profile. + """ + profiles = [".zshrc", ".bashrc", ".zprofile", ".bash_profile", ".profile"] + pattern = re.compile(r'^\s*(?:export\s+)?MEM0_API_KEY=(.+)$') + + for name in profiles: + path = Path.home() / name + if not path.is_file(): + continue + try: + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + m = pattern.match(line) + if not m: + continue + value = m.group(1).strip() + value = re.sub(r'#.*$', '', value).strip() + value = value.strip("\"'") + if value and not value.startswith("$"): + return value + except OSError: + continue + return "" def resolve_api_key() -> str: @@ -26,6 +60,9 @@ def resolve_api_key() -> str: if key: return key key = os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", "").strip() + if key: + return key + key = _extract_key_from_shell_profiles() if key: return key return "" @@ -50,10 +87,7 @@ def resolve_config() -> dict: "search_limit": 10, "retention_session_days": 90, "confidence_threshold": 0.3, - "output_style": "compact", "debug": False, - "skip_tools": ["Read", "Glob", "Grep"], - "capture_tools": ["Edit", "Write", "Bash"], } diff --git a/mem0-plugin/scripts/_identity.sh b/mem0-plugin/scripts/_identity.sh index 1b46ffa77..0a0856d12 100644 --- a/mem0-plugin/scripts/_identity.sh +++ b/mem0-plugin/scripts/_identity.sh @@ -4,12 +4,15 @@ # 1. MEM0_API_KEY env var (explicit / shell profile) # 2. CLAUDE_PLUGIN_OPTION_API_KEY (set by `claude plugin configure mem0`) # 3. CLAUDE_PLUGIN_OPTION_MEM0_API_KEY (legacy userConfig) +# 4. Extract from shell profile files (~/.zshrc, ~/.bashrc, etc.) +# Desktop app doesn't inherit shell env — this fallback covers users +# who set MEM0_API_KEY in their profile but use the Desktop app. # # Settings: ~/.mem0/settings.json (user-editable, falls back to defaults) _SCRIPT_DIR="$( cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd )" -# Resolve API key: env var > userConfig +# Resolve API key: env var > userConfig > shell profile extraction if [ -z "${MEM0_API_KEY:-}" ] && [ -n "${CLAUDE_PLUGIN_OPTION_API_KEY:-}" ]; then MEM0_API_KEY="$CLAUDE_PLUGIN_OPTION_API_KEY" export MEM0_API_KEY @@ -18,6 +21,29 @@ if [ -z "${MEM0_API_KEY:-}" ] && [ -n "${CLAUDE_PLUGIN_OPTION_MEM0_API_KEY:-}" ] MEM0_API_KEY="$CLAUDE_PLUGIN_OPTION_MEM0_API_KEY" export MEM0_API_KEY fi +# Fallback: extract MEM0_API_KEY from shell profile files. +# The Desktop app only reads PATH from shell profiles — env vars like +# MEM0_API_KEY are not inherited. This grep-based extraction handles +# the common `export MEM0_API_KEY=...` pattern without sourcing the +# full profile (which could have side effects). +if [ -z "${MEM0_API_KEY:-}" ]; then + for _profile in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.bash_profile" "$HOME/.profile"; do + if [ -f "$_profile" ]; then + _extracted=$(grep -E '^\s*(export\s+)?MEM0_API_KEY=' "$_profile" 2>/dev/null \ + | tail -1 \ + | sed 's/^[^=]*=//' \ + | sed "s/^[\"']//;s/[\"']$//" \ + | sed 's/#.*//' \ + | tr -d '[:space:]') + # Only use literal values — skip variable references like ${OTHER_VAR} + if [ -n "$_extracted" ] && [ "${_extracted#\$}" = "$_extracted" ]; then + MEM0_API_KEY="$_extracted" + export MEM0_API_KEY + break + fi + fi + done +fi _mem0_resolve_identity() { if [ -n "${MEM0_USER_ID:-}" ]; then diff --git a/mem0-plugin/scripts/_search.py b/mem0-plugin/scripts/_search.py index 54939c33f..81a38785a 100644 --- a/mem0-plugin/scripts/_search.py +++ b/mem0-plugin/scripts/_search.py @@ -13,6 +13,19 @@ SEARCH_URL = "https://api.mem0.ai/v3/memories/search/" SEARCH_TIMEOUT = 5 +def _do_search(api_key: str, payload: dict) -> list[dict]: + body = json.dumps(payload).encode() + req = urllib.request.Request( + SEARCH_URL, + data=body, + headers={"Authorization": f"Token {api_key}", "Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=SEARCH_TIMEOUT) as r: + data = json.loads(r.read()) + return data if isinstance(data, list) else data.get("results", []) + + def search_memories( api_key: str, user_id: str, @@ -28,30 +41,24 @@ def search_memories( if not api_key: return [] - filters: dict = {"AND": [{"user_id": user_id}, {"app_id": project_id}]} + base_clauses: list[dict] = [{"user_id": user_id}, {"app_id": project_id}] if metadata_type: - filters["AND"].append({"metadata": {"type": metadata_type}}) + base_clauses.append({"metadata": {"type": metadata_type}}) if metadata_filters: for key, value in metadata_filters.items(): - filters["AND"].append({"metadata": {key: value}}) + base_clauses.append({"metadata": {key: value}}) - payload: dict = {"query": query, "filters": filters, "top_k": top_k, "threshold": threshold} + base_payload: dict = {"query": query, "top_k": top_k, "threshold": threshold} if rerank: - payload["rerank"] = True - body = json.dumps(payload).encode() - req = urllib.request.Request( - SEARCH_URL, - data=body, - headers={"Authorization": f"Token {api_key}", "Content-Type": "application/json"}, - method="POST", - ) + base_payload["rerank"] = True + try: - with urllib.request.urlopen(req, timeout=SEARCH_TIMEOUT) as r: - data = json.loads(r.read()) - results = data if isinstance(data, list) else data.get("results", []) - if min_score > 0: - results = [m for m in results if m.get("score", 0) >= min_score] - return results + payload = {**base_payload, "filters": {"AND": list(base_clauses)}} + results = _do_search(api_key, payload)[:top_k] + + if min_score > 0: + results = [m for m in results if m.get("score", 0) >= min_score] + return results except Exception: return [] diff --git a/mem0-plugin/scripts/auto_capture.py b/mem0-plugin/scripts/auto_capture.py new file mode 100755 index 000000000..356c02803 --- /dev/null +++ b/mem0-plugin/scripts/auto_capture.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Auto-capture recent conversation exchanges into mem0. + +Runs in the background from UserPromptSubmit hook (every 3rd message). +Reads the last few exchanges from the transcript, sends them to the +mem0 API with infer=True so the platform extracts facts automatically. + +Input: env vars (MEM0_API_KEY, MEM0_RESOLVED_USER_ID, MEM0_PROJECT_ID, etc.) + argv[1] = transcript_path +Output: stderr logs only (exit 0 always — must not block) +""" + +from __future__ import annotations + +import json +import logging +import os +import sys +import urllib.error +import urllib.request + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _identity import resolve_api_key, resolve_user_id +from _project import resolve_branch, resolve_project_id + +log = logging.getLogger("mem0-auto-capture") +log.setLevel(logging.DEBUG) +_handler = logging.StreamHandler(sys.stderr) +_handler.setFormatter(logging.Formatter("[mem0-auto-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) + _fh = logging.FileHandler(os.path.join(_log_dir, "hooks.log")) + _fh.setFormatter(logging.Formatter("[mem0-auto-capture] %(asctime)s %(message)s")) + log.addHandler(_fh) + except OSError: + pass + +API_URL = "https://api.mem0.ai" +TAIL_LINES = 200 +MAX_CONTENT_CHARS = 8000 +MIN_CONTENT_CHARS = 100 + + +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 extract_recent_exchanges(lines: list[str], max_exchanges: int = 3) -> list[dict]: + """Extract the last N user+assistant message pairs from the transcript JSONL.""" + messages = [] + for line in lines: + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + + if entry.get("isCompactSummary"): + continue + + msg = entry.get("message", {}) + role = msg.get("role", "") + if role not in ("user", "assistant"): + continue + + content = msg.get("content", "") + 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", "")) + content = "\n".join(parts).strip() + + if not content or len(content) < 20: + continue + + # Skip tool-call-only assistant messages + if role == "assistant" and content.startswith("{"): + continue + + messages.append({"role": role, "content": content[:2000]}) + + # Take last N exchanges (pairs of user+assistant) + if not messages: + return [] + + result = messages[-(max_exchanges * 2):] + return result + + +def store_exchange(api_key: str, messages: list[dict], user_id: str, + project_id: str, branch: str, session_id: str) -> bool: + metadata = { + "type": "auto_capture", + "source": "auto_capture", + "confidence": 0.7, + } + if branch: + metadata["branch"] = branch + if session_id: + metadata["session_id"] = session_id + + body = { + "messages": messages, + "user_id": user_id, + "app_id": project_id, + "metadata": metadata, + "infer": True, + } + + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request( + f"{API_URL}/v3/memories/add/", + data=data, + headers={ + "Content-Type": "application/json", + "Authorization": f"Token {api_key}", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + if resp.status in (200, 201): + result = json.loads(resp.read()) + log.info("Auto-captured: event_id=%s status=%s", + result.get("event_id", "?"), result.get("status", "?")) + 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 = resolve_api_key() + if not api_key: + log.debug("MEM0_API_KEY not set, skipping") + return + + if len(sys.argv) < 2: + log.debug("No transcript_path argument") + return + + transcript_path = sys.argv[1] + if not transcript_path or not os.path.isfile(transcript_path): + log.debug("Transcript not found: %s", transcript_path) + return + + user_id = resolve_user_id() + project_id = resolve_project_id() + branch = resolve_branch() + session_id = "" + sid_file = f"/tmp/mem0_session_id_{os.environ.get('USER', 'default')}" + if os.path.isfile(sid_file): + try: + with open(sid_file) as f: + session_id = f.read().strip() + except OSError: + pass + + lines = tail_lines(transcript_path, TAIL_LINES) + if not lines: + log.debug("Transcript empty") + return + + messages = extract_recent_exchanges(lines, max_exchanges=4) + if not messages: + log.debug("No substantial exchanges found") + return + + total_chars = sum(len(m["content"]) for m in messages) + if total_chars < MIN_CONTENT_CHARS: + log.debug("Exchanges too short (%d chars), skipping", total_chars) + return + + log.info("Auto-capturing %d messages (%d chars)", len(messages), total_chars) + if store_exchange(api_key, messages, user_id, project_id, branch, session_id): + try: + import session_stats + session_stats.record_add("auto_capture") + except Exception: + pass + + +if __name__ == "__main__": + try: + main() + except Exception as e: + log.error("Unexpected error: %s", e) + sys.exit(0) diff --git a/mem0-plugin/scripts/auto_import.py b/mem0-plugin/scripts/auto_import.py index 9929bdfcc..004772bbd 100644 --- a/mem0-plugin/scripts/auto_import.py +++ b/mem0-plugin/scripts/auto_import.py @@ -196,7 +196,7 @@ def _delete_stale_chunks(api_key: str, user_id: str, project_id: str, filename: for mid in ids_to_delete: try: del_req = urllib.request.Request( - f"{API_URL}/v3/memories/{mid}/", + f"{API_URL}/v1/memories/{mid}/", headers={"Authorization": f"Token {api_key}"}, method="DELETE", ) diff --git a/mem0-plugin/scripts/enforce_metadata_defaults.sh b/mem0-plugin/scripts/enforce_metadata_defaults.sh index fe0fdfff1..cb5c009d5 100755 --- a/mem0-plugin/scripts/enforce_metadata_defaults.sh +++ b/mem0-plugin/scripts/enforce_metadata_defaults.sh @@ -15,7 +15,7 @@ # the updatedInput replaces the tool's input parameters. # exit 2 = block (stderr shown as rejection reason). -set -euo pipefail +set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" source "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true @@ -41,6 +41,7 @@ esac TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input // "{}"' 2>/dev/null) _PATCH_OUT="/tmp/mem0_enforce_$$" +trap 'rm -f "$_PATCH_OUT"' EXIT _MEM0_TOOL_INPUT="$TOOL_INPUT" \ _MEM0_USER_ID="${MEM0_RESOLVED_USER_ID:-}" \ _MEM0_APP_ID="${MEM0_PROJECT_ID:-}" \ @@ -154,7 +155,11 @@ if handler == "add_memory": inp["infer"] = False changed = True - if "run_id" not in inp: + # Track session in metadata instead of run_id. + # run_id creates a separate entity partition in the v3 API, + # making memories invisible to search/get_memories calls + # that don't include a run_id filter. + if "session_id" not in meta: sid = os.environ.get("MEM0_SESSION_ID", "") if not sid: session_file = "/tmp/mem0_session_id_" + os.environ.get("USER", "default") @@ -165,7 +170,7 @@ if handler == "add_memory": except OSError: pass if sid: - inp["run_id"] = sid + meta["session_id"] = sid changed = True if changed: @@ -193,4 +198,15 @@ if [ -n "$PATCHED" ] && echo "$PATCHED" | jq empty 2>/dev/null; then }' 2>/dev/null || true fi +# Track session stats here because PostToolUse hooks don't fire for plugin MCP tools. +case "$HANDLER" in + add_memory) + _CAT=$(echo "$TOOL_INPUT" | jq -r '.metadata.type // .metadata.category // ""' 2>/dev/null || echo "") + python3 "$SCRIPT_DIR/session_stats.py" add "$_CAT" 2>/dev/null & + ;; + search_memories|get_memories) + python3 "$SCRIPT_DIR/session_stats.py" search 2>/dev/null & + ;; +esac + exit 0 diff --git a/mem0-plugin/scripts/install_codex_hooks.py b/mem0-plugin/scripts/install_codex_hooks.py index ff2ba671f..ac77c8221 100755 --- a/mem0-plugin/scripts/install_codex_hooks.py +++ b/mem0-plugin/scripts/install_codex_hooks.py @@ -25,6 +25,7 @@ from __future__ import annotations import argparse import json +import platform import sys from pathlib import Path @@ -126,6 +127,18 @@ def main() -> int: print(f"Removed Mem0 hooks from {HOOKS_FILE}") return 0 + # Codex lifecycle hooks register .sh paths directly in ~/.codex/hooks.json. + # On native Windows .sh has no default handler, so Codex spawning a hook + # triggers "Open With" dialogs (one OpenWith.exe per event). See #5243. + if platform.system() == "Windows": + print( + "Codex lifecycle hooks register .sh scripts directly, which Windows\n" + "cannot execute without a bash interpreter on PATH. Re-run this\n" + "installer from WSL or Git Bash, or use Mem0 via MCP / Direct tools\n", + file=sys.stderr, + ) + return 2 + if not TEMPLATE_FILE.exists(): print(f"error: template not found at {TEMPLATE_FILE}", file=sys.stderr) return 1 @@ -137,7 +150,7 @@ def main() -> int: print(f"Installed Mem0 hooks into {HOOKS_FILE}") print(f"Plugin path: {PLUGIN_ROOT}") - print("Events: SessionStart, UserPromptSubmit, Stop") + print("Events: PreToolUse, SessionStart, UserPromptSubmit, PostToolUse") if not feature_flag_enabled(): print_feature_flag_hint() diff --git a/mem0-plugin/scripts/load_settings.py b/mem0-plugin/scripts/load_settings.py index e77c81224..271cec245 100644 --- a/mem0-plugin/scripts/load_settings.py +++ b/mem0-plugin/scripts/load_settings.py @@ -16,10 +16,7 @@ DEFAULTS = { "search_limit": 10, "retention_session_days": 90, "confidence_threshold": 0.3, - "output_style": "compact", "debug": False, - "skip_tools": ["Read", "Glob", "Grep"], - "capture_tools": ["Edit", "Write", "Bash"], } diff --git a/mem0-plugin/scripts/on_bash_output.sh b/mem0-plugin/scripts/on_bash_output.sh index bb28d32eb..698729f73 100755 --- a/mem0-plugin/scripts/on_bash_output.sh +++ b/mem0-plugin/scripts/on_bash_output.sh @@ -23,7 +23,7 @@ if [ ${#TOOL_RESULT} -lt 50 ]; then exit 0 fi -# Skip if this is a git commit (handled by on_post_commit.sh) +# Skip git operations — not useful for error detection COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "") case "$COMMAND" in *"git commit"*|*"git merge"*|*"git rebase"*) diff --git a/mem0-plugin/scripts/on_file_read.sh b/mem0-plugin/scripts/on_file_read.sh deleted file mode 100755 index b53f3f06f..000000000 --- a/mem0-plugin/scripts/on_file_read.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env bash -# Hook: PreToolUse (matcher: Read) -# -# When Claude reads a file, searches mem0 for memories tagged with that -# file path. Injects results as additionalContext if found. - -set -uo pipefail - -INPUT=$(cat) - -FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""' 2>/dev/null || echo "") - -if [ -z "$FILE_PATH" ]; then - exit 0 -fi - -# Skip non-code files (images, binaries, lockfiles, etc.) -case "$FILE_PATH" in - *.png|*.jpg|*.jpeg|*.gif|*.svg|*.ico|*.woff|*.woff2|*.ttf|*.eot) exit 0 ;; - *.lock|*.sum|*.min.js|*.min.css|*.map) exit 0 ;; - *node_modules/*|*.git/*|*__pycache__/*) exit 0 ;; -esac - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true - -# Skip if no API key (checked after _identity.sh resolves CLAUDE_PLUGIN_OPTION_MEM0_API_KEY) -if [ -z "${MEM0_API_KEY:-}" ]; then - exit 0 -fi - -# Skip repeated reads: track last 10 files in a temp file. -# Use full FILE_PATH (not basename) to avoid false dedup. -RECENT_FILE="/tmp/mem0_recent_reads_${USER}_${MEM0_PROJECT_ID:-unknown}" -if [ -f "$RECENT_FILE" ] && grep -qxF "$FILE_PATH" "$RECENT_FILE" 2>/dev/null; then - exit 0 -fi -echo "$FILE_PATH" >> "$RECENT_FILE" 2>/dev/null || true -tail -10 "$RECENT_FILE" > "$RECENT_FILE.tmp" 2>/dev/null && mv "$RECENT_FILE.tmp" "$RECENT_FILE" 2>/dev/null || true - -USER_ID="${MEM0_RESOLVED_USER_ID:-${USER:-default}}" -PROJECT_ID="${MEM0_PROJECT_ID:-unknown}" -BASENAME=$(basename "$FILE_PATH") - -CWD="${MEM0_CWD:-$(pwd)}" -REL_PATH="${FILE_PATH#$CWD/}" -if [ "$REL_PATH" = "$FILE_PATH" ]; then - REL_PATH="$BASENAME" -fi - -CONTEXT=$(PYTHONPATH="$SCRIPT_DIR" MEM0_SEARCH_USER="$USER_ID" MEM0_SEARCH_PROJECT="$PROJECT_ID" MEM0_SEARCH_QUERY="$BASENAME" MEM0_SEARCH_RELPATH="$REL_PATH" python3 -c " -import os, sys -sys.path.insert(0, os.environ.get('PYTHONPATH', '.')) -from _search import search_memories, format_results_for_context - -api_key = os.environ.get('MEM0_API_KEY', '') -user_id = os.environ.get('MEM0_SEARCH_USER', 'default') -project_id = os.environ.get('MEM0_SEARCH_PROJECT', 'unknown') -filename = os.environ.get('MEM0_SEARCH_QUERY', '') -relpath = os.environ.get('MEM0_SEARCH_RELPATH', '') - -results = search_memories( - api_key, user_id, project_id, filename, - metadata_filters={'files': {'contains': relpath}} if relpath else None, - top_k=3, min_score=0.4, -) -if not results and relpath: - results = search_memories( - api_key, user_id, project_id, filename, - top_k=3, min_score=0.4, - ) -if results: - print(format_results_for_context(results, heading=f'mem0 context for {filename}')) -" 2>/dev/null || true) - -if [ -n "$CONTEXT" ]; then - jq -nc --arg ctx "$CONTEXT" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$ctx}}' -fi - -exit 0 diff --git a/mem0-plugin/scripts/on_git_commit_capture.sh b/mem0-plugin/scripts/on_git_commit_capture.sh deleted file mode 100755 index f676627a9..000000000 --- a/mem0-plugin/scripts/on_git_commit_capture.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env bash -# Hook: PreToolUse (matcher: Bash) -# -# Detects `git commit` commands and searches for relevant memories -# about the changed files, surfacing them as pre-commit context. -# -# Input: JSON on stdin with tool_name, tool_input -# Output: JSON with additionalContext (relevant memories for the commit) - -set -uo pipefail - -INPUT=$(cat) - -COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "") - -if [ -z "$COMMAND" ]; then - exit 0 -fi - -case "$COMMAND" in - *"git commit"*|*"git merge"*|*"git rebase"*) - ;; - *) - exit 0 - ;; -esac - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -API_KEY="${MEM0_API_KEY:-${CLAUDE_PLUGIN_OPTION_MEM0_API_KEY:-}}" -if [ -z "$API_KEY" ]; then - exit 0 -fi - -# Foreground: search for relevant memories about changed files -CHANGED_FILES=$(git diff --cached --name-only 2>/dev/null | head -10 | tr '\n' ', ' | sed 's/,$//') -if [ -z "$CHANGED_FILES" ]; then - exit 0 -fi - -USER_ID="${MEM0_RESOLVED_USER_ID:-$USER}" -PROJECT_ID="${MEM0_PROJECT_ID:-unknown}" - -CONTEXT=$(_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 = 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}', - 'filters': {'AND': [{'user_id': user_id}, {'app_id': app_id}]}, - 'top_k': 3, -}).encode() -req = urllib.request.Request( - 'https://api.mem0.ai/v3/memories/search/', - data=body, - headers={'Authorization': f'Token {api_key}', 'Content-Type': 'application/json'}, - method='POST', -) -try: - with urllib.request.urlopen(req, timeout=5) as r: - results = json.loads(r.read()) - memories = results if isinstance(results, list) else results.get('results', []) - if memories: - lines = ['## Pre-Commit Memory Check', '', 'Relevant memories for files being committed (' + files + '):', ''] - for m in memories[:3]: - mid = m.get('id', '?')[:8] - text = m.get('memory', '')[:200] - cat = (m.get('metadata') or {}).get('type', 'unknown') - lines.append(f'- [{cat}] {text} [mem0:{mid}]') - lines.append('') - lines.append('Consider: does this commit introduce a learning worth saving? If so, suggest storing it after the commit completes.') - print('\\n'.join(lines)) -except Exception: - pass -" 2>/dev/null || true) - -if [ -n "$CONTEXT" ]; then - jq -nc --arg ctx "$CONTEXT" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$ctx}}' -fi - -exit 0 diff --git a/mem0-plugin/scripts/on_post_commit.sh b/mem0-plugin/scripts/on_post_commit.sh deleted file mode 100755 index aa4cc2441..000000000 --- a/mem0-plugin/scripts/on_post_commit.sh +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env bash -# Hook: PostToolUse (matcher: Bash) -# -# Fires AFTER a Bash tool call completes. When a git commit/merge/rebase -# just succeeded, surfaces 1-3 relevant memories from the changed files -# and prompts Claude to ask the user if this change should be stored as -# a learning. -# -# Input: JSON on stdin with tool_name, tool_input, tool_response -# Output: Context injected into Claude's next response (exit 0) -# -# This implements Spec #28 — interactive pre-commit memory check. - -set -uo pipefail - -INPUT=$(cat) - -COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "") - -if [ -z "$COMMAND" ]; then - exit 0 -fi - -# Only trigger on git commit/merge/rebase commands -case "$COMMAND" in - *"git commit"*|*"git merge"*|*"git rebase"*) - ;; - *) - exit 0 - ;; -esac - -# Check if the command actually succeeded (look for commit hash in output) -TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_response // ""' 2>/dev/null || echo "") -if echo "$TOOL_RESULT" | grep -qiE '(error|fatal|abort|nothing to commit|no changes)'; then - exit 0 -fi - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true - -# No API key — skip interactive prompt -if [ -z "${MEM0_API_KEY:-}" ]; then - exit 0 -fi - -# C5: commit prompts default OFF — only prompt if explicitly enabled in mem0.md -MEM0_CWD=$(git rev-parse --show-toplevel 2>/dev/null || echo ".") -COMMIT_PROMPTS=$(python3 "$SCRIPT_DIR/parse_mem0_config.py" --key settings.commit_prompts "$MEM0_CWD" 2>/dev/null || echo "") -if [ "$COMMIT_PROMPTS" != "true" ]; then - exit 0 -fi - -# Extract changed files from commit -CHANGED_FILES=$(git -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 -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 ' ') - -CTX="Commit captured: ${COMMIT_MSG}\nFiles changed (${FILE_COUNT}):\n${FILE_LIST}\n\nCommit learnings (decision, task_learning, anti_pattern, convention) are stored via add_memory with user_id=${MEM0_RESOLVED_USER_ID}, app_id=${MEM0_PROJECT_ID}, branch=${MEM0_BRANCH}." - -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 & - -exit 0 diff --git a/mem0-plugin/scripts/on_post_compact.sh b/mem0-plugin/scripts/on_post_compact.sh deleted file mode 100755 index 75d88b7b1..000000000 --- a/mem0-plugin/scripts/on_post_compact.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# Hook: PostCompact (matcher: manual|auto) -# -# Fires after context compaction completes. Runs silently — recovery -# is handled by SessionStart hook with source=compact. - -set -uo pipefail - -if [ -n "${MEM0_DEBUG:-}" ]; then - mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" -fi - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true - -INPUT=$(cat) -TRIGGER=$(echo "$INPUT" | jq -r '.trigger // "auto"' 2>/dev/null || echo "auto") -RETAINED=$(echo "$INPUT" | jq -r '.messages_retained // "?"' 2>/dev/null || echo "?") -REMOVED=$(echo "$INPUT" | jq -r '.messages_removed // "?"' 2>/dev/null || echo "?") - -# Telemetry (background) -python3 "$SCRIPT_DIR/telemetry.py" post_compact --trigger="$TRIGGER" --retained="$RETAINED" --removed="$REMOVED" 2>/dev/null & - -exit 0 diff --git a/mem0-plugin/scripts/on_session_end.sh b/mem0-plugin/scripts/on_session_end.sh deleted file mode 100755 index 3f06440aa..000000000 --- a/mem0-plugin/scripts/on_session_end.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -set -uo pipefail - -if [ -n "${MEM0_DEBUG:-}" ]; then - mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" -fi - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -INPUT=$(cat) -REASON=$(echo "$INPUT" | jq -r '.reason // "other"' 2>/dev/null || echo "other") - -REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "") -if [ -n "$REPORT" ] && [ "$REPORT" != "Session: no memory operations." ]; then - mkdir -p "$HOME/.mem0" 2>/dev/null || true - _LOG_FILE="$HOME/.mem0/session-log.md" - echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $REPORT" >> "$_LOG_FILE" 2>/dev/null || true - _LINE_COUNT=$(wc -l < "$_LOG_FILE" 2>/dev/null || echo 0) - if [ "$_LINE_COUNT" -gt 500 ]; then - tail -500 "$_LOG_FILE" > "${_LOG_FILE}.tmp" 2>/dev/null && mv "${_LOG_FILE}.tmp" "$_LOG_FILE" 2>/dev/null || true - fi -fi - -python3 "$SCRIPT_DIR/telemetry.py" session_end --reason="$REASON" 2>/dev/null & - -find "$HOME/.mem0" -name ".captured_*" -mtime +7 -delete 2>/dev/null || true - -exit 0 diff --git a/mem0-plugin/scripts/on_session_start.sh b/mem0-plugin/scripts/on_session_start.sh index 5fb3efc67..828be49a4 100755 --- a/mem0-plugin/scripts/on_session_start.sh +++ b/mem0-plugin/scripts/on_session_start.sh @@ -18,6 +18,7 @@ fi PYTHONPATH="$SCRIPT_DIR" python3 "$SCRIPT_DIR/load_settings.py" init 2>/dev/null || true rm -f "/tmp/mem0_rubric_injected_${USER}" 2>/dev/null || true rm -f /tmp/mem0_rubric_* 2>/dev/null || true +rm -f "/tmp/mem0_msg_count_${USER:-default}" 2>/dev/null || true MEM0_SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "") if [ -z "$MEM0_SESSION_ID" ]; then MEM0_SESSION_ID="ses_$(date +%s)_$$" @@ -25,12 +26,15 @@ 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 +# Persist identity to Claude's env so Bash tool calls, MCP config, 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" + if [ -n "${MEM0_API_KEY:-}" ]; then + echo "export MEM0_API_KEY=\"$MEM0_API_KEY\"" >> "$CLAUDE_ENV_FILE" + fi fi if [ -z "${MEM0_API_KEY:-}" ]; then @@ -48,7 +52,12 @@ IMPORTANT: In your FIRST response, display this exact status line as your openin Mem0 — Setup Required | user=${_UID} | project=${_PID} | branch=${_BR} | auth=none \`\`\` -MEM0_API_KEY is not set. Invoke the \`mem0:onboard\` skill immediately using the Skill tool to set up the API key and configure mem0 for this project. +MEM0_API_KEY is not set. To configure: +- **Desktop app**: Run \`claude plugin configure mem0\` in the built-in terminal, or add \`MEM0_API_KEY\` in the Desktop app's environment editor (Settings → Environment) +- **CLI**: Add \`export MEM0_API_KEY=m0-...\` to your shell profile (~/.zshrc or ~/.bashrc) +- Get a key at https://app.mem0.ai/dashboard/api-keys + +Then invoke the \`mem0:onboard\` skill to complete setup. BANNER exit 0 fi @@ -66,22 +75,26 @@ import json, os, urllib.request, urllib.error api_key = os.environ.get('MEM0_API_KEY', '') user_id = os.environ.get('MEM0_RESOLVED_USER_ID', 'default') app_id = os.environ.get('MEM0_PROJECT_ID', '') -body = json.dumps({'filters': {'user_id': user_id, 'app_id': app_id}}).encode() -req = urllib.request.Request( - 'https://api.mem0.ai/v3/memories/?page=1&page_size=1', - headers={'Authorization': f'Token {api_key}', 'Content-Type': 'application/json'}, - data=body, - method='POST', -) -try: + +def get_count(filters): + body = json.dumps({'filters': filters}).encode() + req = urllib.request.Request( + 'https://api.mem0.ai/v3/memories/?page=1&page_size=1', + headers={'Authorization': f'Token {api_key}', 'Content-Type': 'application/json'}, + data=body, method='POST', + ) with urllib.request.urlopen(req, timeout=5) as r: data = json.loads(r.read()) if isinstance(data, dict) and 'count' in data: - print(data['count']) - elif isinstance(data, list): - print(len(data)) - else: - print('?') + return data['count'] + if isinstance(data, list): + return len(data) + return 0 + +try: + base = [{'user_id': user_id}, {'app_id': app_id}] + total = get_count({'AND': base}) + print(total) except Exception: print('?') " 2>/dev/null || echo "?") @@ -107,6 +120,8 @@ Always include \`user_id\` + \`app_id\` in every \`search_memories\` filter and - user_id: \`${_UID}\` - app_id: \`${_PID}\` (project scope — passed as top-level \`app_id\`, NOT in metadata) +After completing any task, decision, or meaningful exchange, proactively store learnings via \`add_memory\`. Do NOT wait until the session ends — store memories incrementally as work progresses. Focus on: decisions made, bugs fixed, patterns discovered, user preferences, or task outcomes. Aim for 1–3 memories per substantial interaction. + BANNER if [ -n "$_INSTALL_WARN" ]; then @@ -135,7 +150,7 @@ if [ "$SOURCE" = "startup" ]; then echo "Native MEMORY.md detected at ${_MEMORY_MD}. Add autoMemoryEnabled:false to settings.json or run /mem0:import." fi - MEM0_CWD="$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")" \ + MEM0_CWD="$MEM0_CWD_RESOLVED" \ python3 "$SCRIPT_DIR/auto_import.py" 2>/dev/null & elif [ "$SOURCE" = "resume" ]; then diff --git a/mem0-plugin/scripts/on_stop.sh b/mem0-plugin/scripts/on_stop.sh deleted file mode 100755 index 695e83bd0..000000000 --- a/mem0-plugin/scripts/on_stop.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/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 - mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" -fi - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -# shellcheck source=_identity.sh -. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true - -INPUT=$(cat) - -# 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 & - -# 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_stop_codex.sh b/mem0-plugin/scripts/on_stop_codex.sh deleted file mode 100755 index cdad20a83..000000000 --- a/mem0-plugin/scripts/on_stop_codex.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -# Hook: Stop (Codex) -# -# Fires when Codex finishes a turn. Reminds the agent to persist any -# important learnings via the mem0 MCP tools before the turn closes. -# -# Input: JSON on stdin with session_id, turn_id, stop_hook_active, -# last_assistant_message, transcript_path, cwd, -# hook_event_name, model -# Output: JSON on stdout (Codex rejects plain text on Stop). -# - stop_hook_active=true -> {"continue": true} (let the turn end) -# - stop_hook_active=false -> {"decision":"block","reason":"..."} -# (continue the turn with the reminder as context) -# -# We must respect stop_hook_active or we'd loop forever: every "block" -# reopens the turn, which triggers Stop again when the agent settles. - -set -uo pipefail - -if [ -n "${MEM0_DEBUG:-}" ]; then - mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" -fi - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -# shellcheck source=_identity.sh -. "$SCRIPT_DIR/_identity.sh" - -INPUT=$(cat) -STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false") - -if [ "$STOP_HOOK_ACTIVE" = "true" ]; then - printf '{"continue":true}\n' - exit 0 -fi - -# Telemetry: fire before report() deletes stats file -_TELEM_CAT=$(python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('categories',[])))" 2>/dev/null || echo "0") -python3 "$SCRIPT_DIR/telemetry.py" stop --categories_count="$_TELEM_CAT" 2>/dev/null & - -# Session-end report (best-effort, must not break JSON output) -REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "") -REPORT_BLOCK="" -if [ -n "$REPORT" ]; then - REPORT_BLOCK="---\nmem0 $REPORT\n---\n\n" - mkdir -p "$HOME/.mem0" 2>/dev/null || true - echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $REPORT" >> "$HOME/.mem0/session-log.md" 2>/dev/null || true -fi - -REASON=$(cat < Store with metadata \`{"type": "decision"}\` -2. Were any new patterns or strategies discovered? -> Store with metadata \`{"type": "task_learning"}\` -3. Did any approach fail? -> Store with metadata \`{"type": "anti_pattern"}\` -4. Did you learn anything about the user's preferences? -> Store with metadata \`{"type": "user_preference"}\` -5. Were there environment/setup discoveries? -> Store with metadata \`{"type": "environmental"}\` - -Memories can be as detailed as needed — include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners. - -Always include \`app_id\` (the active project_id from SessionStart) as a top-level parameter in every \`add_memory\` call. - -If nothing notable happened in this interaction, it's fine to skip. Only store genuinely useful learnings. -EOF -) - -jq -cn --arg reason "$REASON" '{decision:"block", reason:$reason}' - -# Capture transcript state in the background via Mem0 REST API -echo "$INPUT" | python3 "$SCRIPT_DIR/on_pre_compact.py" --source=session-end 2>/dev/null & - -exit 0 diff --git a/mem0-plugin/scripts/on_stop_cursor.sh b/mem0-plugin/scripts/on_stop_cursor.sh deleted file mode 100755 index ebad12edb..000000000 --- a/mem0-plugin/scripts/on_stop_cursor.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env bash -# Hook: Stop (Cursor) -# -# Fires when Cursor agent completes a turn. Wraps the same logic as -# on_stop.sh but outputs JSON (Cursor expects {"followup_message":"..."}). -# -# Input: JSON on stdin with status, loop_count, conversation_id, etc. -# Output: JSON on stdout: {"followup_message":""} - -set -uo pipefail - -if [ -n "${MEM0_DEBUG:-}" ]; then - mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" -fi - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -# shellcheck source=_identity.sh -. "$SCRIPT_DIR/_identity.sh" - -INPUT=$(cat) - -# Guard against infinite loops: if this is a re-entry after a prior followup, -# let the turn end. Cursor exposes loop_count in the input JSON. -LOOP_COUNT=$(echo "$INPUT" | jq -r '.loop_count // 0' 2>/dev/null || echo "0") -if [ "$LOOP_COUNT" -gt 1 ]; then - echo '{}' - exit 0 -fi - -# Telemetry: fire before report() deletes stats file -_TELEM_CAT=$(python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('categories',[])))" 2>/dev/null || echo "0") -python3 "$SCRIPT_DIR/telemetry.py" stop --categories_count="$_TELEM_CAT" 2>/dev/null & - -# Session-end report (best-effort) -REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "") -REPORT_BLOCK="" -if [ -n "$REPORT" ]; then - REPORT_BLOCK="---\nmem0 $REPORT\n---\n\n" - mkdir -p "$HOME/.mem0" 2>/dev/null || true - echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $REPORT" >> "$HOME/.mem0/session-log.md" 2>/dev/null || true -fi - -MESSAGE=$(cat < Store with metadata \`{"type": "decision"}\` -2. Were any new patterns or strategies discovered? -> Store with metadata \`{"type": "task_learning"}\` -3. Did any approach fail? -> Store with metadata \`{"type": "anti_pattern"}\` -4. Did you learn anything about the user's preferences? -> Store with metadata \`{"type": "user_preference"}\` -5. Were there environment/setup discoveries? -> Store with metadata \`{"type": "environmental"}\` - -Always include \`app_id\` (the active project_id from session start) as a top-level parameter in every \`add_memory\` call. - -If nothing notable happened, it's fine to skip. Only store genuinely useful learnings. -EOF -) - -jq -cn --arg msg "$MESSAGE" '{followup_message:$msg}' - -# Capture transcript state in the background via Mem0 REST API -echo "$INPUT" | python3 "$SCRIPT_DIR/on_pre_compact.py" --source=session-end 2>/dev/null & - -exit 0 diff --git a/mem0-plugin/scripts/on_subagent_stop.sh b/mem0-plugin/scripts/on_subagent_stop.sh deleted file mode 100755 index c6bd178a4..000000000 --- a/mem0-plugin/scripts/on_subagent_stop.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -# Hook: SubagentStop -# -# 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: Text context for parent agent (exit 0) - -set -uo pipefail - -if [ -n "${MEM0_DEBUG:-}" ]; then - mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" -fi - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -INPUT=$(cat) -AGENT_TYPE=$(echo "$INPUT" | jq -r '.agent_type // ""' 2>/dev/null || echo "") -RESULT_SUMMARY=$(echo "$INPUT" | jq -r '.result_summary // ""' 2>/dev/null || echo "") - -# Skip short/empty results -if [ ${#RESULT_SUMMARY} -lt 50 ]; then - exit 0 -fi - -# Read subagent skip list from config, default to Explore|Plan -_SKIP_CSV=$(python3 "$SCRIPT_DIR/parse_mem0_config.py" --key settings.subagent_skip "$(git rev-parse --show-toplevel 2>/dev/null || echo ".")" 2>/dev/null || echo "") -if [ -n "$_SKIP_CSV" ]; then - _SKIP_PATTERN=$(echo "$_SKIP_CSV" | tr -d ' ' | tr ',' '|') -else - _SKIP_PATTERN="Explore|Plan" -fi - -# Skip 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 -fi - -. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true - -echo "Subagent completed: ${AGENT_TYPE}. Reusable learnings from subagents are stored via add_memory." - -exit 0 diff --git a/mem0-plugin/scripts/on_task_completed.sh b/mem0-plugin/scripts/on_task_completed.sh deleted file mode 100755 index 907774269..000000000 --- a/mem0-plugin/scripts/on_task_completed.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -# Hook: TaskCompleted -# -# Fires when a task is marked as completed. Stdout is fed back to Claude -# as context, prompting it to capture learnings. -# -# Input: JSON on stdin with task_id, task_subject, task_description -# Output: Text feedback to Claude (exit 0) - -set -uo pipefail - -if [ -n "${MEM0_DEBUG:-}" ]; then - mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" -fi - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true - -INPUT=$(cat) -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}" - -echo "Task completed: ${TASK_SUBJECT}. Learnings (decision, task_learning, anti_pattern, convention) are captured via add_memory with app_id=${_PROJECT}." - -# Telemetry (background, fire-and-forget) -python3 "$SCRIPT_DIR/telemetry.py" task_completed 2>/dev/null & - -exit 0 diff --git a/mem0-plugin/scripts/on_tool_failure.sh b/mem0-plugin/scripts/on_tool_failure.sh deleted file mode 100755 index eb40991bb..000000000 --- a/mem0-plugin/scripts/on_tool_failure.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env bash -# Hook: PostToolUseFailure (matcher: mcp__mem0__) -# -# Fires when a mem0 MCP tool call fails. Logs the failure, bumps telemetry, -# and injects a retry hint so Claude can recover. -# -# Input: JSON on stdin with tool_name, tool_input, tool_error -# Output: Context injected into Claude's next response (exit 0) - -set -uo pipefail - -if [ -n "${MEM0_DEBUG:-}" ]; then - mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" -fi - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -INPUT=$(cat) -TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null || echo "") -TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_error // ""' 2>/dev/null || echo "") -TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}' 2>/dev/null || echo "{}") - -# Extract the short tool name (strip any mcp prefix variant) -SHORT_NAME="${TOOL_NAME##*__}" -[ "$SHORT_NAME" = "$TOOL_NAME" ] && SHORT_NAME="${TOOL_NAME#mcp__mem0__}" - -# Log failure to persistent file for debugging -mkdir -p "$HOME/.mem0" 2>/dev/null || true -echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) FAIL $TOOL_NAME: $TOOL_RESULT" >> "$HOME/.mem0/tool-failures.log" 2>/dev/null || true - -# Telemetry (background, fire-and-forget) -python3 "$SCRIPT_DIR/telemetry.py" tool_failure --tool="$SHORT_NAME" 2>/dev/null & - -# Classify the error -IS_AUTH_ERROR="" -IS_RATE_LIMIT="" -IS_NETWORK_ERROR="" - -if echo "$TOOL_RESULT" | grep -qiE '(401|403|unauthorized|forbidden|invalid.*token|invalid.*key)'; then - IS_AUTH_ERROR="true" -elif echo "$TOOL_RESULT" | grep -qiE '(429|rate.?limit|too many requests|quota)'; then - IS_RATE_LIMIT="true" -elif echo "$TOOL_RESULT" | grep -qiE '(timeout|connect|ECONNREFUSED|network|DNS|resolve)'; then - IS_NETWORK_ERROR="true" -fi - -cat </dev/null || true # Rubric dedup: only inject full rubric once per session. -# Key on session ID (from stdin JSON) to avoid cross-session interference. +# Key on session ID to avoid cross-session interference. SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "") -RUBRIC_DIR="${MEM0_RUBRIC_DIR:-/tmp}" -if [ -n "$SESSION_ID" ]; then - RUBRIC_FLAG="$RUBRIC_DIR/mem0_rubric_${SESSION_ID}" -else - RUBRIC_FLAG="$RUBRIC_DIR/mem0_rubric_injected_${USER}" +if [ -z "$SESSION_ID" ]; then + _SID_FILE="/tmp/mem0_session_id_${USER:-default}" + [ -f "$_SID_FILE" ] && SESSION_ID=$(cat "$_SID_FILE" 2>/dev/null) || true fi +if [ -z "$SESSION_ID" ]; then + SESSION_ID="default_${USER:-unknown}" +fi +RUBRIC_DIR="${MEM0_RUBRIC_DIR:-/tmp}" +RUBRIC_FLAG="$RUBRIC_DIR/mem0_rubric_${SESSION_ID}" RUBRIC_ALREADY_SHOWN="" if [ -f "$RUBRIC_FLAG" ]; then RUBRIC_ALREADY_SHOWN="true" fi +# Track message count for periodic memory-save nudges. +# Every 5th substantial message, remind the agent to store learnings. +MSG_COUNT_FILE="/tmp/mem0_msg_count_${USER:-default}" +MSG_COUNT=0 +if [ -f "$MSG_COUNT_FILE" ]; then + MSG_COUNT=$(cat "$MSG_COUNT_FILE" 2>/dev/null || echo "0") +fi +MSG_COUNT=$((MSG_COUNT + 1)) +printf '%s' "$MSG_COUNT" > "$MSG_COUNT_FILE" 2>/dev/null || true +NEEDS_SAVE_NUDGE="" +if [ $((MSG_COUNT % 5)) -eq 0 ] && [ "$MSG_COUNT" -gt 0 ]; then + NEEDS_SAVE_NUDGE="true" +fi + # Detect stack traces and error patterns in the prompt (no API needed) HAS_ERROR="" if echo "$PROMPT" | grep -qE '(Traceback|panic:)'; then @@ -151,6 +168,27 @@ if [ -n "$FILE_PATHS" ]; then _PROMPT_CTX="${_PROMPT_CTX:+${_PROMPT_CTX}\n}File paths detected: ${FILE_PATHS}" fi +# Auto-capture: directly call mem0 API in background every 3rd message. +# At MSG_COUNT=3 the 3rd response isn't in the transcript yet (hook fires +# before Claude responds), so we capture 4 exchanges instead of 3. The +# overlapping window ensures the next batch (MSG_COUNT=6) picks up the +# exchange that was incomplete in the previous batch. +TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null || echo "") +if [ $((MSG_COUNT % 3)) -eq 0 ] && [ "$MSG_COUNT" -gt 0 ] && [ -n "$TRANSCRIPT_PATH" ]; then + python3 "$SCRIPT_DIR/auto_capture.py" "$TRANSCRIPT_PATH" 2>/dev/null & +fi + +# Prompt-based nudge as fallback when auto-capture hasn't run yet. +_ADDS=0 +_STATS_FILE="/tmp/mem0_session_stats_${USER:-default}.json" +if [ -f "$_STATS_FILE" ]; then + _ADDS=$(python3 -c "import json; print(json.load(open('$_STATS_FILE')).get('adds',0))" 2>/dev/null || echo "0") +fi + +if [ "$MSG_COUNT" -ge 3 ] && [ "$_ADDS" -lt "$((MSG_COUNT / 3))" ]; then + _PROMPT_CTX="${_PROMPT_CTX:+${_PROMPT_CTX}\n}After responding, store any new decisions, learnings, or preferences from this exchange via add_memory. Keep it to 1 sentence per memory." +fi + if [ -n "$_PROMPT_CTX" ]; then jq -cn --arg ctx "$_PROMPT_CTX" '{ hookSpecificOutput: { diff --git a/mem0-plugin/scripts/stop_hook_check.py b/mem0-plugin/scripts/stop_hook_check.py deleted file mode 100755 index 4294ea375..000000000 --- a/mem0-plugin/scripts/stop_hook_check.py +++ /dev/null @@ -1,158 +0,0 @@ -#!/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/skills/mem0/references/api-reference.md b/mem0-plugin/skills/mem0/references/api-reference.md index 62ec4bea7..4ab8548ca 100644 --- a/mem0-plugin/skills/mem0/references/api-reference.md +++ b/mem0-plugin/skills/mem0/references/api-reference.md @@ -14,8 +14,8 @@ All endpoints require: `Authorization: Token ` | Get Single Memory | `GET` | `/v1/memories/{memory_id}/` | | Update Memory | `PUT` | `/v1/memories/{memory_id}/` | | Delete Memory | `DELETE` | `/v1/memories/{memory_id}/` | - -Note: v1/v2 endpoints still work (backward compatible). +| Delete All Memories | `DELETE` | `/v1/memories/?user_id=X&app_id=Y` | +| Get Event Status | `GET` | `/v1/event/{event_id}/` | ## Memory Object Structure diff --git a/mem0-plugin/skills/mem0/references/quickstart.md b/mem0-plugin/skills/mem0/references/quickstart.md index 0a47a2fd4..132982a49 100644 --- a/mem0-plugin/skills/mem0/references/quickstart.md +++ b/mem0-plugin/skills/mem0/references/quickstart.md @@ -74,7 +74,7 @@ console.log(results); export MEM0_API_KEY="m0-your-api-key" # Add memory -curl -X POST https://api.mem0.ai/v1/memories/ \ +curl -X POST https://api.mem0.ai/v3/memories/add/ \ -H "Authorization: Token $MEM0_API_KEY" \ -H "Content-Type: application/json" \ -d '{ diff --git a/mem0-plugin/skills/onboard/SKILL.md b/mem0-plugin/skills/onboard/SKILL.md index 48716de64..ccb5c1af2 100644 --- a/mem0-plugin/skills/onboard/SKILL.md +++ b/mem0-plugin/skills/onboard/SKILL.md @@ -21,10 +21,10 @@ This is silent and idempotent — safe to run anytime. ## Step 1: Set up API key -Check if `MEM0_API_KEY` is already set by running: +Check if the API key is available from any source: ```bash -[ -n "$MEM0_API_KEY" ] && echo "SET" || echo "NOT_SET" +[ -n "${MEM0_API_KEY:-${CLAUDE_PLUGIN_OPTION_API_KEY:-}}" ] && echo "SET" || echo "NOT_SET" ``` IMPORTANT: Never run `echo $MEM0_API_KEY` — that prints the secret in plaintext to the conversation log. @@ -33,27 +33,32 @@ IMPORTANT: Never run `echo $MEM0_API_KEY` — that prints the secret in plaintex Print: `- API key found.` and proceed to Step 2. -### If API key is NOT set (empty output) +### If API key is NOT set (output is "NOT_SET") -Guide the user through API key setup. Show this message and walk them through it: +Guide the user through API key setup. Show this message: ``` Step 1: Setting up API key. -- MEM0_API_KEY not found. Let's set it up. +- API key not found. Let's set it up. 1. Get your API key from https://app.mem0.ai/dashboard/api-keys - (or run: mem0 init --agent --json) - 2. Add to shell profile: - echo 'export MEM0_API_KEY="m0-your-key-here"' >> ~/.zshrc - source ~/.zshrc + 2. Choose ONE method: + + Option A — Plugin config (works on Desktop + CLI): + Type: ! claude plugin configure mem0 + Paste your API key when prompted. + + Option B — Shell profile (CLI only): + echo 'export MEM0_API_KEY="m0-your-key-here"' >> ~/.zshrc + source ~/.zshrc 3. Verify: - [ -n "$MEM0_API_KEY" ] && echo "SET" || echo "NOT_SET" + [ -n "${MEM0_API_KEY:-${CLAUDE_PLUGIN_OPTION_API_KEY:-}}" ] && echo "SET" || echo "NOT_SET" ``` -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. +After the user confirms, re-run the verify command. If NOT_SET, repeat. If SET, proceed to Step 2. ## Step 2: MCP server connection diff --git a/mem0-plugin/skills/stats/SKILL.md b/mem0-plugin/skills/stats/SKILL.md index 8e790affd..5652cb58e 100644 --- a/mem0-plugin/skills/stats/SKILL.md +++ b/mem0-plugin/skills/stats/SKILL.md @@ -25,24 +25,23 @@ If the script returns empty or errors, note "No session data available" and cont ### Step 2: Fetch lifetime and session stats from API **Lifetime stats:** -Call `get_memories` with: -- `filters={"AND": [{"user_id": ""}, {"app_id": ""}]}` -- `page_size=100` +Call `get_memories` to fetch all memories for this project: -Count the returned memories. Group them by: +`filters={"AND": [{"user_id": ""}, {"app_id": ""}]}`, `page_size=100` + +Group by: 1. `categories[0]` (platform-assigned) — primary grouping 2. `metadata.type` (agent-assigned) — secondary if no categories 3. `created_at` date — for age analysis -**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": ""}]}` -- `page_size=100` +**Category normalization:** Merge `auto_capture` and `uncategorized` into a single `uncategorized` row. These are memories where the platform didn't assign a meaningful content category. Do NOT show `auto_capture` as its own row in the table. -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). +**Session stats (local only):** +Session stats come from the local stats file read in Step 1. Do NOT query the API with +`run_id` or `metadata.session_id` filters — these return unreliable results because +memories are stored without `run_id` and metadata filters on `session_id` are inconsistent. + +The local stats file tracks adds and searches for the current session accurately. 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. diff --git a/mem0-plugin/skills/tour/SKILL.md b/mem0-plugin/skills/tour/SKILL.md index 8928c2479..7e3bcb4aa 100644 --- a/mem0-plugin/skills/tour/SKILL.md +++ b/mem0-plugin/skills/tour/SKILL.md @@ -56,11 +56,9 @@ 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: -- `filters={"AND": [{"user_id": ""}, {"app_id": ""}]}` -- `page_size=100` +Call `get_memories` to fetch all memories for this project: -This returns every memory scoped to the project — no semantic filtering, no missed results. +`filters={"AND": [{"user_id": ""}, {"app_id": ""}]}`, `page_size=100` ### Step 2: Run supplementary semantic searches diff --git a/mem0-plugin/tests/conftest.py b/mem0-plugin/tests/conftest.py index a7d4e4a4b..ab3eb809c 100644 --- a/mem0-plugin/tests/conftest.py +++ b/mem0-plugin/tests/conftest.py @@ -23,8 +23,9 @@ def _scripts_on_path(): @pytest.fixture(autouse=True) -def _clean_project_map(): - """Remove project_map.json before each test to prevent cross-test pollution.""" +def _clean_project_map(monkeypatch): + """Remove project_map.json and clear MEM0_PROJECT_ID before each test.""" + monkeypatch.delenv("MEM0_PROJECT_ID", raising=False) map_path = os.path.expanduser("~/.mem0/project_map.json") if os.path.isfile(map_path): os.remove(map_path) diff --git a/mem0-plugin/tests/test_auto_capture.py b/mem0-plugin/tests/test_auto_capture.py new file mode 100644 index 000000000..3866b9503 --- /dev/null +++ b/mem0-plugin/tests/test_auto_capture.py @@ -0,0 +1,142 @@ +"""Tests for auto_capture.py transcript parsing and exchange extraction.""" + +from __future__ import annotations + +import json +import os +import sys + +import pytest + +SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts") + + +@pytest.fixture(autouse=True) +def _scripts_path(): + abs_scripts = os.path.abspath(SCRIPTS_DIR) + if abs_scripts not in sys.path: + sys.path.insert(0, abs_scripts) + yield + if abs_scripts in sys.path: + sys.path.remove(abs_scripts) + + +def _make_transcript(tmp_path, entries): + path = tmp_path / "transcript.jsonl" + lines = [] + for entry in entries: + lines.append(json.dumps(entry)) + path.write_text("\n".join(lines) + "\n") + return str(path) + + +def _msg(role, content): + return {"message": {"role": role, "content": content}} + + +class TestTailLines: + def test_reads_last_n_lines(self, tmp_path): + from auto_capture import tail_lines + + p = tmp_path / "test.txt" + p.write_text("\n".join(f"line{i}" for i in range(100)) + "\n") + result = tail_lines(str(p), 5) + assert len(result) >= 5 + assert result[-1] == "line99" + + def test_empty_file(self, tmp_path): + from auto_capture import tail_lines + + p = tmp_path / "empty.txt" + p.write_text("") + assert tail_lines(str(p), 10) == [] + + def test_nonexistent_file(self): + from auto_capture import tail_lines + + assert tail_lines("/nonexistent/path", 10) == [] + + +class TestExtractRecentExchanges: + def test_extracts_user_assistant_pairs(self): + from auto_capture import extract_recent_exchanges + + lines = [ + json.dumps(_msg("user", "What is Python used for?" * 3)), + json.dumps(_msg("assistant", "Python is used for many things." * 3)), + json.dumps(_msg("user", "Tell me about web frameworks." * 3)), + json.dumps(_msg("assistant", "Django and Flask are popular." * 3)), + ] + result = extract_recent_exchanges(lines, max_exchanges=2) + assert len(result) == 4 + assert result[0]["role"] == "user" + assert result[1]["role"] == "assistant" + + def test_skips_short_messages(self): + from auto_capture import extract_recent_exchanges + + lines = [ + json.dumps(_msg("user", "ok")), + json.dumps(_msg("assistant", "Sure, here is a detailed explanation." * 3)), + ] + result = extract_recent_exchanges(lines, max_exchanges=2) + assert len(result) == 1 + assert result[0]["role"] == "assistant" + + def test_skips_compact_summaries(self): + from auto_capture import extract_recent_exchanges + + lines = [ + json.dumps({"isCompactSummary": True, "message": {"role": "assistant", "content": "summary " * 20}}), + json.dumps(_msg("user", "This is a real user message here." * 2)), + ] + result = extract_recent_exchanges(lines, max_exchanges=2) + assert len(result) == 1 + assert result[0]["role"] == "user" + + def test_limits_to_max_exchanges(self): + from auto_capture import extract_recent_exchanges + + lines = [] + for i in range(10): + lines.append(json.dumps(_msg("user", f"Question number {i} with enough text to pass." * 2))) + lines.append(json.dumps(_msg("assistant", f"Answer number {i} with enough text to pass." * 2))) + result = extract_recent_exchanges(lines, max_exchanges=2) + assert len(result) == 4 + + def test_handles_list_content(self): + from auto_capture import extract_recent_exchanges + + lines = [ + json.dumps({"message": {"role": "user", "content": [ + {"type": "text", "text": "This is block content that is long enough." * 2}, + ]}}), + ] + result = extract_recent_exchanges(lines, max_exchanges=2) + assert len(result) == 1 + assert "block content" in result[0]["content"] + + def test_empty_lines(self): + from auto_capture import extract_recent_exchanges + + assert extract_recent_exchanges([], max_exchanges=2) == [] + + def test_truncates_long_content(self): + from auto_capture import extract_recent_exchanges + + long_text = "x" * 5000 + lines = [json.dumps(_msg("user", long_text))] + result = extract_recent_exchanges(lines, max_exchanges=1) + assert len(result) == 1 + assert len(result[0]["content"]) == 2000 + + def test_skips_tool_call_assistant_messages(self): + from auto_capture import extract_recent_exchanges + + lines = [ + json.dumps(_msg("assistant", '{"tool_calls": [{"name": "read"}]}')), + json.dumps(_msg("user", "Thanks for reading that file for me!" * 2)), + ] + result = extract_recent_exchanges(lines, max_exchanges=2) + assert len(result) == 1 + assert result[0]["role"] == "user" diff --git a/mem0-plugin/tests/test_on_file_read.py b/mem0-plugin/tests/test_on_file_read.py deleted file mode 100644 index 6eafa7ea6..000000000 --- a/mem0-plugin/tests/test_on_file_read.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Tests for on_file_read.sh hook — skip rules and dedup logic.""" - -from __future__ import annotations - -import json -import os -import subprocess - -SCRIPT = os.path.join(os.path.dirname(__file__), "..", "scripts", "on_file_read.sh") - - -def _run_hook(file_path: str, env_overrides: dict | None = None) -> subprocess.CompletedProcess: - env = { - **os.environ, - "MEM0_API_KEY": "", - "CLAUDE_PLUGIN_OPTION_API_KEY": "", - "CLAUDE_PLUGIN_OPTION_MEM0_API_KEY": "", - "USER": "testuser", - } - if env_overrides: - env.update(env_overrides) - - payload = json.dumps({"tool_name": "Read", "tool_input": {"file_path": file_path}}) - return subprocess.run( - ["bash", SCRIPT], - input=payload, - capture_output=True, - text=True, - env=env, - timeout=8, - ) - - -def test_skip_png(): - result = _run_hook("/project/logo.png") - assert result.returncode == 0 - assert result.stdout == "" - - -def test_skip_lockfile(): - result = _run_hook("/project/package-lock.json") - assert result.returncode == 0 - assert result.stdout == "" - - -def test_skip_min_js(): - result = _run_hook("/project/dist/bundle.min.js") - assert result.returncode == 0 - assert result.stdout == "" - - -def test_skip_node_modules(): - result = _run_hook("/project/node_modules/lodash/index.js") - assert result.returncode == 0 - assert result.stdout == "" - - -def test_skip_git_dir(): - result = _run_hook("/project/.git/config") - assert result.returncode == 0 - assert result.stdout == "" - - -def test_skip_pycache(): - result = _run_hook("/project/__pycache__/module.cpython-311.pyc") - assert result.returncode == 0 - assert result.stdout == "" - - -def test_skip_no_api_key(tmp_path): - """Code file but no API key — should exit cleanly with no output.""" - result = _run_hook("/project/src/app.py", {"MEM0_API_KEY": "", "HOME": str(tmp_path)}) - assert result.returncode == 0 - assert result.stdout == "" - - -def test_skip_empty_file_path(): - """Empty file_path — should exit cleanly.""" - result = _run_hook("") - assert result.returncode == 0 - assert result.stdout == "" - - -def test_dedup_repeated_reads(tmp_path): - """Same file read twice — second should be skipped (via dedup file).""" - dedup_file = str(tmp_path / "mem0_recent_reads_testuser") - env = {"MEM0_API_KEY": "fake-key", "USER": "testuser"} - - # Write the file path into the dedup tracking file - with open(dedup_file, "w") as f: - f.write("/project/src/app.py\n") - - # Patch RECENT_FILE by providing the USER env and ensuring /tmp has the file - # Since the script uses /tmp/mem0_recent_reads_${USER}, we create it there - tmp_dedup = "/tmp/mem0_recent_reads_testuser" - try: - with open(tmp_dedup, "w") as f: - f.write("/project/src/app.py\n") - - result = _run_hook("/project/src/app.py", env) - assert result.returncode == 0 - assert result.stdout == "" - finally: - if os.path.exists(tmp_dedup): - os.remove(tmp_dedup) diff --git a/mem0-plugin/tests/test_rubric_dedup.py b/mem0-plugin/tests/test_rubric_dedup.py index 3e80d69c0..e37816019 100644 --- a/mem0-plugin/tests/test_rubric_dedup.py +++ b/mem0-plugin/tests/test_rubric_dedup.py @@ -13,9 +13,12 @@ SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts") @pytest.fixture(autouse=True) def _clean_rubric_flag(tmp_path, monkeypatch): - """Use a temp dir for the rubric flag file.""" + """Use a temp dir for the rubric flag file and clean msg counter.""" monkeypatch.setenv("MEM0_RUBRIC_DIR", str(tmp_path)) + msg_count_file = "/tmp/mem0_msg_count_testuser" yield + if os.path.exists(msg_count_file): + os.unlink(msg_count_file) def _run_hook(prompt: str, env_overrides: dict | None = None, session_id: str = "test-sess-001") -> str: @@ -46,8 +49,7 @@ def _run_hook(prompt: str, env_overrides: dict | None = None, session_id: str = def test_first_prompt_gets_full_rubric(): """First substantial prompt of session gets full memory check rubric.""" output = _run_hook("How should we refactor the auth module?") - assert "Search mem0" in output - assert "Search tips" in output + assert "Mem0 searches apply" in output assert "metadata.type" in output diff --git a/mem0-plugin/tests/test_write_path.py b/mem0-plugin/tests/test_write_path.py index 7dce282d4..1be57df83 100644 --- a/mem0-plugin/tests/test_write_path.py +++ b/mem0-plugin/tests/test_write_path.py @@ -192,4 +192,16 @@ def test_resolve_api_key_returns_empty_when_neither_set(monkeypatch): monkeypatch.delenv("MEM0_API_KEY", raising=False) monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_API_KEY", raising=False) monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False) + monkeypatch.setattr("_identity._extract_key_from_shell_profiles", lambda: "") assert resolve_api_key() == "" + + +def test_resolve_api_key_falls_back_to_shell_profile(monkeypatch): + """resolve_api_key extracts key from shell profile when env vars are empty.""" + from _identity import resolve_api_key + + monkeypatch.delenv("MEM0_API_KEY", raising=False) + monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_API_KEY", raising=False) + monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False) + monkeypatch.setattr("_identity._extract_key_from_shell_profiles", lambda: "m0-from-profile") + assert resolve_api_key() == "m0-from-profile"