Opus-4.5 wrote this fix. It is untested - if you use it:
a) BACKUP FIRST
b) open Cursor in every folder you use (so it upgrades your DB and creates a correct current ID)
c) use at own risk!
d) CHECK afterwards (restore backup if problems)
#!/usr/bin/env python3
"""
find_orphaned_chats.py - Find and rescue orphaned chats in Cursor databases
This tool detects chats that are "orphaned" due to workspace ID changes.
When a folder's creation timestamp changes, Cursor computes a new workspace ID,
leaving old chats invisible but still in the database.
This tool:
1. Finds all workspace IDs referenced in the global database
2. Compares against current workspaceStorage folders
3. Identifies orphaned workspace IDs
4. Maps orphaned IDs to current IDs by project path
5. Optionally migrates orphaned chats to current workspace IDs
Usage:
python find_orphaned_chats.py --scan # Just scan and report
python find_orphaned_chats.py --fix # Fix orphaned chats
python find_orphaned_chats.py --fix --dry-run # Preview fixes
Author: Generated by Cursor AI Assistant
Date: 2026-01-24
"""
import argparse
import json
import os
import re
import shutil
import sqlite3
import sys
from collections import defaultdict
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Set, Optional, Tuple
from urllib.parse import unquote
def normalize_path(path: str) -> str:
"""Normalize path for comparison."""
if not path:
return None
path = path.lower().replace('\\', '/').rstrip('/')
path = re.sub(r'^[a-z]:', '', path)
path = re.sub(r'^/mnt/[a-z]', '', path)
return path
def extract_project_name(path: str) -> str:
"""Extract project name from path."""
if not path:
return None
normalized = normalize_path(path)
return normalized.split('/')[-1] if normalized else None
def get_workspace_path_from_json(workspace_dir: Path) -> Optional[str]:
"""Get workspace path from workspace.json."""
workspace_json = workspace_dir / 'workspace.json'
if workspace_json.exists():
try:
with open(workspace_json, encoding='utf-8') as f:
data = json.load(f)
if 'folder' in data:
folder = data['folder']
if folder.startswith('file:///'):
return unquote(folder[8:])
return folder
except:
pass
return None
def get_cursor_paths() -> Dict[str, Path]:
"""Get Cursor user directory paths."""
cursor_user = Path(os.path.expandvars('%APPDATA%')) / 'Cursor' / 'User'
return {
'user': cursor_user,
'global_storage': cursor_user / 'globalStorage',
'global_db': cursor_user / 'globalStorage' / 'state.vscdb',
'workspace_storage': cursor_user / 'workspaceStorage',
}
class OrphanedChatFinder:
"""Find and fix orphaned chats."""
def __init__(self, paths: Dict[str, Path]):
self.paths = paths
self.current_workspaces = {} # workspace_id -> path
self.referenced_workspaces = {} # workspace_id -> set of composer_ids
self.orphaned_workspaces = {} # workspace_id -> info
self.remap_table = {} # old_id -> new_id
def load_current_workspaces(self):
"""Load workspace IDs from current workspaceStorage."""
workspace_storage = self.paths['workspace_storage']
if not workspace_storage.exists():
print(f"Warning: workspaceStorage not found: {workspace_storage}")
return
for workspace_dir in workspace_storage.iterdir():
if not workspace_dir.is_dir():
continue
workspace_id = workspace_dir.name
if len(workspace_id) != 32:
continue
path = get_workspace_path_from_json(workspace_dir)
if path:
self.current_workspaces[workspace_id] = {
'path': path,
'project': extract_project_name(path),
'normalized': normalize_path(path),
}
print(f"Loaded {len(self.current_workspaces)} current workspaces")
def scan_global_database(self):
"""Scan global database for workspace ID references."""
global_db = self.paths['global_db']
if not global_db.exists():
print(f"Error: Global database not found: {global_db}")
return
try:
conn = sqlite3.connect(f'file:{global_db}?mode=ro', uri=True)
cursor = conn.cursor()
# Find all keys that contain workspace-like IDs (32 hex chars)
cursor.execute("SELECT key FROM cursorDiskKV")
workspace_pattern = re.compile(r'[a-f0-9]{32}')
for (key,) in cursor.fetchall():
matches = workspace_pattern.findall(key)
for match in matches:
if match not in self.referenced_workspaces:
self.referenced_workspaces[match] = set()
# If this is a composerData key, track the composer
if key.startswith('composerData:'):
composer_id = key.split(':')[1]
# The workspace ID might be in the key or we need to check the value
self.referenced_workspaces[match].add(composer_id)
# Also scan ItemTable for workspace references
cursor.execute("SELECT key, value FROM ItemTable")
for key, value in cursor.fetchall():
if value:
try:
value_str = value.decode('utf-8') if isinstance(value, bytes) else value
matches = workspace_pattern.findall(value_str)
for match in matches:
if match not in self.referenced_workspaces:
self.referenced_workspaces[match] = set()
except:
pass
conn.close()
except sqlite3.DatabaseError as e:
print(f"Database error: {e}")
def find_orphaned_workspaces(self):
"""Identify workspace IDs that are referenced but don't exist."""
current_ids = set(self.current_workspaces.keys())
referenced_ids = set(self.referenced_workspaces.keys())
orphaned_ids = referenced_ids - current_ids
print(f"\nWorkspace ID Analysis:")
print(f" Current workspaces: {len(current_ids)}")
print(f" Referenced in DB: {len(referenced_ids)}")
print(f" Orphaned: {len(orphaned_ids)}")
# Try to identify what project each orphaned ID belonged to
for orphan_id in orphaned_ids:
composers = self.referenced_workspaces.get(orphan_id, set())
self.orphaned_workspaces[orphan_id] = {
'composers': composers,
'composer_count': len(composers),
}
return orphaned_ids
def analyze_orphaned_chats(self):
"""Analyze orphaned chats to find their original projects."""
global_db = self.paths['global_db']
if not global_db.exists():
return
try:
conn = sqlite3.connect(f'file:{global_db}?mode=ro', uri=True)
cursor = conn.cursor()
# For each orphaned workspace, try to find associated chats
for orphan_id in self.orphaned_workspaces:
# Look for inlineDiffs entries which often contain workspace IDs
cursor.execute(
"SELECT key FROM cursorDiskKV WHERE key LIKE ?",
(f'inlineDiffs-{orphan_id}%',)
)
inline_diffs = cursor.fetchall()
if inline_diffs:
self.orphaned_workspaces[orphan_id]['has_inline_diffs'] = True
# Count bubbles and other data
cursor.execute(
"SELECT COUNT(*) FROM cursorDiskKV WHERE key LIKE ?",
(f'%{orphan_id}%',)
)
count = cursor.fetchone()[0]
self.orphaned_workspaces[orphan_id]['total_keys'] = count
conn.close()
except sqlite3.DatabaseError as e:
print(f"Database error: {e}")
def build_remap_table_from_workspace_dbs(self):
"""Try to find project paths from old workspace databases."""
workspace_storage = self.paths['workspace_storage']
# First, build a map of project paths to current workspace IDs
path_to_current_id = {}
for wid, info in self.current_workspaces.items():
normalized = info.get('normalized')
project = info.get('project')
if normalized:
path_to_current_id[normalized] = wid
if project:
path_to_current_id[project] = wid
# Check if any orphaned IDs have workspace folders (they might still exist)
for orphan_id in self.orphaned_workspaces:
orphan_dir = workspace_storage / orphan_id
if orphan_dir.exists():
path = get_workspace_path_from_json(orphan_dir)
if path:
normalized = normalize_path(path)
project = extract_project_name(path)
self.orphaned_workspaces[orphan_id]['path'] = path
self.orphaned_workspaces[orphan_id]['project'] = project
# Find matching current workspace
target_id = path_to_current_id.get(normalized) or path_to_current_id.get(project)
if target_id and target_id != orphan_id:
self.remap_table[orphan_id] = target_id
self.orphaned_workspaces[orphan_id]['can_remap_to'] = target_id
def scan_for_orphaned_composers(self) -> List[Dict]:
"""Find composers that reference orphaned workspace IDs."""
global_db = self.paths['global_db']
orphaned_composers = []
if not global_db.exists():
return orphaned_composers
try:
conn = sqlite3.connect(f'file:{global_db}?mode=ro', uri=True)
cursor = conn.cursor()
# Get all composers
cursor.execute("""
SELECT key, value FROM cursorDiskKV
WHERE key LIKE 'composerData:%' AND value IS NOT NULL
""")
for key, value in cursor.fetchall():
composer_id = key.split(':')[1]
try:
if isinstance(value, bytes):
value_str = value.decode('utf-8')
else:
value_str = value
# Check if this composer references any orphaned workspace
for orphan_id in self.orphaned_workspaces:
if orphan_id in value_str:
data = json.loads(value_str)
orphaned_composers.append({
'composer_id': composer_id,
'name': data.get('name', '(unnamed)'),
'orphan_workspace_id': orphan_id,
'created_at': data.get('createdAt', 0),
'bubble_count': len(data.get('fullConversationHeadersOnly', data.get('conversation', []))),
})
break
except:
pass
conn.close()
except sqlite3.DatabaseError as e:
print(f"Database error: {e}")
return orphaned_composers
def fix_orphaned_chats(self, dry_run: bool = True) -> Dict:
"""Remap orphaned chats to current workspace IDs."""
global_db = self.paths['global_db']
stats = {
'keys_updated': 0,
'composers_fixed': 0,
'errors': [],
}
if not self.remap_table:
print("No remappings available - cannot fix orphaned chats")
return stats
if not global_db.exists():
stats['errors'].append("Global database not found")
return stats
try:
if dry_run:
conn = sqlite3.connect(f'file:{global_db}?mode=ro', uri=True)
else:
conn = sqlite3.connect(global_db)
cursor = conn.cursor()
# For each remap, update all keys and values
for old_id, new_id in self.remap_table.items():
print(f"\n Remapping {old_id[:16]}... -> {new_id[:16]}...")
# Find all keys containing the old ID
cursor.execute(
"SELECT key, value FROM cursorDiskKV WHERE key LIKE ? OR value LIKE ?",
(f'%{old_id}%', f'%{old_id}%')
)
rows = cursor.fetchall()
print(f" Found {len(rows)} keys to update")
for key, value in rows:
new_key = key.replace(old_id, new_id) if old_id in key else key
new_value = value
if value:
if isinstance(value, bytes):
try:
value_str = value.decode('utf-8')
if old_id in value_str:
new_value = value_str.replace(old_id, new_id).encode('utf-8')
except:
pass
elif isinstance(value, str) and old_id in value:
new_value = value.replace(old_id, new_id)
if not dry_run:
if new_key != key:
# Delete old key, insert new
cursor.execute("DELETE FROM cursorDiskKV WHERE key = ?", (key,))
cursor.execute(
"INSERT OR REPLACE INTO cursorDiskKV (key, value) VALUES (?, ?)",
(new_key, new_value)
)
else:
# Just update value
cursor.execute(
"UPDATE cursorDiskKV SET value = ? WHERE key = ?",
(new_value, key)
)
stats['keys_updated'] += 1
if key.startswith('composerData:'):
stats['composers_fixed'] += 1
if not dry_run:
conn.commit()
conn.close()
except sqlite3.DatabaseError as e:
stats['errors'].append(f"Database error: {e}")
except Exception as e:
stats['errors'].append(f"Error: {e}")
return stats
def main():
parser = argparse.ArgumentParser(
description='Find and rescue orphaned chats in Cursor databases',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
This tool detects chats that became "orphaned" when workspace IDs changed.
Common causes of workspace ID changes:
- git clone (new folder creation time)
- Moving/copying project folders
- Restoring from backup
- Changing folder names
Examples:
# Scan and report orphaned chats
python find_orphaned_chats.py --scan
# Preview fixes without making changes
python find_orphaned_chats.py --fix --dry-run
# Actually fix orphaned chats
python find_orphaned_chats.py --fix
"""
)
parser.add_argument(
'--scan',
action='store_true',
help='Scan for orphaned chats and report'
)
parser.add_argument(
'--fix',
action='store_true',
help='Fix orphaned chats by remapping workspace IDs'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Preview changes without modifying database'
)
parser.add_argument(
'--no-backup',
action='store_true',
help='Skip creating backup before fixing'
)
parser.add_argument(
'-v', '--verbose',
action='store_true',
help='Verbose output'
)
args = parser.parse_args()
if not args.scan and not args.fix:
args.scan = True # Default to scan
paths = get_cursor_paths()
print("=" * 70)
print("Orphaned Chat Finder")
print("=" * 70)
print(f"\nCursor User Dir: {paths['user']}")
print(f"Global Database: {paths['global_db']}")
print(f"Workspace Storage: {paths['workspace_storage']}")
finder = OrphanedChatFinder(paths)
# Load current workspaces
print("\n" + "-" * 70)
print("Loading current workspaces...")
print("-" * 70)
finder.load_current_workspaces()
# Scan global database
print("\n" + "-" * 70)
print("Scanning global database for workspace references...")
print("-" * 70)
finder.scan_global_database()
# Find orphaned workspaces
print("\n" + "-" * 70)
print("Identifying orphaned workspaces...")
print("-" * 70)
orphaned_ids = finder.find_orphaned_workspaces()
if not orphaned_ids:
print("\nNo orphaned workspaces found!")
return 0
# Analyze orphaned chats
print("\n" + "-" * 70)
print("Analyzing orphaned workspace data...")
print("-" * 70)
finder.analyze_orphaned_chats()
finder.build_remap_table_from_workspace_dbs()
# Report findings
print("\n" + "=" * 70)
print("ORPHANED WORKSPACES")
print("=" * 70)
remappable = []
not_remappable = []
for orphan_id, info in finder.orphaned_workspaces.items():
if 'can_remap_to' in info:
remappable.append((orphan_id, info))
else:
not_remappable.append((orphan_id, info))
if remappable:
print(f"\n### Can Be Remapped ({len(remappable)}) ###")
for orphan_id, info in remappable:
project = info.get('project', 'unknown')
target = info.get('can_remap_to', 'unknown')
keys = info.get('total_keys', 0)
print(f"\n Project: {project}")
print(f" Old ID: {orphan_id}")
print(f" New ID: {target}")
print(f" Keys to migrate: {keys}")
if not_remappable:
print(f"\n### Cannot Auto-Remap ({len(not_remappable)}) ###")
for orphan_id, info in not_remappable[:10]:
keys = info.get('total_keys', 0)
project = info.get('project', 'unknown')
print(f" {orphan_id[:16]}... - {keys} keys (project: {project})")
if len(not_remappable) > 10:
print(f" ... and {len(not_remappable) - 10} more")
# Find orphaned composers
print("\n" + "-" * 70)
print("Scanning for orphaned composers...")
print("-" * 70)
orphaned_composers = finder.scan_for_orphaned_composers()
if orphaned_composers:
print(f"\nFound {len(orphaned_composers)} orphaned composers:")
for comp in sorted(orphaned_composers, key=lambda x: x['created_at'], reverse=True)[:20]:
from datetime import datetime
date_str = datetime.fromtimestamp(comp['created_at']/1000).strftime('%Y-%m-%d') if comp['created_at'] else 'unknown'
print(f" [{date_str}] {comp['name'][:50]} ({comp['bubble_count']} bubbles)")
if len(orphaned_composers) > 20:
print(f" ... and {len(orphaned_composers) - 20} more")
# Fix if requested
if args.fix:
if not finder.remap_table:
print("\nNo remappings available - nothing to fix automatically.")
print("The orphaned workspaces may need manual investigation.")
return 0
print("\n" + "=" * 70)
print("FIXING ORPHANED CHATS")
print("=" * 70)
if args.dry_run:
print("\n[DRY RUN MODE]")
else:
print("\n[LIVE MODE]")
if not args.no_backup:
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = paths['global_db'].parent / f"state.vscdb.backup-orphan-fix-{timestamp}"
shutil.copy2(paths['global_db'], backup_path)
print(f"Backup created: {backup_path}")
stats = finder.fix_orphaned_chats(dry_run=args.dry_run)
print(f"\nResults:")
print(f" Keys updated: {stats['keys_updated']}")
print(f" Composers fixed: {stats['composers_fixed']}")
if stats['errors']:
print(f" Errors: {stats['errors']}")
if args.dry_run:
print("\n[DRY RUN] No changes were made.")
else:
print("\nNext steps:")
print("1. Close Cursor IDE completely")
print("2. Delete WAL files:")
print(f" Remove-Item '{paths['global_db'].parent / 'state.vscdb-wal'}' -Force -ErrorAction SilentlyContinue")
print(f" Remove-Item '{paths['global_db'].parent / 'state.vscdb-shm'}' -Force -ErrorAction SilentlyContinue")
print("3. Start Cursor IDE")
print("4. Check if previously missing chats are now visible")
return 0
if __name__ == '__main__':
sys.exit(main())
[And those lost 33 agents are only from one of my three PCs I work from!]