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:
|
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.
|
- **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
|
## 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.
|
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
|
## 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
|
## Support
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- run: pip install -e ".[dev]"
|
- 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:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
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.
|
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)
|
### With Claude, ChatGPT, Cursor, Gemini (MCP-compatible tools)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -439,6 +450,11 @@ Letta charges $20–200/mo for agent-managed memory. MemPalace does it with a wi
|
|||||||
## MCP Server
|
## MCP Server
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
## Benchmarks
|
||||||
|
|||||||
+13
-2
@@ -38,11 +38,11 @@ Repository = "https://github.com/milla-jovovich/mempalace"
|
|||||||
mempalace = "mempalace:main"
|
mempalace = "mempalace:main"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[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"]
|
spellcheck = ["autocorrect>=2.0"]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["pytest>=7.0", "ruff>=0.4.0"]
|
dev = ["pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.4.0"]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
@@ -64,3 +64,14 @@ quote-style = "double"
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
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