diff --git a/.claude-plugin/README.md b/.claude-plugin/README.md index 0212980..fd98952 100644 --- a/.claude-plugin/README.md +++ b/.claude-plugin/README.md @@ -43,9 +43,11 @@ After installing the plugin, run the init command to complete setup (pip install MemPalace registers two hooks that run automatically: -- **Stop** -- Saves conversation context when a session ends. +- **Stop** -- Saves conversation context every 15 messages. - **PreCompact** -- Preserves important memories before context compaction. +Set the `MEMPAL_DIR` environment variable to a directory path to automatically run `mempalace mine` on that directory during each save trigger. + ## MCP Server The plugin automatically configures a local MCP server with 19 tools for storing, searching, and managing memories. No manual MCP setup is required -- `/mempalace:init` handles everything. diff --git a/.codex-plugin/README.md b/.codex-plugin/README.md index 59e01e9..57dbc34 100644 --- a/.codex-plugin/README.md +++ b/.codex-plugin/README.md @@ -65,7 +65,9 @@ codex /init ## Hooks -The plugin includes an auto-save hook that runs on session stop, automatically preserving conversation context into your palace. +The plugin includes auto-save hooks that run on session stop (every 15 messages) and before context compaction, automatically preserving conversation context into your palace. + +Set the `MEMPAL_DIR` environment variable to a directory path to automatically run `mempalace mine` on that directory during each save trigger. ## Support diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ccb15e..4cca6ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - run: pip install -e ".[dev]" - - run: python -m pytest tests/ -v + - run: python -m pytest tests/ -v --cov=mempalace --cov-report=term-missing --cov-fail-under=30 lint: runs-on: ubuntu-latest diff --git a/README.md b/README.md index d05952f..441f506 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,17 @@ Three mining modes: **projects** (code and docs), **convos** (conversation expor After the one-time setup (install → init → mine), you don't run MemPalace commands manually. Your AI uses it for you. There are two ways, depending on which AI you use. +### With Claude Code (recommended) + +Native marketplace install: + +```bash +claude plugin marketplace add milla-jovovich/mempalace +claude plugin install --scope user mempalace +``` + +Restart Claude Code, then type `/skills` to verify "mempalace" appears. + ### With Claude, ChatGPT, Cursor, Gemini (MCP-compatible tools) ```bash @@ -439,6 +450,11 @@ Letta charges $20–200/mo for agent-managed memory. MemPalace does it with a wi ## MCP Server ```bash +# Via plugin (recommended) +claude plugin marketplace add milla-jovovich/mempalace +claude plugin install --scope user mempalace + +# Or manually claude mcp add mempalace -- python -m mempalace.mcp_server ``` @@ -509,6 +525,8 @@ Two hooks for Claude Code that automatically save memories during work: } ``` +**Optional auto-ingest:** Set the `MEMPAL_DIR` environment variable to a directory path and the hooks will automatically run `mempalace mine` on that directory during each save trigger (background on stop, synchronous on precompact). + --- ## Benchmarks diff --git a/pyproject.toml b/pyproject.toml index ff7e2f1..a83ace6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,11 +38,11 @@ Repository = "https://github.com/milla-jovovich/mempalace" mempalace = "mempalace:main" [project.optional-dependencies] -dev = ["pytest>=7.0", "ruff>=0.4.0"] +dev = ["pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.4.0"] spellcheck = ["autocorrect>=2.0"] [dependency-groups] -dev = ["pytest>=7.0", "ruff>=0.4.0"] +dev = ["pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.4.0"] [build-system] requires = ["hatchling"] @@ -64,3 +64,14 @@ quote-style = "double" [tool.pytest.ini_options] testpaths = ["tests"] + +[tool.coverage.run] +source = ["mempalace"] + +[tool.coverage.report] +fail_under = 30 +show_missing = true +exclude_lines = [ + "if __name__", + "pragma: no cover", +] diff --git a/tests/test_hooks_cli.py b/tests/test_hooks_cli.py new file mode 100644 index 0000000..8eeffed --- /dev/null +++ b/tests/test_hooks_cli.py @@ -0,0 +1,192 @@ +import contextlib +import json +from pathlib import Path +from unittest.mock import patch + +from mempalace.hooks_cli import ( + SAVE_INTERVAL, + STOP_BLOCK_REASON, + PRECOMPACT_BLOCK_REASON, + _count_human_messages, + _sanitize_session_id, + hook_stop, + hook_session_start, + hook_precompact, +) + + +# --- _sanitize_session_id --- + + +def test_sanitize_normal_id(): + assert _sanitize_session_id("abc-123_XYZ") == "abc-123_XYZ" + + +def test_sanitize_strips_dangerous_chars(): + assert _sanitize_session_id("../../etc/passwd") == "etcpasswd" + + +def test_sanitize_empty_returns_unknown(): + assert _sanitize_session_id("") == "unknown" + assert _sanitize_session_id("!!!") == "unknown" + + +# --- _count_human_messages --- + + +def _write_transcript(path: Path, entries: list[dict]): + with open(path, "w", encoding="utf-8") as f: + for entry in entries: + f.write(json.dumps(entry) + "\n") + + +def test_count_human_messages_basic(tmp_path): + transcript = tmp_path / "t.jsonl" + _write_transcript(transcript, [ + {"message": {"role": "user", "content": "hello"}}, + {"message": {"role": "assistant", "content": "hi"}}, + {"message": {"role": "user", "content": "bye"}}, + ]) + assert _count_human_messages(str(transcript)) == 2 + + +def test_count_skips_command_messages(tmp_path): + transcript = tmp_path / "t.jsonl" + _write_transcript(transcript, [ + {"message": {"role": "user", "content": "status"}}, + {"message": {"role": "user", "content": "real question"}}, + ]) + assert _count_human_messages(str(transcript)) == 1 + + +def test_count_handles_list_content(tmp_path): + transcript = tmp_path / "t.jsonl" + _write_transcript(transcript, [ + {"message": {"role": "user", "content": [{"type": "text", "text": "hello"}]}}, + {"message": {"role": "user", "content": [{"type": "text", "text": "x"}]}}, + ]) + assert _count_human_messages(str(transcript)) == 1 + + +def test_count_missing_file(): + assert _count_human_messages("/nonexistent/path.jsonl") == 0 + + +def test_count_empty_file(tmp_path): + transcript = tmp_path / "t.jsonl" + transcript.write_text("") + assert _count_human_messages(str(transcript)) == 0 + + +def test_count_malformed_json_lines(tmp_path): + transcript = tmp_path / "t.jsonl" + transcript.write_text('not json\n{"message": {"role": "user", "content": "ok"}}\n') + assert _count_human_messages(str(transcript)) == 1 + + +# --- hook_stop --- + + +def _capture_hook_output(hook_fn, data, harness="claude-code", state_dir=None): + """Run a hook and capture its JSON stdout output.""" + import io + buf = io.StringIO() + patches = [patch("mempalace.hooks_cli._output", side_effect=lambda d: buf.write(json.dumps(d)))] + if state_dir: + patches.append(patch("mempalace.hooks_cli.STATE_DIR", state_dir)) + with contextlib.ExitStack() as stack: + for p in patches: + stack.enter_context(p) + hook_fn(data, harness) + return json.loads(buf.getvalue()) + + +def test_stop_hook_passthrough_when_active(tmp_path): + with patch("mempalace.hooks_cli.STATE_DIR", tmp_path): + result = _capture_hook_output( + hook_stop, + {"session_id": "test", "stop_hook_active": True, "transcript_path": ""}, + state_dir=tmp_path, + ) + assert result == {} + + +def test_stop_hook_passthrough_when_active_string(tmp_path): + with patch("mempalace.hooks_cli.STATE_DIR", tmp_path): + result = _capture_hook_output( + hook_stop, + {"session_id": "test", "stop_hook_active": "true", "transcript_path": ""}, + state_dir=tmp_path, + ) + assert result == {} + + +def test_stop_hook_passthrough_below_interval(tmp_path): + transcript = tmp_path / "t.jsonl" + _write_transcript(transcript, [ + {"message": {"role": "user", "content": f"msg {i}"}} + for i in range(SAVE_INTERVAL - 1) + ]) + result = _capture_hook_output( + hook_stop, + {"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)}, + state_dir=tmp_path, + ) + assert result == {} + + +def test_stop_hook_blocks_at_interval(tmp_path): + transcript = tmp_path / "t.jsonl" + _write_transcript(transcript, [ + {"message": {"role": "user", "content": f"msg {i}"}} + for i in range(SAVE_INTERVAL) + ]) + result = _capture_hook_output( + hook_stop, + {"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)}, + state_dir=tmp_path, + ) + assert result["decision"] == "block" + assert result["reason"] == STOP_BLOCK_REASON + + +def test_stop_hook_tracks_save_point(tmp_path): + transcript = tmp_path / "t.jsonl" + _write_transcript(transcript, [ + {"message": {"role": "user", "content": f"msg {i}"}} + for i in range(SAVE_INTERVAL) + ]) + data = {"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)} + + # First call blocks + result = _capture_hook_output(hook_stop, data, state_dir=tmp_path) + assert result["decision"] == "block" + + # Second call with same count passes through (already saved) + result = _capture_hook_output(hook_stop, data, state_dir=tmp_path) + assert result == {} + + +# --- hook_session_start --- + + +def test_session_start_passes_through(tmp_path): + result = _capture_hook_output( + hook_session_start, + {"session_id": "test"}, + state_dir=tmp_path, + ) + assert result == {} + + +# --- hook_precompact --- + + +def test_precompact_always_blocks(tmp_path): + result = _capture_hook_output( + hook_precompact, + {"session_id": "test"}, + state_dir=tmp_path, + ) + assert result["decision"] == "block" + assert result["reason"] == PRECOMPACT_BLOCK_REASON