From 75f6d41e514bc784de76bcfe7e33e64d69dff85a Mon Sep 17 00:00:00 2001 From: Russell Stout Date: Sat, 30 May 2026 11:57:36 -0500 Subject: [PATCH 1/2] Add Cursor Agent session indexing (Closes #9) Index Cursor IDE and agent CLI transcripts from ~/.cursor/projects/, with parse_cursor_session, --source cursor, substring project matching, read_session detection, and unit tests. Co-authored-by: Cursor --- CHANGELOG.md | 20 +++++ README.md | 22 ++--- SKILL.md | 44 +++++++--- scripts/read_session.py | 32 +++++-- scripts/recall.py | 162 ++++++++++++++++++++++++++++++++++-- tests/test_cursor_parser.py | 156 ++++++++++++++++++++++++++++++++++ 6 files changed, 398 insertions(+), 38 deletions(-) create mode 100644 tests/test_cursor_parser.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 75ff7ea..f0fa7a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.5.0 + +Closes #9. + +- Add **Cursor Agent** session support (Cursor IDE + `agent` CLI) +- Indexes `~/.cursor/projects/*/agent-transcripts//.jsonl` +- New `--source cursor` filter; results tagged `[cursor]` +- Decodes workspace slug to filesystem path for `--project` matching +- Session timestamps use file mtime (Cursor JSONL has no message timestamps) +- `read_session.py` detects Cursor transcripts by path under `agent-transcripts/` +- Resume: `agent --resume ` (see SKILL.md) + +### Upgrading to 0.5.0 + +Run `--reindex` once to pull Cursor sessions into the index: + +```bash +python3 ~/.claude/skills/recall/scripts/recall.py --reindex "test" +``` + ## 0.4.1 - Make the positional `query` argument optional. When omitted, list every diff --git a/README.md b/README.md index 237ce81..f5499d4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # recall -Ever lost a conversation session with Claude Code, Codex, or pi and wish you could resume it? This skill lets your agents search across all your past conversations with full-text search. Builds a SQLite FTS5 index over `~/.claude/projects/`, `~/.codex/sessions/`, and `~/.pi/agent/sessions/` with BM25 ranking, Porter stemming, CJK support, and incremental updates. +Ever lost a conversation session with Claude Code, Codex, pi, or Cursor Agent and wish you could resume it? This skill lets your agents search across all your past conversations with full-text search. Builds a SQLite FTS5 index over `~/.claude/projects/`, `~/.codex/sessions/`, `~/.pi/agent/sessions/`, and `~/.cursor/projects/*/agent-transcripts/` with BM25 ranking, Porter stemming, CJK support, and incremental updates. ## Install @@ -14,11 +14,13 @@ Then use `/recall` in Claude Code (or Codex, or pi) or ask "find a past session ### Index ``` - ~/.claude/projects/**/*.jsonl ──┐ - │ - ~/.codex/sessions/**/*.jsonl ───┼─▶ Index ──▶ ~/.recall.db (SQLite FTS5) - │ [incremental - mtime-based] - ~/.pi/agent/sessions/**/*.jsonl ┘ + ~/.claude/projects/**/*.jsonl ──────────────┐ + │ + ~/.codex/sessions/**/*.jsonl ───────────────┼─▶ Index ──▶ ~/.recall.db (SQLite FTS5) + │ [incremental - mtime-based] + ~/.pi/agent/sessions/**/*.jsonl ────────────┤ + │ + ~/.cursor/projects/**/agent-transcripts/ ───┘ ``` ### Query ``` @@ -43,7 +45,7 @@ Then use `/recall` in Claude Code (or Codex, or pi) or ask "find a past session - CJK messages are selectively indexed into the trigram table; query routing is automatic - Skips tool_use, tool_result, thinking, and image blocks - Results ranked by BM25 with a slight recency bias (recent sessions get up to a 20% boost, decaying with a 30-day half-life) -- Results tagged `[claude]`, `[codex]`, or `[pi]` with highlighted excerpts +- Results tagged `[claude]`, `[codex]`, `[pi]`, or `[cursor]` with highlighted excerpts - No dependencies — Python 3.9+ stdlib only (sqlite3, json, argparse) ## Tests @@ -53,9 +55,9 @@ python3 -m unittest discover tests -v ``` Stdlib `unittest` only — no test deps. Synthetic JSONL fixtures generated -in `tmpdir` from the suite itself (no fixture files committed). An -integration test runs against any real pi sessions in `~/.pi/agent/sessions/` -on the host and is skipped if none are present. +in `tmpdir` from the suite itself (no fixture files committed). Integration +tests run against real sessions on the host when present (`~/.pi/agent/sessions/` +for pi, `~/.cursor/projects/` for Cursor) and are skipped otherwise. ## Contributing diff --git a/SKILL.md b/SKILL.md index b2eb86c..1963dd2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,23 +1,23 @@ --- name: recall description: > - Search past Claude Code, Codex, and pi sessions. Triggers: /recall, "search old conversations", - "find a past session", "recall a previous conversation", "search session history", - "what did we discuss", "remember when we" + Search past Claude Code, Codex, pi, and Cursor Agent sessions. Triggers: /recall, + "search old conversations", "find a past session", "recall a previous conversation", + "search session history", "what did we discuss", "remember when we" metadata: author: arjunkmrm - version: "0.4.1" + version: "0.5.0" license: MIT --- -# /recall — Search Past Claude, Codex & pi Sessions +# /recall — Search Past Claude, Codex, pi & Cursor Sessions -Search all past Claude Code, Codex, and pi sessions using full-text search with BM25 ranking. +Search all past Claude Code, Codex, pi, and **Cursor Agent** (IDE + CLI) sessions using full-text search with BM25 ranking. ## Usage ```bash -python3 ~/.claude/skills/recall/scripts/recall.py [QUERY] [--project PATH] [--days N] [--source claude|codex|pi] [--limit N] [--reindex] +python3 ~/.claude/skills/recall/scripts/recall.py [QUERY] [--project PATH] [--days N] [--source claude|codex|pi|cursor] [--limit N] [--reindex] ``` ## Examples @@ -26,8 +26,8 @@ python3 ~/.claude/skills/recall/scripts/recall.py [QUERY] [--project PATH] [--da # List every session in the last day (no text search) python3 ~/.claude/skills/recall/scripts/recall.py --days 1 -# List every pi session in the last week -python3 ~/.claude/skills/recall/scripts/recall.py --days 7 --source pi +# List every Cursor session in the last week +python3 ~/.claude/skills/recall/scripts/recall.py --days 7 --source cursor # Simple keyword search python3 ~/.claude/skills/recall/scripts/recall.py "bufferStore" @@ -53,7 +53,10 @@ python3 ~/.claude/skills/recall/scripts/recall.py "buffer" --source codex # Search only pi sessions python3 ~/.claude/skills/recall/scripts/recall.py "buffer" --source pi -# Force reindex +# Search only Cursor Agent sessions (IDE + CLI) +python3 ~/.claude/skills/recall/scripts/recall.py "sorting" --source cursor --project quick-gtasks + +# Force reindex (required once after upgrading to 0.5.0 for Cursor) python3 ~/.claude/skills/recall/scripts/recall.py --reindex "test" ``` @@ -82,6 +85,12 @@ codex resume SESSION_ID # Pi sessions [pi] cd /path/to/project pi --session SESSION_ID # full or partial id; pi resolves prefix matches + +# Cursor Agent sessions [cursor] — IDE or CLI (agent) +cd /path/to/project +agent --resume SESSION_ID # UUID from recall output +# or: agent resume # latest session +# or: agent ls # pick interactively ``` Each result includes a `File:` path. Use it to read the raw transcript (auto-detects format): @@ -95,12 +104,23 @@ If results are missing `File:` paths, run `--reindex` to backfill. ## Notes - Index is stored at `~/.recall.db` (SQLite FTS5, auto-migrated from `~/.claude/recall.db`) -- Indexes three sources: `~/.claude/projects/` (Claude Code), `~/.codex/sessions/` (Codex), and `~/.pi/agent/sessions/` (pi) +- Indexes four sources: + - `~/.claude/projects/` — Claude Code + - `~/.codex/sessions/` — Codex + - `~/.pi/agent/sessions/` — pi + - `~/.cursor/projects/*/agent-transcripts/` — **Cursor Agent** (IDE + `agent` CLI) - First run indexes all sessions (a few seconds); subsequent runs are incremental - Only user and assistant messages are indexed (tool calls, thinking blocks, state snapshots skipped) -- Results show `[claude]`, `[codex]`, or `[pi]` tags to indicate the source +- Results show `[claude]`, `[codex]`, `[pi]`, or `[cursor]` tags to indicate the source - Dual-table FTS: English queries use Porter stemming, CJK queries use trigram matching - Omit the query argument for **list mode** — every session in the window, sorted by recency, no FTS - Provide a query for full-text search; both modes accept `--project`, `--days`, `--source`, `--limit` +- **Upgrading to 0.5.0**: run `--reindex` once to pull in Cursor Agent sessions - **Upgrading from 0.3.x**: run `--reindex` once to pull in pi sessions - **Upgrading from 0.2.x**: run `--reindex` once to build the CJK index + +## Cursor-specific behavior + +- **Project path**: prefer `working_directory` from Shell tool calls in the transcript; fall back to decoding the encoded folder name under `~/.cursor/projects/` (hyphens in folder names like `quick-gtasks` are not always recoverable from the slug alone). +- Session timestamps use file modification time (Cursor JSONL has no per-message timestamps). +- `--project` matches path prefix **or** substring (e.g. `quick-gtasks` matches `C:\Users\you\src\quick-gtasks`). diff --git a/scripts/read_session.py b/scripts/read_session.py index 01c2ab2..48c173c 100644 --- a/scripts/read_session.py +++ b/scripts/read_session.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Pretty-print a Claude Code, Codex, or pi session transcript.""" +"""Pretty-print a Claude Code, Codex, pi, or Cursor Agent session transcript.""" import json import sys @@ -44,7 +44,16 @@ def iter_messages(path): if entry.get("record_type") == "state": continue - if fmt == "pi": + if fmt == "cursor": + role = entry.get("role", "") + if role not in ("user", "assistant"): + continue + msg = entry.get("message", {}) + if not isinstance(msg, dict): + continue + content = msg.get("content", "") + + elif fmt == "pi": # Pi: {type, id, parentId, timestamp, message: {role, content, ...}} # Header is {type: "session", id, cwd, version, ...} — skip. etype = entry.get("type", "") @@ -105,14 +114,19 @@ def iter_messages(path): def detect_format(path): - """Detect whether a session file is Claude Code, Codex, or pi format. + """Detect whether a session file is Claude Code, Codex, pi, or Cursor format. Detection runs on the first non-empty parseable line and returns one of - "pi", "claude", or "codex". Order matters: pi headers carry both `type: - "session"` and `cwd`, which is the most distinctive signature; Claude - files have `parentUuid` or a top-level `message`; Codex files have - `record_type`, `instructions`, or `type: "session_meta"`. + "cursor", "pi", "claude", or "codex". Cursor transcripts live under + ~/.cursor/projects/.../agent-transcripts/; pi headers carry `type: + "session"` and `cwd`; Claude files have `parentUuid` or a top-level + `message`; Codex files have `record_type`, `instructions`, or + `type: "session_meta"`. """ + norm = str(path).replace("\\", "/") + if "/.cursor/projects/" in norm and "/agent-transcripts/" in norm: + return "cursor" + with open(path, "r", encoding="utf-8", errors="replace") as f: for line in f: line = line.strip() @@ -145,7 +159,9 @@ def detect_format(path): def main(): import argparse - parser = argparse.ArgumentParser(description="Pretty-print a Claude Code, Codex, or pi session transcript") + parser = argparse.ArgumentParser( + description="Pretty-print a Claude Code, Codex, pi, or Cursor Agent session transcript" + ) parser.add_argument("path", help="Path to a session .jsonl file") parser.add_argument("--pretty", action="store_true", help="Human-readable output instead of JSON") args = parser.parse_args() diff --git a/scripts/recall.py b/scripts/recall.py index 6fcfeba..0c0a965 100755 --- a/scripts/recall.py +++ b/scripts/recall.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Search past Claude Code, Codex, and pi sessions using FTS5 full-text search.""" +"""Search past Claude Code, Codex, pi, and Cursor Agent sessions using FTS5 full-text search.""" import argparse import json @@ -16,10 +16,12 @@ CLAUDE_DIR = Path.home() / ".claude" CODEX_DIR = Path.home() / ".codex" PI_DIR = Path.home() / ".pi" +CURSOR_DIR = Path.home() / ".cursor" DB_PATH = Path.home() / ".recall.db" CLAUDE_PROJECTS_DIR = CLAUDE_DIR / "projects" CODEX_SESSIONS_DIR = CODEX_DIR / "sessions" PI_SESSIONS_DIR = PI_DIR / "agent" / "sessions" +CURSOR_PROJECTS_DIR = CURSOR_DIR / "projects" CJK_RE = re.compile( @@ -437,6 +439,133 @@ def parse_pi_session(path): return metadata, messages +# — Cursor Agent session parser ——————————————————————————————————————————— + +def decode_cursor_project_slug(slug): + """Decode a Cursor project folder name back to a filesystem path. + + Cursor encodes workspace paths as hyphen-separated segments under + ~/.cursor/projects/, e.g. c-Users-alice-src-foo -> C:\\Users\\alice\\src\\foo. + """ + if not slug or slug.isdigit(): + return "" + parts = slug.split("-") + if len(parts) < 2: + if len(parts) == 1 and len(parts[0]) == 1 and parts[0].isalpha(): + return f"{parts[0].upper()}:{os.sep}" + return "" + + if len(parts[0]) == 1 and parts[0].isalpha(): + drive = parts[0].upper() + rest = os.sep.join(parts[1:]) + return f"{drive}:{os.sep}{rest}" + + if parts[0] in ("Users", "home", "tmp", "var", "opt"): + return os.sep + os.sep.join(parts) + + return os.sep.join(parts) + + +def cursor_project_from_path(path): + """Extract decoded project path from a Cursor agent-transcripts file path.""" + parts = Path(path).parts + try: + idx = parts.index("projects") + except ValueError: + return "" + if idx + 1 >= len(parts): + return "" + return decode_cursor_project_slug(parts[idx + 1]) + + +def cursor_working_directory_from_transcript(path): + """Read the first Shell tool_use working_directory from a Cursor transcript.""" + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + msg = entry.get("message", {}) + if not isinstance(msg, dict): + continue + content = msg.get("content", []) + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict) or block.get("type") != "tool_use": + continue + inp = block.get("input", {}) + if isinstance(inp, dict): + wd = inp.get("working_directory", "") + if wd: + return wd + except OSError: + pass + return "" + + +def parse_cursor_session(path): + """Parse a Cursor Agent JSONL transcript, returning (metadata, messages). + + Cursor Agent (CLI and IDE) stores transcripts at: + ~/.cursor/projects//agent-transcripts//.jsonl + + Each line is {role: user|assistant, message: {content: ...}}. Tool calls and + other blocks are skipped via extract_text (text blocks only). + """ + session_id = Path(path).stem + project = cursor_working_directory_from_transcript(path) or cursor_project_from_path(path) + slug = session_id[:8] if len(session_id) >= 8 else session_id + messages = [] + + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + + role = entry.get("role", "") + if role not in ("user", "assistant"): + continue + + msg = entry.get("message", {}) + if not isinstance(msg, dict): + continue + + text = extract_text(msg.get("content", "")) + if text: + messages.append((role, text)) + + except (OSError, PermissionError) as e: + print(f"Warning: skipping {path}: {e}", file=sys.stderr) + return None + + try: + mtime_ms = int(os.path.getmtime(path) * 1000) + except OSError: + mtime_ms = 0 + + metadata = { + "session_id": session_id, + "source": "cursor", + "file_path": path, + "project": project or "", + "slug": slug, + "timestamp": mtime_ms, + } + return metadata, messages + + # — Indexing ——————————————————————————————————————————————————————————————— def index_sessions(conn, force=False): @@ -474,6 +603,13 @@ def index_sessions(conn, force=False): for fpath in glob(pi_pattern, recursive=True): sources.append((fpath, "pi")) + # Cursor Agent: ~/.cursor/projects/*/agent-transcripts/*/*.jsonl + cursor_pattern = str(CURSOR_PROJECTS_DIR / "**" / "agent-transcripts" / "*" / "*.jsonl") + for fpath in glob(cursor_pattern, recursive=True): + # Only index canonical session files (uuid/uuid.jsonl) + if Path(fpath).parent.name == Path(fpath).stem: + sources.append((fpath, "cursor")) + indexed = 0 skipped = 0 @@ -502,8 +638,10 @@ def index_sessions(conn, force=False): result = parse_claude_session(fpath) elif source == "codex": result = parse_codex_session(fpath) - else: # pi + elif source == "pi": result = parse_pi_session(fpath) + else: # cursor + result = parse_cursor_session(fpath) if result is None: continue @@ -588,8 +726,8 @@ def list_sessions(conn, project=None, days=None, source=None, limit=10): conds = [] params = [] if project: - conds.append("project LIKE ? || '%'") - params.append(project) + conds.append("(project LIKE ? || '%' OR project LIKE '%' || ? || '%')") + params.extend([project, project]) if days: cutoff = int((time.time() - days * 86400) * 1000) conds.append("timestamp >= ?") @@ -624,8 +762,10 @@ def search(conn, query, project=None, days=None, source=None, limit=10): session_filter_conds = [] filter_params = [] if project: - session_filter_conds.append("s2.project LIKE ? || '%'") - filter_params.append(project) + session_filter_conds.append( + "(s2.project LIKE ? || '%' OR s2.project LIKE '%' || ? || '%')" + ) + filter_params.extend([project, project]) if days: cutoff = int((time.time() - days * 86400) * 1000) session_filter_conds.append("s2.timestamp >= ?") @@ -733,11 +873,17 @@ def format_timestamp(ts_ms): def main(): - parser = argparse.ArgumentParser(description="Search past Claude Code, Codex, and pi sessions") + parser = argparse.ArgumentParser( + description="Search past Claude Code, Codex, pi, and Cursor Agent sessions" + ) parser.add_argument("query", nargs="?", help="Search query (FTS5 syntax: quotes for phrases, AND/OR/NOT). Omit to list all sessions in the time window without text matching.") parser.add_argument("--project", help="Filter to sessions from a specific project path (prefix match)") parser.add_argument("--days", type=int, help="Only sessions from last N days") - parser.add_argument("--source", choices=["claude", "codex", "pi"], help="Filter by source (claude, codex, or pi)") + parser.add_argument( + "--source", + choices=["claude", "codex", "pi", "cursor"], + help="Filter by source (claude, codex, pi, or cursor)", + ) parser.add_argument("--limit", type=int, default=10, help="Max results (default: 10)") parser.add_argument("--reindex", action="store_true", help="Force full rebuild of the index") diff --git a/tests/test_cursor_parser.py b/tests/test_cursor_parser.py new file mode 100644 index 0000000..b813c4d --- /dev/null +++ b/tests/test_cursor_parser.py @@ -0,0 +1,156 @@ +"""Tests for parse_cursor_session and cursor detection in read_session.""" +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +import recall # noqa: E402 +import read_session # noqa: E402 + + +CURSOR_SAMPLE = [ + { + "role": "user", + "message": { + "content": [ + { + "type": "text", + "text": "\nfix sorting when adding tasks\n", + } + ] + }, + }, + { + "role": "assistant", + "message": { + "content": [ + {"type": "text", "text": "Checking the sort order in tasks.service.ts."}, + { + "type": "tool_use", + "name": "Read", + "input": {"path": "src/app/services/tasks.service.ts"}, + }, + ] + }, + }, +] + + +def write_jsonl(tmpdir: Path, rel_path: str, entries: list[dict]) -> str: + path = tmpdir / rel_path + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as f: + for entry in entries: + f.write(json.dumps(entry)) + f.write("\n") + return str(path) + + +class TestDecodeCursorProjectSlug(unittest.TestCase): + def test_windows_path_without_internal_hyphens(self): + self.assertEqual( + recall.decode_cursor_project_slug("c-Users-alice-src-foo"), + "C:\\Users\\alice\\src\\foo", + ) + + def test_drive_only_slug(self): + self.assertEqual(recall.decode_cursor_project_slug("D"), "D:\\") + + def test_numeric_slug_returns_empty(self): + self.assertEqual(recall.decode_cursor_project_slug("1768486658315"), "") + + +class TestParseCursorSession(unittest.TestCase): + def setUp(self): + self.tmpdir = Path(tempfile.mkdtemp(prefix="recall-cursor-test-")) + + def test_extracts_user_and_assistant_text(self): + sample = list(CURSOR_SAMPLE) + sample[1] = { + "role": "assistant", + "message": { + "content": [ + {"type": "text", "text": "Checking the sort order."}, + { + "type": "tool_use", + "name": "Shell", + "input": { + "command": "git status", + "working_directory": "C:\\Users\\alice\\src\\foo", + }, + }, + ] + }, + } + path = write_jsonl( + self.tmpdir, + ".cursor/projects/c-Users-alice-src-foo/agent-transcripts/" + "2cb862d9-8a30-4c5c-823b-f20169c3d18d/2cb862d9-8a30-4c5c-823b-f20169c3d18d.jsonl", + sample, + ) + metadata, messages = recall.parse_cursor_session(path) + + self.assertEqual(metadata["session_id"], "2cb862d9-8a30-4c5c-823b-f20169c3d18d") + self.assertEqual(metadata["source"], "cursor") + self.assertEqual(metadata["project"], "C:\\Users\\alice\\src\\foo") + self.assertEqual(metadata["slug"], "2cb862d9") + self.assertEqual(len(messages), 2) + self.assertEqual(messages[0][0], "user") + self.assertIn("sorting", messages[0][1]) + self.assertEqual(messages[1][0], "assistant") + self.assertIn("sort order", messages[1][1]) + + def test_skips_tool_use_blocks(self): + path = write_jsonl( + self.tmpdir, + "projects/c-Users-alice/agent-transcripts/abc/abc.jsonl", + CURSOR_SAMPLE, + ) + _, messages = recall.parse_cursor_session(path) + assistant_text = messages[1][1] + self.assertNotIn("tool_use", assistant_text) + self.assertNotIn("Read", assistant_text) + + +class TestDetectFormatCursor(unittest.TestCase): + def setUp(self): + self.tmpdir = Path(tempfile.mkdtemp(prefix="recall-cursor-test-")) + + def test_detects_cursor_from_path(self): + path = write_jsonl( + self.tmpdir, + ".cursor/projects/c-Users-alice/agent-transcripts/sid/sid.jsonl", + CURSOR_SAMPLE, + ) + self.assertEqual(read_session.detect_format(path), "cursor") + + +class TestRealCursorSession(unittest.TestCase): + def test_parse_any_real_cursor_session(self): + cursor_dir = Path.home() / ".cursor" / "projects" + if not cursor_dir.is_dir(): + self.skipTest("no cursor projects on this host") + + files = sorted(cursor_dir.glob("**/agent-transcripts/*/*.jsonl")) + files = [f for f in files if f.parent.name == f.stem] + if not files: + self.skipTest("no cursor agent transcripts on this host") + + smallest = min(files, key=lambda f: f.stat().st_size) + result = recall.parse_cursor_session(str(smallest)) + self.assertIsNotNone(result) + metadata, messages = result + self.assertEqual(metadata["source"], "cursor") + self.assertTrue(metadata["session_id"]) + for role, text in messages: + self.assertIn(role, ("user", "assistant")) + self.assertTrue(text) + + +if __name__ == "__main__": + unittest.main() From 83a785cef59742d21609a92080d06ee94b38cd3e Mon Sep 17 00:00:00 2001 From: Russell Stout Date: Wed, 24 Jun 2026 14:17:24 -0500 Subject: [PATCH 2/2] fix(cursor): decode Windows drive-letter slugs with backslashes Cursor encodes Windows workspace paths in project folder names; use backslashes for drive-letter slugs on all platforms so tests and --project matching behave consistently. Co-authored-by: Cursor --- scripts/recall.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/recall.py b/scripts/recall.py index 0c0a965..de8557a 100755 --- a/scripts/recall.py +++ b/scripts/recall.py @@ -450,15 +450,16 @@ def decode_cursor_project_slug(slug): if not slug or slug.isdigit(): return "" parts = slug.split("-") + win_sep = "\\" if len(parts) < 2: if len(parts) == 1 and len(parts[0]) == 1 and parts[0].isalpha(): - return f"{parts[0].upper()}:{os.sep}" + return f"{parts[0].upper()}:{win_sep}" return "" if len(parts[0]) == 1 and parts[0].isalpha(): drive = parts[0].upper() - rest = os.sep.join(parts[1:]) - return f"{drive}:{os.sep}{rest}" + rest = win_sep.join(parts[1:]) + return f"{drive}:{win_sep}{rest}" if parts[0] in ("Users", "home", "tmp", "var", "opt"): return os.sep + os.sep.join(parts)