feat: add Cursor IDE support (hooks, plugin, skill, docs, tests)

Adds first-class Cursor IDE integration alongside the existing Claude
Code and Codex hook flows, so Cursor users get the same automatic
diary saves, pre-compaction transcript capture, and session-start
memory recall — without changing any default behaviour for existing
users.

What's included
---------------

Cursor hook scripts (hooks/cursor/):
  - mempal_save_hook_cursor.sh       — Stop event, counter +
    loop_count guard, pending-save marker consumption, background
    mempalace mine, followup_message emission.
  - mempal_precompact_hook_cursor.sh — synchronous mine before
    compaction, drops a pending_save marker, returns user_message.
  - mempal_wake_hook_cursor.sh       — sessionStart event,
    wing-scoped recall guidance via additional_context.
  - lib/common.sh                    — shared parsing + state helpers
    (bash 3.2 safe, no heredoc-in-subshell traps).
  - install.sh                       — idempotent installer with
    --scope, --variant, --dry-run, --uninstall. Recognises existing
    entries by basename so re-installs across paths work.
  - STDIN_SHAPE.md, README.md        — payload schemas + quick
    reference.

Cursor plugin (.cursor-plugin/ + repo-root components):
  - plugin.json, marketplace.json, README.md.
  - skills/mempalace/SKILL.md  — model-invocable skill mirroring the
    Claude plugin's skill surface.
  - commands/mempalace-{help,init,mine,search,status}.md  — slash
    commands for marketplace-published installs (filename = slug).
  - mcp.json                   — auto-registers the mempalace MCP
    server, wrapped under the documented mcpServers key.

Examples + docs:
  - examples/cursor/hooks.json, hooks.minimal.json + README.
  - website/guide/cursor-hooks.md + sidebar entry.
  - README.md and CHANGELOG.md updates.

Tests (129 new, all green):
  - tests/test_cursor_hooks_shell.py     — 75 behavioural tests for
    the three hook scripts: kill switches, input parsing, counter
    logic, loop prevention, pending markers, wing inference, logging.
  - tests/test_cursor_hooks_install.py   — 19 contract tests for the
    installer: dry-run, idempotent merge, basename-matched uninstall,
    refusal to overwrite malformed JSON.
  - tests/test_cursor_plugin_manifest.py — 35 contract tests for the
    plugin: manifest validity, version sync with mempalace.version,
    mcp.json shape, skill/command frontmatter, default-discovery
    layout invariants.

Design notes
------------

- Local-first and zero-API by default; hooks never call external
  services. Same privacy model as the existing Claude Code hooks.
- Fail-open: hook scripts deliberately do not use set -e so a broken
  hook can never block the user's conversation.
- Cursor preCompact cannot block + return a followup, so we
  synchronously mine the transcript and drop a pending_save marker
  that the next stop hook consumes — guarantees verbatim capture
  before context window compression.
- Cursor's default plugin discovery requires real commands/, skills/,
  and mcp.json at the plugin root (verified against the cached
  cloudflare plugin); .cursor-plugin/{commands,skills} are convenience
  symlinks back to those canonical locations.
- bash 3.2 compatibility throughout: avoids heredoc-in-command-
  substitution parser bugs; uses python -c for JSON parsing;
  basename-matched entry recognition in install.sh.
- All changes are additive. No existing files are removed, no
  existing hooks change behaviour, and no new runtime dependencies
  are introduced.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
undeadindustries
2026-05-27 14:05:40 +10:00
co-authored by Cursor
parent 6957c7e599
commit 071fa015e0
31 changed files with 3643 additions and 2 deletions
+118
View File
@@ -0,0 +1,118 @@
# MemPalace Cursor Plugin
A Cursor IDE plugin that gives your agent a persistent memory system. Auto-registers the `mempalace-mcp` server (19 MCP tools), ships 5 slash commands, and provides one model-invocable skill that guides the agent through setup, mining, and search.
> Hooks (auto-save + session-start memory recall) are shipped separately under `hooks/cursor/` so the plugin is safe to install in any Cursor workspace without touching the agent loop. See [Hooks](#hooks-optional) below.
## Prerequisites
- Python 3.9+
- Cursor 1.7+ (plugin manifest schema requires it)
## Installation
### Local clone (recommended while not in the marketplace yet)
Symlink (or copy) this repository into Cursor's local plugins folder:
```bash
ln -s /path/to/mempalace ~/.cursor/plugins/local/mempalace
```
Then in Cursor: <kbd>Cmd</kbd>-<kbd>Shift</kbd>-<kbd>P</kbd> → **Developer: Reload Window**.
### Marketplace
Once published, install via the Cursor marketplace panel and select `mempalace`. Required-plugin distribution from a team marketplace is also supported.
## Post-Install Setup
After installing the plugin, run the `init` command in a Cursor chat:
```
/mempalace-init
```
(Or just say "use the mempalace skill" — Cursor will model-invoke the bundled skill.)
This installs the `mempalace` package via `uv tool` or `pip`, initializes a palace under `~/.mempalace/`, and verifies the MCP server is reachable.
## Available Slash Commands
| Command | Description |
|---------------------|-----------------------------------------------------------------------------------|
| `/mempalace-help` | Show available tools, skills, CLI commands, hooks, and architecture |
| `/mempalace-init` | Set up MemPalace — install, configure, onboard |
| `/mempalace-search` | Search your memories across the palace using semantic search |
| `/mempalace-mine` | Mine projects and conversations into the palace |
| `/mempalace-status` | Show palace overview — wings, rooms, drawer counts |
> Cursor commands are global, not plugin-namespaced — that's why each slug is prefixed with `mempalace-` rather than appearing as `/help`, `/init`, etc. This keeps them collision-free with built-in or other-plugin commands.
## MCP Server
This plugin ships `mcp.json` at the plugin root, so Cursor auto-loads the `mempalace-mcp` server on plugin install:
```json
{
"mempalace": {
"command": "mempalace-mcp"
}
}
```
All 19 MemPalace MCP tools (`mempalace_search`, `mempalace_add_drawer`, `mempalace_diary_write`, `mempalace_check_duplicate`, `mempalace_diary_read`, …) become available to the agent immediately. No manual `~/.cursor/mcp.json` edit required.
If the server doesn't appear, confirm `mempalace-mcp` is on the user `$PATH`:
```bash
command -v mempalace-mcp
```
If it isn't, run `/init` (or `mempalace install` from a terminal) — `mempalace-mcp` is installed alongside the `mempalace` package.
## Hooks (optional)
Cursor's hooks system is configured separately from plugins (in `~/.cursor/hooks.json` or `.cursor/hooks.json`), so this plugin does **not** wire hooks itself. The MemPalace repository ships three Cursor-native hooks under [`hooks/cursor/`](../hooks/cursor/) that you install with one command.
User scope — writes `~/.cursor/hooks.json`, applies to every Cursor workspace (recommended):
```bash
hooks/cursor/install.sh --scope user --variant full
```
Project scope — writes `.cursor/hooks.json` under the current project only:
```bash
hooks/cursor/install.sh --scope project --variant full
```
What you get:
| Hook event | What it does |
|----------------|-------------------------------------------------------------------------------------------------------|
| `sessionStart` | Injects an `additional_context` recap of relevant memories scoped to the workspace wing |
| `stop` | Counts agent turns; every N turns, emits a `followup_message` instructing a memory checkpoint |
| `preCompact` | Synchronously mines the transcript before compaction, drops a marker so the next `stop` saves a diary |
Full details: [`website/guide/cursor-hooks.md`](../website/guide/cursor-hooks.md) and [`hooks/cursor/README.md`](../hooks/cursor/README.md).
## Uninstall
Remove the local plugin symlink:
```bash
rm ~/.cursor/plugins/local/mempalace
```
Then in Cursor: <kbd>Cmd</kbd>-<kbd>Shift</kbd>-<kbd>P</kbd> → **Developer: Reload Window**.
If you also installed the hooks, remove them (leaves any unrelated hooks in `hooks.json` untouched):
```bash
hooks/cursor/install.sh --scope user --uninstall
```
## Full Documentation
See the main [README](../README.md) for complete documentation, architecture details, and advanced usage.
+1
View File
@@ -0,0 +1 @@
../commands
+18
View File
@@ -0,0 +1,18 @@
{
"name": "mempalace",
"owner": {
"name": "milla-jovovich",
"url": "https://github.com/MemPalace"
},
"plugins": [
{
"name": "mempalace",
"source": ".",
"description": "AI memory system — mine projects and conversations into a searchable palace. 19 MCP tools, slash commands, and a guided skill for Cursor.",
"version": "3.3.6",
"author": {
"name": "milla-jovovich"
}
}
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"mcpServers": {
"mempalace": {
"command": "mempalace-mcp"
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "mempalace",
"version": "3.3.6",
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, slash commands, and a guided skill for Cursor.",
"author": {
"name": "milla-jovovich"
},
"homepage": "https://github.com/MemPalace/mempalace",
"repository": "https://github.com/MemPalace/mempalace",
"license": "MIT",
"keywords": [
"memory",
"ai",
"rag",
"mcp",
"chromadb",
"palace",
"search"
]
}
+1
View File
@@ -0,0 +1 @@
../skills
+10
View File
@@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
---
## [Unreleased]
### Added
- **Cursor IDE plugin (`.cursor-plugin/`).** Drops into `~/.cursor/plugins/local/mempalace` (or installs from the Cursor marketplace once published) and auto-registers the `mempalace-mcp` server, five slash commands (`/mempalace-help`, `/mempalace-init`, `/mempalace-mine`, `/mempalace-search`, `/mempalace-status`), and the model-invocable [`mempalace` skill](.cursor-plugin/skills/mempalace/SKILL.md) — no manual `~/.cursor/mcp.json` edit required. Plugin manifest pinned to package version 3.3.6 (test enforces match against `mempalace.version.__version__` so the two never drift). Mirrors the surface of [`.claude-plugin/`](.claude-plugin/) and [`.codex-plugin/`](.codex-plugin/) without duplicating their hook scripts: the Cursor hook scripts under [`hooks/cursor/`](hooks/cursor/) (shipped in the same release) remain the canonical install path for `stop` / `preCompact` / `sessionStart`, wired separately by [`hooks/cursor/install.sh`](hooks/cursor/install.sh). Contract tests in [`tests/test_cursor_plugin_manifest.py`](tests/test_cursor_plugin_manifest.py) cover manifest JSON validity, kebab-case naming, `..`-free relative paths, on-disk path resolution, marketplace alignment, MCP config shape (`mcpServers` wrapper required by Cursor, unlike Claude's flat `.mcp.json`), and every skill/command frontmatter — 32 tests, all pure file inspection so they run on any CI platform without Cursor itself.
- **Cursor IDE hook support (`stop` / `preCompact` / `sessionStart`).** Three new bash hooks live under [`hooks/cursor/`](hooks/cursor/) and share a `lib/common.sh` helpers module. The save hook counts `stop` invocations per Cursor `conversation_id` and emits a `followup_message` every `MEMPAL_SAVE_INTERVAL` (default 15) so the agent files the session into MemPalace and writes a diary entry. The precompact hook synchronously mines the transcript before Cursor's compaction summarises it and drops a marker so the next `stop` forces a save nudge (Cursor's `preCompact` is observational-only — it cannot block or emit a `followup_message`, unlike Claude Code's `PreCompact`). The wake hook is Cursor-only: `sessionStart` returns `additional_context` telling the agent to recall scoped to the wing inferred from the workspace root. Honours the same `MEMPALACE_HOOKS_AUTO_SAVE=false` kill switch as the Claude Code hooks, plus a new `MEMPAL_DISABLE_HOOK=1` alias and a `MEMPAL_STATE_DIR` env override. Includes an opt-in installer at [`hooks/cursor/install.sh`](hooks/cursor/install.sh) with `--scope user|project`, `--variant full|minimal`, `--dry-run`, and `--uninstall` (idempotent, preserves unrelated hooks via `python3`-based JSON merge — no `jq` dependency). Example wirings live at [`examples/cursor/hooks.json`](examples/cursor/hooks.json) and [`examples/cursor/hooks.minimal.json`](examples/cursor/hooks.minimal.json); they are intentionally not placed at the repo root because Cursor auto-loads project hooks from any trusted workspace and we do not arm hooks on contributor checkout. Per-event stdin/stdout schema documented at [`hooks/cursor/STDIN_SHAPE.md`](hooks/cursor/STDIN_SHAPE.md). Walkthrough at [`website/guide/cursor-hooks.md`](website/guide/cursor-hooks.md). Coverage added in [`tests/test_cursor_hooks_shell.py`](tests/test_cursor_hooks_shell.py) and [`tests/test_cursor_hooks_install.py`](tests/test_cursor_hooks_install.py).
---
## [3.3.6] — 2026-05-24
### Features
+8 -2
View File
@@ -165,8 +165,14 @@ system prompt:
## Auto-save hooks
Two Claude Code hooks save periodically and before context compression:
[mempalaceofficial.com/guide/hooks](https://mempalaceofficial.com/guide/hooks.html).
Auto-save hooks for **Claude Code, Codex CLI, and Cursor IDE** save
periodically and before context compression:
- Claude Code + Codex →
[mempalaceofficial.com/guide/hooks](https://mempalaceofficial.com/guide/hooks.html)
- Cursor IDE (adds session-start recall and a transcript snapshot before
compaction) →
[mempalaceofficial.com/guide/cursor-hooks](https://mempalaceofficial.com/guide/cursor-hooks.html)
If you are installing under time pressure, start with the
[Claude Code retention setup checklist](https://mempalaceofficial.com/guide/claude-code-retention.html):
+7
View File
@@ -0,0 +1,7 @@
---
description: Show comprehensive MemPalace help — available skills, MCP tools, CLI commands, hooks, and architecture.
---
Invoke the `mempalace` skill from this plugin and run the `help` instructions, then follow them.
Concretely: run `mempalace instructions help` in a terminal, then carry out the steps it prints.
+12
View File
@@ -0,0 +1,12 @@
---
description: Set up MemPalace — install the package, initialize a palace, register the MCP server with Cursor, and verify everything works.
---
Invoke the `mempalace` skill from this plugin and run the `init` instructions, then follow them.
Concretely: run `mempalace instructions init` in a terminal, then carry out the steps it prints.
Cursor-specific extras after init:
1. The `mempalace-mcp` server is already auto-registered by this plugin — no manual `mcp.json` edit needed.
2. For automatic background saves and session-start memory recall, also run `hooks/cursor/install.sh --scope user` from a cloned MemPalace repo. See `website/guide/cursor-hooks.md` for the walkthrough.
+7
View File
@@ -0,0 +1,7 @@
---
description: Mine projects and conversations into the MemPalace. Supports project files, conversation exports, and auto-classification.
---
Invoke the `mempalace` skill from this plugin and run the `mine` instructions, then follow them.
Concretely: run `mempalace instructions mine` in a terminal, then carry out the steps it prints.
+7
View File
@@ -0,0 +1,7 @@
---
description: Search your memories across the MemPalace using semantic search with wing/room filtering.
---
Invoke the `mempalace` skill from this plugin and run the `search` instructions, then follow them.
Concretely: run `mempalace instructions search` in a terminal, then carry out the steps it prints. The MCP tool `mempalace_search` is also available directly from this Cursor session.
+7
View File
@@ -0,0 +1,7 @@
---
description: Show the current state of your memory palace — wings, rooms, drawer counts, and suggestions.
---
Invoke the `mempalace` skill from this plugin and run the `status` instructions, then follow them.
Concretely: run `mempalace instructions status` in a terminal, then carry out the steps it prints.
+110
View File
@@ -0,0 +1,110 @@
# Cursor IDE Hooks — Example `hooks.json` Files
Sample configurations for wiring the MemPalace Cursor hooks into the
Cursor IDE. These are **examples only** — they are intentionally not
placed at the repo root (`/.cursor/hooks.json`) because Cursor
auto-loads project-level hooks from any trusted workspace, and the
repo is regularly opened by contributors. We do not auto-arm hooks on
contributor checkout.
## Variants
### `hooks.json` — full (recommended)
Three hooks wired:
- **`sessionStart`** — calls `mempal_wake_hook_cursor.sh`, which returns
`additional_context` telling the agent to recall scoped to the wing
inferred from the workspace root. Cursor-only — Claude Code has no
equivalent.
- **`stop`** — calls `mempal_save_hook_cursor.sh`. Counts stop
invocations per conversation and emits a `followup_message` every
`MEMPAL_SAVE_INTERVAL` (default 15) telling the agent to file the
session into the palace and write a diary entry. `loop_limit: 1` is
defense-in-depth on top of our own loop-count check.
- **`preCompact`** — calls `mempal_precompact_hook_cursor.sh`. Runs
`mempalace mine` synchronously on the transcript before compaction
summarises it, then drops a marker so the next `stop` forces a save
followup.
### `hooks.minimal.json` — `stop` only
Lightest install. Wires just the save hook. Use this if you don't
want the sessionStart recall context or the preCompact transcript
snapshot.
## How to use
The `$HOME` placeholder is **not** expanded by Cursor — you must
substitute the absolute path before saving the file. Pick one:
### Option A — let `install.sh` do it
Project scope — writes `<repo>/.cursor/hooks.json`:
```bash
hooks/cursor/install.sh --scope project --target /path/to/your/repo
```
User scope — writes `~/.cursor/hooks.json`, applies to every Cursor workspace:
```bash
hooks/cursor/install.sh --scope user
```
The installer copies the hook scripts to `~/.mempalace/hooks/cursor/`,
substitutes the absolute paths, and merges the entries into your
existing `hooks.json` without clobbering unrelated hooks. See
`install.sh --help` for `--dry-run`, `--uninstall`, and `--variant`.
### Option B — copy + edit manually
1. Copy the chosen example to the target location:
- User scope: `~/.cursor/hooks.json`
- Project scope: `<your-repo>/.cursor/hooks.json`
2. Replace every `$HOME` with the absolute path to your home
directory (e.g., `/Users/you` or `/home/you`).
3. Make sure each hook script is executable
(`chmod +x ~/.mempalace/hooks/cursor/mempal_*_hook_cursor.sh`).
4. Restart Cursor, or wait for it to auto-reload the file.
## Why aren't these files at the repo root?
Cursor automatically loads `.cursor/hooks.json` from any trusted
workspace. Placing a real `hooks.json` at the repo root would arm
MemPalace's hooks on every contributor's machine the moment they open
the repo in Cursor — which would modify their conversation behaviour
without consent and write to `~/.mempalace/hook_state/` without
asking. Editor configuration is sacred; opt-in only.
If you actually want MemPalace's hooks armed when working on the
MemPalace repo itself, run:
```bash
hooks/cursor/install.sh --scope project --target .
```
That will write `./.cursor/hooks.json` for the repo workspace
specifically — but it is your decision, not ours, and the file is
listed in `.gitignore` paths Cursor users typically already exclude.
## Related: the Cursor plugin
The hooks here are **only one half** of MemPalace's Cursor integration. The other half is the [`.cursor-plugin/`](../../.cursor-plugin/) folder at the repo root, which packages MemPalace's MCP server, five slash commands, and the model-invocable `mempalace` skill as a regular Cursor plugin you can drop into `~/.cursor/plugins/local/mempalace`.
The two install paths are orthogonal — install whichever you want, in any order:
| You want | Install |
|---------------------------------------------------------------------------|----------------------------------------------------------|
| MCP tools (`mempalace_search`, `mempalace_add_drawer`, …) + slash commands | The plugin — see [`.cursor-plugin/README.md`](../../.cursor-plugin/README.md) |
| Auto-save every N turns + sessionStart memory recall | The hooks here — see Option A above |
| Both | Install the plugin AND run `hooks/cursor/install.sh` |
Hooks are deliberately **not** bundled into the plugin because Cursor's hooks system is configured per-user/per-project (in `~/.cursor/hooks.json` or `.cursor/hooks.json`), not per-plugin — so the installer here owns that file with idempotent merge semantics, while the plugin owns the MCP+commands+skill side.
## See also
- [`hooks/cursor/README.md`](../../hooks/cursor/README.md) — full reference for hooks
- [`hooks/cursor/STDIN_SHAPE.md`](../../hooks/cursor/STDIN_SHAPE.md) — per-event schema with citations
- [`website/guide/cursor-hooks.md`](../../website/guide/cursor-hooks.md) — rendered docs
- [`.cursor-plugin/README.md`](../../.cursor-plugin/README.md) — Cursor plugin (MCP + commands + skill)
+21
View File
@@ -0,0 +1,21 @@
{
"version": 1,
"hooks": {
"sessionStart": [
{
"command": "$HOME/.mempalace/hooks/cursor/mempal_wake_hook_cursor.sh"
}
],
"stop": [
{
"command": "$HOME/.mempalace/hooks/cursor/mempal_save_hook_cursor.sh",
"loop_limit": 1
}
],
"preCompact": [
{
"command": "$HOME/.mempalace/hooks/cursor/mempal_precompact_hook_cursor.sh"
}
]
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": 1,
"hooks": {
"stop": [
{
"command": "$HOME/.mempalace/hooks/cursor/mempal_save_hook_cursor.sh",
"loop_limit": 1
}
]
}
}
+7
View File
@@ -2,6 +2,13 @@
These hook scripts make MemPalace save automatically. No manual "save" commands needed.
This file covers the **Claude Code** and **Codex CLI** hooks that live
flat under `hooks/`. For the **Cursor IDE** hooks, see
[`hooks/cursor/README.md`](cursor/README.md) or the rendered docs at
[`website/guide/cursor-hooks.md`](../website/guide/cursor-hooks.md). The
two are additive and share the same `~/.mempalace/hook_state/`
directory.
If you are trying to protect existing Claude Code transcripts immediately,
use the short checklist first: [`website/guide/claude-code-retention.md`](../website/guide/claude-code-retention.md).
It covers hook wiring, JSONL backup, and one-time backfill.
+133
View File
@@ -0,0 +1,133 @@
# MemPalace Cursor IDE Hooks
Auto-save and session-recall hooks for the [Cursor](https://cursor.com) IDE,
matching the behaviour of the existing Claude Code + Codex hooks at the repo
root and adding two Cursor-only capabilities (`sessionStart` recall and a
preCompact transcript snapshot).
For the rendered documentation see
[`website/guide/cursor-hooks.md`](../../website/guide/cursor-hooks.md) or
the published version at
[mempalaceofficial.com/guide/cursor-hooks](https://mempalaceofficial.com/guide/cursor-hooks.html).
## What's here
| File | Role |
|-------------------------------------|---------------------------------------------------------------------|
| `lib/common.sh` | Shared bash helpers (parse, log, counter, wing inference, kill switch). Sourced by all three hooks. |
| `mempal_save_hook_cursor.sh` | Cursor `stop` hook. Counts stop invocations per conversation, emits a `followup_message` every `SAVE_INTERVAL` (default 15) telling the agent to file the session into MemPalace. |
| `mempal_precompact_hook_cursor.sh` | Cursor `preCompact` hook. Runs `mempalace mine` synchronously on the transcript before compaction, then drops a `.pending` marker so the next stop forces a save nudge. |
| `mempal_wake_hook_cursor.sh` | Cursor `sessionStart` hook. Returns `additional_context` telling the agent to recall scoped to the wing inferred from the workspace root. Cursor-only — Claude Code has no equivalent. |
| `install.sh` | Optional installer. Copies the scripts to `~/.mempalace/hooks/cursor/` and merges entries into `~/.cursor/hooks.json` (or `.cursor/hooks.json` for project scope). Supports `--dry-run` and `--uninstall`. |
| `STDIN_SHAPE.md` | Reference. Per-event stdin / stdout schema with citations to the official Cursor docs. |
## Quick install
Preview first (writes nothing, prints the would-be JSON to stdout):
```bash
hooks/cursor/install.sh --scope user --dry-run
```
Apply — writes `~/.cursor/hooks.json` and copies the scripts to `~/.mempalace/hooks/cursor/`:
```bash
hooks/cursor/install.sh --scope user
```
Pass `--scope project --target <repo>` to write `<repo>/.cursor/hooks.json` instead.
The installer never auto-runs — it is a documented opt-in step. We do not
modify your Cursor config on `pip install mempalace` because editor config
is sacred and should never be touched without explicit consent.
## Manual install (no installer)
The minimum wiring is `stop` only. Add to `~/.cursor/hooks.json`:
```json
{
"version": 1,
"hooks": {
"stop": [
{
"command": "/absolute/path/to/hooks/cursor/mempal_save_hook_cursor.sh",
"loop_limit": 1
}
]
}
}
```
For the full triple (recommended), also wire `sessionStart` and `preCompact`
— see [`examples/cursor/hooks.json`](../../examples/cursor/hooks.json).
After editing the file, Cursor watches `hooks.json` and reloads
automatically. If hooks still do not fire, restart Cursor and check the
Hooks panel in Settings.
## Configuration
All knobs are env vars; defaults match the Claude Code hooks where
possible so a single hook-state directory works for both editors.
| Variable | Default | Purpose |
|--------------------------------|------------------------------------|---------|
| `MEMPAL_SAVE_INTERVAL` | `15` | Number of `stop` events between save followups. |
| `MEMPAL_DIR` | (unset) | Optional project directory to also mine on each save. Additive — never replaces the transcript mine. |
| `MEMPAL_PYTHON` | auto-detected | Path to a Python 3 interpreter. Fallback order: `$MEMPAL_PYTHON` → `command -v python3` → bare `python3`. Useful when Cursor is launched from a GUI on macOS and the inherited PATH lacks your installed `python3`. |
| `MEMPAL_STATE_DIR` | `$HOME/.mempalace/hook_state` | Where the hook keeps its per-conversation counter files, pending-save markers, and `cursor_hook.log`. |
| `MEMPAL_DISABLE_HOOK` | (unset) | Set to `1`/`true`/`yes` to disable all three hooks. Emergency kill switch. |
| `MEMPALACE_HOOKS_AUTO_SAVE` | (unset) | Set to `false`/`0`/`no` to disable. Same semantics as the Claude Code hooks. Also honoured via `~/.mempalace/config.json` → `{"hooks": {"auto_save": false}}`. |
## Debugging
Everything appends to:
```bash
cat ~/.mempalace/hook_state/cursor_hook.log
```
Example log lines (ISO 8601 + event + conversation id):
```
[2026-05-27T02:16:01Z] [event=sessionStart] [conv=abc123] workspace=/Users/me/proj wing=proj
[2026-05-27T02:21:33Z] [event=stop] [conv=abc123] counter 0 -> 1 (interval=15)
[2026-05-27T02:42:09Z] [event=stop] [conv=abc123] counter 14 -> 15 (interval=15)
[2026-05-27T02:42:09Z] [event=stop] [conv=abc123] TRIGGERING SAVE at counter=15
[2026-05-27T02:42:11Z] [event=stop] [conv=abc123] loop_count>0; letting agent stop
```
When a hook can't parse its stdin (corrupt payload, future Cursor schema
change), the raw input (capped at 4096 bytes, mode 0600) lands at:
```
~/.mempalace/hook_state/cursor_last_input.log
~/.mempalace/hook_state/cursor_last_python_err.log
```
These are overwritten on each failure, never appended, so a repeating
misconfiguration cannot grow disk usage.
## What differs from the Claude Code hooks
| Aspect | Claude Code hooks (`hooks/mempal_*.sh`) | Cursor hooks (`hooks/cursor/*.sh`) |
|-------------------------|------------------------------------------------|-----------------------------------------------------|
| Counter key | `session_id` | `conversation_id` (Cursor's stable per-conv id) |
| Loop guard | `stop_hook_active` flag in stdin | `loop_count` field in stdin |
| Counting method | Parses JSONL transcript for user messages | Counts `stop` invocations (transcript schema undoc) |
| PreCompact behaviour | `decision: block` forces save before compaction | Pre-mine + pending-save marker (Cursor preCompact is observational-only) |
| sessionStart | n/a (Claude Code has no equivalent) | `additional_context` injects recall guidance |
| State dir | `$HOME/.mempalace/hook_state` (hardcoded) | Same default, plus `MEMPAL_STATE_DIR` env override |
| Kill switch | `MEMPALACE_HOOKS_AUTO_SAVE=false` | Same, plus `MEMPAL_DISABLE_HOOK=1` alias |
| Log file | `hook.log` | `cursor_hook.log` (kept separate to avoid cross-tool log churn) |
See [`STDIN_SHAPE.md`](STDIN_SHAPE.md) for the per-event schema and
[`website/guide/cursor-hooks.md`](../../website/guide/cursor-hooks.md) for
the full walkthrough with diagrams.
## Cost
Zero extra tokens. The hooks are local bash scripts that run on your machine.
The followup message the save hook emits is a normal user turn — it counts
the same as any other user message and does not invoke any extra LLM call
beyond the one the user would otherwise make.
+184
View File
@@ -0,0 +1,184 @@
# Cursor Hook Stdin Shape — Reference
This file documents the JSON payloads the Cursor IDE sends to the
MemPalace hook scripts in `hooks/cursor/`. It exists so a future
contributor does not have to re-discover the schema by writing a
probe hook.
**Source:** [`cursor.com/docs/hooks.md`](https://cursor.com/docs/hooks.md),
fetched 2026-05-27. Cursor's hook system is documented as a stable
v1 schema (`{"version": 1, ...}` at the top of `hooks.json`).
If you suspect Cursor has changed the payload shape since that fetch
date, re-verify against the upstream docs and update both this file
and `hooks/cursor/lib/common.sh::mempal_parse_stdin`. The hook
scripts deliberately ignore fields they do not consume, so adding
new fields is non-breaking.
## Common fields (all events)
Every hook receives these on stdin in addition to its event-specific
fields. Source: docs section "Common schema → Input (all hooks)".
```json
{
"conversation_id": "string",
"generation_id": "string",
"model": "string",
"hook_event_name": "string",
"cursor_version": "string",
"workspace_roots": ["<absolute path>"],
"user_email": "string | null",
"transcript_path": "string | null"
}
```
**Field notes (verified):**
- `conversation_id` is the stable per-conversation ID. The Cursor
`stop` event does **not** carry a `session_id` — only
`conversation_id`. MemPalace keys its counter files on this. Cursor
`sessionStart` does carry a `session_id`, and the docs note it is
"same as `conversation_id`".
- `generation_id` changes every user message. We do not use it.
- `transcript_path` may be `null` if the user has disabled
transcripts in Cursor settings. The hooks degrade gracefully when
the value is empty.
- `workspace_roots` is normally a single-entry array but multi-root
workspaces are supported; MemPalace uses index `[0]`.
## Event-specific fields
### `stop` (consumed by `mempal_save_hook_cursor.sh`)
```json
{
"status": "completed" | "aborted" | "error",
"loop_count": 0
}
```
- `loop_count` indicates how many times this stop hook has already
triggered an automatic followup for this conversation (starts at
0). When `loop_count > 0` we know our own previous `followup_message`
is currently being processed — the save hook returns `{}` so the
agent can finish. Equivalent to Claude Code's `stop_hook_active`.
- The per-script `loop_limit` (default 5 for Cursor hooks, configurable
via the `loop_limit` field on the hook entry in `hooks.json`) is
defense-in-depth on top of our own check. The example `hooks.json`
in `examples/cursor/` sets `loop_limit: 1`.
**Allowed output fields** (only):
```json
{ "followup_message": "<text to auto-submit as next user turn>" }
```
### `preCompact` (consumed by `mempal_precompact_hook_cursor.sh`)
```json
{
"trigger": "auto" | "manual",
"context_usage_percent": 85,
"context_tokens": 120000,
"context_window_size": 128000,
"message_count": 45,
"messages_to_compact": 30,
"is_first_compaction": true
}
```
**Critical constraint:** preCompact is documented as **observational
only**. It cannot block compaction and its allowed output fields are
limited to:
```json
{ "user_message": "<short message shown to the user when compaction occurs>" }
```
There is **no** `followup_message` and **no** `decision: block` on
this event — unlike Claude Code's `PreCompact`. MemPalace works
around this by:
1. Running `mempalace mine` synchronously inside the hook so the
verbatim transcript lands in the palace before compaction
summarises it.
2. Dropping a `cursor_<conversation_id>.pending` marker that the next
`stop` invocation reads and uses to force a save followup
regardless of its counter.
### `sessionStart` (consumed by `mempal_wake_hook_cursor.sh`)
```json
{
"session_id": "<unique session identifier>",
"is_background_agent": true,
"composer_mode": "agent" | "ask" | "edit"
}
```
`session_id` equals `conversation_id` on this event (docs are
explicit about this).
**Allowed output fields:**
```json
{
"env": { "<key>": "<value>" },
"additional_context": "<text added to conversation's initial system context>"
}
```
`additional_context` is the field MemPalace uses. The schema also
accepts `continue` and `user_message` but the docs explicitly note
"current callers do not enforce them; session creation is not
blocked even when continue is false". We do not emit either.
## Environment variables (all hooks)
Cursor sets these env vars on every hook execution; the hook scripts
fall back to them when JSON parsing fails for any reason.
| Variable | Description |
|---------------------------|---------------------------------------------------|
| `CURSOR_PROJECT_DIR` | Workspace root (= `workspace_roots[0]`) |
| `CURSOR_VERSION` | Cursor version string |
| `CURSOR_USER_EMAIL` | Authenticated user email (if logged in) |
| `CURSOR_TRANSCRIPT_PATH` | Conversation transcript path (if transcripts on) |
| `CURSOR_CODE_REMOTE` | `"true"` if running in a remote workspace |
| `CLAUDE_PROJECT_DIR` | Alias for `CURSOR_PROJECT_DIR` (Claude compat) |
## Exit code semantics
Cursor interprets command-hook exit codes as follows
(docs "Hook Types → Command-Based Hooks → Exit code behavior"):
- `0` — success, use the JSON output.
- `2` — block the action (equivalent to `permission: "deny"`).
- Other — hook failed; action proceeds (fail-open by default).
MemPalace hooks always exit `0` and emit either `{}` (no-op) or a
valid JSON response. We never use exit code `2`; nothing MemPalace
does should ever block an agent action.
## Working directory contract
- **User hooks** (`~/.cursor/hooks.json`) run from `~/.cursor/`.
- **Project hooks** (`.cursor/hooks.json`) run from the project root.
The MemPalace hooks always resolve their sibling `lib/common.sh` via
`BASH_SOURCE[0]` so the working directory does not matter for the
script's own loading — only the `command` path in `hooks.json` needs
to point at the absolute location of the script.
## Transcript file format (out of scope)
The format of the file at `transcript_path` is **not documented by
Cursor** as of the fetch date above. MemPalace deliberately does not
parse it: the save hook counts `stop` invocations (each one
corresponds to one assistant turn) and hands the transcript to
`mempalace mine`, which has its own normaliser layer.
If you need to consume the transcript directly, probe its shape with
a throw-away hook that does `cat > /tmp/cursor-transcript-sample.txt`
and inspect the output — there is no shortcut.
+391
View File
@@ -0,0 +1,391 @@
#!/bin/bash
# MEMPALACE CURSOR HOOK INSTALLER
#
# Optional helper. Copies the three Cursor hook scripts to a
# stable install location and merges entries into a Cursor
# `hooks.json` config file — without clobbering unrelated hooks
# already in that file.
#
# This is NEVER auto-invoked. Editor config is sacred; we do not
# modify a user's hooks.json without explicit consent. The user runs
# this script (or wires the hooks manually) as a documented opt-in.
#
# === USAGE ===
#
# hooks/cursor/install.sh [options]
#
# Options:
# --scope user|project Target scope. Default: user.
# - user: merges into ~/.cursor/hooks.json
# - project: merges into <target>/.cursor/hooks.json
# --target <path> Project root for --scope project (default: $PWD).
# Ignored for --scope user.
# --install-dir <path> Where to copy the hook scripts.
# Default: ~/.mempalace/hooks/cursor
# --variant full|minimal Which hook set to wire.
# - full: stop + preCompact + sessionStart
# - minimal: stop only
# Default: full.
# --dry-run Print the would-be JSON to stdout, do not write
# and do not copy scripts.
# --uninstall Remove MemPalace entries from the target
# hooks.json (preserves unrelated hooks).
# Does NOT delete the installed scripts.
# -h, --help Show this help and exit.
#
# === PORTABILITY ===
#
# Pure bash 3.2 + POSIX tools + python3 (which the hook scripts
# themselves already require). No `jq` dependency.
#
# Python helpers are materialised to temp files rather than piped via
# `$(... <<'PYEOF' ... PYEOF)` to dodge the bash 3.2.57 parser bug
# that trips on parens nested inside a heredoc body that lives inside
# a `$(...)` command substitution.
set -e
usage() {
sed -n '2,38p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
}
# ── Defaults ──────────────────────────────────────────────────────
SCOPE="user"
TARGET=""
INSTALL_DIR="$HOME/.mempalace/hooks/cursor"
VARIANT="full"
DRY_RUN=0
UNINSTALL=0
# ── Parse args ────────────────────────────────────────────────────
while [ $# -gt 0 ]; do
case "$1" in
--scope)
shift
SCOPE="${1:-}"
;;
--target)
shift
TARGET="${1:-}"
;;
--install-dir)
shift
INSTALL_DIR="${1:-}"
;;
--variant)
shift
VARIANT="${1:-}"
;;
--dry-run) DRY_RUN=1 ;;
--uninstall) UNINSTALL=1 ;;
-h|--help)
usage
exit 0
;;
*)
printf 'install.sh: unknown argument: %s\n' "$1" >&2
usage >&2
exit 64
;;
esac
shift || true
done
case "$SCOPE" in
user|project) ;;
*)
printf 'install.sh: --scope must be "user" or "project" (got "%s")\n' \
"$SCOPE" >&2
exit 64
;;
esac
case "$VARIANT" in
full|minimal) ;;
*)
printf 'install.sh: --variant must be "full" or "minimal" (got "%s")\n' \
"$VARIANT" >&2
exit 64
;;
esac
# ── Resolve paths ─────────────────────────────────────────────────
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
SOURCE_DIR="$_script_dir"
# Resolve the Python interpreter the same way the hooks themselves do
# so a user with a non-default Python is consistent across install +
# runtime.
if [ -n "${MEMPAL_PYTHON:-}" ] && [ -x "$MEMPAL_PYTHON" ]; then
PYTHON_BIN="$MEMPAL_PYTHON"
else
PYTHON_BIN="$(command -v python3 2>/dev/null || true)"
fi
if [ -z "$PYTHON_BIN" ]; then
printf 'install.sh: python3 not found on PATH; cannot proceed.\n' >&2
printf 'Set $MEMPAL_PYTHON to an interpreter path or install python3.\n' >&2
exit 1
fi
# Determine the target hooks.json path.
case "$SCOPE" in
user)
if [ -n "$TARGET" ]; then
printf 'install.sh: --target is only meaningful with --scope project; ignoring.\n' >&2
fi
TARGET_DIR="$HOME/.cursor"
;;
project)
TARGET_DIR="${TARGET:-$PWD}/.cursor"
;;
esac
TARGET_FILE="$TARGET_DIR/hooks.json"
# Determine which commands the merge / uninstall logic should
# install or remove. Paths point at the install location, NOT the
# source repo — once the user runs install.sh they can move / delete
# the cloned repo without breaking the wiring.
SAVE_CMD="$INSTALL_DIR/mempal_save_hook_cursor.sh"
PRECOMPACT_CMD="$INSTALL_DIR/mempal_precompact_hook_cursor.sh"
WAKE_CMD="$INSTALL_DIR/mempal_wake_hook_cursor.sh"
# ── Step 1: copy scripts (skipped on --dry-run / --uninstall) ─────
if [ "$UNINSTALL" -eq 0 ] && [ "$DRY_RUN" -eq 0 ]; then
mkdir -p "$INSTALL_DIR/lib"
cp "$SOURCE_DIR/lib/common.sh" "$INSTALL_DIR/lib/common.sh"
cp "$SOURCE_DIR/mempal_save_hook_cursor.sh" "$INSTALL_DIR/"
cp "$SOURCE_DIR/mempal_precompact_hook_cursor.sh" "$INSTALL_DIR/"
cp "$SOURCE_DIR/mempal_wake_hook_cursor.sh" "$INSTALL_DIR/"
chmod +x "$INSTALL_DIR/mempal_save_hook_cursor.sh" \
"$INSTALL_DIR/mempal_precompact_hook_cursor.sh" \
"$INSTALL_DIR/mempal_wake_hook_cursor.sh"
printf 'install.sh: copied scripts to %s\n' "$INSTALL_DIR" >&2
fi
# ── Short-circuit: uninstall with no existing file is a no-op ─────
#
# Without this, the merge step would happily write an empty
# {"version": 1, "hooks": {}} to a brand new file that the user
# never asked us to create — surprising behaviour that the test
# suite explicitly guards against.
if [ "$UNINSTALL" -eq 1 ] && [ ! -f "$TARGET_FILE" ]; then
printf 'install.sh: nothing to uninstall (%s does not exist)\n' \
"$TARGET_FILE" >&2
exit 0
fi
# ── Step 2: merge / unmerge hooks.json via python3 ────────────────
#
# Materialise the merge logic to a temp .py file (see bash 3.2
# rationale at the top of this script), then invoke it. The Python
# script is responsible for:
# * tolerating a missing or empty hooks.json (starts from {})
# * preserving unrelated hook entries on install
# * preserving unrelated hook entries on uninstall
# * recognising MemPalace entries by basename in the `command` field
# * idempotent install (re-running does not duplicate entries)
# mktemp portability: BSD mktemp wants the template positional, GNU
# accepts -t with a suffix. The common subset is `mktemp -t prefix`
# (BSD picks up TMPDIR; GNU emits a path under /tmp). Both work.
MERGE_PY="$(mktemp -t mempal-install-merge.XXXXXX)"
trap 'rm -f "$MERGE_PY"' EXIT
cat > "$MERGE_PY" <<'PYEOF'
"""hooks.json merge helper for hooks/cursor/install.sh.
Argv:
sys.argv[1]: path to hooks.json (may not exist)
sys.argv[2]: variant ("full" or "minimal")
sys.argv[3]: uninstall flag ("1" or "0")
sys.argv[4]: save_cmd absolute path
sys.argv[5]: precompact_cmd absolute path
sys.argv[6]: wake_cmd absolute path
Output: prints the merged JSON to stdout. Exits 2 on a malformed
existing config (refuses to overwrite a broken file).
"""
import json
import os
import sys
target_file = sys.argv[1]
variant = sys.argv[2]
uninstall = sys.argv[3] == "1"
save_cmd = sys.argv[4]
precompact_cmd = sys.argv[5]
wake_cmd = sys.argv[6]
# Recognise our entries by basename. The three filenames below are
# the unique product-of-our-naming convention; any entry whose command
# ends in one of them is treated as a MemPalace entry on
# install (so we replace rather than duplicate it) and on uninstall
# (so we remove it without touching unrelated entries). Matching on
# basename rather than a full-path substring lets users pick any
# --install-dir without breaking uninstall.
MEMPAL_BASENAMES = (
"mempal_save_hook_cursor.sh",
"mempal_precompact_hook_cursor.sh",
"mempal_wake_hook_cursor.sh",
)
if os.path.exists(target_file):
with open(target_file, "r", encoding="utf-8") as fh:
try:
cfg = json.load(fh)
except Exception as exc:
sys.stderr.write(
"install.sh: existing %s is not valid JSON: %s\n"
"Refusing to overwrite. Fix the file and retry.\n"
% (target_file, exc)
)
sys.exit(2)
else:
cfg = {}
if not isinstance(cfg, dict):
sys.stderr.write(
"install.sh: %s top level must be a JSON object; got %s\n"
% (target_file, type(cfg).__name__)
)
sys.exit(2)
cfg.setdefault("version", 1)
cfg.setdefault("hooks", {})
if not isinstance(cfg["hooks"], dict):
sys.stderr.write(
"install.sh: %s 'hooks' must be a JSON object\n" % target_file
)
sys.exit(2)
def is_mempal_entry(entry):
if not isinstance(entry, dict):
return False
cmd = entry.get("command", "")
if not isinstance(cmd, str):
return False
# Match on basename so a customised --install-dir (e.g. /opt/...,
# ~/.local/share/..., or anything with or without a leading dot)
# still round-trips through uninstall.
base = os.path.basename(cmd)
return base in MEMPAL_BASENAMES
def filter_mempal(entries):
if not isinstance(entries, list):
return entries
return [e for e in entries if not is_mempal_entry(e)]
def upsert(event, entry):
existing = cfg["hooks"].get(event, [])
if not isinstance(existing, list):
sys.stderr.write(
"install.sh: %s hooks[%s] must be a list\n" % (target_file, event)
)
sys.exit(2)
cleaned = [e for e in existing if not is_mempal_entry(e)]
cleaned.append(entry)
cfg["hooks"][event] = cleaned
if uninstall:
for event in list(cfg["hooks"].keys()):
cfg["hooks"][event] = filter_mempal(cfg["hooks"][event])
if not cfg["hooks"][event]:
del cfg["hooks"][event]
else:
upsert("stop", {"command": save_cmd, "loop_limit": 1})
if variant == "full":
upsert("preCompact", {"command": precompact_cmd})
upsert("sessionStart", {"command": wake_cmd})
# Stable key order for the events MemPalace touches, then preserve
# any unrelated event names in their original order so future Cursor
# events we don't know about yet still round-trip.
known_order = [
"sessionStart",
"stop",
"preCompact",
"sessionEnd",
"preToolUse",
"postToolUse",
"postToolUseFailure",
"subagentStart",
"subagentStop",
"beforeShellExecution",
"afterShellExecution",
"beforeMCPExecution",
"afterMCPExecution",
"beforeReadFile",
"afterFileEdit",
"beforeSubmitPrompt",
"afterAgentResponse",
"afterAgentThought",
"beforeTabFileRead",
"afterTabFileEdit",
"workspaceOpen",
]
ordered_hooks = {}
for event in known_order:
if event in cfg["hooks"]:
ordered_hooks[event] = cfg["hooks"][event]
for event, entries in cfg["hooks"].items():
if event not in ordered_hooks:
ordered_hooks[event] = entries
cfg["hooks"] = ordered_hooks
print(json.dumps(cfg, indent=2, sort_keys=False))
PYEOF
NEW_JSON="$("$PYTHON_BIN" "$MERGE_PY" \
"$TARGET_FILE" "$VARIANT" "$UNINSTALL" \
"$SAVE_CMD" "$PRECOMPACT_CMD" "$WAKE_CMD")"
# ── Step 3: emit, write, or remove ────────────────────────────────
if [ "$DRY_RUN" -eq 1 ]; then
printf 'install.sh: --dry-run; would write to %s\n' "$TARGET_FILE" >&2
printf '%s\n' "$NEW_JSON"
exit 0
fi
mkdir -p "$TARGET_DIR"
# If --uninstall left an empty hooks object AND no other top-level
# keys beyond version, remove the file entirely so the user's
# `.cursor/` directory does not accumulate orphan configs.
if [ "$UNINSTALL" -eq 1 ]; then
EMPTY_CHECK_PY="$(mktemp -t mempal-install-empty.XXXXXX)"
cat > "$EMPTY_CHECK_PY" <<'PYEOF'
"""Returns "1" (non-empty) or "0" (empty) on stdout for use by the
shell caller. 'Empty' means: no hook entries and no top-level keys
other than 'version' / 'hooks'."""
import json
import sys
cfg = json.load(sys.stdin)
hooks = cfg.get("hooks", {})
extras = [k for k in cfg.keys() if k not in ("version", "hooks")]
print("1" if (hooks or extras) else "0")
PYEOF
NON_EMPTY="$(printf '%s' "$NEW_JSON" | "$PYTHON_BIN" "$EMPTY_CHECK_PY")"
rm -f "$EMPTY_CHECK_PY"
if [ "$NON_EMPTY" = "0" ] && [ -f "$TARGET_FILE" ]; then
rm -f "$TARGET_FILE"
printf 'install.sh: removed empty %s\n' "$TARGET_FILE" >&2
exit 0
fi
fi
TMP_FILE="${TARGET_FILE}.tmp.$$"
printf '%s\n' "$NEW_JSON" > "$TMP_FILE"
mv "$TMP_FILE" "$TARGET_FILE"
if [ "$UNINSTALL" -eq 1 ]; then
printf 'install.sh: removed MemPalace entries from %s\n' "$TARGET_FILE" >&2
else
printf 'install.sh: wrote %s\n' "$TARGET_FILE" >&2
printf 'install.sh: restart Cursor (or wait for it to reload hooks.json)\n' >&2
fi
+418
View File
@@ -0,0 +1,418 @@
# shellcheck shell=bash
# MEMPALACE CURSOR HOOK — shared helpers
#
# Sourced by the three Cursor hooks (stop / preCompact / sessionStart).
# Mirrors the conventions of the existing Claude Code hook scripts
# (hooks/mempal_save_hook.sh, hooks/mempal_precompact_hook.sh) so a
# user who already debugs one knows how to debug the other:
#
# * STATE_DIR layout under ~/.mempalace/hook_state/
# * MEMPAL_PYTHON resolution order (override → $PATH → bare python3)
# * MEMPALACE_HOOKS_AUTO_SAVE=false kill switch (config.json fallback)
# * sentinel-guarded Python parser via `sed -n 'Np'` (bash 3.2 safe)
# * fail-open on internal errors: emit `{}` and log, never crash the
# hook host
#
# Cursor-specific additions on top of that contract:
#
# * MEMPAL_DISABLE_HOOK=1 as an additional kill-switch alias
# * MEMPAL_STATE_DIR env override for the state directory
# * conversation_id (Cursor's stable per-conversation ID) replaces
# Claude Code's session_id in the counter file names — Cursor `stop`
# events do not carry a session_id, only conversation_id
# * loop_count is the loop-prevention signal in place of Claude Code's
# stop_hook_active flag (Cursor docs, "stop" event)
#
# This file is sourced, not executed, so it intentionally has no
# shebang. The `# shellcheck shell=bash` directive above tells
# shellcheck to treat it as bash when run standalone.
# ── State directory + log path ────────────────────────────────────────
#
# Honour MEMPAL_STATE_DIR (additive override introduced for Cursor)
# while keeping the default identical to the Claude Code hooks so a
# user running both keeps a single state directory.
MEMPAL_STATE_DIR="${MEMPAL_STATE_DIR:-$HOME/.mempalace/hook_state}"
mkdir -p "$MEMPAL_STATE_DIR" 2>/dev/null
MEMPAL_CURSOR_LOG="$MEMPAL_STATE_DIR/cursor_hook.log"
# ── Python interpreter resolution ─────────────────────────────────────
#
# Same contract as the Claude Code hooks:
# 1. $MEMPAL_PYTHON — explicit user override (absolute path)
# 2. $(command -v python3) — first python3 on the hook's PATH
# 3. bare "python3" — last-resort fallback
mempal_resolve_python() {
local p="${MEMPAL_PYTHON:-}"
if [ -n "$p" ] && [ -x "$p" ]; then
printf '%s' "$p"
return 0
fi
p="$(command -v python3 2>/dev/null || true)"
if [ -n "$p" ]; then
printf '%s' "$p"
return 0
fi
printf '%s' "python3"
}
MEMPAL_PYTHON_BIN="$(mempal_resolve_python)"
# ── Logging ───────────────────────────────────────────────────────────
#
# Lines are `[ISO8601Z] [event=...] [conv=...] message`. ISO8601 keeps
# the format greppable across timezones (the Claude Code log uses
# %H:%M:%S which loses the date — we improve on that here without
# changing the existing log file).
mempal_log() {
local event="${1:-?}"
local conv="${2:-unknown}"
local msg="${3:-}"
local ts
ts="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
printf '[%s] [event=%s] [conv=%s] %s\n' "$ts" "$event" "$conv" "$msg" \
>> "$MEMPAL_CURSOR_LOG" 2>/dev/null
}
# ── Kill switch ───────────────────────────────────────────────────────
#
# Disabled if ANY of:
# * MEMPAL_DISABLE_HOOK is a truthy string (Cursor-prompt addition)
# * MEMPALACE_HOOKS_AUTO_SAVE is false/0/no (Claude Code convention)
# * ~/.mempalace/config.json has hooks.auto_save == false
#
# Returns 0 (true in shell) when disabled, 1 when enabled.
mempal_is_disabled() {
case "${MEMPAL_DISABLE_HOOK:-}" in
1|true|yes|on) return 0 ;;
esac
case "${MEMPALACE_HOOKS_AUTO_SAVE:-}" in
false|0|no|off) return 0 ;;
esac
local cfg="$HOME/.mempalace/config.json"
if [ -f "$cfg" ]; then
local result
result="$("$MEMPAL_PYTHON_BIN" - "$cfg" <<'PYEOF' 2>/dev/null
import json, sys
try:
with open(sys.argv[1]) as f:
cfg = json.load(f)
print(str(cfg.get("hooks", {}).get("auto_save", True)).lower())
except Exception:
print("true")
PYEOF
)"
if [ "$result" = "false" ]; then
return 0
fi
fi
return 1
}
# ── Stdin parser ──────────────────────────────────────────────────────
#
# Reads Cursor's hook JSON from $1 and exports:
# MEMPAL_CONV_ID — conversation_id, falls back to "unknown"
# MEMPAL_LOOP_COUNT — integer (0 if absent / non-numeric)
# MEMPAL_TRANSCRIPT — transcript_path, may be empty
# MEMPAL_WORKSPACE — first workspace_roots entry, falls back to
# CURSOR_PROJECT_DIR env var, then $PWD
# MEMPAL_TRIGGER — preCompact trigger ("auto" | "manual"), empty otherwise
# MEMPAL_STATUS — stop status ("completed" | "aborted" | "error"),
# empty otherwise
# MEMPAL_PARSE_OK — "1" if parser ran cleanly, "0" otherwise
#
# Uses the same sentinel + `sed -n 'Np'` extraction as the Claude Code
# hooks for bash 3.2 compatibility (mapfile/readarray are unavailable
# on macOS /bin/bash 3.2.57; #1440 regression). Each line of output is
# pre-sanitised by the Python side to a shell-safe character set.
mempal_parse_stdin() {
local input="${1:-}"
local parsed
# We invoke Python via -c with a single-quoted multi-line string
# rather than ``python3 - <<'PYEOF'`` because the heredoc form
# would shadow Python's stdin with the heredoc body, leaving
# ``json.load(sys.stdin)`` to read nothing and silently fail. The
# parser body deliberately uses only double-quoted Python strings
# so the surrounding bash single-quote is safe verbatim, and uses
# only the shell-safe character set (alphanumeric, underscore,
# dash, slash, dot, tilde) matching the Claude Code hook's
# sanitiser so a hostile transcript_path cannot splice
# metacharacters into the parsed output.
parsed="$(
umask 077
printf '%s' "$input" | "$MEMPAL_PYTHON_BIN" -c '
import json, re, sys
def safe_str(value):
return re.sub(r"[^a-zA-Z0-9_/.\-~]", "", str(value or ""))
def safe_int(value):
try:
return str(int(value))
except (TypeError, ValueError):
return "0"
try:
data = json.load(sys.stdin)
except Exception:
sys.exit(1)
if not isinstance(data, dict):
sys.exit(1)
conv = safe_str(data.get("conversation_id") or data.get("session_id"))
loop_count = safe_int(data.get("loop_count", 0))
transcript = safe_str(data.get("transcript_path", ""))
trigger = safe_str(data.get("trigger", ""))
status = safe_str(data.get("status", ""))
roots = data.get("workspace_roots") or []
workspace = ""
if isinstance(roots, list) and roots:
workspace = safe_str(roots[0])
print("__MEMPAL_PARSE_OK__")
print(conv)
print(loop_count)
print(transcript)
print(workspace)
print(trigger)
print(status)
' 2>"$MEMPAL_STATE_DIR/cursor_last_python_err.log"
)"
# Drop empty stderr capture on success; lock it to 0600 on failure
# (mirrors the privacy contract in the Claude Code hooks — the
# traceback can echo back transcript_path / home layout).
if [ -s "$MEMPAL_STATE_DIR/cursor_last_python_err.log" ]; then
chmod 600 "$MEMPAL_STATE_DIR/cursor_last_python_err.log" 2>/dev/null
else
rm -f "$MEMPAL_STATE_DIR/cursor_last_python_err.log" 2>/dev/null
fi
local marker
marker="$(printf '%s\n' "$parsed" | sed -n '1p')"
if [ "$marker" = "__MEMPAL_PARSE_OK__" ]; then
MEMPAL_PARSE_OK="1"
MEMPAL_CONV_ID="$(printf '%s\n' "$parsed" | sed -n '2p')"
MEMPAL_LOOP_COUNT="$(printf '%s\n' "$parsed" | sed -n '3p')"
MEMPAL_TRANSCRIPT="$(printf '%s\n' "$parsed" | sed -n '4p')"
MEMPAL_WORKSPACE="$(printf '%s\n' "$parsed" | sed -n '5p')"
MEMPAL_TRIGGER="$(printf '%s\n' "$parsed" | sed -n '6p')"
MEMPAL_STATUS="$(printf '%s\n' "$parsed" | sed -n '7p')"
else
MEMPAL_PARSE_OK="0"
MEMPAL_CONV_ID=""
MEMPAL_LOOP_COUNT="0"
MEMPAL_TRANSCRIPT=""
MEMPAL_WORKSPACE=""
MEMPAL_TRIGGER=""
MEMPAL_STATUS=""
fi
# Defaults and environment fallbacks. The Cursor docs guarantee
# CURSOR_TRANSCRIPT_PATH and CURSOR_PROJECT_DIR env vars are set
# for every hook execution; if JSON parsing failed for any reason
# (sentinel missing, malformed payload, missing interpreter) we
# still have a usable workspace.
MEMPAL_CONV_ID="${MEMPAL_CONV_ID:-unknown}"
case "$MEMPAL_LOOP_COUNT" in
''|*[!0-9]*) MEMPAL_LOOP_COUNT="0" ;;
esac
if [ -z "$MEMPAL_TRANSCRIPT" ] && [ -n "${CURSOR_TRANSCRIPT_PATH:-}" ]; then
MEMPAL_TRANSCRIPT="${CURSOR_TRANSCRIPT_PATH}"
fi
if [ -z "$MEMPAL_WORKSPACE" ]; then
if [ -n "${CURSOR_PROJECT_DIR:-}" ]; then
MEMPAL_WORKSPACE="${CURSOR_PROJECT_DIR}"
elif [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
MEMPAL_WORKSPACE="${CLAUDE_PROJECT_DIR}"
else
MEMPAL_WORKSPACE="${PWD:-/}"
fi
fi
# Expand a leading ~ in the transcript path so downstream
# ``[ -f "$path" ]`` checks resolve correctly.
case "$MEMPAL_TRANSCRIPT" in
'~/'*) MEMPAL_TRANSCRIPT="$HOME/${MEMPAL_TRANSCRIPT#~/}" ;;
esac
}
# ── Defense-in-depth: dump unparseable stdin ──────────────────────────
#
# Same shape as the Claude Code hooks' last_input.log: bounded to 4096
# bytes, overwritten (never appended) so a misconfiguration loop cannot
# grow disk usage, 0600 perms because the dump mirrors the raw hook
# payload (transcript_path reveals the user's home + project layout).
mempal_dump_bad_input() {
local input="${1:-}"
if [ -z "$input" ]; then
return 0
fi
mempal_log "${2:-?}" "${MEMPAL_CONV_ID:-unknown}" \
"WARN: input parse failed (sentinel missing); see $MEMPAL_STATE_DIR/cursor_last_input.log + cursor_last_python_err.log"
(
umask 077
printf '%s' "$input" | head -c 4096 > "$MEMPAL_STATE_DIR/cursor_last_input.log"
)
chmod 600 "$MEMPAL_STATE_DIR/cursor_last_input.log" 2>/dev/null
}
# ── Counter helpers ───────────────────────────────────────────────────
#
# One counter file per conversation_id. Atomic write via temp file
# inside the same directory (rename is atomic on POSIX) so concurrent
# hook invocations cannot half-write the file. Read tolerates a
# corrupted or empty file by returning 0, never crashing.
_mempal_counter_path() {
local conv="${1:-unknown}"
# Sanitise the conv id one more time: it has already been through
# the Python sanitiser, but be defensive in case a caller passes a
# raw string. Strip any character outside [a-zA-Z0-9_.-].
local safe_conv
safe_conv="$(printf '%s' "$conv" | tr -cd 'a-zA-Z0-9_.-')"
if [ -z "$safe_conv" ]; then
safe_conv="unknown"
fi
printf '%s/cursor_%s.count' "$MEMPAL_STATE_DIR" "$safe_conv"
}
mempal_read_counter() {
local path="$1"
if [ ! -f "$path" ]; then
printf '0'
return 0
fi
local raw
raw="$(cat "$path" 2>/dev/null)"
case "$raw" in
''|*[!0-9]*) printf '0' ;;
*) printf '%s' "$raw" ;;
esac
}
mempal_write_counter_atomic() {
local path="$1"
local value="$2"
case "$value" in
''|*[!0-9]*) value="0" ;;
esac
local tmp="${path}.tmp.$$"
printf '%s' "$value" > "$tmp" 2>/dev/null || return 1
mv "$tmp" "$path" 2>/dev/null || {
rm -f "$tmp" 2>/dev/null
return 1
}
}
# ── Pending-save marker ───────────────────────────────────────────────
#
# Dropped by the preCompact hook (which cannot itself emit a
# followup_message — Cursor's preCompact is observational-only) and
# consumed by the next stop invocation so the LLM still gets a diary
# nudge after compaction.
_mempal_pending_path() {
local conv="${1:-unknown}"
local safe_conv
safe_conv="$(printf '%s' "$conv" | tr -cd 'a-zA-Z0-9_.-')"
if [ -z "$safe_conv" ]; then
safe_conv="unknown"
fi
printf '%s/cursor_%s.pending' "$MEMPAL_STATE_DIR" "$safe_conv"
}
mempal_set_pending() {
local conv="${1:-unknown}"
local path
path="$(_mempal_pending_path "$conv")"
: > "$path" 2>/dev/null || return 1
chmod 600 "$path" 2>/dev/null
}
mempal_consume_pending() {
local conv="${1:-unknown}"
local path
path="$(_mempal_pending_path "$conv")"
if [ -f "$path" ]; then
rm -f "$path" 2>/dev/null
return 0
fi
return 1
}
# ── Workspace → wing inference ────────────────────────────────────────
#
# basename(workspace_root), normalised to [a-z0-9_-]. Edge cases:
# / → "root"
# /path/ → trailing slash stripped, then basename
# "/foo bar/" → "foo_bar" (spaces collapsed to underscores)
# "" → "cursor_session"
# "C:\\proj" → "proj" (Windows-style path; basename via tr fallback)
#
# We intentionally keep this in pure bash + POSIX tools so the
# inference is identical across the hook scripts and the test suite
# can target it as a function via `bash -c 'source ...; ...'`.
mempal_infer_wing() {
local raw="${1:-}"
if [ -z "$raw" ]; then
printf 'cursor_session'
return 0
fi
# Strip trailing slashes (but preserve the lone "/" case).
while [ "$raw" != "/" ] && [ "${raw%/}" != "$raw" ]; do
raw="${raw%/}"
done
if [ "$raw" = "/" ]; then
printf 'root'
return 0
fi
local base="${raw##*/}"
# On Windows-style paths with backslashes, fall back to splitting
# on backslash too so we don't return the whole path verbatim.
case "$base" in
*\\*) base="${base##*\\}" ;;
esac
# Lowercase + replace anything outside [a-z0-9_-] with underscore.
# Collapse runs of underscores so "foo bar" doesn't become
# "foo__bar".
base="$(printf '%s' "$base" \
| tr '[:upper:]' '[:lower:]' \
| tr -c 'a-z0-9_-' '_' \
| tr -s '_' \
| sed 's/^_//; s/_$//')"
if [ -z "$base" ]; then
printf 'cursor_session'
return 0
fi
printf '%s' "$base"
}
# ── Transcript path validation ────────────────────────────────────────
#
# Mirrors hooks/mempal_save_hook.sh::is_valid_transcript_path so the
# Cursor and Claude Code hooks reject the same shapes:
# * non-empty
# * .json or .jsonl suffix
# * no .. traversal segments
mempal_is_valid_transcript() {
local path="${1:-}"
[ -n "$path" ] || return 1
case "$path" in
*.json|*.jsonl) ;;
*) return 1 ;;
esac
case "/$path/" in
*/../*) return 1 ;;
esac
return 0
}
# ── JSON emit ─────────────────────────────────────────────────────────
#
# Final stdout write. Uses ``printf '%s'`` instead of ``echo`` because
# echo interprets ``-n``/``-e``/``-E`` as flags and varies in backslash
# handling between builtin and /bin/echo (xpg_echo shopt). Matches the
# Claude Code hook's documented rationale.
mempal_emit() {
printf '%s\n' "${1:-{\}}"
}
+111
View File
@@ -0,0 +1,111 @@
#!/bin/bash
# MEMPALACE CURSOR PRE-COMPACT HOOK — Snapshot transcript before compaction
#
# Cursor "preCompact" hook. Cursor's preCompact is documented as
# OBSERVATIONAL ONLY (cursor.com/docs/hooks.md fetched 2026-05-27):
#
# * It cannot block compaction.
# * Its only output field is `user_message` (no `followup_message`,
# no `decision: block`).
#
# So unlike the Claude Code PreCompact hook (which can block the AI
# and force a save before compaction proceeds), the Cursor preCompact
# hook can only do two useful things at this moment:
#
# 1. Run `mempalace mine` SYNCHRONOUSLY against the transcript file.
# The verbatim drawers land in the palace BEFORE Cursor
# summarises the conversation. This is the actual data-loss
# protection — zero LLM cost, no agent interaction needed.
#
# 2. Drop a `.pending` marker file keyed on conversation_id. The
# next `stop` hook reads that marker and forces a save followup
# regardless of its counter, so the AI still gets a "write a
# diary entry now" nudge on the very next turn.
#
# === INSTALL ===
#
# Add to ~/.cursor/hooks.json (or .cursor/hooks.json for project
# scope) under "preCompact":
#
# {
# "version": 1,
# "hooks": {
# "preCompact": [
# { "command": "/absolute/path/to/mempal_precompact_hook_cursor.sh" }
# ]
# }
# }
#
# No loop_limit is needed; preCompact is not a looping hook.
_mempal_self="${BASH_SOURCE[0]:-$0}"
_mempal_dir="$(cd "$(dirname "$_mempal_self")" 2>/dev/null && pwd)"
# shellcheck source=lib/common.sh
. "$_mempal_dir/lib/common.sh"
# Optional additional project directory to mine before compaction
# (parity with the Claude Code hook's MEMPAL_DIR knob).
MEMPAL_DIR="${MEMPAL_DIR:-}"
if mempal_is_disabled; then
mempal_emit '{}'
exit 0
fi
INPUT="$(cat)"
mempal_parse_stdin "$INPUT"
if [ "$MEMPAL_PARSE_OK" != "1" ]; then
mempal_dump_bad_input "$INPUT" "preCompact"
mempal_emit '{}'
exit 0
fi
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
"trigger=${MEMPAL_TRIGGER:-?} transcript=$MEMPAL_TRANSCRIPT"
# ── Synchronous mine ──────────────────────────────────────────────
#
# This intentionally blocks the hook (within Cursor's per-hook
# timeout). Compaction is irreversible — once Cursor summarises the
# conversation we cannot get the verbatim text back. Background-mining
# would race the compaction.
if command -v mempalace >/dev/null 2>&1; then
if mempal_is_valid_transcript "$MEMPAL_TRANSCRIPT" \
&& [ -f "$MEMPAL_TRANSCRIPT" ]; then
mempalace mine "$(dirname "$MEMPAL_TRANSCRIPT")" --mode convos \
>> "$MEMPAL_CURSOR_LOG" 2>&1 || \
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
"WARN: mempalace mine convos returned non-zero"
elif [ -n "$MEMPAL_TRANSCRIPT" ]; then
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
"skipping invalid transcript path: $MEMPAL_TRANSCRIPT"
fi
if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
mempalace mine "$MEMPAL_DIR" --mode projects \
>> "$MEMPAL_CURSOR_LOG" 2>&1 || \
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
"WARN: mempalace mine projects returned non-zero"
fi
else
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
"mempalace CLI not on PATH; skipping synchronous mine"
fi
# ── Drop the pending-save marker ──────────────────────────────────
mempal_set_pending "$MEMPAL_CONV_ID" || \
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
"WARN: could not write pending-save marker"
# Surface a short user-visible note that compaction is about to
# happen and we've already captured the verbatim text. user_message
# is the only output field Cursor's preCompact accepts.
"$MEMPAL_PYTHON_BIN" -c '
import json
print(json.dumps({
"user_message": (
"MemPalace: transcript snapshotted before compaction. "
"A diary nudge is queued for the next agent turn."
)
}))
'
+195
View File
@@ -0,0 +1,195 @@
#!/bin/bash
# MEMPALACE CURSOR SAVE HOOK — Auto-save every N stop events
#
# Cursor "stop" hook. After every agent loop ends, this hook:
# 1. Counts stop invocations per conversation_id (each stop ≈ one
# assistant turn ≈ roughly one user message — see plan rationale).
# 2. Every SAVE_INTERVAL stops, returns a followup_message telling
# the agent to file the session into MemPalace and write a diary
# entry. Cursor auto-submits that as the next user message.
# 3. On the next stop, loop_count > 0 so we let the agent finish
# without re-firing — Cursor's loop_count is the equivalent of
# Claude Code's stop_hook_active flag.
# 4. If the preCompact hook has left a `.pending` marker, force a
# save followup regardless of the counter and clear the marker.
#
# Companion files in this directory:
# * lib/common.sh — shared helpers (sourced)
# * mempal_precompact_hook_cursor.sh — preCompact event
# * mempal_wake_hook_cursor.sh — sessionStart event
#
# === INSTALL ===
#
# Recommended path: run `hooks/cursor/install.sh` from a cloned repo,
# which copies the scripts to ~/.mempalace/hooks/cursor/ and merges
# the wiring into your ~/.cursor/hooks.json. See hooks/cursor/README.md
# for the full walkthrough, or website/guide/cursor-hooks.md for the
# rendered version.
#
# Manual wiring (user scope: ~/.cursor/hooks.json):
#
# {
# "version": 1,
# "hooks": {
# "stop": [
# {
# "command": "/absolute/path/to/mempal_save_hook_cursor.sh",
# "loop_limit": 1
# }
# ]
# }
# }
#
# The `loop_limit: 1` cap is defense-in-depth — even if our own
# loop_count check below regresses, Cursor itself will stop emitting
# our followup after one auto-iteration.
#
# === KILL SWITCHES ===
#
# MEMPAL_DISABLE_HOOK=1 — Cursor-prompt addition
# MEMPALACE_HOOKS_AUTO_SAVE=false — matches the Claude Code hooks
# ~/.mempalace/config.json "hooks.auto_save": false
#
# Any one of these short-circuits the hook to `{}` and exits 0.
# Resolve the directory this script lives in so we can source the
# sibling lib/common.sh whether the user invoked us by absolute path,
# by relative path, or via a symlink.
_mempal_self="${BASH_SOURCE[0]:-$0}"
_mempal_dir="$(cd "$(dirname "$_mempal_self")" 2>/dev/null && pwd)"
# shellcheck source=lib/common.sh
. "$_mempal_dir/lib/common.sh"
SAVE_INTERVAL="${MEMPAL_SAVE_INTERVAL:-15}"
case "$SAVE_INTERVAL" in
''|*[!0-9]*) SAVE_INTERVAL=15 ;;
esac
# Optional additional project directory to mine on save (parity with
# the Claude Code hook's MEMPAL_DIR knob — purely additive, never an
# override for the transcript mine).
MEMPAL_DIR="${MEMPAL_DIR:-}"
# Kill switch — emit `{}` so Cursor proceeds with normal stop.
if mempal_is_disabled; then
mempal_emit '{}'
exit 0
fi
INPUT="$(cat)"
mempal_parse_stdin "$INPUT"
if [ "$MEMPAL_PARSE_OK" != "1" ]; then
mempal_dump_bad_input "$INPUT" "stop"
# Fail-open: don't block the host on a parse error.
mempal_emit '{}'
exit 0
fi
mempal_log "stop" "$MEMPAL_CONV_ID" \
"loop_count=$MEMPAL_LOOP_COUNT status=${MEMPAL_STATUS:-?} workspace=$MEMPAL_WORKSPACE"
# ── Loop-prevention ────────────────────────────────────────────────
#
# Cursor's loop_count indicates how many times THIS stop hook has
# already triggered an automatic followup for this conversation
# (starts at 0). If it is > 0, our own previous followup is currently
# being consumed by the agent — let it finish without re-firing.
if [ "$MEMPAL_LOOP_COUNT" -gt 0 ] 2>/dev/null; then
mempal_log "stop" "$MEMPAL_CONV_ID" "loop_count>0; letting agent stop"
mempal_emit '{}'
exit 0
fi
WING="$(mempal_infer_wing "$MEMPAL_WORKSPACE")"
# Build the followup message once; both the pending-marker branch and
# the threshold branch use it. Constructed via Python -c (rather than
# a heredoc) so we can pass the inferred wing as argv[1] and so the
# JSON encoding is correct even for wings whose name would otherwise
# need shell quoting.
_mempal_build_followup() {
"$MEMPAL_PYTHON_BIN" -c '
import json, sys
wing = sys.argv[1] if len(sys.argv) > 1 else "cursor_session"
msg = (
"MemPalace save checkpoint. "
"(1) Call mempalace_check_duplicate on the key topics, decisions, "
"and verbatim quotes from this session. "
"(2) For each non-duplicate, call mempalace_add_drawer (wing="
+ wing + ", room=<short topic>, content=verbatim quote). "
"(3) Call mempalace_diary_write (agent_name=cursor-ide, wing="
+ wing + ", entry=AAAK-format summary). "
"Then stop."
)
print(json.dumps({"followup_message": msg}))
' "$WING"
}
# ── Pending-save marker from preCompact ───────────────────────────
#
# preCompact cannot itself emit a followup_message (Cursor docs:
# preCompact is observational-only, output supports only user_message),
# so it drops a marker file and we consume it here. Forces a save
# nudge regardless of the counter.
if mempal_consume_pending "$MEMPAL_CONV_ID"; then
mempal_log "stop" "$MEMPAL_CONV_ID" \
"consumed pending-save marker (post-compaction)"
_mempal_build_followup
exit 0
fi
# ── Normal counter path ───────────────────────────────────────────
COUNTER_FILE="$(_mempal_counter_path "$MEMPAL_CONV_ID")"
CURRENT="$(mempal_read_counter "$COUNTER_FILE")"
NEXT=$((CURRENT + 1))
mempal_write_counter_atomic "$COUNTER_FILE" "$NEXT" || {
mempal_log "stop" "$MEMPAL_CONV_ID" \
"WARN: counter write failed for $COUNTER_FILE; passing through"
mempal_emit '{}'
exit 0
}
mempal_log "stop" "$MEMPAL_CONV_ID" \
"counter $CURRENT -> $NEXT (interval=$SAVE_INTERVAL)"
# Trigger when we hit a multiple of SAVE_INTERVAL. Modulo arithmetic
# keeps the counter monotonically growing (no reset) so the log file
# is greppable for total turns across a conversation.
if [ "$((NEXT % SAVE_INTERVAL))" -ne 0 ]; then
mempal_emit '{}'
exit 0
fi
mempal_log "stop" "$MEMPAL_CONV_ID" "TRIGGERING SAVE at counter=$NEXT"
# ── Background mine (best effort) ─────────────────────────────────
#
# Two independent targets — both run if both are set:
# 1. transcript_path → its parent directory, --mode convos
# 2. MEMPAL_DIR (user-configured project) → --mode projects
#
# Both run with stdout/stderr appended to the cursor log and are
# backgrounded so a slow mine cannot push the hook past its
# Cursor-configured timeout. `command -v mempalace` gates so a user
# without the CLI on PATH (e.g. a fresh GUI-launched session) does
# not see a noisy error.
if command -v mempalace >/dev/null 2>&1; then
if mempal_is_valid_transcript "$MEMPAL_TRANSCRIPT" \
&& [ -f "$MEMPAL_TRANSCRIPT" ]; then
( mempalace mine "$(dirname "$MEMPAL_TRANSCRIPT")" --mode convos \
>> "$MEMPAL_CURSOR_LOG" 2>&1 ) &
elif [ -n "$MEMPAL_TRANSCRIPT" ]; then
mempal_log "stop" "$MEMPAL_CONV_ID" \
"skipping invalid transcript path: $MEMPAL_TRANSCRIPT"
fi
if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
( mempalace mine "$MEMPAL_DIR" --mode projects \
>> "$MEMPAL_CURSOR_LOG" 2>&1 ) &
fi
else
mempal_log "stop" "$MEMPAL_CONV_ID" \
"mempalace CLI not on PATH; skipping background mine"
fi
_mempal_build_followup
+82
View File
@@ -0,0 +1,82 @@
#!/bin/bash
# MEMPALACE CURSOR WAKE HOOK — Session-start memory recall
#
# Cursor "sessionStart" hook. This is a Cursor-only capability —
# Claude Code's third-party-hooks compatibility layer does not have
# an equivalent event with the same "inject context into the agent's
# initial system message" semantics.
#
# Behaviour:
# 1. Parse Cursor's sessionStart payload (conversation_id /
# session_id, is_background_agent, composer_mode, plus the
# common workspace_roots field).
# 2. Infer the wing from basename(workspace_roots[0]).
# 3. Return {"additional_context": "..."} instructing the agent to
# scope its memory recall by calling mempalace_search +
# mempalace_diary_read with wing=<inferred>.
#
# sessionStart is documented as fire-and-forget — Cursor does not
# enforce a blocking response and does not consume "continue" /
# "user_message" — but "additional_context" is honoured and added to
# the conversation's initial system context. Verified in
# cursor.com/docs/hooks.md (fetched 2026-05-27).
#
# === INSTALL ===
#
# Add to ~/.cursor/hooks.json (or .cursor/hooks.json for project
# scope) under "sessionStart":
#
# {
# "version": 1,
# "hooks": {
# "sessionStart": [
# { "command": "/absolute/path/to/mempal_wake_hook_cursor.sh" }
# ]
# }
# }
_mempal_self="${BASH_SOURCE[0]:-$0}"
_mempal_dir="$(cd "$(dirname "$_mempal_self")" 2>/dev/null && pwd)"
# shellcheck source=lib/common.sh
. "$_mempal_dir/lib/common.sh"
if mempal_is_disabled; then
mempal_emit '{}'
exit 0
fi
INPUT="$(cat)"
mempal_parse_stdin "$INPUT"
if [ "$MEMPAL_PARSE_OK" != "1" ]; then
mempal_dump_bad_input "$INPUT" "sessionStart"
mempal_emit '{}'
exit 0
fi
WING="$(mempal_infer_wing "$MEMPAL_WORKSPACE")"
mempal_log "sessionStart" "$MEMPAL_CONV_ID" \
"workspace=$MEMPAL_WORKSPACE wing=$WING"
# Emit the additional_context payload via Python -c (rather than a
# heredoc) so the JSON encoding survives wings whose name contains
# characters that would otherwise need shell escaping, and so the
# inferred wing arrives as an argv positional. The MCP tool names
# referenced here are verified against mempalace/mcp_server.py:
# mempalace_search and mempalace_diary_read both exist and accept
# the wing parameter.
"$MEMPAL_PYTHON_BIN" -c '
import json, sys
wing = sys.argv[1] if len(sys.argv) > 1 else "cursor_session"
ctx = (
"MemPalace wake-up. The Cursor workspace maps to wing=" + wing + ". "
"Before answering anything that touches past work in this "
"project, call mempalace_search (wing=" + wing + ", "
"query=<relevant keywords>) and mempalace_diary_read "
"(agent_name=cursor-ide, wing=" + wing + ", last_n=10). "
"Use what you find verbatim where it answers the question; "
"never summarise the user'"'"'s own words."
)
print(json.dumps({"additional_context": ctx}))
' "$WING"
+7
View File
@@ -0,0 +1,7 @@
{
"mcpServers": {
"mempalace": {
"command": "mempalace-mcp"
}
}
}
+40
View File
@@ -0,0 +1,40 @@
---
name: mempalace
description: MemPalace — mine projects and conversations into a searchable memory palace. Use when the user asks about MemPalace, memory palace, mining memories, searching memories, palace setup, wings, rooms, or drawers; or when they want to recall past work that may already be filed in their palace.
---
# 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 (uv recommended):
```bash
uv tool install mempalace # or: pip install mempalace
```
## Usage
MemPalace provides dynamic, version-correct 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.
## Cursor-specific notes
- The `mempalace-mcp` server is auto-registered by this plugin. Once installed, all 19 MemPalace MCP tools (`mempalace_search`, `mempalace_add_drawer`, `mempalace_diary_write`, `mempalace_check_duplicate`, `mempalace_diary_read`, etc.) are available to the agent without any further configuration.
- For automatic background saving every N agent turns plus session-start memory recall, also install the Cursor hooks separately by running `hooks/cursor/install.sh --scope user` from a cloned MemPalace repo. See [`website/guide/cursor-hooks.md`](../../../website/guide/cursor-hooks.md) for the full walkthrough.
- The recommended `agent_name` when calling `mempalace_diary_write` from a Cursor session is `cursor-ide` (matches the precedent of `claude-code` and `codex`).
+398
View File
@@ -0,0 +1,398 @@
"""Contract tests for ``hooks/cursor/install.sh``.
The installer's job is to merge MemPalace hook entries into a Cursor
``hooks.json`` file without:
- modifying unrelated hook entries already in the file,
- duplicating MemPalace entries when re-run,
- writing to disk when ``--dry-run`` is passed,
- leaving stale MemPalace entries behind on ``--uninstall``.
These four contracts are what protects a user's existing Cursor
configuration. Tests use ``--scope project --target <tmp_path>`` so
the test never touches the real user `~/.cursor/`.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
INSTALL_SH = REPO_ROOT / "hooks" / "cursor" / "install.sh"
pytestmark = pytest.mark.skipif(os.name == "nt", reason="install.sh is POSIX-only")
# ── helpers ─────────────────────────────────────────────────────────
def _run_install(
*args: str,
target: Path,
home: Path | None = None,
expected_rc: int = 0,
) -> tuple[str, str]:
"""Invoke install.sh with --scope project --target <target>.
Forces ``MEMPAL_PYTHON=sys.executable`` for the same reason the
shell-hook tests do — ensures the JSON merge runs even if PATH on
a GUI-launched CI runner is missing python3. Forces a clean HOME
so the default --install-dir (under ~/.mempalace/hooks/cursor/)
lands in a sandboxed tmp tree rather than the developer's real
home.
"""
env = {
"HOME": str(home) if home else "/tmp/mempal-install-test-home",
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"MEMPAL_PYTHON": sys.executable,
}
cmd = [
"bash",
str(INSTALL_SH),
"--scope",
"project",
"--target",
str(target),
*args,
]
p = subprocess.run(
cmd,
capture_output=True,
text=True,
env=env,
timeout=30,
)
assert p.returncode == expected_rc, (
f"install.sh exited {p.returncode} (expected {expected_rc}); "
f"stderr={p.stderr!r}; stdout={p.stdout!r}; argv={cmd}"
)
return p.stdout, p.stderr
def _hooks_file(target: Path) -> Path:
return target / ".cursor" / "hooks.json"
def _seed(target: Path, payload: dict) -> Path:
cursor_dir = target / ".cursor"
cursor_dir.mkdir(parents=True, exist_ok=True)
hf = cursor_dir / "hooks.json"
hf.write_text(json.dumps(payload, indent=2))
return hf
# ── --help and bash syntax ──────────────────────────────────────────
def test_bash_syntax_clean():
p = subprocess.run(
["bash", "-n", str(INSTALL_SH)],
capture_output=True,
text=True,
)
assert p.returncode == 0, f"install.sh syntax error: {p.stderr}"
def test_help_describes_all_flags():
p = subprocess.run(
["bash", str(INSTALL_SH), "--help"],
capture_output=True,
text=True,
env={
"HOME": "/tmp/mempal-help",
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
},
timeout=10,
)
assert p.returncode == 0
for flag in ("--scope", "--target", "--variant", "--dry-run", "--uninstall"):
assert flag in p.stdout, f"--help must describe {flag}"
def test_unknown_flag_exits_nonzero():
p = subprocess.run(
["bash", str(INSTALL_SH), "--bogus-flag"],
capture_output=True,
text=True,
env={
"HOME": "/tmp/mempal-bogus",
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
},
timeout=10,
)
assert p.returncode != 0
# ── --dry-run contract ─────────────────────────────────────────────
class TestDryRun:
def test_dry_run_does_not_write_target_file(self, tmp_path):
stdout, _ = _run_install("--dry-run", target=tmp_path, home=tmp_path)
assert not _hooks_file(tmp_path).exists(), "--dry-run must not write the target file"
# But it must still print valid JSON to stdout for the user to
# review.
parsed = json.loads(stdout)
assert parsed["version"] == 1
assert "hooks" in parsed
def test_dry_run_does_not_copy_scripts(self, tmp_path):
install_dir = tmp_path / "install-dest"
_run_install(
"--dry-run",
"--install-dir",
str(install_dir),
target=tmp_path,
home=tmp_path,
)
assert not install_dir.exists(), (
"--dry-run must not copy any hook scripts to the install dir"
)
def test_dry_run_emits_full_variant_by_default(self, tmp_path):
stdout, _ = _run_install("--dry-run", target=tmp_path, home=tmp_path)
cfg = json.loads(stdout)
assert set(cfg["hooks"].keys()) >= {"sessionStart", "stop", "preCompact"}
def test_dry_run_minimal_variant_only_wires_stop(self, tmp_path):
stdout, _ = _run_install(
"--dry-run",
"--variant",
"minimal",
target=tmp_path,
home=tmp_path,
)
cfg = json.loads(stdout)
# `stop` is the only event we touch. Anything else (including
# sessionStart / preCompact) should not be present unless the
# seed file already had it.
assert "stop" in cfg["hooks"]
assert "sessionStart" not in cfg["hooks"]
assert "preCompact" not in cfg["hooks"]
# ── merge-preservation contract ────────────────────────────────────
class TestMergePreservation:
def test_preserves_unrelated_hook_events(self, tmp_path):
_seed(
tmp_path,
{
"version": 1,
"hooks": {
"afterFileEdit": [
{"command": "/usr/local/bin/my-formatter.sh"},
],
"beforeShellExecution": [
{"command": "/usr/local/bin/audit-shell.sh"},
],
},
},
)
_run_install(target=tmp_path, home=tmp_path)
result = json.loads(_hooks_file(tmp_path).read_text())
assert result["hooks"]["afterFileEdit"] == [
{"command": "/usr/local/bin/my-formatter.sh"},
], "unrelated afterFileEdit entry must survive merge"
assert result["hooks"]["beforeShellExecution"] == [
{"command": "/usr/local/bin/audit-shell.sh"},
], "unrelated beforeShellExecution entry must survive merge"
def test_preserves_other_entries_on_same_event(self, tmp_path):
# User has their own `stop` hook. We must add MemPalace's
# entry alongside, not replace.
_seed(
tmp_path,
{
"version": 1,
"hooks": {
"stop": [
{"command": "/usr/local/bin/my-stop-hook.sh"},
],
},
},
)
_run_install(target=tmp_path, home=tmp_path)
result = json.loads(_hooks_file(tmp_path).read_text())
stop_entries = result["hooks"]["stop"]
assert len(stop_entries) == 2, (
f"expected user entry + MemPalace entry; got {stop_entries!r}"
)
commands = {e["command"] for e in stop_entries}
assert "/usr/local/bin/my-stop-hook.sh" in commands
assert any("mempal_save_hook_cursor.sh" in c for c in commands)
def test_creates_target_dir_when_missing(self, tmp_path):
# No .cursor/ exists yet; install must create both the
# directory and the file.
assert not (tmp_path / ".cursor").exists()
_run_install(target=tmp_path, home=tmp_path)
assert _hooks_file(tmp_path).exists()
cfg = json.loads(_hooks_file(tmp_path).read_text())
assert "stop" in cfg["hooks"]
def test_refuses_to_overwrite_malformed_existing_json(self, tmp_path):
cursor_dir = tmp_path / ".cursor"
cursor_dir.mkdir(parents=True)
(cursor_dir / "hooks.json").write_text("{ this is not json")
# The merge step should fail with a non-zero exit; the file
# must remain untouched so the user can fix it.
_, stderr = _run_install(target=tmp_path, home=tmp_path, expected_rc=2)
assert "not valid JSON" in stderr or "Refusing to overwrite" in stderr
# File should be unchanged.
assert (cursor_dir / "hooks.json").read_text() == "{ this is not json"
# ── idempotency contract ───────────────────────────────────────────
class TestIdempotency:
def test_running_install_twice_does_not_duplicate(self, tmp_path):
_run_install(target=tmp_path, home=tmp_path)
first = json.loads(_hooks_file(tmp_path).read_text())
_run_install(target=tmp_path, home=tmp_path)
second = json.loads(_hooks_file(tmp_path).read_text())
assert first == second, (
"re-running install.sh must produce an identical config "
f"(idempotency); first={first!r} second={second!r}"
)
# And specifically no duplicate MemPalace entry on `stop`.
stop = second["hooks"]["stop"]
mempal_entries = [e for e in stop if "mempal_save_hook_cursor.sh" in e["command"]]
assert len(mempal_entries) == 1, (
f"re-running install must not duplicate the stop entry; got {stop!r}"
)
# ── --uninstall contract ───────────────────────────────────────────
class TestUninstall:
def test_uninstall_removes_only_mempalace_entries(self, tmp_path):
# Seed: user has their own stop hook AND an unrelated event.
_seed(
tmp_path,
{
"version": 1,
"hooks": {
"stop": [
{"command": "/usr/local/bin/my-stop-hook.sh"},
],
"afterFileEdit": [
{"command": "/usr/local/bin/my-formatter.sh"},
],
},
},
)
_run_install(target=tmp_path, home=tmp_path)
# MemPalace is now wired alongside the user's entries.
_run_install("--uninstall", target=tmp_path, home=tmp_path)
cfg = json.loads(_hooks_file(tmp_path).read_text())
# User's stop hook must remain; MemPalace's must be gone.
commands = {e["command"] for e in cfg["hooks"].get("stop", [])}
assert commands == {"/usr/local/bin/my-stop-hook.sh"}
# Unrelated event untouched.
assert cfg["hooks"]["afterFileEdit"] == [
{"command": "/usr/local/bin/my-formatter.sh"},
]
# sessionStart / preCompact (which were ONLY ever wired by us)
# must be removed entirely since they would otherwise dangle
# as empty lists.
assert "sessionStart" not in cfg["hooks"]
assert "preCompact" not in cfg["hooks"]
def test_uninstall_removes_empty_file_when_no_user_hooks_remain(self, tmp_path):
# No pre-existing hooks; install then uninstall should leave
# an effectively-empty config -> file removed entirely.
_run_install(target=tmp_path, home=tmp_path)
assert _hooks_file(tmp_path).exists()
_run_install("--uninstall", target=tmp_path, home=tmp_path)
assert not _hooks_file(tmp_path).exists(), (
"fully-empty hooks.json after uninstall must be removed, "
'not left as `{"version": 1, "hooks": {}}`'
)
def test_uninstall_is_safe_when_file_missing(self, tmp_path):
# User never installed; running uninstall must not crash.
assert not _hooks_file(tmp_path).exists()
_run_install("--uninstall", target=tmp_path, home=tmp_path)
# File should still not exist (and definitely should not have
# been created with an empty config).
assert not _hooks_file(tmp_path).exists()
def test_uninstall_dry_run_does_not_modify_file(self, tmp_path):
_seed(
tmp_path,
{
"version": 1,
"hooks": {
"stop": [
{"command": "/usr/local/bin/my-stop-hook.sh"},
{
"command": (
"/Users/anon/.mempalace/hooks/cursor/mempal_save_hook_cursor.sh"
),
"loop_limit": 1,
},
],
},
},
)
before = _hooks_file(tmp_path).read_text()
_run_install("--uninstall", "--dry-run", target=tmp_path, home=tmp_path)
after = _hooks_file(tmp_path).read_text()
assert before == after, "--uninstall --dry-run must not mutate the target file"
# ── scope handling ──────────────────────────────────────────────────
def test_project_scope_writes_to_project_dir(tmp_path):
# Sanity check that --scope project + --target lands the file at
# <target>/.cursor/hooks.json and nowhere else.
home = tmp_path / "fake-home"
home.mkdir()
project = tmp_path / "fake-project"
project.mkdir()
_run_install(target=project, home=home)
assert (project / ".cursor" / "hooks.json").exists()
assert not (home / ".cursor" / "hooks.json").exists(), (
"--scope project must not write into HOME"
)
def test_invalid_scope_rejected(tmp_path):
p = subprocess.run(
["bash", str(INSTALL_SH), "--scope", "bogus"],
capture_output=True,
text=True,
env={
"HOME": str(tmp_path),
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
},
timeout=10,
)
assert p.returncode != 0
assert "scope" in p.stderr.lower()
def test_invalid_variant_rejected(tmp_path):
p = subprocess.run(
["bash", str(INSTALL_SH), "--variant", "bogus"],
capture_output=True,
text=True,
env={
"HOME": str(tmp_path),
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
},
timeout=10,
)
assert p.returncode != 0
assert "variant" in p.stderr.lower()
+576
View File
@@ -0,0 +1,576 @@
"""Behavioral coverage for the Cursor hook shell scripts.
Mirrors ``tests/test_hooks_shell.py`` + ``tests/test_hooks_bash_compat.py``
in shape so a future contributor recognises the pattern. The three
hooks live at ``hooks/cursor/`` and source ``hooks/cursor/lib/common.sh``.
Covered contracts:
- bash 3.2 compatibility (no ``mapfile`` / ``readarray``; ``sed -n 'Np'``
used for line extraction; ``bash -n`` clean).
- Per-conversation counter increments atomically across ``stop`` calls
and emits a ``followup_message`` only on the configured interval.
- ``MEMPAL_DISABLE_HOOK=1`` and ``MEMPALACE_HOOKS_AUTO_SAVE=false`` both
short-circuit every hook to ``{}``.
- Malformed stdin dumps the payload to a bounded 0600 file and logs a
warning; the hook still exits 0 with ``{}`` so Cursor proceeds.
- ``loop_count > 0`` short-circuits the save hook (loop-prevention).
- A pending-save marker dropped by ``preCompact`` forces a save
followup on the very next ``stop`` regardless of the counter.
- ``infer_wing_from_cwd`` handles ``/``, trailing slashes, spaces, and
empty input.
- The wake hook emits ``additional_context`` referencing the inferred
wing.
- The precompact hook drops a pending-save marker and emits the
documented ``user_message`` shape.
"""
from __future__ import annotations
import json
import os
import stat
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
HOOKS_DIR = REPO_ROOT / "hooks" / "cursor"
SAVE_HOOK = HOOKS_DIR / "mempal_save_hook_cursor.sh"
PRECOMPACT_HOOK = HOOKS_DIR / "mempal_precompact_hook_cursor.sh"
WAKE_HOOK = HOOKS_DIR / "mempal_wake_hook_cursor.sh"
COMMON_LIB = HOOKS_DIR / "lib" / "common.sh"
# All three .sh scripts, parametrised together for the source-level and
# universal-behaviour tests. ids= keeps pytest output readable.
ALL_HOOKS = pytest.mark.parametrize(
"hook",
[SAVE_HOOK, PRECOMPACT_HOOK, WAKE_HOOK],
ids=["save_hook", "precompact_hook", "wake_hook"],
)
pytestmark = pytest.mark.skipif(os.name == "nt", reason="bash hook scripts are POSIX-only")
# ── helpers ──────────────────────────────────────────────────────────
def _run_hook(
hook: Path,
stdin: str,
home: Path,
*,
extra_env: dict | None = None,
path_prefix: list[Path] | None = None,
expected_rc: int = 0,
) -> tuple[str, str]:
"""Invoke a hook with a controlled environment and assert exit code.
Returns ``(stdout, stderr)``. Forces ``MEMPAL_PYTHON=sys.executable``
so the hook always finds a Python that can ``import json`` — without
this, GUI-launched CI on macOS could hit a missing python3 on the
inherited PATH and produce spurious failures unrelated to the hook
logic. Forces a clean ``HOME`` so the state directory is sandboxed
under the test's ``tmp_path``.
"""
env = {
"HOME": str(home),
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"MEMPAL_PYTHON": sys.executable,
}
if path_prefix:
env["PATH"] = os.pathsep.join(str(p) for p in path_prefix) + os.pathsep + env["PATH"]
if extra_env:
env.update(extra_env)
p = subprocess.run(
["bash", str(hook)],
input=stdin,
capture_output=True,
text=True,
env=env,
timeout=30,
# Force a permissive umask so the hook's own ``umask 077`` inside
# parsing subshells is provably the sole reason diagnostic files
# end up mode 0600. Without this, an ambient restrictive umask
# on the CI runner would mask a regression that drops the in-hook
# ``umask`` line. Mirrors tests/test_hooks_bash_compat.py.
preexec_fn=lambda: os.umask(0o022),
)
assert p.returncode == expected_rc, (
f"{hook.name} exited {p.returncode} (expected {expected_rc}); "
f"stderr={p.stderr!r}; stdout={p.stdout!r}"
)
return p.stdout, p.stderr
def _stop_payload(
*,
conv: str = "conv-1",
loop_count: int = 0,
transcript: str = "",
) -> str:
return json.dumps(
{
"conversation_id": conv,
"loop_count": loop_count,
"status": "completed",
"model": "claude-sonnet-4-20250514",
"hook_event_name": "stop",
"transcript_path": transcript,
"workspace_roots": ["/Users/test/sampleProj"],
}
)
def _precompact_payload(*, conv: str = "conv-1", transcript: str = "") -> str:
return json.dumps(
{
"conversation_id": conv,
"hook_event_name": "preCompact",
"trigger": "auto",
"transcript_path": transcript,
"workspace_roots": ["/Users/test/sampleProj"],
}
)
def _session_start_payload(*, conv: str = "conv-1") -> str:
return json.dumps(
{
"conversation_id": conv,
"session_id": conv,
"hook_event_name": "sessionStart",
"is_background_agent": False,
"composer_mode": "agent",
"workspace_roots": ["/Users/test/sampleProj"],
}
)
def _state_dir(home: Path) -> Path:
return home / ".mempalace" / "hook_state"
def _log_text(home: Path) -> str:
log = _state_dir(home) / "cursor_hook.log"
return log.read_text() if log.exists() else ""
# ── source-level bash 3.2 compat (matches tests/test_hooks_bash_compat.py) ──
class TestBash32Compat:
@ALL_HOOKS
def test_bash_syntax_clean(self, hook):
p = subprocess.run(
["bash", "-n", str(hook)],
capture_output=True,
text=True,
)
assert p.returncode == 0, f"{hook.name} syntax error: {p.stderr}"
def test_common_lib_syntax_clean(self):
p = subprocess.run(
["bash", "-n", str(COMMON_LIB)],
capture_output=True,
text=True,
)
assert p.returncode == 0, f"common.sh syntax error: {p.stderr}"
@ALL_HOOKS
def test_no_bash4_array_builtins(self, hook):
src = "\n".join(
line for line in hook.read_text().splitlines() if not line.lstrip().startswith("#")
)
assert "mapfile" not in src, (
f"{hook.name} uses mapfile; unavailable on macOS /bin/bash 3.2 (#1440)"
)
assert "readarray" not in src, (
f"{hook.name} uses readarray; unavailable on macOS /bin/bash 3.2 (#1440)"
)
def test_common_lib_no_bash4_array_builtins(self):
src = "\n".join(
line
for line in COMMON_LIB.read_text().splitlines()
if not line.lstrip().startswith("#")
)
assert "mapfile" not in src and "readarray" not in src, (
"common.sh uses bash-4-only array builtins; would break macOS bash 3.2"
)
def test_common_lib_uses_sed_n_for_extraction(self):
# Defense: if a future edit swaps the sed-based parser for
# mapfile (the bash-4 form), this catches it at source level
# before any behavioural test runs. ``parse_cursor_stdin`` reads
# seven values (sentinel + six fields) so we expect at least
# seven ``sed -n 'Np'`` calls.
src = "\n".join(
line
for line in COMMON_LIB.read_text().splitlines()
if not line.lstrip().startswith("#")
)
assert src.count("sed -n '") >= 7, (
"common.sh must use sed -n 'Np' for POSIX-portable line extraction"
)
# ── kill switches ───────────────────────────────────────────────────
class TestKillSwitches:
@ALL_HOOKS
@pytest.mark.parametrize("value", ["1", "true", "yes", "on"])
def test_disable_hook_env_short_circuits(self, hook, value, tmp_path):
out, _ = _run_hook(
hook,
_stop_payload(),
tmp_path,
extra_env={"MEMPAL_DISABLE_HOOK": value},
)
assert json.loads(out) == {}, f"MEMPAL_DISABLE_HOOK={value} must short-circuit; got {out!r}"
# No state files should be created when the kill switch fires.
state = _state_dir(tmp_path)
assert not (state / "cursor_hook.log").exists() or _log_text(tmp_path) == ""
@ALL_HOOKS
@pytest.mark.parametrize("value", ["false", "0", "no", "off"])
def test_auto_save_env_short_circuits(self, hook, value, tmp_path):
out, _ = _run_hook(
hook,
_stop_payload(),
tmp_path,
extra_env={"MEMPALACE_HOOKS_AUTO_SAVE": value},
)
assert json.loads(out) == {}, (
f"MEMPALACE_HOOKS_AUTO_SAVE={value} must short-circuit; got {out!r}"
)
@ALL_HOOKS
def test_config_file_auto_save_false_short_circuits(self, hook, tmp_path):
cfg_dir = tmp_path / ".mempalace"
cfg_dir.mkdir(parents=True)
(cfg_dir / "config.json").write_text(json.dumps({"hooks": {"auto_save": False}}))
out, _ = _run_hook(hook, _stop_payload(), tmp_path)
assert json.loads(out) == {}, (
f"config.json hooks.auto_save=false must short-circuit; got {out!r}"
)
# ── malformed stdin ─────────────────────────────────────────────────
class TestMalformedStdin:
@ALL_HOOKS
def test_malformed_input_does_not_crash(self, hook, tmp_path):
out, _ = _run_hook(hook, "not-json garbage", tmp_path)
# Must still produce parseable JSON so Cursor proceeds.
assert json.loads(out) == {}, f"hook must emit {{}} on malformed input; got {out!r}"
@ALL_HOOKS
def test_malformed_input_logs_warning_and_dumps_payload(self, hook, tmp_path):
_run_hook(hook, "not-json garbage", tmp_path)
state = _state_dir(tmp_path)
log = (state / "cursor_hook.log").read_text()
assert "WARN: input parse failed" in log, (
f"expected parse-failure warning in log; got: {log!r}"
)
dump = state / "cursor_last_input.log"
assert dump.exists()
assert "not-json garbage" in dump.read_text()
@ALL_HOOKS
def test_dump_is_mode_0600(self, hook, tmp_path):
_run_hook(hook, "not-json garbage", tmp_path)
dump = _state_dir(tmp_path) / "cursor_last_input.log"
mode = stat.S_IMODE(dump.stat().st_mode)
assert mode == 0o600, f"cursor_last_input.log mode should be 0600, got {oct(mode)}"
@ALL_HOOKS
def test_dump_cap_at_4096_bytes(self, hook, tmp_path):
_run_hook(hook, "x" * 4097, tmp_path)
dump = _state_dir(tmp_path) / "cursor_last_input.log"
assert dump.stat().st_size == 4096, (
f"cap must be exactly 4096 bytes; got {dump.stat().st_size}"
)
@ALL_HOOKS
def test_empty_stdin_does_not_dump(self, hook, tmp_path):
out, _ = _run_hook(hook, "", tmp_path)
assert json.loads(out) == {}
dump = _state_dir(tmp_path) / "cursor_last_input.log"
assert not dump.exists(), "empty stdin must not produce a dump file"
@ALL_HOOKS
def test_successful_parse_leaves_no_python_err_log(self, hook, tmp_path):
if hook == SAVE_HOOK:
payload = _stop_payload()
elif hook == PRECOMPACT_HOOK:
payload = _precompact_payload()
else:
payload = _session_start_payload()
_run_hook(hook, payload, tmp_path)
err_log = _state_dir(tmp_path) / "cursor_last_python_err.log"
assert not err_log.exists(), "successful parse must clean up cursor_last_python_err.log"
# ── save hook: counter + threshold ──────────────────────────────────
class TestSaveHookCounter:
def test_counter_increments_across_invocations(self, tmp_path):
for _ in range(3):
out, _ = _run_hook(SAVE_HOOK, _stop_payload(conv="conv-A"), tmp_path)
assert json.loads(out) == {}, "below threshold must be a no-op"
counter_file = _state_dir(tmp_path) / "cursor_conv-A.count"
assert counter_file.read_text() == "3", (
f"counter should be 3 after 3 invocations; got {counter_file.read_text()!r}"
)
def test_counter_per_conversation_isolated(self, tmp_path):
_run_hook(SAVE_HOOK, _stop_payload(conv="conv-A"), tmp_path)
_run_hook(SAVE_HOOK, _stop_payload(conv="conv-A"), tmp_path)
_run_hook(SAVE_HOOK, _stop_payload(conv="conv-B"), tmp_path)
assert (_state_dir(tmp_path) / "cursor_conv-A.count").read_text() == "2"
assert (_state_dir(tmp_path) / "cursor_conv-B.count").read_text() == "1"
def test_threshold_emits_followup_message(self, tmp_path):
# Lower the interval to keep the test fast.
env = {"MEMPAL_SAVE_INTERVAL": "3"}
for _ in range(2):
out, _ = _run_hook(SAVE_HOOK, _stop_payload(), tmp_path, extra_env=env)
assert json.loads(out) == {}
out, _ = _run_hook(SAVE_HOOK, _stop_payload(), tmp_path, extra_env=env)
response = json.loads(out)
assert "followup_message" in response, (
f"third invocation must emit a followup_message; got {response!r}"
)
msg = response["followup_message"]
# Followup must reference the real MCP tool names (regression
# guard against future typos that would silently fail).
assert "mempalace_add_drawer" in msg
assert "mempalace_check_duplicate" in msg
assert "mempalace_diary_write" in msg
assert "cursor-ide" in msg, "diary entries must be tagged agent_name=cursor-ide"
def test_threshold_followup_references_inferred_wing(self, tmp_path):
env = {"MEMPAL_SAVE_INTERVAL": "1"}
# workspace_roots[0] = /Users/test/sampleProj -> wing=sampleproj
out, _ = _run_hook(SAVE_HOOK, _stop_payload(), tmp_path, extra_env=env)
msg = json.loads(out)["followup_message"]
assert "sampleproj" in msg, f"followup should reference inferred wing; got {msg!r}"
# ── save hook: loop-prevention ──────────────────────────────────────
class TestSaveHookLoopPrevention:
def test_loop_count_gt_zero_short_circuits(self, tmp_path):
out, _ = _run_hook(
SAVE_HOOK,
_stop_payload(loop_count=1),
tmp_path,
extra_env={"MEMPAL_SAVE_INTERVAL": "1"},
)
assert json.loads(out) == {}, (
"loop_count > 0 must short-circuit even at the trigger interval"
)
# No counter file should be written in the short-circuit path.
assert not (_state_dir(tmp_path) / "cursor_conv-1.count").exists()
def test_loop_count_zero_does_not_short_circuit(self, tmp_path):
out, _ = _run_hook(
SAVE_HOOK,
_stop_payload(loop_count=0),
tmp_path,
extra_env={"MEMPAL_SAVE_INTERVAL": "1"},
)
assert "followup_message" in json.loads(out)
# ── save hook: pending-save marker from preCompact ──────────────────
class TestPendingSaveMarker:
def test_pending_marker_forces_followup_regardless_of_counter(self, tmp_path):
state = _state_dir(tmp_path)
state.mkdir(parents=True, exist_ok=True)
# Drop the marker as if precompact had run.
(state / "cursor_conv-1.pending").write_text("")
out, _ = _run_hook(
SAVE_HOOK,
_stop_payload(),
tmp_path,
# SAVE_INTERVAL=1000 ensures the normal counter path would
# not trigger; the marker is the only reason a followup
# gets emitted.
extra_env={"MEMPAL_SAVE_INTERVAL": "1000"},
)
response = json.loads(out)
assert "followup_message" in response, (
"pending marker must force a followup even far below threshold"
)
# Marker must be consumed on read.
assert not (state / "cursor_conv-1.pending").exists(), (
"pending marker must be deleted after consumption"
)
def test_pending_marker_is_per_conversation(self, tmp_path):
state = _state_dir(tmp_path)
state.mkdir(parents=True, exist_ok=True)
(state / "cursor_conv-OTHER.pending").write_text("")
out, _ = _run_hook(
SAVE_HOOK,
_stop_payload(conv="conv-1"),
tmp_path,
extra_env={"MEMPAL_SAVE_INTERVAL": "1000"},
)
# conv-1 has no marker -> counter path -> no trigger -> {}.
assert json.loads(out) == {}
# conv-OTHER marker must NOT be consumed by conv-1's invocation.
assert (state / "cursor_conv-OTHER.pending").exists()
# ── preCompact hook ─────────────────────────────────────────────────
class TestPreCompactHook:
def test_emits_user_message(self, tmp_path):
out, _ = _run_hook(PRECOMPACT_HOOK, _precompact_payload(), tmp_path)
response = json.loads(out)
# Cursor's preCompact only accepts user_message; never
# followup_message or decision.
assert "user_message" in response, f"expected user_message; got {response!r}"
assert "followup_message" not in response
assert "decision" not in response
def test_drops_pending_marker(self, tmp_path):
_run_hook(PRECOMPACT_HOOK, _precompact_payload(conv="conv-X"), tmp_path)
marker = _state_dir(tmp_path) / "cursor_conv-X.pending"
assert marker.exists(), "preCompact must drop a pending-save marker"
def test_logs_trigger(self, tmp_path):
_run_hook(PRECOMPACT_HOOK, _precompact_payload(conv="conv-Y"), tmp_path)
log = _log_text(tmp_path)
assert "event=preCompact" in log
assert "conv=conv-Y" in log
assert "trigger=auto" in log
# ── wake (sessionStart) hook ───────────────────────────────────────
class TestWakeHook:
def test_emits_additional_context(self, tmp_path):
out, _ = _run_hook(WAKE_HOOK, _session_start_payload(), tmp_path)
response = json.loads(out)
assert "additional_context" in response, (
f"sessionStart must emit additional_context; got {response!r}"
)
ctx = response["additional_context"]
# Must reference the inferred wing AND the real MCP tools.
assert "sampleproj" in ctx, f"context should reference inferred wing; got {ctx!r}"
assert "mempalace_search" in ctx
assert "mempalace_diary_read" in ctx
assert "cursor-ide" in ctx
def test_falls_back_to_env_when_workspace_roots_missing(self, tmp_path):
# Cursor always provides workspace_roots, but the env-var
# fallback path needs coverage so a future Cursor schema
# change cannot silently break the wake hook.
payload = json.dumps(
{
"conversation_id": "conv-Z",
"session_id": "conv-Z",
"hook_event_name": "sessionStart",
"is_background_agent": False,
"composer_mode": "agent",
}
)
out, _ = _run_hook(
WAKE_HOOK,
payload,
tmp_path,
extra_env={"CURSOR_PROJECT_DIR": "/Users/test/envFallback"},
)
ctx = json.loads(out)["additional_context"]
assert "envfallback" in ctx, (
f"env-var fallback workspace should drive wing inference; got {ctx!r}"
)
# ── infer_wing_from_cwd via direct function call ────────────────────
def _call_infer_wing(arg: str) -> str:
"""Source common.sh in a bash subshell and invoke mempal_infer_wing.
Returns the function's stdout. Uses bash -c so we never have to
pollute the test's own shell environment with the common.sh state
(which mkdir's directories and resolves Python paths).
"""
script = f'. "{COMMON_LIB}" >/dev/null 2>&1; mempal_infer_wing "$1"'
# The argument is passed as a positional so it survives any shell
# quirks around spaces/empty values exactly as the production hook
# would see them.
p = subprocess.run(
["bash", "-c", script, "_test", arg],
capture_output=True,
text=True,
env={
"HOME": "/tmp",
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"MEMPAL_PYTHON": sys.executable,
},
timeout=10,
)
assert p.returncode == 0, f"infer_wing call failed: {p.stderr!r}"
return p.stdout
class TestInferWing:
def test_basename_of_normal_path(self):
assert _call_infer_wing("/Users/me/myproject") == "myproject"
def test_strips_trailing_slash(self):
assert _call_infer_wing("/Users/me/myproject/") == "myproject"
def test_root_path_falls_back(self):
assert _call_infer_wing("/") == "root"
def test_empty_input_falls_back(self):
assert _call_infer_wing("") == "cursor_session"
def test_spaces_collapsed_to_underscore(self):
assert _call_infer_wing("/Users/me/my project") == "my_project"
def test_lowercases_uppercase_basename(self):
# Cursor on macOS often hands us /Users/<user>/Projects/MyApp.
# The wing scoping in MemPalace's MCP tools is case-sensitive,
# so the wake hook and save hook must produce identical wings
# for the same workspace — lowercasing is the simplest
# contract.
assert _call_infer_wing("/Users/me/MyApp") == "myapp"
def test_windows_style_path(self):
# Cursor on Windows passes C:\path\to\Project as workspace_root.
# The hook scripts are POSIX-only (we skip them on Windows) but
# WSL users may still hit a backslash-bearing path via the
# CURSOR_PROJECT_DIR env var when Cursor is launched from
# PowerShell.
assert _call_infer_wing(r"C:\Users\me\MyProj") == "myproj"
# ── logging discipline ─────────────────────────────────────────────
class TestLogging:
def test_log_uses_iso8601_utc_timestamps(self, tmp_path):
_run_hook(SAVE_HOOK, _stop_payload(), tmp_path)
log = _log_text(tmp_path)
# ISO 8601 with 'Z' suffix means UTC, locale-independent.
# Regression guard against switching back to %H:%M:%S which
# loses both the date and the timezone.
assert "T" in log and "Z]" in log, f"log timestamps must be ISO 8601 UTC; got: {log!r}"
+437
View File
@@ -0,0 +1,437 @@
"""Contract tests for ``.cursor-plugin/``.
These tests protect the four things a Cursor user actually relies on
once they install the plugin:
1. The manifest (``.cursor-plugin/plugin.json``) is valid JSON, satisfies
Cursor's required + structural fields, and every component path it
declares resolves to a real on-disk target.
2. The marketplace manifest (``.cursor-plugin/marketplace.json``) is
valid JSON and points at the same plugin.
3. The MCP config (``.cursor-plugin/mcp.json``) is valid JSON, wraps
server entries under the documented ``mcpServers`` key, and
registers the ``mempalace-mcp`` binary that ships with the package.
4. Every skill ``SKILL.md`` and command ``*.md`` parses as YAML
frontmatter + markdown body. Cursor derives the slash-command
slug from the **filename stem** (e.g. ``mempalace-help.md`` →
``/mempalace-help``), so command files do NOT need a ``name``
frontmatter field — only ``description`` is required.
Run with::
uv run pytest tests/test_cursor_plugin_manifest.py -v
All tests are pure file inspection (no subprocesses, no network) and
take milliseconds. They run on every CI platform without needing
Cursor itself.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
PLUGIN_DIR = REPO_ROOT / ".cursor-plugin"
MANIFEST_PATH = PLUGIN_DIR / "plugin.json"
MARKETPLACE_PATH = PLUGIN_DIR / "marketplace.json"
MCP_PATH = PLUGIN_DIR / "mcp.json"
README_PATH = PLUGIN_DIR / "README.md"
# Component directories: canonical location is at the plugin root (repo root),
# NOT inside .cursor-plugin/. Cursor's default discovery requires real
# directories at the plugin root; .cursor-plugin/ symlinks back to these.
SKILLS_DIR = REPO_ROOT / "skills"
COMMANDS_DIR = REPO_ROOT / "commands"
# The slugs we promise to ship. The README's "Available Slash Commands"
# table is the user-facing contract; if you add/remove a command,
# update both the README and this list.
EXPECTED_COMMAND_NAMES = {
"mempalace-help",
"mempalace-init",
"mempalace-mine",
"mempalace-search",
"mempalace-status",
}
# Per cursor.com/docs/reference/plugins: "Plugin identifier. Lowercase,
# kebab-case (alphanumerics, hyphens, and periods). Must start and end
# with an alphanumeric character."
KEBAB_RE = re.compile(r"^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$")
# Cursor's submission checklist explicitly forbids these in manifest
# paths: "All paths in manifest are relative and valid (no `..`, no
# absolute paths)." Treat both as hard failures rather than warnings —
# the marketplace review bot would reject the plugin otherwise.
_FORBIDDEN_PATH_FRAGMENTS = ("..",)
# ── helpers ─────────────────────────────────────────────────────────
def _parse_frontmatter(text: str) -> tuple[dict, str]:
"""Split a markdown file with YAML frontmatter into ``(meta, body)``.
The frontmatter must start at byte 0 with a literal ``---\\n`` and
close with another ``---\\n`` line. Files without frontmatter are
treated as having an empty ``meta`` dict so the caller can decide
whether that's acceptable for the file type under test.
"""
if not text.startswith("---\n"):
return {}, text
end = text.find("\n---\n", 4)
if end == -1:
return {}, text
raw = text[4:end]
body = text[end + 5 :]
parsed = yaml.safe_load(raw) or {}
if not isinstance(parsed, dict):
raise AssertionError(f"Frontmatter parsed to {type(parsed).__name__}, expected dict")
return parsed, body
def _is_safe_relative(path_str: str) -> bool:
"""Return True iff ``path_str`` is a relative, ``..``-free path."""
if not isinstance(path_str, str) or not path_str:
return False
p = Path(path_str)
if p.is_absolute():
return False
return not any(part in _FORBIDDEN_PATH_FRAGMENTS for part in p.parts)
# ── plugin.json ─────────────────────────────────────────────────────
class TestPluginManifest:
def test_manifest_exists(self):
assert MANIFEST_PATH.is_file(), f"{MANIFEST_PATH} is missing"
def test_manifest_is_valid_json(self):
json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
def test_manifest_has_required_name_field(self):
data = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
assert isinstance(data.get("name"), str) and data["name"], (
"plugin.json must have a non-empty 'name' (only required field per Cursor schema)"
)
def test_manifest_name_is_kebab_case(self):
data = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
assert KEBAB_RE.match(data["name"]), (
f"name must be lowercase kebab-case; got {data['name']!r}"
)
def test_manifest_has_recommended_optional_fields(self):
"""``description`` and ``author.name`` aren't required by the
schema but ARE required by the submission checklist, so failing
early here saves a round-trip with the marketplace reviewers."""
data = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
assert isinstance(data.get("description"), str) and data["description"]
author = data.get("author")
assert isinstance(author, dict) and isinstance(author.get("name"), str)
assert author["name"], "author.name must be non-empty"
def test_manifest_version_matches_package_version(self):
"""plugin.json::version must track the installed package version
so users can tell at a glance which mempalace they're getting.
The package version lives in ``mempalace/version.py`` as the
single source of truth (per CLAUDE.md). When we bump there, we
bump here; otherwise the plugin says one thing and `pip show`
says another, and bug reports become harder to triage.
"""
from mempalace.version import __version__ as pkg_version
data = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
assert data.get("version") == pkg_version, (
f"plugin.json version ({data.get('version')!r}) must match "
f"mempalace.version.__version__ ({pkg_version!r})"
)
@pytest.mark.parametrize("field", ["skills", "commands", "mcpServers"])
def test_manifest_component_paths_are_safe(self, field: str):
"""Every path the manifest declares must be relative + ``..``-free.
Cursor's submission checklist rejects ``..`` or absolute paths
outright. We check here so a typo doesn't fail review.
"""
data = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
value = data.get(field)
if value is None:
return # optional — if missing, default discovery kicks in
if isinstance(value, str):
paths = [value]
elif isinstance(value, list):
paths = [v for v in value if isinstance(v, str)]
else:
return # inline object form; nothing to validate path-wise
for p in paths:
assert _is_safe_relative(p), f"{field}: {p!r} must be relative and contain no '..'"
@pytest.mark.parametrize(
"field,expected_type",
[
("skills", "dir"),
("commands", "dir"),
("mcpServers", "file"),
],
)
def test_manifest_component_paths_resolve(self, field: str, expected_type: str):
"""Every component path must point at a real on-disk target.
Use REPO_ROOT (not PLUGIN_DIR) as the resolution base because
Cursor resolves manifest paths against the plugin root, which
for our layout is the repo root (the dir containing
``.cursor-plugin/``).
"""
data = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
value = data.get(field)
if not isinstance(value, str):
return # inline object form or absent
target = (REPO_ROOT / value).resolve()
if expected_type == "dir":
assert target.is_dir(), f"{field}={value!r} -> {target} is not a directory"
else:
assert target.is_file(), f"{field}={value!r} -> {target} is not a file"
# ── marketplace.json ────────────────────────────────────────────────
class TestMarketplaceManifest:
def test_marketplace_exists(self):
assert MARKETPLACE_PATH.is_file(), f"{MARKETPLACE_PATH} is missing"
def test_marketplace_is_valid_json(self):
json.loads(MARKETPLACE_PATH.read_text(encoding="utf-8"))
def test_marketplace_required_fields(self):
data = json.loads(MARKETPLACE_PATH.read_text(encoding="utf-8"))
assert isinstance(data.get("name"), str) and data["name"]
owner = data.get("owner")
assert isinstance(owner, dict) and isinstance(owner.get("name"), str)
plugins = data.get("plugins")
assert isinstance(plugins, list) and 1 <= len(plugins) <= 500
def test_marketplace_lists_mempalace_plugin(self):
"""The marketplace must list our plugin, and the listed name must
match the actual ``plugin.json::name`` — otherwise the marketplace
resolver looks up ``my-plugin/.cursor-plugin/plugin.json`` and
gets a name mismatch, which Cursor rejects at install time.
"""
data = json.loads(MARKETPLACE_PATH.read_text(encoding="utf-8"))
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
names = {p.get("name") for p in data["plugins"] if isinstance(p, dict)}
assert manifest["name"] in names, (
f"marketplace.json plugins list does not include {manifest['name']!r}"
)
# ── mcp.json ────────────────────────────────────────────────────────
class TestMcpConfig:
def test_mcp_config_exists(self):
assert MCP_PATH.is_file(), f"{MCP_PATH} is missing"
def test_mcp_config_is_valid_json(self):
json.loads(MCP_PATH.read_text(encoding="utf-8"))
def test_mcp_config_wraps_servers_under_mcpservers_key(self):
"""Per cursor.com/docs/reference/plugins#mcp-servers, the MCP
config file must contain server entries under a ``mcpServers``
key. Using the flat shape (used by Claude's ``.mcp.json``) here
would silently fail to register the server with Cursor.
"""
data = json.loads(MCP_PATH.read_text(encoding="utf-8"))
assert "mcpServers" in data and isinstance(data["mcpServers"], dict), (
"mcp.json must wrap servers under an 'mcpServers' object"
)
def test_mcp_config_registers_mempalace_server(self):
data = json.loads(MCP_PATH.read_text(encoding="utf-8"))
servers = data["mcpServers"]
assert "mempalace" in servers, "mcp.json must register a server named 'mempalace'"
entry = servers["mempalace"]
assert isinstance(entry, dict) and isinstance(entry.get("command"), str)
assert entry["command"] == "mempalace-mcp", (
f"mempalace server command must be 'mempalace-mcp' (the binary "
f"shipped by the package); got {entry.get('command')!r}"
)
# ── skills/ ─────────────────────────────────────────────────────────
class TestSkills:
def test_skills_dir_exists(self):
assert SKILLS_DIR.is_dir()
def test_at_least_one_skill_present(self):
skill_files = list(SKILLS_DIR.glob("*/SKILL.md"))
assert skill_files, (
f"{SKILLS_DIR} must contain at least one <name>/SKILL.md "
"(otherwise Cursor's discovery treats the plugin as having no skills)"
)
def test_mempalace_skill_exists(self):
assert (SKILLS_DIR / "mempalace" / "SKILL.md").is_file()
def test_each_skill_has_valid_frontmatter(self):
"""Every SKILL.md must declare ``name`` (kebab-case) and a
non-empty ``description``. Skills missing these fields silently
fail to register in Cursor's skill picker (#1410 equivalent).
"""
for skill_path in SKILLS_DIR.glob("*/SKILL.md"):
text = skill_path.read_text(encoding="utf-8")
meta, body = _parse_frontmatter(text)
ctx = f"{skill_path.relative_to(REPO_ROOT)}"
assert meta, f"{ctx}: missing YAML frontmatter"
assert isinstance(meta.get("name"), str) and meta["name"], (
f"{ctx}: 'name' must be a non-empty string"
)
assert KEBAB_RE.match(meta["name"]), (
f"{ctx}: name must be lowercase kebab-case; got {meta['name']!r}"
)
assert isinstance(meta.get("description"), str) and meta["description"], (
f"{ctx}: 'description' must be a non-empty string"
)
assert body.strip(), f"{ctx}: body must not be empty"
def test_skill_name_matches_directory(self):
"""The skill's directory name should equal the frontmatter
``name`` — Cursor displays the directory name in the picker and
the frontmatter name in the API; mismatches confuse both users
and the agent.
"""
for skill_path in SKILLS_DIR.glob("*/SKILL.md"):
meta, _ = _parse_frontmatter(skill_path.read_text(encoding="utf-8"))
dir_name = skill_path.parent.name
assert meta.get("name") == dir_name, (
f"{skill_path.relative_to(REPO_ROOT)}: name={meta.get('name')!r} "
f"must match directory {dir_name!r}"
)
# ── commands/ ───────────────────────────────────────────────────────
class TestDefaultDiscoveryLayout:
"""Cursor discovers plugin components from real ``commands/``,
``skills/``, and ``mcp.json`` at the *plugin root* (our repo root).
These must be real directories/files — Cursor does not follow
symlinks for local-plugin component discovery. We verified this
behaviour by comparing the cached Cloudflare plugin structure
(all real dirs) against our earlier broken symlink-only attempt.
"""
def test_commands_is_real_dir_at_plugin_root(self):
target = REPO_ROOT / "commands"
assert target.is_dir(), "commands/ missing at repo root"
assert not target.is_symlink(), (
"commands/ must be a real directory, not a symlink — "
"Cursor does not follow symlinks for local-plugin discovery"
)
def test_skills_is_real_dir_at_plugin_root(self):
target = REPO_ROOT / "skills"
assert target.is_dir(), "skills/ missing at repo root"
assert not target.is_symlink(), (
"skills/ must be a real directory, not a symlink — "
"Cursor does not follow symlinks for local-plugin discovery"
)
def test_mcp_json_is_real_file_at_plugin_root(self):
target = REPO_ROOT / "mcp.json"
assert target.is_file(), "mcp.json missing at repo root"
assert not target.is_symlink(), (
"mcp.json must be a real file, not a symlink — "
"Cursor does not follow symlinks for local-plugin discovery"
)
class TestCommands:
def test_commands_dir_exists(self):
assert COMMANDS_DIR.is_dir()
def test_command_set_matches_promised_set(self):
"""The README documents exactly five slash commands. The files
on disk must match that set — no more, no fewer — otherwise the
README is lying to users.
Cursor derives the slash-command slug from the filename stem, so
we compare stems, not frontmatter ``name`` values.
"""
actual = {cmd_path.stem for cmd_path in COMMANDS_DIR.glob("*.md")}
assert actual == EXPECTED_COMMAND_NAMES, (
f"Command file stem set drifted from promised set. "
f"On disk: {sorted(actual)}. "
f"Expected: {sorted(EXPECTED_COMMAND_NAMES)}."
)
def test_each_command_has_valid_frontmatter(self):
"""Every command file must have YAML frontmatter with a non-empty
``description`` and a non-empty body.
Cursor derives the slash-command slug from the filename stem, so
a ``name`` field is intentionally absent — the ``description``
field is what Cursor shows in the command picker.
"""
for cmd_path in COMMANDS_DIR.glob("*.md"):
text = cmd_path.read_text(encoding="utf-8")
meta, body = _parse_frontmatter(text)
ctx = f"{cmd_path.relative_to(REPO_ROOT)}"
assert meta, f"{ctx}: missing YAML frontmatter"
assert isinstance(meta.get("description"), str) and meta["description"], (
f"{ctx}: 'description' must be a non-empty string"
)
assert body.strip(), f"{ctx}: body must not be empty"
def test_each_command_name_prefixed_with_mempalace(self):
"""Cursor commands are global (not plugin-namespaced), so every
command file must be named ``mempalace-*.md`` to avoid colliding
with built-in or other-plugin commands.
The slash-command slug is the filename stem, so
``mempalace-help.md`` → ``/mempalace-help``.
"""
for cmd_path in COMMANDS_DIR.glob("*.md"):
stem = cmd_path.stem
assert stem.startswith("mempalace-"), (
f"{cmd_path.relative_to(REPO_ROOT)}: filename stem {stem!r} "
"must be prefixed with 'mempalace-' to avoid global-namespace collisions"
)
# ── README.md ───────────────────────────────────────────────────────
class TestReadme:
def test_readme_exists(self):
assert README_PATH.is_file(), f"{README_PATH} is missing"
def test_readme_documents_every_command(self):
"""If the README has a command table, every command we ship
must be listed in it. This catches the drift case where someone
adds a command file but forgets to update the docs."""
text = README_PATH.read_text(encoding="utf-8")
missing = [name for name in EXPECTED_COMMAND_NAMES if f"/{name}" not in text]
assert not missing, f"README does not document: {missing}"
def test_readme_cross_references_hooks_install_path(self):
"""Hooks are deliberately NOT part of the plugin (they're wired
via hooks/cursor/install.sh). The README must tell users where
to go for that, otherwise users will assume the plugin already
installed the hooks and wonder why nothing saves.
"""
text = README_PATH.read_text(encoding="utf-8")
assert "hooks/cursor/install.sh" in text, (
"README must reference hooks/cursor/install.sh so users know how to enable auto-save"
)
+1
View File
@@ -60,6 +60,7 @@ export default withMermaid(
{ text: 'OpenClaw Skill', link: '/guide/openclaw' },
{ text: 'Local Models', link: '/guide/local-models' },
{ text: 'Auto-Save Hooks', link: '/guide/hooks' },
{ text: 'Cursor IDE Hooks', link: '/guide/cursor-hooks' },
{ text: 'Configuration', link: '/guide/configuration' },
],
},
+298
View File
@@ -0,0 +1,298 @@
# Cursor IDE Hooks
Three hooks for the [Cursor](https://cursor.com) IDE that save memories
automatically and inject recall context at session start. No manual "save"
commands needed.
These are additive to the existing [Claude Code + Codex hooks](/guide/hooks).
You can run both — they share the same `~/.mempalace/hook_state/`
directory and the same kill switches.
::: tip Pair this with the Cursor plugin
The hooks here only handle the auto-save side. To also get MemPalace's
MCP server, slash commands (`/mempalace-search`, etc.), and the
guided `mempalace` skill, install the bundled
[Cursor plugin](https://github.com/MemPalace/mempalace/blob/main/.cursor-plugin/README.md) —
it's the `.cursor-plugin/` folder at the repo root, dropped into
`~/.cursor/plugins/local/mempalace`. The plugin and the hooks are
orthogonal: install whichever you want, in any order. The plugin
deliberately does **not** wire hooks itself because Cursor's hooks
system is configured per-user/per-project (in `~/.cursor/hooks.json`),
not per-plugin.
:::
## What They Do
| Hook | When It Fires | What Happens |
|------|---------------|--------------|
| **Wake Hook** | `sessionStart` — when a new Cursor conversation opens | Returns `additional_context` telling the agent to recall scoped to the wing inferred from the workspace root. Cursor-only — Claude Code has no equivalent. |
| **Save Hook** | `stop` — after every agent turn | Counts stop invocations per conversation. Every 15 (default), emits a `followup_message` telling the agent to file the session into MemPalace and write a diary entry. |
| **PreCompact Hook** | `preCompact` — right before context compaction | Runs `mempalace mine` synchronously on the transcript before compaction summarises it. Drops a pending-save marker so the next stop forces a save followup. |
**Two-layer capture:** the save and precompact hooks both mine the JSONL
transcript directly into the palace (capturing verbatim tool output — Shell
results, search findings, build errors). The save hook also nudges the AI
to write structured drawers and a diary entry. Belt-and-suspenders.
## Install — Cursor
The fastest path is the installer that ships in the repo.
Preview the change first (writes nothing, just prints the would-be JSON):
```bash
hooks/cursor/install.sh --scope user --dry-run
```
User scope — applies globally, writes `~/.cursor/hooks.json`:
```bash
hooks/cursor/install.sh --scope user
```
Or project scope — only this repo, writes `<repo>/.cursor/hooks.json`:
```bash
hooks/cursor/install.sh --scope project --target /path/to/your/repo
```
The installer copies the three hook scripts to `~/.mempalace/hooks/cursor/`,
merges the entries into your `hooks.json`, and preserves any unrelated
hooks already in that file. Re-running is idempotent. Pass `--variant
minimal` for the `stop`-only setup, or `--uninstall` to remove the
MemPalace entries (leaves other hooks intact).
### Manual install — `~/.cursor/hooks.json` (user scope)
```json
{
"version": 1,
"hooks": {
"sessionStart": [
{ "command": "/absolute/path/to/hooks/cursor/mempal_wake_hook_cursor.sh" }
],
"stop": [
{
"command": "/absolute/path/to/hooks/cursor/mempal_save_hook_cursor.sh",
"loop_limit": 1
}
],
"preCompact": [
{ "command": "/absolute/path/to/hooks/cursor/mempal_precompact_hook_cursor.sh" }
]
}
}
```
### Manual install — `.cursor/hooks.json` (project scope)
Identical content. Project hooks load in any trusted workspace and are
checked into version control with the project. Cloud agents also load
project hooks.
Make the scripts executable once:
```bash
chmod +x hooks/cursor/mempal_save_hook_cursor.sh \
hooks/cursor/mempal_precompact_hook_cursor.sh \
hooks/cursor/mempal_wake_hook_cursor.sh
```
Cursor watches `hooks.json` and reloads automatically after a save. If
hooks still do not fire, restart Cursor and check the Hooks panel in
Settings → Hooks.
## Configuration
All knobs are environment variables. Defaults match the Claude Code hooks
where they overlap.
- **`MEMPAL_SAVE_INTERVAL=15`** — number of `stop` events between save
followups. Lower = more frequent saves, higher = less interruption.
- **`MEMPAL_STATE_DIR`** — where the hook keeps counter files, the
pending-save marker, and `cursor_hook.log`. Defaults to
`~/.mempalace/hook_state/`.
- **`MEMPAL_DIR`** — optional project directory (code, notes, docs) to
also mine on each save trigger, with `--mode projects`. The transcript
is always mined regardless — `MEMPAL_DIR` is purely additive.
- **`MEMPAL_PYTHON`** — path to a Python 3 interpreter. The hook's own
JSON parsing and the install script's JSON merge use this. Resolution
order: `$MEMPAL_PYTHON` → `command -v python3` → bare `python3`. Set
this when Cursor is launched from a GUI on macOS and the inherited
PATH lacks the Python where you installed MemPalace.
- **`MEMPAL_DISABLE_HOOK=1`** — emergency kill switch. Disables all
three hooks; they emit `{}` and exit 0.
- **`MEMPALACE_HOOKS_AUTO_SAVE=false`** — same effect as
`MEMPAL_DISABLE_HOOK=1`. Also honoured via `~/.mempalace/config.json`:
```json
{ "hooks": { "auto_save": false } }
```
## How It Works
### Wake Hook (`sessionStart`)
```
Cursor opens new conversation → sessionStart fires
↓
Hook reads workspace_roots[0]
↓
Infers wing = basename(workspace_root)
↓
{"additional_context": "scope recall to wing=<...>"}
↓
Agent reads additional_context before first turn
↓
Agent calls mempalace_search + mempalace_diary_read
wing-scoped on the first relevant question
```
Cursor's `sessionStart` is fire-and-forget — the agent loop does not wait
for a blocking response and does not consume `continue` / `user_message`.
But it does honour `additional_context`, and that is the only field
MemPalace emits.
### Save Hook (`stop` event)
```
User sends message → agent responds → Cursor fires stop hook
↓
Hook reads loop_count from stdin
↓
┌─── loop_count > 0 (our own followup running) ──→ echo "{}"
│
└─── loop_count == 0
↓
Check pending-save marker from preCompact
↓
┌── marker present ──→ delete + emit followup_message
│
└── no marker
↓
Atomic counter++ for this conversation_id
↓
┌── counter % SAVE_INTERVAL != 0 ──→ echo "{}"
│
└── counter % SAVE_INTERVAL == 0
↓
Background: mempalace mine <transcript_dir>
↓
Emit {"followup_message": "save key topics..."}
↓
Cursor auto-submits followup as next user turn
↓
Agent files drawers + writes diary
↓
Agent stops; stop fires again with loop_count = 1
↓
Hook sees loop_count > 0 → echo "{}" → agent stops
```
The `loop_count > 0` short-circuit prevents infinite loops: emit once →
agent saves → stops → we see `loop_count = 1` → we let it through. This
is the Cursor equivalent of Claude Code's `stop_hook_active` flag. The
`loop_limit: 1` in `hooks.json` is defense-in-depth on top.
### PreCompact Hook
```
Context window near full → Cursor fires preCompact (observational)
↓
Synchronously: mempalace mine <transcript_dir>
↓
Drop pending-save marker for this conversation_id
↓
{"user_message": "transcript snapshotted..."}
↓
Compaction proceeds (we cannot block it)
↓
Next stop event picks up the marker → forces save
```
Cursor's `preCompact` is documented as **observational only** — its only
output field is `user_message`, with no `followup_message` and no way to
block. That is fundamentally different from Claude Code's `PreCompact`
which can block until the AI has saved. We work around the limitation by
mining the verbatim transcript synchronously (zero LLM cost) and queueing
a save nudge for the next agent turn.
## Cursor-only extras
The features below are not available in the Claude Code or Codex hooks
because their hook surfaces do not expose the necessary events.
- **Session-start recall via `sessionStart`.** The wake hook injects
wing-scoped recall guidance into the conversation's initial system
context, so the agent searches the palace before answering anything
that touches past work. Verified output field — see the [Cursor hooks
reference](https://cursor.com/docs/hooks.md) section "sessionStart".
- **Per-script `loop_limit`.** Cursor's `loop_limit` (default 5,
configurable per script) is a hard cap on how many auto-followups
Cursor will issue. MemPalace sets it to `1` in the example
`hooks.json` as defense-in-depth on top of its own `loop_count`
check.
- **Inferred wing from `workspace_roots`.** Both the wake hook and the
save hook use `basename(workspace_roots[0])` to scope memory
operations. A user with multiple Cursor workspaces gets per-project
wings without any manual configuration.
## Debugging
```bash
cat ~/.mempalace/hook_state/cursor_hook.log
```
Example output (ISO-8601 timestamps, event + conversation id, message):
```
[2026-05-27T02:16:01Z] [event=sessionStart] [conv=abc123] workspace=/Users/me/proj wing=proj
[2026-05-27T02:21:33Z] [event=stop] [conv=abc123] counter 0 -> 1 (interval=15)
[2026-05-27T02:42:09Z] [event=stop] [conv=abc123] counter 14 -> 15 (interval=15)
[2026-05-27T02:42:09Z] [event=stop] [conv=abc123] TRIGGERING SAVE at counter=15
[2026-05-27T02:42:11Z] [event=stop] [conv=abc123] loop_count>0; letting agent stop
[2026-05-27T03:05:44Z] [event=preCompact] [conv=abc123] trigger=auto transcript=/Users/me/.cursor/.../transcript.txt
[2026-05-27T03:05:46Z] [event=stop] [conv=abc123] consumed pending-save marker (post-compaction)
```
When a hook can't parse its stdin (corrupt payload, future Cursor schema
change), the raw input — capped at 4096 bytes, mode 0600 — lands at:
```
~/.mempalace/hook_state/cursor_last_input.log
~/.mempalace/hook_state/cursor_last_python_err.log
```
Both are overwritten on each failure, never appended, so a repeating
misconfiguration cannot grow disk usage.
## Cost
**Zero extra tokens.** The hooks are bash scripts that run locally. They
do not call any API. The `followup_message` the save hook emits is a
normal user turn — it counts the same as any other user message and does
not invoke any extra LLM call beyond the one the user would otherwise
make.
## Known limitations
- **Hooks load at session start.** Cursor watches `hooks.json` and reloads
the wiring when the file changes, but for the freshly-loaded hook
scripts to take effect on an existing conversation you usually have to
start a new conversation. This matches the behaviour of Claude Code's
hook lifecycle.
- **`preCompact` cannot block.** See the diagram above. The
pending-save marker is the workaround.
- **Transcript file format is opaque.** Cursor does not document the
schema of the file at `transcript_path`. MemPalace's `mempalace mine`
command handles it via its normaliser layer; the hooks themselves never
parse the transcript directly.
## Related
- [Auto-Save Hooks (Claude Code + Codex)](/guide/hooks) — the analogous
feature for those tools.
- [`hooks/cursor/STDIN_SHAPE.md`](https://github.com/MemPalace/mempalace/blob/develop/hooks/cursor/STDIN_SHAPE.md)
— per-event JSON schema with citations.
- [Claude Code Retention](/guide/claude-code-retention) — broader
setup checklist if you mix Cursor with Claude Code.