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,538 @@
1
+ """
2
+ Cursor IDE chat export parser.
3
+
4
+ Parses chat history exported from Cursor IDE.
5
+
6
+ ## Format
7
+
8
+ Storage location (local machine, not devcontainers):
9
+
10
+ - **macOS**: ``~/Library/Application Support/Cursor/User/workspaceStorage/``
11
+ - **Linux**: ``~/.config/Cursor/User/workspaceStorage/``
12
+ - **Windows**: ``%APPDATA%\\Cursor\\User\\workspaceStorage\\``
13
+
14
+ Each workspace has a hashed subdirectory containing ``state.vscdb`` (SQLite).
15
+
16
+ Export methods:
17
+
18
+ 1. ``cursor-export`` CLI tool (recommended) → ZIP with ``cursor_chats.json``
19
+ 2. Cursor UI: Chat menu → Export Chat → Markdown file (single chat only)
20
+
21
+ ## Edge Cases
22
+
23
+ Multiple Format Versions
24
+ Cursor's internal format has evolved. The parser handles:
25
+ - v1: Simple messages with ``content`` string
26
+ - v2: Structured ``content_blocks`` with tool calls inline
27
+ The exporter normalizes both to our schema.
28
+
29
+ Workspace Isolation
30
+ Each workspace has separate chat history. Cross-workspace conversations
31
+ don't exist. The ``workspace_hash`` field tracks origin.
32
+
33
+ Markdown Export Limitations
34
+ Built-in Markdown export loses structure (tool calls become text).
35
+ Use ``cursor-export`` CLI for full fidelity.
36
+
37
+ Timestamp Formats
38
+ May be ISO string or Unix timestamp (seconds or milliseconds).
39
+ Parser auto-detects and normalizes.
40
+
41
+ Role Normalization
42
+ Some versions use ``"human"``/``"ai"`` instead of ``"user"``/``"assistant"``.
43
+ Normalized automatically.
44
+
45
+ Tool Calls
46
+ Cursor stores tool calls in dedicated ``tool_calls`` array on messages,
47
+ separate from ``content``. Converted to ``tool_use`` content blocks.
48
+
49
+ ## Validation
50
+
51
+ - **Thinking blocks**: Model-specific. See ``ProviderConfig`` for exempt models.
52
+ - **Parent references**: Not applicable (linear conversations).
53
+ - **Branching**: None.
54
+
55
+ ## Field Mapping
56
+
57
+ - ``id`` → ``provider_message_id``
58
+ - ``conversation_id`` → ``provider_conversation_id``
59
+ - ``created_at`` → ``created_at`` (normalized to ISO)
60
+ - ``role`` → ``role`` (``"human"`` → ``"user"``, ``"ai"`` → ``"assistant"``)
61
+ - ``content`` / ``content_blocks`` → ``content_blocks``
62
+ """
63
+
64
+ from typing import Any, Dict, List, Optional, cast
65
+
66
+ from thread_archive._thread_import.timestamps import parse_timestamp_iso
67
+
68
+ from . import cursor_blocks
69
+ from .base import (
70
+ ContentBlock,
71
+ FieldMapping,
72
+ NormalizedMessage,
73
+ ProviderParser,
74
+ SemanticCheck,
75
+ ValidationSeverity,
76
+ )
77
+ from .config import CURSOR_CONFIG, ProviderConfig
78
+ from .validators import (
79
+ ContentValidator,
80
+ ReferentialIntegrityValidator,
81
+ ThinkingBlockValidator,
82
+ TypeValidator,
83
+ )
84
+
85
+
86
+ def _parse_cursor_timestamp(ts: Any) -> Optional[str]:
87
+ """Parse timestamp to ISO format, handling multiple formats."""
88
+ return parse_timestamp_iso(ts)
89
+
90
+
91
+ def _extract_created_at(msg: Dict[str, Any]) -> Optional[str]:
92
+ """Extract and normalize created_at timestamp."""
93
+ return _parse_cursor_timestamp(msg.get("created_at"))
94
+
95
+
96
+ def _extract_role(msg: Dict[str, Any]) -> str:
97
+ """Extract and normalize role."""
98
+ role = msg.get("role", "")
99
+ if not role:
100
+ return "unknown"
101
+
102
+ role_lower = role.lower()
103
+ role_map = {
104
+ "user": "user",
105
+ "human": "user",
106
+ "assistant": "assistant",
107
+ "ai": "assistant",
108
+ "bot": "assistant",
109
+ "system": "system",
110
+ "tool": "tool",
111
+ "function": "tool",
112
+ }
113
+ return cast(str, role_map.get(role_lower, role_lower))
114
+
115
+
116
+ def _extract_content(msg: Dict[str, Any]) -> str:
117
+ """Extract primary content as string."""
118
+ content = msg.get("content", "")
119
+ if isinstance(content, str):
120
+ return content
121
+ return ""
122
+
123
+
124
+ def _extract_model(msg: Dict[str, Any]) -> Optional[str]:
125
+ """Extract model name if present."""
126
+ return msg.get("model")
127
+
128
+
129
+ class CursorParser(ProviderParser):
130
+ """Parser for Cursor IDE chat exports.
131
+
132
+ Handles exports created by:
133
+ 1. cursor-export CLI tool (ZIP with cursor_chats.json)
134
+ 2. Cursor's built-in Markdown export (single chat)
135
+
136
+ Outputs normalized messages with content_blocks in the unified schema.
137
+
138
+ ## Explicit Field Mappings
139
+
140
+ This parser uses explicit semantic mappings to ensure we correctly interpret
141
+ Cursor's export format. Each mapping documents what the field MEANS in
142
+ our model, not just where it comes from in the provider JSON.
143
+ """
144
+
145
+ PROVIDER_NAME = "cursor"
146
+
147
+ # Provider configuration (replaces centralized PROVIDER_EXPECTATIONS)
148
+ PROVIDER_CONFIG: ProviderConfig = CURSOR_CONFIG
149
+
150
+ # Explicit field mappings with semantic documentation
151
+ MESSAGE_FIELD_MAPPINGS = [
152
+ FieldMapping(
153
+ model_field="created_at",
154
+ extractor=_extract_created_at,
155
+ required=False,
156
+ semantic_doc=(
157
+ "When the message was SENT. Cursor may use ISO strings or Unix "
158
+ "timestamps (seconds or milliseconds). Represents user-facing time."
159
+ ),
160
+ ),
161
+ FieldMapping(
162
+ model_field="role",
163
+ extractor=_extract_role,
164
+ required=True,
165
+ semantic_doc=(
166
+ "The actor role. Cursor uses various terms like 'user', 'human', "
167
+ "'assistant', 'ai', 'bot'. We normalize to user/assistant/system/tool."
168
+ ),
169
+ ),
170
+ FieldMapping(
171
+ model_field="content",
172
+ extractor=_extract_content,
173
+ required=False,
174
+ semantic_doc=(
175
+ "Primary text content. May be empty if content is in content_blocks."
176
+ ),
177
+ ),
178
+ FieldMapping(
179
+ model_field="model",
180
+ extractor=_extract_model,
181
+ required=False,
182
+ semantic_doc=(
183
+ "The model that generated this message (for assistant messages). "
184
+ "e.g., 'gpt-4', 'claude-3-opus', etc."
185
+ ),
186
+ ),
187
+ ]
188
+
189
+ # Semantic checks for Cursor-specific requirements
190
+ SEMANTIC_CHECKS = [
191
+ SemanticCheck(
192
+ name="user_context_on_first_message",
193
+ description=(
194
+ "First user message should have user context. In Cursor, this is "
195
+ "typically the workspace context, open files, and selection."
196
+ ),
197
+ applies_to="first_user_message",
198
+ severity=ValidationSeverity.error,
199
+ ),
200
+ SemanticCheck(
201
+ name="thinking_on_assistant_messages",
202
+ description=(
203
+ "Assistant messages may have thinking blocks depending on the model. "
204
+ "Not all models support extended thinking."
205
+ ),
206
+ applies_to="assistant_messages",
207
+ severity=ValidationSeverity.warning,
208
+ ),
209
+ SemanticCheck(
210
+ name="tool_use_tracking",
211
+ description=(
212
+ "Cursor heavily uses tools (file read, grep, terminal, etc.). "
213
+ "Tool calls and results should be captured in content_blocks."
214
+ ),
215
+ applies_to="assistant_messages",
216
+ severity=ValidationSeverity.warning,
217
+ ),
218
+ ]
219
+
220
+ def __init__(self, strict: bool = False):
221
+ """Initialize the Cursor parser."""
222
+ super().__init__(strict=strict)
223
+ # Initialize validators from the new architecture
224
+ self._validators = [
225
+ ThinkingBlockValidator(self.PROVIDER_CONFIG, strict=strict),
226
+ ReferentialIntegrityValidator(self.PROVIDER_CONFIG, strict=strict),
227
+ TypeValidator(self.PROVIDER_CONFIG, strict=strict),
228
+ ContentValidator(self.PROVIDER_CONFIG, strict=strict),
229
+ ]
230
+
231
+ def parse_export(self, data: Any) -> List[NormalizedMessage]:
232
+ """
233
+ Parse Cursor chat export data into normalized message format.
234
+
235
+ Args:
236
+ data: Either:
237
+ - A dict with 'conversations' key (from cursor_chats.json)
238
+ - A dict with 'provider': 'cursor' (full export bundle)
239
+ - Raw conversations list
240
+ - Markdown content string (from Cursor's built-in export)
241
+
242
+ Returns:
243
+ List of NormalizedMessage dictionaries with content_blocks
244
+ """
245
+ # Handle different input formats
246
+ if isinstance(data, str):
247
+ # Markdown export from Cursor's built-in feature
248
+ return self._parse_markdown_export(data)
249
+
250
+ if isinstance(data, dict):
251
+ # Check for cursor_chats.json format
252
+ if data.get("provider") == "cursor" or "conversations" in data:
253
+ conversations = data.get("conversations", [])
254
+ else:
255
+ # Single conversation
256
+ conversations = [data]
257
+ elif isinstance(data, list):
258
+ conversations = data
259
+ else:
260
+ return []
261
+
262
+ messages: List[NormalizedMessage] = []
263
+
264
+ for conv in conversations:
265
+ conv_id = conv.get("id") or self._generate_id(conv)
266
+ conv_title = conv.get("title") or "Untitled"
267
+ workspace_name = conv.get("workspace_name") or ""
268
+ workspace_uri = conv.get("workspace_uri") or ""
269
+
270
+ # Extract conversation-level timestamps as fallback
271
+ conv_created_at = conv.get("created_at")
272
+ conv_updated_at = conv.get("updated_at")
273
+
274
+ # Build conversation metadata
275
+ conv_metadata = {
276
+ "title": conv_title,
277
+ "workspace_hash": conv.get("workspace_hash"),
278
+ "workspace_name": workspace_name,
279
+ "workspace_uri": workspace_uri,
280
+ }
281
+
282
+ conv_messages = conv.get("messages", [])
283
+
284
+ for msg_idx, msg in enumerate(conv_messages):
285
+ msg_record = self._parse_message(
286
+ msg,
287
+ conv_id,
288
+ conv_title,
289
+ conv_metadata,
290
+ msg_idx,
291
+ conv_created_at=conv_created_at,
292
+ conv_updated_at=conv_updated_at,
293
+ )
294
+ if msg_record:
295
+ messages.append(msg_record)
296
+
297
+ return messages
298
+
299
+ # parse_export_with_validation is inherited from ProviderParser (shared logic
300
+ # driving self._validators); no provider-specific override needed.
301
+
302
+ def _parse_message(
303
+ self,
304
+ msg: Dict[str, Any],
305
+ conv_id: str,
306
+ conv_title: str,
307
+ conv_metadata: Dict[str, Any],
308
+ msg_idx: int,
309
+ conv_created_at: Any = None,
310
+ conv_updated_at: Any = None,
311
+ ) -> Optional[NormalizedMessage]:
312
+ """Parse a single message from Cursor's format using explicit field mappings."""
313
+ msg_id = msg.get("id") or f"{conv_id}_{msg_idx}"
314
+
315
+ # Apply explicit field mappings for semantic correctness
316
+ mapped_fields = self.apply_field_mappings(msg, self.MESSAGE_FIELD_MAPPINGS)
317
+ role = mapped_fields.get("role", "unknown")
318
+ content = mapped_fields.get("content", "")
319
+ model = mapped_fields.get("model")
320
+ created_at_iso = mapped_fields.get("created_at")
321
+
322
+ raw_blocks = msg.get("content_blocks", [])
323
+
324
+ # Build content blocks
325
+ content_blocks = self._build_content_blocks(msg, raw_blocks, content)
326
+
327
+ # Extract primary text
328
+ content_text = self.extract_text_from_blocks(content_blocks)
329
+ if not content_text and content:
330
+ content_text = content if isinstance(content, str) else ""
331
+
332
+ # Fallback to conversation-level timestamp if message has none
333
+ # Semantic: We prefer message-level timestamp but use conversation timestamp
334
+ # as a reasonable approximation when message timestamp is missing
335
+ if not created_at_iso and conv_updated_at:
336
+ created_at_iso = _parse_cursor_timestamp(conv_updated_at)
337
+ if not created_at_iso and conv_created_at:
338
+ created_at_iso = _parse_cursor_timestamp(conv_created_at)
339
+
340
+ # Create the normalized message
341
+ msg_record: NormalizedMessage = {
342
+ "source_provider": self.PROVIDER_NAME,
343
+ "provider_message_id": msg_id,
344
+ "provider_message_id_lower": msg_id.lower() if msg_id else None,
345
+ "provider_conversation_id": conv_id,
346
+ "content_hash": self.hash_message(msg_id, role, content, msg.get("created_at")),
347
+ "role": role,
348
+ "content_text": content_text,
349
+ "content_blocks": content_blocks,
350
+ "created_at": created_at_iso,
351
+ "updated_at": None,
352
+ "message_order": msg_idx,
353
+ # Provider data preservation
354
+ "provider_data": {
355
+ "message": msg,
356
+ "conversation_title": conv_title,
357
+ "model": model,
358
+ },
359
+ # Cursor doesn't have branching like ChatGPT
360
+ "provider_parent_id": None,
361
+ "provider_parent_id_lower": None,
362
+ "is_active_path": True,
363
+ # Conversation metadata
364
+ "conversation_title": conv_title,
365
+ "conversation_metadata": conv_metadata,
366
+ }
367
+
368
+ return msg_record
369
+
370
+ def _build_content_blocks(
371
+ self,
372
+ msg: Dict[str, Any],
373
+ raw_blocks: List[Dict],
374
+ fallback_content: Any,
375
+ ) -> List[ContentBlock]:
376
+ """Build normalized content blocks from Cursor message data.
377
+
378
+ Delegates to :func:`cursor_blocks.build_content_blocks`; the heavy,
379
+ stateless per-shape appenders live in that sibling module. Kept on the
380
+ class because tests call it directly.
381
+ """
382
+ return cursor_blocks.build_content_blocks(msg, raw_blocks, fallback_content)
383
+
384
+ def _normalize_tool_input(
385
+ self,
386
+ original_name: str,
387
+ canonical_name: str,
388
+ input_data: Any,
389
+ msg: Dict[str, Any],
390
+ ) -> Any:
391
+ """Normalize tool input to Thread's canonical format.
392
+
393
+ Delegates to :func:`cursor_blocks.normalize_tool_input`.
394
+ """
395
+ return cursor_blocks.normalize_tool_input(
396
+ original_name, canonical_name, input_data, msg
397
+ )
398
+
399
+ def _generate_id(self, conv: Dict[str, Any]) -> str:
400
+ """Generate a conversation ID from available data."""
401
+ import hashlib
402
+
403
+ # Use workspace + title + timestamp if available
404
+ parts = [
405
+ conv.get("workspace_hash", ""),
406
+ conv.get("title", ""),
407
+ str(conv.get("created_at", "")),
408
+ ]
409
+ hash_input = "|".join(parts)
410
+ return hashlib.sha256(hash_input.encode()).hexdigest()[:16]
411
+
412
+ def _parse_markdown_export(self, markdown: str) -> List[NormalizedMessage]:
413
+ """
414
+ Parse Cursor's built-in Markdown export format.
415
+
416
+ Cursor exports chats as Markdown with user/assistant turns.
417
+ Format is typically:
418
+
419
+ # Chat Title
420
+
421
+ ## User
422
+ Message content...
423
+
424
+ ## Assistant
425
+ Response content...
426
+ ```python
427
+ code blocks...
428
+ ```
429
+ """
430
+ import re
431
+
432
+ messages: List[NormalizedMessage] = []
433
+
434
+ # Extract title from first heading
435
+ title_match = re.match(r"^#\s+(.+?)$", markdown, re.MULTILINE)
436
+ title = title_match.group(1) if title_match else "Exported Chat"
437
+
438
+ # Generate conversation ID from content
439
+ import hashlib
440
+ conv_id = hashlib.sha256(markdown.encode()).hexdigest()[:16]
441
+
442
+ # Split by ## headings for user/assistant turns
443
+ # Pattern matches ## followed by role (User, Assistant, System, etc.)
444
+ turn_pattern = r"##\s+(User|Assistant|System|Human|AI)\s*\n"
445
+ parts = re.split(turn_pattern, markdown, flags=re.IGNORECASE)
446
+
447
+ # First part is the title/preamble, skip it
448
+ # Then alternating: role, content, role, content...
449
+ if len(parts) > 1:
450
+ turns = parts[1:] # Skip preamble
451
+ msg_idx = 0
452
+
453
+ for i in range(0, len(turns) - 1, 2):
454
+ role_raw = turns[i].strip().lower()
455
+ content = turns[i + 1].strip() if i + 1 < len(turns) else ""
456
+
457
+ if not content:
458
+ continue
459
+
460
+ role = _extract_role({"role": role_raw})
461
+ msg_id = f"{conv_id}_{msg_idx}"
462
+
463
+ # Parse code blocks in markdown
464
+ content_blocks = self._parse_markdown_content(content)
465
+
466
+ content_text = self.extract_text_from_blocks(content_blocks)
467
+ if not content_text:
468
+ content_text = content
469
+
470
+ msg_record: NormalizedMessage = {
471
+ "source_provider": self.PROVIDER_NAME,
472
+ "provider_message_id": msg_id,
473
+ "provider_message_id_lower": msg_id.lower(),
474
+ "provider_conversation_id": conv_id,
475
+ "content_hash": self.hash_message(msg_id, role, content, None),
476
+ "role": role,
477
+ "content_text": content_text,
478
+ "content_blocks": content_blocks,
479
+ "created_at": None,
480
+ "updated_at": None,
481
+ "message_order": msg_idx,
482
+ "provider_data": {
483
+ "raw_markdown": content,
484
+ "conversation_title": title,
485
+ },
486
+ "provider_parent_id": None,
487
+ "provider_parent_id_lower": None,
488
+ "is_active_path": True,
489
+ "conversation_title": title,
490
+ "conversation_metadata": {"title": title, "format": "markdown"},
491
+ }
492
+
493
+ messages.append(msg_record)
494
+ msg_idx += 1
495
+
496
+ return messages
497
+
498
+ def _parse_markdown_content(self, content: str) -> List[ContentBlock]:
499
+ """Parse markdown content into content blocks, extracting code blocks."""
500
+ import re
501
+
502
+ blocks: List[ContentBlock] = []
503
+ seq = 0
504
+
505
+ # Pattern for fenced code blocks
506
+ code_pattern = r"```(\w*)\n(.*?)```"
507
+
508
+ last_end = 0
509
+ for match in re.finditer(code_pattern, content, re.DOTALL):
510
+ # Add text before code block
511
+ text_before = content[last_end:match.start()].strip()
512
+ if text_before:
513
+ blocks.append(self.create_text_block(text_before, seq))
514
+ seq += 1
515
+
516
+ # Add code block
517
+ language = match.group(1) or None
518
+ code = match.group(2).strip()
519
+ blocks.append({
520
+ "type": "code",
521
+ "code": code,
522
+ "language": language,
523
+ "seq": seq,
524
+ })
525
+ seq += 1
526
+
527
+ last_end = match.end()
528
+
529
+ # Add remaining text
530
+ remaining = content[last_end:].strip()
531
+ if remaining:
532
+ blocks.append(self.create_text_block(remaining, seq))
533
+
534
+ # If no blocks created, just use the whole content
535
+ if not blocks:
536
+ blocks.append(self.create_text_block(content, 0))
537
+
538
+ return blocks