Merge upstream/main into bench/scale-test-suite to resolve conflicts

Merged both the PR's benchmark suite additions (psutil dep, pytest
markers, --ignore=tests/benchmarks) and upstream's coverage changes
(pytest-cov, --cov-fail-under=30, coverage config) so both coexist.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
Igor Lins e Silva
2026-04-08 16:28:06 -03:00
44 changed files with 1585 additions and 83 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"name": "mempalace",
"interface": {
"displayName": "MemPalace"
},
"plugins": [
{
"name": "mempalace",
"source": {
"source": "local",
"path": "./.codex-plugin"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "NONE"
},
"category": "Coding"
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"mempalace": {
"command": "python3",
"args": [
"-m",
"mempalace.mcp_server"
]
}
}
+57
View File
@@ -0,0 +1,57 @@
# MemPalace Claude Code Plugin
A Claude Code plugin that gives your AI a persistent memory system. Mine projects and conversations into a searchable palace backed by ChromaDB, with 19 MCP tools, auto-save hooks, and 5 guided skills.
## Prerequisites
- Python 3.9+
## Installation
### Claude Code Marketplace
```bash
claude plugin marketplace add milla-jovovich/mempalace
claude plugin install --scope user mempalace
```
### Local Clone
```bash
claude plugin add /path/to/mempalace
```
## Post-Install Setup
After installing the plugin, run the init command to complete setup (pip install, MCP configuration, etc.):
```
/mempalace:init
```
## Available Slash Commands
| Command | Description |
|---------|-------------|
| `/mempalace:help` | Show available tools, skills, and architecture |
| `/mempalace:init` | Set up MemPalace -- install, configure MCP, onboard |
| `/mempalace:search` | Search your memories across the palace |
| `/mempalace:mine` | Mine projects and conversations into the palace |
| `/mempalace:status` | Show palace overview -- wings, rooms, drawer counts |
## Hooks
MemPalace registers two hooks that run automatically:
- **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.
## Full Documentation
See the main [README](../README.md) for complete documentation, architecture details, and advanced usage.
+6
View File
@@ -0,0 +1,6 @@
---
description: Show comprehensive MemPalace help — available skills, MCP tools, CLI commands, hooks, and architecture.
allowed-tools: Bash, Read
---
Invoke the generic mempalace skill (using the Skill tool) with the `help` command, then follow its instructions.
+6
View File
@@ -0,0 +1,6 @@
---
description: Set up MemPalace — install the package, initialize a palace, configure MCP server, and verify everything works.
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
---
Invoke the generic mempalace skill (using the Skill tool) with the `init` command, then follow its instructions.
+7
View File
@@ -0,0 +1,7 @@
---
description: Mine projects and conversations into the MemPalace. Supports project files, conversation exports, and auto-classification.
argument-hint: Path to project or conversation export to mine.
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
---
Invoke the generic mempalace skill (using the Skill tool) with the `mine` command, then follow its instructions.
+7
View File
@@ -0,0 +1,7 @@
---
description: Search your memories across the MemPalace using semantic search with wing/room filtering.
argument-hint: Search query, optionally with wing/room filters.
allowed-tools: Bash, Read
---
Invoke the generic mempalace skill (using the Skill tool) with the `search` command, then follow its instructions.
+6
View File
@@ -0,0 +1,6 @@
---
description: Show the current state of your memory palace — wings, rooms, drawer counts, and suggestions.
allowed-tools: Bash, Read
---
Invoke the generic mempalace skill (using the Skill tool) with the `status` command, then follow its instructions.
+25
View File
@@ -0,0 +1,25 @@
{
"description": "MemPalace auto-save and pre-compact hooks",
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/mempal-stop-hook.sh"
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/mempal-precompact-hook.sh"
}
]
}
]
}
}
@@ -0,0 +1,5 @@
#!/bin/bash
# MemPalace PreCompact Hook — thin wrapper calling Python CLI
# All logic lives in mempalace.hooks_cli for cross-harness extensibility
INPUT=$(cat)
echo "$INPUT" | python3 -m mempalace hook run --hook precompact --harness claude-code
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# MemPalace Stop Hook — thin wrapper calling Python CLI
# All logic lives in mempalace.hooks_cli for cross-harness extensibility
INPUT=$(cat)
echo "$INPUT" | python3 -m mempalace hook run --hook stop --harness claude-code
+18
View File
@@ -0,0 +1,18 @@
{
"name": "mempalace",
"owner": {
"name": "milla-jovovich",
"url": "https://github.com/milla-jovovich"
},
"plugins": [
{
"name": "mempalace",
"source": "./.claude-plugin",
"description": "AI memory system — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, guided setup.",
"version": "3.0.12",
"author": {
"name": "milla-jovovich"
}
}
]
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "mempalace",
"version": "3.0.12",
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.",
"author": {
"name": "milla-jovovich"
},
"license": "MIT",
"commands": [],
"mcpServers": {
"mempalace": {
"command": "python3",
"args": [
"-m",
"mempalace.mcp_server"
]
}
},
"keywords": [
"memory",
"ai",
"rag",
"mcp",
"chromadb",
"palace",
"search"
],
"repository": "https://github.com/milla-jovovich/mempalace"
}
+35
View File
@@ -0,0 +1,35 @@
---
name: mempalace
description: MemPalace — mine projects and conversations into a searchable memory palace. Use when asked about mempalace, memory palace, mining memories, searching memories, or palace setup.
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
---
# MemPalace
A searchable memory palace for AI — mine projects and conversations, then search them semantically.
## Prerequisites
Ensure `mempalace` is installed:
```bash
mempalace --version
```
If not installed:
```bash
pip install mempalace
```
## Usage
MemPalace provides dynamic instructions via the CLI. To get instructions for any operation:
```bash
mempalace instructions <command>
```
Where `<command>` is one of: `help`, `init`, `mine`, `search`, `status`.
Run the appropriate instructions command, then follow the returned instructions step by step.
+75
View File
@@ -0,0 +1,75 @@
# MemPalace - Codex CLI Plugin
Give your AI a persistent memory -- mine projects and conversations into a searchable palace backed by ChromaDB, with 19 MCP tools, auto-save hooks, and guided skills.
## Prerequisites
- Python 3.9+
- Codex CLI installed and configured
- `pip install mempalace`
## Installation
### Local Install
1. Copy or symlink the `.codex-plugin` directory into your project root:
```bash
cp -r .codex-plugin /path/to/your/project/.codex-plugin
```
2. Verify the plugin is detected:
```bash
codex --plugins
```
3. Initialize your palace:
```bash
codex /init
```
### Git Install
1. Clone the MemPalace repository:
```bash
git clone https://github.com/milla-jovovich/mempalace.git
cd mempalace
```
2. Install the Python package:
```bash
pip install -e .
```
3. The `.codex-plugin` directory is already in the repo root. Codex CLI will detect it automatically when you run Codex from inside the repository.
4. Initialize your palace:
```bash
codex /init
```
## Available Skills
| Skill | Description |
|-------|-------------|
| `/help` | Show available commands and usage tips |
| `/init` | Initialize a new memory palace |
| `/search` | Semantic search across all mined memories |
| `/mine` | Mine a project or conversation into your palace |
| `/status` | Show palace status, room counts, and health |
## Hooks
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
- Repository: https://github.com/milla-jovovich/mempalace
- Issues: https://github.com/milla-jovovich/mempalace/issues
+37
View File
@@ -0,0 +1,37 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "${CODEX_PLUGIN_ROOT}/hooks/mempal-hook.sh session-start"
}
]
}
],
"Stop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "${CODEX_PLUGIN_ROOT}/hooks/mempal-hook.sh stop"
}
]
}
],
"PreCompact": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "${CODEX_PLUGIN_ROOT}/hooks/mempal-hook.sh precompact"
}
]
}
]
}
}
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
HOOK_NAME="${1:?Usage: mempal-hook.sh <hook-name>}"
INPUT_FILE=$(mktemp) || { echo "Failed to create temp file" >&2; exit 1; }
cat > "$INPUT_FILE"
cat "$INPUT_FILE" | python3 -m mempalace hook run --hook "$HOOK_NAME" --harness codex
EXIT_CODE=$?
rm -f "$INPUT_FILE" 2>/dev/null
exit $EXIT_CODE
+52
View File
@@ -0,0 +1,52 @@
{
"name": "mempalace",
"version": "3.0.12",
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.",
"author": {
"name": "milla-jovovich"
},
"homepage": "https://github.com/milla-jovovich/mempalace",
"repository": "https://github.com/milla-jovovich/mempalace",
"license": "MIT",
"keywords": [
"memory",
"ai",
"rag",
"mcp",
"chromadb",
"palace",
"search"
],
"skills": "./skills/",
"hooks": "./hooks.json",
"mcpServers": {
"mempalace": {
"command": "python3",
"args": [
"-m",
"mempalace.mcp_server"
]
}
},
"interface": {
"displayName": "MemPalace",
"shortDescription": "AI memory system for Codex",
"longDescription": "Give your AI a persistent memory — mine projects and conversations into a searchable palace backed by ChromaDB, with 19 MCP tools, auto-save hooks, and guided skills.",
"developerName": "milla-jovovich",
"category": "Coding",
"capabilities": [
"Interactive",
"Read",
"Write"
],
"websiteURL": "https://github.com/milla-jovovich/mempalace",
"privacyPolicyURL": "https://github.com/milla-jovovich/mempalace",
"termsOfServiceURL": "https://github.com/milla-jovovich/mempalace",
"defaultPrompt": [
"Search my memories for recent decisions",
"Mine this project into my memory palace",
"Show my palace status and room counts"
],
"brandColor": "#7C3AED"
}
}
+13
View File
@@ -0,0 +1,13 @@
---
name: help
description: Show MemPalace help — available commands, usage tips, and getting started guidance.
allowed-tools: Bash, Read
---
# MemPalace Help
Run the following command and follow the returned instructions step by step:
```bash
mempalace instructions help
```
+13
View File
@@ -0,0 +1,13 @@
---
name: init
description: Initialize a new MemPalace — guided setup for your AI memory palace with ChromaDB backend.
allowed-tools: Bash, Read, Write, Edit
---
# MemPalace Init
Run the following command and follow the returned instructions step by step:
```bash
mempalace instructions init
```
+13
View File
@@ -0,0 +1,13 @@
---
name: mine
description: Mine a project or conversation into your MemPalace — extract and store memories for later retrieval.
allowed-tools: Bash, Read, Glob, Grep
---
# MemPalace Mine
Run the following command and follow the returned instructions step by step:
```bash
mempalace instructions mine
```
+13
View File
@@ -0,0 +1,13 @@
---
name: search
description: Search your MemPalace — semantic search across all mined memories, projects, and conversations.
allowed-tools: Bash, Read
---
# MemPalace Search
Run the following command and follow the returned instructions step by step:
```bash
mempalace instructions search
```
+13
View File
@@ -0,0 +1,13 @@
---
name: status
description: Show MemPalace status — room counts, storage usage, and palace health.
allowed-tools: Bash, Read
---
# MemPalace Status
Run the following command and follow the returned instructions step by step:
```bash
mempalace instructions status
```
+51
View File
@@ -0,0 +1,51 @@
name: Bump Version
on:
push:
branches: [main]
jobs:
bump-version:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- name: Bump patch version
run: |
CURRENT=$(python3 -c "exec(open('mempalace/version.py').read()); print(__version__)")
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
PATCH=$((PATCH + 1))
NEW="${MAJOR}.${MINOR}.${PATCH}"
echo "__version__ = \"${NEW}\"" > mempalace/version.py
# Prepend docstring
sed -i '1i"""Single source of truth for the MemPalace package version."""\n' mempalace/version.py
echo "version=$NEW" >> "$GITHUB_OUTPUT"
id: version
- name: Sync plugin.json
run: |
jq --arg v "${{ steps.version.outputs.version }}" '.version = $v' .claude-plugin/plugin.json > tmp.json && mv tmp.json .claude-plugin/plugin.json
- name: Sync marketplace.json
run: |
jq --arg v "${{ steps.version.outputs.version }}" '.plugins[0].version = $v' .claude-plugin/marketplace.json > tmp.json && mv tmp.json .claude-plugin/marketplace.json
- name: Sync codex plugin.json
run: |
jq --arg v "${{ steps.version.outputs.version }}" '.version = $v' .codex-plugin/plugin.json > tmp.json && mv tmp.json .codex-plugin/plugin.json
- name: Sync pyproject.toml
run: |
sed -i "s/^version = \".*\"/version = \"${{ steps.version.outputs.version }}\"/" pyproject.toml
- name: Commit and push
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add mempalace/version.py .claude-plugin/plugin.json .claude-plugin/marketplace.json .codex-plugin/plugin.json pyproject.toml
if ! git diff --staged --quiet; then
git commit -m "chore: bump version to ${{ steps.version.outputs.version }}"
git push
fi
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[dev]"
- run: python -m pytest tests/ -v --ignore=tests/benchmarks
- run: python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing --cov-fail-under=30
lint:
runs-on: ubuntu-latest
+1
View File
@@ -5,3 +5,4 @@ __pycache__/
*.pyc
.pytest_cache/
mempal.yaml
.a5c/
+19 -1
View File
@@ -29,7 +29,7 @@ Other memory systems try to fix this by letting AI decide what's worth rememberi
<br>
[Quick Start](#quick-start) · [The Palace](#the-palace) · [AAAK Dialect](#aaak-compression) · [Benchmarks](#benchmarks) · [MCP Tools](#mcp-server)
[Quick Start](#quick-start) · [The Palace](#the-palace) · [AAAK Dialect](#aaak-dialect-experimental) · [Benchmarks](#benchmarks) · [MCP Tools](#mcp-server)
<br>
@@ -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 $20200/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
+17 -2
View File
@@ -1,6 +1,21 @@
"""MemPalace — Give your AI a memory. No API key required."""
from .cli import main
from .version import __version__
import logging
import os
import platform
from .cli import main # noqa: E402
from .version import __version__ # noqa: E402
# ChromaDB 0.6.x ships a Posthog telemetry client whose capture() signature is
# incompatible with the bundled posthog library, producing noisy stderr warnings
# on every client operation ("Failed to send telemetry event … capture() takes
# 1 positional argument but 3 were given"). Silence just that logger.
logging.getLogger("chromadb.telemetry.product.posthog").setLevel(logging.CRITICAL)
# ONNX Runtime's CoreML provider segfaults during vector queries on Apple Silicon.
# Force CPU execution unless the user has explicitly set a preference.
if platform.machine() == "arm64" and platform.system() == "Darwin":
os.environ.setdefault("ORT_DISABLE_COREML", "1")
__all__ = ["main", "__version__"]
+60
View File
@@ -226,6 +226,20 @@ def cmd_repair(args):
print(f"\n{'=' * 55}\n")
def cmd_hook(args):
"""Run hook logic: reads JSON from stdin, outputs JSON to stdout."""
from .hooks_cli import run_hook
run_hook(hook_name=args.hook, harness=args.harness)
def cmd_instructions(args):
"""Output skill instructions to stdout."""
from .instructions_cli import run_instructions
run_instructions(name=args.name)
def cmd_compress(args):
"""Compress drawers in a wing using AAAK Dialect."""
import chromadb
@@ -451,6 +465,35 @@ def main():
help="Only split files containing at least N sessions (default: 2)",
)
# hook
p_hook = sub.add_parser(
"hook",
help="Run hook logic (reads JSON from stdin, outputs JSON to stdout)",
)
hook_sub = p_hook.add_subparsers(dest="hook_action")
p_hook_run = hook_sub.add_parser("run", help="Execute a hook")
p_hook_run.add_argument(
"--hook",
required=True,
choices=["session-start", "stop", "precompact"],
help="Hook name to run",
)
p_hook_run.add_argument(
"--harness",
required=True,
choices=["claude-code", "codex"],
help="Harness type (determines stdin JSON format)",
)
# instructions
p_instructions = sub.add_parser(
"instructions",
help="Output skill instructions to stdout",
)
instructions_sub = p_instructions.add_subparsers(dest="instructions_name")
for instr_name in ["init", "search", "mine", "help", "status"]:
instructions_sub.add_parser(instr_name, help=f"Output {instr_name} instructions")
# repair
sub.add_parser(
"repair",
@@ -466,6 +509,23 @@ def main():
parser.print_help()
return
# Handle two-level subcommands
if args.command == "hook":
if not getattr(args, "hook_action", None):
p_hook.print_help()
return
cmd_hook(args)
return
if args.command == "instructions":
name = getattr(args, "instructions_name", None)
if not name:
p_instructions.print_help()
return
args.name = name
cmd_instructions(args)
return
dispatch = {
"init": cmd_init,
"mine": cmd_mine,
+226
View File
@@ -0,0 +1,226 @@
"""
Hook logic for MemPalace — Python implementation of session-start, stop, and precompact hooks.
Reads JSON from stdin, outputs JSON to stdout.
Supported hooks: session-start, stop, precompact
Supported harnesses: claude-code, codex (extensible to cursor, gemini, etc.)
"""
import json
import os
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path
SAVE_INTERVAL = 15
STATE_DIR = Path.home() / ".mempalace" / "hook_state"
STOP_BLOCK_REASON = (
"AUTO-SAVE checkpoint. Save key topics, decisions, quotes, and code "
"from this session to your memory system. Organize into appropriate "
"categories. Use verbatim quotes where possible. Continue conversation "
"after saving."
)
PRECOMPACT_BLOCK_REASON = (
"COMPACTION IMMINENT. Save ALL topics, decisions, quotes, code, and "
"important context from this session to your memory system. Be thorough "
"\u2014 after compaction, detailed context will be lost. Organize into "
"appropriate categories. Use verbatim quotes where possible. Save "
"everything, then allow compaction to proceed."
)
def _sanitize_session_id(session_id: str) -> str:
"""Only allow alnum, dash, underscore to prevent path traversal."""
sanitized = re.sub(r"[^a-zA-Z0-9_-]", "", session_id)
return sanitized or "unknown"
def _count_human_messages(transcript_path: str) -> int:
"""Count human messages in a JSONL transcript, skipping command-messages."""
path = Path(transcript_path).expanduser()
if not path.is_file():
return 0
count = 0
try:
with open(path, encoding="utf-8", errors="replace") as f:
for line in f:
try:
entry = json.loads(line)
msg = entry.get("message", {})
if isinstance(msg, dict) and msg.get("role") == "user":
content = msg.get("content", "")
if isinstance(content, str):
if "<command-message>" in content:
continue
elif isinstance(content, list):
text = " ".join(
b.get("text", "") for b in content if isinstance(b, dict)
)
if "<command-message>" in text:
continue
count += 1
except (json.JSONDecodeError, AttributeError):
pass
except OSError:
return 0
return count
def _log(message: str):
"""Append to hook state log file."""
try:
STATE_DIR.mkdir(parents=True, exist_ok=True)
log_path = STATE_DIR / "hook.log"
timestamp = datetime.now().strftime("%H:%M:%S")
with open(log_path, "a") as f:
f.write(f"[{timestamp}] {message}\n")
except OSError:
pass
def _output(data: dict):
"""Print JSON to stdout with consistent formatting (pretty-printed)."""
print(json.dumps(data, indent=2, ensure_ascii=False))
def _maybe_auto_ingest():
"""If MEMPAL_DIR is set and exists, run mempalace mine in background."""
mempal_dir = os.environ.get("MEMPAL_DIR", "")
if mempal_dir and os.path.isdir(mempal_dir):
try:
log_path = STATE_DIR / "hook.log"
with open(log_path, "a") as log_f:
subprocess.Popen(
[sys.executable, "-m", "mempalace", "mine", mempal_dir],
stdout=log_f,
stderr=log_f,
)
except OSError:
pass
SUPPORTED_HARNESSES = {"claude-code", "codex"}
def _parse_harness_input(data: dict, harness: str) -> dict:
"""Parse stdin JSON according to the harness type."""
if harness not in SUPPORTED_HARNESSES:
print(f"Unknown harness: {harness}", file=sys.stderr)
sys.exit(1)
return {
"session_id": _sanitize_session_id(str(data.get("session_id", "unknown"))),
"stop_hook_active": data.get("stop_hook_active", False),
"transcript_path": str(data.get("transcript_path", "")),
}
def hook_stop(data: dict, harness: str):
"""Stop hook: block every N messages for auto-save."""
parsed = _parse_harness_input(data, harness)
session_id = parsed["session_id"]
stop_hook_active = parsed["stop_hook_active"]
transcript_path = parsed["transcript_path"]
# If already in a save cycle, let through (infinite-loop prevention)
if str(stop_hook_active).lower() in ("true", "1", "yes"):
_output({})
return
# Count human messages
exchange_count = _count_human_messages(transcript_path)
# Track last save point
STATE_DIR.mkdir(parents=True, exist_ok=True)
last_save_file = STATE_DIR / f"{session_id}_last_save"
last_save = 0
if last_save_file.is_file():
try:
last_save = int(last_save_file.read_text().strip())
except (ValueError, OSError):
last_save = 0
since_last = exchange_count - last_save
_log(f"Session {session_id}: {exchange_count} exchanges, {since_last} since last save")
if since_last >= SAVE_INTERVAL and exchange_count > 0:
# Update last save point
try:
last_save_file.write_text(str(exchange_count))
except OSError:
pass
_log(f"TRIGGERING SAVE at exchange {exchange_count}")
# Optional: auto-ingest if MEMPAL_DIR is set
_maybe_auto_ingest()
_output({"decision": "block", "reason": STOP_BLOCK_REASON})
else:
_output({})
def hook_session_start(data: dict, harness: str):
"""Session start hook: initialize session tracking state."""
parsed = _parse_harness_input(data, harness)
session_id = parsed["session_id"]
_log(f"SESSION START for session {session_id}")
# Initialize session state directory
STATE_DIR.mkdir(parents=True, exist_ok=True)
# Pass through — no blocking on session start
_output({})
def hook_precompact(data: dict, harness: str):
"""Precompact hook: always block with comprehensive save instruction."""
parsed = _parse_harness_input(data, harness)
session_id = parsed["session_id"]
_log(f"PRE-COMPACT triggered for session {session_id}")
# Optional: auto-ingest synchronously before compaction (so memories land first)
mempal_dir = os.environ.get("MEMPAL_DIR", "")
if mempal_dir and os.path.isdir(mempal_dir):
try:
log_path = STATE_DIR / "hook.log"
with open(log_path, "a") as log_f:
subprocess.run(
[sys.executable, "-m", "mempalace", "mine", mempal_dir],
stdout=log_f,
stderr=log_f,
timeout=60,
)
except OSError:
pass
# Always block -- compaction = save everything
_output({"decision": "block", "reason": PRECOMPACT_BLOCK_REASON})
def run_hook(hook_name: str, harness: str):
"""Main entry point: read stdin JSON, dispatch to hook handler."""
try:
data = json.load(sys.stdin)
except (json.JSONDecodeError, EOFError):
_log("WARNING: Failed to parse stdin JSON, proceeding with empty data")
data = {}
hooks = {
"session-start": hook_session_start,
"stop": hook_stop,
"precompact": hook_precompact,
}
handler = hooks.get(hook_name)
if handler is None:
print(f"Unknown hook: {hook_name}", file=sys.stderr)
sys.exit(1)
handler(data, harness)
+105
View File
@@ -0,0 +1,105 @@
# MemPalace
AI memory system. Store everything, find anything. Local, free, no API key.
---
## Slash Commands
| Command | Description |
|----------------------|--------------------------------|
| /mempalace:init | Install and set up MemPalace |
| /mempalace:search | Search your memories |
| /mempalace:mine | Mine projects and conversations|
| /mempalace:status | Palace overview and stats |
| /mempalace:help | This help message |
---
## MCP Tools (19)
### Palace (read)
- mempalace_status -- Palace status and stats
- mempalace_list_wings -- List all wings
- mempalace_list_rooms -- List rooms in a wing
- mempalace_get_taxonomy -- Get the full taxonomy tree
- mempalace_search -- Search memories by query
- mempalace_check_duplicate -- Check if a memory already exists
- mempalace_get_aaak_spec -- Get the AAAK specification
### Palace (write)
- mempalace_add_drawer -- Add a new memory (drawer)
- mempalace_delete_drawer -- Delete a memory (drawer)
### Knowledge Graph
- mempalace_kg_query -- Query the knowledge graph
- mempalace_kg_add -- Add a knowledge graph entry
- mempalace_kg_invalidate -- Invalidate a knowledge graph entry
- mempalace_kg_timeline -- View knowledge graph timeline
- mempalace_kg_stats -- Knowledge graph statistics
### Navigation
- mempalace_traverse -- Traverse the palace structure
- mempalace_find_tunnels -- Find cross-wing connections
- mempalace_graph_stats -- Graph connectivity statistics
### Agent Diary
- mempalace_diary_write -- Write a diary entry
- mempalace_diary_read -- Read diary entries
---
## CLI Commands
mempalace init <dir> Initialize a new palace
mempalace mine <dir> Mine a project (default mode)
mempalace mine <dir> --mode convos Mine conversation exports
mempalace search "query" Search your memories
mempalace split <dir> Split large transcript files
mempalace wake-up Load palace into context
mempalace compress Compress palace storage
mempalace status Show palace status
mempalace repair Rebuild vector index
mempalace hook run Run hook logic (for harness integration)
mempalace instructions <name> Output skill instructions
---
## Auto-Save Hooks
- Stop hook -- Automatically saves memories every 15 messages. Counts human
messages in the session transcript (skipping command-messages). When the
threshold is reached, blocks the AI with a save instruction. Uses
~/.mempalace/hook_state/ to track save points per session. If
stop_hook_active is true, passes through to prevent infinite loops.
- PreCompact hook -- Emergency save before context compaction. Always blocks
with a comprehensive save instruction because compaction means the AI is
about to lose detailed context.
Hooks read JSON from stdin and output JSON to stdout. They can be invoked via:
echo '{"session_id":"abc","stop_hook_active":false,"transcript_path":"..."}' | mempalace hook run --hook stop --harness claude-code
---
## Architecture
Wings (projects/people)
+-- Rooms (topics)
+-- Closets (summaries)
+-- Drawers (verbatim memories)
Halls connect rooms within a wing.
Tunnels connect rooms across wings.
The palace is stored locally using ChromaDB for vector search and SQLite for
metadata. No cloud services or API keys required.
---
## Getting Started
1. /mempalace:init -- Set up your palace
2. /mempalace:mine -- Mine a project or conversation
3. /mempalace:search -- Find what you stored
+69
View File
@@ -0,0 +1,69 @@
# MemPalace Init
Guide the user through a complete MemPalace setup. Follow each step in order,
stopping to report errors and attempt remediation before proceeding.
## Step 1: Check Python version
Run `python3 --version` (or `python --version` on Windows) and confirm the
version is 3.9 or higher. If Python is not found or the version is too old,
tell the user they need Python 3.9+ installed and stop.
## Step 2: Check if mempalace is already installed
Run `pip show mempalace` to see if the package is already present. If it is,
report the installed version and skip to Step 4.
## Step 3: Install mempalace
Run `pip install mempalace`.
### Error handling -- pip failures
If `pip install mempalace` fails, try these fallbacks in order:
1. Try `pip3 install mempalace`
2. Try `python -m pip install mempalace` (or `python3 -m pip install mempalace`)
3. If the error mentions missing build tools or compilation failures (commonly
from chromadb or its native dependencies):
- On Linux/macOS: suggest `sudo apt-get install build-essential python3-dev`
(Debian/Ubuntu) or `xcode-select --install` (macOS)
- On Windows: suggest installing Microsoft C++ Build Tools from
https://visualstudio.microsoft.com/visual-cpp-build-tools/
- Then retry the install command
4. If all attempts fail, report the error clearly and stop.
## Step 4: Ask for project directory
Ask the user which project directory they want to initialize with MemPalace.
Offer the current working directory as the default. Wait for their response
before continuing.
## Step 5: Initialize the palace
Run `mempalace init <dir>` where `<dir>` is the directory from Step 4.
If this fails, report the error and stop.
## Step 6: Configure MCP server
Run the following command to register the MemPalace MCP server with Claude:
claude mcp add mempalace -- python -m mempalace.mcp_server
If this fails, report the error but continue to the next step (MCP
configuration can be done manually later).
## Step 7: Verify installation
Run `mempalace status` and confirm the output shows a healthy palace.
If the command fails or reports errors, walk the user through troubleshooting
based on the output.
## Step 8: Show next steps
Tell the user setup is complete and suggest these next actions:
- Use /mempalace:mine to start adding data to their palace
- Use /mempalace:search to query their palace and retrieve stored knowledge
+64
View File
@@ -0,0 +1,64 @@
# MemPalace Mine
When the user invokes this skill, follow these steps:
## 1. Ask what to mine
Ask the user what they want to mine and where the source data is located.
Clarify:
- Is it a project directory (code, docs, notes)?
- Is it conversation exports (Claude, ChatGPT, Slack)?
- Do they want auto-classification (decisions, milestones, problems)?
## 2. Choose the mining mode
There are three mining modes:
### Project mining
mempalace mine <dir>
Mines code files, documentation, and notes from a project directory.
### Conversation mining
mempalace mine <dir> --mode convos
Mines conversation exports from Claude, ChatGPT, or Slack into the palace.
### General extraction (auto-classify)
mempalace mine <dir> --mode convos --extract general
Auto-classifies mined content into decisions, milestones, and problems.
## 3. Optionally split mega-files first
If the source directory contains very large files, suggest splitting them
before mining:
mempalace split <dir> [--dry-run]
Use --dry-run first to preview what will be split without making changes.
## 4. Optionally tag with a wing
If the user wants to organize mined content under a specific wing, add the
--wing flag:
mempalace mine <dir> --wing <name>
## 5. Show progress and results
Run the selected mining command and display progress as it executes. After
completion, summarize the results including:
- Number of items mined
- Categories or classifications applied
- Any warnings or skipped files
## 6. Suggest next steps
After mining completes, suggest the user try:
- /mempalace:search -- search the newly mined content
- /mempalace:status -- check the current state of their palace
- Mine more data from additional sources
+57
View File
@@ -0,0 +1,57 @@
# MemPalace Search
When the user wants to search their MemPalace memories, follow these steps:
## 1. Parse the Search Query
Extract the core search intent from the user's message. Identify any explicit
or implicit filters:
- Wing -- a top-level category (e.g., "work", "personal", "research")
- Room -- a sub-category within a wing
- Keywords / semantic query -- the actual search terms
## 2. Determine Wing/Room Filters
If the user mentions a specific domain, topic area, or context, map it to the
appropriate wing and/or room. If unsure, omit filters to search globally. You
can discover the taxonomy first if needed.
## 3. Use MCP Tools (Preferred)
If MCP tools are available, use them in this priority order:
- mempalace_search(query, wing, room) -- Primary search tool. Pass the semantic
query and any wing/room filters.
- mempalace_list_wings -- Discover all available wings. Use when the user asks
what categories exist or you need to resolve a wing name.
- mempalace_list_rooms(wing) -- List rooms within a specific wing. Use to help
the user navigate or to resolve a room name.
- mempalace_get_taxonomy -- Retrieve the full wing/room/drawer tree. Use when
the user wants an overview of their entire memory structure.
- mempalace_traverse(room) -- Walk the knowledge graph starting from a room.
Use when the user wants to explore connections and related memories.
- mempalace_find_tunnels(wing1, wing2) -- Find cross-wing connections (tunnels)
between two wings. Use when the user asks about relationships between
different knowledge domains.
## 4. CLI Fallback
If MCP tools are not available, fall back to the CLI:
mempalace search "query" [--wing X] [--room Y]
## 5. Present Results
When presenting search results:
- Always include source attribution: wing, room, and drawer for each result
- Show relevance or similarity scores if available
- Group results by wing/room when returning multiple hits
- Quote or summarize the memory content clearly
## 6. Offer Next Steps
After presenting results, offer the user options to go deeper:
- Drill deeper -- search within a specific room or narrow the query
- Traverse -- explore the knowledge graph from a related room
- Check tunnels -- look for cross-wing connections if the topic spans domains
- Browse taxonomy -- show the full structure for manual exploration
+49
View File
@@ -0,0 +1,49 @@
# MemPalace Status
Display the current state of the user's memory palace.
## Step 1: Gather Palace Status
Check if MCP tools are available (look for mempalace_status in available tools).
- If MCP is available: Call the mempalace_status tool to retrieve palace state.
- If MCP is not available: Run the CLI command: mempalace status
## Step 2: Display Wing/Room/Drawer Counts
Present the palace structure counts clearly:
- Number of wings
- Number of rooms
- Number of drawers
- Total memories stored
Keep the output concise -- use a brief summary format, not verbose tables.
## Step 3: Knowledge Graph Stats (MCP only)
If MCP tools are available, also call:
- mempalace_kg_stats -- for a knowledge graph overview (triple count, entity
count, relationship types)
- mempalace_graph_stats -- for connectivity information (connected components,
average connections per entity)
Present these alongside the palace counts in a unified summary.
## Step 4: Suggest Next Actions
Based on the current state, suggest one relevant action:
- Empty palace (zero memories): Suggest "Try /mempalace:mine to add data from
files, URLs, or text."
- Has data but no knowledge graph (memories exist but KG stats show zero
triples): Suggest "Consider adding knowledge graph triples for richer
queries."
- Healthy palace (has memories and KG data): Suggest "Use /mempalace:search to
query your memories."
## Output Style
- Be concise and informative -- aim for a quick glance, not a report.
- Use short labels and numbers, not prose paragraphs.
- If any step fails or a tool is unavailable, note it briefly and continue
with what is available.
+28
View File
@@ -0,0 +1,28 @@
"""
Instruction text output for MemPalace CLI commands.
Each instruction lives as a .md file in the instructions/ directory
inside the package. The CLI reads and prints the file content.
"""
import sys
from pathlib import Path
INSTRUCTIONS_DIR = Path(__file__).parent / "instructions"
AVAILABLE = ["init", "search", "mine", "help", "status"]
def run_instructions(name: str):
"""Read and print the instruction .md file for the given name."""
if name not in AVAILABLE:
print(f"Unknown instructions: {name}", file=sys.stderr)
print(f"Available: {', '.join(sorted(AVAILABLE))}", file=sys.stderr)
sys.exit(1)
md_path = INSTRUCTIONS_DIR / f"{name}.md"
if not md_path.is_file():
print(f"Instructions file not found: {md_path}", file=sys.stderr)
sys.exit(1)
print(md_path.read_text())
+45 -17
View File
@@ -2,7 +2,7 @@
"""
MemPalace MCP Server — read/write palace access for Claude Code
================================================================
Install: claude mcp add mempalace -- python -m mempalace.mcp_server
Install: claude mcp add mempalace -- python -m mempalace.mcp_server [--palace /path/to/palace]
Tools (read):
mempalace_status — total drawers, wing/room breakdown
@@ -17,6 +17,8 @@ Tools (write):
mempalace_delete_drawer — remove a drawer by ID
"""
import argparse
import os
import sys
import json
import logging
@@ -31,21 +33,48 @@ import chromadb
from .knowledge_graph import KnowledgeGraph
_kg = KnowledgeGraph()
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stderr)
logger = logging.getLogger("mempalace_mcp")
def _parse_args():
parser = argparse.ArgumentParser(description="MemPalace MCP Server")
parser.add_argument(
"--palace",
metavar="PATH",
help="Path to the palace directory (overrides config file and env var)",
)
args, _ = parser.parse_known_args()
return args
_args = _parse_args()
if _args.palace:
os.environ["MEMPALACE_PALACE_PATH"] = os.path.abspath(_args.palace)
_config = MempalaceConfig()
if _args.palace:
_kg = KnowledgeGraph(db_path=os.path.join(_config.palace_path, "knowledge_graph.sqlite3"))
else:
_kg = KnowledgeGraph()
_client_cache = None
_collection_cache = None
def _get_collection(create=False):
"""Return the ChromaDB collection, or None on failure."""
"""Return the ChromaDB collection, caching the client between calls."""
global _client_cache, _collection_cache
try:
client = chromadb.PersistentClient(path=_config.palace_path)
if _client_cache is None:
_client_cache = chromadb.PersistentClient(path=_config.palace_path)
if create:
return client.get_or_create_collection(_config.collection_name)
return client.get_collection(_config.collection_name)
_collection_cache = _client_cache.get_or_create_collection(_config.collection_name)
elif _collection_cache is None:
_collection_cache = _client_cache.get_collection(_config.collection_name)
return _collection_cache
except Exception:
return None
@@ -255,19 +284,18 @@ def tool_add_drawer(
if not col:
return _no_palace()
# Duplicate check
dup = tool_check_duplicate(content, threshold=0.9)
if dup.get("is_duplicate"):
return {
"success": False,
"reason": "duplicate",
"matches": dup["matches"],
}
drawer_id = f"drawer_{wing}_{room}_{hashlib.md5(content.encode()).hexdigest()[:16]}"
drawer_id = f"drawer_{wing}_{room}_{hashlib.md5((content[:100] + datetime.now().isoformat()).encode()).hexdigest()[:16]}"
# Idempotency: if the deterministic ID already exists, return success as a no-op.
try:
existing = col.get(ids=[drawer_id])
if existing and existing["ids"]:
return {"success": True, "reason": "already_exists", "drawer_id": drawer_id}
except Exception:
pass
try:
col.add(
col.upsert(
ids=[drawer_id],
documents=[content],
metadatas=[
+25 -11
View File
@@ -403,10 +403,22 @@ def get_collection(palace_path: str):
def file_already_mined(collection, source_file: str) -> bool:
"""Fast check: has this file been filed before?"""
"""Fast check: has this file been filed before and is unchanged?
Compares the stored mtime in drawer metadata against the file's current
mtime. Returns False (needs re-mining) when the file has been modified
since it was last mined, or when no mtime was stored.
"""
try:
results = collection.get(where={"source_file": source_file}, limit=1)
return len(results.get("ids", [])) > 0
if not results.get("ids"):
return False
stored_meta = results["metadatas"][0] if results.get("metadatas") else {}
stored_mtime = stored_meta.get("source_mtime")
if stored_mtime is None:
return False
current_mtime = os.path.getmtime(source_file)
return float(stored_mtime) == current_mtime
except Exception:
return False
@@ -417,11 +429,7 @@ def add_drawer(
"""Add one drawer to the palace."""
drawer_id = f"drawer_{wing}_{room}_{hashlib.md5((source_file + str(chunk_index)).encode(), usedforsecurity=False).hexdigest()[:16]}"
try:
collection.add(
documents=[content],
ids=[drawer_id],
metadatas=[
{
metadata = {
"wing": wing,
"room": room,
"source_file": source_file,
@@ -429,12 +437,18 @@ def add_drawer(
"added_by": agent,
"filed_at": datetime.now().isoformat(),
}
],
# Store file mtime so we can detect modifications later.
try:
metadata["source_mtime"] = os.path.getmtime(source_file)
except OSError:
pass
collection.upsert(
documents=[content],
ids=[drawer_id],
metadatas=[metadata],
)
return True
except Exception as e:
if "already exists" in str(e).lower() or "duplicate" in str(e).lower():
return False
except Exception:
raise
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the MemPalace package version."""
__version__ = "3.0.0"
__version__ = "3.0.12"
+14 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "mempalace"
version = "3.0.0"
version = "3.0.12"
description = "Give your AI a memory — mine projects and conversations into a searchable palace. No API key required."
readme = "README.md"
requires-python = ">=3.9"
@@ -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", "psutil>=5.9"]
dev = ["pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.4.0", "psutil>=5.9"]
spellcheck = ["autocorrect>=2.0"]
[dependency-groups]
dev = ["pytest>=7.0", "ruff>=0.4.0", "psutil>=5.9"]
dev = ["pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.4.0", "psutil>=5.9"]
[build-system]
requires = ["hatchling"]
@@ -71,3 +71,14 @@ markers = [
"slow: tests that take more than 30 seconds",
"stress: destructive scale tests (100K+ drawers)",
]
[tool.coverage.run]
source = ["mempalace"]
[tool.coverage.report]
fail_under = 30
show_missing = true
exclude_lines = [
"if __name__",
"pragma: no cover",
]
+21 -1
View File
@@ -34,6 +34,24 @@ from mempalace.config import MempalaceConfig # noqa: E402
from mempalace.knowledge_graph import KnowledgeGraph # noqa: E402
@pytest.fixture(autouse=True)
def _reset_mcp_cache():
"""Reset the MCP server's cached ChromaDB client/collection between tests."""
def _clear_cache():
try:
from mempalace import mcp_server
mcp_server._client_cache = None
mcp_server._collection_cache = None
except (ImportError, AttributeError):
pass
_clear_cache()
yield
_clear_cache()
@pytest.fixture(scope="session", autouse=True)
def _isolate_home():
"""Ensure HOME points to a temp dir for the entire test session.
@@ -84,7 +102,9 @@ def collection(palace_path):
"""A ChromaDB collection pre-seeded in the temp palace."""
client = chromadb.PersistentClient(path=palace_path)
col = client.get_or_create_collection("mempalace_drawers")
return col
yield col
client.delete_collection("mempalace_drawers")
del client
@pytest.fixture
+207
View File
@@ -0,0 +1,207 @@
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
+45 -39
View File
@@ -9,25 +9,26 @@ via monkeypatch to avoid touching real data.
import json
def _patch_mcp_server(monkeypatch, config, palace_path, kg):
def _patch_mcp_server(monkeypatch, config, kg):
"""Patch the mcp_server module globals to use test fixtures."""
from mempalace import mcp_server
assert getattr(config, "palace_path", None) == palace_path, (
f"config.palace_path ({getattr(config, 'palace_path', None)!r}) does not match palace_path fixture ({palace_path!r})"
)
monkeypatch.setattr(mcp_server, "_config", config)
monkeypatch.setattr(mcp_server, "_kg", kg)
def _get_collection(palace_path, create=False):
"""Helper to get collection from test palace."""
"""Helper to get collection from test palace.
Returns (client, collection) so callers can clean up the client
when they are done.
"""
import chromadb
client = chromadb.PersistentClient(path=palace_path)
if create:
return client.get_or_create_collection("mempalace_drawers")
return client.get_collection("mempalace_drawers")
return client, client.get_or_create_collection("mempalace_drawers")
return client, client.get_collection("mempalace_drawers")
# ── Protocol Layer ──────────────────────────────────────────────────────
@@ -77,11 +78,12 @@ class TestHandleRequest:
assert resp["error"]["code"] == -32601
def test_tools_call_dispatches(self, monkeypatch, config, palace_path, seeded_kg):
_patch_mcp_server(monkeypatch, config, palace_path, seeded_kg)
_patch_mcp_server(monkeypatch, config, seeded_kg)
from mempalace.mcp_server import handle_request
# Create a collection so status works
_get_collection(palace_path, create=True)
_client, _col = _get_collection(palace_path, create=True)
del _client
resp = handle_request(
{
@@ -100,8 +102,9 @@ class TestHandleRequest:
class TestReadTools:
def test_status_empty_palace(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, palace_path, kg)
_get_collection(palace_path, create=True)
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_status
result = tool_status()
@@ -109,7 +112,7 @@ class TestReadTools:
assert result["wings"] == {}
def test_status_with_data(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, palace_path, kg)
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_status
result = tool_status()
@@ -118,7 +121,7 @@ class TestReadTools:
assert "notes" in result["wings"]
def test_list_wings(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, palace_path, kg)
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_wings
result = tool_list_wings()
@@ -126,7 +129,7 @@ class TestReadTools:
assert result["wings"]["notes"] == 1
def test_list_rooms_all(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, palace_path, kg)
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_rooms
result = tool_list_rooms()
@@ -135,7 +138,7 @@ class TestReadTools:
assert "planning" in result["rooms"]
def test_list_rooms_filtered(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, palace_path, kg)
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_rooms
result = tool_list_rooms(wing="project")
@@ -143,7 +146,7 @@ class TestReadTools:
assert "planning" not in result["rooms"]
def test_get_taxonomy(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, palace_path, kg)
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_get_taxonomy
result = tool_get_taxonomy()
@@ -152,8 +155,7 @@ class TestReadTools:
assert result["taxonomy"]["notes"]["planning"] == 1
def test_no_palace_returns_error(self, monkeypatch, config, kg):
config._file_config["palace_path"] = "/nonexistent/path"
_patch_mcp_server(monkeypatch, config, "/nonexistent/path", kg)
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_status
result = tool_status()
@@ -165,7 +167,7 @@ class TestReadTools:
class TestSearchTool:
def test_search_basic(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, palace_path, kg)
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_search
result = tool_search(query="JWT authentication tokens")
@@ -176,14 +178,14 @@ class TestSearchTool:
assert "JWT" in top["text"] or "authentication" in top["text"].lower()
def test_search_with_wing_filter(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, palace_path, kg)
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_search
result = tool_search(query="planning", wing="notes")
assert all(r["wing"] == "notes" for r in result["results"])
def test_search_with_room_filter(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, palace_path, kg)
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_search
result = tool_search(query="database", room="backend")
@@ -195,8 +197,9 @@ class TestSearchTool:
class TestWriteTools:
def test_add_drawer(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, palace_path, kg)
_get_collection(palace_path, create=True)
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_add_drawer
result = tool_add_drawer(
@@ -210,8 +213,9 @@ class TestWriteTools:
assert result["drawer_id"].startswith("drawer_test_wing_test_room_")
def test_add_drawer_duplicate_detection(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, palace_path, kg)
_get_collection(palace_path, create=True)
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_add_drawer
content = "This is a unique test memory about Rust ownership and borrowing."
@@ -219,11 +223,11 @@ class TestWriteTools:
assert result1["success"] is True
result2 = tool_add_drawer(wing="w", room="r", content=content)
assert result2["success"] is False
assert result2["reason"] == "duplicate"
assert result2["success"] is True
assert result2["reason"] == "already_exists"
def test_delete_drawer(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, palace_path, kg)
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_delete_drawer
result = tool_delete_drawer("drawer_proj_backend_aaa")
@@ -231,14 +235,14 @@ class TestWriteTools:
assert seeded_collection.count() == 3
def test_delete_drawer_not_found(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, palace_path, kg)
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_delete_drawer
result = tool_delete_drawer("nonexistent_drawer")
assert result["success"] is False
def test_check_duplicate(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, palace_path, kg)
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_check_duplicate
# Exact match text from seeded_collection should be flagged
@@ -262,7 +266,7 @@ class TestWriteTools:
class TestKGTools:
def test_kg_add(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, palace_path, kg)
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_kg_add
result = tool_kg_add(
@@ -274,14 +278,14 @@ class TestKGTools:
assert result["success"] is True
def test_kg_query(self, monkeypatch, config, palace_path, seeded_kg):
_patch_mcp_server(monkeypatch, config, palace_path, seeded_kg)
_patch_mcp_server(monkeypatch, config, seeded_kg)
from mempalace.mcp_server import tool_kg_query
result = tool_kg_query(entity="Max")
assert result["count"] > 0
def test_kg_invalidate(self, monkeypatch, config, palace_path, seeded_kg):
_patch_mcp_server(monkeypatch, config, palace_path, seeded_kg)
_patch_mcp_server(monkeypatch, config, seeded_kg)
from mempalace.mcp_server import tool_kg_invalidate
result = tool_kg_invalidate(
@@ -293,14 +297,14 @@ class TestKGTools:
assert result["success"] is True
def test_kg_timeline(self, monkeypatch, config, palace_path, seeded_kg):
_patch_mcp_server(monkeypatch, config, palace_path, seeded_kg)
_patch_mcp_server(monkeypatch, config, seeded_kg)
from mempalace.mcp_server import tool_kg_timeline
result = tool_kg_timeline(entity="Alice")
assert result["count"] > 0
def test_kg_stats(self, monkeypatch, config, palace_path, seeded_kg):
_patch_mcp_server(monkeypatch, config, palace_path, seeded_kg)
_patch_mcp_server(monkeypatch, config, seeded_kg)
from mempalace.mcp_server import tool_kg_stats
result = tool_kg_stats()
@@ -312,8 +316,9 @@ class TestKGTools:
class TestDiaryTools:
def test_diary_write_and_read(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, palace_path, kg)
_get_collection(palace_path, create=True)
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_diary_write, tool_diary_read
w = tool_diary_write(
@@ -330,8 +335,9 @@ class TestDiaryTools:
assert "authentication" in r["entries"][0]["content"]
def test_diary_read_empty(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, palace_path, kg)
_get_collection(palace_path, create=True)
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_diary_read
r = tool_diary_read(agent_name="Nobody")
+2 -2
View File
@@ -30,8 +30,8 @@ class TestSearchMemories:
result = search_memories("code", palace_path, n_results=2)
assert len(result["results"]) <= 2
def test_no_palace_returns_error(self):
result = search_memories("anything", "/nonexistent/path")
def test_no_palace_returns_error(self, tmp_path):
result = search_memories("anything", str(tmp_path / "missing"))
assert "error" in result
def test_result_fields(self, palace_path, seeded_collection):