feat(mem0-plugin): add export/import skills + competing tool importers

Tier 3 implementation:
- #10: Unified project_id resolver already complete (shared scripts/)
- #11: /mem0:export dumps all project memories as YAML-frontmatter
  markdown; /mem0:import restores from same format. Round-trip capable.
  parse_export_file.py handles parsing (stdlib-only).
- #12: import_competing_tools.py imports from .cursorrules, copilot
  instructions, cline memory-bank, continue.dev rules. Splits by
  section headers, POSTs with app_id + infer=False.
This commit is contained in:
kartik-mem0
2026-05-21 17:43:30 +05:30
parent b79be63e30
commit 63641f5573
7 changed files with 1418 additions and 0 deletions
@@ -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 import_competing_tools 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 import_competing_tools 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 import_competing_tools 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 import_competing_tools 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 import_competing_tools 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 import_competing_tools 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 import_competing_tools 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
+298
View File
@@ -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() == "[]"