From 47511df6d1fd5cfdbd9127df2998671b9ab85a78 Mon Sep 17 00:00:00 2001 From: max Date: Mon, 3 Aug 2026 14:49:03 +0300 Subject: [PATCH] feat(cursor): Cursor sessions as the third index source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format captured from a LIVE Cursor 3.14.7 install before it was removed (the install existed for exactly this): one SQLite database User/globalStorage/state.vscdb — composerHeaders as the session catalog, composerData: for bubble order + ISO timestamps, bubbleId:* rows typed 1=user / 2=assistant, thinking as empty-text bubbles. The surface rule stays the project invariant: non-empty text only. No file per session, so incremental indexing keys on virtual paths cursor: with lastUpdatedAt in the signature; reconciliation compares Cursor's catalog against indexed_files instead of the disk, and prune_deleted skips the virtual prefix. The live db is snapshotted (WAL) before reading. Subagent sessions are skipped like Claude sidechains. Workspace→folder mapping follows the VS Code workspace.json convention; empty-window sessions index with an empty project. Also fixes a reuse-cache hole this work surfaced, in both cursor and metadocs indexing: embeddings_by_hash reuse is now gated on the OLD signature carrying the current embed fingerprint — vectors never cross embedding spaces via the by-hash cache. Co-Authored-By: Claude Fable 5 --- src/session_recall/cli.py | 16 +- src/session_recall/config.py | 12 ++ src/session_recall/cursor.py | 208 ++++++++++++++++++++++++ src/session_recall/metadocs/indexing.py | 6 +- src/session_recall/retrieve.py | 4 +- src/session_recall/store.py | 5 +- tests/test_cursor.py | 139 ++++++++++++++++ tests/test_metadocs.py | 3 + 8 files changed, 384 insertions(+), 9 deletions(-) create mode 100644 src/session_recall/cursor.py create mode 100644 tests/test_cursor.py diff --git a/src/session_recall/cli.py b/src/session_recall/cli.py index 93983d3..09e85a4 100644 --- a/src/session_recall/cli.py +++ b/src/session_recall/cli.py @@ -73,27 +73,28 @@ def main(argv=None): parser = argparse.ArgumentParser(prog="session-recall") sub = parser.add_subparsers(dest="cmd", required=True) ip = sub.add_parser("index") - ip.add_argument("--source", choices=("all", "claude", "codex"), default="all") + ip.add_argument("--source", choices=("all", "claude", "codex", "cursor"), + default="all") sp = sub.add_parser("search") sp.add_argument("query") sp.add_argument("-k", type=int, default=10) sp.add_argument("--scope", help="cwd to scope results to (repo root)") - sp.add_argument("--source", choices=("claude", "codex")) + sp.add_argument("--source", choices=("claude", "codex", "cursor")) _add_date_args(sp) rp = sub.add_parser("recent") rp.add_argument("--scope") rp.add_argument("-n", type=int, default=10) - rp.add_argument("--source", choices=("claude", "codex")) + rp.add_argument("--source", choices=("claude", "codex", "cursor")) _add_date_args(rp) gp = sub.add_parser("grep") gp.add_argument("pattern") gp.add_argument("--scope") gp.add_argument("--session") - gp.add_argument("--source", choices=("claude", "codex")) + gp.add_argument("--source", choices=("claude", "codex", "cursor")) gp.add_argument("--limit", type=int, default=100) _add_date_args(gp) pp = sub.add_parser("prune") # drop rows for transcripts deleted from disk - pp.add_argument("--source", choices=("claude", "codex")) + pp.add_argument("--source", choices=("claude", "codex", "cursor")) sub.add_parser("health") # is recall actually working right now? from .share import cli as share_cli share_cli.add_parser(sub) @@ -125,6 +126,11 @@ def main(argv=None): codex_dirs=codex_roots, ) 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)") # meta docs entries ride the same index (source="metadocs") whenever # the feature is configured; the SessionStart hook keeps them fresh if args.source == "all": diff --git a/src/session_recall/config.py b/src/session_recall/config.py index a64eef4..67f6153 100644 --- a/src/session_recall/config.py +++ b/src/session_recall/config.py @@ -1,6 +1,7 @@ import json import os import socket +import sys from dataclasses import dataclass from pathlib import Path from urllib.parse import urlparse @@ -18,6 +19,17 @@ CODEX_SESSIONS = CODEX_HOME / "sessions" CODEX_ARCHIVED_SESSIONS = CODEX_HOME / "archived_sessions" + +def _default_cursor_db() -> Path: + base = (Path.home() / "Library" / "Application Support" + if sys.platform == "darwin" else Path.home() / ".config") + return base / "Cursor" / "User" / "globalStorage" / "state.vscdb" + + +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, # model=text-embedding-3-large, dim=1024). Adding a provider = one branch in diff --git a/src/session_recall/cursor.py b/src/session_recall/cursor.py new file mode 100644 index 0000000..0c91435 --- /dev/null +++ b/src/session_recall/cursor.py @@ -0,0 +1,208 @@ +"""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. +""" + +import hashlib +import json +import shutil +import sqlite3 +import tempfile +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from urllib.parse import unquote, urlparse + +from .models import Chunk +from .store import Store + +SIG_TAG = "cursor-v1" +VPATH_PREFIX = "cursor:" # virtual indexed_files path, never on disk + + +@dataclass +class CursorSession: + composer_id: str + workspace_id: str + name: str + updated_ms: int + turns: list = field(default_factory=list) # {bubble_id, role, text, ts} + + +def _iso_to_epoch(iso: str, fallback_ms: int) -> int: + 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) + return copy + + +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() + + +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.""" + if not workspace_id or workspace_id == "empty-window": + return "", "" + ws = db_path.parent.parent / "workspaceStorage" / workspace_id / "workspace.json" + try: + folder = json.loads(ws.read_text()).get("folder") or "" + except (OSError, ValueError): + return "", "" + if folder.startswith("file://"): + path = unquote(urlparse(folder).path) + return Path(path).name, path + return "", "" + + +def _embed_fp() -> str: + from . import config + 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.""" + from . import config as app_config + db_path = db_path or app_config.CURSOR_DB + if not Path(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): + 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 {}) + 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) + 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") + store.commit() + count += 1 + except Exception: + store.rollback() + 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( + "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() + return count diff --git a/src/session_recall/metadocs/indexing.py b/src/session_recall/metadocs/indexing.py index f076b24..b4d8f23 100644 --- a/src/session_recall/metadocs/indexing.py +++ b/src/session_recall/metadocs/indexing.py @@ -65,7 +65,11 @@ def index_metadocs(store: Store, embedder, repo: Path) -> int: if entry is None: continue # half-written or foreign file: skip, no marker text = f"{entry.title}\n\n{entry.body}" - cached = store.embeddings_by_hash(str(path)) + # reuse only within one embedding space — an fp change invalidates + # the signature and must invalidate the by-hash cache too + old_sig = store.stored_sig(str(path)) or "" + cached = (store.embeddings_by_hash(str(path)) + if f":{_embed_fp()}:" in old_sig else {}) chunk = _chunk(entry, path, text) try: vec = cached.get(chunk.content_hash) diff --git a/src/session_recall/retrieve.py b/src/session_recall/retrieve.py index 4c74b0c..45f9e1a 100644 --- a/src/session_recall/retrieve.py +++ b/src/session_recall/retrieve.py @@ -51,9 +51,9 @@ def __init__(self, store: Store, embedder: Embedder, reranker: "Reranker | None" @staticmethod def _validate_source(source: str | None) -> None: - if source not in {None, "claude", "codex"}: + if source not in {None, "claude", "codex", "cursor"}: raise ValueError( - f"source must be 'claude', 'codex', or omitted; got {source!r}") + f"source must be 'claude', 'codex', 'cursor', or omitted; got {source!r}") @staticmethod def _anchor(c, score: "float | None") -> Anchor: diff --git a/src/session_recall/store.py b/src/session_recall/store.py index bbe4f5f..e4bfe0a 100644 --- a/src/session_recall/store.py +++ b/src/session_recall/store.py @@ -156,9 +156,12 @@ def prune_deleted(self, source: str | None = None) -> int: WHY: docs/decisions/2026-06-27-grep-resilient-to-deleted-transcripts.md""" source_sql = " WHERE source = ?" if source else "" params = (source,) if source else () + # cursor rows key on virtual paths (cursor:) that never + # exist on disk — their reconciliation lives in cursor.index_cursor, + # against Cursor's own catalog gone = [r[0] for r in self.db.execute( f"SELECT path FROM indexed_files{source_sql}", params).fetchall() - if not Path(r[0]).exists()] + if not r[0].startswith("cursor:") and not Path(r[0]).exists()] for path in gone: self.delete_file(path) # chunks + vec + fts self.db.execute("DELETE FROM indexed_files WHERE path = ?", (path,)) diff --git a/tests/test_cursor.py b/tests/test_cursor.py new file mode 100644 index 0000000..de1ab20 --- /dev/null +++ b/tests/test_cursor.py @@ -0,0 +1,139 @@ +"""Cursor as the third source. The fixture database reproduces the format +captured from a live Cursor 3.14.7 install (composerHeaders + cursorDiskKV, +bubbles typed 1=user / 2=assistant, thinking as empty-text bubbles) — the +extractor must survive exactly that shape, and reconciliation must track +Cursor's catalog rather than the filesystem.""" + +import json +import sqlite3 +from pathlib import Path + +from session_recall import config +from session_recall.cursor import index_cursor, read_sessions +from session_recall.embed import FakeEmbedder +from session_recall.store import Store + + +def _make_db(root: Path, sessions) -> Path: + """sessions: list of (composer_id, workspace_id, updated_ms, is_subagent, + turns) where turns = [(type, text, iso_ts)].""" + gs = root / "User" / "globalStorage" + gs.mkdir(parents=True, exist_ok=True) + db = gs / "state.vscdb" + conn = sqlite3.connect(db) + conn.execute("CREATE TABLE IF NOT EXISTS composerHeaders(" + "composerId TEXT PRIMARY KEY, workspaceId TEXT, createdAt INTEGER, " + "lastUpdatedAt INTEGER, isArchived INTEGER, isSubagent INTEGER, " + "recency INTEGER, checkpointAt INTEGER, value TEXT)") + conn.execute("CREATE TABLE IF NOT EXISTS cursorDiskKV(" + "key TEXT PRIMARY KEY, value BLOB)") + conn.execute("DELETE FROM composerHeaders") + conn.execute("DELETE FROM cursorDiskKV") + for cid, ws, updated, sub, turns in sessions: + conn.execute("INSERT INTO composerHeaders VALUES (?,?,?,?,0,?,0,0,'{}')", + (cid, ws, updated - 1000, updated, sub)) + headers = [] + for i, (btype, text, iso) in enumerate(turns): + bid = f"b{i}" + headers.append({"bubbleId": bid, "type": btype, "createdAt": iso}) + conn.execute("INSERT INTO cursorDiskKV VALUES (?,?)", + (f"bubbleId:{cid}:{bid}", + json.dumps({"_v": 3, "type": btype, "bubbleId": bid, + "text": text}))) + conn.execute("INSERT INTO cursorDiskKV VALUES (?,?)", + (f"composerData:{cid}", + json.dumps({"_v": 1, "composerId": cid, "name": f"chat {cid}", + "fullConversationHeadersOnly": headers}))) + conn.commit() + conn.close() + return db + + +_TURNS = [ + (1, "почему падает деплой по пятницам?", "2026-08-03T10:00:00.000Z"), + (2, "", "2026-08-03T10:00:01.000Z"), # thinking bubble + (2, "Крон собирал кэш в полночь UTC — по пятницам он пересекался с релизом.", + "2026-08-03T10:00:02.000Z"), +] + + +def test_read_sessions_surface_only(tmp_path): + db = _make_db(tmp_path, [ + ("comp-1", "ws-1", 1_700_000_000_000, 0, _TURNS), + ("comp-sub", "ws-1", 1_700_000_000_000, 1, _TURNS), # subagent: skipped + ]) + (tmp_path / "User" / "workspaceStorage" / "ws-1").mkdir(parents=True) + sessions = read_sessions(db) + assert [s.composer_id for s in sessions] == ["comp-1"] + s = sessions[0] + assert [t["role"] for t in s.turns] == ["user", "assistant"], \ + "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 + + +def test_index_cursor_end_to_end_with_workspace_mapping(tmp_path): + db = _make_db(tmp_path, [("comp-1", "ws-1", 1_700_000_000_000, 0, _TURNS)]) + ws = tmp_path / "User" / "workspaceStorage" / "ws-1" + ws.mkdir(parents=True) + (ws / "workspace.json").write_text( + json.dumps({"folder": "file:///Users/me/deploy-service"})) + + store = Store(tmp_path / "i.db") + n = index_cursor(store, FakeEmbedder(), db_path=db) + 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")] + + # unchanged catalog → nothing re-indexed + assert index_cursor(store, FakeEmbedder(), db_path=db) == 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 store.db.execute("SELECT count(*) FROM chunks").fetchone()[0] == 3 + store.close() + + +def test_reconciliation_follows_the_catalog_not_the_disk(tmp_path): + db = _make_db(tmp_path, [ + ("comp-1", "empty-window", 1_700_000_000_000, 0, _TURNS), + ("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 + + # 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) + left = {r[0] for r in store.db.execute( + "SELECT DISTINCT session_id FROM chunks WHERE source='cursor'")} + assert left == {"comp-1"} + 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 + store.close() + + +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) + 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) + assert emb.doc_calls > first_calls, \ + "old-space vectors must be re-embedded, never reused across spaces" + store.close() diff --git a/tests/test_metadocs.py b/tests/test_metadocs.py index 1c59898..dab4fe3 100644 --- a/tests/test_metadocs.py +++ b/tests/test_metadocs.py @@ -474,6 +474,9 @@ def __init__(self): def is_indexed(self, path, sig): return self.indexed.get(path) == sig + def stored_sig(self, path): + return self.indexed.get(path) + def embeddings_by_hash(self, path): return {}