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:
co-authored by
Cursor
parent
6957c7e599
commit
071fa015e0
@@ -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.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../commands
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "mempalace-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../skills
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"stop": [
|
||||
{
|
||||
"command": "$HOME/.mempalace/hooks/cursor/mempal_save_hook_cursor.sh",
|
||||
"loop_limit": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Executable
+391
@@ -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
|
||||
@@ -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
@@ -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."
|
||||
)
|
||||
}))
|
||||
'
|
||||
Executable
+195
@@ -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
|
||||
Executable
+82
@@ -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"
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "mempalace-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`).
|
||||
@@ -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()
|
||||
@@ -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}"
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user