From 66600565ed1ed0c6374624fe388a9e8df0622ea3 Mon Sep 17 00:00:00 2001 From: kartik-mem0 Date: Thu, 21 May 2026 16:50:11 +0530 Subject: [PATCH] fix(mem0-plugin): thread cwd to resolvers, add write-path tests, userConfig for API key - on_pre_compact.py and capture_compact_summary.py now read cwd from hook stdin and pass it to resolve_project_id/resolve_branch (monorepo safe) - 5 new tests verify all write functions use app_id top-level and never put project_id in metadata - plugin.json declares userConfig.MEM0_API_KEY so Claude Code prompts for the key at install time (sensitive, stored in system keychain) --- mem0-plugin/.claude-plugin/plugin.json | 11 +- .../scripts/capture_compact_summary.py | 5 +- mem0-plugin/scripts/on_pre_compact.py | 5 +- mem0-plugin/tests/test_write_path.py | 166 ++++++++++++++++++ 4 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 mem0-plugin/tests/test_write_path.py diff --git a/mem0-plugin/.claude-plugin/plugin.json b/mem0-plugin/.claude-plugin/plugin.json index d82722125..5d1f41e4a 100644 --- a/mem0-plugin/.claude-plugin/plugin.json +++ b/mem0-plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.2.0", + "version": "0.2.1", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows using the Mem0 Platform MCP server.", "author": { "name": "Mem0", @@ -9,5 +9,12 @@ "homepage": "https://mem0.ai", "repository": "https://github.com/mem0ai/mem0", "license": "Apache-2.0", - "keywords": ["memory", "personalization", "mcp", "semantic-search"] + "keywords": ["memory", "personalization", "mcp", "semantic-search"], + "userConfig": { + "MEM0_API_KEY": { + "description": "Your Mem0 API key (get one at https://app.mem0.ai/dashboard/api-keys or run: mem0 init --agent --json)", + "required": true, + "sensitive": true + } + } } diff --git a/mem0-plugin/scripts/capture_compact_summary.py b/mem0-plugin/scripts/capture_compact_summary.py index 44112933e..9cbb825da 100644 --- a/mem0-plugin/scripts/capture_compact_summary.py +++ b/mem0-plugin/scripts/capture_compact_summary.py @@ -153,9 +153,10 @@ def main(): return session_id = hook_input.get("session_id", "") + cwd = hook_input.get("cwd") or None user_id = resolve_user_id() - project_id = resolve_project_id() - branch = resolve_branch() + project_id = resolve_project_id(cwd) + branch = resolve_branch(cwd) lines = tail_lines(transcript_path, MAX_TAIL_LINES) if not lines: diff --git a/mem0-plugin/scripts/on_pre_compact.py b/mem0-plugin/scripts/on_pre_compact.py index 5466eae61..6b614e210 100755 --- a/mem0-plugin/scripts/on_pre_compact.py +++ b/mem0-plugin/scripts/on_pre_compact.py @@ -234,9 +234,10 @@ def main(): return session_id = hook_input.get("session_id", "") + cwd = hook_input.get("cwd") or None user_id = resolve_user_id() - project_id = resolve_project_id() - branch = resolve_branch() + project_id = resolve_project_id(cwd) + branch = resolve_branch(cwd) lines = tail_lines(transcript_path, MAX_TAIL_LINES) if not lines: diff --git a/mem0-plugin/tests/test_write_path.py b/mem0-plugin/tests/test_write_path.py new file mode 100644 index 000000000..42404b24b --- /dev/null +++ b/mem0-plugin/tests/test_write_path.py @@ -0,0 +1,166 @@ +"""Tests for write-path app_id migration. + +Verifies that all scripts writing to the Mem0 API: +1. Pass app_id as a top-level parameter (not in metadata) +2. Do NOT include project_id in metadata +3. Include branch in metadata when available +""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + + +def test_auto_import_post_memory_uses_app_id(): + """auto_import.post_memory sends app_id top-level, not metadata.project_id.""" + from auto_import import post_memory + + captured = {} + + def mock_urlopen(req, timeout=None): + body = json.loads(req.data.decode("utf-8")) + captured.update(body) + resp = MagicMock() + resp.status = 200 + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + with patch("urllib.request.urlopen", side_effect=mock_urlopen): + result = post_memory( + api_key="test-key", + content="test content", + user_id="testuser", + filename="CLAUDE.md", + project_id="my-project", + branch="main", + ) + + assert result is True + assert captured["app_id"] == "my-project" + assert captured["user_id"] == "testuser" + assert "project_id" not in captured.get("metadata", {}) + assert captured["metadata"]["type"] == "project_profile" + assert captured["metadata"]["branch"] == "main" + assert captured["infer"] is False + + +def test_auto_import_post_memory_omits_empty_branch(): + """auto_import.post_memory skips branch in metadata when empty.""" + from auto_import import post_memory + + captured = {} + + def mock_urlopen(req, timeout=None): + body = json.loads(req.data.decode("utf-8")) + captured.update(body) + resp = MagicMock() + resp.status = 200 + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + with patch("urllib.request.urlopen", side_effect=mock_urlopen): + post_memory("key", "content", "user", "FILE.md", "proj", branch="") + + assert "branch" not in captured.get("metadata", {}) + + +def test_on_pre_compact_store_memory_uses_app_id(): + """on_pre_compact.store_memory sends app_id top-level.""" + from on_pre_compact import store_memory + + captured = {} + + def mock_urlopen(req, timeout=None): + body = json.loads(req.data.decode("utf-8")) + captured.update(body) + resp = MagicMock() + resp.status = 200 + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + with patch("urllib.request.urlopen", side_effect=mock_urlopen): + result = store_memory( + api_key="test-key", + content="session state content", + user_id="testuser", + source="pre-compaction", + session_id="sess-123", + project_id="my-project", + branch="feat/auth", + ) + + assert result is True + assert captured["app_id"] == "my-project" + assert captured["user_id"] == "testuser" + assert "project_id" not in captured.get("metadata", {}) + assert captured["metadata"]["type"] == "session_state" + assert captured["metadata"]["source"] == "pre-compaction" + assert captured["metadata"]["branch"] == "feat/auth" + assert "expiration_date" in captured + + +def test_capture_compact_summary_store_uses_app_id(): + """capture_compact_summary.store_summary sends app_id top-level.""" + from capture_compact_summary import store_summary + + captured = {} + + def mock_urlopen(req, timeout=None): + body = json.loads(req.data.decode("utf-8")) + captured.update(body) + resp = MagicMock() + resp.status = 200 + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + with patch("urllib.request.urlopen", side_effect=mock_urlopen): + result = store_summary( + api_key="test-key", + summary="compact summary text", + user_id="testuser", + session_id="sess-456", + project_id="my-project", + branch="main", + ) + + assert result is True + assert captured["app_id"] == "my-project" + assert captured["user_id"] == "testuser" + assert "project_id" not in captured.get("metadata", {}) + assert captured["metadata"]["type"] == "compact_summary" + assert captured["metadata"]["branch"] == "main" + assert captured["infer"] is False + assert "expiration_date" in captured + + +def test_no_metadata_project_id_anywhere(): + """Ensure none of the write functions put project_id in metadata.""" + from auto_import import post_memory + from capture_compact_summary import store_summary + from on_pre_compact import store_memory + + bodies = [] + + def mock_urlopen(req, timeout=None): + body = json.loads(req.data.decode("utf-8")) + bodies.append(body) + resp = MagicMock() + resp.status = 200 + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + with patch("urllib.request.urlopen", side_effect=mock_urlopen): + post_memory("k", "c", "u", "f", "proj", "br") + store_memory("k", "c", "u", "src", "sid", "proj", "br") + store_summary("k", "s", "u", "sid", "proj", "br") + + for i, body in enumerate(bodies): + metadata = body.get("metadata", {}) + assert "project_id" not in metadata, f"Write function #{i} still has metadata.project_id" + assert body.get("app_id") == "proj", f"Write function #{i} missing app_id top-level"