thread-archive 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- thread_archive/__init__.py +34 -0
- thread_archive/_api.py +2235 -0
- thread_archive/_config.py +126 -0
- thread_archive/_importers/__init__.py +81 -0
- thread_archive/_importers/_continuation.py +136 -0
- thread_archive/_importers/_cursor.py +103 -0
- thread_archive/_importers/_events.py +244 -0
- thread_archive/_importers/_line_stream.py +193 -0
- thread_archive/_importers/_read.py +82 -0
- thread_archive/_importers/_result.py +17 -0
- thread_archive/_importers/_sidecar.py +113 -0
- thread_archive/_importers/_state.py +240 -0
- thread_archive/_importers/_titles.py +179 -0
- thread_archive/_importers/antigravity.py +254 -0
- thread_archive/_importers/claude_code.py +293 -0
- thread_archive/_importers/claude_science.py +330 -0
- thread_archive/_importers/cloth.py +20 -0
- thread_archive/_importers/codex.py +324 -0
- thread_archive/_importers/cowork.py +107 -0
- thread_archive/_importers/cursor.py +432 -0
- thread_archive/_importers/exports.py +389 -0
- thread_archive/_importers/grok.py +600 -0
- thread_archive/_importers/opencode.py +538 -0
- thread_archive/_knowledge/__init__.py +67 -0
- thread_archive/_knowledge/_claims.py +116 -0
- thread_archive/_knowledge/_community.py +75 -0
- thread_archive/_knowledge/graph.py +208 -0
- thread_archive/_knowledge/materialize.py +212 -0
- thread_archive/_knowledge/write.py +438 -0
- thread_archive/_launchd.py +167 -0
- thread_archive/_mcp/__init__.py +1 -0
- thread_archive/_mcp/librarian.py +151 -0
- thread_archive/_mcp/server.py +307 -0
- thread_archive/_retrieval/__init__.py +280 -0
- thread_archive/_retrieval/_classify.py +80 -0
- thread_archive/_retrieval/_codex.py +157 -0
- thread_archive/_retrieval/_context.py +123 -0
- thread_archive/_retrieval/_extract.py +184 -0
- thread_archive/_retrieval/embed.py +186 -0
- thread_archive/_retrieval/format.py +149 -0
- thread_archive/_retrieval/fts.py +538 -0
- thread_archive/_retrieval/rank.py +228 -0
- thread_archive/_retrieval/read.py +908 -0
- thread_archive/_retrieval/rerank.py +143 -0
- thread_archive/_retrieval/vectors.py +611 -0
- thread_archive/_scripts/__init__.py +1 -0
- thread_archive/_scripts/backfill_recompute.py +328 -0
- thread_archive/_scripts/backfill_reconcile.py +296 -0
- thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
- thread_archive/_scripts/recover_dropped_events.py +190 -0
- thread_archive/_scripts/repair_grok_tool_names.py +118 -0
- thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
- thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
- thread_archive/_setup/__init__.py +12 -0
- thread_archive/_setup/clients.py +89 -0
- thread_archive/_setup/wizard.py +474 -0
- thread_archive/_store/__init__.py +45 -0
- thread_archive/_store/_base.py +173 -0
- thread_archive/_store/_defaults.py +57 -0
- thread_archive/_store/_types.py +38 -0
- thread_archive/_store/models.py +370 -0
- thread_archive/_store/schema.py +84 -0
- thread_archive/_thread_import/__init__.py +40 -0
- thread_archive/_thread_import/api.py +78 -0
- thread_archive/_thread_import/event_builder.py +810 -0
- thread_archive/_thread_import/exporters/__init__.py +9 -0
- thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
- thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
- thread_archive/_thread_import/exporters/cursor.py +582 -0
- thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
- thread_archive/_thread_import/parsers/__init__.py +191 -0
- thread_archive/_thread_import/parsers/base.py +698 -0
- thread_archive/_thread_import/parsers/chatgpt.py +722 -0
- thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
- thread_archive/_thread_import/parsers/claude.py +443 -0
- thread_archive/_thread_import/parsers/claude_code.py +1035 -0
- thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
- thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
- thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
- thread_archive/_thread_import/parsers/config/__init__.py +26 -0
- thread_archive/_thread_import/parsers/config/base.py +220 -0
- thread_archive/_thread_import/parsers/cursor.py +538 -0
- thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
- thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
- thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
- thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
- thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
- thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
- thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
- thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
- thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
- thread_archive/_thread_import/parsers/types/__init__.py +56 -0
- thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
- thread_archive/_thread_import/parsers/types/claude.py +120 -0
- thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
- thread_archive/_thread_import/parsers/types/cursor.py +109 -0
- thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
- thread_archive/_thread_import/parsers/validators/base.py +168 -0
- thread_archive/_thread_import/parsers/validators/content.py +80 -0
- thread_archive/_thread_import/parsers/validators/referential.py +57 -0
- thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
- thread_archive/_thread_import/parsers/validators/types.py +87 -0
- thread_archive/_thread_import/schemas/__init__.py +231 -0
- thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
- thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
- thread_archive/_thread_import/schemas/claude/v1.json +188 -0
- thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
- thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
- thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
- thread_archive/_thread_import/timestamps.py +57 -0
- thread_archive/_thread_import/tool_names.py +48 -0
- thread_archive/_truth/__init__.py +40 -0
- thread_archive/_truth/jsonl_log.py +2340 -0
- thread_archive/_truth/repair.py +322 -0
- thread_archive/_watcher/__init__.py +57 -0
- thread_archive/_watcher/base.py +65 -0
- thread_archive/_watcher/daemon.py +289 -0
- thread_archive/_watcher/export_drop.py +203 -0
- thread_archive/_watcher/exthost.py +316 -0
- thread_archive/_watcher/lazy.py +117 -0
- thread_archive/_watcher/sources.py +616 -0
- thread_archive/_web/__init__.py +15 -0
- thread_archive/_web/server.py +299 -0
- thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
- thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
- thread_archive/_web/static/index.html +13 -0
- thread_archive/cli.py +746 -0
- thread_archive-0.0.2.dist-info/METADATA +296 -0
- thread_archive-0.0.2.dist-info/RECORD +132 -0
- thread_archive-0.0.2.dist-info/WHEEL +4 -0
- thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
- thread_archive-0.0.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cursor IDE Chat Exporter
|
|
3
|
+
|
|
4
|
+
Extracts chat history and plans from Cursor IDE's local storage.
|
|
5
|
+
|
|
6
|
+
USAGE (CLI):
|
|
7
|
+
python -m packages.chat_import.exporters.cursor --list
|
|
8
|
+
python -m packages.chat_import.exporters.cursor --workspace HASH
|
|
9
|
+
python -m packages.chat_import.exporters.cursor --output chats.zip
|
|
10
|
+
|
|
11
|
+
USAGE (Library):
|
|
12
|
+
from packages.chat_import.exporters.cursor import CursorExporter
|
|
13
|
+
|
|
14
|
+
exporter = CursorExporter()
|
|
15
|
+
workspaces = exporter.list_workspaces()
|
|
16
|
+
result = exporter.export_all(output_path="chats.zip")
|
|
17
|
+
|
|
18
|
+
STORAGE LOCATIONS:
|
|
19
|
+
Chats:
|
|
20
|
+
- macOS: ~/Library/Application Support/Cursor/User/workspaceStorage/
|
|
21
|
+
- Linux: ~/.config/Cursor/User/workspaceStorage/
|
|
22
|
+
- Windows: %APPDATA%\\Cursor\\User\\workspaceStorage\\
|
|
23
|
+
Plans:
|
|
24
|
+
- All platforms: ~/.cursor/plans/
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import platform
|
|
30
|
+
import tempfile
|
|
31
|
+
import zipfile
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from datetime import datetime
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
36
|
+
|
|
37
|
+
from ._cursor_kv_mixin import CursorKVMixin
|
|
38
|
+
from ._cursor_parse_mixin import CursorParseMixin
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ExportResult:
|
|
43
|
+
"""Result of an export operation."""
|
|
44
|
+
output_path: Path
|
|
45
|
+
conversation_count: int
|
|
46
|
+
message_count: int
|
|
47
|
+
image_count: int
|
|
48
|
+
plan_count: int = 0
|
|
49
|
+
workspaces: List[Dict[str, Any]] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CursorExporter(CursorKVMixin, CursorParseMixin):
|
|
53
|
+
"""Exports chat data from Cursor IDE's local storage.
|
|
54
|
+
|
|
55
|
+
Database reading + KV→conversation building live in ``CursorKVMixin``;
|
|
56
|
+
loose composer/chat-blob parsing lives in ``CursorParseMixin``. Both are
|
|
57
|
+
assembled here so every method stays a ``CursorExporter`` attribute (callers
|
|
58
|
+
and tests reach them as ``exporter._read_cursor_disk_kv`` etc.).
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, storage_path: Optional[Path] = None):
|
|
62
|
+
"""
|
|
63
|
+
Initialize the exporter.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
storage_path: Custom storage path, or None to auto-detect
|
|
67
|
+
"""
|
|
68
|
+
self.storage_path = storage_path or self._get_storage_path()
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def _get_storage_path() -> Optional[Path]:
|
|
72
|
+
"""Get the Cursor workspace storage path for the current OS."""
|
|
73
|
+
system = platform.system()
|
|
74
|
+
|
|
75
|
+
if system == "Darwin": # macOS
|
|
76
|
+
path = Path.home() / "Library" / "Application Support" / "Cursor" / "User" / "workspaceStorage"
|
|
77
|
+
elif system == "Linux":
|
|
78
|
+
path = Path.home() / ".config" / "Cursor" / "User" / "workspaceStorage"
|
|
79
|
+
elif system == "Windows":
|
|
80
|
+
appdata = os.environ.get("APPDATA", "")
|
|
81
|
+
if appdata:
|
|
82
|
+
path = Path(appdata) / "Cursor" / "User" / "workspaceStorage"
|
|
83
|
+
else:
|
|
84
|
+
return None
|
|
85
|
+
else:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
return path if path.exists() else None
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _get_global_storage_path() -> Optional[Path]:
|
|
92
|
+
"""Get the Cursor global storage path."""
|
|
93
|
+
system = platform.system()
|
|
94
|
+
|
|
95
|
+
if system == "Darwin":
|
|
96
|
+
path = Path.home() / "Library" / "Application Support" / "Cursor" / "User" / "globalStorage"
|
|
97
|
+
elif system == "Linux":
|
|
98
|
+
path = Path.home() / ".config" / "Cursor" / "User" / "globalStorage"
|
|
99
|
+
elif system == "Windows":
|
|
100
|
+
appdata = os.environ.get("APPDATA", "")
|
|
101
|
+
if appdata:
|
|
102
|
+
path = Path(appdata) / "Cursor" / "User" / "globalStorage"
|
|
103
|
+
else:
|
|
104
|
+
return None
|
|
105
|
+
else:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
return path if path.exists() else None
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _get_plans_path() -> Optional[Path]:
|
|
112
|
+
"""Get the Cursor plans storage path (~/.cursor/plans/)."""
|
|
113
|
+
system = platform.system()
|
|
114
|
+
|
|
115
|
+
if system == "Darwin":
|
|
116
|
+
path = Path.home() / ".cursor" / "plans"
|
|
117
|
+
elif system == "Linux":
|
|
118
|
+
path = Path.home() / ".cursor" / "plans"
|
|
119
|
+
elif system == "Windows":
|
|
120
|
+
path = Path.home() / ".cursor" / "plans"
|
|
121
|
+
else:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
return path if path.exists() else None
|
|
125
|
+
|
|
126
|
+
def _collect_plans(self) -> List[Dict[str, Any]]:
|
|
127
|
+
"""Collect all plan files from ~/.cursor/plans/."""
|
|
128
|
+
plans = []
|
|
129
|
+
plans_path = self._get_plans_path()
|
|
130
|
+
|
|
131
|
+
if not plans_path or not plans_path.exists():
|
|
132
|
+
return plans
|
|
133
|
+
|
|
134
|
+
for plan_file in plans_path.glob("*.plan.md"):
|
|
135
|
+
try:
|
|
136
|
+
content = plan_file.read_text(encoding="utf-8")
|
|
137
|
+
stat = plan_file.stat()
|
|
138
|
+
|
|
139
|
+
# Parse filename: name_hash.plan.md
|
|
140
|
+
stem = plan_file.stem.replace(".plan", "")
|
|
141
|
+
parts = stem.rsplit("_", 1)
|
|
142
|
+
if len(parts) == 2:
|
|
143
|
+
name = parts[0].replace("_", " ")
|
|
144
|
+
plan_hash = parts[1]
|
|
145
|
+
else:
|
|
146
|
+
name = stem
|
|
147
|
+
plan_hash = None
|
|
148
|
+
|
|
149
|
+
plans.append({
|
|
150
|
+
"filename": plan_file.name,
|
|
151
|
+
"name": name,
|
|
152
|
+
"hash": plan_hash,
|
|
153
|
+
"content": content,
|
|
154
|
+
"created_at": datetime.fromtimestamp(stat.st_ctime).isoformat(),
|
|
155
|
+
"modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
156
|
+
"size_bytes": stat.st_size,
|
|
157
|
+
})
|
|
158
|
+
except (IOError, OSError):
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
# Sort by modified time, most recent first
|
|
162
|
+
plans.sort(key=lambda p: p.get("modified_at", ""), reverse=True)
|
|
163
|
+
return plans
|
|
164
|
+
|
|
165
|
+
def _find_workspace_info(self, workspace_dir: Path) -> Dict[str, Any]:
|
|
166
|
+
"""Extract workspace info from workspace.json if available."""
|
|
167
|
+
workspace_json = workspace_dir / "workspace.json"
|
|
168
|
+
info = {
|
|
169
|
+
"hash": workspace_dir.name,
|
|
170
|
+
"path": str(workspace_dir),
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if workspace_json.exists():
|
|
174
|
+
try:
|
|
175
|
+
with open(workspace_json) as f:
|
|
176
|
+
data = json.load(f)
|
|
177
|
+
folder = data.get("folder", "")
|
|
178
|
+
if folder:
|
|
179
|
+
if folder.startswith("file://"):
|
|
180
|
+
folder = folder[7:]
|
|
181
|
+
info["workspace_uri"] = folder
|
|
182
|
+
info["workspace_name"] = Path(folder).name
|
|
183
|
+
except (json.JSONDecodeError, IOError):
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
return info
|
|
187
|
+
|
|
188
|
+
def _collect_workspace_images(self) -> Dict[str, Path]:
|
|
189
|
+
"""Collect all images from workspace storage folders."""
|
|
190
|
+
images = {}
|
|
191
|
+
|
|
192
|
+
if not self.storage_path:
|
|
193
|
+
return images
|
|
194
|
+
|
|
195
|
+
for workspace_dir in self.storage_path.iterdir():
|
|
196
|
+
if not workspace_dir.is_dir():
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
images_dir = workspace_dir / "images"
|
|
200
|
+
if not images_dir.exists():
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
for img_file in images_dir.iterdir():
|
|
204
|
+
if img_file.suffix.lower() in ('.png', '.jpg', '.jpeg', '.gif', '.webp'):
|
|
205
|
+
name = img_file.stem
|
|
206
|
+
parts = name.split('-')
|
|
207
|
+
if len(parts) >= 5:
|
|
208
|
+
potential_uuid = '-'.join(parts[-5:])
|
|
209
|
+
if len(potential_uuid) == 36:
|
|
210
|
+
images[potential_uuid] = img_file
|
|
211
|
+
continue
|
|
212
|
+
images[name] = img_file
|
|
213
|
+
|
|
214
|
+
return images
|
|
215
|
+
|
|
216
|
+
def _export_workspace(self, workspace_info: Dict[str, Any]) -> Dict[str, Any]:
|
|
217
|
+
"""Export chat data from a single workspace."""
|
|
218
|
+
result = {
|
|
219
|
+
"workspace": workspace_info,
|
|
220
|
+
"conversations": [],
|
|
221
|
+
"raw_chat_data": {},
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
state_db = Path(workspace_info.get("state_db", ""))
|
|
225
|
+
if state_db.exists():
|
|
226
|
+
db_data = self._read_sqlite_db(state_db)
|
|
227
|
+
chat_data = self._extract_chat_data(db_data)
|
|
228
|
+
|
|
229
|
+
result["raw_chat_data"]["state"] = chat_data
|
|
230
|
+
|
|
231
|
+
for key, value in chat_data.items():
|
|
232
|
+
conversations = self._parse_composer_data(value)
|
|
233
|
+
result["conversations"].extend(conversations)
|
|
234
|
+
|
|
235
|
+
for db_path in workspace_info.get("other_dbs", []):
|
|
236
|
+
db_file = Path(db_path)
|
|
237
|
+
if db_file.exists():
|
|
238
|
+
db_data = self._read_sqlite_db(db_file)
|
|
239
|
+
chat_data = self._extract_chat_data(db_data)
|
|
240
|
+
|
|
241
|
+
result["raw_chat_data"][db_file.stem] = chat_data
|
|
242
|
+
|
|
243
|
+
for key, value in chat_data.items():
|
|
244
|
+
conversations = self._parse_composer_data(value)
|
|
245
|
+
result["conversations"].extend(conversations)
|
|
246
|
+
|
|
247
|
+
return result
|
|
248
|
+
|
|
249
|
+
def list_workspaces(self) -> List[Dict[str, Any]]:
|
|
250
|
+
"""List all available Cursor workspaces with chat data."""
|
|
251
|
+
workspaces = []
|
|
252
|
+
|
|
253
|
+
if not self.storage_path or not self.storage_path.exists():
|
|
254
|
+
return workspaces
|
|
255
|
+
|
|
256
|
+
for workspace_dir in self.storage_path.iterdir():
|
|
257
|
+
if not workspace_dir.is_dir():
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
state_db = workspace_dir / "state.vscdb"
|
|
261
|
+
if not state_db.exists():
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
info = self._find_workspace_info(workspace_dir)
|
|
265
|
+
info["state_db"] = str(state_db)
|
|
266
|
+
|
|
267
|
+
for db_file in workspace_dir.glob("*.vscdb"):
|
|
268
|
+
if db_file.name != "state.vscdb":
|
|
269
|
+
info.setdefault("other_dbs", []).append(str(db_file))
|
|
270
|
+
|
|
271
|
+
images_dir = workspace_dir / "images"
|
|
272
|
+
if images_dir.exists():
|
|
273
|
+
info["images_dir"] = str(images_dir)
|
|
274
|
+
info["image_count"] = len(list(images_dir.glob("*")))
|
|
275
|
+
|
|
276
|
+
workspaces.append(info)
|
|
277
|
+
|
|
278
|
+
return workspaces
|
|
279
|
+
|
|
280
|
+
def export_all(
|
|
281
|
+
self,
|
|
282
|
+
output_path: Optional[Path] = None,
|
|
283
|
+
workspace_filter: Optional[str] = None,
|
|
284
|
+
include_raw: bool = True,
|
|
285
|
+
) -> ExportResult:
|
|
286
|
+
"""
|
|
287
|
+
Export chat data from all (or filtered) workspaces.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
output_path: Output ZIP file path (auto-generated if None)
|
|
291
|
+
workspace_filter: Partial hash to filter workspaces
|
|
292
|
+
include_raw: Include raw database data in export
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
ExportResult with export details
|
|
296
|
+
"""
|
|
297
|
+
if not self.storage_path:
|
|
298
|
+
raise RuntimeError("Could not find Cursor storage path")
|
|
299
|
+
|
|
300
|
+
all_conversations = self._collect_kv_conversations()
|
|
301
|
+
workspaces, workspace_data = self._gather_workspace_data(workspace_filter, include_raw)
|
|
302
|
+
|
|
303
|
+
# Deduplicate conversations by ID and sort (most recent first)
|
|
304
|
+
unique_conversations = self._dedupe_and_sort_conversations(all_conversations)
|
|
305
|
+
|
|
306
|
+
images = self._collect_workspace_images()
|
|
307
|
+
plans = self._collect_plans()
|
|
308
|
+
|
|
309
|
+
output_path = self._resolve_output_path(output_path)
|
|
310
|
+
total_messages = sum(len(c.get("messages", [])) for c in unique_conversations)
|
|
311
|
+
|
|
312
|
+
self._write_export_zip(
|
|
313
|
+
output_path,
|
|
314
|
+
unique_conversations=unique_conversations,
|
|
315
|
+
workspaces=workspaces,
|
|
316
|
+
workspace_data=workspace_data,
|
|
317
|
+
images=images,
|
|
318
|
+
plans=plans,
|
|
319
|
+
total_messages=total_messages,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
return ExportResult(
|
|
323
|
+
output_path=output_path,
|
|
324
|
+
conversation_count=len(unique_conversations),
|
|
325
|
+
message_count=total_messages,
|
|
326
|
+
image_count=len(images),
|
|
327
|
+
plan_count=len(plans),
|
|
328
|
+
workspaces=workspace_data,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
def _collect_kv_conversations(self) -> List[Dict[str, Any]]:
|
|
332
|
+
"""Read conversations from globalStorage's cursorDiskKV (primary source)."""
|
|
333
|
+
assert self.storage_path is not None # guaranteed by export_all
|
|
334
|
+
# Read from globalStorage's cursorDiskKV table (primary source)
|
|
335
|
+
global_storage_db = self.storage_path.parent / "globalStorage" / "state.vscdb"
|
|
336
|
+
if not global_storage_db.exists():
|
|
337
|
+
return []
|
|
338
|
+
|
|
339
|
+
composers, bubbles = self._read_cursor_disk_kv(global_storage_db)
|
|
340
|
+
if not composers:
|
|
341
|
+
return []
|
|
342
|
+
|
|
343
|
+
return self._build_conversations_from_kv(composers, bubbles)
|
|
344
|
+
|
|
345
|
+
def _gather_workspace_data(
|
|
346
|
+
self,
|
|
347
|
+
workspace_filter: Optional[str],
|
|
348
|
+
include_raw: bool,
|
|
349
|
+
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
350
|
+
"""List (and filter) workspaces and summarize each one's export.
|
|
351
|
+
|
|
352
|
+
Returns the (filtered) workspace list and the per-workspace summary
|
|
353
|
+
records that feed both metadata.json and the ExportResult.
|
|
354
|
+
"""
|
|
355
|
+
workspaces = self.list_workspaces()
|
|
356
|
+
if workspace_filter:
|
|
357
|
+
workspaces = [ws for ws in workspaces if workspace_filter in ws.get("hash", "")]
|
|
358
|
+
|
|
359
|
+
workspace_data = []
|
|
360
|
+
for ws in workspaces:
|
|
361
|
+
result = self._export_workspace(ws)
|
|
362
|
+
workspace_data.append({
|
|
363
|
+
"info": ws,
|
|
364
|
+
"conversation_count": len(result["conversations"]),
|
|
365
|
+
"raw_data": result["raw_chat_data"] if include_raw else None,
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
return workspaces, workspace_data
|
|
369
|
+
|
|
370
|
+
@staticmethod
|
|
371
|
+
def _resolve_output_path(output_path: Optional[Path]) -> Path:
|
|
372
|
+
"""Default the output path to a timestamped file under exports/."""
|
|
373
|
+
if output_path is None:
|
|
374
|
+
timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
|
|
375
|
+
exports_dir = Path("exports")
|
|
376
|
+
exports_dir.mkdir(exist_ok=True)
|
|
377
|
+
return exports_dir / f"cursor-chats-{timestamp}.zip"
|
|
378
|
+
return Path(output_path)
|
|
379
|
+
|
|
380
|
+
def _write_export_zip(
|
|
381
|
+
self,
|
|
382
|
+
output_path: Path,
|
|
383
|
+
*,
|
|
384
|
+
unique_conversations: List[Dict[str, Any]],
|
|
385
|
+
workspaces: List[Dict[str, Any]],
|
|
386
|
+
workspace_data: List[Dict[str, Any]],
|
|
387
|
+
images: Dict[str, Path],
|
|
388
|
+
plans: List[Dict[str, Any]],
|
|
389
|
+
total_messages: int,
|
|
390
|
+
) -> None:
|
|
391
|
+
"""Write the export ZIP (chats/metadata/plans JSON + image & plan files)."""
|
|
392
|
+
# Create ZIP archive
|
|
393
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
394
|
+
temp_path = Path(temp_dir)
|
|
395
|
+
|
|
396
|
+
conversations_file = temp_path / "cursor_chats.json"
|
|
397
|
+
with open(conversations_file, "w") as f:
|
|
398
|
+
json.dump({
|
|
399
|
+
"provider": "cursor",
|
|
400
|
+
"export_version": "2.0",
|
|
401
|
+
"export_date": datetime.now().isoformat(),
|
|
402
|
+
"conversations": unique_conversations,
|
|
403
|
+
}, f, indent=2, default=str)
|
|
404
|
+
|
|
405
|
+
metadata_file = temp_path / "metadata.json"
|
|
406
|
+
with open(metadata_file, "w") as f:
|
|
407
|
+
json.dump({
|
|
408
|
+
"export_date": datetime.now().isoformat(),
|
|
409
|
+
"platform": platform.system(),
|
|
410
|
+
"workspace_count": len(workspaces),
|
|
411
|
+
"conversation_count": len(unique_conversations),
|
|
412
|
+
"message_count": total_messages,
|
|
413
|
+
"image_count": len(images),
|
|
414
|
+
"plan_count": len(plans),
|
|
415
|
+
"workspaces": workspace_data,
|
|
416
|
+
}, f, indent=2, default=str)
|
|
417
|
+
|
|
418
|
+
# Write plans JSON
|
|
419
|
+
plans_file = temp_path / "cursor_plans.json"
|
|
420
|
+
with open(plans_file, "w") as f:
|
|
421
|
+
json.dump({
|
|
422
|
+
"provider": "cursor",
|
|
423
|
+
"export_version": "1.0",
|
|
424
|
+
"export_date": datetime.now().isoformat(),
|
|
425
|
+
"plans": plans,
|
|
426
|
+
}, f, indent=2, default=str)
|
|
427
|
+
|
|
428
|
+
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
429
|
+
zf.write(conversations_file, "cursor_chats.json")
|
|
430
|
+
zf.write(metadata_file, "metadata.json")
|
|
431
|
+
zf.write(plans_file, "cursor_plans.json")
|
|
432
|
+
|
|
433
|
+
for img_id, img_path in images.items():
|
|
434
|
+
zf.write(img_path, f"images/{img_path.name}")
|
|
435
|
+
|
|
436
|
+
# Also include raw plan markdown files
|
|
437
|
+
for plan in plans:
|
|
438
|
+
plans_path = self._get_plans_path()
|
|
439
|
+
if plans_path:
|
|
440
|
+
plan_file = plans_path / plan["filename"]
|
|
441
|
+
if plan_file.exists():
|
|
442
|
+
zf.write(plan_file, f"plans/{plan['filename']}")
|
|
443
|
+
|
|
444
|
+
@staticmethod
|
|
445
|
+
def _dedupe_and_sort_conversations(
|
|
446
|
+
all_conversations: List[Dict[str, Any]]
|
|
447
|
+
) -> List[Dict[str, Any]]:
|
|
448
|
+
"""Deduplicate conversations by id and sort most-recent-first.
|
|
449
|
+
|
|
450
|
+
Keeps the first occurrence of each id; conversations without an id are
|
|
451
|
+
all kept. Sort key is ``updated_at`` then ``created_at`` then 0, reversed.
|
|
452
|
+
"""
|
|
453
|
+
# Deduplicate conversations by ID
|
|
454
|
+
seen_ids = set()
|
|
455
|
+
unique_conversations = []
|
|
456
|
+
for conv in all_conversations:
|
|
457
|
+
conv_id = conv.get("id")
|
|
458
|
+
if conv_id and conv_id not in seen_ids:
|
|
459
|
+
seen_ids.add(conv_id)
|
|
460
|
+
unique_conversations.append(conv)
|
|
461
|
+
elif not conv_id:
|
|
462
|
+
unique_conversations.append(conv)
|
|
463
|
+
|
|
464
|
+
# Sort by updated_at (most recent first)
|
|
465
|
+
unique_conversations.sort(
|
|
466
|
+
key=lambda c: c.get("updated_at") or c.get("created_at") or 0,
|
|
467
|
+
reverse=True
|
|
468
|
+
)
|
|
469
|
+
return unique_conversations
|
|
470
|
+
|
|
471
|
+
def inspect_db(self, db_path: Path) -> Dict[str, Any]:
|
|
472
|
+
"""
|
|
473
|
+
Inspect a Cursor database and return available keys.
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
Dict with 'chat_keys' and 'other_keys' lists
|
|
477
|
+
"""
|
|
478
|
+
if not db_path.exists():
|
|
479
|
+
raise FileNotFoundError(f"Database not found: {db_path}")
|
|
480
|
+
|
|
481
|
+
data = self._read_sqlite_db(db_path)
|
|
482
|
+
|
|
483
|
+
chat_patterns = ["chat", "composer", "conversation", "ai", "cursor"]
|
|
484
|
+
chat_keys = []
|
|
485
|
+
other_keys = []
|
|
486
|
+
|
|
487
|
+
for key in sorted(data.keys()):
|
|
488
|
+
key_lower = key.lower()
|
|
489
|
+
is_chat = any(p in key_lower for p in chat_patterns)
|
|
490
|
+
if is_chat:
|
|
491
|
+
chat_keys.append(key)
|
|
492
|
+
else:
|
|
493
|
+
other_keys.append(key)
|
|
494
|
+
|
|
495
|
+
return {
|
|
496
|
+
"total_keys": len(data),
|
|
497
|
+
"chat_keys": chat_keys,
|
|
498
|
+
"other_keys": other_keys,
|
|
499
|
+
"data": data,
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def main():
|
|
504
|
+
"""CLI entry point."""
|
|
505
|
+
import argparse
|
|
506
|
+
import sys
|
|
507
|
+
|
|
508
|
+
parser = argparse.ArgumentParser(
|
|
509
|
+
description="Export Cursor IDE chat history",
|
|
510
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
511
|
+
epilog="""
|
|
512
|
+
Examples:
|
|
513
|
+
python -m packages.chat_import.exporters.cursor --list
|
|
514
|
+
python -m packages.chat_import.exporters.cursor --workspace abc123
|
|
515
|
+
python -m packages.chat_import.exporters.cursor --output chats.zip
|
|
516
|
+
""",
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
parser.add_argument("--list", "-l", action="store_true", help="List available workspaces")
|
|
520
|
+
parser.add_argument("--workspace", "-w", help="Export specific workspace (partial hash match)")
|
|
521
|
+
parser.add_argument("--output", "-o", type=Path, help="Output file path")
|
|
522
|
+
parser.add_argument("--inspect", "-i", type=Path, help="Inspect a specific database file")
|
|
523
|
+
parser.add_argument("--no-raw", action="store_true", help="Exclude raw database data")
|
|
524
|
+
parser.add_argument("--storage-path", "-s", type=Path, help="Custom Cursor storage path")
|
|
525
|
+
|
|
526
|
+
args = parser.parse_args()
|
|
527
|
+
|
|
528
|
+
exporter = CursorExporter(storage_path=args.storage_path)
|
|
529
|
+
|
|
530
|
+
if args.inspect:
|
|
531
|
+
result = exporter.inspect_db(args.inspect)
|
|
532
|
+
print(f"\nFound {result['total_keys']} keys:\n")
|
|
533
|
+
|
|
534
|
+
if result['chat_keys']:
|
|
535
|
+
print("Chat-related keys:")
|
|
536
|
+
for key in result['chat_keys']:
|
|
537
|
+
print(f" - {key}")
|
|
538
|
+
|
|
539
|
+
print(f"\nOther keys ({len(result['other_keys'])} total):")
|
|
540
|
+
for key in result['other_keys'][:20]:
|
|
541
|
+
print(f" - {key}")
|
|
542
|
+
if len(result['other_keys']) > 20:
|
|
543
|
+
print(f" ... and {len(result['other_keys']) - 20} more")
|
|
544
|
+
return
|
|
545
|
+
|
|
546
|
+
if not exporter.storage_path:
|
|
547
|
+
print("Could not find Cursor storage path.", file=sys.stderr)
|
|
548
|
+
sys.exit(1)
|
|
549
|
+
|
|
550
|
+
print(f"Cursor storage: {exporter.storage_path}")
|
|
551
|
+
|
|
552
|
+
if args.list:
|
|
553
|
+
workspaces = exporter.list_workspaces()
|
|
554
|
+
if not workspaces:
|
|
555
|
+
print("No Cursor workspaces found.")
|
|
556
|
+
return
|
|
557
|
+
|
|
558
|
+
print(f"\nFound {len(workspaces)} workspace(s):\n")
|
|
559
|
+
print(f"{'Hash':<20} {'Workspace Name':<40} {'Path'}")
|
|
560
|
+
print("-" * 100)
|
|
561
|
+
|
|
562
|
+
for ws in workspaces:
|
|
563
|
+
ws_hash = ws.get("hash", "")[:18]
|
|
564
|
+
ws_name = ws.get("workspace_name", "Unknown")[:38]
|
|
565
|
+
ws_path = ws.get("workspace_uri", "Unknown path")
|
|
566
|
+
print(f"{ws_hash:<20} {ws_name:<40} {ws_path}")
|
|
567
|
+
return
|
|
568
|
+
|
|
569
|
+
result = exporter.export_all(
|
|
570
|
+
output_path=args.output,
|
|
571
|
+
workspace_filter=args.workspace,
|
|
572
|
+
include_raw=not args.no_raw,
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
print(f"\nExported {result.conversation_count} conversations to {result.output_path}")
|
|
576
|
+
print(f"Total messages: {result.message_count}")
|
|
577
|
+
print(f"Images: {result.image_count}")
|
|
578
|
+
print(f"Plans: {result.plan_count}")
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
if __name__ == "__main__":
|
|
582
|
+
main()
|