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,166 @@
|
|
|
1
|
+
"""SQLite / cursorDiskKV reading methods for ``CursorExporter``.
|
|
2
|
+
|
|
3
|
+
A mixin of the database-reading and KV→conversation-building methods, lifted out
|
|
4
|
+
of ``CursorExporter`` so the class file stays focused. ``_read_sqlite_db`` and
|
|
5
|
+
``_read_cursor_disk_kv`` open a SQLite file passed in by path; the rest transform
|
|
6
|
+
the resulting composer/bubble dicts into conversation structures, delegating the
|
|
7
|
+
per-message shape work to ``cursor_parse``. None read ``self.storage_path``.
|
|
8
|
+
|
|
9
|
+
The methods stay methods (not free functions) so callers that access them as
|
|
10
|
+
class/instance attributes — including tests that build a bare exporter with
|
|
11
|
+
``__new__`` and the ``export_all`` orchestrator that stubs ``_read_cursor_disk_kv``
|
|
12
|
+
and ``_build_conversations_from_kv`` on the instance — keep resolving to the same
|
|
13
|
+
objects via the assembled class's MRO. Moved byte-for-byte from ``cursor.py``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import sqlite3
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Dict, List, Tuple
|
|
21
|
+
|
|
22
|
+
from . import cursor_parse
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CursorKVMixin:
|
|
28
|
+
"""SQLite key-value reading + KV→conversation building methods."""
|
|
29
|
+
|
|
30
|
+
def _read_sqlite_db(self, db_path: Path) -> Dict[str, Any]:
|
|
31
|
+
"""Read key-value pairs from a Cursor SQLite database."""
|
|
32
|
+
data = {}
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
conn = sqlite3.connect(str(db_path))
|
|
36
|
+
cursor = conn.cursor()
|
|
37
|
+
|
|
38
|
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
39
|
+
tables = [row[0] for row in cursor.fetchall()]
|
|
40
|
+
|
|
41
|
+
if "ItemTable" in tables:
|
|
42
|
+
cursor.execute("SELECT key, value FROM ItemTable")
|
|
43
|
+
for key, value in cursor.fetchall():
|
|
44
|
+
if value and isinstance(value, str):
|
|
45
|
+
try:
|
|
46
|
+
if value.strip().startswith(("{", "[")):
|
|
47
|
+
data[key] = json.loads(value)
|
|
48
|
+
else:
|
|
49
|
+
data[key] = value
|
|
50
|
+
except json.JSONDecodeError:
|
|
51
|
+
data[key] = value
|
|
52
|
+
else:
|
|
53
|
+
data[key] = value
|
|
54
|
+
|
|
55
|
+
conn.close()
|
|
56
|
+
except sqlite3.Error:
|
|
57
|
+
pass
|
|
58
|
+
except Exception:
|
|
59
|
+
logger.warning("Failed to read SQLite database at %s", db_path, exc_info=True)
|
|
60
|
+
|
|
61
|
+
return data
|
|
62
|
+
|
|
63
|
+
def _read_cursor_disk_kv(self, db_path: Path) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
64
|
+
"""
|
|
65
|
+
Read conversation data from Cursor's cursorDiskKV table.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Tuple of (composers, bubbles)
|
|
69
|
+
"""
|
|
70
|
+
composers = {}
|
|
71
|
+
bubbles = {}
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
conn = sqlite3.connect(str(db_path))
|
|
75
|
+
cursor = conn.cursor()
|
|
76
|
+
|
|
77
|
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='cursorDiskKV'")
|
|
78
|
+
if not cursor.fetchone():
|
|
79
|
+
conn.close()
|
|
80
|
+
return composers, bubbles
|
|
81
|
+
|
|
82
|
+
cursor.execute("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'")
|
|
83
|
+
for key, value in cursor.fetchall():
|
|
84
|
+
try:
|
|
85
|
+
composer_id = key.replace("composerData:", "")
|
|
86
|
+
data = json.loads(value)
|
|
87
|
+
composers[composer_id] = data
|
|
88
|
+
except (json.JSONDecodeError, Exception):
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
cursor.execute("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'")
|
|
92
|
+
for key, value in cursor.fetchall():
|
|
93
|
+
try:
|
|
94
|
+
parts = key.split(":")
|
|
95
|
+
if len(parts) >= 3:
|
|
96
|
+
bubble_id = parts[2]
|
|
97
|
+
composer_id = parts[1]
|
|
98
|
+
data = json.loads(value)
|
|
99
|
+
data["_composerId"] = composer_id
|
|
100
|
+
bubbles[f"{composer_id}:{bubble_id}"] = data
|
|
101
|
+
except (json.JSONDecodeError, Exception):
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
conn.close()
|
|
105
|
+
|
|
106
|
+
except sqlite3.Error:
|
|
107
|
+
pass
|
|
108
|
+
except Exception:
|
|
109
|
+
logger.warning("Failed to read cursorDiskKV from %s", db_path, exc_info=True)
|
|
110
|
+
|
|
111
|
+
return composers, bubbles
|
|
112
|
+
|
|
113
|
+
def _build_conversations_from_kv(
|
|
114
|
+
self,
|
|
115
|
+
composers: Dict[str, Any],
|
|
116
|
+
bubbles: Dict[str, Any]
|
|
117
|
+
) -> List[Dict[str, Any]]:
|
|
118
|
+
"""Build conversation structures from composer and bubble data."""
|
|
119
|
+
conversations = []
|
|
120
|
+
|
|
121
|
+
for composer_id, composer in composers.items():
|
|
122
|
+
headers = composer.get("fullConversationHeadersOnly", [])
|
|
123
|
+
if not headers:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
messages = []
|
|
127
|
+
for idx, header in enumerate(headers):
|
|
128
|
+
bubble_id = header.get("bubbleId")
|
|
129
|
+
if not bubble_id:
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
bubble = bubbles.get(f"{composer_id}:{bubble_id}", {})
|
|
133
|
+
messages.append(
|
|
134
|
+
self._build_kv_message(bubble_id, bubble, header, idx)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
conversations.append({
|
|
138
|
+
"id": composer_id,
|
|
139
|
+
"title": composer.get("name", "Untitled"),
|
|
140
|
+
"created_at": composer.get("createdAt"),
|
|
141
|
+
"updated_at": composer.get("lastUpdatedAt"),
|
|
142
|
+
"messages": messages,
|
|
143
|
+
"metadata": self._build_kv_metadata(composer),
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
return conversations
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def _kv_role(bubble: Dict[str, Any], header: Dict[str, Any]) -> str:
|
|
150
|
+
"""Map a Cursor bubble/header ``type`` (1/2) onto a canonical role."""
|
|
151
|
+
return cursor_parse.kv_role(bubble, header)
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _build_kv_message(
|
|
155
|
+
bubble_id: str,
|
|
156
|
+
bubble: Dict[str, Any],
|
|
157
|
+
header: Dict[str, Any],
|
|
158
|
+
idx: int,
|
|
159
|
+
) -> Dict[str, Any]:
|
|
160
|
+
"""Build one conversation message from a composer header + its bubble."""
|
|
161
|
+
return cursor_parse.build_kv_message(bubble_id, bubble, header, idx)
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _build_kv_metadata(composer: Dict[str, Any]) -> Dict[str, Any]:
|
|
165
|
+
"""Extract the conversation metadata block from a composer record."""
|
|
166
|
+
return cursor_parse.build_kv_metadata(composer)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Composer/chat-blob parsing methods for ``CursorExporter``.
|
|
2
|
+
|
|
3
|
+
A mixin of the stateless ``_parse_*`` / ``_normalize_role`` /
|
|
4
|
+
``_content_blocks_from_content`` / ``_extract_chat_data`` methods, lifted out of
|
|
5
|
+
``CursorExporter`` so the class file stays focused. These read no instance state
|
|
6
|
+
(no ``self.storage_path``) — they transform loose composer/chat blobs into
|
|
7
|
+
conversation dicts, delegating the per-message shape work to ``cursor_parse``.
|
|
8
|
+
|
|
9
|
+
The methods stay methods (not free functions) so callers that access them as
|
|
10
|
+
class/instance attributes — including tests that build a bare exporter with
|
|
11
|
+
``__new__`` and the ``export_all`` orchestrator that stubs them on the instance —
|
|
12
|
+
keep resolving to the same objects via the assembled class's MRO. Moved
|
|
13
|
+
byte-for-byte from ``cursor.py``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
from . import cursor_parse
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CursorParseMixin:
|
|
23
|
+
"""Stateless composer/chat-blob → conversation parsing methods."""
|
|
24
|
+
|
|
25
|
+
def _extract_chat_data(self, db_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
26
|
+
"""Extract chat-related data from the database dump."""
|
|
27
|
+
chat_data = {}
|
|
28
|
+
|
|
29
|
+
chat_patterns = [
|
|
30
|
+
r".*chat.*",
|
|
31
|
+
r".*composer.*",
|
|
32
|
+
r".*conversation.*",
|
|
33
|
+
r".*aiContext.*",
|
|
34
|
+
r".*ai\..*",
|
|
35
|
+
r".*cursor\..*",
|
|
36
|
+
r".*workbench\.panel\.aichat.*",
|
|
37
|
+
r".*workbench\.panel\.chat.*",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
for key, value in db_data.items():
|
|
41
|
+
key_lower = key.lower()
|
|
42
|
+
for pattern in chat_patterns:
|
|
43
|
+
if re.match(pattern, key_lower, re.IGNORECASE):
|
|
44
|
+
chat_data[key] = value
|
|
45
|
+
break
|
|
46
|
+
|
|
47
|
+
return chat_data
|
|
48
|
+
|
|
49
|
+
def _parse_composer_data(self, data: Any) -> List[Dict[str, Any]]:
|
|
50
|
+
"""Parse Cursor's composer/chat data into conversations."""
|
|
51
|
+
if not data:
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
if isinstance(data, dict):
|
|
55
|
+
return self._parse_composer_dict(data)
|
|
56
|
+
|
|
57
|
+
if isinstance(data, list):
|
|
58
|
+
return self._parse_conversation_items(data)
|
|
59
|
+
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
def _parse_composer_dict(self, data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
63
|
+
"""Parse a dict-shaped composer blob: keyed conversation collections,
|
|
64
|
+
falling back to treating the dict itself as a single conversation."""
|
|
65
|
+
conversations: List[Dict[str, Any]] = []
|
|
66
|
+
|
|
67
|
+
for key in ["tabs", "conversations", "chats", "composerData", "allTabs"]:
|
|
68
|
+
if key in data:
|
|
69
|
+
conversations.extend(self._parse_keyed_collection(data[key]))
|
|
70
|
+
|
|
71
|
+
if not conversations and ("messages" in data or "bubbles" in data or "conversation" in data):
|
|
72
|
+
conv = self._parse_single_conversation(data)
|
|
73
|
+
if conv:
|
|
74
|
+
conversations.append(conv)
|
|
75
|
+
|
|
76
|
+
return conversations
|
|
77
|
+
|
|
78
|
+
def _parse_keyed_collection(self, items: Any) -> List[Dict[str, Any]]:
|
|
79
|
+
"""Parse one keyed collection (list of conversations or id->conversation
|
|
80
|
+
dict) into a list of conversations."""
|
|
81
|
+
if isinstance(items, list):
|
|
82
|
+
return self._parse_conversation_items(items)
|
|
83
|
+
if isinstance(items, dict):
|
|
84
|
+
conversations: List[Dict[str, Any]] = []
|
|
85
|
+
for conv_id, item in items.items():
|
|
86
|
+
conv = self._parse_single_conversation(item, conv_id)
|
|
87
|
+
if conv:
|
|
88
|
+
conversations.append(conv)
|
|
89
|
+
return conversations
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
def _parse_conversation_items(self, items: List[Any]) -> List[Dict[str, Any]]:
|
|
93
|
+
"""Parse a plain list of conversation/tab structures, dropping any that
|
|
94
|
+
don't yield a conversation."""
|
|
95
|
+
conversations: List[Dict[str, Any]] = []
|
|
96
|
+
for item in items:
|
|
97
|
+
conv = self._parse_single_conversation(item)
|
|
98
|
+
if conv:
|
|
99
|
+
conversations.append(conv)
|
|
100
|
+
return conversations
|
|
101
|
+
|
|
102
|
+
def _parse_single_conversation(self, data: Any, conv_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
103
|
+
"""Parse a single conversation/tab structure."""
|
|
104
|
+
if not isinstance(data, dict):
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
conversation = {
|
|
108
|
+
"id": conv_id or data.get("id") or data.get("tabId") or data.get("conversationId"),
|
|
109
|
+
"title": data.get("title") or data.get("name") or "Untitled",
|
|
110
|
+
"created_at": data.get("createdAt") or data.get("created_at") or data.get("timestamp"),
|
|
111
|
+
"updated_at": data.get("updatedAt") or data.get("updated_at"),
|
|
112
|
+
"messages": [],
|
|
113
|
+
"raw_data": data,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
messages = []
|
|
117
|
+
for msg_key in ["messages", "bubbles", "conversation", "chatMessages", "chat_messages"]:
|
|
118
|
+
if msg_key in data:
|
|
119
|
+
msg_data = data[msg_key]
|
|
120
|
+
if isinstance(msg_data, list):
|
|
121
|
+
messages = msg_data
|
|
122
|
+
break
|
|
123
|
+
elif isinstance(msg_data, dict) and "messages" in msg_data:
|
|
124
|
+
messages = msg_data["messages"]
|
|
125
|
+
break
|
|
126
|
+
|
|
127
|
+
for i, msg in enumerate(messages):
|
|
128
|
+
parsed_msg = self._parse_message(msg, i)
|
|
129
|
+
if parsed_msg:
|
|
130
|
+
conversation["messages"].append(parsed_msg)
|
|
131
|
+
|
|
132
|
+
if conversation["messages"] or conversation["id"]:
|
|
133
|
+
return conversation
|
|
134
|
+
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
def _parse_message(self, msg: Any, index: int = 0) -> Optional[Dict[str, Any]]:
|
|
138
|
+
"""Parse a single message from Cursor's format."""
|
|
139
|
+
return cursor_parse.parse_message(msg, index)
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _normalize_role(msg: Dict[str, Any]) -> Optional[str]:
|
|
143
|
+
"""Map Cursor's role/type/sender aliases onto canonical role names."""
|
|
144
|
+
return cursor_parse.normalize_role(msg)
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def _content_blocks_from_content(raw_content: Any) -> List[Dict[str, Any]]:
|
|
148
|
+
"""Build text content blocks from a message's raw ``content`` field."""
|
|
149
|
+
return cursor_parse.content_blocks_from_content(raw_content)
|