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,722 @@
1
+ """
2
+ ChatGPT conversation export parser.
3
+
4
+ Parses the conversations.json export file from ChatGPT.
5
+
6
+ ## Format
7
+
8
+ Export source: ChatGPT web UI → Settings → Data controls → Export data
9
+ File structure: ZIP archive containing ``conversations.json``
10
+
11
+ The ``conversations.json`` contains an array of conversation objects, each with:
12
+ - ``mapping``: Dict of message_id → message node (tree structure)
13
+ - ``current_node``: ID of the leaf node in the "active" conversation path
14
+ - ``title``, ``create_time``, ``update_time``: Conversation metadata
15
+
16
+ ## Edge Cases
17
+
18
+ Tree Structure via ``mapping``
19
+ ChatGPT stores messages as a tree, not a linear list. Each message has a
20
+ ``parent`` field pointing to its predecessor. Branching occurs when users
21
+ edit messages or regenerate responses, creating alternate paths.
22
+
23
+ Coalescing Tool Messages
24
+ ChatGPT exports tool calls, thinking, and results as separate messages
25
+ linked via parent_id. This parser coalesces them into ``content_blocks``
26
+ on the parent assistant message. Original message boundaries are preserved
27
+ via ``provider_message_id`` on each block.
28
+
29
+ Active Path Detection
30
+ ``current_node`` identifies the conversation's "main" thread. Messages not
31
+ on this path are marked ``is_active_path=False``. The path is computed by
32
+ walking from ``current_node`` back to root.
33
+
34
+ Thinking Content Types
35
+ o1/o1-pro models emit thinking as separate messages with content_type in:
36
+ ``thoughts``, ``analysis``, ``reasoning_recap``. These are coalesced as
37
+ ``thinking`` blocks on the parent assistant message.
38
+
39
+ System Context Messages
40
+ Custom instructions appear as messages with content_type in:
41
+ ``user_editable_context``, ``model_editable_context``. Converted to
42
+ ``system_context`` blocks.
43
+
44
+ ## Validation Notes
45
+
46
+ Thinking blocks
47
+ Model-specific. Required for o1/o1-pro models, not expected for GPT-4/GPT-3.5.
48
+ See ``ProviderConfig`` for exempt models.
49
+
50
+ Parent references
51
+ Always valid within a conversation. Missing parents indicate data corruption.
52
+
53
+ Branching
54
+ Full tree structure preserved. Use ``is_active_path`` to filter to main thread.
55
+
56
+ ## Field Mapping
57
+
58
+ - ``create_time`` → ``created_at`` (Unix timestamp, immutable)
59
+ - ``update_time`` → ``updated_at`` (mutable, changes on edits)
60
+ - ``mapping[id].parent`` → ``provider_parent_id`` (tree structure)
61
+ - ``current_node`` → Used to compute ``is_active_path``
62
+ """
63
+
64
+ from typing import Any, Dict, List, Optional, Set, Union, cast
65
+
66
+ from thread_archive._thread_import.timestamps import parse_timestamp_iso
67
+
68
+ from .base import (
69
+ ContentBlock,
70
+ FieldMapping,
71
+ NormalizedMessage,
72
+ ProviderParser,
73
+ SemanticCheck,
74
+ ValidationSeverity,
75
+ )
76
+ from .chatgpt_content import (
77
+ append_image_parts,
78
+ append_metadata_tool_calls,
79
+ append_text_content,
80
+ build_content_blocks,
81
+ collect_coalesce_candidates,
82
+ extract_content_blocks,
83
+ extract_special_block,
84
+ extract_text_from_content,
85
+ is_primary_message,
86
+ )
87
+ from .config import CHATGPT_CONFIG, ProviderConfig
88
+ from .types.chatgpt import ChatGPTConversation, ChatGPTExport
89
+ from .validators import (
90
+ ContentValidator,
91
+ ReferentialIntegrityValidator,
92
+ ThinkingBlockValidator,
93
+ TypeValidator,
94
+ )
95
+
96
+ # Content types that indicate thinking/reasoning messages
97
+ THINKING_CONTENT_TYPES = {"thoughts", "analysis", "reasoning_recap"}
98
+
99
+ # Content types that indicate structural/scaffolding messages (not displayed)
100
+ STRUCTURAL_CONTENT_TYPES = {"code", "commentary", "metadata", "system"}
101
+
102
+ # Content types that represent system context
103
+ CONTEXT_CONTENT_TYPES = {"user_editable_context", "model_editable_context"}
104
+
105
+
106
+ def _extract_create_time_iso(raw_msg: Dict[str, Any]) -> Optional[str]:
107
+ """Extract create_time and convert to ISO format."""
108
+ create_time = raw_msg.get("create_time")
109
+ if create_time is None:
110
+ return None
111
+ # Epoch is an absolute instant — parse to aware UTC, not naive-local (which
112
+ # would silently skew every ChatGPT timestamp by the host's UTC offset).
113
+ return parse_timestamp_iso(create_time)
114
+
115
+
116
+ def _extract_update_time_iso(raw_msg: Dict[str, Any]) -> Optional[str]:
117
+ """Extract update_time and convert to ISO format."""
118
+ update_time = raw_msg.get("update_time")
119
+ if update_time is None:
120
+ return None
121
+ # Aware UTC (see _extract_create_time_iso) — never naive-local.
122
+ return parse_timestamp_iso(update_time)
123
+
124
+
125
+ def _extract_message_order(raw_msg: Dict[str, Any]) -> Optional[int]:
126
+ """Convert create_time to microsecond-precision message order."""
127
+ create_time = raw_msg.get("create_time")
128
+ if create_time is None or not isinstance(create_time, (int, float)):
129
+ return None
130
+ try:
131
+ return int(create_time * 1_000_000)
132
+ except (ValueError, OverflowError):
133
+ return None
134
+
135
+
136
+ def _extract_role(raw_msg: Dict[str, Any]) -> Optional[str]:
137
+ """Extract role from nested author structure."""
138
+ return raw_msg.get("author", {}).get("role")
139
+
140
+
141
+ def _extract_author_name(raw_msg: Dict[str, Any]) -> Optional[str]:
142
+ """Extract author name (for tool attribution)."""
143
+ return raw_msg.get("author", {}).get("name")
144
+
145
+
146
+ def _extract_model(raw_msg: Dict[str, Any]) -> Optional[str]:
147
+ """Extract model name from message metadata.model_slug."""
148
+ return (raw_msg.get("metadata") or {}).get("model_slug")
149
+
150
+
151
+ class ChatGPTParser(ProviderParser):
152
+ """Parser for ChatGPT conversations.json export.
153
+
154
+ Implements coalescing: tool calls, thinking, and results from child messages
155
+ are merged into content_blocks on their parent message.
156
+
157
+ ## Architecture
158
+
159
+ Uses the pipeline architecture with:
160
+ - PROVIDER_CONFIG: ChatGPT-specific configuration (thinking expectations, etc.)
161
+ - Validators: ThinkingBlockValidator, ReferentialIntegrityValidator, etc.
162
+ - Types: ChatGPTExport, ChatGPTConversation for typed input
163
+
164
+ ## Explicit Field Mappings
165
+
166
+ This parser uses explicit semantic mappings to ensure we correctly interpret
167
+ ChatGPT's JSON structure. Each mapping documents what the field MEANS in
168
+ our model, not just where it comes from in the provider JSON.
169
+ """
170
+
171
+ PROVIDER_NAME = "chatgpt"
172
+
173
+ # Provider configuration (replaces centralized PROVIDER_EXPECTATIONS)
174
+ PROVIDER_CONFIG: ProviderConfig = CHATGPT_CONFIG
175
+
176
+ # Explicit field mappings with semantic documentation
177
+ # These are applied to the raw message dict (from mapping[id].message)
178
+ MESSAGE_FIELD_MAPPINGS = [
179
+ FieldMapping(
180
+ model_field="created_at",
181
+ extractor=_extract_create_time_iso,
182
+ required=False,
183
+ semantic_doc=(
184
+ "When the message was originally SENT by the user/assistant. "
185
+ "This is create_time in ChatGPT, which is immutable and represents "
186
+ "the user-facing timestamp. NOT the same as when the DB record was created."
187
+ ),
188
+ ),
189
+ FieldMapping(
190
+ model_field="updated_at",
191
+ extractor=_extract_update_time_iso,
192
+ required=False,
193
+ semantic_doc=(
194
+ "When the message was last EDITED. This is update_time in ChatGPT. "
195
+ "May be null if message was never edited. Different from DB update timestamps."
196
+ ),
197
+ ),
198
+ FieldMapping(
199
+ model_field="message_order",
200
+ extractor=_extract_message_order,
201
+ required=False,
202
+ semantic_doc=(
203
+ "Microsecond-precision ordering value derived from create_time. "
204
+ "Used for consistent message ordering within a conversation."
205
+ ),
206
+ ),
207
+ FieldMapping(
208
+ model_field="role",
209
+ extractor=_extract_role,
210
+ required=False,
211
+ semantic_doc=(
212
+ "The actor role: 'user', 'assistant', 'system', or 'tool'. "
213
+ "Extracted from message.author.role in ChatGPT's structure."
214
+ ),
215
+ ),
216
+ FieldMapping(
217
+ model_field="author_name",
218
+ extractor=_extract_author_name,
219
+ required=False,
220
+ semantic_doc=(
221
+ "The tool/plugin name when role='tool'. "
222
+ "Used to identify which tool produced a tool_result block."
223
+ ),
224
+ ),
225
+ FieldMapping(
226
+ model_field="model",
227
+ extractor=_extract_model,
228
+ required=False,
229
+ semantic_doc=(
230
+ "The model that generated this message (e.g. 'gpt-4', 'gpt-4o'). "
231
+ "Extracted from message.metadata.model_slug in ChatGPT's structure. "
232
+ "Only present on assistant messages."
233
+ ),
234
+ ),
235
+ ]
236
+
237
+ # Semantic checks for ChatGPT-specific requirements
238
+ SEMANTIC_CHECKS = [
239
+ SemanticCheck(
240
+ name="user_context_on_first_message",
241
+ description=(
242
+ "First user message should have user context (custom instructions, "
243
+ "memory, etc.) from user_editable_context or model_editable_context blocks"
244
+ ),
245
+ applies_to="first_user_message",
246
+ severity=ValidationSeverity.error,
247
+ ),
248
+ SemanticCheck(
249
+ name="thinking_on_assistant_messages",
250
+ description=(
251
+ "Assistant messages from o1/o3 models should have thinking blocks. "
252
+ "Not required for older models, so this is a warning not an error."
253
+ ),
254
+ applies_to="assistant_messages",
255
+ severity=ValidationSeverity.warning,
256
+ ),
257
+ ]
258
+
259
+ def __init__(self, strict: bool = False):
260
+ """Initialize the ChatGPT parser."""
261
+ super().__init__(strict=strict)
262
+ # Initialize validators from the new architecture
263
+ self._validators = [
264
+ ThinkingBlockValidator(self.PROVIDER_CONFIG, strict=strict),
265
+ ReferentialIntegrityValidator(self.PROVIDER_CONFIG, strict=strict),
266
+ TypeValidator(self.PROVIDER_CONFIG, strict=strict),
267
+ ContentValidator(self.PROVIDER_CONFIG, strict=strict),
268
+ ]
269
+
270
+ def parse_export(
271
+ self, data: Union[ChatGPTExport, List[ChatGPTConversation], Any]
272
+ ) -> List[NormalizedMessage]:
273
+ """
274
+ Parse ChatGPT export data into normalized message format.
275
+
276
+ The coalescing strategy:
277
+ 1. First pass: Parse all messages, build parent->children map
278
+ 2. Second pass: For each primary message (user/assistant with text content),
279
+ collect child tool/thinking messages and convert to content_blocks
280
+ 3. Only emit separate rows for true branch points (multiple children with different paths)
281
+ """
282
+ # Handle both formats: raw list or bundled {"conversations": [...]}
283
+ if isinstance(data, dict) and "conversations" in data:
284
+ conversations = data["conversations"]
285
+ elif isinstance(data, list):
286
+ conversations = data
287
+ else:
288
+ conversations = [data]
289
+
290
+ all_messages: List[NormalizedMessage] = []
291
+
292
+ for conv in conversations:
293
+ conv_messages = self._parse_conversation(cast(Dict[str, Any], conv))
294
+ all_messages.extend(conv_messages)
295
+
296
+ return all_messages
297
+
298
+ def _parse_conversation(self, conv: Dict) -> List[NormalizedMessage]:
299
+ """Parse a single ChatGPT conversation with coalescing."""
300
+ conv_id = cast(str, conv.get("id"))
301
+ conv_title = conv.get("title", "Untitled")
302
+ mapping = conv.get("mapping", {}) or {}
303
+
304
+ # Conversation-level metadata
305
+ conv_metadata = {
306
+ "title": conv_title,
307
+ "current_node": conv.get("current_node"),
308
+ "moderation_results": conv.get("moderation_results"),
309
+ "plugin_ids": conv.get("plugin_ids"),
310
+ "gizmo_id": conv.get("gizmo_id"),
311
+ "conversation_template_id": conv.get("conversation_template_id"),
312
+ "safe_urls": conv.get("safe_urls"),
313
+ "is_archived": conv.get("is_archived"),
314
+ }
315
+
316
+ # Build active path set
317
+ active_path_ids = self._build_active_path(conv.get("current_node"), mapping)
318
+
319
+ # First pass: Parse all raw messages
320
+ raw_messages = self._parse_all_messages(mapping, conv_id, conv_title, conv_metadata, active_path_ids)
321
+
322
+ # Build lookup structures
323
+ msg_by_id: Dict[str, Dict] = {m["provider_message_id"]: m for m in raw_messages}
324
+ children_by_parent: Dict[str, List[str]] = {}
325
+ for msg in raw_messages:
326
+ parent_id = msg.get("provider_parent_id")
327
+ if parent_id:
328
+ children_by_parent.setdefault(parent_id, []).append(msg["provider_message_id"])
329
+
330
+ # Second pass: Coalesce and build final messages
331
+ messages = self._coalesce_messages(
332
+ raw_messages,
333
+ msg_by_id,
334
+ children_by_parent,
335
+ active_path_ids,
336
+ conv_id,
337
+ conv_title,
338
+ conv_metadata,
339
+ )
340
+
341
+ return messages
342
+
343
+ def _build_active_path(self, current_node: Optional[str], mapping: Dict) -> Set[str]:
344
+ """Walk from current_node to root to build active path set."""
345
+ active_path_ids: Set[str] = set()
346
+ node_id = current_node
347
+ hop_guard = 0
348
+ while node_id and node_id in mapping and hop_guard < 10_000:
349
+ active_path_ids.add(node_id)
350
+ node_id = mapping.get(node_id, {}).get("parent")
351
+ hop_guard += 1
352
+ return active_path_ids
353
+
354
+ def _parse_all_messages(
355
+ self,
356
+ mapping: Dict,
357
+ conv_id: str,
358
+ conv_title: str,
359
+ conv_metadata: Dict,
360
+ active_path_ids: Set[str],
361
+ ) -> List[Dict]:
362
+ """
363
+ First pass: Parse all messages from mapping into raw dict format.
364
+ This includes stubs, tool messages, thinking messages, etc.
365
+
366
+ Uses explicit field mappings where possible to ensure semantic correctness.
367
+ """
368
+ messages = []
369
+ mapping_ids = set(mapping.keys())
370
+ referenced_parents: Set[str] = set()
371
+ parent_min_create: Dict[str, float] = {}
372
+
373
+ for msg_id, msg_data in mapping.items():
374
+ message = msg_data.get("message") or {}
375
+ is_stub = not bool(message)
376
+
377
+ author = message.get("author", {}) if not is_stub else {}
378
+ content = message.get("content", {}) if not is_stub else {}
379
+
380
+ # Normalize content type
381
+ content_type = None
382
+ if isinstance(content, dict):
383
+ content_type = content.get("content_type")
384
+ if not content_type:
385
+ meta_ct = message.get("metadata", {}).get("content_type")
386
+ if isinstance(meta_ct, str):
387
+ content_type = meta_ct
388
+
389
+ # Get timestamps using explicit mappings
390
+ # Note: We extract raw values here, mappings convert them
391
+ create_time = message.get("create_time") if not is_stub else None
392
+ update_time = message.get("update_time") if not is_stub else None
393
+
394
+ # Provider parent ID - explicit mapping from msg_data.parent
395
+ # Semantic: This is the conversation tree parent, NOT a "reply to" relationship
396
+ provider_parent_id = msg_data.get("parent") or message.get("metadata", {}).get("parent_id")
397
+
398
+ if provider_parent_id:
399
+ referenced_parents.add(provider_parent_id)
400
+ if provider_parent_id and isinstance(create_time, (int, float)):
401
+ prev = parent_min_create.get(provider_parent_id)
402
+ if prev is None or create_time < prev:
403
+ parent_min_create[provider_parent_id] = create_time
404
+
405
+ # Apply field mappings for timestamp conversion
406
+ # These mappings ensure correct semantic interpretation
407
+ mapped_fields = self.apply_field_mappings(message, self.MESSAGE_FIELD_MAPPINGS)
408
+
409
+ is_active_path = msg_id in active_path_ids
410
+ msg_metadata = message.get("metadata", {}) or {}
411
+
412
+ raw_msg = {
413
+ "provider_message_id": msg_id,
414
+ "provider_parent_id": provider_parent_id,
415
+ # Use mapped values (with semantic correctness)
416
+ "role": mapped_fields.get("role"),
417
+ "author_name": mapped_fields.get("author_name"),
418
+ "created_at": mapped_fields.get("created_at"),
419
+ "updated_at": mapped_fields.get("updated_at"),
420
+ "message_order": mapped_fields.get("message_order"),
421
+ "model": mapped_fields.get("model"),
422
+ "recipient": message.get("recipient"),
423
+ # Raw values for internal processing
424
+ "content": content,
425
+ "content_type": content_type,
426
+ "create_time": create_time, # Keep raw for hash computation
427
+ "update_time": update_time, # Keep raw for reference
428
+ "is_active_path": is_active_path,
429
+ "is_stub": is_stub,
430
+ "is_visually_hidden": msg_metadata.get("is_visually_hidden_from_conversation"),
431
+ "is_user_system_message": msg_metadata.get("is_user_system_message"),
432
+ "children": msg_data.get("children", []),
433
+ "msg_metadata": msg_metadata,
434
+ "author": author,
435
+ "raw_message": message,
436
+ "raw_msg_data": msg_data,
437
+ }
438
+ messages.append(raw_msg)
439
+
440
+ # Add stub nodes for missing parents
441
+ # These are synthetic records for orphaned parent references
442
+ missing_parents = referenced_parents - mapping_ids
443
+ for missing_id in missing_parents:
444
+ if not missing_id:
445
+ continue
446
+ stub_ct = parent_min_create.get(missing_id)
447
+ # Create a minimal message dict for field mapping
448
+ stub_message = {"create_time": stub_ct, "update_time": stub_ct}
449
+ stub_mapped = self.apply_field_mappings(stub_message, self.MESSAGE_FIELD_MAPPINGS)
450
+
451
+ messages.append({
452
+ "provider_message_id": missing_id,
453
+ "provider_parent_id": None,
454
+ "role": None,
455
+ "author_name": None,
456
+ "content": {},
457
+ "content_type": None,
458
+ "create_time": stub_ct,
459
+ "update_time": stub_ct,
460
+ "created_at": stub_mapped.get("created_at"),
461
+ "updated_at": stub_mapped.get("updated_at"),
462
+ "message_order": stub_mapped.get("message_order"),
463
+ "is_active_path": False,
464
+ "is_stub": True,
465
+ "is_visually_hidden": None,
466
+ "is_user_system_message": None,
467
+ "children": [],
468
+ "msg_metadata": {},
469
+ "author": {},
470
+ "raw_message": {},
471
+ "raw_msg_data": {},
472
+ "synthetic_stub": True,
473
+ })
474
+
475
+ return messages
476
+
477
+ def _coalesce_messages(
478
+ self,
479
+ raw_messages: List[Dict],
480
+ msg_by_id: Dict[str, Dict],
481
+ children_by_parent: Dict[str, List[str]],
482
+ active_path_ids: Set[str],
483
+ conv_id: str,
484
+ conv_title: str,
485
+ conv_metadata: Dict,
486
+ ) -> List[NormalizedMessage]:
487
+ """
488
+ Second pass: Build normalized messages with coalesced content_blocks.
489
+
490
+ Strategy:
491
+ - Primary messages (user/assistant with displayable text) become rows
492
+ - Tool/thinking messages on the active path get coalesced into their parent
493
+ - Non-active-path branches remain as separate rows for history
494
+ """
495
+ messages: List[NormalizedMessage] = []
496
+ coalesced_ids: Set[str] = set() # Track which messages were coalesced
497
+
498
+ # Sort by message_order for consistent processing
499
+ sorted_raw = sorted(raw_messages, key=lambda m: m.get("message_order") or 0)
500
+
501
+ for raw_msg in sorted_raw:
502
+ msg_id = raw_msg["provider_message_id"]
503
+
504
+ # Skip if already coalesced into another message
505
+ if msg_id in coalesced_ids:
506
+ continue
507
+
508
+ # Determine if this is a primary message (should become a row)
509
+ is_primary = self._is_primary_message(raw_msg)
510
+
511
+ if is_primary:
512
+ # Collect children to coalesce (tool calls, thinking, results)
513
+ children_to_coalesce = self._collect_coalesce_candidates(
514
+ msg_id,
515
+ children_by_parent,
516
+ msg_by_id,
517
+ active_path_ids
518
+ )
519
+
520
+ # Mark coalesced messages
521
+ for child_id in children_to_coalesce:
522
+ coalesced_ids.add(child_id)
523
+
524
+ # Build content blocks from primary + coalesced children
525
+ content_blocks = self._build_content_blocks(
526
+ raw_msg,
527
+ [msg_by_id[cid] for cid in children_to_coalesce if cid in msg_by_id],
528
+ )
529
+
530
+ # Create normalized message
531
+ normalized = self._create_normalized_message(
532
+ raw_msg,
533
+ content_blocks,
534
+ conv_id,
535
+ conv_title,
536
+ conv_metadata,
537
+ )
538
+ messages.append(normalized)
539
+ else:
540
+ # Non-primary message that wasn't coalesced - emit as separate row
541
+ # This includes stubs, non-active-path branches, etc.
542
+ content_blocks = self._build_content_blocks(raw_msg, [])
543
+ normalized = self._create_normalized_message(
544
+ raw_msg,
545
+ content_blocks,
546
+ conv_id,
547
+ conv_title,
548
+ conv_metadata,
549
+ )
550
+ messages.append(normalized)
551
+
552
+ return messages
553
+
554
+ def _is_primary_message(self, raw_msg: Dict) -> bool:
555
+ """Determine if a message should be a primary row (vs coalesced).
556
+
557
+ Delegates to ``chatgpt_content.is_primary_message`` (stateless body).
558
+ """
559
+ return is_primary_message(raw_msg)
560
+
561
+ def _collect_coalesce_candidates(
562
+ self,
563
+ parent_id: str,
564
+ children_by_parent: Dict[str, List[str]],
565
+ msg_by_id: Dict[str, Dict],
566
+ active_path_ids: Set[str],
567
+ ) -> List[str]:
568
+ """Collect child messages to coalesce into parent.
569
+
570
+ Delegates to ``chatgpt_content.collect_coalesce_candidates``.
571
+ """
572
+ return collect_coalesce_candidates(
573
+ parent_id, children_by_parent, msg_by_id, active_path_ids
574
+ )
575
+
576
+ def _build_content_blocks(
577
+ self,
578
+ primary_msg: Dict,
579
+ coalesced_msgs: List[Dict]
580
+ ) -> List[ContentBlock]:
581
+ """Build content_blocks from primary message and coalesced children.
582
+
583
+ Delegates to ``chatgpt_content.build_content_blocks``.
584
+ """
585
+ return build_content_blocks(primary_msg, coalesced_msgs)
586
+
587
+ def _extract_content_blocks(
588
+ self,
589
+ raw_msg: Dict,
590
+ start_seq: int
591
+ ) -> tuple[List[ContentBlock], int]:
592
+ """Extract content blocks from a single raw message; returns (blocks, next_seq).
593
+
594
+ Delegates to ``chatgpt_content.extract_content_blocks``.
595
+ """
596
+ return extract_content_blocks(raw_msg, start_seq)
597
+
598
+ def _extract_special_block(
599
+ self,
600
+ raw_msg: Dict,
601
+ content: Any,
602
+ content_type: str,
603
+ role: str,
604
+ msg_id: Optional[str],
605
+ seq: int,
606
+ ) -> Optional[tuple[List[ContentBlock], int]]:
607
+ """Handle the exclusive content cases that fully consume a message.
608
+
609
+ Delegates to ``chatgpt_content.extract_special_block``.
610
+ """
611
+ return extract_special_block(
612
+ raw_msg, content, content_type, role, msg_id, seq
613
+ )
614
+
615
+ def _append_metadata_tool_calls(
616
+ self,
617
+ blocks: List[ContentBlock],
618
+ msg_metadata: Dict,
619
+ msg_id: Optional[str],
620
+ seq: int,
621
+ ) -> int:
622
+ """Append tool_use blocks from metadata tool_calls (alternate export formats).
623
+
624
+ Delegates to ``chatgpt_content.append_metadata_tool_calls``.
625
+ """
626
+ return append_metadata_tool_calls(blocks, msg_metadata, msg_id, seq)
627
+
628
+ def _append_text_content(
629
+ self, blocks: List[ContentBlock], content: Any, seq: int
630
+ ) -> int:
631
+ """Append a text block from regular content, if any.
632
+
633
+ Delegates to ``chatgpt_content.append_text_content``.
634
+ """
635
+ return append_text_content(blocks, content, seq)
636
+
637
+ def _append_image_parts(
638
+ self, blocks: List[ContentBlock], content: Any, seq: int
639
+ ) -> int:
640
+ """Append image blocks from multimodal content parts.
641
+
642
+ Delegates to ``chatgpt_content.append_image_parts``.
643
+ """
644
+ return append_image_parts(blocks, content, seq)
645
+
646
+ def _extract_text_from_content(self, content: Any) -> str:
647
+ """Extract text content from ChatGPT content object.
648
+
649
+ Delegates to ``chatgpt_content.extract_text_from_content``.
650
+ """
651
+ return extract_text_from_content(content)
652
+
653
+ def _create_normalized_message(
654
+ self,
655
+ raw_msg: Dict,
656
+ content_blocks: List[ContentBlock],
657
+ conv_id: str,
658
+ conv_title: str,
659
+ conv_metadata: Dict,
660
+ ) -> NormalizedMessage:
661
+ """Create a NormalizedMessage from raw message data."""
662
+ msg_id = raw_msg["provider_message_id"]
663
+
664
+ # Extract primary text from blocks
665
+ content_text = self.extract_text_from_blocks(content_blocks)
666
+
667
+ # Normalize role
668
+ role = self.normalize_role(raw_msg.get("role"))
669
+
670
+ # Build provider_data (exact original structure plus conversation context)
671
+ provider_data = {
672
+ "message": raw_msg.get("raw_message", {}),
673
+ "msg_data": raw_msg.get("raw_msg_data", {}),
674
+ "conversation_title": conv_title,
675
+ }
676
+ if raw_msg.get("model"):
677
+ provider_data["model"] = raw_msg["model"]
678
+ if raw_msg.get("synthetic_stub"):
679
+ provider_data["synthetic_stub"] = True
680
+
681
+ return {
682
+ "source_provider": "chatgpt",
683
+ "provider_message_id": msg_id,
684
+ "provider_message_id_lower": msg_id.lower() if msg_id else None,
685
+ "provider_conversation_id": conv_id,
686
+ "content_hash": self.hash_message(
687
+ msg_id,
688
+ cast(str, raw_msg.get("role")),
689
+ raw_msg.get("content"),
690
+ raw_msg.get("create_time"),
691
+ ),
692
+ "role": role,
693
+ "content_text": content_text,
694
+ "content_blocks": content_blocks,
695
+ "created_at": raw_msg.get("created_at"),
696
+ "updated_at": raw_msg.get("updated_at"),
697
+ "message_order": raw_msg.get("message_order"),
698
+ "provider_data": provider_data,
699
+ "provider_parent_id": raw_msg.get("provider_parent_id"),
700
+ "provider_parent_id_lower": raw_msg["provider_parent_id"].lower() if raw_msg.get("provider_parent_id") else None,
701
+ "is_active_path": raw_msg.get("is_active_path"),
702
+ "conversation_title": conv_title,
703
+ "conversation_metadata": conv_metadata,
704
+ }
705
+
706
+ def _timestamp_to_iso(self, ts: Optional[float]) -> Optional[str]:
707
+ """Convert Unix timestamp to ISO format (aware UTC, never naive-local)."""
708
+ if ts is None:
709
+ return None
710
+ return parse_timestamp_iso(ts)
711
+
712
+ def _timestamp_to_order(self, ts: Optional[float]) -> Optional[int]:
713
+ """Convert Unix timestamp to message order (microseconds)."""
714
+ if ts is None or not isinstance(ts, (int, float)):
715
+ return None
716
+ try:
717
+ return int(ts * 1_000_000)
718
+ except (ValueError, OverflowError):
719
+ return None
720
+
721
+ # parse_export_with_validation is inherited from ProviderParser (shared logic
722
+ # driving self._validators); no provider-specific override needed.