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.
Files changed (132) hide show
  1. thread_archive/__init__.py +34 -0
  2. thread_archive/_api.py +2235 -0
  3. thread_archive/_config.py +126 -0
  4. thread_archive/_importers/__init__.py +81 -0
  5. thread_archive/_importers/_continuation.py +136 -0
  6. thread_archive/_importers/_cursor.py +103 -0
  7. thread_archive/_importers/_events.py +244 -0
  8. thread_archive/_importers/_line_stream.py +193 -0
  9. thread_archive/_importers/_read.py +82 -0
  10. thread_archive/_importers/_result.py +17 -0
  11. thread_archive/_importers/_sidecar.py +113 -0
  12. thread_archive/_importers/_state.py +240 -0
  13. thread_archive/_importers/_titles.py +179 -0
  14. thread_archive/_importers/antigravity.py +254 -0
  15. thread_archive/_importers/claude_code.py +293 -0
  16. thread_archive/_importers/claude_science.py +330 -0
  17. thread_archive/_importers/cloth.py +20 -0
  18. thread_archive/_importers/codex.py +324 -0
  19. thread_archive/_importers/cowork.py +107 -0
  20. thread_archive/_importers/cursor.py +432 -0
  21. thread_archive/_importers/exports.py +389 -0
  22. thread_archive/_importers/grok.py +600 -0
  23. thread_archive/_importers/opencode.py +538 -0
  24. thread_archive/_knowledge/__init__.py +67 -0
  25. thread_archive/_knowledge/_claims.py +116 -0
  26. thread_archive/_knowledge/_community.py +75 -0
  27. thread_archive/_knowledge/graph.py +208 -0
  28. thread_archive/_knowledge/materialize.py +212 -0
  29. thread_archive/_knowledge/write.py +438 -0
  30. thread_archive/_launchd.py +167 -0
  31. thread_archive/_mcp/__init__.py +1 -0
  32. thread_archive/_mcp/librarian.py +151 -0
  33. thread_archive/_mcp/server.py +307 -0
  34. thread_archive/_retrieval/__init__.py +280 -0
  35. thread_archive/_retrieval/_classify.py +80 -0
  36. thread_archive/_retrieval/_codex.py +157 -0
  37. thread_archive/_retrieval/_context.py +123 -0
  38. thread_archive/_retrieval/_extract.py +184 -0
  39. thread_archive/_retrieval/embed.py +186 -0
  40. thread_archive/_retrieval/format.py +149 -0
  41. thread_archive/_retrieval/fts.py +538 -0
  42. thread_archive/_retrieval/rank.py +228 -0
  43. thread_archive/_retrieval/read.py +908 -0
  44. thread_archive/_retrieval/rerank.py +143 -0
  45. thread_archive/_retrieval/vectors.py +611 -0
  46. thread_archive/_scripts/__init__.py +1 -0
  47. thread_archive/_scripts/backfill_recompute.py +328 -0
  48. thread_archive/_scripts/backfill_reconcile.py +296 -0
  49. thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
  50. thread_archive/_scripts/recover_dropped_events.py +190 -0
  51. thread_archive/_scripts/repair_grok_tool_names.py +118 -0
  52. thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
  53. thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
  54. thread_archive/_setup/__init__.py +12 -0
  55. thread_archive/_setup/clients.py +89 -0
  56. thread_archive/_setup/wizard.py +474 -0
  57. thread_archive/_store/__init__.py +45 -0
  58. thread_archive/_store/_base.py +173 -0
  59. thread_archive/_store/_defaults.py +57 -0
  60. thread_archive/_store/_types.py +38 -0
  61. thread_archive/_store/models.py +370 -0
  62. thread_archive/_store/schema.py +84 -0
  63. thread_archive/_thread_import/__init__.py +40 -0
  64. thread_archive/_thread_import/api.py +78 -0
  65. thread_archive/_thread_import/event_builder.py +810 -0
  66. thread_archive/_thread_import/exporters/__init__.py +9 -0
  67. thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
  68. thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
  69. thread_archive/_thread_import/exporters/cursor.py +582 -0
  70. thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
  71. thread_archive/_thread_import/parsers/__init__.py +191 -0
  72. thread_archive/_thread_import/parsers/base.py +698 -0
  73. thread_archive/_thread_import/parsers/chatgpt.py +722 -0
  74. thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
  75. thread_archive/_thread_import/parsers/claude.py +443 -0
  76. thread_archive/_thread_import/parsers/claude_code.py +1035 -0
  77. thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
  78. thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
  79. thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
  80. thread_archive/_thread_import/parsers/config/__init__.py +26 -0
  81. thread_archive/_thread_import/parsers/config/base.py +220 -0
  82. thread_archive/_thread_import/parsers/cursor.py +538 -0
  83. thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
  84. thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
  85. thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
  86. thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
  87. thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
  88. thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
  89. thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
  90. thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
  91. thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
  92. thread_archive/_thread_import/parsers/types/__init__.py +56 -0
  93. thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
  94. thread_archive/_thread_import/parsers/types/claude.py +120 -0
  95. thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
  96. thread_archive/_thread_import/parsers/types/cursor.py +109 -0
  97. thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
  98. thread_archive/_thread_import/parsers/validators/base.py +168 -0
  99. thread_archive/_thread_import/parsers/validators/content.py +80 -0
  100. thread_archive/_thread_import/parsers/validators/referential.py +57 -0
  101. thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
  102. thread_archive/_thread_import/parsers/validators/types.py +87 -0
  103. thread_archive/_thread_import/schemas/__init__.py +231 -0
  104. thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
  105. thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
  106. thread_archive/_thread_import/schemas/claude/v1.json +188 -0
  107. thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
  108. thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
  109. thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
  110. thread_archive/_thread_import/timestamps.py +57 -0
  111. thread_archive/_thread_import/tool_names.py +48 -0
  112. thread_archive/_truth/__init__.py +40 -0
  113. thread_archive/_truth/jsonl_log.py +2340 -0
  114. thread_archive/_truth/repair.py +322 -0
  115. thread_archive/_watcher/__init__.py +57 -0
  116. thread_archive/_watcher/base.py +65 -0
  117. thread_archive/_watcher/daemon.py +289 -0
  118. thread_archive/_watcher/export_drop.py +203 -0
  119. thread_archive/_watcher/exthost.py +316 -0
  120. thread_archive/_watcher/lazy.py +117 -0
  121. thread_archive/_watcher/sources.py +616 -0
  122. thread_archive/_web/__init__.py +15 -0
  123. thread_archive/_web/server.py +299 -0
  124. thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
  125. thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
  126. thread_archive/_web/static/index.html +13 -0
  127. thread_archive/cli.py +746 -0
  128. thread_archive-0.0.2.dist-info/METADATA +296 -0
  129. thread_archive-0.0.2.dist-info/RECORD +132 -0
  130. thread_archive-0.0.2.dist-info/WHEEL +4 -0
  131. thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
  132. 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()