diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json new file mode 100644 index 000000000..6c412994d --- /dev/null +++ b/.cursor-plugin/marketplace.json @@ -0,0 +1,19 @@ +{ + "name": "agentmemory", + "owner": { + "name": "Rohit Ghumare", + "github": "rohitg00" + }, + "metadata": { + "description": "Persistent memory for AI coding agents", + "version": "0.9.28" + }, + "plugins": [ + { + "name": "agentmemory", + "source": "./plugin", + "description": "Cursor lifecycle hooks + MCP + skills for agentmemory", + "version": "0.9.28" + } + ] +} diff --git a/.gitignore b/.gitignore index ba6af995b..24fd0ff9d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,8 @@ dist/ .DS_Store .claude/ -plugin/scripts/*.map -plugin/scripts/*.d.mts +plugin/scripts/**/*.map +plugin/scripts/**/*.d.mts data/ !eval/data/ !eval/data/** diff --git a/README.md b/README.md index 247511929..5b93c4c5c 100644 --- a/README.md +++ b/README.md @@ -570,6 +570,19 @@ copilot plugin install rohitg00/agentmemory:plugin `agentmemory connect copilot-cli` merges `mcpServers.agentmemory` into `~/.copilot/mcp-config.json` (or `$COPILOT_HOME/mcp-config.json` when `COPILOT_HOME` is set) and preserves existing servers. This adapter is Windows-safe even though other `connect` adapters still require manual Windows setup. Copilot picks up the MCP server on next launch or after `/mcp`. Install the plugin as well when you want the full hook/skill experience. +### Cursor + +```bash +# MCP wiring +agentmemory connect cursor +``` + +Then add the native hooks: **Settings → Plugins → Add marketplace** → select an agentmemory checkout (the directory containing `.cursor-plugin/marketplace.json`), enable the **agentmemory** plugin, and reload the window. + +Cursor runs the same lifecycle hooks as every other host — they are the canonical `plugin/scripts/*.mjs`, not a separate implementation. A thin adapter in front of them resolves which project a session belongs to, which Cursor does not reliably report: its payload `cwd` can be `.cursor`, the Cursor install directory, or missing entirely. Do not also wire hooks into `~/.cursor/hooks.json` — the plugin already dispatches them, and both copies firing records every observation twice. + +Full guide: [`integrations/cursor/`](integrations/cursor/) +
OpenClaw (paste this prompt) @@ -656,7 +669,7 @@ The agentmemory entry is the **same MCP server block** across every host that us | Agent | Config file | Notes | |---|---|---| -| **Cursor** | `~/.cursor/mcp.json` | Merge into `mcpServers`. One-click deeplink also available on the website. | +| **Cursor** | `~/.cursor/mcp.json` | Merge into `mcpServers`. One-click deeplink also available on the website. Cursor also runs the native lifecycle hooks — see [Cursor](#cursor). | | **Claude Desktop** | `claude_desktop_config.json` (Application Support) | Merge into `mcpServers`. Restart Claude Desktop after editing. | | **Cline / Roo Code / Kilo Code** | Cline MCP settings (Settings UI → MCP Servers → Edit) | Same `mcpServers` block. | | **Windsurf** | `~/.codeium/windsurf/mcp_config.json` | Same `mcpServers` block. | diff --git a/integrations/cursor/README.md b/integrations/cursor/README.md new file mode 100644 index 000000000..ebf834d53 --- /dev/null +++ b/integrations/cursor/README.md @@ -0,0 +1,322 @@ +# agentmemory for Cursor + +Cursor-native plugin: **lifecycle hooks**, **MCP**, and shared **skills**. + +The hooks are the canonical ones. `plugin/scripts/*.mjs` — compiled from +`src/hooks/*.ts` and shared with Claude Code, Codex and Copilot — do all the +work. Cursor gets a thin adapter in front of them, compiled from +`src/hooks/cursor/*.ts` through the same tsdown pipeline, and nothing else. + +--- + +## Quick local install + +Prereq: `AGENTMEMORY_URL` and `AGENTMEMORY_SECRET`, in the environment or in +`~/.agentmemory/.env`. + +```bash +npm run build +node integrations/cursor/install-local.mjs +node integrations/cursor/verify-flow.mjs +``` + +Then in Cursor: + +1. **Settings → Plugins → Add marketplace** → select the **repo root** + `` + (must contain `.cursor-plugin/marketplace.json`) +2. Enable plugin **agentmemory** +3. **Disable** the old `rohitg00/agentmemory` marketplace entry if both are on +4. **Developer: Reload Window** +5. Confirm the hooks log shows `scripts/cursor/run-hook.mjs` or `run-detached.mjs` + +Only after plugin hooks are confirmed: + +```bash +node integrations/cursor/install-local.mjs --clear-user-hooks +``` + +--- + +## Layout + +```text +agentmemory/ ← marketplace root (git repo) + .cursor-plugin/marketplace.json + plugin/ + .cursor-plugin/plugin.json + hooks/hooks.cursor.json ← Cursor's camelCase lifecycle names + scripts/cursor/ ← built from src/hooks/cursor/ + run-hook.mjs ← the 8 synchronous hooks + run-detached.mjs ← stop / sessionEnd + scripts/*.mjs ← canonical hooks, shared with every agent + skills/ + src/hooks/cursor/ ← the only Cursor-specific source + run-hook.ts, run-detached.ts ← CLI entrypoints + delegate.ts ← event -> canonical hook dispatch + workspace.ts ← resolveWorkspace() + cursor-db.ts ← reads Cursor's own SQLite storage +integrations/cursor/ + install-local.mjs ← local marketplace + MCP wiring + verify-flow.mjs ← live smoke test against a daemon + close-stale-am-sessions.mjs ← dev utility + _env.mjs ← shared config loading +``` + +`workspace.ts` and `delegate.ts` are not build entrypoints; they are inlined +into each CLI, so the emitted files are self-contained exactly like every +other hook artifact. + +--- + +## What a hook call actually does + +```text +Cursor + │ reads plugin/hooks/hooks.cursor.json, spawns a process, writes JSON to stdin + ▼ +run-hook.mjs run-detached.mjs stop|sessionEnd + │ │ writes the payload to a temp file, + │ │ spawns a detached worker, returns + │ ▼ + │ (background worker) + ▼ +resolveWorkspace(payload) ──► { project, cwd } + │ + │ AGENTMEMORY_PROJECT_NAME= in the environment + │ cwd= merged into the payload + ▼ +node plugin/scripts/.mjs ← unmodified + │ + ▼ +POST $AGENTMEMORY_URL/agentmemory/... +``` + +`resolveProject()` in the canonical hooks reads `AGENTMEMORY_PROJECT_NAME` +before falling back to git or cwd. That one environment variable is the whole +delegation mechanism — it is how Cursor-specific knowledge reaches a hook that +knows nothing about Cursor. + +### Differences from the Claude Code hooks + +| | Claude Code | Cursor | +|---|---|---| +| Event names | `PostToolUse` | `postToolUse` | +| Config shape | `[{hooks:[{type,command}]}]` | `[{command}]` | +| Plugin root var | `${CLAUDE_PLUGIN_ROOT}` | `${CURSOR_PLUGIN_ROOT}` | +| `preToolUse` matcher | `Edit\|Write\|Read\|Glob\|Grep` | …`\|Shell` (Cursor has a Shell tool) | +| Session id field | `session_id` | `session_id` or `sessionId` | +| Working directory | reliable | **not reliable — see below** | + +The extra process hop costs about 100ms per synchronous hook (one additional +Node startup plus resolution). The canonical hooks already spend ~500ms of +their own deliberately, waiting for their fire-and-forget POST to leave, so +this is roughly a 20% overhead on something the user does not wait for. It +buys zero duplicated hook logic, which was the explicit trade. + +### Why `stop` and `sessionEnd` are detached + +`sessionEnd` fans out to four daemon endpoints — session end, crystals, +consolidate pipeline, bridge sync. Cursor kills its hook process tree when the +window closes, so run inline that work is cut off halfway and the session's +memories are lost. + +`run-detached.mjs` runs the same file twice in two roles. The parent reads +stdin, writes the payload to a `0600` temp file, spawns itself with +`detached: true`, `stdio: 'ignore'` and `AM_HOOK_WORKER=1`, then exits as soon +as the spawn is confirmed. The worker reads the file back, deletes it, and +delegates normally under a watchdog. + +* Temp file, not a pipe, because `stdio: 'ignore'` is what keeps the parent + from being held open by the child. +* `detached` puts the worker in its own process group, out of reach of + Cursor's teardown. +* `unref()` stops the parent's event loop waiting on it. + +Measured: the parent returns in **99ms** where an inline `stop` would hold +Cursor for about **1500ms**. + +The eight synchronous hooks stay inline: they are fast, and some need to write +to stdout for Cursor to read (`sessionStart` can inject project context). + +--- + +## Why there is a workspace resolver at all + +Every hook has to answer one question: **which project is this?** For Claude +Code that is 20 lines — read `cwd`, run `git rev-parse --show-toplevel`, take +the basename. + +Cursor does not give hooks a trustworthy working directory. In practice the +payload contains one of: + +* `.cursor` — Cursor's own metadata directory, not the project; +* the **Cursor install directory**, leaked through `VSCODE_CWD`, which resolves + to a project literally named `cursor`; +* nothing at all, just a `tool_input` holding a *file* path; +* a session id, with the workspace recorded somewhere else entirely. + +Getting this wrong is not a crash. It silently files a user's memories under +the wrong project, or under `.cursor`, and nobody notices until the data is +already there. That is why the resolver is the largest piece of this +integration, and why every layer either verifies its answer against the +filesystem or refuses to answer. + +### The chain + +Layers are tried in order. `resolveWorkspace` caches the result under the +session id, so the whole chain runs at most **once per session** — every +later hook in that session is a single JSON read. + +| # | Layer | Basis | Notes | +|---|---|---|---| +| 1 | Session cache | previous answer | `~/.cursor/hooks/.agentmemory-session-cache.json`, written under a lock | +| 2 | Payload paths | `workspace_roots`, `workspace_folder`, `cwd`, … | eight aliases because Cursor is inconsistent; `cwd` is deliberately ranked low | +| 3 | `tool_input` paths | any path-shaped string in the tool arguments | file paths resolve to their directory | +| 4 | **Cursor's database** | `composer.composerHeaders` | exact, no inference — see below | +| 5 | Transcript directory name | `~/.cursor/projects//` | slug is the workspace path with separators flattened | +| 6 | Transcript contents | most-mentioned git root | a guess; needs ≥3 votes | +| 7 | Environment | `CURSOR_WORKSPACE_ROOT`, `PWD`, `VSCODE_CWD` | last, because `VSCODE_CWD` lies | +| 8 | `unknown-project` | — | the honest answer when nothing else is | + +Every candidate then passes the same gate: not `.cursor`, not an IDE install +path, not an OS directory, not a bare drive root, and not another agent's +state directory. + +That last one is narrower than it sounds. Sessions were landing under +`.codex`, but rejecting every dot-named directory would be wrong — plenty of +real projects are dot-named, from `~/.dotfiles` and `~/.emacs.d` to GitHub's +convention of a repository literally called `.github`. What separates those +from `~/.codex` or `~/.vscode` is not the name but that a human deliberately +version controls them, so the rule is **a dot-named directory that is not a +git repository**. No list of tool names to keep current as new agents ship. + +Layers 4–6 additionally require the directory to **exist exactly**. Climbing to +whatever ancestor survives is right for a file path out of `tool_input`, but +wrong for a workspace path a source claims is authoritative: a project that +moved away from `D:/Andrew/Code/cc-router` otherwise resolves to the project +`Code`, and every session from that machine piles up under it. + +Set **`AM_CURSOR_DEBUG=1`** to have the resolver print which layer answered. +Every layer returns the same shape, so a wrong project is otherwise +undiagnosable from the outside. + +--- + +## How Cursor stores this (reverse engineered) + +Layers 4–6 rely on Cursor's on-disk state. The layout, as of Cursor 3.x: + +```text +/ %APPDATA%/Cursor/User (Windows) + │ ~/Library/Application Support/Cursor/User (macOS) + │ ~/.config/Cursor/User (Linux) + ├── globalStorage/state.vscdb SQLite; all chat content + the 3.0 index + └── workspaceStorage// + ├── workspace.json {"folder":"file:///d%3A/repo"} + └── state.vscdb SQLite; pre-3.0 per-workspace chat list + +~/.cursor/projects//agent-transcripts//.jsonl +``` + +**A Cursor hook's `session_id` is the `composerId`.** That is what makes layer +4 possible: `composer.composerHeaders` in the global database maps a +`composerId` straight to `workspaceIdentifier.uri.fsPath`. One indexed read, +measured at 4–7ms even against a 3.6GB database, and cached for the rest of +the session. + +Two caveats, both handled: + +* **Cursor 3.0 (April 2026) centralised that index**, moving it out of the + per-workspace databases, and migrates each workspace lazily — when it is next + opened. Machines still on ≤2.6, or with workspaces untouched since the + upgrade, keep the old per-workspace `allComposers` array. On one real machine + 55 of 124 workspaces were still in the old format. Both shapes are read; the + legacy scan is ordered by recency and capped, because the workspace a live + session belongs to was touched moments ago. +* **Reading SQLite needs a driver.** `node:sqlite` exists from Node 22.5 and + this package supports Node ≥20; `better-sqlite3` is optional. With neither, + every path in `cursor-db.ts` returns null and layers 5–6 take over. This + layer is an accuracy upgrade, never a requirement. + +### The slug encoding (layer 5) + +`~/.cursor/projects/` names one directory per workspace after the workspace +path with every separator flattened to `-`: + +```text +D:\Andrew\Code\Github\agentmemory → d-Andrew-Code-Github-agentmemory +``` + +The encoding is lossy in one direction: a directory name may itself contain +hyphens, so `d-Andrew-Code-cc-router` is `D:/Andrew/Code/cc-router` and, just +as validly on paper, `D:/Andrew/Code/cc/router`. The disambiguator is the +filesystem — try every grouping of consecutive segments, descend only into +groupings that exist. Pruning turns what looks like a 2ⁿ search into the +handful of real directories on the machine: 39 slugs decode in 29ms, with zero +ambiguous results. + +### What layer 6 is for, and why it is last + +The transcript scan answers "which git root is mentioned most in this +conversation". That is a guess, and it was originally asked *before* the slug — +so a session about agentmemory that was actually running elsewhere resolved to +agentmemory. It now runs last, only existing directories vote, they vote for +their git root, and a winner needs at least three votes. Without the git-root +rule the winner is whatever generic directory came up most: `/bin`, `C:/Users`. + +It stays because it is the only layer that can place a **workspace-less +window** — a chat started from Cursor's welcome screen — that was nonetheless +editing real files. + +### Measured behaviour + +Over 300 real sessions on a Windows machine, resolving from **`session_id` +alone** (no payload at all — the worst case, which real hooks rarely hit +because layer 2 usually answers): + +| Resolved by | Sessions | +|---|---| +| Cursor's database (layer 4) | 10 | +| Slug decode (layer 5) | 8 | +| `unknown-project` | 12 | + +The twelve unknowns are deleted or moved projects and workspace-less windows — +cases where no correct answer exists. There were no wrong attributions. Before +this work both layers returned **zero** on Windows: the slug decoder bailed +unless the name started with `Users-`, and the transcript scan anchored its +regex on `$HOME`, which never matches a checkout on another drive. + +--- + +## Known Cursor limitations + +* `sessionEnd` on window close is unreliable in Cursor 3.13.x + (`MainThreadShellExec not initialized`). `stop` is what the pipeline relies + on; `verify-flow.mjs` treats `sessionEnd` as diagnostic only and needs + `--with-session-end` to exercise it at all. +* The daemon drops observations while it is busy — a `stop` leaves + `/summarize` running in the background, and observations posted into that + window can vanish. `verify-flow.mjs` retries with a varied `tool_input`, + because the dedup key is `(sessionId, tool_name, tool_input)` and a + byte-identical retry would be discarded as a duplicate. + +--- + +## Testing + +```bash +npx vitest run test/cursor-adapter.test.ts test/cursor-workspace.test.ts # no daemon +node integrations/cursor/verify-flow.mjs # live daemon +node integrations/cursor/close-stale-am-sessions.mjs --dry-run # dev utility +``` + +The unit tests are the authoritative check: they assert the delegation +contract against a local HTTP server and stand-in hooks, and cover the +resolver branches that were found misfiring on real sessions. + +One trap worth knowing if you extend them: **do not use `spawnSync`**. +`spawnSync` blocks the caller's event loop, so an in-process HTTP server +cannot accept the connection the child is opening. The requests only arrive +after the child has exited, which looks exactly like "the hook sent nothing" +and costs an afternoon. diff --git a/integrations/cursor/_env.mjs b/integrations/cursor/_env.mjs new file mode 100644 index 000000000..846b0d076 --- /dev/null +++ b/integrations/cursor/_env.mjs @@ -0,0 +1,54 @@ +/** + * Shared configuration loading for the Cursor integration scripts. + * + * The documented contract for agentmemory is that `AGENTMEMORY_URL` and + * `AGENTMEMORY_SECRET` come from the runtime environment, with + * `~/.agentmemory/.env` as the persistent default. These scripts end and + * rewrite sessions on whatever host they are pointed at, so reading the file + * only -- and ignoring an explicit `AGENTMEMORY_URL=... node ...` -- would + * quietly aim a destructive operation at the wrong server. + */ +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export const ENV_PATH = join(homedir(), '.agentmemory', '.env'); + +export function parseEnvFile(path = ENV_PATH) { + const out = {}; + if (!existsSync(path)) return out; + for (const line of readFileSync(path, 'utf-8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const idx = trimmed.indexOf('='); + if (idx === -1) continue; + out[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim(); + } + return out; +} + +/** Runtime environment first, `~/.agentmemory/.env` second, '' if neither. */ +export function loadConfig(keys, path = ENV_PATH) { + const file = parseEnvFile(path); + const out = {}; + for (const key of keys) { + const fromEnv = process.env[key]?.trim(); + out[key] = fromEnv || file[key] || ''; + } + return out; +} + +/** loadConfig for the two keys every script needs, with a uniform error. */ +export function requireConnection(path = ENV_PATH) { + const { AGENTMEMORY_URL: url, AGENTMEMORY_SECRET: secret } = loadConfig( + ['AGENTMEMORY_URL', 'AGENTMEMORY_SECRET'], + path + ); + if (!url || !secret) { + console.error( + `Missing AGENTMEMORY_URL / AGENTMEMORY_SECRET. Set them in the environment or in ${path}.` + ); + process.exit(1); + } + return { url, secret }; +} diff --git a/integrations/cursor/close-stale-am-sessions.mjs b/integrations/cursor/close-stale-am-sessions.mjs new file mode 100644 index 000000000..bf8fee2fb --- /dev/null +++ b/integrations/cursor/close-stale-am-sessions.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/** + * Close agentmemory sessions stuck in status "active" (sessionEnd hook never ran). + * + * Usage: + * node integrations/cursor/close-stale-am-sessions.mjs --dry-run + * node integrations/cursor/close-stale-am-sessions.mjs --min-age-hours 24 + * node integrations/cursor/close-stale-am-sessions.mjs --min-age-hours 6 --project my-project + * node integrations/cursor/close-stale-am-sessions.mjs --exclude ses_abc123,def456 + */ +import { requireConnection } from './_env.mjs'; + +function parseArgs() { + const args = process.argv.slice(2); + const opts = { + dryRun: args.includes('--dry-run'), + minAgeHours: 24, + project: null, + exclude: new Set() + }; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--min-age-hours' && args[i + 1]) { + const raw = args[++i]; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 0) { + console.error(`Invalid --min-age-hours value: ${raw}`); + process.exit(1); + } + opts.minAgeHours = parsed; + } else if (args[i] === '--project' && args[i + 1]) { + opts.project = args[++i]; + } else if (args[i] === '--exclude' && args[i + 1]) { + for (const id of args[++i].split(',')) { + const t = id.trim(); + if (t) opts.exclude.add(t); + } + } + } + return opts; +} + +async function main() { + const opts = parseArgs(); + const { url: restUrl, secret } = requireConnection(); + + const headers = { + Authorization: `Bearer ${secret}`, + 'Content-Type': 'application/json' + }; + + const listRes = await fetch(`${restUrl}/agentmemory/sessions`, { headers }); + if (!listRes.ok) { + console.error('Failed to list sessions:', listRes.status, await listRes.text()); + process.exit(1); + } + + const { sessions } = await listRes.json(); + const cutoff = Date.now() - opts.minAgeHours * 3600000; + const candidates = sessions.filter((s) => { + if (s.status !== 'active') return false; + if (opts.exclude.has(s.id)) return false; + if (opts.project && s.project !== opts.project) return false; + const started = Date.parse(s.startedAt); + if (!Number.isFinite(started) || started > cutoff) return false; + return true; + }); + + candidates.sort((a, b) => Date.parse(a.startedAt) - Date.parse(b.startedAt)); + + console.log( + `Found ${candidates.length} active session(s) older than ${opts.minAgeHours}h` + + (opts.project ? ` (project=${opts.project})` : '') + ); + + for (const s of candidates) { + const ageH = ((Date.now() - Date.parse(s.startedAt)) / 3600000).toFixed(1); + console.log( + ` ${s.id.slice(0, 12)}… ${s.project} obs=${s.observationCount ?? 0} age=${ageH}h` + ); + } + + if (!candidates.length) { + console.log('Nothing to close.'); + return; + } + + if (opts.dryRun) { + console.log(`Dry run: would close ${candidates.length} session(s) via POST /agentmemory/session/end`); + return; + } + + let closed = 0; + let failed = 0; + + for (const s of candidates) { + const res = await fetch(`${restUrl}/agentmemory/session/end`, { + method: 'POST', + headers, + body: JSON.stringify({ sessionId: s.id }) + }); + if (res.ok) { + closed++; + } else { + failed++; + console.error(` failed ${s.id}: ${res.status} ${await res.text()}`); + } + } + + console.log(`Closed ${closed} session(s), ${failed} failed.`); + + const verify = await fetch(`${restUrl}/agentmemory/sessions`, { headers }).then((r) => + r.json() + ); + const stillActive = verify.sessions.filter( + (s) => s.status === 'active' && Date.parse(s.startedAt) <= cutoff + ).length; + console.log(`Remaining stale active (>${opts.minAgeHours}h): ${stillActive}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/integrations/cursor/install-local.mjs b/integrations/cursor/install-local.mjs new file mode 100644 index 000000000..6aae0abf0 --- /dev/null +++ b/integrations/cursor/install-local.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execSync } from 'node:child_process'; +import { ENV_PATH, loadConfig } from './_env.mjs'; + +const INTEGRATION_ROOT = resolve(dirname(fileURLToPath(import.meta.url))); +const REPO_ROOT = resolve(INTEGRATION_ROOT, '../..'); +const CURSOR_DIR = join(homedir(), '.cursor'); +const MCP_PATH = join(CURSOR_DIR, 'mcp.json'); +const HOOKS_PATH = join(CURSOR_DIR, 'hooks.json'); +const MARKETPLACE_NAME = 'local-agentmemory'; +const MARKETPLACE_DIR = join(CURSOR_DIR, 'plugins', 'marketplaces', MARKETPLACE_NAME); +const LEGACY_MARKETPLACE_DIR = join(CURSOR_DIR, 'plugins', 'marketplaces', 'local-agentmemory-cursor'); + +function readJson(path) { + if (!existsSync(path)) return {}; + try { + return JSON.parse(readFileSync(path, 'utf-8')); + } catch (err) { + throw new Error(`Cannot parse ${path}: ${err.message}`); + } +} + +function writeJson(path, data, restrictPermissions = false) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, 'utf-8'); + if (restrictPermissions) { + try { + chmodSync(path, 0o600); + } catch {} + } +} + +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function describeJsonType(value) { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + return typeof value; +} + +function requireObjectConfig(path, value, label) { + if (!isPlainObject(value)) { + console.error(`${path}: expected ${label} to be a JSON object, got ${describeJsonType(value)}`); + process.exit(1); + } +} + +function requireObjectField(path, fieldName, value) { + if (value === undefined) return; + if (!isPlainObject(value)) { + console.error(`${path}: expected "${fieldName}" to be a JSON object, got ${describeJsonType(value)}`); + process.exit(1); + } +} + +function linkMarketplace() { + mkdirSync(dirname(MARKETPLACE_DIR), { recursive: true }); + if (!existsSync(MARKETPLACE_DIR)) { + if (process.platform === 'win32') { + execSync(`cmd /c mklink /J "${MARKETPLACE_DIR}" "${REPO_ROOT}"`, { stdio: 'inherit' }); + } else { + execSync(`ln -s "${REPO_ROOT}" "${MARKETPLACE_DIR}"`, { stdio: 'inherit' }); + } + } +} + +function mergeMcp(env) { + let mcp; + try { + mcp = existsSync(MCP_PATH) ? readJson(MCP_PATH) : {}; + } catch (err) { + console.error(err.message); + console.error('Refusing to overwrite mcp.json. Fix the file or restore from backup.'); + process.exit(1); + } + requireObjectConfig(MCP_PATH, mcp, 'mcp.json root'); + requireObjectField(MCP_PATH, 'mcpServers', mcp.mcpServers); + if (!mcp.mcpServers) mcp.mcpServers = {}; + mcp.mcpServers.agentmemory = { + command: 'npx', + args: ['-y', '@agentmemory/mcp'], + env: { + AGENTMEMORY_URL: env.AGENTMEMORY_URL || 'http://localhost:3111', + AGENTMEMORY_SECRET: env.AGENTMEMORY_SECRET || '', + AGENTMEMORY_TOOLS: env.AGENTMEMORY_TOOLS || 'all', + }, + }; + const backup = `${MCP_PATH}.bak-${Date.now()}`; + if (existsSync(MCP_PATH)) { + copyFileSync(MCP_PATH, backup); + try { + chmodSync(backup, 0o600); + } catch {} + } + writeJson(MCP_PATH, mcp, true); + return backup; +} + +function disableUserHooks() { + if (!process.argv.includes('--clear-user-hooks')) return null; + if (!existsSync(HOOKS_PATH)) return null; + let hooks; + try { + hooks = readJson(HOOKS_PATH); + } catch (err) { + console.error(err.message); + console.error('Refusing to clear hooks.json until the file is valid JSON.'); + process.exit(1); + } + requireObjectConfig(HOOKS_PATH, hooks, 'hooks.json root'); + requireObjectField(HOOKS_PATH, 'hooks', hooks.hooks); + const hasAgentmemory = JSON.stringify(hooks).includes('agentmemory-'); + if (!hasAgentmemory) return null; + const backup = `${HOOKS_PATH}.pre-plugin-${Date.now()}.bak`; + copyFileSync(HOOKS_PATH, backup); + writeJson(HOOKS_PATH, { version: 1, hooks: {} }); + return backup; +} + +const env = loadConfig(['AGENTMEMORY_URL', 'AGENTMEMORY_SECRET', 'AGENTMEMORY_TOOLS']); +if (!env.AGENTMEMORY_URL || !env.AGENTMEMORY_SECRET) { + console.error( + `Missing AGENTMEMORY_URL / AGENTMEMORY_SECRET. Set them in the environment or in ${ENV_PATH}.` + ); + process.exit(1); +} + +linkMarketplace(); +const mcpBackup = mergeMcp(env); +const hooksBackup = disableUserHooks(); + +console.log('\nagentmemory Cursor plugin (local dev) wired.\n'); +console.log(`Repo root: ${REPO_ROOT}`); +console.log(`Plugin package: ${join(REPO_ROOT, 'plugin')}`); +console.log(`Marketplace junction: ${MARKETPLACE_DIR}`); +if (existsSync(LEGACY_MARKETPLACE_DIR)) { + console.log(`Legacy junction still present: ${LEGACY_MARKETPLACE_DIR}`); + console.log(' Remove it in Cursor Settings → Plugins if you added marketplace from integrations/cursor before.'); +} +if (mcpBackup) console.log(`mcp.json backup: ${mcpBackup}`); +if (hooksBackup) console.log(`hooks.json backup: ${hooksBackup}`); +console.log(`AGENTMEMORY_URL: ${env.AGENTMEMORY_URL}`); +console.log('AGENTMEMORY_SECRET: '); +console.log('\nNext steps:'); +console.log('1. Cursor → Settings → Plugins → Add marketplace from folder:'); +console.log(` ${REPO_ROOT}`); +console.log(' (repo root — must contain .cursor-plugin/marketplace.json)'); +console.log('2. Enable plugin: agentmemory'); +console.log('3. Disable the old rohitg00/agentmemory marketplace plugin if both are enabled.'); +console.log('4. Developer: Reload Window'); +console.log('5. Run: node integrations/cursor/verify-flow.mjs'); +console.log('6. In hooks log, confirm commands use ${CURSOR_PLUGIN_ROOT}/scripts/cursor/run-hook.mjs'); +console.log('\nOnly after plugin hooks are confirmed:'); +console.log(' node integrations/cursor/install-local.mjs --clear-user-hooks'); diff --git a/integrations/cursor/verify-flow.mjs b/integrations/cursor/verify-flow.mjs new file mode 100644 index 000000000..ea9ff00f9 --- /dev/null +++ b/integrations/cursor/verify-flow.mjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node +/** + * End-to-end smoke test for the Cursor plugin hook pipeline against a live + * agentmemory daemon. + * + * Every assertion polls the daemon rather than sleeping for a fixed time. + * The hook processes are deliberately not synchronous with the work they + * cause: the canonical hooks fire-and-forget their HTTP calls and exit on a + * short timer, and run-detached.mjs returns as soon as its background worker + * is spawned. A fixed sleep therefore proves nothing -- too short and a + * healthy pipeline reports FAIL, too long and a broken one still has time to + * look healthy. Polling for the state change is the only honest check. + */ +import { existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { requireConnection } from './_env.mjs'; + +const INTEGRATION_ROOT = resolve(dirname(fileURLToPath(import.meta.url))); +const REPO_ROOT = resolve(INTEGRATION_ROOT, '../..'); +const CURSOR_SCRIPTS = join(REPO_ROOT, 'plugin', 'scripts', 'cursor'); + +const POLL_INTERVAL_MS = 1000; +const POLL_TIMEOUT_MS = 90000; + +const { url, secret } = requireConnection(); +const authHeaders = { Authorization: `Bearer ${secret}` }; + +function runHook(script, args, payload) { + const r = spawnSync(process.execPath, [join(CURSOR_SCRIPTS, script), ...args], { + input: JSON.stringify(payload), + encoding: 'utf-8', + timeout: 60000, + env: { ...process.env, AGENTMEMORY_URL: url, AGENTMEMORY_SECRET: secret } + }); + return { status: r.status, stderr: r.stderr?.trim().slice(0, 300) ?? '' }; +} + +async function fetchSession(id) { + const r = await fetch(`${url}/agentmemory/sessions`, { + headers: authHeaders, + signal: AbortSignal.timeout(30000) + }); + if (!r.ok) throw new Error(`GET /agentmemory/sessions -> ${r.status}`); + const data = await r.json(); + return data.sessions?.find((s) => s.id === id) ?? null; +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** Poll until `predicate(session)` holds. Returns the last session seen. */ +async function waitFor(id, predicate, timeoutMs = POLL_TIMEOUT_MS) { + const deadline = Date.now() + timeoutMs; + let last = null; + for (;;) { + last = await fetchSession(id); + if (last && predicate(last)) return { session: last, ok: true, waitedMs: 0 }; + if (Date.now() >= deadline) return { session: last, ok: false }; + await sleep(POLL_INTERVAL_MS); + } +} + +if (!existsSync(join(CURSOR_SCRIPTS, 'run-hook.mjs'))) { + console.error(`Missing Cursor shim at ${CURSOR_SCRIPTS}. Run \`npm run build\` first.`); + process.exit(1); +} + +const repoRootNorm = REPO_ROOT.replace(/\\/g, '/'); +const sessionId = `cursor-plugin-verify-${Date.now()}`; +const basePayload = { + session_id: sessionId, + workspace_roots: [repoRootNorm], + cwd: repoRootNorm +}; + +console.log('=== agentmemory Cursor plugin verify ===\n'); +console.log(`Shim scripts: ${CURSOR_SCRIPTS}`); +console.log(`Session id: ${sessionId}\n`); + +// A non-2xx /livez means the daemon is up but rejecting us (usually a bad +// secret). Continuing past it turns one clear error into four confusing ones. +let livez; +try { + livez = await fetch(`${url}/agentmemory/livez`, { + headers: authHeaders, + signal: AbortSignal.timeout(15000) + }); +} catch (e) { + console.error(`livez: unreachable (${e.message})`); + process.exit(1); +} +if (!livez.ok) { + console.error(`livez: HTTP ${livez.status} -- check AGENTMEMORY_URL / AGENTMEMORY_SECRET`); + process.exit(1); +} +console.log('livez: ok'); + +const results = []; +function record(name, ok, detail) { + results.push({ name, ok, detail }); + console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` -- ${detail}` : ''}`); +} + +// 1. sessionStart: the session must show up on the daemon at all. +console.log('\n[1/4] sessionStart'); +let r = runHook('run-hook.mjs', ['sessionStart'], basePayload); +console.log(` shim exit ${r.status}${r.stderr ? ` (${r.stderr})` : ''}`); +let w = await waitFor(sessionId, (s) => Boolean(s)); +record('session registered', w.ok, w.session ? `project=${w.session.project}` : 'never appeared'); +record( + 'resolver picked the repo, not .cursor', + w.session?.project === 'agentmemory', + `got ${w.session?.project ?? '(none)'}` +); + +// 2. postToolUse: an observation must land against that session. +// +// Retried, because the daemon drops observations when it is busy -- a +// `stop` from an earlier run leaves /summarize working in the background, +// and observations posted into that window can vanish. The adapter's job +// ends at "emitted a correct POST" (test/cursor-adapter.test.ts asserts +// that deterministically against a local server); this step additionally +// checks the daemon actually recorded it, so it has to tolerate the +// daemon's own load. Each attempt varies tool_input: the dedup key is +// (sessionId, tool_name, tool_input), so a byte-identical retry would be +// discarded as a duplicate and could never succeed. +console.log('\n[2/4] postToolUse'); +// Measured against a Tailscale-reachable daemon: roughly 15% of observations +// are dropped, in bursts rather than independently -- three in a row is +// normal, then thirty in a row land. The same rate applies when the canonical +// hook is invoked directly, without this adapter, so it is a property of the +// fire-and-forget hook contract plus a busy daemon, not of the Cursor path. +// Retries therefore have to be spaced, not just repeated. +const OBSERVE_ATTEMPTS = 4; +for (let attempt = 1; attempt <= OBSERVE_ATTEMPTS; attempt++) { + if (attempt > 1) await sleep(attempt * 2000); + r = runHook('run-hook.mjs', ['postToolUse'], { + ...basePayload, + tool_name: 'Read', + tool_input: { path: join(REPO_ROOT, 'package.json'), attempt }, + tool_output: `verify-flow smoke test (attempt ${attempt})` + }); + console.log(` attempt ${attempt}: shim exit ${r.status}${r.stderr ? ` (${r.stderr})` : ''}`); + if (r.status !== 0) break; + w = await waitFor(sessionId, (s) => (s.observationCount ?? 0) >= 1, 20000); + if (w.ok) break; + if (attempt < OBSERVE_ATTEMPTS) console.log(' not recorded yet -- daemon may be busy, retrying'); +} +record( + 'observation captured', + Boolean(w.ok), + w.ok ? `obs=${w.session?.observationCount ?? 0}` : `daemon never recorded it in ${OBSERVE_ATTEMPTS} attempts` +); + +// 3. stop: run-detached returns immediately, so the only meaningful signal is +// the session reaching a terminal state on the daemon afterwards. +console.log('\n[3/4] stop (detached)'); +r = runHook('run-detached.mjs', ['stop'], basePayload); +console.log(` parent exit ${r.status}${r.stderr ? ` (${r.stderr})` : ''} (worker continues in background)`); +w = await waitFor(sessionId, (s) => s.status === 'completed' || Boolean(s.endedAt)); +record( + 'detached worker closed the session', + w.ok, + `status=${w.session?.status ?? '?'} endedAt=${w.session?.endedAt ?? '(none)'}` +); + +// 4. sessionEnd is opt-in and diagnostic only, for two reasons. Cursor 3.13.x +// frequently fails to fire it on window close ("MainThreadShellExec not +// initialized"), so `stop` is the hook the pipeline actually relies on -- +// gating on it would report a Cursor bug as an adapter bug. And it fans out +// to /crystals/auto plus /consolidate-pipeline on the daemon, which is +// heavy enough that back-to-back smoke runs start losing observations to +// the resulting load. Running it by default makes this script flaky +// against itself. +if (process.argv.includes('--with-session-end')) { + console.log('\n[4/4] sessionEnd (diagnostic only)'); + r = runHook('run-detached.mjs', ['sessionEnd'], { ...basePayload, reason: 'window_close' }); + console.log(` parent exit ${r.status}${r.stderr ? ` (${r.stderr})` : ''}`); + const final = await fetchSession(sessionId); + console.log(` session: status=${final?.status ?? '?'} obs=${final?.observationCount ?? 0}`); + console.log(' (not a pass condition -- Cursor may never fire this on window close)'); +} else { + console.log('\n[4/4] sessionEnd skipped (pass --with-session-end to exercise it)'); +} + +const failed = results.filter((x) => !x.ok); +console.log(`\n${results.length - failed.length}/${results.length} checks passed.`); +if (failed.length) { + console.error(`FAILED: ${failed.map((f) => f.name).join(', ')}`); + process.exit(1); +} +console.log('Cursor hook pipeline OK.'); diff --git a/plugin/.cursor-plugin/plugin.json b/plugin/.cursor-plugin/plugin.json new file mode 100644 index 000000000..3b314b6c5 --- /dev/null +++ b/plugin/.cursor-plugin/plugin.json @@ -0,0 +1,29 @@ +{ + "name": "agentmemory", + "displayName": "agentmemory", + "version": "0.9.28", + "description": "Persistent memory for AI coding agents — Cursor-native lifecycle hooks, 53 MCP tools, 15 skills, real-time viewer.", + "author": { + "name": "Rohit Ghumare", + "url": "https://github.com/rohitg00" + }, + "license": "Apache-2.0", + "homepage": "https://github.com/rohitg00/agentmemory", + "repository": "https://github.com/rohitg00/agentmemory", + "keywords": [ + "agentmemory", + "memory", + "hooks", + "mcp", + "cursor" + ], + "category": "developer-tools", + "tags": [ + "memory", + "hooks", + "mcp" + ], + "skills": "./skills/", + "mcpServers": "./.mcp.json", + "hooks": "./hooks/hooks.cursor.json" +} diff --git a/plugin/hooks/hooks.cursor.json b/plugin/hooks/hooks.cursor.json new file mode 100644 index 000000000..2efe3a1b5 --- /dev/null +++ b/plugin/hooks/hooks.cursor.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "command": "node \"${CURSOR_PLUGIN_ROOT}/scripts/cursor/run-hook.mjs\" sessionStart" + } + ], + "beforeSubmitPrompt": [ + { + "command": "node \"${CURSOR_PLUGIN_ROOT}/scripts/cursor/run-hook.mjs\" beforeSubmitPrompt" + } + ], + "preToolUse": [ + { + "matcher": "Edit|Write|Read|Glob|Grep|Shell", + "command": "node \"${CURSOR_PLUGIN_ROOT}/scripts/cursor/run-hook.mjs\" preToolUse" + } + ], + "postToolUse": [ + { + "command": "node \"${CURSOR_PLUGIN_ROOT}/scripts/cursor/run-hook.mjs\" postToolUse" + } + ], + "postToolUseFailure": [ + { + "command": "node \"${CURSOR_PLUGIN_ROOT}/scripts/cursor/run-hook.mjs\" postToolUseFailure" + } + ], + "preCompact": [ + { + "command": "node \"${CURSOR_PLUGIN_ROOT}/scripts/cursor/run-hook.mjs\" preCompact" + } + ], + "subagentStart": [ + { + "command": "node \"${CURSOR_PLUGIN_ROOT}/scripts/cursor/run-hook.mjs\" subagentStart" + } + ], + "subagentStop": [ + { + "command": "node \"${CURSOR_PLUGIN_ROOT}/scripts/cursor/run-hook.mjs\" subagentStop" + } + ], + "stop": [ + { + "command": "node \"${CURSOR_PLUGIN_ROOT}/scripts/cursor/run-detached.mjs\" stop" + } + ], + "sessionEnd": [ + { + "command": "node \"${CURSOR_PLUGIN_ROOT}/scripts/cursor/run-detached.mjs\" sessionEnd" + } + ] + } +} diff --git a/plugin/scripts/cursor/run-detached.mjs b/plugin/scripts/cursor/run-detached.mjs new file mode 100644 index 000000000..f15951d47 --- /dev/null +++ b/plugin/scripts/cursor/run-detached.mjs @@ -0,0 +1,740 @@ +#!/usr/bin/env node +import { createRequire } from "node:module"; +import { execSync, spawn, spawnSync } from "node:child_process"; +import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { basename, dirname, join } from "node:path"; +import { homedir } from "node:os"; +//#region src/hooks/cursor/cursor-db.ts +/** +* Exact session -> workspace lookup from Cursor's own SQLite storage. +* +* Everything else in the resolver infers the workspace: from payload fields +* Cursor may or may not send, from paths that happen to appear in tool +* arguments, from a transcript directory name whose path separators have been +* flattened into hyphens. Those work, but they are inference, and inference +* that lands on the wrong project writes a user's memories into the wrong +* place without ever reporting an error. +* +* Cursor knows the answer exactly, and stores it. The layout (reverse +* engineered; see integrations/cursor/README.md for the full picture): +* +* /globalStorage/state.vscdb +* ItemTable["composer.composerHeaders"] +* -> { allComposers: [ { composerId, workspaceIdentifier: { +* id, uri: { fsPath, ... } } } ] } +* +* A Cursor hook's `session_id` is the `composerId`, so one indexed lookup +* gives the workspace path with no guessing. +* +* Two things stop this from being the only strategy: +* +* - Cursor 3.0 (April 2026) moved this index from per-workspace databases +* into the global one, and the migration is lazy: a workspace migrates +* when it is next opened. Machines still on <=2.6, or with workspaces not +* opened since the upgrade, keep the old per-workspace `allComposers` +* array instead. Both shapes are handled below. +* - Reading SQLite needs a driver. `node:sqlite` only exists from Node 22.5, +* and this package supports Node >=20. better-sqlite3 is an optional +* dependency of the daemon, not something a hook can count on. +* +* So every failure path here returns null and the caller falls back to +* inference. This module is an accuracy upgrade, never a requirement. +*/ +const require = createRequire(import.meta.url); +let driverCache; +let warningFilterInstalled = false; +function suppressSqliteExperimentalWarning() { + if (warningFilterInstalled) return; + warningFilterInstalled = true; + const previous = process.listeners("warning"); + process.removeAllListeners("warning"); + process.on("warning", (warning) => { + if (warning.name === "ExperimentalWarning" && /sqlite/i.test(warning.message)) return; + for (const listener of previous) listener(warning); + }); +} +function requireQuietly(id) { + suppressSqliteExperimentalWarning(); + try { + return require(id); + } catch { + return null; + } +} +function loadDriver() { + if (driverCache !== void 0) return driverCache; + const nodeSqlite = requireQuietly("node:sqlite"); + if (nodeSqlite?.DatabaseSync) { + driverCache = { open: (path) => new nodeSqlite.DatabaseSync(path, { readOnly: true }) }; + return driverCache; + } + const better = requireQuietly("better-sqlite3"); + if (better) { + driverCache = { open: (path) => new better(path, { + readonly: true, + fileMustExist: true + }) }; + return driverCache; + } + driverCache = null; + return driverCache; +} +/** Read one ItemTable value. Returns null for any failure, including a locked DB. */ +function readItemTableValue(dbPath, key) { + if (!existsSync(dbPath)) return null; + const driver = loadDriver(); + if (!driver) return null; + let db = null; + try { + db = driver.open(dbPath); + const value = db.prepare("SELECT value FROM ItemTable WHERE key = ?").get(key)?.value; + if (typeof value === "string") return value; + if (value instanceof Uint8Array) return Buffer.from(value).toString("utf-8"); + return null; + } catch { + return null; + } finally { + try { + db?.close(); + } catch {} + } +} +function cursorStorageRoots() { + const home = homedir(); + const bases = []; + if (process.platform === "win32") { + const appData = process.env["APPDATA"]; + if (appData) bases.push(join(appData, "Cursor", "User")); + bases.push(join(home, "AppData", "Roaming", "Cursor", "User")); + } else if (process.platform === "darwin") bases.push(join(home, "Library", "Application Support", "Cursor", "User")); + else { + const configHome = process.env["XDG_CONFIG_HOME"]; + if (configHome) bases.push(join(configHome, "Cursor", "User")); + bases.push(join(home, ".config", "Cursor", "User")); + } + for (const base of bases) { + const globalStorage = join(base, "globalStorage"); + if (existsSync(globalStorage)) return { + globalStorage, + workspaceStorage: join(base, "workspaceStorage") + }; + } + return null; +} +function fsPathFromHeader(header) { + const uri = header?.workspaceIdentifier?.uri; + const value = uri?.fsPath ?? uri?.path; + return typeof value === "string" && value.trim() ? value : null; +} +/** Cursor 3.0+: one central index in the global database. */ +function fromGlobalIndex(sessionId, roots) { + const raw = readItemTableValue(join(roots.globalStorage, "state.vscdb"), "composer.composerHeaders"); + if (!raw) return null; + try { + const hit = JSON.parse(raw).allComposers?.find((c) => c?.composerId === sessionId); + return fsPathFromHeader(hit); + } catch { + return null; + } +} +/** `{"folder":"file:///d%3A/repo"}`, or a vscode-remote:// URI we cannot map. */ +function folderFromWorkspaceJson(workspaceDir) { + const file = join(workspaceDir, "workspace.json"); + if (!existsSync(file)) return null; + try { + const folder = JSON.parse(readFileSync(file, "utf-8")).folder; + if (typeof folder !== "string" || !folder.startsWith("file://")) return null; + return fileURLToPath(folder); + } catch { + return null; + } +} +const LEGACY_SCAN_LIMIT = 40; +function fromLegacyWorkspaceDbs(sessionId, roots) { + if (!existsSync(roots.workspaceStorage)) return null; + let dirs; + try { + dirs = readdirSync(roots.workspaceStorage).map((dir) => { + try { + return { + dir, + mtime: statSync(join(roots.workspaceStorage, dir, "state.vscdb")).mtimeMs + }; + } catch { + return null; + } + }).filter((x) => x !== null).sort((a, b) => b.mtime - a.mtime).slice(0, LEGACY_SCAN_LIMIT); + } catch { + return null; + } + for (const { dir } of dirs) { + const raw = readItemTableValue(join(roots.workspaceStorage, dir, "state.vscdb"), "composer.composerData"); + if (!raw) continue; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed.allComposers)) continue; + if (!parsed.allComposers.some((c) => c?.composerId === sessionId)) continue; + return folderFromWorkspaceJson(join(roots.workspaceStorage, dir)); + } catch { + continue; + } + } + return null; +} +/** +* The workspace directory Cursor recorded for this session, or null when it +* cannot be determined (no driver, no storage, workspace-less window, or a +* remote URI that does not map to a local path). +*/ +function workspaceFromCursorDb(sessionId) { + if (!sessionId) return null; + const roots = cursorStorageRoots(); + if (!roots) return null; + return fromGlobalIndex(sessionId, roots) ?? fromLegacyWorkspaceDbs(sessionId, roots); +} +//#endregion +//#region src/hooks/cursor/workspace.ts +const HOME = homedir(); +const CURSOR_PROJECTS_DIR = join(HOME, ".cursor", "projects"); +const SESSION_CACHE_PATH = join(HOME, ".cursor", "hooks", ".agentmemory-session-cache.json"); +const HOOK_PAYLOAD_DIR = join(HOME, ".cursor", "hooks", ".am-hook-payloads"); +function normalizePathSlashes(value) { + return String(value).replace(/\\/g, "/"); +} +function isCursorMetadataPath(value) { + if (!value || typeof value !== "string") return false; + const trimmed = normalizePathSlashes(value.trim()); + if (trimmed === ".cursor") return true; + if (/(^|\/)\.cursor\/worktrees\/[^/]/.test(trimmed)) return false; + return /(^|\/)\.cursor(\/|$)/.test(trimmed); +} +function pathUnderHome(value) { + if (typeof value !== "string") return false; + const homeNorm = normalizePathSlashes(HOME); + const valueNorm = normalizePathSlashes(value); + return valueNorm === homeNorm || valueNorm.startsWith(`${homeNorm}/`); +} +function isIdeInstallPath(value) { + if (!value || typeof value !== "string") return false; + const norm = normalizePathSlashes(value).toLowerCase(); + return /(^|[\\/])(programs|program files|program files \(x86\))[\\/]cursor([\\/]|$)/i.test(norm) || /cursor\.app[\\/]contents/i.test(norm) || /(^|[\\/])microsoft vs code[\\/]resources[\\/]app([\\/]|$)/i.test(norm); +} +function isBadPath(value) { + if (!value || typeof value !== "string") return true; + const trimmed = normalizePathSlashes(value.trim()); + if (!trimmed || trimmed === "/" || trimmed === ".") return true; + if (/^[a-zA-Z]:\/?$/.test(trimmed)) return true; + if (isCursorMetadataPath(trimmed)) return true; + if (isIdeInstallPath(trimmed)) return true; + return false; +} +function sleepMs(ms) { + const end = Date.now() + ms; + while (Date.now() < end); +} +function withSessionCacheLock(fn) { + const lockPath = `${SESSION_CACHE_PATH}.lock`; + mkdirSync(dirname(SESSION_CACHE_PATH), { recursive: true }); + let fd; + for (let i = 0; i < 50; i++) try { + fd = openSync(lockPath, "wx"); + break; + } catch { + sleepMs(10); + } + if (fd === void 0) return void 0; + try { + return fn(); + } finally { + closeSync(fd); + try { + unlinkSync(lockPath); + } catch {} + } +} +function loadSessionCache() { + try { + return JSON.parse(readFileSync(SESSION_CACHE_PATH, "utf-8")); + } catch { + return {}; + } +} +function rememberSession(sessionId, project, cwd) { + if (!sessionId || !project || project === ".cursor") return; + withSessionCacheLock(() => { + try { + const cache = loadSessionCache(); + cache[sessionId] = { + project, + cwd, + updatedAt: (/* @__PURE__ */ new Date()).toISOString() + }; + const tmp = `${SESSION_CACHE_PATH}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tmp, JSON.stringify(cache, null, 2)); + renameSync(tmp, SESSION_CACHE_PATH); + } catch {} + }); +} +function recallSession(sessionId) { + if (!sessionId) return null; + return loadSessionCache()[sessionId] ?? null; +} +const SYSTEM_PATH_PREFIXES = [ + "/usr/", + "/etc/", + "/bin/", + "/sbin/", + "/lib/", + "/lib64/", + "/opt/", + "/var/", + "/proc/", + "/sys/", + "/dev/", + "/run/", + "/boot/", + "/snap/", + "/nix/" +]; +function isSystemPath(value) { + const norm = normalizePathSlashes(value); + return SYSTEM_PATH_PREFIXES.some((prefix) => norm.startsWith(prefix)); +} +function isCollectablePath(value) { + if (typeof value !== "string" || isCursorMetadataPath(value)) return false; + if (pathUnderHome(value)) return true; + if (/^[a-zA-Z]:[\\/]/.test(value)) return pathExists(value); + if (value.startsWith("/")) return !isSystemPath(value) && pathExists(value); + return false; +} +function collectPathStrings(value, out = []) { + if (typeof value === "string") { + if (isCollectablePath(value)) out.push(value); + return out; + } + if (Array.isArray(value)) { + for (const item of value) collectPathStrings(item, out); + return out; + } + if (value && typeof value === "object") for (const v of Object.values(value)) collectPathStrings(v, out); + return out; +} +function pathExists(pathValue) { + if (existsSync(pathValue)) return true; + if (process.platform === "win32") { + const native = pathValue.replace(/\//g, "\\"); + if (native !== pathValue && existsSync(native)) return true; + } + return false; +} +const MAX_ANCESTOR_STEPS = 64; +function existingAncestor(pathValue) { + let current = pathValue; + for (let step = 0; step < MAX_ANCESTOR_STEPS; step++) { + if (!current || current === HOME || current === "/") return null; + if (pathExists(current)) { + const resolved = process.platform === "win32" ? current.replace(/\//g, "\\") : current; + try { + if (statSync(resolved).isFile()) return dirname(resolved); + } catch {} + return resolved; + } + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } + return null; +} +function gitRootFromPath(targetPath) { + return execSync("git rev-parse --show-toplevel", { + cwd: targetPath, + encoding: "utf-8", + stdio: [ + "ignore", + "pipe", + "ignore" + ] + }).trim(); +} +function gitRootNearby(startPath) { + let current = startPath; + for (let step = 0; step < MAX_ANCESTOR_STEPS; step++) { + if (!current || current === "/") return null; + if (pathExists(join(current, ".git"))) return current; + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } + return null; +} +function cleanRepoName(dirPath) { + const normalized = normalizePathSlashes(dirPath).replace(/\/+$/, ""); + if (!normalized) return "unknown-project"; + const claudeWt = normalized.match(/^(.*?)\/\.claude\/worktrees\/[^/]+$/i); + if (claudeWt?.[1]) return cleanRepoName(claudeWt[1]); + const cursorWt = normalized.match(/\/\.cursor\/worktrees\/([^/]+)$/i); + if (cursorWt?.[1]) return cursorWt[1].replace(/-[a-z0-9]{4,8}$/i, "") || cursorWt[1]; + const baseName = basename(normalized); + if (/^agent-[a-f0-9]{6,}$/i.test(baseName)) { + const parent = dirname(normalized); + if (parent && parent !== normalized && parent !== "." && parent !== "/") return cleanRepoName(parent); + } + return baseName.replace(/(-worktree-\d+|-worktree|-[a-f0-9]{7,40})$/i, "") || "unknown-project"; +} +function projectFromPath(targetPath) { + try { + return { + name: cleanRepoName(gitRootFromPath(targetPath)), + fromGitRoot: true + }; + } catch { + return { + name: cleanRepoName(targetPath), + fromGitRoot: false + }; + } +} +function decodeSlugCandidates(slug) { + if (!slug || slug === "empty-window") return []; + if (/^\d{10,}$/.test(slug)) return []; + const parts = slug.split("-"); + if (!parts.length) return []; + const results = /* @__PURE__ */ new Set(); + function walk(index, currentPath) { + if (index >= parts.length) { + results.add(currentPath); + return; + } + for (let take = 1; index + take <= parts.length; take++) { + const next = `${currentPath}/${parts.slice(index, index + take).join("-")}`; + if (!pathExists(next)) continue; + walk(index + take, next); + } + } + const first = parts[0]; + if (first && /^[a-zA-Z]$/.test(first)) walk(1, `${first.toUpperCase()}:`); + walk(0, ""); + return [...results]; +} +function pickBestCandidate(candidates, preferredLabel) { + if (!candidates.length) return null; + if (preferredLabel) { + const labelMatch = candidates.find((p) => basename(p) === preferredLabel); + if (labelMatch) return labelMatch; + } + const gitRoots = []; + for (const candidate of candidates) try { + gitRoots.push(gitRootFromPath(candidate)); + } catch {} + const uniqueGitRoots = [...new Set(gitRoots)]; + if (uniqueGitRoots.length === 1) return uniqueGitRoots[0] ?? null; + return candidates.sort((a, b) => b.length - a.length)[0] ?? null; +} +function findSessionTranscript(sessionId) { + if (!sessionId || !existsSync(CURSOR_PROJECTS_DIR)) return null; + for (const slug of readdirSync(CURSOR_PROJECTS_DIR)) { + const transcriptsRoot = join(CURSOR_PROJECTS_DIR, slug, "agent-transcripts"); + if (!existsSync(transcriptsRoot)) continue; + for (const entry of readdirSync(transcriptsRoot)) if (entry === sessionId || entry.startsWith(`${sessionId}-`)) { + const transcriptFile = join(transcriptsRoot, entry, `${entry}.jsonl`); + return { + slug, + transcriptFile: existsSync(transcriptFile) ? transcriptFile : null + }; + } + } + return null; +} +const TRANSCRIPT_SCAN_BYTES = 25e4; +const TRANSCRIPT_CANDIDATE_LIMIT = 120; +const TRANSCRIPT_MATCH_LIMIT = 4e3; +const TRANSCRIPT_MIN_VOTES = 3; +const TRANSCRIPT_PATH_PATTERNS = [/[a-zA-Z]:[A-Za-z0-9._@+\-/]{3,240}/g, /\/[A-Za-z0-9._@+\-/]{3,240}/g]; +function workspaceFromTranscriptFile(transcriptFile) { + if (!transcriptFile || !existsSync(transcriptFile)) return null; + const chunk = normalizePathSlashes(readFileSync(transcriptFile, "utf-8").slice(0, TRANSCRIPT_SCAN_BYTES)); + const counts = /* @__PURE__ */ new Map(); + const seen = /* @__PURE__ */ new Set(); + for (const pattern of TRANSCRIPT_PATH_PATTERNS) { + pattern.lastIndex = 0; + let match; + let scanned = 0; + while ((match = pattern.exec(chunk)) !== null) { + if (++scanned > TRANSCRIPT_MATCH_LIMIT) break; + if (seen.size >= TRANSCRIPT_CANDIDATE_LIMIT) break; + const value = match[0]; + if (seen.has(value)) continue; + seen.add(value); + if (value.startsWith("//")) continue; + if (isCursorMetadataPath(value) || isIdeInstallPath(value) || isSystemPath(value)) continue; + const existing = existingDirectory(value); + if (!existing || existing === HOME || isBadPath(existing)) continue; + const root = gitRootNearby(existing); + if (!root || root === HOME || isBadPath(root)) continue; + counts.set(root, (counts.get(root) || 0) + 1); + } + } + let best = null; + let bestCount = 0; + for (const [pathValue, count] of counts) if (count > bestCount) { + best = pathValue; + bestCount = count; + } + return bestCount >= TRANSCRIPT_MIN_VOTES ? best : null; +} +function workspaceFromSessionId(sessionId) { + const hit = findSessionTranscript(sessionId); + if (!hit) return null; + const preferredLabel = process.env["CURSOR_WORKSPACE_LABEL"] || ""; + const fromSlug = pickBestCandidate(decodeSlugCandidates(hit.slug), preferredLabel); + if (fromSlug) return fromSlug; + return workspaceFromTranscriptFile(hit.transcriptFile); +} +function readHookStdinComplete(maxWaitMs = 3e4) { + return new Promise((resolve) => { + let input = ""; + let done = false; + const finish = () => { + if (done) return; + done = true; + try { + process.stdin.destroy(); + } catch {} + resolve(input); + }; + const t = setTimeout(finish, maxWaitMs); + if (t.unref) t.unref(); + process.stdin.on("data", (c) => { + input += c; + }); + process.stdin.on("end", () => { + clearTimeout(t); + finish(); + }); + process.stdin.on("error", () => { + clearTimeout(t); + finish(); + }); + }); +} +function writeHookPayloadTemp(input) { + mkdirSync(HOOK_PAYLOAD_DIR, { recursive: true }); + try { + chmodSync(HOOK_PAYLOAD_DIR, 448); + } catch {} + const path = join(HOOK_PAYLOAD_DIR, `am-hook-${process.pid}-${Date.now()}.json`); + writeFileSync(path, input, { + encoding: "utf-8", + mode: 384 + }); + return path; +} +function readWorkerHookPayload() { + const file = process.env["AM_HOOK_INPUT_FILE"]; + if (!file) { + console.error("[agentmemory] missing AM_HOOK_INPUT_FILE in worker"); + return null; + } + try { + const raw = readFileSync(file, "utf-8"); + return JSON.parse(raw); + } catch (err) { + console.error("[agentmemory] failed to parse hook payload:", err.message); + return null; + } finally { + try { + unlinkSync(file); + } catch {} + } +} +function isMetadataProject(project) { + return project.name.startsWith(".") && !project.fromGitRoot; +} +function isHomeDirectory(pathValue) { + return normalizePathSlashes(pathValue) === normalizePathSlashes(HOME); +} +function existingDirectory(pathValue) { + if (!pathExists(pathValue)) return null; + const resolved = process.platform === "win32" ? pathValue.replace(/\//g, "\\") : pathValue; + try { + return statSync(resolved).isFile() ? dirname(resolved) : resolved; + } catch { + return null; + } +} +function resolveFromPathCandidates(candidates, sessionId, options = {}) { + for (const candidate of candidates) { + if (typeof candidate !== "string" || isBadPath(candidate)) continue; + const existing = options.exact ? existingDirectory(candidate) : existingAncestor(candidate); + if (!existing || isBadPath(existing)) continue; + const project = projectFromPath(existing); + if (isHomeDirectory(existing) && !project.fromGitRoot) continue; + if (!isMetadataProject(project)) { + rememberSession(sessionId, project.name, existing); + return { + project: project.name, + cwd: existing + }; + } + } + return null; +} +function debugLayer(layer, result) { + if (result && process.env["AM_CURSOR_DEBUG"] === "1") console.error(`[agentmemory] workspace resolved by ${layer}: ${result.project} (${result.cwd})`); + return result; +} +function resolveWorkspace(data) { + const sessionId = data?.["session_id"] ?? data?.["sessionId"]; + const cached = recallSession(sessionId); + if (cached?.cwd && !isBadPath(cached.cwd)) return { + project: cached.project, + cwd: cached.cwd + }; + const fromPayload = debugLayer("payload", resolveFromPathCandidates([ + ...Array.isArray(data?.["workspace_roots"]) ? data["workspace_roots"] : [], + ...Array.isArray(data?.["workspace_folders"]) ? data["workspace_folders"] : [], + data?.["workspace_folder"], + data?.["workspaceFolder"], + data?.["workspace"], + data?.["cwd"], + data?.["root_path"], + data?.["project_path"] + ], sessionId)); + if (fromPayload) return fromPayload; + const fromTools = debugLayer("tool_input", resolveFromPathCandidates(collectPathStrings(data?.["tool_input"]).map(existingAncestor).filter((p) => Boolean(p) && !isIdeInstallPath(p)), sessionId)); + if (fromTools) return fromTools; + if (sessionId) { + const fromDb = debugLayer("cursor-db", resolveFromPathCandidates([workspaceFromCursorDb(sessionId)], sessionId, { exact: true })); + if (fromDb) return fromDb; + const fromSession = debugLayer("transcript-dir", resolveFromPathCandidates([workspaceFromSessionId(sessionId)], sessionId, { exact: true })); + if (fromSession) return fromSession; + } + const fromEnv = debugLayer("env", resolveFromPathCandidates([ + process.env["CURSOR_WORKSPACE_ROOT"], + process.env["CURSOR_WORKSPACE_FOLDER"], + process.env["PWD"], + process.env["VSCODE_CWD"] + ], sessionId)); + if (fromEnv) return fromEnv; + const label = process.env["CURSOR_WORKSPACE_LABEL"]; + if (label) { + rememberSession(sessionId, label, label); + return { + project: label, + cwd: label + }; + } + return { + project: "unknown-project", + cwd: "unknown-project" + }; +} +//#endregion +//#region src/hooks/cursor/delegate.ts +const HOOK_MAP = { + sessionStart: "session-start.mjs", + beforeSubmitPrompt: "prompt-submit.mjs", + preToolUse: "pre-tool-use.mjs", + postToolUse: "post-tool-use.mjs", + postToolUseFailure: "post-tool-failure.mjs", + preCompact: "pre-compact.mjs", + subagentStart: "subagent-start.mjs", + subagentStop: "subagent-stop.mjs", + stop: "stop.mjs", + sessionEnd: "session-end.mjs" +}; +function isCursorHookKey(value) { + return typeof value === "string" && value in HOOK_MAP; +} +const SLOW_HOOKS = new Set(["stop", "sessionEnd"]); +function defaultOfficialDir() { + return join(dirname(fileURLToPath(import.meta.url)), ".."); +} +function enrichPayload(data) { + const { project, cwd } = resolveWorkspace(data); + return { + project, + payload: { + ...data, + session_id: data["session_id"] ?? data["sessionId"], + cwd + } + }; +} +function delegateHook(hookKey, data, options = {}) { + const script = HOOK_MAP[hookKey]; + const scriptPath = join(options.officialDir ?? defaultOfficialDir(), script); + if (!existsSync(scriptPath)) { + console.error(`[agentmemory] cursor hook "${hookKey}": canonical hook not found at ${scriptPath}. Run \`npm run build\` in the agentmemory checkout.`); + return 0; + } + const { project, payload } = enrichPayload(data); + const child = spawnSync(process.execPath, [scriptPath], { + input: JSON.stringify(payload), + env: { + ...process.env, + AGENTMEMORY_PROJECT_NAME: project + }, + encoding: "utf-8", + maxBuffer: 10 * 1024 * 1024, + timeout: SLOW_HOOKS.has(hookKey) ? 18e4 : 3e4 + }); + if (child.stdout) process.stdout.write(child.stdout); + if (child.stderr) process.stderr.write(child.stderr); + if (child.error) { + console.error(`[agentmemory] cursor hook "${hookKey}" could not run ${script}: ${child.error.message}`); + return 0; + } + if (child.signal) { + console.error(`[agentmemory] cursor hook "${hookKey}" (${script}) was killed by ${child.signal} -- treating as no-op`); + return 0; + } + return child.status ?? 0; +} +//#endregion +//#region src/hooks/cursor/run-detached.ts +const IS_WORKER = process.env["AM_HOOK_WORKER"] === "1"; +const hookKey = process.argv[2]; +function runWorker() { + const watchdog = setTimeout(() => process.exit(0), hookKey === "sessionEnd" ? 25e4 : 13e4); + try { + const data = readWorkerHookPayload(); + if (!data || !isCursorHookKey(hookKey)) return; + delegateHook(hookKey, data); + } finally { + clearTimeout(watchdog); + process.exit(0); + } +} +async function runParent() { + if (!hookKey) process.exit(0); + const payloadFile = writeHookPayloadTemp(await readHookStdinComplete()); + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), hookKey], { + detached: true, + stdio: "ignore", + windowsHide: true, + env: { + ...process.env, + AM_HOOK_WORKER: "1", + AM_HOOK_INPUT_FILE: payloadFile + } + }); + child.unref(); + const bail = setTimeout(() => process.exit(0), 2e3); + if (bail.unref) bail.unref(); + child.on("spawn", () => process.exit(0)); + child.on("error", (err) => { + console.error("[agentmemory] failed to spawn detached hook worker:", err.message); + try { + unlinkSync(payloadFile); + } catch {} + process.exit(0); + }); +} +if (IS_WORKER) runWorker(); +else runParent(); +//#endregion +export {}; + +//# sourceMappingURL=run-detached.mjs.map \ No newline at end of file diff --git a/plugin/scripts/cursor/run-hook.mjs b/plugin/scripts/cursor/run-hook.mjs new file mode 100644 index 000000000..d28abe017 --- /dev/null +++ b/plugin/scripts/cursor/run-hook.mjs @@ -0,0 +1,658 @@ +#!/usr/bin/env node +import { createRequire } from "node:module"; +import { execSync, spawnSync } from "node:child_process"; +import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; +//#region src/hooks/cursor/cursor-db.ts +/** +* Exact session -> workspace lookup from Cursor's own SQLite storage. +* +* Everything else in the resolver infers the workspace: from payload fields +* Cursor may or may not send, from paths that happen to appear in tool +* arguments, from a transcript directory name whose path separators have been +* flattened into hyphens. Those work, but they are inference, and inference +* that lands on the wrong project writes a user's memories into the wrong +* place without ever reporting an error. +* +* Cursor knows the answer exactly, and stores it. The layout (reverse +* engineered; see integrations/cursor/README.md for the full picture): +* +* /globalStorage/state.vscdb +* ItemTable["composer.composerHeaders"] +* -> { allComposers: [ { composerId, workspaceIdentifier: { +* id, uri: { fsPath, ... } } } ] } +* +* A Cursor hook's `session_id` is the `composerId`, so one indexed lookup +* gives the workspace path with no guessing. +* +* Two things stop this from being the only strategy: +* +* - Cursor 3.0 (April 2026) moved this index from per-workspace databases +* into the global one, and the migration is lazy: a workspace migrates +* when it is next opened. Machines still on <=2.6, or with workspaces not +* opened since the upgrade, keep the old per-workspace `allComposers` +* array instead. Both shapes are handled below. +* - Reading SQLite needs a driver. `node:sqlite` only exists from Node 22.5, +* and this package supports Node >=20. better-sqlite3 is an optional +* dependency of the daemon, not something a hook can count on. +* +* So every failure path here returns null and the caller falls back to +* inference. This module is an accuracy upgrade, never a requirement. +*/ +const require = createRequire(import.meta.url); +let driverCache; +let warningFilterInstalled = false; +function suppressSqliteExperimentalWarning() { + if (warningFilterInstalled) return; + warningFilterInstalled = true; + const previous = process.listeners("warning"); + process.removeAllListeners("warning"); + process.on("warning", (warning) => { + if (warning.name === "ExperimentalWarning" && /sqlite/i.test(warning.message)) return; + for (const listener of previous) listener(warning); + }); +} +function requireQuietly(id) { + suppressSqliteExperimentalWarning(); + try { + return require(id); + } catch { + return null; + } +} +function loadDriver() { + if (driverCache !== void 0) return driverCache; + const nodeSqlite = requireQuietly("node:sqlite"); + if (nodeSqlite?.DatabaseSync) { + driverCache = { open: (path) => new nodeSqlite.DatabaseSync(path, { readOnly: true }) }; + return driverCache; + } + const better = requireQuietly("better-sqlite3"); + if (better) { + driverCache = { open: (path) => new better(path, { + readonly: true, + fileMustExist: true + }) }; + return driverCache; + } + driverCache = null; + return driverCache; +} +/** Read one ItemTable value. Returns null for any failure, including a locked DB. */ +function readItemTableValue(dbPath, key) { + if (!existsSync(dbPath)) return null; + const driver = loadDriver(); + if (!driver) return null; + let db = null; + try { + db = driver.open(dbPath); + const value = db.prepare("SELECT value FROM ItemTable WHERE key = ?").get(key)?.value; + if (typeof value === "string") return value; + if (value instanceof Uint8Array) return Buffer.from(value).toString("utf-8"); + return null; + } catch { + return null; + } finally { + try { + db?.close(); + } catch {} + } +} +function cursorStorageRoots() { + const home = homedir(); + const bases = []; + if (process.platform === "win32") { + const appData = process.env["APPDATA"]; + if (appData) bases.push(join(appData, "Cursor", "User")); + bases.push(join(home, "AppData", "Roaming", "Cursor", "User")); + } else if (process.platform === "darwin") bases.push(join(home, "Library", "Application Support", "Cursor", "User")); + else { + const configHome = process.env["XDG_CONFIG_HOME"]; + if (configHome) bases.push(join(configHome, "Cursor", "User")); + bases.push(join(home, ".config", "Cursor", "User")); + } + for (const base of bases) { + const globalStorage = join(base, "globalStorage"); + if (existsSync(globalStorage)) return { + globalStorage, + workspaceStorage: join(base, "workspaceStorage") + }; + } + return null; +} +function fsPathFromHeader(header) { + const uri = header?.workspaceIdentifier?.uri; + const value = uri?.fsPath ?? uri?.path; + return typeof value === "string" && value.trim() ? value : null; +} +/** Cursor 3.0+: one central index in the global database. */ +function fromGlobalIndex(sessionId, roots) { + const raw = readItemTableValue(join(roots.globalStorage, "state.vscdb"), "composer.composerHeaders"); + if (!raw) return null; + try { + const hit = JSON.parse(raw).allComposers?.find((c) => c?.composerId === sessionId); + return fsPathFromHeader(hit); + } catch { + return null; + } +} +/** `{"folder":"file:///d%3A/repo"}`, or a vscode-remote:// URI we cannot map. */ +function folderFromWorkspaceJson(workspaceDir) { + const file = join(workspaceDir, "workspace.json"); + if (!existsSync(file)) return null; + try { + const folder = JSON.parse(readFileSync(file, "utf-8")).folder; + if (typeof folder !== "string" || !folder.startsWith("file://")) return null; + return fileURLToPath(folder); + } catch { + return null; + } +} +const LEGACY_SCAN_LIMIT = 40; +function fromLegacyWorkspaceDbs(sessionId, roots) { + if (!existsSync(roots.workspaceStorage)) return null; + let dirs; + try { + dirs = readdirSync(roots.workspaceStorage).map((dir) => { + try { + return { + dir, + mtime: statSync(join(roots.workspaceStorage, dir, "state.vscdb")).mtimeMs + }; + } catch { + return null; + } + }).filter((x) => x !== null).sort((a, b) => b.mtime - a.mtime).slice(0, LEGACY_SCAN_LIMIT); + } catch { + return null; + } + for (const { dir } of dirs) { + const raw = readItemTableValue(join(roots.workspaceStorage, dir, "state.vscdb"), "composer.composerData"); + if (!raw) continue; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed.allComposers)) continue; + if (!parsed.allComposers.some((c) => c?.composerId === sessionId)) continue; + return folderFromWorkspaceJson(join(roots.workspaceStorage, dir)); + } catch { + continue; + } + } + return null; +} +/** +* The workspace directory Cursor recorded for this session, or null when it +* cannot be determined (no driver, no storage, workspace-less window, or a +* remote URI that does not map to a local path). +*/ +function workspaceFromCursorDb(sessionId) { + if (!sessionId) return null; + const roots = cursorStorageRoots(); + if (!roots) return null; + return fromGlobalIndex(sessionId, roots) ?? fromLegacyWorkspaceDbs(sessionId, roots); +} +//#endregion +//#region src/hooks/cursor/workspace.ts +const HOME = homedir(); +const CURSOR_PROJECTS_DIR = join(HOME, ".cursor", "projects"); +const SESSION_CACHE_PATH = join(HOME, ".cursor", "hooks", ".agentmemory-session-cache.json"); +join(HOME, ".cursor", "hooks", ".am-hook-payloads"); +function normalizePathSlashes(value) { + return String(value).replace(/\\/g, "/"); +} +function isCursorMetadataPath(value) { + if (!value || typeof value !== "string") return false; + const trimmed = normalizePathSlashes(value.trim()); + if (trimmed === ".cursor") return true; + if (/(^|\/)\.cursor\/worktrees\/[^/]/.test(trimmed)) return false; + return /(^|\/)\.cursor(\/|$)/.test(trimmed); +} +function pathUnderHome(value) { + if (typeof value !== "string") return false; + const homeNorm = normalizePathSlashes(HOME); + const valueNorm = normalizePathSlashes(value); + return valueNorm === homeNorm || valueNorm.startsWith(`${homeNorm}/`); +} +function isIdeInstallPath(value) { + if (!value || typeof value !== "string") return false; + const norm = normalizePathSlashes(value).toLowerCase(); + return /(^|[\\/])(programs|program files|program files \(x86\))[\\/]cursor([\\/]|$)/i.test(norm) || /cursor\.app[\\/]contents/i.test(norm) || /(^|[\\/])microsoft vs code[\\/]resources[\\/]app([\\/]|$)/i.test(norm); +} +function isBadPath(value) { + if (!value || typeof value !== "string") return true; + const trimmed = normalizePathSlashes(value.trim()); + if (!trimmed || trimmed === "/" || trimmed === ".") return true; + if (/^[a-zA-Z]:\/?$/.test(trimmed)) return true; + if (isCursorMetadataPath(trimmed)) return true; + if (isIdeInstallPath(trimmed)) return true; + return false; +} +function sleepMs(ms) { + const end = Date.now() + ms; + while (Date.now() < end); +} +function withSessionCacheLock(fn) { + const lockPath = `${SESSION_CACHE_PATH}.lock`; + mkdirSync(dirname(SESSION_CACHE_PATH), { recursive: true }); + let fd; + for (let i = 0; i < 50; i++) try { + fd = openSync(lockPath, "wx"); + break; + } catch { + sleepMs(10); + } + if (fd === void 0) return void 0; + try { + return fn(); + } finally { + closeSync(fd); + try { + unlinkSync(lockPath); + } catch {} + } +} +function loadSessionCache() { + try { + return JSON.parse(readFileSync(SESSION_CACHE_PATH, "utf-8")); + } catch { + return {}; + } +} +function rememberSession(sessionId, project, cwd) { + if (!sessionId || !project || project === ".cursor") return; + withSessionCacheLock(() => { + try { + const cache = loadSessionCache(); + cache[sessionId] = { + project, + cwd, + updatedAt: (/* @__PURE__ */ new Date()).toISOString() + }; + const tmp = `${SESSION_CACHE_PATH}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tmp, JSON.stringify(cache, null, 2)); + renameSync(tmp, SESSION_CACHE_PATH); + } catch {} + }); +} +function recallSession(sessionId) { + if (!sessionId) return null; + return loadSessionCache()[sessionId] ?? null; +} +const SYSTEM_PATH_PREFIXES = [ + "/usr/", + "/etc/", + "/bin/", + "/sbin/", + "/lib/", + "/lib64/", + "/opt/", + "/var/", + "/proc/", + "/sys/", + "/dev/", + "/run/", + "/boot/", + "/snap/", + "/nix/" +]; +function isSystemPath(value) { + const norm = normalizePathSlashes(value); + return SYSTEM_PATH_PREFIXES.some((prefix) => norm.startsWith(prefix)); +} +function isCollectablePath(value) { + if (typeof value !== "string" || isCursorMetadataPath(value)) return false; + if (pathUnderHome(value)) return true; + if (/^[a-zA-Z]:[\\/]/.test(value)) return pathExists(value); + if (value.startsWith("/")) return !isSystemPath(value) && pathExists(value); + return false; +} +function collectPathStrings(value, out = []) { + if (typeof value === "string") { + if (isCollectablePath(value)) out.push(value); + return out; + } + if (Array.isArray(value)) { + for (const item of value) collectPathStrings(item, out); + return out; + } + if (value && typeof value === "object") for (const v of Object.values(value)) collectPathStrings(v, out); + return out; +} +function pathExists(pathValue) { + if (existsSync(pathValue)) return true; + if (process.platform === "win32") { + const native = pathValue.replace(/\//g, "\\"); + if (native !== pathValue && existsSync(native)) return true; + } + return false; +} +const MAX_ANCESTOR_STEPS = 64; +function existingAncestor(pathValue) { + let current = pathValue; + for (let step = 0; step < MAX_ANCESTOR_STEPS; step++) { + if (!current || current === HOME || current === "/") return null; + if (pathExists(current)) { + const resolved = process.platform === "win32" ? current.replace(/\//g, "\\") : current; + try { + if (statSync(resolved).isFile()) return dirname(resolved); + } catch {} + return resolved; + } + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } + return null; +} +function gitRootFromPath(targetPath) { + return execSync("git rev-parse --show-toplevel", { + cwd: targetPath, + encoding: "utf-8", + stdio: [ + "ignore", + "pipe", + "ignore" + ] + }).trim(); +} +function gitRootNearby(startPath) { + let current = startPath; + for (let step = 0; step < MAX_ANCESTOR_STEPS; step++) { + if (!current || current === "/") return null; + if (pathExists(join(current, ".git"))) return current; + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } + return null; +} +function cleanRepoName(dirPath) { + const normalized = normalizePathSlashes(dirPath).replace(/\/+$/, ""); + if (!normalized) return "unknown-project"; + const claudeWt = normalized.match(/^(.*?)\/\.claude\/worktrees\/[^/]+$/i); + if (claudeWt?.[1]) return cleanRepoName(claudeWt[1]); + const cursorWt = normalized.match(/\/\.cursor\/worktrees\/([^/]+)$/i); + if (cursorWt?.[1]) return cursorWt[1].replace(/-[a-z0-9]{4,8}$/i, "") || cursorWt[1]; + const baseName = basename(normalized); + if (/^agent-[a-f0-9]{6,}$/i.test(baseName)) { + const parent = dirname(normalized); + if (parent && parent !== normalized && parent !== "." && parent !== "/") return cleanRepoName(parent); + } + return baseName.replace(/(-worktree-\d+|-worktree|-[a-f0-9]{7,40})$/i, "") || "unknown-project"; +} +function projectFromPath(targetPath) { + try { + return { + name: cleanRepoName(gitRootFromPath(targetPath)), + fromGitRoot: true + }; + } catch { + return { + name: cleanRepoName(targetPath), + fromGitRoot: false + }; + } +} +function decodeSlugCandidates(slug) { + if (!slug || slug === "empty-window") return []; + if (/^\d{10,}$/.test(slug)) return []; + const parts = slug.split("-"); + if (!parts.length) return []; + const results = /* @__PURE__ */ new Set(); + function walk(index, currentPath) { + if (index >= parts.length) { + results.add(currentPath); + return; + } + for (let take = 1; index + take <= parts.length; take++) { + const next = `${currentPath}/${parts.slice(index, index + take).join("-")}`; + if (!pathExists(next)) continue; + walk(index + take, next); + } + } + const first = parts[0]; + if (first && /^[a-zA-Z]$/.test(first)) walk(1, `${first.toUpperCase()}:`); + walk(0, ""); + return [...results]; +} +function pickBestCandidate(candidates, preferredLabel) { + if (!candidates.length) return null; + if (preferredLabel) { + const labelMatch = candidates.find((p) => basename(p) === preferredLabel); + if (labelMatch) return labelMatch; + } + const gitRoots = []; + for (const candidate of candidates) try { + gitRoots.push(gitRootFromPath(candidate)); + } catch {} + const uniqueGitRoots = [...new Set(gitRoots)]; + if (uniqueGitRoots.length === 1) return uniqueGitRoots[0] ?? null; + return candidates.sort((a, b) => b.length - a.length)[0] ?? null; +} +function findSessionTranscript(sessionId) { + if (!sessionId || !existsSync(CURSOR_PROJECTS_DIR)) return null; + for (const slug of readdirSync(CURSOR_PROJECTS_DIR)) { + const transcriptsRoot = join(CURSOR_PROJECTS_DIR, slug, "agent-transcripts"); + if (!existsSync(transcriptsRoot)) continue; + for (const entry of readdirSync(transcriptsRoot)) if (entry === sessionId || entry.startsWith(`${sessionId}-`)) { + const transcriptFile = join(transcriptsRoot, entry, `${entry}.jsonl`); + return { + slug, + transcriptFile: existsSync(transcriptFile) ? transcriptFile : null + }; + } + } + return null; +} +const TRANSCRIPT_SCAN_BYTES = 25e4; +const TRANSCRIPT_CANDIDATE_LIMIT = 120; +const TRANSCRIPT_MATCH_LIMIT = 4e3; +const TRANSCRIPT_MIN_VOTES = 3; +const TRANSCRIPT_PATH_PATTERNS = [/[a-zA-Z]:[A-Za-z0-9._@+\-/]{3,240}/g, /\/[A-Za-z0-9._@+\-/]{3,240}/g]; +function workspaceFromTranscriptFile(transcriptFile) { + if (!transcriptFile || !existsSync(transcriptFile)) return null; + const chunk = normalizePathSlashes(readFileSync(transcriptFile, "utf-8").slice(0, TRANSCRIPT_SCAN_BYTES)); + const counts = /* @__PURE__ */ new Map(); + const seen = /* @__PURE__ */ new Set(); + for (const pattern of TRANSCRIPT_PATH_PATTERNS) { + pattern.lastIndex = 0; + let match; + let scanned = 0; + while ((match = pattern.exec(chunk)) !== null) { + if (++scanned > TRANSCRIPT_MATCH_LIMIT) break; + if (seen.size >= TRANSCRIPT_CANDIDATE_LIMIT) break; + const value = match[0]; + if (seen.has(value)) continue; + seen.add(value); + if (value.startsWith("//")) continue; + if (isCursorMetadataPath(value) || isIdeInstallPath(value) || isSystemPath(value)) continue; + const existing = existingDirectory(value); + if (!existing || existing === HOME || isBadPath(existing)) continue; + const root = gitRootNearby(existing); + if (!root || root === HOME || isBadPath(root)) continue; + counts.set(root, (counts.get(root) || 0) + 1); + } + } + let best = null; + let bestCount = 0; + for (const [pathValue, count] of counts) if (count > bestCount) { + best = pathValue; + bestCount = count; + } + return bestCount >= TRANSCRIPT_MIN_VOTES ? best : null; +} +function workspaceFromSessionId(sessionId) { + const hit = findSessionTranscript(sessionId); + if (!hit) return null; + const preferredLabel = process.env["CURSOR_WORKSPACE_LABEL"] || ""; + const fromSlug = pickBestCandidate(decodeSlugCandidates(hit.slug), preferredLabel); + if (fromSlug) return fromSlug; + return workspaceFromTranscriptFile(hit.transcriptFile); +} +function isMetadataProject(project) { + return project.name.startsWith(".") && !project.fromGitRoot; +} +function isHomeDirectory(pathValue) { + return normalizePathSlashes(pathValue) === normalizePathSlashes(HOME); +} +function existingDirectory(pathValue) { + if (!pathExists(pathValue)) return null; + const resolved = process.platform === "win32" ? pathValue.replace(/\//g, "\\") : pathValue; + try { + return statSync(resolved).isFile() ? dirname(resolved) : resolved; + } catch { + return null; + } +} +function resolveFromPathCandidates(candidates, sessionId, options = {}) { + for (const candidate of candidates) { + if (typeof candidate !== "string" || isBadPath(candidate)) continue; + const existing = options.exact ? existingDirectory(candidate) : existingAncestor(candidate); + if (!existing || isBadPath(existing)) continue; + const project = projectFromPath(existing); + if (isHomeDirectory(existing) && !project.fromGitRoot) continue; + if (!isMetadataProject(project)) { + rememberSession(sessionId, project.name, existing); + return { + project: project.name, + cwd: existing + }; + } + } + return null; +} +function debugLayer(layer, result) { + if (result && process.env["AM_CURSOR_DEBUG"] === "1") console.error(`[agentmemory] workspace resolved by ${layer}: ${result.project} (${result.cwd})`); + return result; +} +function resolveWorkspace(data) { + const sessionId = data?.["session_id"] ?? data?.["sessionId"]; + const cached = recallSession(sessionId); + if (cached?.cwd && !isBadPath(cached.cwd)) return { + project: cached.project, + cwd: cached.cwd + }; + const fromPayload = debugLayer("payload", resolveFromPathCandidates([ + ...Array.isArray(data?.["workspace_roots"]) ? data["workspace_roots"] : [], + ...Array.isArray(data?.["workspace_folders"]) ? data["workspace_folders"] : [], + data?.["workspace_folder"], + data?.["workspaceFolder"], + data?.["workspace"], + data?.["cwd"], + data?.["root_path"], + data?.["project_path"] + ], sessionId)); + if (fromPayload) return fromPayload; + const fromTools = debugLayer("tool_input", resolveFromPathCandidates(collectPathStrings(data?.["tool_input"]).map(existingAncestor).filter((p) => Boolean(p) && !isIdeInstallPath(p)), sessionId)); + if (fromTools) return fromTools; + if (sessionId) { + const fromDb = debugLayer("cursor-db", resolveFromPathCandidates([workspaceFromCursorDb(sessionId)], sessionId, { exact: true })); + if (fromDb) return fromDb; + const fromSession = debugLayer("transcript-dir", resolveFromPathCandidates([workspaceFromSessionId(sessionId)], sessionId, { exact: true })); + if (fromSession) return fromSession; + } + const fromEnv = debugLayer("env", resolveFromPathCandidates([ + process.env["CURSOR_WORKSPACE_ROOT"], + process.env["CURSOR_WORKSPACE_FOLDER"], + process.env["PWD"], + process.env["VSCODE_CWD"] + ], sessionId)); + if (fromEnv) return fromEnv; + const label = process.env["CURSOR_WORKSPACE_LABEL"]; + if (label) { + rememberSession(sessionId, label, label); + return { + project: label, + cwd: label + }; + } + return { + project: "unknown-project", + cwd: "unknown-project" + }; +} +//#endregion +//#region src/hooks/cursor/delegate.ts +const HOOK_MAP = { + sessionStart: "session-start.mjs", + beforeSubmitPrompt: "prompt-submit.mjs", + preToolUse: "pre-tool-use.mjs", + postToolUse: "post-tool-use.mjs", + postToolUseFailure: "post-tool-failure.mjs", + preCompact: "pre-compact.mjs", + subagentStart: "subagent-start.mjs", + subagentStop: "subagent-stop.mjs", + stop: "stop.mjs", + sessionEnd: "session-end.mjs" +}; +function isCursorHookKey(value) { + return typeof value === "string" && value in HOOK_MAP; +} +const SLOW_HOOKS = new Set(["stop", "sessionEnd"]); +function defaultOfficialDir() { + return join(dirname(fileURLToPath(import.meta.url)), ".."); +} +function enrichPayload(data) { + const { project, cwd } = resolveWorkspace(data); + return { + project, + payload: { + ...data, + session_id: data["session_id"] ?? data["sessionId"], + cwd + } + }; +} +function delegateHook(hookKey, data, options = {}) { + const script = HOOK_MAP[hookKey]; + const scriptPath = join(options.officialDir ?? defaultOfficialDir(), script); + if (!existsSync(scriptPath)) { + console.error(`[agentmemory] cursor hook "${hookKey}": canonical hook not found at ${scriptPath}. Run \`npm run build\` in the agentmemory checkout.`); + return 0; + } + const { project, payload } = enrichPayload(data); + const child = spawnSync(process.execPath, [scriptPath], { + input: JSON.stringify(payload), + env: { + ...process.env, + AGENTMEMORY_PROJECT_NAME: project + }, + encoding: "utf-8", + maxBuffer: 10 * 1024 * 1024, + timeout: SLOW_HOOKS.has(hookKey) ? 18e4 : 3e4 + }); + if (child.stdout) process.stdout.write(child.stdout); + if (child.stderr) process.stderr.write(child.stderr); + if (child.error) { + console.error(`[agentmemory] cursor hook "${hookKey}" could not run ${script}: ${child.error.message}`); + return 0; + } + if (child.signal) { + console.error(`[agentmemory] cursor hook "${hookKey}" (${script}) was killed by ${child.signal} -- treating as no-op`); + return 0; + } + return child.status ?? 0; +} +//#endregion +//#region src/hooks/cursor/run-hook.ts +async function main() { + const hookKey = process.argv[2]; + if (!isCursorHookKey(hookKey)) process.exit(0); + let input = ""; + for await (const chunk of process.stdin) input += chunk; + if (!input.trim()) process.exit(0); + let data; + try { + data = JSON.parse(input); + } catch { + process.exit(0); + } + process.exit(delegateHook(hookKey, data)); +} +main(); +//#endregion +export {}; + +//# sourceMappingURL=run-hook.mjs.map \ No newline at end of file diff --git a/src/cli/connect/cursor.ts b/src/cli/connect/cursor.ts index fb4c5afb3..642add4ef 100644 --- a/src/cli/connect/cursor.ts +++ b/src/cli/connect/cursor.ts @@ -5,9 +5,17 @@ import { createJsonMcpAdapter } from "./json-mcp-adapter.js"; export const adapter = createJsonMcpAdapter({ name: "cursor", displayName: "Cursor", + // Cursor speaks lifecycle hooks as well as MCP, so it groups with the + // native hosts rather than the MCP-only ones. + category: "native", detectDir: join(homedir(), ".cursor"), configPath: join(homedir(), ".cursor", "mcp.json"), - docs: "https://github.com/rohitg00/agentmemory#other-agents", + docs: "https://github.com/rohitg00/agentmemory#cursor", + // Deliberately no --with-hooks here, unlike Codex and Claude Code. Theirs + // mirror the plugin's hooks into a user-scope file to work around hosts + // that fail to dispatch plugin-scope hooks; Cursor dispatches them fine. + // Writing ~/.cursor/hooks.json as well would just make both copies fire and + // record every observation twice. protocolNote: - "→ Using MCP (the only protocol Cursor speaks). Memory bridge runs at :3111 underneath.", + "→ Using MCP. Lifecycle hooks ship in the Cursor plugin: Settings → Plugins → Add marketplace → this repo, then enable agentmemory.", }); diff --git a/src/hooks/cursor/cursor-db.ts b/src/hooks/cursor/cursor-db.ts new file mode 100644 index 000000000..538886e4e --- /dev/null +++ b/src/hooks/cursor/cursor-db.ts @@ -0,0 +1,273 @@ +/** + * Exact session -> workspace lookup from Cursor's own SQLite storage. + * + * Everything else in the resolver infers the workspace: from payload fields + * Cursor may or may not send, from paths that happen to appear in tool + * arguments, from a transcript directory name whose path separators have been + * flattened into hyphens. Those work, but they are inference, and inference + * that lands on the wrong project writes a user's memories into the wrong + * place without ever reporting an error. + * + * Cursor knows the answer exactly, and stores it. The layout (reverse + * engineered; see integrations/cursor/README.md for the full picture): + * + * /globalStorage/state.vscdb + * ItemTable["composer.composerHeaders"] + * -> { allComposers: [ { composerId, workspaceIdentifier: { + * id, uri: { fsPath, ... } } } ] } + * + * A Cursor hook's `session_id` is the `composerId`, so one indexed lookup + * gives the workspace path with no guessing. + * + * Two things stop this from being the only strategy: + * + * - Cursor 3.0 (April 2026) moved this index from per-workspace databases + * into the global one, and the migration is lazy: a workspace migrates + * when it is next opened. Machines still on <=2.6, or with workspaces not + * opened since the upgrade, keep the old per-workspace `allComposers` + * array instead. Both shapes are handled below. + * - Reading SQLite needs a driver. `node:sqlite` only exists from Node 22.5, + * and this package supports Node >=20. better-sqlite3 is an optional + * dependency of the daemon, not something a hook can count on. + * + * So every failure path here returns null and the caller falls back to + * inference. This module is an accuracy upgrade, never a requirement. + */ +import { createRequire } from "node:module"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); + +interface Statement { + get(...params: unknown[]): unknown; +} +interface DbHandle { + prepare(sql: string): Statement; + close(): void; +} +interface Driver { + open(path: string): DbHandle; +} + +// `undefined` = not probed yet, `null` = probed and unavailable. +let driverCache: Driver | null | undefined; + +// node:sqlite is still flagged experimental and emits a process warning on +// load. Hook stderr is surfaced in Cursor's hook log, so that would put a +// paragraph about an implementation detail in front of the user on every +// session that reaches this module. +// +// The filter is installed once and left in place, rather than wrapped around +// the require, because process.emitWarning defers the actual emission to the +// next tick: restoring the original listeners in a `finally` happens before +// the warning is ever emitted, so the wrapping approach silences nothing. It +// is scoped as tightly as it can be -- only this one warning is dropped, and +// everything else is re-emitted untouched. +let warningFilterInstalled = false; + +function suppressSqliteExperimentalWarning(): void { + if (warningFilterInstalled) return; + warningFilterInstalled = true; + const previous = process.listeners("warning"); + process.removeAllListeners("warning"); + process.on("warning", (warning: Error) => { + if (warning.name === "ExperimentalWarning" && /sqlite/i.test(warning.message)) return; + for (const listener of previous) listener(warning); + }); +} + +function requireQuietly(id: string): T | null { + suppressSqliteExperimentalWarning(); + try { + return require(id) as T; + } catch { + return null; + } +} + +function loadDriver(): Driver | null { + if (driverCache !== undefined) return driverCache; + + const nodeSqlite = requireQuietly<{ DatabaseSync: new (p: string, o?: object) => DbHandle }>( + "node:sqlite", + ); + if (nodeSqlite?.DatabaseSync) { + driverCache = { + open: (path) => new nodeSqlite.DatabaseSync(path, { readOnly: true }), + }; + return driverCache; + } + + // Same lazy-optional pattern the daemon uses for better-sqlite3 in + // src/functions/migrate.ts -- used when installed, never required. + const better = requireQuietly DbHandle>("better-sqlite3"); + if (better) { + driverCache = { + open: (path) => new better(path, { readonly: true, fileMustExist: true }), + }; + return driverCache; + } + + driverCache = null; + return driverCache; +} + +/** Read one ItemTable value. Returns null for any failure, including a locked DB. */ +function readItemTableValue(dbPath: string, key: string): string | null { + if (!existsSync(dbPath)) return null; + const driver = loadDriver(); + if (!driver) return null; + + let db: DbHandle | null = null; + try { + db = driver.open(dbPath); + const row = db.prepare("SELECT value FROM ItemTable WHERE key = ?").get(key) as + | { value?: unknown } + | undefined; + const value = row?.value; + if (typeof value === "string") return value; + if (value instanceof Uint8Array) return Buffer.from(value).toString("utf-8"); + return null; + } catch { + // Cursor holds the database open while running. Readers normally coexist + // fine, but a locked or half-migrated file must not break the hook. + return null; + } finally { + try { + db?.close(); + } catch {} + } +} + +export interface CursorStorageRoots { + globalStorage: string; + workspaceStorage: string; +} + +export function cursorStorageRoots(): CursorStorageRoots | null { + const home = homedir(); + const bases: string[] = []; + if (process.platform === "win32") { + const appData = process.env["APPDATA"]; + if (appData) bases.push(join(appData, "Cursor", "User")); + bases.push(join(home, "AppData", "Roaming", "Cursor", "User")); + } else if (process.platform === "darwin") { + bases.push(join(home, "Library", "Application Support", "Cursor", "User")); + } else { + const configHome = process.env["XDG_CONFIG_HOME"]; + if (configHome) bases.push(join(configHome, "Cursor", "User")); + bases.push(join(home, ".config", "Cursor", "User")); + } + + for (const base of bases) { + const globalStorage = join(base, "globalStorage"); + if (existsSync(globalStorage)) { + return { globalStorage, workspaceStorage: join(base, "workspaceStorage") }; + } + } + return null; +} + +interface ComposerHeader { + composerId?: unknown; + workspaceIdentifier?: { uri?: { fsPath?: unknown; path?: unknown } }; +} + +function fsPathFromHeader(header: ComposerHeader | undefined): string | null { + const uri = header?.workspaceIdentifier?.uri; + const value = uri?.fsPath ?? uri?.path; + return typeof value === "string" && value.trim() ? value : null; +} + +/** Cursor 3.0+: one central index in the global database. */ +function fromGlobalIndex(sessionId: string, roots: CursorStorageRoots): string | null { + const raw = readItemTableValue( + join(roots.globalStorage, "state.vscdb"), + "composer.composerHeaders", + ); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as { allComposers?: ComposerHeader[] }; + const hit = parsed.allComposers?.find((c) => c?.composerId === sessionId); + // A hit with no uri is a workspace-less "empty window" chat -- Cursor + // genuinely has no path for it, so neither do we. + return fsPathFromHeader(hit); + } catch { + return null; + } +} + +/** `{"folder":"file:///d%3A/repo"}`, or a vscode-remote:// URI we cannot map. */ +function folderFromWorkspaceJson(workspaceDir: string): string | null { + const file = join(workspaceDir, "workspace.json"); + if (!existsSync(file)) return null; + try { + const parsed = JSON.parse(readFileSync(file, "utf-8")) as { folder?: unknown }; + const folder = parsed.folder; + if (typeof folder !== "string" || !folder.startsWith("file://")) return null; + return fileURLToPath(folder); + } catch { + return null; + } +} + +// Pre-3.0 lookup means opening one database per workspace, so it is ordered +// by recency and capped: the workspace a live session belongs to was touched +// moments ago and sits at the top. A full scan of ~120 workspaces measures +// around 200ms; this ordering turns the common case into one or two opens. +const LEGACY_SCAN_LIMIT = 40; + +function fromLegacyWorkspaceDbs(sessionId: string, roots: CursorStorageRoots): string | null { + if (!existsSync(roots.workspaceStorage)) return null; + + let dirs: Array<{ dir: string; mtime: number }>; + try { + dirs = readdirSync(roots.workspaceStorage) + .map((dir) => { + try { + return { dir, mtime: statSync(join(roots.workspaceStorage, dir, "state.vscdb")).mtimeMs }; + } catch { + return null; + } + }) + .filter((x): x is { dir: string; mtime: number } => x !== null) + .sort((a, b) => b.mtime - a.mtime) + .slice(0, LEGACY_SCAN_LIMIT); + } catch { + return null; + } + + for (const { dir } of dirs) { + const raw = readItemTableValue( + join(roots.workspaceStorage, dir, "state.vscdb"), + "composer.composerData", + ); + if (!raw) continue; + try { + const parsed = JSON.parse(raw) as { allComposers?: Array<{ composerId?: unknown }> }; + // Migrated workspaces no longer carry allComposers; their sessions are + // in the global index handled above. + if (!Array.isArray(parsed.allComposers)) continue; + if (!parsed.allComposers.some((c) => c?.composerId === sessionId)) continue; + return folderFromWorkspaceJson(join(roots.workspaceStorage, dir)); + } catch { + continue; + } + } + return null; +} + +/** + * The workspace directory Cursor recorded for this session, or null when it + * cannot be determined (no driver, no storage, workspace-less window, or a + * remote URI that does not map to a local path). + */ +export function workspaceFromCursorDb(sessionId: string): string | null { + if (!sessionId) return null; + const roots = cursorStorageRoots(); + if (!roots) return null; + return fromGlobalIndex(sessionId, roots) ?? fromLegacyWorkspaceDbs(sessionId, roots); +} diff --git a/src/hooks/cursor/delegate.ts b/src/hooks/cursor/delegate.ts new file mode 100644 index 000000000..b123d3332 --- /dev/null +++ b/src/hooks/cursor/delegate.ts @@ -0,0 +1,120 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveWorkspace } from "./workspace.js"; + +// Cursor fires camelCase lifecycle events; each one maps to the canonical +// hook compiled from src/hooks/*.ts. Keep this table the only place that +// knows Cursor's naming — everything downstream is the shared hook. +export const HOOK_MAP = { + sessionStart: "session-start.mjs", + beforeSubmitPrompt: "prompt-submit.mjs", + preToolUse: "pre-tool-use.mjs", + postToolUse: "post-tool-use.mjs", + postToolUseFailure: "post-tool-failure.mjs", + preCompact: "pre-compact.mjs", + subagentStart: "subagent-start.mjs", + subagentStop: "subagent-stop.mjs", + stop: "stop.mjs", + sessionEnd: "session-end.mjs", +} as const; + +export type CursorHookKey = keyof typeof HOOK_MAP; + +export function isCursorHookKey(value: unknown): value is CursorHookKey { + return typeof value === "string" && value in HOOK_MAP; +} + +// stop/sessionEnd fan out to summarize + consolidate on the daemon side, so +// they get a longer leash than the interactive hooks. +const SLOW_HOOKS = new Set(["stop", "sessionEnd"]); + +export type HookPayload = Record; + +export interface DelegateOptions { + // Tests run against src/, where the canonical .mjs files do not sit one + // level up. Bundled output resolves it correctly on its own. + officialDir?: string; +} + +// Bundled to plugin/scripts/cursor/.mjs, so the canonical hooks are +// exactly one directory up. +function defaultOfficialDir(): string { + return join(dirname(fileURLToPath(import.meta.url)), ".."); +} + +export function enrichPayload(data: HookPayload): { + project: string; + payload: HookPayload; +} { + const { project, cwd } = resolveWorkspace(data); + return { + project, + payload: { + ...data, + session_id: data["session_id"] ?? data["sessionId"], + cwd, + }, + }; +} + +export function delegateHook( + hookKey: CursorHookKey, + data: HookPayload, + options: DelegateOptions = {}, +): number { + const script = HOOK_MAP[hookKey]; + const scriptPath = join(options.officialDir ?? defaultOfficialDir(), script); + + // Checked explicitly rather than left to the spawn: node starts fine and + // then exits 1 with a module-not-found stack, which the block below would + // forward as a deliberate non-zero decision from the hook, and which lands + // a Node stack trace in Cursor's hook log. This is a real installation + // state -- the plugin ships built .mjs, so a source checkout that has not + // run `npm run build` hits it -- and it deserves an answer the user can act + // on rather than a stack trace. + if (!existsSync(scriptPath)) { + console.error( + `[agentmemory] cursor hook "${hookKey}": canonical hook not found at ${scriptPath}.` + + ` Run \`npm run build\` in the agentmemory checkout.`, + ); + return 0; + } + + const { project, payload } = enrichPayload(data); + + const child = spawnSync(process.execPath, [scriptPath], { + input: JSON.stringify(payload), + // resolveProject() in the canonical hooks reads this before falling back + // to git/cwd, which is how the Cursor-specific resolution wins. + env: { ...process.env, AGENTMEMORY_PROJECT_NAME: project }, + encoding: "utf-8", + maxBuffer: 10 * 1024 * 1024, + timeout: SLOW_HOOKS.has(hookKey) ? 180000 : 30000, + }); + + if (child.stdout) process.stdout.write(child.stdout); + if (child.stderr) process.stderr.write(child.stderr); + + // spawnSync reports a failure to launch (missing script, timeout kill) as + // status === null, so `status ?? 0` would announce success and lose the + // observation silently -- the exact failure mode that makes a memory hook + // untrustworthy. Say so on stderr, but still exit 0: agentmemory is a + // passive recorder and must never block the editor. A genuine non-zero + // exit from the canonical hook is a real decision and is passed through. + if (child.error) { + console.error( + `[agentmemory] cursor hook "${hookKey}" could not run ${script}: ${child.error.message}`, + ); + return 0; + } + if (child.signal) { + console.error( + `[agentmemory] cursor hook "${hookKey}" (${script}) was killed by ${child.signal}` + + ` -- treating as no-op`, + ); + return 0; + } + return child.status ?? 0; +} diff --git a/src/hooks/cursor/run-detached.ts b/src/hooks/cursor/run-detached.ts new file mode 100644 index 000000000..914cf16df --- /dev/null +++ b/src/hooks/cursor/run-detached.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { unlinkSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { delegateHook, isCursorHookKey } from "./delegate.js"; +import { + readHookStdinComplete, + readWorkerHookPayload, + writeHookPayloadTemp, +} from "./workspace.js"; + +// stop/sessionEnd trigger summarize + consolidate on the daemon. Cursor +// kills its hook process tree when the window closes, so the real work runs +// in a detached worker that outlives the window; the parent only hands off +// the payload and returns control immediately. +const IS_WORKER = process.env["AM_HOOK_WORKER"] === "1"; +const hookKey = process.argv[2]; + +function runWorker(): void { + const hardLimitMs = hookKey === "sessionEnd" ? 250000 : 130000; + const watchdog = setTimeout(() => process.exit(0), hardLimitMs); + + try { + const data = readWorkerHookPayload(); + if (!data || !isCursorHookKey(hookKey)) return; + delegateHook(hookKey, data); + } finally { + clearTimeout(watchdog); + process.exit(0); + } +} + +async function runParent(): Promise { + if (!hookKey) process.exit(0); + + const input = await readHookStdinComplete(); + const payloadFile = writeHookPayloadTemp(input); + + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), hookKey], { + detached: true, + stdio: "ignore", + windowsHide: true, + env: { + ...process.env, + AM_HOOK_WORKER: "1", + AM_HOOK_INPUT_FILE: payloadFile, + }, + }); + child.unref(); + + const bail = setTimeout(() => process.exit(0), 2000); + if (bail.unref) bail.unref(); + child.on("spawn", () => process.exit(0)); + child.on("error", (err: Error) => { + console.error("[agentmemory] failed to spawn detached hook worker:", err.message); + try { + unlinkSync(payloadFile); + } catch {} + process.exit(0); + }); +} + +if (IS_WORKER) { + runWorker(); +} else { + void runParent(); +} diff --git a/src/hooks/cursor/run-hook.ts b/src/hooks/cursor/run-hook.ts new file mode 100644 index 000000000..9a81a9d29 --- /dev/null +++ b/src/hooks/cursor/run-hook.ts @@ -0,0 +1,25 @@ +#!/usr/bin/env node +import { delegateHook, isCursorHookKey, type HookPayload } from "./delegate.js"; + +// CLI entrypoint for Cursor's synchronous hooks. Kept separate from +// delegate.ts so importing the dispatcher never starts a second stdin +// reader in the same process (run-detached.ts depends on that). +async function main(): Promise { + const hookKey = process.argv[2]; + if (!isCursorHookKey(hookKey)) process.exit(0); + + let input = ""; + for await (const chunk of process.stdin) input += chunk; + if (!input.trim()) process.exit(0); + + let data: HookPayload; + try { + data = JSON.parse(input) as HookPayload; + } catch { + process.exit(0); + } + + process.exit(delegateHook(hookKey, data)); +} + +main(); diff --git a/src/hooks/cursor/workspace.ts b/src/hooks/cursor/workspace.ts new file mode 100644 index 000000000..a80ea5eb8 --- /dev/null +++ b/src/hooks/cursor/workspace.ts @@ -0,0 +1,703 @@ +import { + chmodSync, + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + readdirSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { execSync } from "node:child_process"; +import { dirname, join, basename } from "node:path"; +import { homedir } from "node:os"; +import { workspaceFromCursorDb } from "./cursor-db.js"; + +// Cursor does not hand hooks a trustworthy working directory: `cwd` can be +// `.cursor`, the IDE install path (via VSCODE_CWD), or absent entirely. This +// module is the Cursor-specific piece of the adapter — everything it exists +// to do is turn whatever Cursor sends into a real project directory, so the +// canonical hooks in src/hooks/*.ts can stay unchanged. +const HOME = homedir(); +const CURSOR_PROJECTS_DIR = join(HOME, ".cursor", "projects"); +const SESSION_CACHE_PATH = join(HOME, ".cursor", "hooks", ".agentmemory-session-cache.json"); +const HOOK_PAYLOAD_DIR = join(HOME, ".cursor", "hooks", ".am-hook-payloads"); + +export interface Workspace { + project: string; + cwd: string; +} + +export type HookData = Record | null | undefined; + +interface SessionCacheEntry { + project: string; + cwd: string; + updatedAt: string; +} + +type SessionCache = Record; + +export function normalizePathSlashes(value: unknown): string { + return String(value).replace(/\\/g, "/"); +} + +export function isCursorMetadataPath(value: unknown): boolean { + if (!value || typeof value !== "string") return false; + const trimmed = normalizePathSlashes(value.trim()); + if (trimmed === ".cursor") return true; + // ~/.cursor/worktrees/ is the exception: Cursor puts real git + // checkouts there for its background agents. Treating those as metadata + // sends the session to whatever the transcript scan guesses instead of to + // the repository the agent is actually editing. + if (/(^|\/)\.cursor\/worktrees\/[^/]/.test(trimmed)) return false; + return /(^|\/)\.cursor(\/|$)/.test(trimmed); +} + +export function pathUnderHome(value: unknown): boolean { + if (typeof value !== "string") return false; + const homeNorm = normalizePathSlashes(HOME); + const valueNorm = normalizePathSlashes(value); + return valueNorm === homeNorm || valueNorm.startsWith(`${homeNorm}/`); +} + +// VSCODE_CWD points at the Cursor install directory, which resolves to a +// project literally named "cursor" if it is allowed through. +function isIdeInstallPath(value: unknown): boolean { + if (!value || typeof value !== "string") return false; + const norm = normalizePathSlashes(value).toLowerCase(); + return ( + /(^|[\\/])(programs|program files|program files \(x86\))[\\/]cursor([\\/]|$)/i.test(norm) || + /cursor\.app[\\/]contents/i.test(norm) || + /(^|[\\/])microsoft vs code[\\/]resources[\\/]app([\\/]|$)/i.test(norm) + ); +} + +function isBadPath(value: unknown): boolean { + if (!value || typeof value !== "string") return true; + const trimmed = normalizePathSlashes(value.trim()); + if (!trimmed || trimmed === "/" || trimmed === ".") return true; + // A bare drive root is never a project. Cursor emits a single-letter + // transcript slug for some legacy windows ("c"), which decodes to "C:". + if (/^[a-zA-Z]:\/?$/.test(trimmed)) return true; + if (isCursorMetadataPath(trimmed)) return true; + if (isIdeInstallPath(trimmed)) return true; + return false; +} + +function sleepMs(ms: number): void { + const end = Date.now() + ms; + while (Date.now() < end) { + // Busy wait: this runs inside a lock retry loop in a short-lived hook + // process, where blocking is cheaper than an async scheduler hop. + } +} + +function withSessionCacheLock(fn: () => T): T | undefined { + const lockPath = `${SESSION_CACHE_PATH}.lock`; + mkdirSync(dirname(SESSION_CACHE_PATH), { recursive: true }); + let fd: number | undefined; + for (let i = 0; i < 50; i++) { + try { + fd = openSync(lockPath, "wx"); + break; + } catch { + sleepMs(10); + } + } + if (fd === undefined) return undefined; + try { + return fn(); + } finally { + closeSync(fd); + try { + unlinkSync(lockPath); + } catch {} + } +} + +function loadSessionCache(): SessionCache { + try { + return JSON.parse(readFileSync(SESSION_CACHE_PATH, "utf-8")) as SessionCache; + } catch { + return {}; + } +} + +function rememberSession(sessionId: string | undefined, project: string, cwd: string): void { + if (!sessionId || !project || project === ".cursor") return; + withSessionCacheLock(() => { + try { + const cache = loadSessionCache(); + cache[sessionId] = { project, cwd, updatedAt: new Date().toISOString() }; + const tmp = `${SESSION_CACHE_PATH}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tmp, JSON.stringify(cache, null, 2)); + renameSync(tmp, SESSION_CACHE_PATH); + } catch {} + }); +} + +function recallSession(sessionId: string | undefined): SessionCacheEntry | null { + if (!sessionId) return null; + const cache = loadSessionCache(); + return cache[sessionId] ?? null; +} + +// Absolute paths that belong to the OS rather than to any user project. A +// stray /usr/lib/... or /etc/... reference inside tool_input must not be +// allowed to resolve to a project named "lib" or "etc". +const SYSTEM_PATH_PREFIXES = [ + "/usr/", + "/etc/", + "/bin/", + "/sbin/", + "/lib/", + "/lib64/", + "/opt/", + "/var/", + "/proc/", + "/sys/", + "/dev/", + "/run/", + "/boot/", + "/snap/", + "/nix/", +]; + +function isSystemPath(value: string): boolean { + const norm = normalizePathSlashes(value); + return SYSTEM_PATH_PREFIXES.some((prefix) => norm.startsWith(prefix)); +} + +// tool_input carries whatever shape the tool used, so paths are harvested by +// walking the object. The filter has to be permissive enough to find the +// project and strict enough not to invent one. +function isCollectablePath(value: unknown): value is string { + if (typeof value !== "string" || isCursorMetadataPath(value)) return false; + if (pathUnderHome(value)) return true; + // Windows drive-absolute: HOME is regularly on C: while the checkout sits + // on D:, so a HOME-only rule loses every cross-drive project. + if (/^[a-zA-Z]:[\\/]/.test(value)) return pathExists(value); + // POSIX-absolute outside $HOME: containers (Codespaces, devcontainers) + // check repos out at /workspaces/..., which a HOME-only rule silently + // rejects. existingAncestor() and the git lookup downstream validate it. + if (value.startsWith("/")) return !isSystemPath(value) && pathExists(value); + return false; +} + +function collectPathStrings(value: unknown, out: string[] = []): string[] { + if (typeof value === "string") { + if (isCollectablePath(value)) out.push(value); + return out; + } + if (Array.isArray(value)) { + for (const item of value) collectPathStrings(item, out); + return out; + } + if (value && typeof value === "object") { + for (const v of Object.values(value)) collectPathStrings(v, out); + } + return out; +} + +function pathExists(pathValue: string): boolean { + if (existsSync(pathValue)) return true; + if (process.platform === "win32") { + const native = pathValue.replace(/\//g, "\\"); + if (native !== pathValue && existsSync(native)) return true; + } + return false; +} + +const MAX_ANCESTOR_STEPS = 64; + +// Payloads often carry a file path (tool_input.path) rather than a +// directory, and sometimes a path that no longer exists. Walk up until +// something real is found, then normalise a file down to its directory. +function existingAncestor(pathValue: string): string | null { + let current = pathValue; + for (let step = 0; step < MAX_ANCESTOR_STEPS; step++) { + if (!current || current === HOME || current === "/") return null; + if (pathExists(current)) { + const resolved = process.platform === "win32" ? current.replace(/\//g, "\\") : current; + try { + if (statSync(resolved).isFile()) { + return dirname(resolved); + } + } catch {} + return resolved; + } + // dirname() is a fixed point at every filesystem root -- dirname("//") is + // "//", dirname("C:") is "C:", dirname("D:/") is "D:/" -- so climbing + // without a progress check spins forever. Nothing produced such an input + // while this only ever saw $HOME-prefixed paths; a general path scan + // produces them constantly, because every "https://host/x" in a + // transcript contains a "//host/x". + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } + return null; +} + +function gitRootFromPath(targetPath: string): string { + return execSync("git rev-parse --show-toplevel", { + cwd: targetPath, + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); +} + +// Filesystem-only equivalent of `git rev-parse --show-toplevel`, for hot +// paths that would otherwise spawn a process per candidate. `.git` is a +// directory in a normal clone and a file in a linked worktree, so a plain +// existence check covers both. +function gitRootNearby(startPath: string): string | null { + let current = startPath; + for (let step = 0; step < MAX_ANCESTOR_STEPS; step++) { + if (!current || current === "/") return null; + if (pathExists(join(current, ".git"))) return current; + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } + return null; +} + +function cleanRepoName(dirPath: string): string { + const normalized = normalizePathSlashes(dirPath).replace(/\/+$/, ""); + if (!normalized) return "unknown-project"; + + const claudeWt = normalized.match(/^(.*?)\/\.claude\/worktrees\/[^/]+$/i); + if (claudeWt?.[1]) return cleanRepoName(claudeWt[1]); + + // Cursor names agent worktrees "-<4-8 char token>" under + // ~/.cursor/worktrees. Fold them back onto the repository so a background + // agent's memories land with the rest of that project's. + const cursorWt = normalized.match(/\/\.cursor\/worktrees\/([^/]+)$/i); + if (cursorWt?.[1]) { + const stripped = cursorWt[1].replace(/-[a-z0-9]{4,8}$/i, ""); + return stripped || cursorWt[1]; + } + + const baseName = basename(normalized); + if (/^agent-[a-f0-9]{6,}$/i.test(baseName)) { + const parent = dirname(normalized); + if (parent && parent !== normalized && parent !== "." && parent !== "/") { + return cleanRepoName(parent); + } + } + + const name = baseName.replace(/(-worktree-\d+|-worktree|-[a-f0-9]{7,40})$/i, ""); + return name || "unknown-project"; +} + +interface ResolvedProject { + name: string; + /** Whether the name came from a git toplevel rather than a bare directory. */ + fromGitRoot: boolean; +} + +function projectFromPath(targetPath: string): ResolvedProject { + try { + return { name: cleanRepoName(gitRootFromPath(targetPath)), fromGitRoot: true }; + } catch { + return { name: cleanRepoName(targetPath), fromGitRoot: false }; + } +} + +// Cursor names each directory under ~/.cursor/projects after the workspace +// path with every separator flattened to "-", so the transcript directory for +// a session already encodes where that session was running. Decoding it is +// lossy in one direction only: a directory name may itself contain hyphens, +// which makes "d-Andrew-Code-cc-router" mean D:/Andrew/Code/cc-router and, +// just as validly on paper, D:/Andrew/Code/cc/router. +// +// The disambiguator is the filesystem. Try every way of grouping consecutive +// segments into one directory name, but only descend into groupings that +// actually exist -- pruning collapses what looks like a 2^segments search +// into the handful of real directories on the machine. +function decodeSlugCandidates(slug: string): string[] { + if (!slug || slug === "empty-window") return []; + // Cursor 3.x names workspace-less windows (started from the welcome screen) + // after a timestamp. Those never correspond to a path. + if (/^\d{10,}$/.test(slug)) return []; + + const parts = slug.split("-"); + if (!parts.length) return []; + + const results = new Set(); + + function walk(index: number, currentPath: string): void { + if (index >= parts.length) { + results.add(currentPath); + return; + } + for (let take = 1; index + take <= parts.length; take++) { + const next = `${currentPath}/${parts.slice(index, index + take).join("-")}`; + if (!pathExists(next)) continue; + walk(index + take, next); + } + } + + // A single leading letter is a Windows drive: "d-Andrew-Code" -> D:/Andrew/Code. + // Without this branch the whole function returns nothing on Windows, which + // is where HOME and the checkout most often sit on different drives. + const first = parts[0]; + if (first && /^[a-zA-Z]$/.test(first)) walk(1, `${first.toUpperCase()}:`); + // Otherwise the slug starts at the filesystem root: "Users-alice-src", + // "home-andrew-src", "workspaces-repo". + walk(0, ""); + + return [...results]; +} + +function pickBestCandidate(candidates: string[], preferredLabel: string): string | null { + if (!candidates.length) return null; + if (preferredLabel) { + const labelMatch = candidates.find((p) => basename(p) === preferredLabel); + if (labelMatch) return labelMatch; + } + + const gitRoots: string[] = []; + for (const candidate of candidates) { + try { + gitRoots.push(gitRootFromPath(candidate)); + } catch {} + } + const uniqueGitRoots = [...new Set(gitRoots)]; + if (uniqueGitRoots.length === 1) return uniqueGitRoots[0] ?? null; + + return candidates.sort((a, b) => b.length - a.length)[0] ?? null; +} + +function findSessionTranscript( + sessionId: string, +): { slug: string; transcriptFile: string | null } | null { + if (!sessionId || !existsSync(CURSOR_PROJECTS_DIR)) return null; + + for (const slug of readdirSync(CURSOR_PROJECTS_DIR)) { + const transcriptsRoot = join(CURSOR_PROJECTS_DIR, slug, "agent-transcripts"); + if (!existsSync(transcriptsRoot)) continue; + + for (const entry of readdirSync(transcriptsRoot)) { + if (entry === sessionId || entry.startsWith(`${sessionId}-`)) { + const transcriptFile = join(transcriptsRoot, entry, `${entry}.jsonl`); + return { + slug, + transcriptFile: existsSync(transcriptFile) ? transcriptFile : null, + }; + } + } + } + return null; +} + +const TRANSCRIPT_SCAN_BYTES = 250000; +const TRANSCRIPT_CANDIDATE_LIMIT = 120; +const TRANSCRIPT_MATCH_LIMIT = 4000; +const TRANSCRIPT_MIN_VOTES = 3; + +// Paths are normalised to forward slashes before matching, so a Windows path +// looks like "D:/repo/src/a.ts" by the time these run. +// +// Both patterns are a single character class with one bounded quantifier, on +// purpose. The obvious formulation -- /\/(?:[\w.-]+\/)+[\w.-]*/ -- nests a +// quantifier inside a quantifier, and on a 250KB transcript full of +// slash-bearing strings that backtracks badly enough to hang the hook for +// minutes. There is no clever matching to do here anyway: grab anything +// path-shaped and let the existence check below decide. +const TRANSCRIPT_PATH_PATTERNS = [ + /[a-zA-Z]:[A-Za-z0-9._@+\-/]{3,240}/g, // Windows drive-absolute + /\/[A-Za-z0-9._@+\-/]{3,240}/g, // POSIX absolute +]; + +// Last resort before environment variables: mine the session transcript for +// paths and take the directory that shows up across the most of them. +// +// This used to anchor its regex on $HOME, which quietly made the whole layer +// dead on the two most common non-trivial setups -- Windows with HOME on C: +// and the checkout on D:, and containers that check out under /workspaces. +// Match any absolute path shape instead and let existence plus the git lookup +// downstream throw out the noise (URLs, log fragments, OS paths). +function workspaceFromTranscriptFile(transcriptFile: string | null): string | null { + if (!transcriptFile || !existsSync(transcriptFile)) return null; + + const chunk = normalizePathSlashes( + readFileSync(transcriptFile, "utf-8").slice(0, TRANSCRIPT_SCAN_BYTES), + ); + const counts = new Map(); + const seen = new Set(); + + // Bounded on both axes: every candidate costs filesystem walks, and a busy + // transcript can contain thousands of path-shaped strings. + for (const pattern of TRANSCRIPT_PATH_PATTERNS) { + pattern.lastIndex = 0; + let match: RegExpExecArray | null; + let scanned = 0; + while ((match = pattern.exec(chunk)) !== null) { + if (++scanned > TRANSCRIPT_MATCH_LIMIT) break; + if (seen.size >= TRANSCRIPT_CANDIDATE_LIMIT) break; + const value = match[0]; + if (seen.has(value)) continue; + seen.add(value); + // "//host/path" is the tail of a URL, not a filesystem path. + if (value.startsWith("//")) continue; + if (isCursorMetadataPath(value) || isIdeInstallPath(value) || isSystemPath(value)) continue; + // Only paths that still exist vote, and they vote for their git root. + // + // Both halves matter. Letting a path climb to whatever ancestor still + // exists (existingAncestor) means a deleted D:/repo/pkg/src/a.ts votes + // for D:/repo, and a transcript is full of such paths, so the + // shallowest common directory always wins -- a session in + // D:/Andrew/Code/pkg resolves to the project "Code". Counting existing + // directories as themselves instead just moves the problem: the winner + // becomes whatever generic directory the conversation mentioned most, + // which in practice is "/bin" or "C:/Users". Requiring a repository + // root is what makes a vote mean "this is a project". + const existing = existingDirectory(value); + if (!existing || existing === HOME || isBadPath(existing)) continue; + const root = gitRootNearby(existing); + if (!root || root === HOME || isBadPath(root)) continue; + counts.set(root, (counts.get(root) || 0) + 1); + } + } + + let best: string | null = null; + let bestCount = 0; + for (const [pathValue, count] of counts) { + if (count > bestCount) { + best = pathValue; + bestCount = count; + } + } + + // One passing mention of a repository is not evidence that the session was + // running in it. This layer is a guess of last resort, and a wrong guess + // files a user's memories under someone else's project -- worse than + // admitting the workspace is unknown. + return bestCount >= TRANSCRIPT_MIN_VOTES ? best : null; +} + +function workspaceFromSessionId(sessionId: string): string | null { + const hit = findSessionTranscript(sessionId); + if (!hit) return null; + + // Slug first. It is a lossy encoding of the workspace path, but every + // candidate it produces is verified against the filesystem, so a result is + // a directory that really exists and really matches the name Cursor gave + // this session's transcript directory. + const preferredLabel = process.env["CURSOR_WORKSPACE_LABEL"] || ""; + const fromSlug = pickBestCandidate(decodeSlugCandidates(hit.slug), preferredLabel); + if (fromSlug) return fromSlug; + + // Transcript scan last: it answers "which directory is mentioned most in + // this conversation", which is a guess, not a fact. Asked first it will + // happily answer "agentmemory" for a session about agentmemory that was + // actually running somewhere else entirely. It stays because it is the only + // thing that can place a workspace-less window that was still working on + // real files. + return workspaceFromTranscriptFile(hit.transcriptFile); +} + +export function readHookStdinComplete(maxWaitMs = 30000): Promise { + return new Promise((resolve) => { + let input = ""; + let done = false; + const finish = (): void => { + if (done) return; + done = true; + try { + process.stdin.destroy(); + } catch {} + resolve(input); + }; + const t = setTimeout(finish, maxWaitMs); + if (t.unref) t.unref(); + process.stdin.on("data", (c) => { + input += c; + }); + process.stdin.on("end", () => { + clearTimeout(t); + finish(); + }); + process.stdin.on("error", () => { + clearTimeout(t); + finish(); + }); + }); +} + +export function writeHookPayloadTemp(input: string): string { + mkdirSync(HOOK_PAYLOAD_DIR, { recursive: true }); + try { + chmodSync(HOOK_PAYLOAD_DIR, 0o700); + } catch {} + const path = join(HOOK_PAYLOAD_DIR, `am-hook-${process.pid}-${Date.now()}.json`); + writeFileSync(path, input, { encoding: "utf-8", mode: 0o600 }); + return path; +} + +export function readWorkerHookPayload(): Record | null { + const file = process.env["AM_HOOK_INPUT_FILE"]; + if (!file) { + console.error("[agentmemory] missing AM_HOOK_INPUT_FILE in worker"); + return null; + } + try { + const raw = readFileSync(file, "utf-8"); + return JSON.parse(raw) as Record; + } catch (err) { + console.error("[agentmemory] failed to parse hook payload:", (err as Error).message); + return null; + } finally { + try { + unlinkSync(file); + } catch {} + } +} + +// Sessions were landing under ".codex": a path pointing into an agent's state +// directory walked up to ~/.codex and that became the project name. +// +// Rejecting every dot-named directory would be wrong, though. Plenty of real +// projects are dot-named -- ~/.dotfiles, ~/.emacs.d, ~/.config kept under +// chezmoi, and GitHub's own convention of a repository literally named +// ".github". What separates those from ~/.codex or ~/.vscode is not the name, +// it is that a human deliberately version controls them. So the rule is "a +// dot-named directory that is not a repository", which needs no list of tool +// names to keep up to date as new agents ship. +function isMetadataProject(project: ResolvedProject): boolean { + return project.name.startsWith(".") && !project.fromGitRoot; +} + +function isHomeDirectory(pathValue: string): boolean { + return normalizePathSlashes(pathValue) === normalizePathSlashes(HOME); +} + +// The path exactly, or its parent when it points at a file. Unlike +// existingAncestor() this does not climb: for a workspace path that some +// source claims is authoritative, "the directory is gone" means the record is +// stale, not "use whatever ancestor still exists". Climbing there silently +// turns D:/Andrew/Code/cc-router (moved away) into the project "Code" and +// files the session's memories under it. +function existingDirectory(pathValue: string): string | null { + if (!pathExists(pathValue)) return null; + const resolved = process.platform === "win32" ? pathValue.replace(/\//g, "\\") : pathValue; + try { + return statSync(resolved).isFile() ? dirname(resolved) : resolved; + } catch { + return null; + } +} + +function resolveFromPathCandidates( + candidates: unknown[], + sessionId: string | undefined, + options: { exact?: boolean } = {}, +): Workspace | null { + for (const candidate of candidates) { + if (typeof candidate !== "string" || isBadPath(candidate)) continue; + const existing = options.exact ? existingDirectory(candidate) : existingAncestor(candidate); + if (!existing || isBadPath(existing)) continue; + const project = projectFromPath(existing); + // $HOME is what a hook sees when the agent was launched with no workspace + // at all. Accepting it invents a project named after the account -- real + // sessions were being filed under "Andrew" with cwd C:\Users\Andrew -- + // when the honest answer is that there is no workspace. Same carve-out as + // the dot-directory rule: allowed when HOME is itself a repository, which + // is the dotfiles-checked-out-in-$HOME layout. + if (isHomeDirectory(existing) && !project.fromGitRoot) continue; + if (!isMetadataProject(project)) { + rememberSession(sessionId, project.name, existing); + return { project: project.name, cwd: existing }; + } + } + return null; +} + +// Which layer answered is the single most useful thing to know when a session +// lands under the wrong project, and it is invisible from the outside: every +// layer returns the same shape. Set AM_CURSOR_DEBUG=1 to have the resolver say +// so on stderr, which Cursor surfaces in its hook log. +function debugLayer(layer: string, result: Workspace | null): Workspace | null { + if (result && process.env["AM_CURSOR_DEBUG"] === "1") { + console.error(`[agentmemory] workspace resolved by ${layer}: ${result.project} (${result.cwd})`); + } + return result; +} + +export function resolveWorkspace(data: HookData): Workspace { + const sessionId = (data?.["session_id"] ?? data?.["sessionId"]) as string | undefined; + const cached = recallSession(sessionId); + if (cached?.cwd && !isBadPath(cached.cwd)) { + return { project: cached.project, cwd: cached.cwd }; + } + + const payloadCandidates: unknown[] = [ + ...(Array.isArray(data?.["workspace_roots"]) ? (data["workspace_roots"] as unknown[]) : []), + ...(Array.isArray(data?.["workspace_folders"]) ? (data["workspace_folders"] as unknown[]) : []), + data?.["workspace_folder"], + data?.["workspaceFolder"], + data?.["workspace"], + data?.["cwd"], + data?.["root_path"], + data?.["project_path"], + ]; + + const fromPayload = debugLayer("payload", resolveFromPathCandidates(payloadCandidates, sessionId)); + if (fromPayload) return fromPayload; + + const toolPaths = collectPathStrings(data?.["tool_input"]) + .map(existingAncestor) + .filter((p): p is string => Boolean(p) && !isIdeInstallPath(p)); + const fromTools = debugLayer("tool_input", resolveFromPathCandidates(toolPaths, sessionId)); + if (fromTools) return fromTools; + + if (sessionId) { + // Cursor's own record of where this session was running. Exact, so it is + // tried before the inference layers below -- it costs one indexed SQLite + // read (~5ms) and, because the result is cached per session, happens at + // most once per session rather than once per hook. + const fromDb = debugLayer( + "cursor-db", + resolveFromPathCandidates([workspaceFromCursorDb(sessionId)], sessionId, { exact: true }), + ); + if (fromDb) return fromDb; + + // Same validation as every other layer: both sources here already + // verified the directory exists, so `exact` keeps a stale one from + // silently climbing to a parent. + const fromSession = debugLayer( + "transcript-dir", + resolveFromPathCandidates([workspaceFromSessionId(sessionId)], sessionId, { exact: true }), + ); + if (fromSession) return fromSession; + } + + // Env comes last: VSCODE_CWD in particular is frequently the IDE install + // directory rather than the workspace. + const envCandidates: unknown[] = [ + process.env["CURSOR_WORKSPACE_ROOT"], + process.env["CURSOR_WORKSPACE_FOLDER"], + process.env["PWD"], + process.env["VSCODE_CWD"], + ]; + const fromEnv = debugLayer("env", resolveFromPathCandidates(envCandidates, sessionId)); + if (fromEnv) return fromEnv; + + const label = process.env["CURSOR_WORKSPACE_LABEL"]; + if (label) { + rememberSession(sessionId, label, label); + return { project: label, cwd: label }; + } + + return { project: "unknown-project", cwd: "unknown-project" }; +} + +export function resolveProject(data: HookData): string { + return resolveWorkspace(data).project; +} diff --git a/test/cursor-adapter.test.ts b/test/cursor-adapter.test.ts new file mode 100644 index 000000000..556ee464b --- /dev/null +++ b/test/cursor-adapter.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { createServer, type Server } from "node:http"; +import { spawn } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { HOOK_MAP, delegateHook, isCursorHookKey } from "../src/hooks/cursor/delegate.js"; + +// What the Cursor adapter is responsible for is narrow and testable without a +// daemon: pick the right canonical hook, tell it which project this is, hand +// it the payload, and report what happened. These tests cover exactly that. +// +// They deliberately do not use spawnSync. spawnSync blocks the calling +// process's event loop, so an in-process HTTP server cannot accept the very +// connection the child is making -- the requests arrive only after the child +// has exited, which reads as "the hook sent nothing" and sends you hunting a +// bug that is not there. + +const REPO_ROOT = join(import.meta.dirname, ".."); +const CURSOR_SCRIPTS = join(REPO_ROOT, "plugin", "scripts", "cursor"); +const OFFICIAL_SCRIPTS = join(REPO_ROOT, "plugin", "scripts"); + +const uniqueId = (prefix: string): string => + `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + +interface Capture { + url: string; + body: unknown; +} + +function startCaptureServer(): Promise<{ url: string; captures: Capture[]; close: () => void }> { + const captures: Capture[] = []; + const server: Server = createServer((req, res) => { + let raw = ""; + req.on("data", (c) => (raw += c)); + req.on("end", () => { + let body: unknown = raw; + try { + body = JSON.parse(raw); + } catch {} + captures.push({ url: req.url ?? "", body }); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, sessions: [] })); + }); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + resolve({ + url: `http://127.0.0.1:${port}`, + captures, + close: () => server.close(), + }); + }); + }); +} + +function runShim( + script: string, + args: string[], + payload: unknown, + env: Record, +): Promise<{ code: number | null; stderr: string }> { + return new Promise((resolve) => { + const child = spawn(process.execPath, [join(CURSOR_SCRIPTS, script), ...args], { + env: { ...process.env, ...env }, + stdio: ["pipe", "pipe", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (c) => (stderr += c)); + child.stdout.resume(); + child.stdin.end(JSON.stringify(payload)); + child.on("close", (code) => resolve({ code, stderr })); + }); +} + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +async function waitForCapture( + captures: Capture[], + predicate: (c: Capture) => boolean, + timeoutMs = 15000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const hit = captures.find(predicate); + if (hit) return hit; + await sleep(50); + } + return null; +} + +describe("cursor hook map", () => { + // The map is the only thing standing between a Cursor lifecycle event and + // the hook that handles it, and its values are bare strings. A typo here + // does not throw: the hook silently stops recording and nothing reports it. + it("every Cursor event maps to a canonical hook that exists", () => { + const missing = Object.entries(HOOK_MAP).filter( + ([, script]) => !existsSync(join(OFFICIAL_SCRIPTS, script)), + ); + expect(missing).toEqual([]); + }); + + it("covers the events hooks.cursor.json declares", () => { + const config = JSON.parse( + readFileSync(join(REPO_ROOT, "plugin", "hooks", "hooks.cursor.json"), "utf-8"), + ) as { hooks: Record }; + for (const event of Object.keys(config.hooks)) { + expect(isCursorHookKey(event), `hooks.cursor.json declares "${event}"`).toBe(true); + } + }); +}); + +describe("delegateHook", () => { + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "am-cursor-delegate-")); + // A stand-in for a canonical hook: records the environment and payload it + // was handed so the adapter's contract with it can be asserted. + writeFileSync( + join(dir, "session-start.mjs"), + [ + "let input = '';", + "for await (const chunk of process.stdin) input += chunk;", + "process.stdout.write(JSON.stringify({", + " project: process.env.AGENTMEMORY_PROJECT_NAME,", + " payload: JSON.parse(input || '{}'),", + "}));", + ].join("\n"), + ); + writeFileSync(join(dir, "stop.mjs"), "process.exit(3);"); + }); + + afterAll(() => rmSync(dir, { recursive: true, force: true })); + + it("passes the resolved project through the environment and cwd through the payload", () => { + const workspace = join(dir, "my-test-project"); + mkdirSync(workspace, { recursive: true }); + + const chunks: string[] = []; + const write = process.stdout.write.bind(process.stdout); + process.stdout.write = ((c: string) => { + chunks.push(String(c)); + return true; + }) as typeof process.stdout.write; + try { + delegateHook( + "sessionStart", + { session_id: uniqueId("delegate"), workspace_roots: [workspace] }, + { officialDir: dir }, + ); + } finally { + process.stdout.write = write; + } + + const seen = JSON.parse(chunks.join("")) as { + project: string; + payload: { cwd: string; session_id: string }; + }; + // resolveProject() in the canonical hooks reads AGENTMEMORY_PROJECT_NAME + // before anything else; that is the whole delegation mechanism. + expect(seen.project).toBe("my-test-project"); + expect(basename(seen.payload.cwd)).toBe("my-test-project"); + expect(seen.payload.session_id).toBeTruthy(); + }); + + it("propagates a non-zero exit from the canonical hook", () => { + expect(delegateHook("stop", { session_id: uniqueId("exit") }, { officialDir: dir })).toBe(3); + }); + + it("reports a hook that cannot be launched instead of returning success", () => { + const errors: string[] = []; + const original = console.error; + console.error = (...args: unknown[]) => errors.push(args.join(" ")); + let status: number; + try { + status = delegateHook( + "preToolUse", // pre-tool-use.mjs does not exist in the temp dir + { session_id: uniqueId("missing") }, + { officialDir: join(dir, "does-not-exist") }, + ); + } finally { + console.error = original; + } + // Fail open -- a memory hook must never block the editor -- but say so, + // rather than reporting the silent loss as success. The message has to be + // actionable: this is what a source checkout that never ran the build + // hits, and a Node module-not-found stack would not explain it. + expect(status).toBe(0); + expect(errors.join("\n")).toMatch(/canonical hook not found/); + expect(errors.join("\n")).toMatch(/npm run build/); + }); +}); + +describe("built shims against a local server", () => { + let server: Awaited>; + + beforeAll(async () => { + server = await startCaptureServer(); + }); + afterAll(() => server.close()); + + const hookEnv = (): Record => ({ + AGENTMEMORY_URL: server.url, + AGENTMEMORY_SECRET: "test-secret", + }); + + it("run-hook delivers sessionStart with the resolved project", async () => { + const workspace = mkdtempSync(join(tmpdir(), "am-cursor-ws-")); + const sessionId = uniqueId("shim-start"); + + const { code } = await runShim( + "run-hook.mjs", + ["sessionStart"], + { session_id: sessionId, workspace_roots: [workspace.replace(/\\/g, "/")] }, + hookEnv(), + ); + expect(code).toBe(0); + + const hit = await waitForCapture(server.captures, (c) => + c.url.includes("/agentmemory/session/start"), + ); + expect(hit, "no session/start request reached the server").not.toBeNull(); + const body = hit!.body as { sessionId: string; project: string; cwd: string }; + expect(body.sessionId).toBe(sessionId); + expect(body.project).toBe(basename(workspace)); + rmSync(workspace, { recursive: true, force: true }); + }, 30000); + + it("run-detached completes the work in its background worker", async () => { + const workspace = mkdtempSync(join(tmpdir(), "am-cursor-ws-")); + const sessionId = uniqueId("shim-stop"); + const before = server.captures.length; + + // The parent's exit says only that the worker was spawned. What matters + // is that the detached worker -- which outlives a closing Cursor window -- + // actually reaches the daemon. + const { code } = await runShim( + "run-detached.mjs", + ["stop"], + { session_id: sessionId, workspace_roots: [workspace.replace(/\\/g, "/")] }, + hookEnv(), + ); + expect(code).toBe(0); + + const hit = await waitForCapture( + server.captures, + (c, ) => + server.captures.indexOf(c) >= before && + typeof c.body === "object" && + c.body !== null && + (c.body as { sessionId?: string }).sessionId === sessionId, + 30000, + ); + expect(hit, "detached worker never reached the server").not.toBeNull(); + rmSync(workspace, { recursive: true, force: true }); + }, 45000); +}); diff --git a/test/cursor-workspace.test.ts b/test/cursor-workspace.test.ts new file mode 100644 index 000000000..0216af2b7 --- /dev/null +++ b/test/cursor-workspace.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { + isCursorMetadataPath, + normalizePathSlashes, + pathUnderHome, + resolveWorkspace +} from '../src/hooks/cursor/workspace.js'; + +// resolveWorkspace caches per session id, so every test needs a fresh one or +// it is served the previous run's answer instead of exercising its branch. +const sessionId = (label: string): string => + `${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + +// The last resort before "unknown" reads the environment. Tests that assert a +// payload was rejected have to blank it, or the developer's own shell (PWD) +// answers instead and the assertion passes for the wrong reason. +const ENV_KEYS = [ + 'CURSOR_WORKSPACE_ROOT', + 'CURSOR_WORKSPACE_FOLDER', + 'CURSOR_WORKSPACE_LABEL', + 'PWD', + 'VSCODE_CWD' +]; + +function withoutWorkspaceEnv(fn: () => T): T { + const saved = ENV_KEYS.map((k) => [k, process.env[k]] as const); + for (const k of ENV_KEYS) delete process.env[k]; + try { + return fn(); + } finally { + for (const [k, v] of saved) if (v !== undefined) process.env[k] = v; + } +} + +describe('cursor workspace resolver', () => { + it('isCursorMetadataPath rejects substring false positives', () => { + expect(isCursorMetadataPath('.cursor')).toBe(true); + expect(isCursorMetadataPath('/foo/.cursor/bar')).toBe(true); + expect(isCursorMetadataPath('C:\\Users\\me\\.cursor-backup')).toBe(false); + expect(isCursorMetadataPath('/home/user/.cursor-workspace-clone')).toBe(false); + }); + + it('treats ~/.cursor/worktrees as real checkouts, not metadata', () => { + // Cursor puts background-agent worktrees there. Classifying them as + // metadata sends those sessions to whatever the transcript scan guesses. + expect(isCursorMetadataPath('/home/me/.cursor/worktrees/myrepo-a1b2')).toBe(false); + expect(isCursorMetadataPath('/home/me/.cursor/extensions')).toBe(true); + }); + + it('pathUnderHome requires a path-component boundary after HOME', () => { + const home = normalizePathSlashes(process.env.HOME || process.env.USERPROFILE || '/home/alice'); + expect(pathUnderHome(home)).toBe(true); + expect(pathUnderHome(`${home}/projects/agentmemory`)).toBe(true); + expect(pathUnderHome(`${home}-backup`)).toBe(false); + }); + + it('uses workspace_roots when cwd is .cursor metadata', () => { + const dir = mkdtempSync(join(tmpdir(), 'am-ws-')); + try { + const resolved = resolveWorkspace({ + session_id: sessionId('metadata-cwd'), + workspace_roots: [normalizePathSlashes(dir)], + cwd: '.cursor' + }); + expect(resolved.project).toBe(basename(dir)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('resolves a tool_input file path to its directory, outside $HOME', () => { + // Doubles as the container case: on CI the temp dir is /tmp/... (POSIX + // absolute, not under $HOME), which a HOME-only rule silently rejected -- + // the same way it rejects a Codespaces checkout under /workspaces. + const dir = mkdtempSync(join(tmpdir(), 'am-ws-')); + writeFileSync(join(dir, 'package.json'), '{}'); + try { + const resolved = resolveWorkspace({ + session_id: sessionId('tool-input'), + tool_input: { path: normalizePathSlashes(join(dir, 'package.json')) } + }); + expect(resolved.project).toBe(basename(dir)); + expect(normalizePathSlashes(resolved.cwd)).toBe(normalizePathSlashes(dir)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('never reports an OS directory as the project', () => { + // A stray /usr/lib/... in tool_input must not produce a project "lib". + const resolved = withoutWorkspaceEnv(() => + resolveWorkspace({ + session_id: sessionId('system-path'), + tool_input: { path: '/usr/lib/node_modules/whatever.js' } + }) + ); + expect(resolved.project).toBe('unknown-project'); + }); + + it('never reports a tool metadata directory as the project', () => { + // Sessions were landing under ".codex"; only ".cursor" used to be blocked. + const home = process.env.HOME || process.env.USERPROFILE || tmpdir(); + const dotDir = join(home, `.am-test-dot-${Date.now()}`); + mkdirSync(dotDir, { recursive: true }); + try { + const resolved = withoutWorkspaceEnv(() => + resolveWorkspace({ session_id: sessionId('dot-dir'), cwd: normalizePathSlashes(dotDir) }) + ); + expect(resolved.project).toBe('unknown-project'); + } finally { + rmSync(dotDir, { recursive: true, force: true }); + } + }); + + it('keeps a dot-named directory that is version controlled', () => { + // ~/.dotfiles, ~/.emacs.d, ~/.config under chezmoi, and GitHub's own + // convention of a repository named ".github" are all real projects people + // open in an editor. What separates them from ~/.codex is not the leading + // dot, it is that a human deliberately version controls them. + const parent = mkdtempSync(join(tmpdir(), 'am-ws-')); + const repo = join(parent, '.dotfiles'); + mkdirSync(repo, { recursive: true }); + execFileSync('git', ['init', '-q'], { cwd: repo, stdio: 'ignore' }); + try { + const resolved = withoutWorkspaceEnv(() => + resolveWorkspace({ + session_id: sessionId('dot-repo'), + workspace_roots: [normalizePathSlashes(repo)] + }) + ); + expect(resolved.project).toBe('.dotfiles'); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + it('never reports the home directory as the project', () => { + // An agent launched with no workspace reports $HOME, which used to become + // a project named after the account -- 18 real sessions in one day filed + // under "Andrew", cwd C:\Users\Andrew. Unless $HOME is itself a + // repository, "no workspace" is the honest answer. + const home = process.env.HOME || process.env.USERPROFILE; + if (!home) return; + const resolved = withoutWorkspaceEnv(() => + resolveWorkspace({ session_id: sessionId('home-cwd'), cwd: normalizePathSlashes(home) }) + ); + expect(resolved.project).toBe('unknown-project'); + }); + + it('never reports a bare drive root as the project', () => { + const resolved = withoutWorkspaceEnv(() => + resolveWorkspace({ session_id: sessionId('drive-root'), cwd: 'C:/' }) + ); + expect(resolved.project).toBe('unknown-project'); + }); + + it('terminates on paths whose parent is itself', () => { + // Regression: the ancestor walk used dirname() with only a `!== "/"` + // guard, but dirname is a fixed point at every root -- dirname("//") is + // "//", dirname("C:") is "C:". Any URL-shaped path ("//host/x", which the + // transcript scan produces constantly) spun forever and hung the hook. + const started = Date.now(); + for (const cwd of ['//', '//host/share/project', 'C:', 'D:/', '/']) { + withoutWorkspaceEnv(() => resolveWorkspace({ session_id: sessionId('root-ish'), cwd })); + } + expect(Date.now() - started).toBeLessThan(10000); + }); + + it('ignores an IDE install directory', () => { + const resolved = withoutWorkspaceEnv(() => + resolveWorkspace({ + session_id: sessionId('ide-install'), + // VSCODE_CWD leaks this shape and used to yield the project "cursor". + cwd: 'C:/Users/me/AppData/Local/Programs/cursor' + }) + ); + expect(resolved.project).not.toBe('cursor'); + }); +}); diff --git a/tsdown.config.ts b/tsdown.config.ts index 5cf108468..eb27ce3e3 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -16,6 +16,14 @@ const hookEntries = [ "src/hooks/post-commit.ts", ]; +// Cursor gets a thin adapter rather than its own hook copies: two CLI +// entrypoints that resolve the workspace Cursor fails to report, then +// delegate to the canonical hooks above. workspace.ts/delegate.ts are not +// entries — they inline into each CLI, so the emitted files stay +// self-contained like every other hook, and the canonical .mjs they spawn +// sit exactly one directory up from plugin/scripts/cursor/. +const cursorEntries = ["src/hooks/cursor/run-hook.ts", "src/hooks/cursor/run-detached.ts"]; + const shared = { format: ["esm"] as const, target: "node20" as const, @@ -84,4 +92,11 @@ export default defineConfig([ clean: false, sourcemap: false, })), + ...cursorEntries.map((entry) => ({ + entry: [entry], + outDir: "plugin/scripts/cursor", + ...shared, + clean: false, + sourcemap: false, + })), ]);