refactor: consolidate agent/editor plugins under integrations/ (#5491)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Kartik
2026-06-12 10:31:35 +05:30
committed by GitHub
co-authored by Claude
parent c676c2c458
commit 2c796d144f
295 changed files with 94 additions and 81 deletions
@@ -0,0 +1,66 @@
"""Shared fixtures for mem0-plugin tests."""
from __future__ import annotations
import os
import subprocess
import sys
import pytest
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
@pytest.fixture(autouse=True)
def _scripts_on_path():
"""Ensure scripts/ is on sys.path so we can import _project, session_stats, etc."""
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)
@pytest.fixture(autouse=True)
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)
yield
if os.path.isfile(map_path):
os.remove(map_path)
@pytest.fixture()
def tmp_git_repo(tmp_path):
"""Create a temp dir with a git repo and HTTPS remote."""
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
subprocess.run(
["git", "remote", "add", "origin", "https://github.com/mem0ai/mem0.git"],
cwd=tmp_path,
capture_output=True,
check=True,
)
return tmp_path
@pytest.fixture()
def tmp_git_repo_ssh(tmp_path):
"""Create a temp dir with a git repo and SSH remote."""
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
subprocess.run(
["git", "remote", "add", "origin", "git@github.com:acme/cool-project.git"],
cwd=tmp_path,
capture_output=True,
check=True,
)
return tmp_path
@pytest.fixture()
def tmp_no_git(tmp_path):
"""Temp dir with no git repo."""
return tmp_path
@@ -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"
@@ -0,0 +1,153 @@
"""Tests for auto_setup_categories.py -- the background coding-categories installer.
Covers the pure, network-free logic:
- fingerprints (api key + category taxonomy)
- state-file gating (load / save / is_applied)
- idempotent apply via an injected fake client (no SDK, no network)
- single source of truth for the category list (shared with setup_coding_categories)
"""
from __future__ import annotations
import os
import sys
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts"))
if SCRIPTS_DIR not in sys.path:
sys.path.insert(0, SCRIPTS_DIR)
import auto_setup_categories as asc # noqa: E402
from setup_coding_categories import CODING_CATEGORIES # noqa: E402
# --------------------------------------------------------------------------- #
# Fake client (dependency injection — apply_categories takes a client) #
# --------------------------------------------------------------------------- #
class _FakeProject:
def __init__(self, current):
self._current = current
self.update_calls: list = []
def get(self, fields=None):
return {"custom_categories": self._current}
def update(self, custom_categories=None, **kwargs):
self.update_calls.append(custom_categories)
return {"ok": True}
class _FakeClient:
def __init__(self, current):
self.project = _FakeProject(current)
# --------------------------------------------------------------------------- #
# Source-of-truth: the taxonomy is shared, not duplicated #
# --------------------------------------------------------------------------- #
def test_uses_shared_category_list():
"""auto_setup_categories must reuse setup_coding_categories' list, not fork it."""
assert asc.CODING_CATEGORIES is CODING_CATEGORIES
assert len(asc.CODING_CATEGORIES) == 17
# --------------------------------------------------------------------------- #
# Fingerprints #
# --------------------------------------------------------------------------- #
def test_categories_fingerprint_is_stable_hex():
fp = asc.categories_fingerprint(CODING_CATEGORIES)
assert isinstance(fp, str)
assert len(fp) == 16
assert all(c in "0123456789abcdef" for c in fp)
# deterministic across calls
assert fp == asc.categories_fingerprint(CODING_CATEGORIES)
def test_categories_fingerprint_changes_when_taxonomy_changes():
base = asc.categories_fingerprint(CODING_CATEGORIES)
changed = asc.categories_fingerprint(CODING_CATEGORIES + [{"new_one": "desc"}])
assert base != changed
def test_categories_fingerprint_order_independent():
"""Reordering the same categories must yield the same fingerprint."""
reordered = list(reversed(CODING_CATEGORIES))
assert asc.categories_fingerprint(CODING_CATEGORIES) == asc.categories_fingerprint(reordered)
def test_apikey_fingerprint_is_stable_and_opaque():
key = "m0-supersecret-abc123"
fp = asc.apikey_fingerprint(key)
assert isinstance(fp, str)
assert len(fp) == 16
assert fp == asc.apikey_fingerprint(key)
# never leak the raw key
assert "supersecret" not in fp
assert key not in fp
def test_apikey_fingerprint_differs_per_key():
assert asc.apikey_fingerprint("m0-aaa") != asc.apikey_fingerprint("m0-bbb")
# --------------------------------------------------------------------------- #
# State file: load / save / gating #
# --------------------------------------------------------------------------- #
def test_load_state_missing_file_returns_empty(tmp_path):
assert asc.load_state(str(tmp_path / "does_not_exist.json")) == {}
def test_load_state_corrupt_file_returns_empty(tmp_path):
p = tmp_path / "categories_setup.json"
p.write_text("{not valid json")
assert asc.load_state(str(p)) == {}
def test_save_then_load_roundtrip_creates_parent_dir(tmp_path):
p = tmp_path / "nested" / "categories_setup.json"
asc.save_state({"abc123": "deadbeef00000000"}, str(p))
assert p.is_file()
assert asc.load_state(str(p)) == {"abc123": "deadbeef00000000"}
def test_is_applied_true_only_on_exact_match():
state = {"keyfp": "catfp"}
assert asc.is_applied(state, "keyfp", "catfp") is True
# stale taxonomy fingerprint
assert asc.is_applied(state, "keyfp", "OTHER") is False
# unknown api key
assert asc.is_applied(state, "other-key", "catfp") is False
# empty state
assert asc.is_applied({}, "keyfp", "catfp") is False
# --------------------------------------------------------------------------- #
# apply_categories: idempotent, network-free via fake client #
# --------------------------------------------------------------------------- #
def test_apply_skips_update_when_already_matching():
client = _FakeClient(CODING_CATEGORIES)
result = asc.apply_categories(client, CODING_CATEGORIES)
assert result == "already-configured"
assert client.project.update_calls == [] # must NOT hit the write endpoint
def test_apply_updates_when_no_categories_set():
client = _FakeClient(None)
result = asc.apply_categories(client, CODING_CATEGORIES)
assert result == "applied"
assert client.project.update_calls == [CODING_CATEGORIES]
def test_apply_updates_when_categories_differ():
client = _FakeClient([{"food": "consumer default"}])
result = asc.apply_categories(client, CODING_CATEGORIES)
assert result == "applied"
assert client.project.update_calls == [CODING_CATEGORIES]
def test_fetch_current_categories_handles_non_dict():
"""A non-dict project response must degrade to None, not raise."""
class Weird:
project = type("P", (), {"get": staticmethod(lambda fields=None: "unexpected")})()
assert asc.fetch_current_categories(Weird()) is None
@@ -0,0 +1,88 @@
"""Tests for setup_coding_categories.py -- CODING_CATEGORIES list completeness."""
from __future__ import annotations
import importlib
import os
import sys
import pytest
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
EXPECTED_KEYS = [
"architecture_decisions",
"anti_patterns",
"task_learnings",
"tooling_setup",
"bug_fixes",
"coding_conventions",
"user_preferences",
"dependency_decisions",
"performance_findings",
"security_constraints",
"testing_patterns",
"data_model",
"api_contracts",
"deployment_runbook",
"team_norms",
"domain_glossary",
"experiment_results",
]
@pytest.fixture()
def coding_categories():
"""Import CODING_CATEGORIES from setup_coding_categories, ensuring scripts/ is on path."""
abs_scripts = os.path.abspath(SCRIPTS_DIR)
inserted = False
if abs_scripts not in sys.path:
sys.path.insert(0, abs_scripts)
inserted = True
# Force re-import in case another test already loaded a stale version
mod_name = "setup_coding_categories"
if mod_name in sys.modules:
del sys.modules[mod_name]
mod = importlib.import_module(mod_name)
yield mod.CODING_CATEGORIES
if inserted and abs_scripts in sys.path:
sys.path.remove(abs_scripts)
def test_total_count(coding_categories):
"""CODING_CATEGORIES must contain exactly 17 entries."""
assert len(coding_categories) == 17, (
f"Expected 17 categories, found {len(coding_categories)}: "
f"{[list(c.keys())[0] for c in coding_categories]}"
)
def test_all_expected_keys_present(coding_categories):
"""Every expected category key must appear exactly once."""
actual_keys = [list(cat.keys())[0] for cat in coding_categories]
for key in EXPECTED_KEYS:
assert key in actual_keys, f"Missing expected category key: '{key}'"
def test_no_duplicate_keys(coding_categories):
"""No category key may appear more than once."""
actual_keys = [list(cat.keys())[0] for cat in coding_categories]
seen = set()
duplicates = []
for key in actual_keys:
if key in seen:
duplicates.append(key)
seen.add(key)
assert not duplicates, f"Duplicate category keys found: {duplicates}"
def test_each_description_is_non_empty_string(coding_categories):
"""Every category must have a non-empty string description."""
for cat in coding_categories:
assert len(cat) == 1, f"Category dict should have exactly one key, got: {cat}"
key = list(cat.keys())[0]
description = cat[key]
assert isinstance(description, str), (
f"Category '{key}' description is not a string: {type(description)}"
)
assert description.strip(), f"Category '{key}' has an empty description"
@@ -0,0 +1,355 @@
"""Tests for import_competing_tools.py — competing tool file importers."""
from __future__ import annotations
import json
import os
import sys
from unittest import mock
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
# ---------------------------------------------------------------------------
# split_sections tests (unit tests on the splitter functions)
# ---------------------------------------------------------------------------
def test_split_by_headers_cursorrules():
"""split_by_headers correctly splits .cursorrules content on ## headers."""
from _chunking import split_by_headers
content = """\
# My Cursor Rules
Some preamble text that belongs to the first section.
## TypeScript Conventions
Always use strict mode. Prefer const over let.
Never use var.
## React Patterns
Use functional components with hooks.
Avoid class components.
## Testing
Write tests for all utility functions.
"""
chunks = split_by_headers(content, "## ")
assert len(chunks) == 4 # preamble + 3 sections
# First chunk is the preamble (before any ## header)
assert "preamble text" in chunks[0]
# Remaining chunks start with their header
assert chunks[1].startswith("## TypeScript Conventions")
assert "strict mode" in chunks[1]
assert chunks[2].startswith("## React Patterns")
assert "functional components" in chunks[2]
assert chunks[3].startswith("## Testing")
assert "utility functions" in chunks[3]
def test_split_by_headers_copilot():
"""split_by_headers correctly splits copilot-instructions.md on ## headers."""
from _chunking import split_by_headers
content = """\
## Code Style
Use 2-space indentation. Always add trailing commas.
## Architecture
Follow clean architecture principles. Keep business logic in domain layer.
"""
chunks = split_by_headers(content, "## ")
assert len(chunks) == 2
assert chunks[0].startswith("## Code Style")
assert "2-space indentation" in chunks[0]
assert chunks[1].startswith("## Architecture")
assert "clean architecture" in chunks[1]
def test_split_by_headers_no_headers():
"""split_by_headers returns entire content as one chunk if no headers found."""
from _chunking import split_by_headers
content = "This file has no headers at all. Just plain text."
chunks = split_by_headers(content, "## ")
assert len(chunks) == 1
assert "Just plain text" in chunks[0]
def test_split_cline_multiple_md_files(tmp_path):
"""cmd_cline processes multiple .md files from memory-bank/ directory."""
from _chunking import filter_and_truncate
# Create a temporary memory-bank directory with .md files
mb_dir = tmp_path / "memory-bank"
mb_dir.mkdir()
(mb_dir / "architecture.md").write_text(
"# Architecture Decisions\n\nUse microservices architecture with event sourcing."
)
(mb_dir / "conventions.md").write_text(
"# Code Conventions\n\nAll functions must have type hints. Use black formatter."
)
(mb_dir / "empty.md").write_text("") # empty file should be skipped
# Read and verify we can split the files
md_files = sorted(f for f in os.listdir(str(mb_dir)) if f.endswith(".md"))
assert "architecture.md" in md_files
assert "conventions.md" in md_files
assert "empty.md" in md_files
non_empty = []
for filename in md_files:
filepath = os.path.join(str(mb_dir), filename)
with open(filepath) as f:
content = f.read().strip()
if content:
chunks = filter_and_truncate([content])
non_empty.extend(chunks)
assert len(non_empty) == 2
assert any("microservices" in c for c in non_empty)
assert any("type hints" in c for c in non_empty)
def test_split_by_hr_or_headers_continue():
"""split_by_hr_or_headers correctly splits .continue/rules.md."""
from _chunking import split_by_hr_or_headers
content = """\
## First Section
Content of first section.
---
## Second Section
Content of second section.
---
Third section without a header (just after HR).
"""
chunks = split_by_hr_or_headers(content)
# Should split into meaningful chunks
assert len(chunks) >= 2
assert any("First Section" in c for c in chunks)
assert any("Second Section" in c for c in chunks)
def test_filter_and_truncate_skips_short():
"""filter_and_truncate skips chunks shorter than MIN_CHUNK_CHARS (50)."""
from _chunking import filter_and_truncate
chunks = [
"Short", # < 50 chars, should be filtered
"A" * 49, # exactly 49 chars, should be filtered
"A" * 50, # exactly 50 chars, should be kept
"A long enough chunk that definitely passes the minimum length filter.",
]
result = filter_and_truncate(chunks)
assert len(result) == 2
assert all(len(c) >= 50 for c in result)
def test_filter_and_truncate_truncates_long():
"""filter_and_truncate truncates chunks over MAX_CHUNK_CHARS (10000)."""
from _chunking import MAX_CHUNK_CHARS, filter_and_truncate
long_chunk = "X" * (MAX_CHUNK_CHARS + 500)
result = filter_and_truncate([long_chunk])
assert len(result) == 1
assert len(result[0]) == MAX_CHUNK_CHARS
# ---------------------------------------------------------------------------
# Mock API tests
# ---------------------------------------------------------------------------
def _make_mock_response(status: int = 201, body: dict | None = None) -> mock.MagicMock:
"""Create a mock HTTP response object."""
if body is None:
body = {"id": "new-mem-id", "memory": "test"}
resp = mock.MagicMock()
resp.status = status
resp.read.return_value = json.dumps(body).encode()
resp.__enter__ = lambda s: s
resp.__exit__ = mock.MagicMock(return_value=False)
return resp
def test_cursorrules_import_api_call(tmp_path):
"""cmd_cursorrules calls the API with correct app_id (top-level), infer=False, and correct source."""
from import_competing_tools import cmd_cursorrules
# Create a .cursorrules file with enough content
cursorrules = tmp_path / ".cursorrules"
cursorrules.write_text(
"## TypeScript Rules\n\nAlways use strict TypeScript. Never use 'any' type. "
"Prefer interfaces over type aliases for object shapes."
)
captured_requests: list[dict] = []
def mock_urlopen(req, timeout=None):
body = json.loads(req.data.decode())
captured_requests.append(body)
return _make_mock_response(201)
with mock.patch("import_competing_tools.resolve_api_key", return_value="m0-testkey"), \
mock.patch("import_competing_tools.resolve_user_id", return_value="testuser"), \
mock.patch("import_competing_tools.resolve_project_id", return_value="my-project"), \
mock.patch("import_competing_tools.resolve_branch", return_value="main"), \
mock.patch("urllib.request.urlopen", side_effect=mock_urlopen):
original_cwd = os.getcwd()
os.chdir(str(tmp_path))
try:
cmd_cursorrules(["--path", str(cursorrules)])
finally:
os.chdir(original_cwd)
assert len(captured_requests) >= 1
req_body = captured_requests[0]
# app_id must be top-level (not inside metadata)
assert req_body["app_id"] == "my-project", f"Expected app_id at top level, got: {req_body}"
# infer must be False (not "false", but the boolean False)
assert req_body["infer"] is False, f"Expected infer=False, got: {req_body['infer']}"
# source must be cursor-import
assert req_body["metadata"]["source"] == "cursor-import", (
f"Expected source=cursor-import, got: {req_body['metadata'].get('source')}"
)
# user_id must be set
assert req_body["user_id"] == "testuser"
# messages must be a list with role/content
assert isinstance(req_body["messages"], list)
assert req_body["messages"][0]["role"] == "user"
assert len(req_body["messages"][0]["content"]) > 0
def test_copilot_import_api_call(tmp_path):
"""cmd_copilot calls the API with source=copilot-import."""
from import_competing_tools import cmd_copilot
copilot_dir = tmp_path / ".github"
copilot_dir.mkdir()
copilot_file = copilot_dir / "copilot-instructions.md"
copilot_file.write_text(
"## Code Style\n\nUse 2-space indentation. Always add trailing commas in multi-line structures. "
"Prefer const over let. Never use var in JavaScript code."
)
captured: list[dict] = []
def mock_urlopen(req, timeout=None):
captured.append(json.loads(req.data.decode()))
return _make_mock_response(201)
with mock.patch("import_competing_tools.resolve_api_key", return_value="m0-key"), \
mock.patch("import_competing_tools.resolve_user_id", return_value="user1"), \
mock.patch("import_competing_tools.resolve_project_id", return_value="proj1"), \
mock.patch("import_competing_tools.resolve_branch", return_value="main"), \
mock.patch("urllib.request.urlopen", side_effect=mock_urlopen):
cmd_copilot(["--path", str(copilot_file)])
assert len(captured) >= 1
assert captured[0]["metadata"]["source"] == "copilot-import"
assert captured[0]["app_id"] == "proj1"
assert captured[0]["infer"] is False
def test_cline_import_multiple_files(tmp_path):
"""cmd_cline imports one memory per non-empty .md file."""
from import_competing_tools import cmd_cline
mb = tmp_path / "memory-bank"
mb.mkdir()
(mb / "arch.md").write_text(
"Architecture: microservices with event-sourcing. Each service owns its database. "
"Communication via message bus only. No direct service-to-service HTTP calls."
)
(mb / "style.md").write_text(
"Code style: PEP 8 for Python. Black formatter. isort for imports. "
"Line length 120. Type hints required on all public functions and methods."
)
(mb / "empty.md").write_text("")
captured: list[dict] = []
def mock_urlopen(req, timeout=None):
captured.append(json.loads(req.data.decode()))
return _make_mock_response(201)
with mock.patch("import_competing_tools.resolve_api_key", return_value="m0-key"), \
mock.patch("import_competing_tools.resolve_user_id", return_value="user1"), \
mock.patch("import_competing_tools.resolve_project_id", return_value="proj1"), \
mock.patch("import_competing_tools.resolve_branch", return_value="main"), \
mock.patch("urllib.request.urlopen", side_effect=mock_urlopen):
cmd_cline(["--path", str(mb)])
# Should have exactly 2 imports (empty.md skipped)
assert len(captured) == 2
sources = {r["metadata"]["source"] for r in captured}
assert sources == {"cline-import"}
for r in captured:
assert r["infer"] is False
assert r["app_id"] == "proj1"
def test_no_api_key_does_not_call_api(tmp_path):
"""When no API key is set, no HTTP call is made."""
from import_competing_tools import cmd_cursorrules
cursorrules = tmp_path / ".cursorrules"
cursorrules.write_text("## Rules\n\n" + "x" * 100)
with mock.patch("import_competing_tools.resolve_api_key", return_value=""), \
mock.patch("urllib.request.urlopen") as mock_url:
cmd_cursorrules(["--path", str(cursorrules)])
mock_url.assert_not_called()
def test_missing_file_does_not_call_api(tmp_path):
"""When the source file doesn't exist, no HTTP call is made."""
from import_competing_tools import cmd_cursorrules
with mock.patch("import_competing_tools.resolve_api_key", return_value="m0-key"), \
mock.patch("urllib.request.urlopen") as mock_url:
cmd_cursorrules(["--path", str(tmp_path / "nonexistent.cursorrules")])
mock_url.assert_not_called()
def test_main_unknown_subcommand_exits_zero():
"""Calling main() with an unknown subcommand exits 0."""
import subprocess
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "import_competing_tools.py"), "unknown"],
capture_output=True,
text=True,
env={**os.environ, "MEM0_API_KEY": ""},
)
assert result.returncode == 0
@@ -0,0 +1,298 @@
"""Tests for parse_export_file.py — mem0 export file parser."""
from __future__ import annotations
import json
import os
import subprocess
import sys
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
# ---------------------------------------------------------------------------
# Direct function tests
# ---------------------------------------------------------------------------
def test_parse_blocks_single_valid_block():
"""parse_blocks returns one record for a single valid block."""
from parse_export_file import parse_blocks
content = """\
---
id: abc123
created_at: 2024-01-15T10:00:00Z
type: task_learnings
confidence: 0.85
branch: main
files: src/foo.py, src/bar.py
categories: coding_conventions, task_learnings
---
Always use context managers when opening files.
"""
records = parse_blocks(content)
assert len(records) == 1
r = records[0]
assert r["id"] == "abc123"
assert r["type"] == "task_learnings"
assert r["confidence"] == "0.85"
assert r["branch"] == "main"
assert r["files"] == ["src/foo.py", "src/bar.py"]
assert r["categories"] == ["coding_conventions", "task_learnings"]
assert "Always use context managers" in r["content"]
def test_parse_blocks_multiple_blocks():
"""parse_blocks returns the correct number of records for multiple blocks."""
from parse_export_file import parse_blocks
content = """\
---
id: mem001
type: architecture_decisions
confidence: 0.9
branch: main
files:
categories: architecture_decisions
---
Use hexagonal architecture for the core domain.
---
id: mem002
type: anti_patterns
confidence: 0.75
branch: feat/refactor
files: src/legacy.py
categories: anti_patterns
---
Avoid direct database calls from view layer.
---
id: mem003
type: coding_conventions
confidence: 0.8
branch: main
files: src/utils.py, src/helpers.py
categories:
---
Use snake_case for all Python identifiers.
"""
records = parse_blocks(content)
assert len(records) == 3
assert records[0]["id"] == "mem001"
assert records[0]["categories"] == ["architecture_decisions"]
assert "hexagonal architecture" in records[0]["content"]
assert records[1]["id"] == "mem002"
assert records[1]["files"] == ["src/legacy.py"]
assert "direct database calls" in records[1]["content"]
assert records[2]["id"] == "mem003"
assert records[2]["files"] == ["src/utils.py", "src/helpers.py"]
assert records[2]["categories"] == []
assert "snake_case" in records[2]["content"]
def test_parse_blocks_missing_optional_fields():
"""parse_blocks uses defaults when optional fields are absent."""
from parse_export_file import parse_blocks
# confidence, branch, files, categories all absent
content = """\
---
id: xyz789
type: task_learnings
---
Run tests before committing.
"""
records = parse_blocks(content)
assert len(records) == 1
r = records[0]
assert r["id"] == "xyz789"
assert r["confidence"] == "" # default empty string
assert r["branch"] == "" # default empty string
assert r["files"] == [] # default empty list
assert r["categories"] == [] # default empty list
assert "Run tests" in r["content"]
def test_parse_blocks_filters_empty_content():
"""parse_blocks skips blocks whose content is empty or whitespace-only."""
from parse_export_file import parse_blocks
content = """\
---
id: empty1
type: task_learnings
---
---
id: real1
type: task_learnings
---
This block has real content.
---
id: empty2
type: coding_conventions
---
"""
records = parse_blocks(content)
# Only the block with actual content should be returned
assert len(records) == 1
assert records[0]["id"] == "real1"
assert "real content" in records[0]["content"]
def test_parse_blocks_round_trip():
"""Content formatted by export matches what parse_blocks expects."""
from parse_export_file import parse_blocks
# Simulate the exact format produced by the export skill
memory_id = "test-id-001"
created_at = "2024-06-01T12:00:00Z"
mem_type = "architecture_decisions"
confidence = "0.92"
branch = "feat/new-feature"
files = ["src/main.py", "tests/test_main.py"]
categories = ["architecture_decisions", "coding_conventions"]
memory_content = "Use dependency injection for all service classes."
# Format exactly as the export skill would
block = (
"---\n"
f"id: {memory_id}\n"
f"created_at: {created_at}\n"
f"type: {mem_type}\n"
f"confidence: {confidence}\n"
f"branch: {branch}\n"
f"files: {', '.join(files)}\n"
f"categories: {', '.join(categories)}\n"
"---\n"
f"{memory_content}\n"
"\n"
)
records = parse_blocks(block)
assert len(records) == 1
r = records[0]
assert r["id"] == memory_id
assert r["type"] == mem_type
assert r["confidence"] == confidence
assert r["branch"] == branch
assert r["files"] == files
assert r["categories"] == categories
assert r["content"] == memory_content
def test_parse_blocks_multiline_content():
"""parse_blocks correctly captures multi-line memory content."""
from parse_export_file import parse_blocks
content = """\
---
id: multi001
type: task_learnings
---
Line one of the memory.
Line two of the memory.
Line four after blank line.
"""
records = parse_blocks(content)
assert len(records) == 1
assert "Line one" in records[0]["content"]
assert "Line two" in records[0]["content"]
assert "Line four" in records[0]["content"]
def test_parse_blocks_empty_input():
"""parse_blocks returns empty list for empty input."""
from parse_export_file import parse_blocks
assert parse_blocks("") == []
assert parse_blocks(" \n ") == []
def test_parse_blocks_no_blocks():
"""parse_blocks returns empty list for content without any --- delimiters."""
from parse_export_file import parse_blocks
assert parse_blocks("Just some text without any delimiters.") == []
def test_parse_blocks_value_with_colon():
"""parse_blocks handles values that themselves contain colons."""
from parse_export_file import parse_blocks
content = """\
---
id: colon-test
type: task_learnings
created_at: 2024-01-01T10:00:00Z
---
Timestamp values contain colons and should parse correctly.
"""
records = parse_blocks(content)
assert len(records) == 1
assert records[0]["id"] == "colon-test"
# created_at field should be captured (it's in the record if present)
assert "2024-01-01T10:00:00Z" in records[0].get("created_at", "")
# ---------------------------------------------------------------------------
# CLI / subprocess tests
# ---------------------------------------------------------------------------
def test_main_cli_outputs_json(tmp_path):
"""Running parse_export_file.py as a script outputs valid JSON."""
export_file = tmp_path / "mem0-export-test.md"
export_file.write_text("""\
---
id: cli-test-001
type: task_learnings
confidence: 0.8
branch: main
files:
categories: task_learnings
---
Prefer composition over inheritance.
""")
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_export_file.py"), str(export_file)],
capture_output=True,
text=True,
)
assert result.returncode == 0
records = json.loads(result.stdout)
assert isinstance(records, list)
assert len(records) == 1
assert records[0]["id"] == "cli-test-001"
def test_main_cli_no_args_exits_zero():
"""Running parse_export_file.py with no arguments exits 0 and prints []."""
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_export_file.py")],
capture_output=True,
text=True,
)
assert result.returncode == 0
assert result.stdout.strip() == "[]"
def test_main_cli_missing_file_exits_zero(tmp_path):
"""Running parse_export_file.py with a missing file exits 0 and prints []."""
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_export_file.py"),
str(tmp_path / "nonexistent.md")],
capture_output=True,
text=True,
)
assert result.returncode == 0
assert result.stdout.strip() == "[]"
@@ -0,0 +1,439 @@
"""Tests for parse_mem0_config.py — mem0.md retention policy parser."""
from __future__ import annotations
import json
import os
import subprocess
import sys
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
# ---------------------------------------------------------------------------
# parse_retention — unit tests
# ---------------------------------------------------------------------------
def test_parse_retention_valid_section():
"""parse_retention extracts day-count policies correctly."""
from parse_mem0_config import parse_retention
content = """\
# Project Config
## Retention
session_state: 90d
compact_summary: 60d
decision: 180d
"""
result = parse_retention(content)
assert result == {
"session_state": 90,
"compact_summary": 60,
"decision": 180,
}
def test_parse_retention_forever_returns_none():
"""parse_retention maps 'forever' to None."""
from parse_mem0_config import parse_retention
content = """\
## Retention
user_preference: forever
anti_pattern: forever
session_state: 30d
"""
result = parse_retention(content)
assert result["user_preference"] is None
assert result["anti_pattern"] is None
assert result["session_state"] == 30
def test_parse_retention_no_section_returns_empty():
"""parse_retention returns {} when there is no ## Retention heading."""
from parse_mem0_config import parse_retention
content = """\
# Project Config
## Some Other Section
key: value
"""
result = parse_retention(content)
assert result == {}
def test_parse_retention_stops_at_next_heading():
"""parse_retention stops reading at the next ## heading."""
from parse_mem0_config import parse_retention
content = """\
## Retention
session_state: 7d
## Other Section
other_key: 999d
"""
result = parse_retention(content)
assert "session_state" in result
assert "other_key" not in result
def test_parse_retention_malformed_lines_skipped():
"""Malformed lines (no colon, bad day format) are silently ignored."""
from parse_mem0_config import parse_retention
content = """\
## Retention
session_state: 90d
bad_line_no_colon
another: badvalue
decision: 30d
"""
result = parse_retention(content)
assert result == {"session_state": 90, "decision": 30}
def test_parse_retention_comments_ignored():
"""Inline # comments are stripped before parsing."""
from parse_mem0_config import parse_retention
content = """\
## Retention
session_state: 90d # rolling 90-day window
user_preference: forever # never prune preferences
"""
result = parse_retention(content)
assert result["session_state"] == 90
assert result["user_preference"] is None
def test_parse_retention_case_insensitive_heading():
"""## retention (lowercase) is matched the same as ## Retention."""
from parse_mem0_config import parse_retention
content = """\
## retention
session_state: 14d
"""
result = parse_retention(content)
assert result == {"session_state": 14}
def test_parse_retention_empty_section_returns_empty():
"""A ## Retention section with no valid lines returns {}."""
from parse_mem0_config import parse_retention
content = """\
## Retention
# only comments here
## Next Section
"""
result = parse_retention(content)
assert result == {}
# ---------------------------------------------------------------------------
# load_retention_policies — integration tests with tmp files
# ---------------------------------------------------------------------------
def test_load_retention_policies_with_tmp_file(tmp_path):
"""load_retention_policies reads a real mem0.md from disk."""
from parse_mem0_config import load_retention_policies
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text(
"""\
# My Project
## Retention
session_state: 90d
compact_summary: 60d
decision: forever
""",
encoding="utf-8",
)
result = load_retention_policies(str(tmp_path))
assert result == {
"session_state": 90,
"compact_summary": 60,
"decision": None,
}
def test_load_retention_policies_no_mem0_md_returns_empty(tmp_path):
"""load_retention_policies returns {} when no mem0.md exists."""
from parse_mem0_config import load_retention_policies
result = load_retention_policies(str(tmp_path))
assert result == {}
def test_load_retention_policies_no_retention_section_returns_empty(tmp_path):
"""load_retention_policies returns {} when mem0.md has no ## Retention."""
from parse_mem0_config import load_retention_policies
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text(
"""\
# My Project
Some general project notes here.
No retention section.
""",
encoding="utf-8",
)
result = load_retention_policies(str(tmp_path))
assert result == {}
def test_load_retention_policies_defaults_to_cwd(tmp_path, monkeypatch):
"""load_retention_policies uses os.getcwd() when cwd is None."""
from parse_mem0_config import load_retention_policies
monkeypatch.chdir(tmp_path)
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text("## Retention\nsession_state: 45d\n", encoding="utf-8")
result = load_retention_policies() # no cwd arg
assert result == {"session_state": 45}
# ---------------------------------------------------------------------------
# CLI / main() — subprocess test
# ---------------------------------------------------------------------------
def test_cli_main_prints_json(tmp_path):
"""CLI: python parse_mem0_config.py <cwd> prints valid JSON."""
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text(
"## Retention\nsession_state: 90d\nuser_preference: forever\n",
encoding="utf-8",
)
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_mem0_config.py"), str(tmp_path)],
capture_output=True,
text=True,
)
assert result.returncode == 0
data = json.loads(result.stdout)
assert data["session_state"] == 90
assert data["user_preference"] is None
def test_cli_main_no_file_prints_empty_json(tmp_path):
"""CLI: prints '{}' when no mem0.md exists."""
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_mem0_config.py"), str(tmp_path)],
capture_output=True,
text=True,
)
assert result.returncode == 0
assert json.loads(result.stdout) == {}
# ---------------------------------------------------------------------------
# parse_section_kv — unit tests
# ---------------------------------------------------------------------------
def test_parse_section_kv_basic():
"""parse_section_kv extracts key-value pairs from a named section."""
from parse_mem0_config import parse_section_kv
content = """\
## Search
default_limit: 10
boost_recency: true
"""
result = parse_section_kv(content, "Search")
assert result == {"default_limit": "10", "boost_recency": "true"}
def test_parse_section_kv_missing_section():
"""parse_section_kv returns {} when section doesn't exist."""
from parse_mem0_config import parse_section_kv
result = parse_section_kv("## Other\nfoo: bar\n", "Search")
assert result == {}
def test_parse_section_kv_stops_at_next_heading():
"""parse_section_kv stops at the next ## heading."""
from parse_mem0_config import parse_section_kv
content = """\
## Identity
user_id: kartik
project_id: mem0
## Other
ignored: yes
"""
result = parse_section_kv(content, "Identity")
assert result == {"user_id": "kartik", "project_id": "mem0"}
assert "ignored" not in result
# ---------------------------------------------------------------------------
# parse_section_list — unit tests
# ---------------------------------------------------------------------------
def test_parse_section_list_basic():
"""parse_section_list extracts list items from a named section."""
from parse_mem0_config import parse_section_list
content = """\
## Categories
- architecture_decisions
- bug_fixes
- coding_conventions
"""
result = parse_section_list(content, "Categories")
assert result == ["architecture_decisions", "bug_fixes", "coding_conventions"]
def test_parse_section_list_bare_lines():
"""parse_section_list works with bare lines (no bullet prefix)."""
from parse_mem0_config import parse_section_list
content = """\
## Categories
architecture_decisions
bug_fixes
"""
result = parse_section_list(content, "Categories")
assert result == ["architecture_decisions", "bug_fixes"]
def test_parse_section_list_missing_section():
"""parse_section_list returns [] when section doesn't exist."""
from parse_mem0_config import parse_section_list
result = parse_section_list("## Other\n- foo\n", "Categories")
assert result == []
# ---------------------------------------------------------------------------
# load_full_config — integration tests
# ---------------------------------------------------------------------------
def test_load_full_config_all_sections(tmp_path):
"""load_full_config extracts all sections from mem0.md."""
from parse_mem0_config import load_full_config
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text(
"""\
# My Project
## Retention
session_state: 90d
decision: forever
## Search
default_limit: 20
boost_recency: true
## Categories
- architecture_decisions
- bug_fixes
- security_constraints
## Identity
user_id: kartik
project_id: my-project
""",
encoding="utf-8",
)
config = load_full_config(str(tmp_path))
assert config["retention"] == {"session_state": 90, "decision": None}
assert config["search"] == {"default_limit": "20", "boost_recency": "true"}
assert config["categories"] == ["architecture_decisions", "bug_fixes", "security_constraints"]
assert config["identity"] == {"user_id": "kartik", "project_id": "my-project"}
def test_load_full_config_partial_sections(tmp_path):
"""load_full_config only includes sections that exist."""
from parse_mem0_config import load_full_config
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text("## Retention\nsession_state: 30d\n", encoding="utf-8")
config = load_full_config(str(tmp_path))
assert "retention" in config
assert "search" not in config
assert "categories" not in config
assert "identity" not in config
def test_load_full_config_no_file(tmp_path):
"""load_full_config returns {} when no mem0.md exists."""
from parse_mem0_config import load_full_config
config = load_full_config(str(tmp_path))
assert config == {}
def test_cli_full_flag(tmp_path):
"""CLI: --full prints all sections as JSON."""
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text(
"## Retention\nsession_state: 90d\n\n## Search\nlimit: 10\n",
encoding="utf-8",
)
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_mem0_config.py"), "--full", str(tmp_path)],
capture_output=True,
text=True,
)
assert result.returncode == 0
data = json.loads(result.stdout)
assert "retention" in data
assert "search" in data
def test_load_full_config_settings_section(tmp_path):
"""Settings section is parsed by load_full_config."""
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text("""\
## Settings
commit_prompts: true
subagent_skip: Explore, Plan, code-reviewer
""")
sys.path.insert(0, SCRIPTS_DIR)
from parse_mem0_config import load_full_config
config = load_full_config(str(tmp_path))
assert config.get("settings", {}).get("commit_prompts") == "true"
assert config.get("settings", {}).get("subagent_skip") == "Explore, Plan, code-reviewer"
@@ -0,0 +1,171 @@
"""Tests for _project.py — project_id + branch resolver."""
from __future__ import annotations
import json
import os
import subprocess
def test_resolve_project_id_from_https_remote(tmp_git_repo):
from _project import resolve_project_id
pid = resolve_project_id(str(tmp_git_repo))
assert pid == "mem0ai-mem0"
def test_resolve_project_id_from_ssh_remote(tmp_git_repo_ssh):
from _project import resolve_project_id
pid = resolve_project_id(str(tmp_git_repo_ssh))
assert pid == "acme-cool-project"
def test_resolve_project_id_fallback_basename(tmp_no_git):
from _project import resolve_project_id
pid = resolve_project_id(str(tmp_no_git))
assert pid == os.path.basename(str(tmp_no_git))
def test_resolve_project_id_from_env(tmp_no_git, monkeypatch):
from _project import resolve_project_id
monkeypatch.setenv("MEM0_PROJECT_ID", "my-override")
pid = resolve_project_id(str(tmp_no_git))
assert pid == "my-override"
def test_resolve_project_id_from_project_map(tmp_no_git):
from _project import resolve_project_id, save_project_mapping
save_project_mapping(str(tmp_no_git), "custom-project")
pid = resolve_project_id(str(tmp_no_git))
assert pid == "custom-project"
def test_save_project_mapping_creates_file(tmp_no_git):
from _project import save_project_mapping
save_project_mapping(str(tmp_no_git), "test-proj")
map_path = os.path.expanduser("~/.mem0/project_map.json")
assert os.path.isfile(map_path)
with open(map_path) as f:
data = json.load(f)
assert data[str(tmp_no_git)] == "test-proj"
def test_resolve_branch_in_git_repo(tmp_git_repo):
from _project import resolve_branch
subprocess.run(
["git", "checkout", "-b", "feat/test-branch"],
cwd=tmp_git_repo,
capture_output=True,
check=True,
)
branch = resolve_branch(str(tmp_git_repo))
assert branch == "feat/test-branch"
def test_resolve_branch_no_git(tmp_no_git):
from _project import resolve_branch
branch = resolve_branch(str(tmp_no_git))
assert branch == "unknown"
def test_remote_url_to_slug_various_formats():
from _project import _remote_url_to_slug
assert _remote_url_to_slug("https://github.com/mem0ai/mem0.git") == "mem0ai-mem0"
assert _remote_url_to_slug("git@github.com:mem0ai/mem0.git") == "mem0ai-mem0"
assert _remote_url_to_slug("ssh://git@github.com/acme/app.git") == "acme-app"
assert _remote_url_to_slug("https://gitlab.com/org/sub/repo.git") == "sub-repo"
assert _remote_url_to_slug("git@bitbucket.org:team/project.git") == "team-project"
def test_remote_url_to_slug_no_git_suffix():
from _project import _remote_url_to_slug
assert _remote_url_to_slug("https://github.com/foo/bar") == "foo-bar"
def test_resolve_project_id_priority_order(tmp_git_repo, monkeypatch):
"""Env var > project_map > git remote > basename."""
from _project import resolve_project_id, save_project_mapping
# Git remote gives "mem0ai-mem0"
assert resolve_project_id(str(tmp_git_repo)) == "mem0ai-mem0"
# project_map overrides git remote
save_project_mapping(str(tmp_git_repo), "from-map")
assert resolve_project_id(str(tmp_git_repo)) == "from-map"
# Env var overrides everything
monkeypatch.setenv("MEM0_PROJECT_ID", "from-env")
assert resolve_project_id(str(tmp_git_repo)) == "from-env"
def test_remote_hash_key_format(tmp_git_repo):
"""_remote_hash_key() returns 'remote:<16-char-hex>' for a repo with a remote."""
import re
from _project import _remote_hash_key
key = _remote_hash_key(str(tmp_git_repo))
assert re.fullmatch(r"remote:[0-9a-f]{16}", key), (
f"Expected 'remote:<16-char-hex>', got {key!r}"
)
def test_remote_hash_key_no_git(tmp_no_git):
"""_remote_hash_key() returns empty string when not in a git repo."""
from _project import _remote_hash_key
key = _remote_hash_key(str(tmp_no_git))
assert key == ""
def test_save_project_mapping_writes_remote_key(tmp_git_repo):
"""save_project_mapping() writes both the CWD key and the remote hash key."""
import re
from _project import save_project_mapping
save_project_mapping(str(tmp_git_repo), "my-project")
map_path = os.path.expanduser("~/.mem0/project_map.json")
with open(map_path) as f:
data = json.load(f)
assert data[str(tmp_git_repo)] == "my-project"
remote_keys = [k for k in data if re.fullmatch(r"remote:[0-9a-f]{16}", k)]
assert remote_keys, "Expected at least one remote:<hash> key in project_map.json"
assert data[remote_keys[0]] == "my-project"
def test_resolve_project_id_remote_hash_fallback(tmp_git_repo, tmp_path):
"""Moving the project folder: remote hash key is used as fallback."""
from _project import resolve_project_id, save_project_mapping
# Save mapping for original location
save_project_mapping(str(tmp_git_repo), "stable-project")
# Simulate folder move: resolve using a different CWD path that shares the same remote.
# We use a second tmp_git_repo with the same remote URL to mimic a renamed directory.
import subprocess as _sp
new_repo = tmp_path / "moved_repo"
new_repo.mkdir()
_sp.run(["git", "init"], cwd=new_repo, capture_output=True, check=True)
_sp.run(
["git", "remote", "add", "origin", "https://github.com/mem0ai/mem0.git"],
cwd=new_repo,
capture_output=True,
check=True,
)
# The new CWD is NOT in project_map, but remote hash should match
pid = resolve_project_id(str(new_repo))
assert pid == "stable-project", (
f"Expected 'stable-project' via remote hash fallback, got {pid!r}"
)
@@ -0,0 +1,60 @@
"""Tests for rubric deduplication in on_user_prompt.sh."""
from __future__ import annotations
import json
import os
import subprocess
import pytest
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 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:
"""Run on_user_prompt.sh with a simulated prompt and return stdout."""
env = {
**os.environ,
"USER": "testuser",
"MEM0_API_KEY": "test-key-123",
"MEM0_RESOLVED_USER_ID": "testuser",
"MEM0_PROJECT_ID": "test-project",
"MEM0_BRANCH": "main",
}
if env_overrides:
env.update(env_overrides)
input_json = json.dumps({"prompt": prompt, "session_id": session_id})
result = subprocess.run(
["bash", os.path.join(SCRIPTS_DIR, "on_user_prompt.sh")],
input=input_json,
capture_output=True,
text=True,
env=env,
timeout=10,
)
return result.stdout
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 "Mem0 searches apply" in output
assert "metadata.type" in output
def test_second_prompt_gets_no_rubric():
"""Second prompt of session emits nothing — rubric and tips only on first prompt."""
_run_hook("How should we refactor the auth module?")
output = _run_hook("What about the database layer?")
assert output.strip() == ""
@@ -0,0 +1,116 @@
"""Tests for _search.py — shared mem0 search API helper."""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
def test_search_memories_returns_results():
from _search import search_memories
fake_results = [
{"id": "abc123", "memory": "Use Postgres for auth", "metadata": {"type": "decision"}},
{"id": "def456", "memory": "Never use floats for money", "metadata": {"type": "anti_pattern"}},
]
def mock_urlopen(req, timeout=None):
resp = MagicMock()
resp.read.return_value = json.dumps({"results": fake_results}).encode()
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
results = search_memories("test-key", "user1", "proj1", "auth decisions")
assert len(results) == 2
assert results[0]["id"] == "abc123"
def test_search_memories_with_metadata_type():
from _search import search_memories
captured_body = {}
def mock_urlopen(req, timeout=None):
captured_body.update(json.loads(req.data.decode()))
resp = MagicMock()
resp.read.return_value = json.dumps({"results": []}).encode()
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
search_memories("key", "user", "proj", "query", metadata_type="decision")
filters = captured_body["filters"]
assert {"metadata": {"type": "decision"}} in filters["AND"]
def test_search_memories_handles_api_error():
from _search import search_memories
with patch("urllib.request.urlopen", side_effect=Exception("timeout")):
results = search_memories("key", "user", "proj", "query")
assert results == []
def test_search_memories_handles_list_response():
from _search import search_memories
fake_results = [{"id": "abc", "memory": "test"}]
def mock_urlopen(req, timeout=None):
resp = MagicMock()
resp.read.return_value = json.dumps(fake_results).encode()
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
results = search_memories("key", "user", "proj", "query")
assert len(results) == 1
def test_search_memories_respects_top_k():
from _search import search_memories
captured_body = {}
def mock_urlopen(req, timeout=None):
captured_body.update(json.loads(req.data.decode()))
resp = MagicMock()
resp.read.return_value = json.dumps({"results": []}).encode()
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
search_memories("key", "user", "proj", "query", top_k=5)
assert captured_body["top_k"] == 5
def test_search_memories_no_api_key_returns_empty():
from _search import search_memories
results = search_memories("", "user", "proj", "query")
assert results == []
def test_format_results_for_context():
from _search import format_results_for_context
memories = [
{"id": "abc12345-long-id", "memory": "Use Postgres for auth", "metadata": {"type": "decision"}},
{"id": "def67890-long-id", "memory": "JWT tokens expire in 1h", "metadata": {"type": "convention"}},
]
output = format_results_for_context(memories, heading="Relevant memories")
assert "Relevant memories" in output
assert "[decision]" in output
assert "Use Postgres for auth" in output
assert "abc12345" in output
@@ -0,0 +1,248 @@
"""Tests for session_stats.py — session-level memory operation tracker."""
from __future__ import annotations
import json
import os
import subprocess
import sys
import pytest
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
@pytest.fixture(autouse=True)
def _isolate_stats_file(tmp_path, monkeypatch):
"""Point STATS_FILE to a temp location so tests don't interfere."""
stats_file = str(tmp_path / "test_stats.json")
monkeypatch.setattr("session_stats.STATS_FILE", stats_file)
yield stats_file
def test_init_creates_file(_isolate_stats_file):
import session_stats
session_stats.init()
assert os.path.isfile(_isolate_stats_file)
with open(_isolate_stats_file) as f:
data = json.load(f)
assert data["adds"] == 0
assert data["searches"] == 0
assert data["categories"] == []
def test_record_add_increments(_isolate_stats_file):
import session_stats
session_stats.init()
session_stats.record_add("bug_fixes")
session_stats.record_add("bug_fixes")
session_stats.record_add("decisions")
with open(_isolate_stats_file) as f:
data = json.load(f)
assert data["adds"] == 3
assert set(data["categories"]) == {"bug_fixes", "decisions"}
def test_record_search_increments(_isolate_stats_file):
import session_stats
session_stats.init()
session_stats.record_search()
session_stats.record_search()
with open(_isolate_stats_file) as f:
data = json.load(f)
assert data["searches"] == 2
def test_report_returns_summary(_isolate_stats_file):
import session_stats
session_stats.init()
session_stats.record_add("architecture_decisions")
session_stats.record_add("task_learnings")
session_stats.record_search()
session_stats.record_search()
session_stats.record_search()
result = session_stats.report()
assert "wrote 2 memories" in result
assert "retrieved 3" in result
assert "architecture_decisions" in result
assert "task_learnings" in result
def test_report_empty_session(_isolate_stats_file):
import session_stats
session_stats.init()
result = session_stats.report()
assert result == ""
def test_report_preserves_file(_isolate_stats_file):
import session_stats
session_stats.init()
session_stats.record_add()
session_stats.report()
assert os.path.isfile(_isolate_stats_file)
def test_record_add_no_category(_isolate_stats_file):
import session_stats
session_stats.init()
session_stats.record_add("")
session_stats.record_add()
with open(_isolate_stats_file) as f:
data = json.load(f)
assert data["adds"] == 2
assert data["categories"] == []
def test_duplicate_categories_not_added(_isolate_stats_file):
import session_stats
session_stats.init()
session_stats.record_add("bug_fixes")
session_stats.record_add("bug_fixes")
session_stats.record_add("bug_fixes")
with open(_isolate_stats_file) as f:
data = json.load(f)
assert data["adds"] == 3
assert data["categories"] == ["bug_fixes"]
def test_cli_init(tmp_path):
"""Test CLI invocation: session_stats.py init."""
env = {**os.environ, "USER": "test"}
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "session_stats.py"), "init"],
capture_output=True,
text=True,
env=env,
)
assert result.returncode == 0
def test_cli_report_no_data(tmp_path):
"""Test CLI invocation: report with no prior init prints fallback."""
env = {**os.environ, "USER": f"test_{os.getpid()}"}
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "session_stats.py"), "report"],
capture_output=True,
text=True,
env=env,
)
assert result.returncode == 0
def test_peek_returns_json_without_clearing(_isolate_stats_file):
"""peek returns JSON stats without deleting the stats file."""
import session_stats
session_stats.init()
session_stats.record_add("decisions")
session_stats.record_add("decisions")
session_stats.record_search()
result = session_stats.peek()
data = json.loads(result)
assert data["adds"] == 2
assert data["searches"] == 1
assert os.path.isfile(_isolate_stats_file)
def test_category_counts_tracked(_isolate_stats_file):
"""category_counts tracks per-category add counts."""
import session_stats
session_stats.init()
session_stats.record_add("bug_fixes")
session_stats.record_add("bug_fixes")
session_stats.record_add("bug_fixes")
session_stats.record_add("decisions")
with open(_isolate_stats_file) as f:
data = json.load(f)
assert data["category_counts"]["bug_fixes"] == 3
assert data["category_counts"]["decisions"] == 1
def test_category_counts_empty_category_not_tracked(_isolate_stats_file):
"""Empty category string doesn't appear in category_counts."""
import session_stats
session_stats.init()
session_stats.record_add("")
session_stats.record_add()
with open(_isolate_stats_file) as f:
data = json.load(f)
assert data["category_counts"] == {}
def test_recent_ids_tracked(_isolate_stats_file):
"""record_add with memory_id stores ID in recent_ids."""
import session_stats
session_stats.init()
session_stats.record_add("decision", "abc-123")
session_stats.record_add("convention", "def-456")
with open(_isolate_stats_file) as f:
data = json.load(f)
assert len(data["recent_ids"]) == 2
assert data["recent_ids"][0]["id"] == "abc-123"
assert data["recent_ids"][1]["id"] == "def-456"
assert data["recent_ids"][0]["category"] == "decision"
def test_recent_ids_capped(_isolate_stats_file):
"""recent_ids list is capped at MAX_RECENT_IDS."""
import session_stats
session_stats.init()
for i in range(60):
session_stats.record_add("test", f"id-{i}")
with open(_isolate_stats_file) as f:
data = json.load(f)
assert len(data["recent_ids"]) == session_stats.MAX_RECENT_IDS
assert data["recent_ids"][0]["id"] == f"id-{60 - session_stats.MAX_RECENT_IDS}"
def test_recent_ids_empty_without_memory_id(_isolate_stats_file):
"""record_add without memory_id doesn't add to recent_ids."""
import session_stats
session_stats.init()
session_stats.record_add("decision")
session_stats.record_add("convention", "")
with open(_isolate_stats_file) as f:
data = json.load(f)
assert data["recent_ids"] == []
def test_cli_peek(tmp_path):
"""Test CLI invocation: session_stats.py peek outputs JSON."""
env = {**os.environ, "USER": "test"}
subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "session_stats.py"), "init"],
capture_output=True, text=True, env=env,
)
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "session_stats.py"), "peek"],
capture_output=True, text=True, env=env,
)
assert result.returncode == 0
data = json.loads(result.stdout)
assert "adds" in data
@@ -0,0 +1,177 @@
"""Tests for telemetry.py — fire-and-forget PostHog plugin telemetry."""
from __future__ import annotations
import json
import os
import sys
import urllib.error
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
sys.path.insert(0, os.path.abspath(SCRIPTS_DIR))
def test_import_succeeds():
import telemetry
assert hasattr(telemetry, "emit")
assert hasattr(telemetry, "main")
def test_opt_out_skips_send(monkeypatch):
import telemetry
monkeypatch.setenv("MEM0_TELEMETRY", "false")
sent = []
monkeypatch.setattr(telemetry, "send", lambda p: sent.append(p))
telemetry.emit("session_start")
assert sent == []
def test_opt_out_variants(monkeypatch):
import telemetry
for val in ("0", "no", "off", "FALSE", "No"):
monkeypatch.setenv("MEM0_TELEMETRY", val)
assert not telemetry.is_enabled()
def test_enabled_by_default(monkeypatch):
import telemetry
monkeypatch.delenv("MEM0_TELEMETRY", raising=False)
assert telemetry.is_enabled()
def test_posthog_payload_structure(monkeypatch):
import telemetry
monkeypatch.setenv("MEM0_RESOLVED_USER_ID", "testuser")
monkeypatch.setenv("MEM0_PROJECT_ID", "test-project")
monkeypatch.delenv("MEM0_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
payload = telemetry.build_posthog_payload("plugin.session_start", {"memory_count": 5})
assert payload["api_key"] == telemetry.POSTHOG_API_KEY
assert payload["event"] == "plugin.session_start"
assert "distinct_id" in payload
assert payload["properties"]["source"] == "plugin"
assert isinstance(payload["properties"]["plugin_version"], str)
assert payload["properties"]["plugin_version"] != ""
assert payload["properties"]["memory_count"] == 5
assert payload["properties"]["$process_person_profile"] is False
raw = json.dumps(payload)
assert "testuser" not in raw
assert "test-project" not in raw
def test_system_props_override_caller_props(monkeypatch):
"""H8: system properties must win over caller-supplied properties."""
import telemetry
monkeypatch.setenv("MEM0_RESOLVED_USER_ID", "testuser")
monkeypatch.setenv("MEM0_PROJECT_ID", "test-project")
monkeypatch.delenv("MEM0_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
# Caller tries to override system-controlled properties
caller_props = {
"source": "CALLER_OVERRIDE",
"platform": "CALLER_OVERRIDE",
"plugin_version": "CALLER_OVERRIDE",
"memory_count": 42,
}
payload = telemetry.build_posthog_payload("plugin.test", caller_props)
props = payload["properties"]
# System props must win
assert props["source"] == "plugin"
assert props["platform"] == telemetry.detect_platform()
assert props["plugin_version"] == telemetry.PLUGIN_VERSION
# Caller-only props still present
assert props["memory_count"] == 42
def test_distinct_id_from_api_key(monkeypatch):
import hashlib
import telemetry
monkeypatch.setenv("MEM0_API_KEY", "m0-testkey123")
expected = hashlib.sha256(b"m0-testkey123").hexdigest()[:32]
assert telemetry._distinct_id() == expected
def test_distinct_id_fallback_no_key(monkeypatch):
import telemetry
monkeypatch.delenv("MEM0_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
monkeypatch.setenv("MEM0_RESOLVED_USER_ID", "kartik")
assert telemetry._distinct_id() == telemetry._sha256("kartik")
def test_hash_deterministic():
import telemetry
h1 = telemetry._sha256("same-value")
h2 = telemetry._sha256("same-value")
assert h1 == h2
assert h1 != telemetry._sha256("different-value")
def test_platform_claude_code(monkeypatch):
import telemetry
monkeypatch.setenv("CLAUDECODE", "1")
monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False)
monkeypatch.delenv("CODEX_PLUGIN_ROOT", raising=False)
assert telemetry.detect_platform() == "claude-code"
def test_platform_cursor(monkeypatch):
import telemetry
monkeypatch.delenv("CLAUDECODE", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/path")
monkeypatch.delenv("CODEX_PLUGIN_ROOT", raising=False)
assert telemetry.detect_platform() == "cursor"
def test_platform_codex(monkeypatch):
import telemetry
monkeypatch.delenv("CLAUDECODE", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False)
monkeypatch.setenv("PLUGIN_ROOT", "/path")
assert telemetry.detect_platform() == "codex"
def test_send_fails_silently(monkeypatch):
import telemetry
def raise_error(req, timeout):
raise urllib.error.URLError("connection refused")
monkeypatch.setattr(telemetry.urllib.request, "urlopen", raise_error)
telemetry.send({"event": "test"})
def test_cli_exits_zero_when_disabled(monkeypatch):
import telemetry
monkeypatch.setenv("MEM0_TELEMETRY", "false")
monkeypatch.setattr(sys, "argv", ["telemetry.py", "session_start"])
assert telemetry.main() == 0
def test_cli_no_args_exits_nonzero(monkeypatch):
import telemetry
monkeypatch.delenv("MEM0_TELEMETRY", raising=False)
monkeypatch.setattr(sys, "argv", ["telemetry.py"])
assert telemetry.main() == 1
@@ -0,0 +1,207 @@
"""Tests for write-path app_id migration and API key resolution.
Verifies that all scripts writing to the Mem0 API:
1. Pass app_id as a top-level parameter (not in metadata)
2. Do NOT include project_id in metadata
3. Include branch in metadata when available
4. Use resolve_api_key() for key resolution with userConfig fallback
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
def test_auto_import_post_memory_uses_app_id():
"""auto_import.post_memory sends app_id top-level, not metadata.project_id."""
from auto_import import post_memory
captured = {}
def mock_urlopen(req, timeout=None):
body = json.loads(req.data.decode("utf-8"))
captured.update(body)
resp = MagicMock()
resp.status = 200
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
result = post_memory(
api_key="test-key",
content="test content",
user_id="testuser",
filename="CLAUDE.md",
project_id="my-project",
branch="main",
)
assert result is True
assert captured["app_id"] == "my-project"
assert captured["user_id"] == "testuser"
assert "project_id" not in captured.get("metadata", {})
assert captured["metadata"]["type"] == "project_profile"
assert captured["metadata"]["branch"] == "main"
assert captured["infer"] is False
def test_auto_import_post_memory_omits_empty_branch():
"""auto_import.post_memory skips branch in metadata when empty."""
from auto_import import post_memory
captured = {}
def mock_urlopen(req, timeout=None):
body = json.loads(req.data.decode("utf-8"))
captured.update(body)
resp = MagicMock()
resp.status = 200
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
post_memory("key", "content", "user", "FILE.md", "proj", branch="")
assert "branch" not in captured.get("metadata", {})
def test_on_pre_compact_store_memory_uses_app_id():
"""on_pre_compact.store_memory sends app_id top-level."""
from on_pre_compact import store_memory
captured = {}
def mock_urlopen(req, timeout=None):
body = json.loads(req.data.decode("utf-8"))
captured.update(body)
resp = MagicMock()
resp.status = 200
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
result = store_memory(
api_key="test-key",
content="session state content",
user_id="testuser",
source="pre-compaction",
session_id="sess-123",
project_id="my-project",
branch="feat/auth",
)
assert result is True
assert captured["app_id"] == "my-project"
assert captured["user_id"] == "testuser"
assert "project_id" not in captured.get("metadata", {})
assert captured["metadata"]["type"] == "session_state"
assert captured["metadata"]["source"] == "pre-compaction"
assert captured["metadata"]["branch"] == "feat/auth"
assert "expiration_date" in captured
def test_capture_compact_summary_store_uses_app_id():
"""capture_compact_summary.store_summary sends app_id top-level."""
from capture_compact_summary import store_summary
captured = {}
def mock_urlopen(req, timeout=None):
body = json.loads(req.data.decode("utf-8"))
captured.update(body)
resp = MagicMock()
resp.status = 200
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
result = store_summary(
api_key="test-key",
summary="compact summary text",
user_id="testuser",
session_id="sess-456",
project_id="my-project",
branch="main",
)
assert result is True
assert captured["app_id"] == "my-project"
assert captured["user_id"] == "testuser"
assert "project_id" not in captured.get("metadata", {})
assert captured["metadata"]["type"] == "compact_summary"
assert captured["metadata"]["branch"] == "main"
assert captured["infer"] is True
assert "expiration_date" in captured
def test_no_metadata_project_id_anywhere():
"""Ensure none of the write functions put project_id in metadata."""
from auto_import import post_memory
from capture_compact_summary import store_summary
from on_pre_compact import store_memory
bodies = []
def mock_urlopen(req, timeout=None):
body = json.loads(req.data.decode("utf-8"))
bodies.append(body)
resp = MagicMock()
resp.status = 200
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
post_memory("k", "c", "u", "f", "proj", "br")
store_memory("k", "c", "u", "src", "sid", "proj", "br")
store_summary("k", "s", "u", "sid", "proj", "br")
for i, body in enumerate(bodies):
metadata = body.get("metadata", {})
assert "project_id" not in metadata, f"Write function #{i} still has metadata.project_id"
assert body.get("app_id") == "proj", f"Write function #{i} missing app_id top-level"
def test_resolve_api_key_prefers_env_var(monkeypatch):
"""resolve_api_key returns MEM0_API_KEY when both are set."""
from _identity import resolve_api_key
monkeypatch.setenv("MEM0_API_KEY", "direct-key")
monkeypatch.setenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", "fallback-key")
assert resolve_api_key() == "direct-key"
def test_resolve_api_key_falls_back_to_plugin_option(monkeypatch):
"""resolve_api_key falls back to CLAUDE_PLUGIN_OPTION_MEM0_API_KEY."""
from _identity import resolve_api_key
monkeypatch.delenv("MEM0_API_KEY", raising=False)
monkeypatch.setenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", "fallback-key")
assert resolve_api_key() == "fallback-key"
def test_resolve_api_key_returns_empty_when_neither_set(monkeypatch):
"""resolve_api_key returns empty string when no key is available."""
from _identity import resolve_api_key
monkeypatch.delenv("MEM0_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_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"