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