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:
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"mempalace": {
|
||||||
|
"command": "python3",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"mempalace.mcp_server"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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 --ignore=tests/benchmarks
|
- run: python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing --cov-fail-under=30
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
mempal.yaml
|
mempal.yaml
|
||||||
|
.a5c/
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ Other memory systems try to fix this by letting AI decide what's worth rememberi
|
|||||||
|
|
||||||
<br>
|
<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>
|
<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.
|
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
|
||||||
|
|||||||
+17
-2
@@ -1,6 +1,21 @@
|
|||||||
"""MemPalace — Give your AI a memory. No API key required."""
|
"""MemPalace — Give your AI a memory. No API key required."""
|
||||||
|
|
||||||
from .cli import main
|
import logging
|
||||||
from .version import __version__
|
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__"]
|
__all__ = ["main", "__version__"]
|
||||||
|
|||||||
@@ -226,6 +226,20 @@ def cmd_repair(args):
|
|||||||
print(f"\n{'=' * 55}\n")
|
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):
|
def cmd_compress(args):
|
||||||
"""Compress drawers in a wing using AAAK Dialect."""
|
"""Compress drawers in a wing using AAAK Dialect."""
|
||||||
import chromadb
|
import chromadb
|
||||||
@@ -451,6 +465,35 @@ def main():
|
|||||||
help="Only split files containing at least N sessions (default: 2)",
|
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
|
# repair
|
||||||
sub.add_parser(
|
sub.add_parser(
|
||||||
"repair",
|
"repair",
|
||||||
@@ -466,6 +509,23 @@ def main():
|
|||||||
parser.print_help()
|
parser.print_help()
|
||||||
return
|
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 = {
|
dispatch = {
|
||||||
"init": cmd_init,
|
"init": cmd_init,
|
||||||
"mine": cmd_mine,
|
"mine": cmd_mine,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -2,7 +2,7 @@
|
|||||||
"""
|
"""
|
||||||
MemPalace MCP Server — read/write palace access for Claude Code
|
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):
|
Tools (read):
|
||||||
mempalace_status — total drawers, wing/room breakdown
|
mempalace_status — total drawers, wing/room breakdown
|
||||||
@@ -17,6 +17,8 @@ Tools (write):
|
|||||||
mempalace_delete_drawer — remove a drawer by ID
|
mempalace_delete_drawer — remove a drawer by ID
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -31,21 +33,48 @@ import chromadb
|
|||||||
|
|
||||||
from .knowledge_graph import KnowledgeGraph
|
from .knowledge_graph import KnowledgeGraph
|
||||||
|
|
||||||
_kg = KnowledgeGraph()
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stderr)
|
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stderr)
|
||||||
logger = logging.getLogger("mempalace_mcp")
|
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()
|
_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):
|
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:
|
try:
|
||||||
client = chromadb.PersistentClient(path=_config.palace_path)
|
if _client_cache is None:
|
||||||
|
_client_cache = chromadb.PersistentClient(path=_config.palace_path)
|
||||||
if create:
|
if create:
|
||||||
return client.get_or_create_collection(_config.collection_name)
|
_collection_cache = _client_cache.get_or_create_collection(_config.collection_name)
|
||||||
return client.get_collection(_config.collection_name)
|
elif _collection_cache is None:
|
||||||
|
_collection_cache = _client_cache.get_collection(_config.collection_name)
|
||||||
|
return _collection_cache
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -255,19 +284,18 @@ def tool_add_drawer(
|
|||||||
if not col:
|
if not col:
|
||||||
return _no_palace()
|
return _no_palace()
|
||||||
|
|
||||||
# Duplicate check
|
drawer_id = f"drawer_{wing}_{room}_{hashlib.md5(content.encode()).hexdigest()[:16]}"
|
||||||
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[: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:
|
try:
|
||||||
col.add(
|
col.upsert(
|
||||||
ids=[drawer_id],
|
ids=[drawer_id],
|
||||||
documents=[content],
|
documents=[content],
|
||||||
metadatas=[
|
metadatas=[
|
||||||
|
|||||||
+30
-16
@@ -403,10 +403,22 @@ def get_collection(palace_path: str):
|
|||||||
|
|
||||||
|
|
||||||
def file_already_mined(collection, source_file: str) -> bool:
|
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:
|
try:
|
||||||
results = collection.get(where={"source_file": source_file}, limit=1)
|
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:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -417,24 +429,26 @@ def add_drawer(
|
|||||||
"""Add one drawer to the palace."""
|
"""Add one drawer to the palace."""
|
||||||
drawer_id = f"drawer_{wing}_{room}_{hashlib.md5((source_file + str(chunk_index)).encode(), usedforsecurity=False).hexdigest()[:16]}"
|
drawer_id = f"drawer_{wing}_{room}_{hashlib.md5((source_file + str(chunk_index)).encode(), usedforsecurity=False).hexdigest()[:16]}"
|
||||||
try:
|
try:
|
||||||
collection.add(
|
metadata = {
|
||||||
|
"wing": wing,
|
||||||
|
"room": room,
|
||||||
|
"source_file": source_file,
|
||||||
|
"chunk_index": chunk_index,
|
||||||
|
"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],
|
documents=[content],
|
||||||
ids=[drawer_id],
|
ids=[drawer_id],
|
||||||
metadatas=[
|
metadatas=[metadata],
|
||||||
{
|
|
||||||
"wing": wing,
|
|
||||||
"room": room,
|
|
||||||
"source_file": source_file,
|
|
||||||
"chunk_index": chunk_index,
|
|
||||||
"added_by": agent,
|
|
||||||
"filed_at": datetime.now().isoformat(),
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception:
|
||||||
if "already exists" in str(e).lower() or "duplicate" in str(e).lower():
|
|
||||||
return False
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Single source of truth for the MemPalace package version."""
|
"""Single source of truth for the MemPalace package version."""
|
||||||
|
|
||||||
__version__ = "3.0.0"
|
__version__ = "3.0.12"
|
||||||
|
|||||||
+14
-3
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mempalace"
|
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."
|
description = "Give your AI a memory — mine projects and conversations into a searchable palace. No API key required."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
@@ -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", "psutil>=5.9"]
|
dev = ["pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.4.0", "psutil>=5.9"]
|
||||||
spellcheck = ["autocorrect>=2.0"]
|
spellcheck = ["autocorrect>=2.0"]
|
||||||
|
|
||||||
[dependency-groups]
|
[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]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
@@ -71,3 +71,14 @@ markers = [
|
|||||||
"slow: tests that take more than 30 seconds",
|
"slow: tests that take more than 30 seconds",
|
||||||
"stress: destructive scale tests (100K+ drawers)",
|
"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
@@ -34,6 +34,24 @@ from mempalace.config import MempalaceConfig # noqa: E402
|
|||||||
from mempalace.knowledge_graph import KnowledgeGraph # 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)
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
def _isolate_home():
|
def _isolate_home():
|
||||||
"""Ensure HOME points to a temp dir for the entire test session.
|
"""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."""
|
"""A ChromaDB collection pre-seeded in the temp palace."""
|
||||||
client = chromadb.PersistentClient(path=palace_path)
|
client = chromadb.PersistentClient(path=palace_path)
|
||||||
col = client.get_or_create_collection("mempalace_drawers")
|
col = client.get_or_create_collection("mempalace_drawers")
|
||||||
return col
|
yield col
|
||||||
|
client.delete_collection("mempalace_drawers")
|
||||||
|
del client
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -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
@@ -9,25 +9,26 @@ via monkeypatch to avoid touching real data.
|
|||||||
import json
|
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."""
|
"""Patch the mcp_server module globals to use test fixtures."""
|
||||||
from mempalace import mcp_server
|
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, "_config", config)
|
||||||
monkeypatch.setattr(mcp_server, "_kg", kg)
|
monkeypatch.setattr(mcp_server, "_kg", kg)
|
||||||
|
|
||||||
|
|
||||||
def _get_collection(palace_path, create=False):
|
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
|
import chromadb
|
||||||
|
|
||||||
client = chromadb.PersistentClient(path=palace_path)
|
client = chromadb.PersistentClient(path=palace_path)
|
||||||
if create:
|
if create:
|
||||||
return client.get_or_create_collection("mempalace_drawers")
|
return client, client.get_or_create_collection("mempalace_drawers")
|
||||||
return client.get_collection("mempalace_drawers")
|
return client, client.get_collection("mempalace_drawers")
|
||||||
|
|
||||||
|
|
||||||
# ── Protocol Layer ──────────────────────────────────────────────────────
|
# ── Protocol Layer ──────────────────────────────────────────────────────
|
||||||
@@ -77,11 +78,12 @@ class TestHandleRequest:
|
|||||||
assert resp["error"]["code"] == -32601
|
assert resp["error"]["code"] == -32601
|
||||||
|
|
||||||
def test_tools_call_dispatches(self, monkeypatch, config, palace_path, seeded_kg):
|
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
|
from mempalace.mcp_server import handle_request
|
||||||
|
|
||||||
# Create a collection so status works
|
# 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(
|
resp = handle_request(
|
||||||
{
|
{
|
||||||
@@ -100,8 +102,9 @@ class TestHandleRequest:
|
|||||||
|
|
||||||
class TestReadTools:
|
class TestReadTools:
|
||||||
def test_status_empty_palace(self, monkeypatch, config, palace_path, kg):
|
def test_status_empty_palace(self, monkeypatch, config, palace_path, kg):
|
||||||
_patch_mcp_server(monkeypatch, config, palace_path, kg)
|
_patch_mcp_server(monkeypatch, config, kg)
|
||||||
_get_collection(palace_path, create=True)
|
_client, _col = _get_collection(palace_path, create=True)
|
||||||
|
del _client
|
||||||
from mempalace.mcp_server import tool_status
|
from mempalace.mcp_server import tool_status
|
||||||
|
|
||||||
result = tool_status()
|
result = tool_status()
|
||||||
@@ -109,7 +112,7 @@ class TestReadTools:
|
|||||||
assert result["wings"] == {}
|
assert result["wings"] == {}
|
||||||
|
|
||||||
def test_status_with_data(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
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
|
from mempalace.mcp_server import tool_status
|
||||||
|
|
||||||
result = tool_status()
|
result = tool_status()
|
||||||
@@ -118,7 +121,7 @@ class TestReadTools:
|
|||||||
assert "notes" in result["wings"]
|
assert "notes" in result["wings"]
|
||||||
|
|
||||||
def test_list_wings(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
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
|
from mempalace.mcp_server import tool_list_wings
|
||||||
|
|
||||||
result = tool_list_wings()
|
result = tool_list_wings()
|
||||||
@@ -126,7 +129,7 @@ class TestReadTools:
|
|||||||
assert result["wings"]["notes"] == 1
|
assert result["wings"]["notes"] == 1
|
||||||
|
|
||||||
def test_list_rooms_all(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
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
|
from mempalace.mcp_server import tool_list_rooms
|
||||||
|
|
||||||
result = tool_list_rooms()
|
result = tool_list_rooms()
|
||||||
@@ -135,7 +138,7 @@ class TestReadTools:
|
|||||||
assert "planning" in result["rooms"]
|
assert "planning" in result["rooms"]
|
||||||
|
|
||||||
def test_list_rooms_filtered(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
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
|
from mempalace.mcp_server import tool_list_rooms
|
||||||
|
|
||||||
result = tool_list_rooms(wing="project")
|
result = tool_list_rooms(wing="project")
|
||||||
@@ -143,7 +146,7 @@ class TestReadTools:
|
|||||||
assert "planning" not in result["rooms"]
|
assert "planning" not in result["rooms"]
|
||||||
|
|
||||||
def test_get_taxonomy(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
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
|
from mempalace.mcp_server import tool_get_taxonomy
|
||||||
|
|
||||||
result = tool_get_taxonomy()
|
result = tool_get_taxonomy()
|
||||||
@@ -152,8 +155,7 @@ class TestReadTools:
|
|||||||
assert result["taxonomy"]["notes"]["planning"] == 1
|
assert result["taxonomy"]["notes"]["planning"] == 1
|
||||||
|
|
||||||
def test_no_palace_returns_error(self, monkeypatch, config, kg):
|
def test_no_palace_returns_error(self, monkeypatch, config, kg):
|
||||||
config._file_config["palace_path"] = "/nonexistent/path"
|
_patch_mcp_server(monkeypatch, config, kg)
|
||||||
_patch_mcp_server(monkeypatch, config, "/nonexistent/path", kg)
|
|
||||||
from mempalace.mcp_server import tool_status
|
from mempalace.mcp_server import tool_status
|
||||||
|
|
||||||
result = tool_status()
|
result = tool_status()
|
||||||
@@ -165,7 +167,7 @@ class TestReadTools:
|
|||||||
|
|
||||||
class TestSearchTool:
|
class TestSearchTool:
|
||||||
def test_search_basic(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
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
|
from mempalace.mcp_server import tool_search
|
||||||
|
|
||||||
result = tool_search(query="JWT authentication tokens")
|
result = tool_search(query="JWT authentication tokens")
|
||||||
@@ -176,14 +178,14 @@ class TestSearchTool:
|
|||||||
assert "JWT" in top["text"] or "authentication" in top["text"].lower()
|
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):
|
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
|
from mempalace.mcp_server import tool_search
|
||||||
|
|
||||||
result = tool_search(query="planning", wing="notes")
|
result = tool_search(query="planning", wing="notes")
|
||||||
assert all(r["wing"] == "notes" for r in result["results"])
|
assert all(r["wing"] == "notes" for r in result["results"])
|
||||||
|
|
||||||
def test_search_with_room_filter(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
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
|
from mempalace.mcp_server import tool_search
|
||||||
|
|
||||||
result = tool_search(query="database", room="backend")
|
result = tool_search(query="database", room="backend")
|
||||||
@@ -195,8 +197,9 @@ class TestSearchTool:
|
|||||||
|
|
||||||
class TestWriteTools:
|
class TestWriteTools:
|
||||||
def test_add_drawer(self, monkeypatch, config, palace_path, kg):
|
def test_add_drawer(self, monkeypatch, config, palace_path, kg):
|
||||||
_patch_mcp_server(monkeypatch, config, palace_path, kg)
|
_patch_mcp_server(monkeypatch, config, kg)
|
||||||
_get_collection(palace_path, create=True)
|
_client, _col = _get_collection(palace_path, create=True)
|
||||||
|
del _client
|
||||||
from mempalace.mcp_server import tool_add_drawer
|
from mempalace.mcp_server import tool_add_drawer
|
||||||
|
|
||||||
result = tool_add_drawer(
|
result = tool_add_drawer(
|
||||||
@@ -210,8 +213,9 @@ class TestWriteTools:
|
|||||||
assert result["drawer_id"].startswith("drawer_test_wing_test_room_")
|
assert result["drawer_id"].startswith("drawer_test_wing_test_room_")
|
||||||
|
|
||||||
def test_add_drawer_duplicate_detection(self, monkeypatch, config, palace_path, kg):
|
def test_add_drawer_duplicate_detection(self, monkeypatch, config, palace_path, kg):
|
||||||
_patch_mcp_server(monkeypatch, config, palace_path, kg)
|
_patch_mcp_server(monkeypatch, config, kg)
|
||||||
_get_collection(palace_path, create=True)
|
_client, _col = _get_collection(palace_path, create=True)
|
||||||
|
del _client
|
||||||
from mempalace.mcp_server import tool_add_drawer
|
from mempalace.mcp_server import tool_add_drawer
|
||||||
|
|
||||||
content = "This is a unique test memory about Rust ownership and borrowing."
|
content = "This is a unique test memory about Rust ownership and borrowing."
|
||||||
@@ -219,11 +223,11 @@ class TestWriteTools:
|
|||||||
assert result1["success"] is True
|
assert result1["success"] is True
|
||||||
|
|
||||||
result2 = tool_add_drawer(wing="w", room="r", content=content)
|
result2 = tool_add_drawer(wing="w", room="r", content=content)
|
||||||
assert result2["success"] is False
|
assert result2["success"] is True
|
||||||
assert result2["reason"] == "duplicate"
|
assert result2["reason"] == "already_exists"
|
||||||
|
|
||||||
def test_delete_drawer(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
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
|
from mempalace.mcp_server import tool_delete_drawer
|
||||||
|
|
||||||
result = tool_delete_drawer("drawer_proj_backend_aaa")
|
result = tool_delete_drawer("drawer_proj_backend_aaa")
|
||||||
@@ -231,14 +235,14 @@ class TestWriteTools:
|
|||||||
assert seeded_collection.count() == 3
|
assert seeded_collection.count() == 3
|
||||||
|
|
||||||
def test_delete_drawer_not_found(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
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
|
from mempalace.mcp_server import tool_delete_drawer
|
||||||
|
|
||||||
result = tool_delete_drawer("nonexistent_drawer")
|
result = tool_delete_drawer("nonexistent_drawer")
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
|
|
||||||
def test_check_duplicate(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
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
|
from mempalace.mcp_server import tool_check_duplicate
|
||||||
|
|
||||||
# Exact match text from seeded_collection should be flagged
|
# Exact match text from seeded_collection should be flagged
|
||||||
@@ -262,7 +266,7 @@ class TestWriteTools:
|
|||||||
|
|
||||||
class TestKGTools:
|
class TestKGTools:
|
||||||
def test_kg_add(self, monkeypatch, config, palace_path, kg):
|
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
|
from mempalace.mcp_server import tool_kg_add
|
||||||
|
|
||||||
result = tool_kg_add(
|
result = tool_kg_add(
|
||||||
@@ -274,14 +278,14 @@ class TestKGTools:
|
|||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
|
|
||||||
def test_kg_query(self, monkeypatch, config, palace_path, seeded_kg):
|
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
|
from mempalace.mcp_server import tool_kg_query
|
||||||
|
|
||||||
result = tool_kg_query(entity="Max")
|
result = tool_kg_query(entity="Max")
|
||||||
assert result["count"] > 0
|
assert result["count"] > 0
|
||||||
|
|
||||||
def test_kg_invalidate(self, monkeypatch, config, palace_path, seeded_kg):
|
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
|
from mempalace.mcp_server import tool_kg_invalidate
|
||||||
|
|
||||||
result = tool_kg_invalidate(
|
result = tool_kg_invalidate(
|
||||||
@@ -293,14 +297,14 @@ class TestKGTools:
|
|||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
|
|
||||||
def test_kg_timeline(self, monkeypatch, config, palace_path, seeded_kg):
|
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
|
from mempalace.mcp_server import tool_kg_timeline
|
||||||
|
|
||||||
result = tool_kg_timeline(entity="Alice")
|
result = tool_kg_timeline(entity="Alice")
|
||||||
assert result["count"] > 0
|
assert result["count"] > 0
|
||||||
|
|
||||||
def test_kg_stats(self, monkeypatch, config, palace_path, seeded_kg):
|
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
|
from mempalace.mcp_server import tool_kg_stats
|
||||||
|
|
||||||
result = tool_kg_stats()
|
result = tool_kg_stats()
|
||||||
@@ -312,8 +316,9 @@ class TestKGTools:
|
|||||||
|
|
||||||
class TestDiaryTools:
|
class TestDiaryTools:
|
||||||
def test_diary_write_and_read(self, monkeypatch, config, palace_path, kg):
|
def test_diary_write_and_read(self, monkeypatch, config, palace_path, kg):
|
||||||
_patch_mcp_server(monkeypatch, config, palace_path, kg)
|
_patch_mcp_server(monkeypatch, config, kg)
|
||||||
_get_collection(palace_path, create=True)
|
_client, _col = _get_collection(palace_path, create=True)
|
||||||
|
del _client
|
||||||
from mempalace.mcp_server import tool_diary_write, tool_diary_read
|
from mempalace.mcp_server import tool_diary_write, tool_diary_read
|
||||||
|
|
||||||
w = tool_diary_write(
|
w = tool_diary_write(
|
||||||
@@ -330,8 +335,9 @@ class TestDiaryTools:
|
|||||||
assert "authentication" in r["entries"][0]["content"]
|
assert "authentication" in r["entries"][0]["content"]
|
||||||
|
|
||||||
def test_diary_read_empty(self, monkeypatch, config, palace_path, kg):
|
def test_diary_read_empty(self, monkeypatch, config, palace_path, kg):
|
||||||
_patch_mcp_server(monkeypatch, config, palace_path, kg)
|
_patch_mcp_server(monkeypatch, config, kg)
|
||||||
_get_collection(palace_path, create=True)
|
_client, _col = _get_collection(palace_path, create=True)
|
||||||
|
del _client
|
||||||
from mempalace.mcp_server import tool_diary_read
|
from mempalace.mcp_server import tool_diary_read
|
||||||
|
|
||||||
r = tool_diary_read(agent_name="Nobody")
|
r = tool_diary_read(agent_name="Nobody")
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ class TestSearchMemories:
|
|||||||
result = search_memories("code", palace_path, n_results=2)
|
result = search_memories("code", palace_path, n_results=2)
|
||||||
assert len(result["results"]) <= 2
|
assert len(result["results"]) <= 2
|
||||||
|
|
||||||
def test_no_palace_returns_error(self):
|
def test_no_palace_returns_error(self, tmp_path):
|
||||||
result = search_memories("anything", "/nonexistent/path")
|
result = search_memories("anything", str(tmp_path / "missing"))
|
||||||
assert "error" in result
|
assert "error" in result
|
||||||
|
|
||||||
def test_result_fields(self, palace_path, seeded_collection):
|
def test_result_fields(self, palace_path, seeded_collection):
|
||||||
|
|||||||
Reference in New Issue
Block a user