From c8839e75c90e7abbbb205c31ade22adf26c1ca5c Mon Sep 17 00:00:00 2001 From: max Date: Mon, 3 Aug 2026 16:28:25 +0300 Subject: [PATCH] feat: add full Cursor support --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 4 +- .codex-plugin/plugin.json | 15 +- .cursor-plugin/marketplace.json | 16 + .cursor-plugin/plugin.json | 35 + .env.example | 4 + README.es-ES.md | 51 +- README.md | 83 +- agents/recall.md | 13 +- commands/recall.md | 6 +- docs/README.ru.md | 7 +- .../2026-06-26-recall-project-scope.md | 20 +- .../2026-08-03-cursor-durable-raw-source.md | 95 +++ hooks/hooks-cursor.json | 10 + mcp.json | 11 + pyproject.toml | 4 +- skills/session-recall/SKILL.md | 13 +- skills/setup/SKILL.md | 3 +- src/session_recall/cli.py | 43 +- src/session_recall/config.py | 5 +- src/session_recall/cursor.py | 710 ++++++++++++++---- src/session_recall/health.py | 33 +- src/session_recall/index.py | 13 +- src/session_recall/metadocs/indexing.py | 4 +- src/session_recall/onboarding.py | 8 + src/session_recall/retrieve.py | 16 +- src/session_recall/scope.py | 24 +- src/session_recall/server.py | 9 +- src/session_recall/store.py | 23 + src/session_recall/transcripts.py | 31 +- tests/test_cli.py | 31 + tests/test_cursor.py | 197 ++++- tests/test_health.py | 30 + tests/test_index.py | 18 + tests/test_metadocs.py | 4 + tests/test_onboarding.py | 9 + tests/test_plugin_manifests.py | 60 ++ tests/test_retrieve.py | 55 ++ tests/test_scope.py | 25 + tests/test_transcripts.py | 1 + 40 files changed, 1464 insertions(+), 279 deletions(-) create mode 100644 .cursor-plugin/marketplace.json create mode 100644 .cursor-plugin/plugin.json create mode 100644 docs/decisions/2026-08-03-cursor-durable-raw-source.md create mode 100644 hooks/hooks-cursor.json create mode 100644 mcp.json create mode 100644 tests/test_plugin_manifests.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b395789..919f179 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "name": "session-recall-marketplace", - "description": "Local shared-memory plugin for Claude Code and Codex sessions.", - "owner": { "name": "max" }, + "description": "Local shared-memory plugin for Claude Code, Codex, and Cursor sessions.", + "owner": { "name": "Max Butorin" }, "plugins": [ { "name": "session-recall", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index c116b09..e9814a0 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "session-recall", "version": "0.5.0", - "description": "Shared semantic recall over local Claude Code and Codex session history. Bundles MCP search tools, a deep-recall subagent, a trigger skill, an agent-driven setup skill (/session-recall:setup), and a background freshness hook.", - "author": { "name": "max" } + "description": "Shared semantic recall over local Claude Code, Codex, and Cursor history. Bundles MCP search tools, deep recall, guided setup, and a background freshness hook.", + "author": { "name": "Max Butorin" } } diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 668aefe..b95d58f 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,9 +1,9 @@ { "name": "session-recall", - "version": "0.3.0+codex.20260716100453", - "description": "Shared semantic recall over local Claude Code and Codex session history.", + "version": "0.5.0", + "description": "Shared semantic recall over local Claude Code, Codex, and Cursor history, with durable deep navigation and guided setup.", "author": { - "name": "max" + "name": "Max Butorin" }, "homepage": "https://github.com/AbsoluteMode/session-recall", "repository": "https://github.com/AbsoluteMode/session-recall", @@ -13,7 +13,8 @@ "recall", "sessions", "claude-code", - "codex" + "codex", + "cursor" ], "skills": "./skills/", "mcpServers": { @@ -27,9 +28,9 @@ }, "interface": { "displayName": "Session Recall", - "shortDescription": "Recall work from Claude Code and Codex.", - "longDescription": "Search one local semantic index of your Claude Code and Codex sessions, then drill into the original conversational trace.", - "developerName": "max", + "shortDescription": "Recall work from Claude Code, Codex, and Cursor.", + "longDescription": "Search one local semantic index of your Claude Code, Codex, and Cursor sessions, then drill into the original conversational trace.", + "developerName": "Max Butorin", "category": "Productivity", "capabilities": [ "Read" diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json new file mode 100644 index 0000000..b55b5dd --- /dev/null +++ b/.cursor-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "session-recall-marketplace", + "owner": { + "name": "Max Butorin" + }, + "metadata": { + "description": "Local-first shared memory for Claude Code, Codex, and Cursor." + }, + "plugins": [ + { + "name": "session-recall", + "source": ".", + "description": "Search and navigate one local semantic index of Claude Code, Codex, and Cursor history." + } + ] +} diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json new file mode 100644 index 0000000..c53dc77 --- /dev/null +++ b/.cursor-plugin/plugin.json @@ -0,0 +1,35 @@ +{ + "name": "session-recall", + "displayName": "Session Recall", + "version": "0.5.0", + "minClientVersions": { + "cursor": "2.5.0" + }, + "description": "Shared semantic recall over local Claude Code, Codex, and Cursor history, with durable deep navigation and guided setup.", + "author": { + "name": "Max Butorin" + }, + "homepage": "https://github.com/AbsoluteMode/session-recall", + "repository": "https://github.com/AbsoluteMode/session-recall", + "license": "MIT", + "keywords": [ + "memory", + "recall", + "sessions", + "claude-code", + "codex", + "cursor", + "mcp" + ], + "category": "developer-tools", + "tags": [ + "memory", + "semantic-search", + "local-first" + ], + "commands": "./commands/", + "agents": "./agents/", + "skills": "./skills/", + "hooks": "./hooks/hooks-cursor.json", + "mcpServers": "./mcp.json" +} diff --git a/.env.example b/.env.example index b6868d2..766ce03 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,7 @@ # Voyage embeddings (https://www.voyageai.com) VOYAGE_API_KEY= + +# Optional only for a portable/custom Cursor profile. The standard macOS/Linux +# database path is detected automatically. +SESSION_RECALL_CURSOR_DB= diff --git a/README.es-ES.md b/README.es-ES.md index ffd468d..6da2d86 100644 --- a/README.es-ES.md +++ b/README.es-ES.md @@ -6,14 +6,14 @@ [README en inglés](README.md), que es la referencia; versión en ruso: [docs/README.ru.md](docs/README.ru.md).* -**Memoria compartida para Claude Code y Codex.** Retoma el trabajo de hace un mes sin tener que reexplicarlo: y Claude puede leer lo que Codex resolvió ayer, porque ambos motores alimentan un mismo índice. No es un archivo de resumen que alguien mantiene a mano: son los turnos reales, incluidas las llamadas a herramientas y el razonamiento, buscables por significado. +**Memoria compartida para Claude Code, Codex y Cursor.** Retoma el trabajo de hace un mes sin tener que reexplicarlo: Claude puede leer lo que Codex o Cursor resolvieron ayer, porque los tres alimentan un mismo índice. No es un archivo de resumen que alguien mantiene a mano: son los turnos reales, incluidas las llamadas a herramientas y el razonamiento, buscables por significado. ```console $ session-recall index indexed 2175 chunks from changed transcripts -your history: 1052 sessions spanning 168 days, 40,035 searchable fragments - Claude Code 372 · Codex 680 +your history: 1053 sessions spanning 168 days, 40,037 searchable fragments + Claude Code 372 · Codex 680 · Cursor 1 busiest: sidekey, trend_detection, glitch ``` @@ -33,19 +33,19 @@ Cinco herramientas a través de MCP: Bajo demanda (sin autoinyección proactiva en v1). Local y de código abierto. -`recall_search`, `grep` y `recent_sessions` también aceptan un opcional `scope_cwd`: pasa tu directorio de trabajo actual para limitar los resultados al repo actual (los worktrees colapsan a la raíz del repo); omítelo para recordatorios entre proyectos. Los resultados clasificados incluyen una marca de tiempo legible por humanos `when_human` junto con la época raw. Cada herramienta MCP acepta un `source` opcional (`claude` o `codex`); omítelo para usar el historial unificado. Los resultados incluyen procedencia como `source=claude` o `source=codex`. Las tres herramientas de descubrimiento también aceptan `on_date` para un solo día o `start_date` / `end_date` inclusivos (`YYYY-MM-DD`) más un `timezone` IANA opcional, para que un agente pueda restringir la recuperación a un día calendario local real en lugar de esperar que una fecha escrita en la consulta semántica afecte la clasificación. Si se omite `timezone`, Session Recall usa la zona horaria de la computadora que ejecuta el servidor MCP. +`recall_search`, `grep` y `recent_sessions` también aceptan un opcional `scope_cwd`: pasa tu directorio de trabajo actual para limitar los resultados al repo actual (los worktrees colapsan a la raíz del repo); omítelo para recordatorios entre proyectos. Los resultados clasificados incluyen una marca de tiempo legible por humanos `when_human` junto con la época raw. Cada herramienta MCP acepta un `source` opcional (`claude`, `codex` o `cursor`); omítelo para usar el historial unificado. Los resultados incluyen la procedencia correspondiente. Las tres herramientas de descubrimiento también aceptan `on_date` para un solo día o `start_date` / `end_date` inclusivos (`YYYY-MM-DD`) más un `timezone` IANA opcional, para que un agente pueda restringir la recuperación a un día calendario local real en lugar de esperar que una fecha escrita en la consulta semántica afecte la clasificación. Si se omite `timezone`, Session Recall usa la zona horaria de la computadora que ejecuta el servidor MCP. **Estado:** v1, construido y validado con historial real. La clave del razonamiento de diseño está en [docs/decisions/](docs/decisions/). ## Cómo funciona -Las transcripciones de Claude Code y las sesiones de Codex desde `~/.codex/sessions` y `~/.codex/archived_sessions` comparten el mismo índice. +Las transcripciones de Claude Code, las sesiones de Codex desde `~/.codex/sessions` y `~/.codex/archived_sessions`, y las conversaciones de Cursor comparten el mismo índice. Cursor se lee desde su SQLite local y cada conversación se conserva como una instantánea JSONL normalizada dentro del directorio de datos de session-recall; por eso `expand_around`, `step` y `grep` siguen funcionando aunque Cursor esté cerrado o se desinstale. Solo se incrusta la "superficie" de la conversación: los prompts del usuario y las respuestas de texto del asistente. -Las llamadas a herramientas, resultados, razonamiento y otros datos de traza no se incrustan, pero permanecen accesibles bajo demanda mediante `expand_around` (y `step`) o `grep`. Los archivos de transcripción raw de Codex permanecen locales; solo la superficie de conversación extraída se envía al proveedor de incrustaciones configurado. +Las llamadas a herramientas, resultados, razonamiento y otros datos de traza no se incrustan, pero permanecen accesibles bajo demanda mediante `expand_around` (y `step`) o `grep`. Las transcripciones originales de Claude/Codex y las instantáneas raw normalizadas de Cursor permanecen locales; solo la superficie de conversación extraída se envía al proveedor de incrustaciones configurado. -Incrustaciones: Voyage `voyage-4-large` (dim 1024) → SQLite (`sqlite-vec` KNN + FTS5, clasificación bm25) → Voyage `rerank-2.5` → top-k. La indexación es incremental (por metadatos de archivo, incluidos inode+tamaño de Codex) y económica en transcripciones en vivo: son solo de agregación, por lo que los fragmentos sin cambios coinciden por hash de contenido y se reutilizan sus vectores: solo los nuevos turnos consultan la API de incrustaciones. Mover una versión de Codex al archivo también reutiliza sus vectores existentes. Cada archivo se indexa en su propia transacción; un archivo fallido se registra y reintenta en la siguiente ejecución, sin abortar el resto. Los subprocesos laterales de Claude (`/subagents/`) y las sesiones de subagentes generados por Codex se omiten intencionalmente: son herramientas internas, no la conversación principal usuario/agente. +La ruta sin configuración usa un modelo ONNX local elegido por idioma → SQLite (`sqlite-vec` KNN + FTS5, clasificación bm25) → top-k. Con una clave de Voyage se usa la ruta alojada de mayor calidad: `voyage-4-large` (dim 1024) → SQLite → `rerank-2.5`. La indexación es incremental y reutiliza los vectores de fragmentos sin cambios. Cada archivo o sesión se indexa en su propia transacción; un fallo se registra y se reintenta sin destruir los datos buenos anteriores. -Las incrustaciones son intercambiables (Voyage es el predeterminado); el reranker es opcional, y el sistema se degrada elegantemente a KNN + FTS sin él. Se detecta el cambio de proveedor/modelo de incrustación (una huella de incrustación forma parte de la firma de índice de cada archivo) y desencadena una reincrustación limpia en lugar de mezclar espacios vectoriales en silencio. +Las incrustaciones son intercambiables (el modelo local incluido es el predeterminado sin claves); el reranker es opcional, y el sistema se degrada elegantemente a KNN + FTS sin él. Se detecta el cambio de proveedor/modelo de incrustación y la búsqueda semántica se detiene hasta que todos los orígenes pertenezcan al mismo espacio vectorial. ## Instalación @@ -58,7 +58,7 @@ pipx install git+https://github.com/AbsoluteMode/session-recall session-recall index # first run walks your whole history; later runs are incremental ``` -Eso es todo si tienes un servidor de incrustaciones local en ejecución: consulta [Embedding providers](#embedding-providers) para la configuración local gratuita. Para incrustaciones alojadas de Voyage, exporta una clave primero: +Eso es todo: sin una clave ni un servidor local, se descarga una vez el modelo ONNX incluido y luego se ejecuta en tu máquina. Consulta [Proveedores de incrustaciones](#proveedores-de-incrustaciones) para las demás opciones. Para usar las incrustaciones alojadas de Voyage, exporta una clave primero: ```bash export VOYAGE_API_KEY=... # voyageai.com; put the line in your shell profile @@ -79,6 +79,15 @@ Luego inicia una nueva sesión: los servidores MCP y las habilidades se cargan a **Codex** — el manifiesto `.codex-plugin/plugin.json` está listo para colocar en un repo local o en tu marketplace personal; consulta la [local plugin installation guide](https://learn.chatgpt.com/docs/build-plugins#install-a-local-plugin-manually). Codex también te pedirá revisar los hooks recién instalados una vez mediante `/hooks`. +**Cursor** — el repositorio incluye un plugin nativo con MCP, skills, comandos, subagente de recall y un hook `sessionStart` en el formato de Cursor: + +```bash +cursor-agent plugin marketplace add https://github.com/AbsoluteMode/session-recall.git +``` + +Después ejecuta `/add-plugin session-recall` dentro de Cursor Agent. Para desarrollo local se puede iniciar con `cursor-agent --plugin-dir /ruta/absoluta/a/session-recall`. +Cursor muestra su aprobación habitual una sola vez para el servidor MCP stdio local; aprueba `session-recall` para iniciar las herramientas. + ### 3. Comprueba que funciona ```bash @@ -87,7 +96,9 @@ session-recall search "something you actually discussed last week" Los resultados con un `score` significan que la búsqueda semántica está activa. En el agente, `claude mcp list` debería mostrar `session-recall ✔ Connected`, y preguntarle sobre trabajo pasado debería activar `recall_search`. -No hay nada más que configurar: el hook `SessionStart` incluido vuelve a indexar en segundo plano a partir de entonces, por lo que el índice se mantiene actualizado con ambos hosts automáticamente. +No hay nada más que configurar: cada plugin nativo incluye el formato de hook de su host y vuelve a indexar en segundo plano, manteniendo actualizados los tres historiales. + +Cursor se detecta automáticamente en su ruta de datos normal de macOS/Linux y no necesita estar abierto. Para un perfil portátil o personalizado, usa `SESSION_RECALL_CURSOR_DB=/ruta/a/User/globalStorage/state.vscdb`. Session Recall abre la base en modo de solo lectura y obtiene una copia coherente mediante la API de backup de SQLite. ### Solución de problemas @@ -98,8 +109,9 @@ $ session-recall health [ok ] Freshness 2 minutes behind [warn] Embedder responded in 5828 ms → slow provider will make indexing crawl -[ok ] Corpus 1053 sessions (claude 373, codex 680) -[ok ] Sources claude, codex present +[ok ] Vector space builtin/BAAI/bge-small-en-v1.5/384 +[ok ] Corpus 1054 sessions (claude 373, codex 680, cursor 1) +[ok ] Sources claude, codex, cursor present verdict: AMBER (voyage/voyage-4-large, index at ~/.local/share/session-recall/index.db) ``` @@ -116,7 +128,7 @@ La frescura compara la transcripción más nueva en el disco con el turno más n ### Referencia de CLI ```bash -session-recall index --source claude|codex|all # defaults to all +session-recall index --source claude|codex|cursor|all # defaults to all session-recall search "query" --source codex session-recall recent --date 2026-07-14 # this computer's timezone session-recall search "deployment work" --start-date 2026-07-14 \ @@ -125,7 +137,7 @@ session-recall grep "exact" --limit 100 # raw scan, no API key needed session-recall prune # drop rows for deleted transcripts ``` -`search`, `recent`, `grep` y `prune` aceptan un opcional `--source claude|codex`; omítelo para buscar en ambos. Los filtros de fecha son inclusivos y se puede omitir cualquiera de los límites; la zona horaria predeterminada es la de esta computadora y acepta cualquier nombre IANA. `grep` se limita a 100 coincidencias por defecto. +`search`, `recent`, `grep` y `prune` aceptan un opcional `--source claude|codex|cursor`; omítelo para buscar en el historial unificado. Los filtros de fecha son inclusivos y se puede omitir cualquiera de los límites; la zona horaria predeterminada es la de esta computadora y acepta cualquier nombre IANA. `grep` se limita a 100 coincidencias por defecto. Para desarrollo, un virtualenv dentro del árbol también funciona: @@ -150,8 +162,11 @@ Nada está atado a un solo proveedor. `SESSION_RECALL_EMBED=` establece | `ollama` | **local, free** | `nomic-embed-text` | 768 | — | | `lmstudio` | **local, free** | `nomic-embed-text-v1.5` | 768 | — | | `openai` | hosted, needs a key | `text-embedding-3-large` | 1024 | — | +| `builtin-en` | **incluido, gratis** | `bge-small-en-v1.5` | 384 | — | +| `builtin-zh` | **incluido, gratis** | `bge-small-zh-v1.5` | 512 | — | +| `builtin-multi` | **incluido, gratis** | `paraphrase-multilingual-MiniLM-L12-v2` | 384 | — | -Sin ningún preset configurado, session-recall elige Voyage cuando `VOYAGE_API_KEY` está presente, y de lo contrario busca un servidor local que ya esté escuchando: mejor que predeterminar a un proveedor que está garantizado para rechazar la solicitud. Con una clave configurada, no se ejecuta la búsqueda. +Sin ningún preset configurado, session-recall elige Voyage cuando `VOYAGE_API_KEY` está presente, luego busca un servidor local que ya esté escuchando y, si no encuentra ninguno, usa el modelo ONNX incluido. Con una clave configurada, no se ejecuta la búsqueda local. **Gratuito y local, de principio a fin:** @@ -179,7 +194,7 @@ Sobre la elección del modelo: `nomic-embed-text` es el predeterminado porque es ## Mantener el índice actualizado -Si instalaste el plugin, esto ya está manejado: pasa a la siguiente sección. El hook `SessionStart` incluido funciona en ambos hosts y ejecuta `session-recall index` en segundo plano, y el `--source all` predeterminado actualiza ambos historiales. La indexación es incremental (omite archivos ya indexados por firma), por lo que mantenerse al día es económico. +Si instalaste el plugin, esto ya está manejado: pasa a la siguiente sección. El hook `SessionStart` incluido ejecuta `session-recall index` en segundo plano, y el `--source all` predeterminado actualiza Claude, Codex y Cursor. La indexación es incremental, por lo que mantenerse al día es económico. Solo si registraste el servidor MCP manualmente, añade el hook tú mismo en `~/.claude/settings.json`: @@ -221,5 +236,5 @@ Este es un repositorio público. **Solo entra código en él.** - Datos, índices, transcripciones raw, incrustaciones → `~/.local/share/session-recall/`, **fuera del árbol del repo**. Físicamente no pueden ser commitados. - Claves API → solo en el entorno (`VOYAGE_API_KEY`); `.gitignore` bloquea `.env`. - Pruebas → solo fixtures sintéticos, nunca una porción real de una sesión. -- Las transcripciones de Claude Code junto con las transcripciones activas y archivadas de Codex se leen localmente. Solo el texto superficial de usuario/asistente se incrusta; los datos de traza de herramientas/razonamiento se mantienen fuera de las incrustaciones y se exponen solo mediante expansión raw explícita o grep. -- Los textos de los fragmentos SE ENVÍAN a tu proveedor de incrustación/rerank configurado (Voyage por defecto) — elige un proveedor en el que confíes con tus transcripciones, o apunta el proveedor compatible con OpenAI a un punto de conexión local. +- Las transcripciones de Claude Code, las transcripciones activas y archivadas de Codex y el SQLite de Cursor se leen localmente. Las instantáneas normalizadas de Cursor permanecen en el directorio de datos. Solo el texto superficial de usuario/asistente se incrusta; las herramientas y el razonamiento quedan fuera de las incrustaciones. +- Los textos de los fragmentos se envían al proveedor configurado. Sin claves, el proveedor incluido permanece completamente local; si eliges Voyage u otro endpoint alojado, usa uno en el que confíes. diff --git a/README.md b/README.md index ef1a878..0be748b 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,16 @@ [README.es-ES.md](README.es-ES.md) (community translation)* **Shared memory for Claude Code, Codex, and Cursor.** Pick up work from a month ago without -re-explaining it — and Claude can read what Codex worked out yesterday, because both engines -feed one index. Not a summary file someone maintains by hand: the actual turns, including tool +re-explaining it — and Claude can read what Codex or Cursor worked out yesterday, because all +three feed one index. Not a summary file someone maintains by hand: the actual turns, including tool calls and reasoning, searchable by meaning. ```console $ session-recall index indexed 2175 chunks from changed transcripts -your history: 1052 sessions spanning 168 days, 40,035 searchable fragments - Claude Code 372 · Codex 680 +your history: 1053 sessions spanning 168 days, 40,037 searchable fragments + Claude Code 372 · Codex 680 · Cursor 1 busiest: sidekey, trend_detection, glitch ``` @@ -64,9 +64,9 @@ indexed today come from Claude Code, Codex, and Cursor. `recall_search`, `grep` and `recent_sessions` also take an optional `scope_cwd` — pass your current working directory to scope results to the current repo (worktrees collapse to the repo root); omit it for cross-project recall. Ranked hits carry a human-readable `when_human` -timestamp alongside the raw epoch. Every MCP tool accepts an optional `source` (`claude` or -`codex`); omit it to use the unified history. Results include provenance as `source=claude` or -`source=codex`. The three discovery tools also accept `on_date` for one day or inclusive +timestamp alongside the raw epoch. Every MCP tool accepts an optional `source` (`claude`, +`codex`, or `cursor`); omit it to use the unified history. Results include provenance such as +`source=claude`, `source=codex`, or `source=cursor`. The three discovery tools also accept `on_date` for one day or inclusive `start_date` / `end_date` (`YYYY-MM-DD`) plus an optional IANA `timezone`, so an agent can constrain retrieval to an actual local calendar day instead of hoping a date written into the semantic query affects ranking. If `timezone` is omitted, Session Recall uses the timezone of @@ -78,16 +78,20 @@ the computer running the MCP server. ## How it works Claude Code transcripts, Codex sessions from `~/.codex/sessions` plus -`~/.codex/archived_sessions`, and Cursor sessions (read via a snapshot of its -SQLite store, `User/globalStorage/state.vscdb`; subagent sessions skipped, -workspaces mapped to projects) share the same index. +`~/.codex/archived_sessions`, and Cursor sessions (read from its SQLite store, +`User/globalStorage/state.vscdb`; subagent sessions skipped, workspaces mapped +to projects) share the same index. Cursor bubbles are normalized into durable, +content-addressed JSONL snapshots under the session-recall data directory, so +raw navigation keeps working after Cursor closes, upgrades, or is uninstalled. Only the conversation "surface" is embedded — user prompts and assistant text replies. Tool calls, results, reasoning, and other trace data are not embedded but stay reachable on -demand via `expand_around` (and `step`) or `grep`. Raw Codex transcript files remain local; -only the extracted conversation surface is sent to the configured embedding provider. +demand via `expand_around` (and `step`) or `grep`. Original Claude/Codex transcripts and +Cursor's normalized raw snapshots remain local; only the extracted conversation surface is +sent to the configured embedding provider. -Embeddings: Voyage `voyage-4-large` (dim 1024) → SQLite -(`sqlite-vec` KNN + FTS5, bm25-ranked) → Voyage `rerank-2.5` → top-k. Indexing is +The zero-config path is a language-aware bundled ONNX model → SQLite +(`sqlite-vec` KNN + FTS5, bm25-ranked) → top-k. With a Voyage key the higher-quality +hosted path is `voyage-4-large` (dim 1024) → SQLite → `rerank-2.5`. Indexing is incremental (by file metadata, including Codex inode+size) and cheap on live transcripts: they are append-only, so unchanged chunks are matched by content hash and their vectors reused — only new turns hit the embedding API. Moving a Codex rollout into the archive also reuses its existing @@ -96,7 +100,7 @@ logged and retried on the next run, never aborting the rest. Claude sidechains (`/subagents/`) and Codex spawned-subagent sessions are intentionally skipped — they are under-the-hood tooling, not the primary user/agent conversation. -Embeddings are pluggable (Voyage is the default); the reranker is optional, and the +Embeddings are pluggable (the bundled local model is the no-key default); the reranker is optional, and the system degrades gracefully to KNN + FTS without one. Switching the embedding provider/model is detected (an embed fingerprint is part of each file's index signature) and triggers a clean re-embed instead of silently mixing vector spaces. @@ -153,6 +157,21 @@ personal marketplace; see the [local plugin installation guide](https://learn.chatgpt.com/docs/build-plugins#install-a-local-plugin-manually). Codex also asks you to review newly installed hooks once via `/hooks`. +**Cursor** — this repository also ships a native `.cursor-plugin/plugin.json`, Cursor-format +MCP and hook configs, and the same skills/commands/recall subagent. Add the repository as a +marketplace, then install the plugin in Cursor: + +```bash +cursor-agent plugin marketplace add https://github.com/AbsoluteMode/session-recall.git +``` + +Then type `/add-plugin session-recall` in Cursor Agent. This requires Cursor 2.5+ (plugins were +introduced there). The plugin exposes the five recall tools to Cursor and its `sessionStart` +hook refreshes Claude Code, Codex, and Cursor history together. For local development, launch +`cursor-agent --plugin-dir /absolute/path/to/session-recall` instead of installing a cached copy. +Cursor shows its normal one-time approval for the local stdio MCP server; approve +`session-recall` so the tools can start. + ### 3. Check it works ```bash @@ -162,8 +181,14 @@ session-recall search "something you actually discussed last week" Hits with a `score` mean semantic search is live. In the agent, `claude mcp list` should show `session-recall ✔ Connected`, and asking it about past work should trigger `recall_search`. -Nothing else to configure: the bundled `SessionStart` hook re-indexes in the background from -then on, so the index keeps up with both hosts on its own. +Nothing else to configure: each native plugin ships the host's startup-hook format and re-indexes +in the background, so the shared index keeps up with all three histories on its own. + +Cursor is auto-detected at its normal macOS/Linux data path and does not need to be running. +If you use a portable or custom Cursor profile, point directly at its database with +`SESSION_RECALL_CURSOR_DB=/path/to/User/globalStorage/state.vscdb`. Session Recall opens it +read-only and uses SQLite's backup API, so a live WAL database is read consistently without +blocking the editor. ### Troubleshooting @@ -175,8 +200,9 @@ $ session-recall health [ok ] Freshness 2 minutes behind [warn] Embedder responded in 5828 ms → slow provider will make indexing crawl -[ok ] Corpus 1053 sessions (claude 373, codex 680) -[ok ] Sources claude, codex present +[ok ] Vector space builtin/BAAI/bge-small-en-v1.5/384 +[ok ] Corpus 1054 sessions (claude 373, codex 680, cursor 1) +[ok ] Sources claude, codex, cursor present verdict: AMBER (voyage/voyage-4-large, index at ~/.local/share/session-recall/index.db) ``` @@ -206,7 +232,7 @@ session-recall prune # drop rows for deleted transcr ``` `search`, `recent`, `grep`, and `prune` all take an optional `--source claude|codex|cursor`; omit it to -search both. Date filters are inclusive and either boundary may be omitted; the timezone +search the unified history. Date filters are inclusive and either boundary may be omitted; the timezone defaults to this computer's and accepts any IANA name. `grep` caps at 100 matches by default. For development, an in-tree virtualenv works too: @@ -287,7 +313,7 @@ handles multilingual history far better than `nomic`. If you installed the plugin, this is already handled — skip to the next section. The bundled `SessionStart` hook works on both hosts and runs `session-recall index` in the background, and -the default `--source all` refreshes both histories. Indexing is incremental (it skips +the default `--source all` refreshes all three histories. Indexing is incremental (it skips already-indexed files by signature), so staying fresh is cheap. Only if you registered the MCP server by hand, add the hook yourself in @@ -479,9 +505,12 @@ This is a public repository. **Only code goes in it.** **outside the repo tree**. They physically cannot be committed. - API keys → environment only (`VOYAGE_API_KEY`); `.gitignore` blocks `.env`. - Tests → synthetic fixtures only, never a real slice of a session. -- Claude Code transcripts plus active and archived Codex transcripts are read locally. Only - user/assistant surface text is embedded; tool/reasoning trace data stays out of embeddings and - is exposed only by explicit raw expansion or grep. -- Chunk texts ARE sent to your configured embedding/rerank provider (Voyage by - default) — pick a provider you trust with your transcripts, or point the - OpenAI-compatible provider at a local endpoint. +- Claude Code transcripts, active and archived Codex transcripts, and Cursor's + SQLite store are read locally. Cursor's normalized raw snapshots stay under + the data directory. Only user/assistant surface text is embedded; + tool/reasoning trace data stays out of embeddings and is exposed only by + explicit raw expansion or grep. +- Chunk texts ARE sent to your configured embedding/rerank provider. With no + key or local server the bundled provider stays entirely on-device; if you + choose Voyage or another hosted endpoint, pick one you trust with your + transcript surface text. diff --git a/agents/recall.md b/agents/recall.md index 3c33955..59f6197 100644 --- a/agents/recall.md +++ b/agents/recall.md @@ -1,14 +1,13 @@ --- name: recall -description: Use to get grounded in a task, bug, feature, or decision from a PREVIOUS Claude Code or Codex session. Dispatch with the topic; it searches the unified history deeply (semantic + keyword + drill-down), reads the raw turns itself, and returns ONLY a tight brief — keeping the main thread's context clean. Prefer when you need the full arc of a past task, not just a snippet. -model: sonnet -tools: mcp__plugin_session-recall_session-recall__recall_search, mcp__plugin_session-recall_session-recall__expand_around, mcp__plugin_session-recall_session-recall__step, mcp__plugin_session-recall_session-recall__grep, mcp__plugin_session-recall_session-recall__recent_sessions, mcp__session-recall__recall_search, mcp__session-recall__expand_around, mcp__session-recall__step, mcp__session-recall__grep, mcp__session-recall__recent_sessions, Read +description: Use to get grounded in a task, bug, feature, or decision from a PREVIOUS Claude Code, Codex, or Cursor session. Dispatch with the topic; it searches the unified history deeply (semantic + keyword + drill-down), reads the raw turns itself, and returns ONLY a tight brief — keeping the main thread's context clean. Prefer when you need the full arc of a past task, not just a snippet. +model: inherit --- You are a session-history retrieval specialist. Given a task/topic, dig through the user's -past Claude Code and Codex sessions and return a tight, decision-focused brief — nothing else. +past Claude Code, Codex, and Cursor sessions and return a tight, decision-focused brief — nothing else. You burn YOUR context on the raw retrieval so the main thread stays clean. Results come from one -index and carry `source=claude|codex`; preserve that provenance in the brief. Primary user/agent +index and carry `source=claude|codex|cursor`; preserve that provenance in the brief. Primary user/agent sessions are indexed, while spawned subagent sidechains are intentionally skipped. ## How to search (be thorough — it is cheap for you) @@ -16,8 +15,8 @@ Ground the brief in these recall tools over the raw transcripts — that is the reconstruct from git logs or memory files when the recall tools can answer. 0. If the topic is "what's the latest / current state", `recent_sessions(scope_cwd=)` lists the freshest sessions first (turn counts + first-prompt labels) to orient before drilling in. - Pass `source="claude"` or `source="codex"` only when the request names a host; otherwise search - both sources, including active and archived Codex sessions. + Pass `source="claude"`, `source="codex"`, or `source="cursor"` only when the request names a + host; otherwise search all sources, including active and archived Codex sessions. If the dispatch names one day, pass `on_date`; for a period, pass inclusive `start_date` / `end_date`. The server uses the computer's local timezone by default; override it only when requested. Preserve the filter across `recent_sessions`, `recall_search`, and `grep`; never diff --git a/commands/recall.md b/commands/recall.md index cf41e3c..3f8c764 100644 --- a/commands/recall.md +++ b/commands/recall.md @@ -1,14 +1,14 @@ --- -description: Recall past Claude Code and Codex sessions about a topic and return a decision-focused brief. +description: Recall past Claude Code, Codex, and Cursor sessions about a topic and return a decision-focused brief. --- -Recall the topic: "$ARGUMENTS" from the unified Claude Code + Codex session index. +Recall the topic: "$ARGUMENTS" from the unified Claude Code + Codex + Cursor session index. If the host exposes the dedicated `recall` subagent, dispatch it (in Claude Code: `subagent_type: session-recall:recall`). Otherwise search iteratively with `recent_sessions`, `recall_search`, `expand_around`/`step`, and `grep`. -Use a requested `claude` or `codex` source filter; otherwise search both. Return only a tight brief: +Use a requested `claude`, `codex`, or `cursor` source filter; otherwise search all three. Return only a tight brief: task, key decisions and why, tried/rejected approaches, current state, and `source` + `session_id` + `uuid` pointers. diff --git a/docs/README.ru.md b/docs/README.ru.md index cb44258..7c934b9 100644 --- a/docs/README.ru.md +++ b/docs/README.ru.md @@ -72,12 +72,15 @@ ## Настройки -- **эмбеддинг-модель** — подключаемая: по умолчанию Voyage, можно любой - OpenAI-совместимый endpoint, в том числе локальный; +- **эмбеддинг-модель** — подключаемая: без ключей используется встроенная + локальная ONNX-модель; Voyage даёт более точное ранжирование, также можно + подключить любой OpenAI-совместимый endpoint; - **solo / team** — можно ли устанавливать контакт с вашим MCP: share выключен по умолчанию и включается явно; - **поисковый индекс — локальный** (SQLite на вашей машине); облачный командный индекс — в планах; +- **Cursor определяется автоматически** по стандартному локальному пути; + нестандартный профиль можно указать через `SESSION_RECALL_CURSOR_DB`; - **approve gate по контактам** — сейчас каждый исходящий ответ подтверждается вручную; bypass для доверенных контактов — в будущем. diff --git a/docs/decisions/2026-06-26-recall-project-scope.md b/docs/decisions/2026-06-26-recall-project-scope.md index cb8c000..5f7b345 100644 --- a/docs/decisions/2026-06-26-recall-project-scope.md +++ b/docs/decisions/2026-06-26-recall-project-scope.md @@ -20,8 +20,9 @@ the previous global search). The agent passes its raw `cwd`; the server normaliz to the repo root and filters the **existing** `cwd` column by a bounded prefix. No schema change and no reindexing. -- `scope.repo_root(cwd)` — strips the `/.claude/worktrees/` suffix → all - sessions of the repo (main + each worktree) collapse into one scope. +- `scope.repo_root(cwd)` — strips a nested `/.claude/worktrees/` or + `/.worktrees/` suffix → all sessions of the repo (main + each nested + worktree) collapse into one scope. - `scope.scope_clause(column, root)` — a shared predicate for KNN and FTS (so the logic doesn't drift apart): `cwd = root OR cwd LIKE root||'/%' ESCAPE '\'`. - KNN: vec0 cannot pre-filter on a joined column → over-fetch candidates @@ -73,3 +74,18 @@ schema change and no reindexing. --- Implementation: `scope.py` (`repo_root` + `scope_clause`) + wiring into store/retrieve/server; tests `tests/test_scope.py`. + +## Follow-up: generic nested worktrees (2026-07-28) + +The same normalization now covers the conventional +`/.worktrees/` layout. The marker remains segment-anchored: +lookalikes such as `.worktrees-cache` are not stripped, while a cwd below a +recognized worktree (`/src/...`) still collapses to the parent repo. +External worktree stores using other path names remain unchanged because their +path alone does not establish which checkout should own the scope. A global +store literally named `.worktrees` is ambiguous and follows the conventional +nested-layout rule. + +Regression coverage includes `repo_root`, `project_label`, SQL-backed scoped +recall (main checkout plus a sibling nested worktree), and streaming scoped +`grep`. diff --git a/docs/decisions/2026-08-03-cursor-durable-raw-source.md b/docs/decisions/2026-08-03-cursor-durable-raw-source.md new file mode 100644 index 0000000..4d51ef9 --- /dev/null +++ b/docs/decisions/2026-08-03-cursor-durable-raw-source.md @@ -0,0 +1,95 @@ +# Cursor as a durable raw recall source + +Date: 2026-08-03 + +## Context + +Cursor stores all workspaces in one private SQLite database rather than one +append-only transcript per session. The first adapter indexed visible bubble +text under virtual paths (`cursor:`). Semantic search and recent +sessions worked, but the normal recall workflow did not: `expand_around`, +`step`, and `grep` opened transcript files, while a virtual path has no file to +open. The private schema can also change independently of session-recall. + +## Decision + +On each Cursor index pass: + +1. Read one transactionally consistent SQLite backup (never copy a live db and + WAL sidecar separately). +2. Discover schema capabilities. Prefer `composerHeaders`; fall back to + `composerData:*` keys when the catalog table is absent. +3. Preserve every available bubble, including unknown/tool/reasoning shapes, + in a sanitized normalized JSONL session snapshot under + `/cursor-transcripts/`. +4. Name snapshots by hashes of the composer id and content. A changed session + gets a new file, so a failed DB transaction cannot make old chunk rows point + at new bytes. +5. Embed only non-empty user/assistant surface text. Raw tool/reasoning data is + available solely through explicit expand/step/grep. +6. Point chunks and `indexed_files` at the physical snapshot. After a + successful migration, delete the old `cursor:` row without re-embedding + unchanged surface text. + +Snapshots deliberately survive Cursor being closed or uninstalled. Deleting a +session from an available Cursor catalog removes its indexed rows and snapshot. +An absent database is treated as temporarily unavailable and preserves the last +good history. + +## Native Cursor host integration + +Cursor is both a history source and an MCP host. The repository therefore ships +its native plugin artifacts alongside the Claude and Codex manifests: + +- `.cursor-plugin/plugin.json` and `.cursor-plugin/marketplace.json`; +- Cursor's wrapped `mcp.json` shape; +- flat, lower-camel `hooks/hooks-cursor.json` with `version: 1` and a + `sessionStart` refresh; +- the shared skills, commands, and recall subagent. + +The Claude hook file is not reused: its `SessionStart`/nested-`hooks` wire shape +is a different protocol even though it launches the same incremental indexer. +Cross-host manifest tests lock component paths, version alignment, and the +native MCP/hook shapes. The Cursor manifest and marketplace also validate +against the schemas from the official `cursor/plugins` repository. + +## Embedding-space invariant + +Claude, Codex, Cursor, and meta docs share one vec0 table. The global +`meta.embed_fp` marker is therefore derived from **all** `indexed_files` +signatures after every producer pass. A source-selective or failed pass records +`mixed`; search disables KNN until every row agrees. `health` reports the same +condition, so it cannot be GREEN while semantic recall is intentionally off. + +## Failure boundary + +An incompatible or corrupt Cursor database makes the overall index command +non-zero and prints the exact source error, but it does not roll back already +committed Claude/Codex work. Unknown bubble fields are preserved generically +rather than silently disappearing. If a catalog row, conversation header, or +bubble body is no longer decodable, the Cursor pass fails closed before +reconciliation and preserves the last good snapshot for the next retry. + +## Rejected alternatives + +- **Keep virtual paths and query live SQLite during expansion.** Navigation + disappears when Cursor is closed, upgraded, or the session is deleted; it + also couples every MCP read to a private live database. +- **Store only surface chunks.** Exact grep and the reasoning/tool context that + makes session recall useful remain impossible. +- **Copy db + WAL files.** A checkpoint between copies can produce a mismatched + snapshot. SQLite's online backup API provides one coherent view. +- **Abort all indexing on a Cursor schema change.** Other sources are + independent and their successful transactions remain valuable. + +## Verification + +Synthetic databases cover the observed Cursor 3.14.7 tables, catalog-less +legacy discovery, subagent filtering, numeric and ISO timestamps, the installed +client's nested `ConversationMessage.ToolResult` envelope, durable +expand/step/raw grep, session deletion, embedding-model swaps, +schema-drift preservation, and migration from virtual v1 paths with vector +reuse. A read-only live smoke test against Cursor 3.14.7 additionally covers +the SQLite backup, current bubble envelope, semantic anchor, expansion, +stepping, raw grep, and unchanged-session incremental path without sending +conversation text to a hosted embedder. diff --git a/hooks/hooks-cursor.json b/hooks/hooks-cursor.json new file mode 100644 index 0000000..3483bda --- /dev/null +++ b/hooks/hooks-cursor.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "command": "export PATH=\"$HOME/.local/bin:$HOME/bin:$PATH\"; sr=session-recall; pgrep -f \"$sr index\" >/dev/null 2>&1 || { command -v \"$sr\" >/dev/null 2>&1 && (\"$sr\" index >/tmp/session-recall-index.log 2>&1 &) || echo 'session-recall: CLI not on PATH - index not refreshed (see README: Keeping the index fresh)' >>/tmp/session-recall-index.log; }" + } + ] + } +} diff --git a/mcp.json b/mcp.json new file mode 100644 index 0000000..f9294dc --- /dev/null +++ b/mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "session-recall": { + "command": "/bin/sh", + "args": [ + "-c", + "export PATH=\"$HOME/.local/bin:$HOME/bin:$PATH\"; exec session-recall-mcp" + ] + } + } +} diff --git a/pyproject.toml b/pyproject.toml index 3f1c99d..dd6a41e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "session-recall" -version = "0.4.0" -description = "Semantic recall over your local Claude Code and Codex session history — one shared index, searchable by meaning" +version = "0.5.0" +description = "Semantic recall over your local Claude Code, Codex, and Cursor history — one shared index, searchable by meaning" readme = "README.md" requires-python = ">=3.11" license = "MIT" diff --git a/skills/session-recall/SKILL.md b/skills/session-recall/SKILL.md index cf40645..abfb910 100644 --- a/skills/session-recall/SKILL.md +++ b/skills/session-recall/SKILL.md @@ -1,12 +1,12 @@ --- name: session-recall -description: Search the unified local history of past Claude Code and Codex sessions at the START of a task when the user references a prior bug, feature, decision, file, or piece of work. Use before assuming fresh context for prompts such as "remember when…", "we worked on…", "the X bug", "back to…", "what did we decide about…", or any task that plausibly has history. +description: Search the unified local history of past Claude Code, Codex, and Cursor sessions at the START of a task when the user references a prior bug, feature, decision, file, or piece of work. Use before assuming fresh context for prompts such as "remember when…", "we worked on…", "the X bug", "back to…", "what did we decide about…", or any task that plausibly has history. --- # Session Recall -Use the shared Claude Code + Codex index so the user does not have to re-explain prior work. -Treat each result's `source` (`claude` or `codex`) as provenance, not relevance. +Use the shared Claude Code + Codex + Cursor index so the user does not have to re-explain prior work. +Treat each result's `source` (`claude`, `codex`, or `cursor`) as provenance, not relevance. ## Recall workflow @@ -18,8 +18,8 @@ Treat each result's `source` (`claude` or `codex`) as provenance, not relevance. and `grep`; do not rely on putting the date into the semantic query. 3. Pass `scope_cwd` for repo-local questions. Omit it for cross-project recall; retry globally if a scoped search is thin. -4. Treat Claude Code and Codex as one history by default. Omit `source` so both are searched; - results retain `source="claude"|"codex"` as provenance. Filter by source only when the user +4. Treat all three hosts as one history by default. Omit `source` so every source is searched; + results retain `source="claude"|"codex"|"cursor"` as provenance. Filter by source only when the user explicitly names a host or provenance is material, and preserve that filter while drilling in. 5. For deeper grounding, inspect the best anchors with `expand_around`, walk with `step`, and use `grep` for exact identifiers that semantic search misses. @@ -34,7 +34,8 @@ Treat each result's `source` (`claude` or `codex`) as provenance, not relevance. working directory, and any requested source filter. In Claude Code this may be `session-recall:recall`. - If the host does not expose that subagent, perform the same deep search directly and - iteratively with the MCP tools. Never require a Claude-only subagent from Codex or another host. + iteratively with the MCP tools. Never require a Claude-only subagent from Codex, Cursor, + or another host. - Return a tight brief: task, key decisions and why, tried/rejected approaches, current state, and `source` + `session_id` + `uuid` pointers. diff --git a/skills/setup/SKILL.md b/skills/setup/SKILL.md index 67f7a45..192d900 100644 --- a/skills/setup/SKILL.md +++ b/skills/setup/SKILL.md @@ -18,7 +18,8 @@ pipx install git+https://github.com/AbsoluteMode/session-recall Plugin (gives the agent recall tools + auto-fresh index): in Claude Code run `/plugin marketplace add AbsoluteMode/session-recall` then -`/plugin install session-recall`; other MCP hosts register +`/plugin install session-recall`; in Cursor add this repository as a plugin +marketplace and run `/add-plugin session-recall`; other MCP hosts register `session-recall-mcp` directly. ## 1. Three questions (ask in chat, one message) diff --git a/src/session_recall/cli.py b/src/session_recall/cli.py index 09e85a4..14ac83f 100644 --- a/src/session_recall/cli.py +++ b/src/session_recall/cli.py @@ -25,7 +25,8 @@ def _date_range(args, parser: argparse.ArgumentParser) -> tuple[int | None, int parser.error(str(exc)) -_SOURCE_LABELS = {"claude": "Claude Code", "codex": "Codex"} +_SOURCE_LABELS = {"claude": "Claude Code", "codex": "Codex", "cursor": "Cursor", + "metadocs": "meta docs"} _ZONE_MARKS = {"GREEN": "ok ", "AMBER": "warn", "RED": "FAIL"} @@ -34,11 +35,26 @@ def _run_health(store: Store) -> int: timer or monitor can act on it without parsing the text.""" from .health import check_all - roots = {"claude": config.CLAUDE_PROJECTS, "codex": config.CODEX_SESSIONS} + roots = { + "claude": config.CLAUDE_PROJECTS, + "codex": config.CODEX_SESSIONS, + "cursor": config.CURSOR_DB, + } transcripts = [p for root in (config.CLAUDE_PROJECTS, config.CODEX_SESSIONS, config.CODEX_ARCHIVED_SESSIONS) if Path(root).is_dir() for p in Path(root).rglob("*.jsonl")] - report = check_all(store, make_embedder(), roots, transcripts) + source_timestamps: tuple[int | float, ...] = () + if Path(config.CURSOR_DB).is_file(): + from .cursor import CursorSchemaError, latest_activity + try: + source_timestamps = (latest_activity(config.CURSOR_DB),) + except CursorSchemaError: + # Sources already reports whether the DB exists. A schema problem + # is reported precisely by `index`; health must not crash and must + # not use the global DB mtime as a fake conversation timestamp. + pass + report = check_all( + store, make_embedder(), roots, transcripts, source_timestamps) width = max(len(d.name) for d in report.dimensions) for d in report.dimensions: @@ -53,7 +69,7 @@ def _run_health(store: Store) -> int: def _print_corpus_summary(store: Store) -> None: """Say what the user now has. A raw chunk count reads as noise right after install — the interesting facts are how far back the memory reaches and that - both engines feed it.""" + every configured source feeds it.""" s = corpus_summary(store) if not s["sessions"]: return @@ -112,6 +128,7 @@ def main(argv=None): return onboarding.run(args) store = Store(config.DB_PATH) + exit_code = 0 if args.cmd == "health": return _run_health(store) if args.cmd == "index": @@ -128,9 +145,20 @@ def main(argv=None): print(f"indexed {n} chunks from changed transcripts") if args.source in {"all", "cursor"}: from .cursor import index_cursor - c = index_cursor(store, embedder) - if c: - print(f"indexed {c} cursor session(s)") + try: + c = index_cursor(store, embedder) + except Exception as exc: + # Cursor's private SQLite schema can change independently of + # us. Claude/Codex commits from the preceding stage remain + # valid, but the overall command is honestly unsuccessful and + # leaves the global vector-space marker mixed. + import sys + print(f"session-recall: Cursor indexing failed; other sources " + f"were kept ({type(exc).__name__}: {exc})", file=sys.stderr) + exit_code = 1 + else: + if c: + print(f"indexed {c} cursor session(s)") # meta docs entries ride the same index (source="metadocs") whenever # the feature is configured; the SessionStart hook keeps them fresh if args.source == "all": @@ -169,6 +197,7 @@ def main(argv=None): elif args.cmd == "prune": print(f"pruned {store.prune_deleted(source=args.source)} deleted transcript(s)") store.close() + return exit_code if __name__ == "__main__": diff --git a/src/session_recall/config.py b/src/session_recall/config.py index 67f6153..b79aece 100644 --- a/src/session_recall/config.py +++ b/src/session_recall/config.py @@ -30,8 +30,9 @@ def _default_cursor_db() -> Path: os.environ.get("SESSION_RECALL_CURSOR_DB") or _default_cursor_db() ).expanduser() -# Embedding provider — PLUGGABLE. Voyage is the default (and the author's preference), -# but any provider works: set these env vars (e.g. provider=openai, +# Embedding provider — PLUGGABLE. A bundled ONNX model is the no-key default; +# Voyage remains the higher-quality hosted option. Any provider works: set the +# env vars below (e.g. provider=openai, # model=text-embedding-3-large, dim=1024). Adding a provider = one branch in # embed.make_embedder; the rest of the pipeline only sees the Embedder protocol. # NB: provider/model changes are detected via the embed fingerprint baked into diff --git a/src/session_recall/cursor.py b/src/session_recall/cursor.py index 0c91435..5f56db7 100644 --- a/src/session_recall/cursor.py +++ b/src/session_recall/cursor.py @@ -1,48 +1,65 @@ -"""Cursor (cursor.com) sessions as a third index source. - -Format captured from a LIVE Cursor 3.14.7 install (2026-08-03), not from -docs: one SQLite database `/User/globalStorage/state.vscdb` holds -every session across every workspace: - -- table `composerHeaders(composerId, workspaceId, createdAt, lastUpdatedAt, - isArchived, isSubagent, …)` — the session catalog; -- `cursorDiskKV` row `composerData:` — a JSON header whose - `fullConversationHeadersOnly` lists bubbles in order, each with an ISO - timestamp; -- `cursorDiskKV` rows `bubbleId::` — the messages: - `type` 1 = user, 2 = assistant. Thinking arrives as empty-text assistant - bubbles, so the surface rule stays the project invariant: non-empty text - only, tool noise never reaches the index. - -Unlike Claude/Codex there is no file per session, so incremental indexing -keys on virtual paths `cursor:` with `lastUpdatedAt` baked into -the signature, and reconciliation compares the catalog against -`indexed_files` instead of the filesystem (`prune_deleted` skips the -virtual prefix). The live database is snapshotted before reading: Cursor -keeps it open in WAL mode, and a copy is the one read that can never block -the editor or tear mid-transaction. - -Subagent sessions (`isSubagent=1`) are skipped like Claude sidechains and -Codex spawned agents. The workspace→folder mapping follows the VS Code -convention (`workspaceStorage//workspace.json`, a `folder` file URI); -sessions run without a folder ("empty-window") index with an empty project. +"""Cursor sessions as a durable third recall source. + +Cursor keeps every workspace's conversations in one SQLite database. The +database is an implementation detail and its schema has changed over time, so +the adapter follows two rules: + +1. inspect capabilities instead of assuming one exact set of columns; +2. turn each session into a normalized, content-addressed JSONL snapshot under + session-recall's data directory. + +The snapshot is what chunks point at. Consequently ``expand_around``, ``step`` +and raw ``grep`` use the same streaming machinery as Claude/Codex and continue +working when Cursor is closed, upgraded, or later uninstalled. Every bubble is +preserved in sanitized raw form, including currently-unknown tool/reasoning +shapes; only non-empty user/assistant surface text is embedded. + +The currently-observed schema (Cursor 3.14.7) uses ``composerHeaders`` as the +catalog and ``cursorDiskKV`` keys ``composerData:`` plus +``bubbleId::``. Tool results and thinking are nested in +assistant bubbles; empty capability/result fields also exist on normal visible +messages, so classification checks payloads rather than key presence. Older +installs without the catalog table fall back to scanning ``composerData:`` +keys. SQLite's online backup API makes a transactionally consistent read of a +live WAL database without copying a possibly-mismatched db/WAL pair. """ +from __future__ import annotations + import hashlib import json -import shutil +import os import sqlite3 import tempfile from dataclasses import dataclass, field from datetime import datetime from pathlib import Path +from typing import Any from urllib.parse import unquote, urlparse from .models import Chunk from .store import Store +from .transcripts import sanitize_raw + +SIG_TAG = "cursor-v2" +LEGACY_VPATH_PREFIX = "cursor:" +SNAPSHOT_DIRNAME = "cursor-transcripts" -SIG_TAG = "cursor-v1" -VPATH_PREFIX = "cursor:" # virtual indexed_files path, never on disk + +class CursorSchemaError(RuntimeError): + """Cursor exists, but its local store has no shape we can safely read.""" + + +@dataclass +class CursorEvent: + bubble_id: str + role: str + event_type: str + text: str + content: str + ts: int + raw_header: dict = field(default_factory=dict) + raw_bubble: dict = field(default_factory=dict) @dataclass @@ -51,84 +68,385 @@ class CursorSession: workspace_id: str name: str updated_ms: int - turns: list = field(default_factory=list) # {bubble_id, role, text, ts} + # Surface turns are the only records sent to an embedding provider. + turns: list[dict] = field(default_factory=list) + # Every bubble, including tool/reasoning/unknown records, reaches raw recall. + events: list[CursorEvent] = field(default_factory=list) -def _iso_to_epoch(iso: str, fallback_ms: int) -> int: +def _epoch(value: Any, fallback_ms: int = 0) -> int: + if isinstance(value, (int, float)): + number = float(value) + return int(number / 1000 if abs(number) >= 100_000_000_000 else number) + if isinstance(value, str) and value.strip(): + raw = value.strip() + try: + number = float(raw) + except ValueError: + try: + return int(datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp()) + except ValueError: + pass + else: + return int(number / 1000 if abs(number) >= 100_000_000_000 else number) + return int(fallback_ms // 1000) + + +def _millis(value: Any) -> int: + seconds = _epoch(value) + if not seconds: + return 0 + if isinstance(value, (int, float)) and abs(float(value)) >= 100_000_000_000: + return int(value) + if isinstance(value, str): + try: + number = float(value.strip()) + except ValueError: + pass + else: + if abs(number) >= 100_000_000_000: + return int(number) + return seconds * 1000 + + +def _json_dict(value: Any) -> dict | None: + if isinstance(value, dict): + return value + if isinstance(value, (str, bytes, bytearray)): + try: + decoded = json.loads(value) + except (ValueError, TypeError, UnicodeDecodeError): + return None + return decoded if isinstance(decoded, dict) else None + return None + + +def _snapshot_database(db_path: Path, tmp: Path) -> Path: + """Use SQLite backup for one consistent view of a live WAL database.""" + copy = tmp / "cursor-snapshot.db" + uri = db_path.resolve().as_uri() + "?mode=ro" + source = sqlite3.connect(uri, uri=True, timeout=2) + target = sqlite3.connect(copy) try: - return int(datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp()) - except (ValueError, TypeError): - return fallback_ms // 1000 - - -def _snapshot(db_path: Path, tmp: Path) -> Path: - """Copy db (+WAL sidecars when present) so the read never races Cursor.""" - copy = tmp / db_path.name - shutil.copy(db_path, copy) - for suffix in ("-wal", "-shm"): - side = db_path.with_name(db_path.name + suffix) - if side.exists(): - shutil.copy(side, tmp / side.name) + source.backup(target) + finally: + target.close() + source.close() return copy +def _tables(conn: sqlite3.Connection) -> set[str]: + return {str(row[0]) for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'").fetchall()} + + +def _columns(conn: sqlite3.Connection, table: str) -> set[str]: + return {str(row[1]) for row in conn.execute(f"PRAGMA table_info({table})").fetchall()} + + +def _truthy(value: Any) -> bool: + if isinstance(value, str): + return value.strip().casefold() not in {"", "0", "false", "no", "null"} + return bool(value) + + +def _catalog(conn: sqlite3.Connection) -> list[tuple[str, str, int, bool, dict | None]]: + """(id, workspace, updated_ms, is_subagent, preloaded composer data).""" + tables = _tables(conn) + if "cursorDiskKV" not in tables: + raise CursorSchemaError("Cursor database has no cursorDiskKV table") + + if "composerHeaders" in tables: + cols = _columns(conn, "composerHeaders") + if "composerId" in cols: + workspace = "workspaceId" if "workspaceId" in cols else "''" + if "lastUpdatedAt" in cols: + updated = "lastUpdatedAt" + elif "createdAt" in cols: + updated = "createdAt" + else: + updated = "0" + subagent = "isSubagent" if "isSubagent" in cols else "0" + rows = conn.execute( + f"SELECT composerId, {workspace}, {updated}, {subagent} " + "FROM composerHeaders" + ).fetchall() + return [ + (str(cid), str(ws or ""), _millis(changed), _truthy(sub), None) + for cid, ws, changed, sub in rows if cid + ] + + # Older Cursor builds had no separate catalog. composerData is enough to + # enumerate sessions; bubble bodies still live under bubbleId keys. + rows = conn.execute( + "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'" + ).fetchall() + catalog: list[tuple[str, str, int, bool, dict | None]] = [] + for key, value in rows: + data = _json_dict(value) + if data is None: + continue + cid = str(data.get("composerId") or str(key).split(":", 1)[-1]) + workspace = str(data.get("workspaceId") or data.get("workspace") or "") + changed = (data.get("lastUpdatedAt") or data.get("updatedAt") + or data.get("createdAt") or 0) + subagent = data.get("isSubagent") or data.get("isAgenticSubagent") + catalog.append((cid, workspace, _millis(changed), _truthy(subagent), data)) + if not catalog and rows: + raise CursorSchemaError("Cursor composerData rows are not valid JSON objects") + return catalog + + +def _text_parts(value: Any) -> list[str]: + if isinstance(value, str): + return [value] if value.strip() else [] + if isinstance(value, list): + out: list[str] = [] + for item in value: + out.extend(_text_parts(item)) + return out + if not isinstance(value, dict): + return [] + direct = value.get("text") + if isinstance(direct, str) and direct.strip(): + return [direct] + out: list[str] = [] + for key in ("content", "message", "summary"): + if key in value: + out.extend(_text_parts(value[key])) + return out + + +def _has_payload(value: Any) -> bool: + """Whether a Cursor envelope contains an actual event, not a default. + + Live bubbles carry fields such as ``supportedTools`` and ``toolResults`` on + every message. Looking only at key names therefore misclassifies ordinary + user/assistant text as a tool event. Payload presence, rather than schema + capability, is the important distinction. + """ + if value is None or value is False: + return False + if isinstance(value, (str, bytes, bytearray, list, tuple, dict, set)): + return bool(value) + return True + + +def _surface_text(bubble: dict, event_type: str) -> str: + """Visible conversation only — tool/reasoning payloads never embed.""" + if event_type in {"tool", "reasoning"}: + return "" + if "text" in bubble: + return "\n".join(_text_parts(bubble.get("text"))).strip() + # Some older stores used content/message for visible bubbles. Accept that + # only when no key suggests a tool/function envelope. + if any( + _has_payload(value) + and ("tool" in str(key).casefold() or "function" in str(key).casefold()) + and str(key).casefold() not in {"supportedtools", "availabletools"} + for key, value in bubble.items() + ): + return "" + return "\n".join(_text_parts( + bubble.get("content", bubble.get("message")) + )).strip() + + +def _role(kind: Any, bubble: dict, header: dict) -> str: + if kind == 1 or str(kind).casefold() in {"1", "user", "human"}: + return "user" + if kind == 2 or str(kind).casefold() in {"2", "assistant", "ai"}: + return "assistant" + named = str(bubble.get("role") or header.get("role") or "").casefold() + if named in {"user", "human"}: + return "user" + if named in {"assistant", "ai"}: + return "assistant" + if "tool" in named: + return "tool" + return "" + + +def _event_type(role: str, bubble: dict, header: dict) -> str: + for key in ("bubbleType", "kind", "eventType"): + value = bubble.get(key, header.get(key)) + if isinstance(value, str) and value: + return value + # Numeric ``type`` is the role in current Cursor stores (1=user, + # 2=assistant). A non-empty visible text wins even though all live bubbles + # also contain empty tool capability/result fields. + visible = "\n".join(_text_parts(bubble.get("text"))).strip() + if visible and role in {"user", "assistant"}: + return role + if any(_has_payload(bubble.get(key)) + for key in ("thinking", "reasoning", "thought", "analysis")): + return "reasoning" + if any( + _has_payload(value) + and ("tool" in str(key).casefold() or "function" in str(key).casefold()) + and str(key).casefold() not in {"supportedtools", "availabletools"} + for key, value in bubble.items() + ): + return "tool" + return role or "cursor_event" + + +def _event_content(text: str, event_type: str, bubble: dict) -> str: + if text: + return text + for key in ("thinking", "reasoning", "thought", "analysis"): + parts = _text_parts(bubble.get(key)) + if parts: + return f"[thinking] {' '.join(parts)}" + safe = sanitize_raw(bubble) + if isinstance(safe, dict) and set(safe) <= { + "_v", "type", "bubbleId", "id", "text", "createdAt", "timestamp"}: + # Cursor currently emits an empty assistant bubble as a thinking + # placeholder. Preserve it for exact raw grep, but do not make step() + # land on a record with no readable information. + return "" + try: + rendered = json.dumps(safe, ensure_ascii=False, sort_keys=True) + except (TypeError, ValueError): + rendered = str(safe) + # Raw remains complete in the snapshot; this is only the readable preview + # returned by expand/step for a shape we do not yet recognize. + return f"[{event_type}] {rendered[:1200]}" if rendered else "" + + +def _headers(data: dict) -> list[dict]: + field = next((key for key in ( + "fullConversationHeadersOnly", "conversation", "bubbles" + ) if key in data), None) + if field is None: + raise CursorSchemaError( + "Cursor composerData has no supported conversation header field") + value = data[field] + if isinstance(value, dict): + value = list(value.values()) + if not isinstance(value, list): + raise CursorSchemaError( + f"Cursor composerData field {field!r} is not an array/object") + if any(not isinstance(item, dict) for item in value): + raise CursorSchemaError( + f"Cursor composerData field {field!r} contains non-object headers") + return value + + +def _inline_bubble(header: dict) -> dict | None: + for key in ("bubble", "data"): + inline = _json_dict(header.get(key)) + if inline is not None: + return inline + # Legacy stores put the complete bubble directly in ``conversation``. + keys = {str(key).casefold() for key in header} + if keys & { + "text", "content", "message", "thinking", "reasoning", "thought", + "analysis", "toolresults", "toolresult", "toolcall", "toolcalls", + "toolname", "functioncall", "functionresult", + }: + return dict(header) + return None + + +def _sessions_from_connection(conn: sqlite3.Connection): + for composer_id, workspace_id, updated_ms, is_subagent, loaded in _catalog(conn): + if is_subagent: + continue + data = loaded + if data is None: + row = conn.execute( + "SELECT value FROM cursorDiskKV WHERE key = ?", + (f"composerData:{composer_id}",), + ).fetchone() + data = _json_dict(row[0]) if row and row[0] else None + if data is None: + raise CursorSchemaError( + "Cursor catalog references missing or non-JSON composerData") + sess = CursorSession( + composer_id=composer_id, + workspace_id=workspace_id or str(data.get("workspaceId") or ""), + name=str(data.get("name") or ""), + updated_ms=updated_ms or _millis( + data.get("lastUpdatedAt") or data.get("updatedAt") or 0), + ) + for index, header in enumerate(_headers(data)): + bubble_id = str(header.get("bubbleId") or header.get("id") or "") + if not bubble_id: + bubble_id = f"cursor:{composer_id}:{index}" + row = conn.execute( + "SELECT value FROM cursorDiskKV WHERE key = ?", + (f"bubbleId:{composer_id}:{bubble_id}",), + ).fetchone() + if row and row[0] is not None: + bubble = _json_dict(row[0]) + if bubble is None: + raise CursorSchemaError( + "Cursor bubble row is not a JSON object") + else: + bubble = _inline_bubble(header) + if bubble is None: + # A header-only bubble can be observed while Cursor is + # writing. Abort this source so reconciliation retains the + # prior complete snapshot for retry. + raise CursorSchemaError( + "Cursor conversation header has no readable bubble body") + kind = bubble.get("type", header.get("type")) + role = _role(kind, bubble, header) + event_type = _event_type(role, bubble, header) + text = _surface_text(bubble, event_type) + timestamp = (header.get("createdAt") or bubble.get("createdAt") + or header.get("timestamp") or bubble.get("timestamp")) + ts = _epoch(timestamp, sess.updated_ms) + event = CursorEvent( + bubble_id=bubble_id, role=role, event_type=event_type, + text=text, content=_event_content(text, event_type, bubble), ts=ts, + raw_header=dict(header), raw_bubble=dict(bubble), + ) + sess.events.append(event) + if text and role in {"user", "assistant"}: + sess.turns.append({ + "bubble_id": bubble_id, "role": role, + "text": text, "ts": ts, + }) + if sess.events: + yield sess + + +def _iter_sessions(db_path: Path): + """Stream one session at a time from a consistent read-only snapshot.""" + try: + with tempfile.TemporaryDirectory() as tmp: + snapshot = _snapshot_database(Path(db_path), Path(tmp)) + conn = sqlite3.connect(snapshot) + try: + yield from _sessions_from_connection(conn) + finally: + conn.close() + except (sqlite3.DatabaseError, OSError) as exc: + raise CursorSchemaError(f"cannot snapshot/read Cursor database: {exc}") from exc + + def read_sessions(db_path: Path) -> list[CursorSession]: - """The whole catalog, surface turns only, oldest bubble first.""" - with tempfile.TemporaryDirectory() as tmp: - conn = sqlite3.connect(_snapshot(db_path, Path(tmp))) - try: - headers = conn.execute( - "SELECT composerId, workspaceId, lastUpdatedAt, COALESCE(isSubagent, 0) " - "FROM composerHeaders").fetchall() - out: list[CursorSession] = [] - for composer_id, workspace_id, updated_ms, is_subagent in headers: - if is_subagent: - continue - raw = conn.execute( - "SELECT value FROM cursorDiskKV WHERE key = ?", - (f"composerData:{composer_id}",)).fetchone() - if not raw or not raw[0]: - continue - try: - data = json.loads(raw[0]) - except ValueError: - continue - sess = CursorSession( - composer_id=composer_id, workspace_id=workspace_id or "", - name=data.get("name") or "", updated_ms=int(updated_ms or 0)) - for h in data.get("fullConversationHeadersOnly") or []: - bubble_id = h.get("bubbleId") - if not bubble_id: - continue - brow = conn.execute( - "SELECT value FROM cursorDiskKV WHERE key = ?", - (f"bubbleId:{composer_id}:{bubble_id}",)).fetchone() - if not brow or not brow[0]: - continue - try: - bubble = json.loads(brow[0]) - except ValueError: - continue - text = (bubble.get("text") or "").strip() - btype = bubble.get("type") or h.get("type") - if not text or btype not in (1, 2): - continue # thinking/tool bubbles carry no surface text - sess.turns.append({ - "bubble_id": bubble_id, - "role": "user" if btype == 1 else "assistant", - "text": text, - "ts": _iso_to_epoch(h.get("createdAt", ""), sess.updated_ms), - }) - if sess.turns: - out.append(sess) - return out - finally: - conn.close() + """Materialize sessions for callers that need the compatibility API.""" + return list(_iter_sessions(Path(db_path))) + + +def latest_activity(db_path: Path) -> int: + """Newest conversation-event timestamp in a consistent Cursor snapshot. + + Health uses event time instead of the SQLite file mtime: Cursor also writes + unrelated settings to the global database, and treating those writes as new + conversation history would report a false indexing lag. + """ + newest = 0 + for session in _iter_sessions(Path(db_path)): + newest = max(newest, *(event.ts for event in session.events)) + return newest def workspace_folder(db_path: Path, workspace_id: str) -> tuple[str, str]: - """(project, cwd) via the VS Code workspace.json convention; sessions - without a folder — Cursor's "empty-window" — get an empty project.""" + """Resolve Cursor's VS Code-style workspace id to (project, cwd).""" if not workspace_id or workspace_id == "empty-window": return "", "" ws = db_path.parent.parent / "workspaceStorage" / workspace_id / "workspace.json" @@ -147,62 +465,164 @@ def _embed_fp() -> str: return config.embed_fingerprint() -def index_cursor(store: Store, embedder, db_path: Path | None = None) -> int: - """Index changed Cursor sessions; returns how many were (re)indexed. - A machine without Cursor is silent — absence is not an error.""" +def _snapshot_bytes(session: CursorSession, project: str, cwd: str) -> tuple[bytes, dict[str, tuple[int, int, int]]]: + payload = bytearray() + positions: dict[str, tuple[int, int, int]] = {} + for index, event in enumerate(session.events): + obj = { + "type": event.event_type, + "uuid": event.bubble_id, + "sessionId": session.composer_id, + "timestamp": event.ts, + "cwd": cwd, + "project": project, + "message": {"role": event.role, "content": event.content}, + "cursor": { + "sessionName": session.name, + "workspaceId": session.workspace_id, + "header": sanitize_raw(event.raw_header), + "bubble": sanitize_raw(event.raw_bubble), + }, + } + line = (json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + "\n").encode() + positions[event.bubble_id] = (len(payload), len(line), index) + payload.extend(line) + return bytes(payload), positions + + +def _snapshot_path(snapshot_dir: Path, composer_id: str, digest: str) -> Path: + opaque = hashlib.sha256(composer_id.encode()).hexdigest()[:24] + return snapshot_dir / f"{opaque}-{digest[:16]}.jsonl" + + +def _write_snapshot(path: Path, payload: bytes) -> bool: + """Atomic and idempotent; returns whether this call created the file.""" + if path.exists(): + return False + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp") + try: + with open(tmp, "wb") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, path) + finally: + tmp.unlink(missing_ok=True) + return True + + +def _prior_paths(store: Store, session_id: str) -> list[str]: + rows = store.db.execute( + "SELECT DISTINCT file_path FROM chunks WHERE source = 'cursor' AND session_id = ?", + (session_id,), + ).fetchall() + legacy = f"{LEGACY_VPATH_PREFIX}{session_id}" + paths = [str(row[0]) for row in rows] + if store.stored_sig(legacy) and legacy not in paths: + paths.append(legacy) + return paths + + +def index_cursor(store: Store, embedder, db_path: Path | None = None, + snapshot_dir: Path | None = None) -> int: + """Index Cursor and materialize navigable raw snapshots. + + Absence is silent and retains earlier snapshots. An incompatible/corrupt + database raises :class:`CursorSchemaError`; the CLI boundary reports that + source as skipped without undoing successful Claude/Codex work. + """ from . import config as app_config - db_path = db_path or app_config.CURSOR_DB - if not Path(db_path).exists(): + + db_path = Path(db_path or app_config.CURSOR_DB) + snapshot_dir = Path(snapshot_dir or (app_config.DATA_DIR / SNAPSHOT_DIRNAME)) + if not db_path.exists(): return 0 - sessions = read_sessions(Path(db_path)) + count = 0 - seen_vpaths = set() - for sess in sessions: - vpath = f"{VPATH_PREFIX}{sess.composer_id}" - seen_vpaths.add(vpath) - sig = f"{SIG_TAG}:{_embed_fp()}:{sess.updated_ms}:{len(sess.turns)}" - if store.is_indexed(vpath, sig): + seen_paths: set[str] = set() + # Stream from the SQLite backup so a large Cursor history does not keep + # every raw bubble in memory at once. + for sess in _iter_sessions(db_path): + project, cwd = workspace_folder(db_path, sess.workspace_id) + payload, positions = _snapshot_bytes(sess, project, cwd) + digest = hashlib.sha256(payload).hexdigest() + snapshot = _snapshot_path(snapshot_dir, sess.composer_id, digest) + snapshot_s = str(snapshot) + seen_paths.add(snapshot_s) + sig = f"{SIG_TAG}:{_embed_fp()}:{sess.updated_ms}:{len(sess.events)}:{digest}" + if store.is_indexed(snapshot_s, sig) and snapshot.exists(): continue - project, cwd = workspace_folder(Path(db_path), sess.workspace_id) - # vector reuse is only sound within one embedding space: an fp change - # invalidates the signature AND must invalidate the by-hash cache - old_sig = store.stored_sig(vpath) or "" - cached = (store.embeddings_by_hash(vpath) - if f":{_embed_fp()}:" in old_sig else {}) + + prior = _prior_paths(store, sess.composer_id) + cached: dict[str, bytes] = {} + for old_path in prior: + old_sig = store.stored_sig(old_path) or "" + if f":{_embed_fp()}:" in old_sig: + cached.update(store.embeddings_by_hash(old_path)) + + created = _write_snapshot(snapshot, payload) + old_files = [Path(path) for path in prior + if path != snapshot_s and not path.startswith(LEGACY_VPATH_PREFIX)] try: - chunks, vecs = [], [] - for i, t in enumerate(sess.turns): - chunk = Chunk( - session_id=sess.composer_id, uuid=t["bubble_id"], - role=t["role"], text=t["text"], project=project, cwd=cwd, - git_branch="", ts=t["ts"], file_path=vpath, - byte_offset=0, byte_len=len(t["text"].encode()), - turn_index=i, - content_hash=hashlib.sha256(t["text"].encode()).hexdigest(), - source="cursor") - chunks.append(chunk) - missing = [c.text for c in chunks if c.content_hash not in cached] - fresh_vecs = embedder.embed_documents(missing) if missing else [] - fresh = dict(zip((c.content_hash for c in chunks - if c.content_hash not in cached), fresh_vecs)) - store.delete_file(vpath) + chunks: list[Chunk] = [] + for turn in sess.turns: + offset, length, turn_index = positions[turn["bubble_id"]] + chunks.append(Chunk( + session_id=sess.composer_id, uuid=turn["bubble_id"], + role=turn["role"], text=turn["text"], project=project, cwd=cwd, + git_branch="", ts=turn["ts"], file_path=snapshot_s, + byte_offset=offset, byte_len=length, turn_index=turn_index, + content_hash=hashlib.sha256(turn["text"].encode()).hexdigest(), + source="cursor", + )) + + missing: dict[str, str] = {} for chunk in chunks: - vec = cached.get(chunk.content_hash) - store.add(chunk, vec if vec is not None else fresh[chunk.content_hash]) - store.mark_indexed(vpath, sig, source="cursor") + if chunk.content_hash not in cached: + missing.setdefault(chunk.content_hash, chunk.text) + texts = list(missing.values()) + vectors = embedder.embed_documents(texts) if texts else [] + if len(vectors) != len(texts): + raise RuntimeError( + f"embedder returned {len(vectors)} vectors for {len(texts)} texts") + fresh = dict(zip(missing, vectors)) + + # Replace all older snapshots for this composer in one DB + # transaction. The content-addressed files themselves are removed + # only after commit, so rollback always leaves navigable old rows. + for old_path in prior: + if old_path == snapshot_s: + continue + store.delete_file(old_path) + store.db.execute("DELETE FROM indexed_files WHERE path = ?", (old_path,)) + store.delete_file(snapshot_s) + for chunk in chunks: + vector = cached.get(chunk.content_hash) + store.add(chunk, vector if vector is not None else fresh[chunk.content_hash]) + store.mark_indexed(snapshot_s, sig, source="cursor") + store.refresh_embed_meta(_embed_fp()) store.commit() count += 1 except Exception: store.rollback() + if created: + snapshot.unlink(missing_ok=True) raise - # reconciliation replaces prune: the "files" live in Cursor's catalog, - # not on disk, so compare against what the catalog still contains - stale = [r[0] for r in store.db.execute( + for old_file in old_files: + old_file.unlink(missing_ok=True) + + # Sessions deleted inside Cursor fall out of the index and snapshot store. + stale = [str(row[0]) for row in store.db.execute( "SELECT path FROM indexed_files WHERE source = 'cursor'").fetchall() - if r[0] not in seen_vpaths] - for vpath in stale: - store.delete_file(vpath) - store.db.execute("DELETE FROM indexed_files WHERE path = ?", (vpath,)) - if stale: - store.commit() + if str(row[0]) not in seen_paths] + stale_files = [Path(path) for path in stale + if not path.startswith(LEGACY_VPATH_PREFIX)] + for path in stale: + store.delete_file(path) + store.db.execute("DELETE FROM indexed_files WHERE path = ?", (path,)) + store.refresh_embed_meta(_embed_fp()) + store.commit() + for path in stale_files: + path.unlink(missing_ok=True) return count diff --git a/src/session_recall/health.py b/src/session_recall/health.py index d543dea..64758f2 100644 --- a/src/session_recall/health.py +++ b/src/session_recall/health.py @@ -16,6 +16,7 @@ from dataclasses import dataclass from pathlib import Path +from . import config from .store import Store @@ -52,7 +53,8 @@ def _humanize_lag(seconds: float) -> str: return f"{seconds / 86400:.1f} days behind" -def check_freshness(store: Store, transcripts: list[Path]) -> Dimension: +def check_freshness(store: Store, transcripts: list[Path], + source_timestamps: tuple[int | float, ...] = ()) -> Dimension: """Gap between the newest transcript on disk and the newest turn in the index. Deliberately not "when did the index last change": an indexer that runs every @@ -61,6 +63,7 @@ def check_freshness(store: Store, transcripts: list[Path]) -> Dimension: """ newest_indexed = store.db.execute("SELECT MAX(ts) FROM chunks").fetchone()[0] or 0 on_disk = [p.stat().st_mtime for p in transcripts if p.exists()] + on_disk.extend(float(ts) for ts in source_timestamps if ts > 0) if not on_disk: return Dimension("Freshness", "AMBER", "no transcripts found on disk", "check the source paths below") @@ -92,7 +95,8 @@ def check_paths(roots: dict[str, Path]) -> Dimension: return Dimension("Sources", "GREEN", ", ".join(sorted(roots)) + " present") zone = "RED" if len(missing) == len(roots) else "AMBER" return Dimension("Sources", zone, f"missing: {', '.join(sorted(missing))}", - "set CODEX_HOME / SESSION_RECALL_CLAUDE_PROJECTS if these live elsewhere") + "set CODEX_HOME / SESSION_RECALL_CLAUDE_PROJECTS / " + "SESSION_RECALL_CURSOR_DB if these live elsewhere") def check_embedder(embedder) -> Dimension: @@ -113,6 +117,25 @@ def check_embedder(embedder) -> Dimension: "" if zone == "GREEN" else "slow provider will make indexing crawl") +def check_embed_space(store: Store) -> Dimension: + """The provider can be healthy while the index belongs to another model.""" + current = config.embed_fingerprint() + stored = store.get_meta("embed_fp") + if stored == current: + return Dimension("Vector space", "GREEN", current) + if stored == "mixed": + return Dimension( + "Vector space", "RED", "index contains mixed embedding spaces", + "run `session-recall index` for all sources before searching") + if stored: + return Dimension( + "Vector space", "RED", f"index: {stored}; configured: {current}", + "run `session-recall index` to re-embed with the configured model") + return Dimension( + "Vector space", "AMBER", "legacy index has no embedding-space marker", + "run `session-recall index` once to attest every source") + + @dataclass(frozen=True) class Report: dimensions: list[Dimension] @@ -120,13 +143,15 @@ class Report: def check_all(store: Store, embedder, roots: dict[str, Path], - transcripts: list[Path]) -> Report: + transcripts: list[Path], + source_timestamps: tuple[int | float, ...] = ()) -> Report: """Every dimension plus one verdict. The verdict is the worst zone present: a single dead dimension makes recall untrustworthy, and averaging would hide it behind everything that still works.""" dims = [ - check_freshness(store, transcripts), + check_freshness(store, transcripts, source_timestamps), check_embedder(embedder), + check_embed_space(store), check_corpus(store), check_paths(roots), ] diff --git a/src/session_recall/index.py b/src/session_recall/index.py index 6e24de7..bf9b5af 100644 --- a/src/session_recall/index.py +++ b/src/session_recall/index.py @@ -183,10 +183,11 @@ def index_corpus(store: Store, embedder: Embedder, projects_dir: Path | None, if failed: print(f"session-recall: {len(failed)} file(s) failed to index (will retry " f"next run):\n " + "\n ".join(failed[:10]), file=sys.stderr) - else: - # The index-wide space marker moves only after a CLEAN pass: a failed - # file keeps its rolled-back old-space vectors, and search must keep - # treating the corpus as mixed until a full run heals it. - store.set_meta("embed_fp", _embed_fp()) - store.commit() + # This is a GLOBAL attestation, not a verdict about only the sources this + # call selected. Cursor and meta docs share the same vector table and may + # be refreshed by a later stage (or not selected at all), so derive the + # marker from every indexed_files signature. A failed/partial pass remains + # explicitly mixed and semantic search stays off until all producers heal. + store.refresh_embed_meta(_embed_fp()) + store.commit() return new_count diff --git a/src/session_recall/metadocs/indexing.py b/src/session_recall/metadocs/indexing.py index b4d8f23..41e117d 100644 --- a/src/session_recall/metadocs/indexing.py +++ b/src/session_recall/metadocs/indexing.py @@ -25,7 +25,7 @@ def _embed_fp() -> str: from .. import config - return f"{config.EMBED_PROVIDER}/{config.EMBED_MODEL}/{config.EMBED_DIM}" + return config.embed_fingerprint() def _entry_files(repo: Path): @@ -84,4 +84,6 @@ def index_metadocs(store: Store, embedder, repo: Path) -> int: store.rollback() raise store.prune_deleted(source="metadocs") + store.refresh_embed_meta(_embed_fp()) + store.commit() return count diff --git a/src/session_recall/onboarding.py b/src/session_recall/onboarding.py index e93e6d1..ccb4ff1 100644 --- a/src/session_recall/onboarding.py +++ b/src/session_recall/onboarding.py @@ -58,6 +58,14 @@ def _transcript_footprint() -> tuple[int, int]: size += p.stat().st_size except OSError: pass + cursor_db = Path(config.CURSOR_DB) + if cursor_db.is_file(): + files += 1 + for path in (cursor_db, cursor_db.with_name(cursor_db.name + "-wal")): + try: + size += path.stat().st_size + except OSError: + pass return files, size diff --git a/src/session_recall/retrieve.py b/src/session_recall/retrieve.py index 45f9e1a..8ddbe66 100644 --- a/src/session_recall/retrieve.py +++ b/src/session_recall/retrieve.py @@ -78,10 +78,16 @@ def recall_search(self, query: str, k: int = 10, candidates: int = 100, # A same-dim model swap passes the schema check but puts the query # in a different vector space than the corpus — matches would be # silent noise. Refuse the mix: words still work, `index` heals it. - degraded = (f"embedder changed: the index was built with {stored_fp}, " - f"the current config is {config.embed_fingerprint()} — " - "semantic ranking is off until `session-recall index` " - "re-embeds") + if stored_fp == "mixed": + degraded = ( + "mixed embedding spaces: at least one index source still " + "uses another model — semantic ranking is off until " + "`session-recall index` finishes cleanly for every source") + else: + degraded = (f"embedder changed: the index was built with {stored_fp}, " + f"the current config is {config.embed_fingerprint()} — " + "semantic ranking is off until `session-recall index` " + "re-embeds") else: try: qv = self.embedder.embed_query(query) @@ -157,7 +163,7 @@ def _files_for(self, uuid: str, session_id: str | None, self._validate_source(source) files: list[str] = [] exact_files: set[str] = set() - for hinted_source in ((source,) if source else ("claude", "codex")): + for hinted_source in ((source,) if source else ("claude", "codex", "cursor")): hinted = self._anchor_files.get((hinted_source, session_id or "", uuid)) if hinted and hinted not in files: files.append(hinted) diff --git a/src/session_recall/scope.py b/src/session_recall/scope.py index 7c89629..70354f1 100644 --- a/src/session_recall/scope.py +++ b/src/session_recall/scope.py @@ -4,27 +4,31 @@ raw `cwd` and we normalize it here to a repo root, then filter the existing `cwd` column by a boundary-safe prefix. No schema change, no reindex. -Worktrees nest UNDER the repo root (`/.claude/worktrees/`), so -stripping that suffix collapses the main checkout and every worktree to ONE -scope — `project`-name derivation can't (it yields a junk hash per worktree). +Worktrees nest UNDER the repo root (`/.claude/worktrees/` or +`/.worktrees/`), so stripping that suffix collapses the main +checkout and every worktree to ONE scope — `project`-name derivation can't +(it yields a junk hash per worktree). # WHY: docs/decisions/2026-06-26-recall-project-scope.md """ import os import re -# Trailing `/.claude/worktrees/` (optionally slash-terminated). Segment- -# anchored on `$` so it only strips a real worktree suffix, never mid-path. -_WORKTREE_SUFFIX = re.compile(r"/\.claude/worktrees/[^/]+/?$") +# A nested worktree root plus an optional cwd below it. Segment-anchored so a +# lookalike such as `.worktrees-cache` never matches; the tail is intentional — +# agents are often launched from `/src`, not the checkout root. +_WORKTREE_SUFFIX = re.compile( + r"/(?:\.claude/worktrees|\.worktrees)/[^/]+(?:/.*)?$" +) def repo_root(cwd: str) -> str: """Normalize a cwd to its parent repository root. - Strips a trailing Claude-Code worktree segment so all of a repo's sessions - (main + every worktree) share one scope; otherwise returns the path with any - trailing slash removed. Pure string op — works on historical/deleted paths - where a `git` call would fail. + Strips a nested worktree segment and any cwd below it so all of a repo's + sessions (main + every worktree) share one scope; otherwise returns the path + with any trailing slash removed. Pure string op — works on + historical/deleted paths where a `git` call would fail. """ if not cwd: return cwd diff --git a/src/session_recall/server.py b/src/session_recall/server.py index d8393e0..dc0db1e 100644 --- a/src/session_recall/server.py +++ b/src/session_recall/server.py @@ -40,7 +40,7 @@ def recall_search(query: str, k: int = 10, scope_cwd: str | None = None, source: str | None = None, start_date: str | None = None, end_date: str | None = None, timezone: str | None = None, on_date: str | None = None) -> dict: - """Semantically search past Claude Code and Codex sessions. + """Semantically search past Claude Code, Codex and Cursor sessions. Returns {"anchors": [...ranked anchors...], "degraded": null | str}. @@ -53,7 +53,8 @@ def recall_search(query: str, k: int = 10, scope_cwd: str | None = None, scope_cwd: pass your current working directory to restrict results to the current project/repo (worktrees collapse to the repo root). Omit it for a global, cross-project search. - source: optionally restrict to "claude" or "codex"; omit for the shared index. + source: optionally restrict to "claude", "codex", or "cursor"; omit for the + shared index. start_date/end_date: inclusive local calendar dates (YYYY-MM-DD). Either may be omitted for an open-ended range. on_date: shorthand for one local calendar day; cannot be combined with a range. @@ -93,7 +94,7 @@ def grep(pattern: str, session_id: str | None = None, scope_cwd: str | None = No scope_cwd: pass your current working directory to restrict the scan to the current project/repo; omit for a global scan. - source: optionally restrict to "claude" or "codex". + source: optionally restrict to "claude", "codex", or "cursor". limit: maximum number of matches returned (default 100). start_date/end_date: inclusive local calendar dates (YYYY-MM-DD). on_date: shorthand for one local calendar day; cannot be combined with a range. @@ -119,7 +120,7 @@ def recent_sessions(scope_cwd: str | None = None, limit: int = 10, scope_cwd: pass your current working directory to restrict to the current project/repo (worktrees collapse to the repo root); omit for all projects. - source: optionally restrict to "claude" or "codex". + source: optionally restrict to "claude", "codex", or "cursor". start_date/end_date: inclusive local calendar dates (YYYY-MM-DD). on_date: shorthand for one local calendar day; cannot be combined with a range. timezone: IANA timezone override; omit it to use the user's computer timezone. diff --git a/src/session_recall/store.py b/src/session_recall/store.py index e4bfe0a..12ebe26 100644 --- a/src/session_recall/store.py +++ b/src/session_recall/store.py @@ -295,6 +295,29 @@ def set_meta(self, key: str, value: str) -> None: "INSERT INTO meta(key, value) VALUES (?, ?) " "ON CONFLICT(key) DO UPDATE SET value = excluded.value", (key, value)) + def refresh_embed_meta(self, fingerprint: str) -> bool: + """Attest the embedding space only when EVERY indexed unit agrees. + + The database is shared by Claude, Codex, Cursor and meta docs. A + source-selective refresh must therefore never move the global marker + merely because the selected source finished cleanly: untouched rows + may still contain vectors from the previous model. Signatures for all + current producers contain ``::``; anything else is + conservatively treated as mixed until its producer refreshes it. + + Returns True when the whole index is homogeneous. The write is not + committed here so callers can keep their normal transaction boundary. + """ + needle = f":{fingerprint}:" + mixed = self.db.execute( + "SELECT 1 FROM indexed_files " + "WHERE sig IS NULL OR instr(sig, ?) = 0 LIMIT 1", + (needle,), + ).fetchone() + value = fingerprint if mixed is None else "mixed" + self.set_meta("embed_fp", value) + return mixed is None + def mark_indexed(self, path: str, sig: str, source: str = "claude"): # Not committed here — joins the caller's per-file transaction, so the # "indexed" marker can never outlive a rolled-back set of chunks. diff --git a/src/session_recall/transcripts.py b/src/session_recall/transcripts.py index ae20ae0..b87d6a2 100644 --- a/src/session_recall/transcripts.py +++ b/src/session_recall/transcripts.py @@ -1,4 +1,4 @@ -"""Streaming adapters for Claude Code and Codex JSONL transcripts. +"""Streaming adapters for Claude Code, Codex and normalized Cursor JSONL. The envelopes are unrelated, so indexing and retrieval consume normalized events from here. Files are always streamed: Codex rollouts can be hundreds of @@ -203,7 +203,7 @@ def discover_codex_transcripts(sessions_dir: Path, archived_dir: Path) -> list[T def extractor_version(source: str) -> str: - versions = {"claude": "2", "codex": "1"} + versions = {"claude": "2", "codex": "1", "cursor": "2"} try: return versions[source] except KeyError as exc: @@ -381,6 +381,26 @@ def iter_transcript_events(path: str | Path, source: str | None = None) -> Itera ) continue + if source == "cursor": + message = obj.get("message") or {} + message = message if isinstance(message, dict) else {} + content = message.get("content") + if not isinstance(content, str): + content = _json_preview(content, 1200) if content is not None else "" + timestamp_value = obj.get("timestamp") + timestamp = str(timestamp_value or "") + sid = str(obj.get("sessionId") or fallback_sid) + yield TranscriptEvent( + source=source, obj=obj, session_id=sid, + uuid=str(obj.get("uuid") or f"cursor:{sid}:{byte_offset}"), + cwd=str(obj.get("cwd") or ""), git_branch="", + ts=parse_ts(timestamp_value), timestamp=timestamp, + role=str(message.get("role") or ""), + type=str(obj.get("type") or "cursor_event"), content=content, + byte_offset=byte_offset, byte_len=byte_len, turn_index=turn_index, + ) + continue + payload = obj.get("payload") or {} payload = payload if isinstance(payload, dict) else {} if obj.get("type") == "session_meta": @@ -413,7 +433,8 @@ def sanitize_raw(value: Any) -> Any: """Recursively remove opaque reasoning ciphertext/signatures from output.""" if isinstance(value, dict): return {key: sanitize_raw(item) for key, item in value.items() - if key not in {"encrypted_content", "signature"}} + if str(key).casefold() not in { + "encrypted_content", "encryptedcontent", "signature"}} if isinstance(value, list): return [sanitize_raw(item) for item in value] return value @@ -448,6 +469,8 @@ def read_transcript(path: str, source: str) -> list[dict[str, Any]]: """Compatibility materializer; production retrieval uses the streaming iterator.""" if source == "claude": return [event.obj for event in iter_transcript_events(path, source="claude")] + if source == "cursor": + return [event.obj for event in iter_transcript_events(path, source="cursor")] if source != "codex": raise ValueError(f"unknown transcript source: {source!r}") @@ -549,6 +572,8 @@ def is_navigable(event: TranscriptEvent) -> bool: # are skipped so navigation lands on readable messages/tools/reasoning. if event.source == "claude": return True + if event.source == "cursor": + return bool(event.content) payload = event.obj.get("payload") or {} if (event.obj.get("type") == "response_item" and isinstance(payload, dict) and payload.get("type") == "message"): diff --git a/tests/test_cli.py b/tests/test_cli.py index c62be61..f0dc8c9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,6 +12,7 @@ def test_cli_index_then_search(tmp_path, monkeypatch, capsys): monkeypatch.setattr(config, "CLAUDE_PROJECTS", tmp_path / "projects") monkeypatch.setattr(config, "CODEX_SESSIONS", tmp_path / "no-codex-sessions") monkeypatch.setattr(config, "CODEX_ARCHIVED_SESSIONS", tmp_path / "no-codex-archive") + monkeypatch.setattr(config, "CURSOR_DB", tmp_path / "no-cursor.db") monkeypatch.setattr(config, "DB_PATH", tmp_path / "cli.db") monkeypatch.setattr(config, "DATA_DIR", tmp_path / "data") # keep the live metadocs config out of the test monkeypatch.setattr(cli, "make_embedder", lambda: FakeEmbedder()) @@ -31,6 +32,7 @@ def test_cli_recent_grep_prune(tmp_path, monkeypatch, capsys): monkeypatch.setattr(config, "CLAUDE_PROJECTS", tmp_path / "projects") monkeypatch.setattr(config, "CODEX_SESSIONS", tmp_path / "no-codex-sessions") monkeypatch.setattr(config, "CODEX_ARCHIVED_SESSIONS", tmp_path / "no-codex-archive") + monkeypatch.setattr(config, "CURSOR_DB", tmp_path / "no-cursor.db") monkeypatch.setattr(config, "DB_PATH", tmp_path / "cli.db") monkeypatch.setattr(config, "DATA_DIR", tmp_path / "data") # keep the live metadocs config out of the test monkeypatch.setattr(cli, "make_embedder", lambda: FakeEmbedder()) @@ -62,6 +64,35 @@ def test_cli_recent_grep_prune(tmp_path, monkeypatch, capsys): assert "pruned 0" in out +def test_cli_cursor_schema_failure_keeps_other_sources(tmp_path, monkeypatch, capsys): + """Cursor is a private schema. A breaking upgrade reports failure while + Claude/Codex commits from the same run remain available.""" + proj = tmp_path / "projects" / "-Users-me-proj" + proj.mkdir(parents=True) + shutil.copy("tests/fixtures/session_a.jsonl", proj / "session_a.jsonl") + cursor_db = tmp_path / "cursor.vscdb" + import sqlite3 + sqlite3.connect(cursor_db).close() # incompatible: no cursorDiskKV + + monkeypatch.setattr(config, "CLAUDE_PROJECTS", tmp_path / "projects") + monkeypatch.setattr(config, "CODEX_SESSIONS", tmp_path / "no-codex-sessions") + monkeypatch.setattr(config, "CODEX_ARCHIVED_SESSIONS", tmp_path / "no-codex-archive") + monkeypatch.setattr(config, "CURSOR_DB", cursor_db) + monkeypatch.setattr(config, "DB_PATH", tmp_path / "cli.db") + monkeypatch.setattr(config, "DATA_DIR", tmp_path / "data") + monkeypatch.setattr(cli, "make_embedder", lambda: FakeEmbedder()) + + assert cli.main(["index"]) == 1 + captured = capsys.readouterr() + assert "Cursor indexing failed" in captured.err + + from session_recall.store import Store + store = Store(config.DB_PATH) + assert store.db.execute( + "SELECT COUNT(*) FROM chunks WHERE source='claude'").fetchone()[0] > 0 + store.close() + + def test_cli_module_entrypoint_runs_main(): # Regression: `python -m session_recall.cli` must invoke main(), not no-op. # A missing __main__ guard once made `index` silently do nothing (no DB). diff --git a/tests/test_cursor.py b/tests/test_cursor.py index de1ab20..71542ca 100644 --- a/tests/test_cursor.py +++ b/tests/test_cursor.py @@ -4,13 +4,17 @@ extractor must survive exactly that shape, and reconciliation must track Cursor's catalog rather than the filesystem.""" +import hashlib import json import sqlite3 from pathlib import Path +import pytest + from session_recall import config -from session_recall.cursor import index_cursor, read_sessions +from session_recall.cursor import CursorSchemaError, index_cursor, read_sessions from session_recall.embed import FakeEmbedder +from session_recall.retrieve import Recall from session_recall.store import Store @@ -33,13 +37,21 @@ def _make_db(root: Path, sessions) -> Path: conn.execute("INSERT INTO composerHeaders VALUES (?,?,?,?,0,?,0,0,'{}')", (cid, ws, updated - 1000, updated, sub)) headers = [] - for i, (btype, text, iso) in enumerate(turns): + for i, turn in enumerate(turns): + btype, text, iso, *extra = turn bid = f"b{i}" headers.append({"bubbleId": bid, "type": btype, "createdAt": iso}) + # Current Cursor writes capability/result fields on every bubble, + # even when no tool was invoked. They must not hide visible text. + bubble = { + "_v": 3, "type": btype, "bubbleId": bid, "text": text, + "supportedTools": [], "toolResults": [], + } + if extra: + bubble.update(extra[0]) conn.execute("INSERT INTO cursorDiskKV VALUES (?,?)", (f"bubbleId:{cid}:{bid}", - json.dumps({"_v": 3, "type": btype, "bubbleId": bid, - "text": text}))) + json.dumps(bubble))) conn.execute("INSERT INTO cursorDiskKV VALUES (?,?)", (f"composerData:{cid}", json.dumps({"_v": 1, "composerId": cid, "name": f"chat {cid}", @@ -51,7 +63,12 @@ def _make_db(root: Path, sessions) -> Path: _TURNS = [ (1, "почему падает деплой по пятницам?", "2026-08-03T10:00:00.000Z"), - (2, "", "2026-08-03T10:00:01.000Z"), # thinking bubble + (2, "", "2026-08-03T10:00:01.000Z", { + "thinking": { + "text": "Проверяю расписание крона.", + "signature": "cursor-thinking-signature-must-not-escape", + }, + }), (2, "Крон собирал кэш в полночь UTC — по пятницам он пересекался с релизом.", "2026-08-03T10:00:02.000Z"), ] @@ -70,6 +87,9 @@ def test_read_sessions_surface_only(tmp_path): "the empty thinking bubble must not reach the surface" assert s.turns[0]["text"].startswith("почему падает") assert s.turns[0]["ts"] == 1785751200 # 2026-08-03T10:00:00Z + assert len(s.events) == 3, "raw recall keeps the empty thinking bubble too" + assert [event.event_type for event in s.events] == [ + "user", "reasoning", "assistant"] def test_index_cursor_end_to_end_with_workspace_mapping(tmp_path): @@ -80,20 +100,26 @@ def test_index_cursor_end_to_end_with_workspace_mapping(tmp_path): json.dumps({"folder": "file:///Users/me/deploy-service"})) store = Store(tmp_path / "i.db") - n = index_cursor(store, FakeEmbedder(), db_path=db) + snapshots = tmp_path / "snapshots" + n = index_cursor(store, FakeEmbedder(), db_path=db, snapshot_dir=snapshots) assert n == 1 rows = store.db.execute( "SELECT role, project, cwd, source FROM chunks ORDER BY turn_index").fetchall() assert rows == [("user", "deploy-service", "/Users/me/deploy-service", "cursor"), ("assistant", "deploy-service", "/Users/me/deploy-service", "cursor")] + raw = next(snapshots.glob("*.jsonl")).read_text() + assert "Проверяю расписание крона" in raw + assert "cursor-thinking-signature-must-not-escape" not in raw # unchanged catalog → nothing re-indexed - assert index_cursor(store, FakeEmbedder(), db_path=db) == 0 + assert index_cursor( + store, FakeEmbedder(), db_path=db, snapshot_dir=snapshots) == 0 # an appended bubble bumps lastUpdatedAt → exactly that session re-indexes _make_db(tmp_path, [("comp-1", "ws-1", 1_700_000_999_000, 0, _TURNS + [(1, "а по субботам?", "2026-08-03T11:00:00.000Z")])]) - assert index_cursor(store, FakeEmbedder(), db_path=db) == 1 + assert index_cursor( + store, FakeEmbedder(), db_path=db, snapshot_dir=snapshots) == 1 assert store.db.execute("SELECT count(*) FROM chunks").fetchone()[0] == 3 store.close() @@ -104,23 +130,28 @@ def test_reconciliation_follows_the_catalog_not_the_disk(tmp_path): ("comp-2", "empty-window", 1_700_000_000_000, 0, _TURNS), ]) store = Store(tmp_path / "i.db") - assert index_cursor(store, FakeEmbedder(), db_path=db) == 2 + snapshots = tmp_path / "snapshots" + assert index_cursor( + store, FakeEmbedder(), db_path=db, snapshot_dir=snapshots) == 2 # generic prune must NOT touch virtual cursor paths… assert store.prune_deleted() == 0 # …and a session deleted inside Cursor falls out via reconciliation _make_db(tmp_path, [("comp-1", "empty-window", 1_700_000_000_000, 0, _TURNS)]) - index_cursor(store, FakeEmbedder(), db_path=db) + index_cursor(store, FakeEmbedder(), db_path=db, snapshot_dir=snapshots) left = {r[0] for r in store.db.execute( "SELECT DISTINCT session_id FROM chunks WHERE source='cursor'")} assert left == {"comp-1"} + assert len(list(snapshots.glob("*.jsonl"))) == 1, \ + "the deleted Cursor session's durable snapshot must be reconciled too" store.close() def test_missing_cursor_install_is_silent(tmp_path): store = Store(tmp_path / "i.db") assert index_cursor(store, FakeEmbedder(), - db_path=tmp_path / "nope" / "state.vscdb") == 0 + db_path=tmp_path / "nope" / "state.vscdb", + snapshot_dir=tmp_path / "snapshots") == 0 store.close() @@ -128,12 +159,152 @@ def test_embedder_swap_invalidates_the_reuse_cache(tmp_path, monkeypatch): db = _make_db(tmp_path, [("comp-1", "empty-window", 1_700_000_000_000, 0, _TURNS)]) store = Store(tmp_path / "i.db") emb = FakeEmbedder() - index_cursor(store, emb, db_path=db) + snapshots = tmp_path / "snapshots" + index_cursor(store, emb, db_path=db, snapshot_dir=snapshots) first_calls = emb.doc_calls # same texts, new fingerprint: the by-hash cache must NOT be reused monkeypatch.setattr(config, "EMBED_MODEL", "swapped-model") - index_cursor(store, emb, db_path=db) + index_cursor(store, emb, db_path=db, snapshot_dir=snapshots) assert emb.doc_calls > first_calls, \ "old-space vectors must be re-embedded, never reused across spaces" store.close() + + +def test_cursor_deep_recall_expand_step_and_raw_grep(tmp_path): + """Cursor is a first-class source: a semantic anchor can be expanded and + stepped through, while grep reaches a non-surface tool bubble.""" + turns = [ + (1, "добавь функцию сложения", "2026-08-03T10:00:00Z"), + (2, "", "2026-08-03T10:00:01Z", { + # Shape from Cursor 3.14.7's installed ConversationMessage.ToolResult + # schema: tool actions/results are nested in the assistant bubble. + "toolResults": [{ + "toolCallId": "call-1", + "toolName": "write_file", + "args": '{"path":"calculator.py"}', + "content": "def add(a,b): return a+b", + "startedAtMs": 1_785_751_201_000, + "completedAtMs": 1_785_751_201_100, + }], + }), + (2, "Готово, тесты проходят.", "2026-08-03T10:00:02Z"), + ] + db = _make_db(tmp_path, [("comp-tools", "empty-window", 1_700_000_000_000, 0, + turns)]) + store = Store(tmp_path / "i.db") + emb = FakeEmbedder() + snapshots = tmp_path / "snapshots" + index_cursor(store, emb, db_path=db, snapshot_dir=snapshots) + recall = Recall(store, emb) + + anchor = recall.recall_search( + "добавь функцию сложения", source="cursor", k=5)[0] + window = recall.expand_around( + anchor.session_id, anchor.uuid, before=0, after=2, source="cursor") + assert [turn.type for turn in window] == ["user", "tool", "assistant"] + assert "calculator.py" in window[1].content + assert recall.step( + anchor.session_id, anchor.uuid, "next", source="cursor")[0].type == "tool" + + exact = recall.grep("calculator.py", source="cursor") + assert len(exact) == 1 and exact[0].session_id == "comp-tools" + assert recall.expand_around( + exact[0].session_id, exact[0].uuid, source="cursor") + store.close() + + +def test_cursor_catalog_falls_back_to_composer_data_keys(tmp_path): + """Older stores without composerHeaders remain readable via inline data.""" + gs = tmp_path / "User" / "globalStorage" + gs.mkdir(parents=True) + db = gs / "state.vscdb" + conn = sqlite3.connect(db) + conn.execute("CREATE TABLE cursorDiskKV(key TEXT PRIMARY KEY, value BLOB)") + data = { + "composerId": "legacy-1", "workspaceId": "empty-window", + "updatedAt": 1_785_751_200_000, + "conversation": [ + {"bubbleId": "old-u", "type": 1, "createdAt": 1_785_751_200_000, + "text": "legacy question"}, + {"bubbleId": "old-a", "type": 2, "createdAt": 1_785_751_201_000, + "text": "legacy answer"}, + ], + } + conn.execute("INSERT INTO cursorDiskKV VALUES (?, ?)", + ("composerData:legacy-1", json.dumps(data))) + conn.commit() + conn.close() + + sessions = read_sessions(db) + assert [session.composer_id for session in sessions] == ["legacy-1"] + assert [turn["text"] for turn in sessions[0].turns] == [ + "legacy question", "legacy answer"] + + +def test_cursor_unknown_schema_is_explicit(tmp_path): + db = tmp_path / "state.vscdb" + sqlite3.connect(db).close() + with pytest.raises(CursorSchemaError, match="cursorDiskKV"): + read_sessions(db) + + +def test_cursor_schema_drift_preserves_last_good_snapshot(tmp_path): + """Unsupported private-schema changes must fail closed: never reconcile a + merely unreadable catalog as though the user deleted every conversation.""" + db = _make_db(tmp_path, [("comp-1", "empty-window", 1_700_000_000_000, 0, + _TURNS)]) + store = Store(tmp_path / "i.db") + snapshots = tmp_path / "snapshots" + index_cursor(store, FakeEmbedder(), db_path=db, snapshot_dir=snapshots) + before_rows = store.db.execute( + "SELECT session_id, uuid, file_path FROM chunks ORDER BY turn_index" + ).fetchall() + before_files = {path: path.read_bytes() for path in snapshots.glob("*.jsonl")} + + conn = sqlite3.connect(db) + conn.execute( + "UPDATE cursorDiskKV SET value=? WHERE key='composerData:comp-1'", + (json.dumps({"composerId": "comp-1", "messagesV99": []}),), + ) + conn.commit() + conn.close() + + with pytest.raises(CursorSchemaError, match="conversation header"): + index_cursor(store, FakeEmbedder(), db_path=db, snapshot_dir=snapshots) + assert store.db.execute( + "SELECT session_id, uuid, file_path FROM chunks ORDER BY turn_index" + ).fetchall() == before_rows + assert {path: path.read_bytes() for path in snapshots.glob("*.jsonl")} == before_files + store.close() + + +def test_cursor_migrates_legacy_virtual_rows_without_reembedding(tmp_path): + db = _make_db(tmp_path, [("comp-1", "empty-window", 1_700_000_000_000, 0, + _TURNS)]) + store = Store(tmp_path / "i.db") + emb = FakeEmbedder() + # Reproduce the v1 virtual-path representation. + from session_recall.models import Chunk + text = _TURNS[0][1] + legacy = Chunk( + session_id="comp-1", uuid="b0", role="user", text=text, + project="", cwd="", git_branch="", ts=1, + file_path="cursor:comp-1", byte_offset=0, byte_len=len(text), + turn_index=0, content_hash=hashlib.sha256(text.encode()).hexdigest(), + source="cursor") + store.add(legacy, emb.embed_query(text)) + store.mark_indexed( + "cursor:comp-1", + f"cursor-v1:{config.embed_fingerprint()}:1700000000000:2", + source="cursor") + store.commit() + calls = emb.doc_calls + + index_cursor(store, emb, db_path=db, snapshot_dir=tmp_path / "snapshots") + assert store.stored_sig("cursor:comp-1") is None + assert all(not row[0].startswith("cursor:") for row in store.db.execute( + "SELECT path FROM indexed_files WHERE source='cursor'")) + assert emb.doc_calls == calls + 1, \ + "only the previously absent assistant vector should be embedded" + store.close() diff --git a/tests/test_health.py b/tests/test_health.py index b5f3f65..5aefe5a 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -51,6 +51,20 @@ def test_freshness_is_green_when_the_index_has_caught_up(tmp_path): store.close() +def test_freshness_accepts_cursor_activity_without_jsonl_transcripts(tmp_path): + """A Cursor-only install has no Claude/Codex JSONL tree to inspect.""" + store = Store(tmp_path / "cursor-only.db") + now = int(time.time()) + store.add(_chunk("u1", now, source="cursor"), [0.0] * 1024) + store.db.commit() + + dim = check_freshness(store, [], (now,)) + + assert dim.zone == "GREEN" + assert dim.detail == "up to date" + store.close() + + def test_corpus_counts_sessions_per_engine(tmp_path): """A total hides the failure worth catching: one source silently stopping.""" store = Store(tmp_path / "c.db") @@ -95,6 +109,22 @@ def test_embedder_check_is_green_when_it_answers(): assert check_embedder(FakeEmbedder()).zone == "GREEN" +def test_vector_space_check_catches_same_dimension_model_swap(tmp_path, monkeypatch): + from session_recall import config + from session_recall.health import check_embed_space + + store = Store(tmp_path / "space.db") + store.set_meta("embed_fp", "builtin/old-model/384") + store.commit() + monkeypatch.setattr(config, "EMBED_MODEL", "new-model") + + dim = check_embed_space(store) + assert dim.zone == "RED" + assert "old-model" in dim.detail and "new-model" in dim.detail + assert "index" in dim.hint + store.close() + + def test_check_all_reports_every_dimension_and_a_verdict(tmp_path): """`health` has to answer one question first — is it working — and only then explain. A list of rows without a verdict makes the user do the aggregation.""" diff --git a/tests/test_index.py b/tests/test_index.py index 6c3c4b0..57a6ad7 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -277,3 +277,21 @@ def test_clean_pass_records_the_embed_space(tmp_path): index_corpus(store, FakeEmbedder(), projects) assert store.get_meta("embed_fp") == config.embed_fingerprint() store.close() + + +def test_source_selective_pass_cannot_attest_a_mixed_index(tmp_path): + """A clean Claude pass says nothing about untouched Cursor/meta-doc rows. + The global marker may move only when every producer signature agrees.""" + from session_recall import config + projects = _corpus(tmp_path) + store = Store(tmp_path / "i.db") + store.mark_indexed( + "cursor:old-session", "cursor-v1:builtin/old-model/384:1:2", + source="cursor") + store.commit() + + index_corpus(store, FakeEmbedder(), projects) + + assert store.get_meta("embed_fp") == "mixed" + assert store.stored_sig("cursor:old-session") is not None + store.close() diff --git a/tests/test_metadocs.py b/tests/test_metadocs.py index dab4fe3..d179fce 100644 --- a/tests/test_metadocs.py +++ b/tests/test_metadocs.py @@ -493,6 +493,10 @@ def prune_deleted(self, source=None): self.pruned += 1 return 0 + def refresh_embed_meta(self, fingerprint): + self.embed_fp = fingerprint + return True + def commit(self): pass diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index 9295a69..9f86068 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -17,6 +17,7 @@ def settings(tmp_path, monkeypatch): monkeypatch.setattr(config, "CLAUDE_PROJECTS", tmp_path / "cl") monkeypatch.setattr(config, "CODEX_SESSIONS", tmp_path / "cx") monkeypatch.setattr(config, "CODEX_ARCHIVED_SESSIONS", tmp_path / "cxa") + monkeypatch.setattr(config, "CURSOR_DB", tmp_path / "cursor.vscdb") return path @@ -65,3 +66,11 @@ def test_footprint_counts_transcripts(settings, tmp_path): (d / "b.jsonl").write_text("y" * 50) files, size = onboarding._transcript_footprint() assert files == 2 and size == 150 + + +def test_footprint_includes_cursor_database_and_live_wal(settings, tmp_path): + db = tmp_path / "cursor.vscdb" + db.write_bytes(b"d" * 100) + db.with_name("cursor.vscdb-wal").write_bytes(b"w" * 50) + files, size = onboarding._transcript_footprint() + assert files == 1 and size == 150 diff --git a/tests/test_plugin_manifests.py b/tests/test_plugin_manifests.py new file mode 100644 index 0000000..97c116c --- /dev/null +++ b/tests/test_plugin_manifests.py @@ -0,0 +1,60 @@ +"""Cross-host packaging must not drift: Cursor support is more than reading its +database; the native plugin also wires MCP, skills, commands, agent, and hook.""" + +import json +import tomllib +from pathlib import Path + + +ROOT = Path(__file__).parents[1] + + +def _json(path: str): + return json.loads((ROOT / path).read_text()) + + +def test_plugin_versions_and_descriptions_cover_all_three_hosts(): + project_version = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"][ + "version"] + manifests = [ + _json(".claude-plugin/plugin.json"), + _json(".codex-plugin/plugin.json"), + _json(".cursor-plugin/plugin.json"), + ] + assert {manifest["version"] for manifest in manifests} == {project_version} + for manifest in manifests: + description = manifest["description"].casefold() + assert all(host in description for host in ("claude", "codex", "cursor")) + + +def test_cursor_plugin_components_and_marketplace_are_resolvable(): + manifest = _json(".cursor-plugin/plugin.json") + assert manifest["minClientVersions"]["cursor"] == "2.5.0" + for field in ("commands", "agents", "skills", "hooks", "mcpServers"): + value = manifest[field] + assert isinstance(value, str) and value.startswith("./") + assert (ROOT / value).exists(), f"Cursor manifest {field} path is stale" + + marketplace = _json(".cursor-plugin/marketplace.json") + assert marketplace["plugins"] == [{ + "name": "session-recall", + "source": ".", + "description": ( + "Search and navigate one local semantic index of Claude Code, " + "Codex, and Cursor history."), + }] + assert manifest["name"] == marketplace["plugins"][0]["name"] + + +def test_cursor_mcp_and_hook_use_native_shapes(): + mcp = _json("mcp.json") + server = mcp["mcpServers"]["session-recall"] + assert server["command"] == "/bin/sh" + assert "session-recall-mcp" in server["args"][-1] + + hooks = _json("hooks/hooks-cursor.json") + assert hooks["version"] == 1 + entries = hooks["hooks"]["sessionStart"] + assert len(entries) == 1 and set(entries[0]) == {"command"} + assert "session-recall" in entries[0]["command"] + assert " index" in entries[0]["command"] diff --git a/tests/test_retrieve.py b/tests/test_retrieve.py index c7ea06d..bbbb522 100644 --- a/tests/test_retrieve.py +++ b/tests/test_retrieve.py @@ -203,6 +203,29 @@ def embed_query(self, text): store.close() +def test_recall_search_scope_normalizes_generic_nested_worktree(tmp_path): + """Generic nested worktrees share scoped history with the main checkout + and sibling worktrees, without leaking another repository.""" + store = Store(tmp_path / "generic-wt.db") + store.add(*_scoped_chunk("main", "alpha main", "/Users/me/repoA", 0)) + store.add(*_scoped_chunk( + "sibling", "alpha sibling", "/Users/me/repoA/.worktrees/feature-b", 1)) + store.add(*_scoped_chunk("other", "alpha other", "/Users/me/repoB", 2)) + qvec = [0.0] * 1024 + qvec[0] = 1.0 + + class _QEmb(FakeEmbedder): + def embed_query(self, text): + return qvec + + scoped = Recall(store, _QEmb(), None).recall_search( + "anything", k=10, + scope_cwd="/Users/me/repoA/.worktrees/feature-a/src/package") + + assert {hit.uuid for hit in scoped} == {"main", "sibling"} + store.close() + + def test_grep_scoped_to_repo(tmp_path): fa = tmp_path / "a.jsonl" fa.write_text('{"type":"user","uuid":"u1","sessionId":"sa",' @@ -222,6 +245,38 @@ def test_grep_scoped_to_repo(tmp_path): store.close() +def test_grep_scope_normalizes_generic_nested_worktree(tmp_path): + paths = {} + for name, uuid, session_id in ( + ("main", "u-main", "s-main"), + ("sibling", "u-sibling", "s-sibling"), + ("other", "u-other", "s-other")): + path = tmp_path / f"{name}.jsonl" + path.write_text( + '{"type":"user","uuid":"' + uuid + '","sessionId":"' + session_id + '",' + '"message":{"role":"user","content":"generic worktree needle"}}\n') + paths[name] = path + + store = Store(tmp_path / "generic-grep.db") + store.add(*_scoped_chunk( + "u-main", "generic worktree needle", "/Users/me/repoA", 0, + file_path=str(paths["main"]), session_id="s-main")) + store.add(*_scoped_chunk( + "u-sibling", "generic worktree needle", + "/Users/me/repoA/.worktrees/feature-b", 1, + file_path=str(paths["sibling"]), session_id="s-sibling")) + store.add(*_scoped_chunk( + "u-other", "generic worktree needle", "/Users/me/repoB", 2, + file_path=str(paths["other"]), session_id="s-other")) + + hits = Recall(store, FakeEmbedder(), FakeReranker()).grep( + "generic worktree needle", + scope_cwd="/Users/me/repoA/.worktrees/feature-a/src/package") + + assert {hit.session_id for hit in hits} == {"s-main", "s-sibling"} + store.close() + + def test_recent_sessions_orders_scopes_and_labels(tmp_path): """recent_sessions surfaces the freshest sessions first (the 'what's current / how fresh' need from feedback), scoped to the repo, each labelled by its first diff --git a/tests/test_scope.py b/tests/test_scope.py index 6d7b001..33fb4f3 100644 --- a/tests/test_scope.py +++ b/tests/test_scope.py @@ -14,6 +14,12 @@ def test_repo_root_strips_claude_worktree_suffix(): ) == "/Users/me/myrepo" +def test_repo_root_strips_generic_nested_worktree_suffix(): + assert repo_root( + "/Users/me/myrepo/.worktrees/feature-a" + ) == "/Users/me/myrepo" + + def test_repo_root_strips_trailing_slash(): assert repo_root("/Users/me/myrepo/") == "/Users/me/myrepo" @@ -22,6 +28,21 @@ def test_repo_root_worktree_with_trailing_slash(): assert repo_root("/Users/me/myrepo/.claude/worktrees/foo-123/") == "/Users/me/myrepo" +def test_repo_root_normalizes_cwd_below_generic_worktree(): + assert repo_root( + "/Users/me/myrepo/.worktrees/feature-a/src" + ) == "/Users/me/myrepo" + assert repo_root( + "/Users/me/myrepo/.claude/worktrees/feature-a/src/pkg" + ) == "/Users/me/myrepo" + + +def test_repo_root_generic_worktree_marker_must_be_a_complete_segment(): + assert repo_root( + "/Users/me/myrepo/.worktrees-cache/feature-a" + ) == "/Users/me/myrepo/.worktrees-cache/feature-a" + + def test_repo_root_empty_is_empty(): assert repo_root("") == "" @@ -59,5 +80,9 @@ def test_project_label_collapses_worktree_to_repo(): assert project_label("/Users/me/myrepo/.claude/worktrees/wt-a1b2c3") == "myrepo" +def test_project_label_collapses_generic_nested_worktree_to_repo(): + assert project_label("/Users/me/myrepo/.worktrees/feature-a") == "myrepo" + + def test_project_label_empty(): assert project_label("") == "" diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py index aab6ff1..16d17a7 100644 --- a/tests/test_transcripts.py +++ b/tests/test_transcripts.py @@ -212,5 +212,6 @@ def test_claude_passthrough_and_version_validation(tmp_path): assert read_transcript(str(path), "claude") == [raw] assert extractor_version("claude") == "2" assert extractor_version("codex") == "1" + assert extractor_version("cursor") == "2" with pytest.raises(ValueError, match="unknown transcript source"): read_transcript(str(path), "other")