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,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
|