diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..58223a0 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -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" + } + ] +} diff --git a/.claude-plugin/.mcp.json b/.claude-plugin/.mcp.json new file mode 100644 index 0000000..b1e81ed --- /dev/null +++ b/.claude-plugin/.mcp.json @@ -0,0 +1,9 @@ +{ + "mempalace": { + "command": "python3", + "args": [ + "-m", + "mempalace.mcp_server" + ] + } +} diff --git a/.claude-plugin/README.md b/.claude-plugin/README.md new file mode 100644 index 0000000..fd98952 --- /dev/null +++ b/.claude-plugin/README.md @@ -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. diff --git a/.claude-plugin/commands/help.md b/.claude-plugin/commands/help.md new file mode 100644 index 0000000..2f56339 --- /dev/null +++ b/.claude-plugin/commands/help.md @@ -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. diff --git a/.claude-plugin/commands/init.md b/.claude-plugin/commands/init.md new file mode 100644 index 0000000..ff27562 --- /dev/null +++ b/.claude-plugin/commands/init.md @@ -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. diff --git a/.claude-plugin/commands/mine.md b/.claude-plugin/commands/mine.md new file mode 100644 index 0000000..edac2b0 --- /dev/null +++ b/.claude-plugin/commands/mine.md @@ -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. diff --git a/.claude-plugin/commands/search.md b/.claude-plugin/commands/search.md new file mode 100644 index 0000000..9fe8c34 --- /dev/null +++ b/.claude-plugin/commands/search.md @@ -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. diff --git a/.claude-plugin/commands/status.md b/.claude-plugin/commands/status.md new file mode 100644 index 0000000..a87f27b --- /dev/null +++ b/.claude-plugin/commands/status.md @@ -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. diff --git a/.claude-plugin/hooks/hooks.json b/.claude-plugin/hooks/hooks.json new file mode 100644 index 0000000..f1f0a90 --- /dev/null +++ b/.claude-plugin/hooks/hooks.json @@ -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" + } + ] + } + ] + } +} diff --git a/.claude-plugin/hooks/mempal-precompact-hook.sh b/.claude-plugin/hooks/mempal-precompact-hook.sh new file mode 100644 index 0000000..0ac46dd --- /dev/null +++ b/.claude-plugin/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 diff --git a/.claude-plugin/hooks/mempal-stop-hook.sh b/.claude-plugin/hooks/mempal-stop-hook.sh new file mode 100644 index 0000000..cba3284 --- /dev/null +++ b/.claude-plugin/hooks/mempal-stop-hook.sh @@ -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 diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..b2b7ed2 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -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.9", + "author": { + "name": "milla-jovovich" + } + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..a5d97af --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,29 @@ +{ + "name": "mempalace", + "version": "3.0.9", + "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" +} diff --git a/.claude-plugin/skills/mempalace/SKILL.md b/.claude-plugin/skills/mempalace/SKILL.md new file mode 100644 index 0000000..ae60fca --- /dev/null +++ b/.claude-plugin/skills/mempalace/SKILL.md @@ -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 +``` + +Where `` is one of: `help`, `init`, `mine`, `search`, `status`. + +Run the appropriate instructions command, then follow the returned instructions step by step. diff --git a/.codex-plugin/README.md b/.codex-plugin/README.md new file mode 100644 index 0000000..57dbc34 --- /dev/null +++ b/.codex-plugin/README.md @@ -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 diff --git a/.codex-plugin/hooks.json b/.codex-plugin/hooks.json new file mode 100644 index 0000000..46f7e66 --- /dev/null +++ b/.codex-plugin/hooks.json @@ -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" + } + ] + } + ] + } +} diff --git a/.codex-plugin/hooks/mempal-hook.sh b/.codex-plugin/hooks/mempal-hook.sh new file mode 100644 index 0000000..1cc0050 --- /dev/null +++ b/.codex-plugin/hooks/mempal-hook.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +HOOK_NAME="${1:?Usage: mempal-hook.sh }" +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 diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 0000000..fb19f29 --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,52 @@ +{ + "name": "mempalace", + "version": "3.0.9", + "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" + } +} diff --git a/.codex-plugin/skills/help/SKILL.md b/.codex-plugin/skills/help/SKILL.md new file mode 100644 index 0000000..d0e8f43 --- /dev/null +++ b/.codex-plugin/skills/help/SKILL.md @@ -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 +``` diff --git a/.codex-plugin/skills/init/SKILL.md b/.codex-plugin/skills/init/SKILL.md new file mode 100644 index 0000000..cc0e2f9 --- /dev/null +++ b/.codex-plugin/skills/init/SKILL.md @@ -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 +``` diff --git a/.codex-plugin/skills/mine/SKILL.md b/.codex-plugin/skills/mine/SKILL.md new file mode 100644 index 0000000..1a94e29 --- /dev/null +++ b/.codex-plugin/skills/mine/SKILL.md @@ -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 +``` diff --git a/.codex-plugin/skills/search/SKILL.md b/.codex-plugin/skills/search/SKILL.md new file mode 100644 index 0000000..4d5bf4b --- /dev/null +++ b/.codex-plugin/skills/search/SKILL.md @@ -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 +``` diff --git a/.codex-plugin/skills/status/SKILL.md b/.codex-plugin/skills/status/SKILL.md new file mode 100644 index 0000000..617d3be --- /dev/null +++ b/.codex-plugin/skills/status/SKILL.md @@ -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 +``` diff --git a/.github/workflows/bump-plugin-version.yml b/.github/workflows/bump-plugin-version.yml new file mode 100644 index 0000000..0867b3c --- /dev/null +++ b/.github/workflows/bump-plugin-version.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ccb15e..4cca6ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - run: pip install -e ".[dev]" - - run: python -m pytest tests/ -v + - run: python -m pytest tests/ -v --cov=mempalace --cov-report=term-missing --cov-fail-under=30 lint: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 54c2d1b..c8b10cc 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ *.pyc .pytest_cache/ mempal.yaml +.a5c/ diff --git a/README.md b/README.md index d05952f..441f506 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,17 @@ Three mining modes: **projects** (code and docs), **convos** (conversation expor After the one-time setup (install → init → mine), you don't run MemPalace commands manually. Your AI uses it for you. There are two ways, depending on which AI you use. +### With Claude Code (recommended) + +Native marketplace install: + +```bash +claude plugin marketplace add milla-jovovich/mempalace +claude plugin install --scope user mempalace +``` + +Restart Claude Code, then type `/skills` to verify "mempalace" appears. + ### With Claude, ChatGPT, Cursor, Gemini (MCP-compatible tools) ```bash @@ -439,6 +450,11 @@ Letta charges $20–200/mo for agent-managed memory. MemPalace does it with a wi ## MCP Server ```bash +# Via plugin (recommended) +claude plugin marketplace add milla-jovovich/mempalace +claude plugin install --scope user mempalace + +# Or manually claude mcp add mempalace -- python -m mempalace.mcp_server ``` @@ -509,6 +525,8 @@ Two hooks for Claude Code that automatically save memories during work: } ``` +**Optional auto-ingest:** Set the `MEMPAL_DIR` environment variable to a directory path and the hooks will automatically run `mempalace mine` on that directory during each save trigger (background on stop, synchronous on precompact). + --- ## Benchmarks diff --git a/mempalace/cli.py b/mempalace/cli.py index 1599b08..0a24abf 100644 --- a/mempalace/cli.py +++ b/mempalace/cli.py @@ -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, diff --git a/mempalace/hooks_cli.py b/mempalace/hooks_cli.py new file mode 100644 index 0000000..fe6e4eb --- /dev/null +++ b/mempalace/hooks_cli.py @@ -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 "" in content: + continue + elif isinstance(content, list): + text = " ".join( + b.get("text", "") for b in content if isinstance(b, dict) + ) + if "" 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) diff --git a/mempalace/instructions/help.md b/mempalace/instructions/help.md new file mode 100644 index 0000000..f18c1de --- /dev/null +++ b/mempalace/instructions/help.md @@ -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 Initialize a new palace + mempalace mine Mine a project (default mode) + mempalace mine --mode convos Mine conversation exports + mempalace search "query" Search your memories + mempalace split 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 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 diff --git a/mempalace/instructions/init.md b/mempalace/instructions/init.md new file mode 100644 index 0000000..40fe8fc --- /dev/null +++ b/mempalace/instructions/init.md @@ -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 ` where `` 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 diff --git a/mempalace/instructions/mine.md b/mempalace/instructions/mine.md new file mode 100644 index 0000000..ec8c250 --- /dev/null +++ b/mempalace/instructions/mine.md @@ -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 + +Mines code files, documentation, and notes from a project directory. + +### Conversation mining + + mempalace mine --mode convos + +Mines conversation exports from Claude, ChatGPT, or Slack into the palace. + +### General extraction (auto-classify) + + mempalace mine --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 [--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 --wing + +## 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 diff --git a/mempalace/instructions/search.md b/mempalace/instructions/search.md new file mode 100644 index 0000000..0b6a813 --- /dev/null +++ b/mempalace/instructions/search.md @@ -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 diff --git a/mempalace/instructions/status.md b/mempalace/instructions/status.md new file mode 100644 index 0000000..ceb902b --- /dev/null +++ b/mempalace/instructions/status.md @@ -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. diff --git a/mempalace/instructions_cli.py b/mempalace/instructions_cli.py new file mode 100644 index 0000000..239d721 --- /dev/null +++ b/mempalace/instructions_cli.py @@ -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()) diff --git a/mempalace/version.py b/mempalace/version.py index 08a910f..0a33c87 100644 --- a/mempalace/version.py +++ b/mempalace/version.py @@ -1,3 +1,3 @@ """Single source of truth for the MemPalace package version.""" -__version__ = "3.0.0" +__version__ = "3.0.9" diff --git a/pyproject.toml b/pyproject.toml index 4862873..c19104a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mempalace" -version = "3.0.0" +version = "3.0.9" 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"] +dev = ["pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.4.0"] spellcheck = ["autocorrect>=2.0"] [dependency-groups] -dev = ["pytest>=7.0", "ruff>=0.4.0"] +dev = ["pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.4.0"] [build-system] requires = ["hatchling"] @@ -64,3 +64,14 @@ quote-style = "double" [tool.pytest.ini_options] testpaths = ["tests"] + +[tool.coverage.run] +source = ["mempalace"] + +[tool.coverage.report] +fail_under = 30 +show_missing = true +exclude_lines = [ + "if __name__", + "pragma: no cover", +] diff --git a/tests/test_hooks_cli.py b/tests/test_hooks_cli.py new file mode 100644 index 0000000..8eeffed --- /dev/null +++ b/tests/test_hooks_cli.py @@ -0,0 +1,192 @@ +import contextlib +import json +from pathlib import Path +from unittest.mock import patch + +from mempalace.hooks_cli import ( + SAVE_INTERVAL, + STOP_BLOCK_REASON, + PRECOMPACT_BLOCK_REASON, + _count_human_messages, + _sanitize_session_id, + hook_stop, + hook_session_start, + hook_precompact, +) + + +# --- _sanitize_session_id --- + + +def test_sanitize_normal_id(): + assert _sanitize_session_id("abc-123_XYZ") == "abc-123_XYZ" + + +def test_sanitize_strips_dangerous_chars(): + assert _sanitize_session_id("../../etc/passwd") == "etcpasswd" + + +def test_sanitize_empty_returns_unknown(): + assert _sanitize_session_id("") == "unknown" + assert _sanitize_session_id("!!!") == "unknown" + + +# --- _count_human_messages --- + + +def _write_transcript(path: Path, entries: list[dict]): + with open(path, "w", encoding="utf-8") as f: + for entry in entries: + f.write(json.dumps(entry) + "\n") + + +def test_count_human_messages_basic(tmp_path): + transcript = tmp_path / "t.jsonl" + _write_transcript(transcript, [ + {"message": {"role": "user", "content": "hello"}}, + {"message": {"role": "assistant", "content": "hi"}}, + {"message": {"role": "user", "content": "bye"}}, + ]) + assert _count_human_messages(str(transcript)) == 2 + + +def test_count_skips_command_messages(tmp_path): + transcript = tmp_path / "t.jsonl" + _write_transcript(transcript, [ + {"message": {"role": "user", "content": "status"}}, + {"message": {"role": "user", "content": "real question"}}, + ]) + assert _count_human_messages(str(transcript)) == 1 + + +def test_count_handles_list_content(tmp_path): + transcript = tmp_path / "t.jsonl" + _write_transcript(transcript, [ + {"message": {"role": "user", "content": [{"type": "text", "text": "hello"}]}}, + {"message": {"role": "user", "content": [{"type": "text", "text": "x"}]}}, + ]) + assert _count_human_messages(str(transcript)) == 1 + + +def test_count_missing_file(): + assert _count_human_messages("/nonexistent/path.jsonl") == 0 + + +def test_count_empty_file(tmp_path): + transcript = tmp_path / "t.jsonl" + transcript.write_text("") + assert _count_human_messages(str(transcript)) == 0 + + +def test_count_malformed_json_lines(tmp_path): + transcript = tmp_path / "t.jsonl" + transcript.write_text('not json\n{"message": {"role": "user", "content": "ok"}}\n') + assert _count_human_messages(str(transcript)) == 1 + + +# --- hook_stop --- + + +def _capture_hook_output(hook_fn, data, harness="claude-code", state_dir=None): + """Run a hook and capture its JSON stdout output.""" + import io + buf = io.StringIO() + patches = [patch("mempalace.hooks_cli._output", side_effect=lambda d: buf.write(json.dumps(d)))] + if state_dir: + patches.append(patch("mempalace.hooks_cli.STATE_DIR", state_dir)) + with contextlib.ExitStack() as stack: + for p in patches: + stack.enter_context(p) + hook_fn(data, harness) + return json.loads(buf.getvalue()) + + +def test_stop_hook_passthrough_when_active(tmp_path): + with patch("mempalace.hooks_cli.STATE_DIR", tmp_path): + result = _capture_hook_output( + hook_stop, + {"session_id": "test", "stop_hook_active": True, "transcript_path": ""}, + state_dir=tmp_path, + ) + assert result == {} + + +def test_stop_hook_passthrough_when_active_string(tmp_path): + with patch("mempalace.hooks_cli.STATE_DIR", tmp_path): + result = _capture_hook_output( + hook_stop, + {"session_id": "test", "stop_hook_active": "true", "transcript_path": ""}, + state_dir=tmp_path, + ) + assert result == {} + + +def test_stop_hook_passthrough_below_interval(tmp_path): + transcript = tmp_path / "t.jsonl" + _write_transcript(transcript, [ + {"message": {"role": "user", "content": f"msg {i}"}} + for i in range(SAVE_INTERVAL - 1) + ]) + result = _capture_hook_output( + hook_stop, + {"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)}, + state_dir=tmp_path, + ) + assert result == {} + + +def test_stop_hook_blocks_at_interval(tmp_path): + transcript = tmp_path / "t.jsonl" + _write_transcript(transcript, [ + {"message": {"role": "user", "content": f"msg {i}"}} + for i in range(SAVE_INTERVAL) + ]) + result = _capture_hook_output( + hook_stop, + {"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)}, + state_dir=tmp_path, + ) + assert result["decision"] == "block" + assert result["reason"] == STOP_BLOCK_REASON + + +def test_stop_hook_tracks_save_point(tmp_path): + transcript = tmp_path / "t.jsonl" + _write_transcript(transcript, [ + {"message": {"role": "user", "content": f"msg {i}"}} + for i in range(SAVE_INTERVAL) + ]) + data = {"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)} + + # First call blocks + result = _capture_hook_output(hook_stop, data, state_dir=tmp_path) + assert result["decision"] == "block" + + # Second call with same count passes through (already saved) + result = _capture_hook_output(hook_stop, data, state_dir=tmp_path) + assert result == {} + + +# --- hook_session_start --- + + +def test_session_start_passes_through(tmp_path): + result = _capture_hook_output( + hook_session_start, + {"session_id": "test"}, + state_dir=tmp_path, + ) + assert result == {} + + +# --- hook_precompact --- + + +def test_precompact_always_blocks(tmp_path): + result = _capture_hook_output( + hook_precompact, + {"session_id": "test"}, + state_dir=tmp_path, + ) + assert result["decision"] == "block" + assert result["reason"] == PRECOMPACT_BLOCK_REASON