feat: update README and CI configuration, add tests for hooks functionality
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-2
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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": "<command-message>status</command-message>"}},
|
||||
{"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": "<command-message>x</command-message>"}]}},
|
||||
])
|
||||
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
|
||||
Reference in New Issue
Block a user