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,443 @@
1
+ """
2
+ Claude conversation export parser.
3
+
4
+ Parses the full Claude data export from Claude.ai (web interface).
5
+
6
+ ## Format
7
+
8
+ Export source: Claude.ai → Settings → Account → Export Data
9
+ File structure: ZIP archive containing:
10
+
11
+ - ``conversations.json`` - Chat conversations with messages (primary)
12
+ - ``memories.json`` - Claude's memory about the user (optional)
13
+ - ``projects.json`` - Claude Projects and attached docs (optional)
14
+ - ``users.json`` - Account information (optional)
15
+
16
+ ## Edge Cases
17
+
18
+ No Thinking Blocks (CRITICAL)
19
+ Claude.ai exports do NOT include extended thinking blocks. Thinking is
20
+ stored server-side and excluded from exports. For thinking blocks, use
21
+ Claude Code exports (``~/.claude/``) or Claude API responses.
22
+
23
+ No Branching
24
+ Messages are strictly linear - no alternate paths or regeneration history.
25
+
26
+ Sender Normalization
27
+ Claude uses ``"human"`` where we use ``"user"``. See :func:`_extract_role`.
28
+
29
+ Legacy Text Field
30
+ Older exports use ``text`` instead of ``content`` array. Both handled
31
+ transparently.
32
+
33
+ ## Validation
34
+
35
+ - **Thinking blocks**: Never present. 0% expected; >0% indicates wrong parser.
36
+ - **Parent references**: Not applicable (linear only).
37
+ - **Branching**: None.
38
+
39
+ ## Field Mapping
40
+
41
+ - ``sender`` → ``role`` (``"human"`` → ``"user"``)
42
+ - ``created_at`` → ``created_at`` (ISO timestamp)
43
+ - ``content`` or ``text`` → ``content_blocks``
44
+ """
45
+
46
+ from typing import Any, Dict, List, Optional, Union, cast
47
+
48
+ from thread_archive._thread_import.timestamps import parse_timestamp_iso
49
+
50
+ from .base import (
51
+ ContentBlock,
52
+ FieldMapping,
53
+ NormalizedMessage,
54
+ ProviderParser,
55
+ SemanticCheck,
56
+ ValidationSeverity,
57
+ )
58
+ from .config import CLAUDE_CONFIG, ProviderConfig
59
+ from .types.claude import ClaudeConversation, ClaudeExport
60
+ from .validators import (
61
+ ContentValidator,
62
+ ReferentialIntegrityValidator,
63
+ ThinkingBlockValidator,
64
+ TypeValidator,
65
+ )
66
+
67
+
68
+ def _parse_claude_timestamp(ts: Optional[str]) -> Optional[str]:
69
+ """Parse Claude timestamp to normalized ISO format."""
70
+ return parse_timestamp_iso(ts)
71
+
72
+
73
+ def _extract_created_at(msg: Dict[str, Any]) -> Optional[str]:
74
+ """Extract and normalize created_at timestamp."""
75
+ return _parse_claude_timestamp(msg.get("created_at"))
76
+
77
+
78
+ def _extract_updated_at(msg: Dict[str, Any]) -> Optional[str]:
79
+ """Extract and normalize updated_at timestamp."""
80
+ return _parse_claude_timestamp(msg.get("updated_at"))
81
+
82
+
83
+ def _extract_role(msg: Dict[str, Any]) -> str:
84
+ """Extract and normalize role from sender field.
85
+
86
+ Claude uses "human" where our schema uses "user".
87
+
88
+ >>> _extract_role({"sender": "human"})
89
+ 'user'
90
+ >>> _extract_role({"sender": "assistant"})
91
+ 'assistant'
92
+ >>> _extract_role({"sender": "Human"}) # case-insensitive
93
+ 'user'
94
+ >>> _extract_role({})
95
+ 'unknown'
96
+ """
97
+ sender = msg.get("sender", "")
98
+ if sender:
99
+ sender_lower = sender.lower()
100
+ if sender_lower == "human":
101
+ return "user"
102
+ elif sender_lower == "assistant":
103
+ return "assistant"
104
+ return sender or "unknown"
105
+
106
+
107
+ def _extract_text(msg: Dict[str, Any]) -> str:
108
+ """Extract primary text content."""
109
+ return msg.get("text", "")
110
+
111
+
112
+ class ClaudeParser(ProviderParser):
113
+ """Parser for Claude data export.
114
+
115
+ Handles both single conversations.json files and full export bundles
116
+ containing memories, projects, and user data.
117
+
118
+ Outputs normalized messages with content_blocks in the unified schema.
119
+
120
+ ## Architecture
121
+
122
+ Uses the pipeline architecture with:
123
+ - PROVIDER_CONFIG: Claude-specific configuration (thinking="never", no branching)
124
+ - Validators: ThinkingBlockValidator, ContentValidator, etc.
125
+ - Types: ClaudeExport, ClaudeConversation for typed input
126
+
127
+ ## Explicit Field Mappings
128
+
129
+ This parser uses explicit semantic mappings to ensure we correctly interpret
130
+ Claude's JSON structure. Each mapping documents what the field MEANS in
131
+ our model, not just where it comes from in the provider JSON.
132
+ """
133
+
134
+ PROVIDER_NAME = "claude"
135
+
136
+ # Provider configuration (replaces centralized PROVIDER_EXPECTATIONS)
137
+ PROVIDER_CONFIG: ProviderConfig = CLAUDE_CONFIG
138
+
139
+ # Explicit field mappings with semantic documentation
140
+ MESSAGE_FIELD_MAPPINGS = [
141
+ FieldMapping(
142
+ model_field="created_at",
143
+ extractor=_extract_created_at,
144
+ required=False,
145
+ semantic_doc=(
146
+ "When the message was originally SENT. Claude's created_at is already "
147
+ "the user-facing timestamp, same semantic as our model."
148
+ ),
149
+ ),
150
+ FieldMapping(
151
+ model_field="updated_at",
152
+ extractor=_extract_updated_at,
153
+ required=False,
154
+ semantic_doc=(
155
+ "When the message was last EDITED. Same semantic as our model."
156
+ ),
157
+ ),
158
+ FieldMapping(
159
+ model_field="role",
160
+ extractor=_extract_role,
161
+ required=True,
162
+ semantic_doc=(
163
+ "The actor role. Claude uses 'human'/'assistant', we normalize to "
164
+ "'user'/'assistant' for consistency across providers."
165
+ ),
166
+ ),
167
+ FieldMapping(
168
+ model_field="text",
169
+ extractor=_extract_text,
170
+ required=False,
171
+ semantic_doc=(
172
+ "Primary text content. In Claude, this is a top-level field that "
173
+ "may exist alongside or instead of content blocks."
174
+ ),
175
+ ),
176
+ ]
177
+
178
+ # Semantic checks for Claude-specific requirements
179
+ SEMANTIC_CHECKS = [
180
+ SemanticCheck(
181
+ name="user_context_on_first_message",
182
+ description=(
183
+ "First user message should have user context. In Claude, this often "
184
+ "comes from project memory or the memories.json file in the export."
185
+ ),
186
+ applies_to="first_user_message",
187
+ severity=ValidationSeverity.error,
188
+ ),
189
+ SemanticCheck(
190
+ name="thinking_on_assistant_messages",
191
+ description=(
192
+ "Claude 3.5+ Sonnet messages should have thinking blocks when using "
193
+ "extended thinking. Not all conversations use this feature."
194
+ ),
195
+ applies_to="assistant_messages",
196
+ severity=ValidationSeverity.warning,
197
+ ),
198
+ ]
199
+
200
+ def __init__(self, strict: bool = False):
201
+ """Initialize the Claude parser."""
202
+ super().__init__(strict=strict)
203
+ # Initialize validators from the new architecture
204
+ self._validators = [
205
+ ThinkingBlockValidator(self.PROVIDER_CONFIG, strict=strict),
206
+ ReferentialIntegrityValidator(self.PROVIDER_CONFIG, strict=strict),
207
+ TypeValidator(self.PROVIDER_CONFIG, strict=strict),
208
+ ContentValidator(self.PROVIDER_CONFIG, strict=strict),
209
+ ]
210
+
211
+ def parse_export(
212
+ self, data: Union[ClaudeExport, List[ClaudeConversation], Any]
213
+ ) -> List[NormalizedMessage]:
214
+ """
215
+ Parse Claude export data into normalized message format.
216
+
217
+ Uses explicit field mappings to ensure semantic correctness.
218
+
219
+ Args:
220
+ data: Either:
221
+ - A list of conversations (from conversations.json)
222
+ - A dict with 'conversations', 'memories', 'projects', 'users' keys
223
+ (full export bundle)
224
+
225
+ Returns:
226
+ List of NormalizedMessage dictionaries with content_blocks
227
+ """
228
+ # Handle both formats: raw conversations list or bundled export
229
+ if isinstance(data, dict):
230
+ conversations = data.get("conversations", [])
231
+ memories = data.get("memories", {})
232
+ projects = data.get("projects", [])
233
+ users = data.get("users", [])
234
+ else:
235
+ # Assume it's a list of conversations
236
+ conversations = data if isinstance(data, list) else []
237
+ memories = {}
238
+ projects = []
239
+ users = []
240
+
241
+ # Build lookup tables for enrichment
242
+ project_map = {p["uuid"]: p for p in cast(List[Dict[str, Any]], projects or [])}
243
+ user_map = {u["uuid"]: u for u in cast(List[Dict[str, Any]], users or [])}
244
+ project_memories = memories.get("project_memories", {}) if isinstance(memories, dict) else {}
245
+
246
+ messages: List[NormalizedMessage] = []
247
+
248
+ for conv in conversations:
249
+ conv_id = conv.get("uuid")
250
+ conv_name = conv.get("name", "Untitled")
251
+ conv_summary = conv.get("summary", "")
252
+
253
+ # Get account info
254
+ account_uuid = conv.get("account", {}).get("uuid")
255
+ account_info = user_map.get(account_uuid, {}) if account_uuid else {}
256
+
257
+ # Check if conversation is part of a project
258
+ _project = conv.get("project")
259
+ project_uuid = _project.get("uuid") if isinstance(_project, dict) else None
260
+ project_info = project_map.get(project_uuid, {}) if project_uuid else {}
261
+ project_memory = project_memories.get(project_uuid, "") if project_uuid else ""
262
+
263
+ # Conversation-level metadata
264
+ conv_metadata = {
265
+ "title": conv_name,
266
+ "summary": conv_summary,
267
+ "project_uuid": project_uuid,
268
+ "project_name": project_info.get("name"),
269
+ "project_description": project_info.get("description"),
270
+ "project_memory": project_memory,
271
+ "account_uuid": account_uuid,
272
+ "account_name": account_info.get("full_name"),
273
+ }
274
+
275
+ chat_messages = conv.get("chat_messages", [])
276
+
277
+ for msg_idx, msg in enumerate(chat_messages):
278
+ msg_id = msg.get("uuid")
279
+
280
+ # Apply explicit field mappings for semantic correctness
281
+ mapped_fields = self.apply_field_mappings(cast(Dict[str, Any], msg), self.MESSAGE_FIELD_MAPPINGS)
282
+ role = mapped_fields.get("role", "unknown")
283
+ text = mapped_fields.get("text", "")
284
+ created_at_iso = mapped_fields.get("created_at")
285
+ updated_at_iso = mapped_fields.get("updated_at")
286
+
287
+ raw_content_blocks = msg.get("content", [])
288
+ # Note: attachments and files available in msg if needed
289
+
290
+ # Convert Claude content blocks to normalized format
291
+ content_blocks = self._normalize_content_blocks(
292
+ cast(List[Dict[str, Any]], raw_content_blocks), text)
293
+
294
+ # Extract primary text from blocks
295
+ content_text = self.extract_text_from_blocks(content_blocks)
296
+ # If no text from blocks, use the top-level text field
297
+ if not content_text and text:
298
+ content_text = text
299
+
300
+ # Message order (use index since Claude doesn't provide explicit ordering)
301
+ # Semantic: Claude messages are ordered by position in array, not by timestamp
302
+ message_order = msg_idx
303
+
304
+ # Build the normalized message
305
+ msg_record: NormalizedMessage = cast(NormalizedMessage, {
306
+ "source_provider": self.PROVIDER_NAME,
307
+ "provider_message_id": msg_id,
308
+ "provider_message_id_lower": msg_id.lower() if msg_id else None,
309
+ "provider_conversation_id": conv_id,
310
+ "content_hash": self.hash_message(cast(str, msg_id), role, text, msg.get("created_at")),
311
+ "role": role,
312
+ "content_text": content_text,
313
+ "content_blocks": content_blocks,
314
+ "created_at": created_at_iso,
315
+ "updated_at": updated_at_iso,
316
+ "message_order": message_order,
317
+ # Provider data preservation - exact original message plus conversation context
318
+ "provider_data": {
319
+ "message": msg,
320
+ "conversation_title": conv_name,
321
+ },
322
+ # Claude doesn't have branching like ChatGPT
323
+ "provider_parent_id": None,
324
+ "provider_parent_id_lower": None,
325
+ "is_active_path": True,
326
+ # Conversation metadata
327
+ "conversation_title": conv_name,
328
+ "conversation_metadata": conv_metadata,
329
+ })
330
+
331
+ messages.append(msg_record)
332
+
333
+ return messages
334
+
335
+ def _normalize_content_blocks(
336
+ self,
337
+ raw_blocks: List[Dict],
338
+ fallback_text: str
339
+ ) -> List[ContentBlock]:
340
+ """
341
+ Convert Claude content blocks to normalized ContentBlock format.
342
+
343
+ Claude block types:
344
+ - text: Plain text content
345
+ - thinking: Extended thinking content (Claude 3.5+)
346
+ - tool_use: Tool/function call
347
+ - tool_result: Tool/function result
348
+ - token_budget: Internal token tracking (stored as system_metadata)
349
+ """
350
+ blocks: List[ContentBlock] = []
351
+ seq = 0
352
+
353
+ # If there are no blocks but there's text, create a text block
354
+ if not raw_blocks and fallback_text:
355
+ blocks.append(self.create_text_block(fallback_text, seq))
356
+ return blocks
357
+
358
+ handlers = {
359
+ "text": self._block_text,
360
+ "tool_use": self._block_tool_use,
361
+ "tool_result": self._block_tool_result,
362
+ "token_budget": self._block_token_budget,
363
+ "thinking": self._block_thinking,
364
+ }
365
+ for raw_block in raw_blocks:
366
+ block_type = raw_block.get("type", "")
367
+ handler = handlers.get(block_type, self._block_unknown)
368
+ block = handler(raw_block, seq)
369
+ if block is not None:
370
+ blocks.append(block)
371
+ seq += 1
372
+
373
+ return blocks
374
+
375
+ def _block_text(self, raw_block: Dict, seq: int) -> Optional[ContentBlock]:
376
+ text = raw_block.get("text", "")
377
+ if not text:
378
+ return None
379
+ return self.create_text_block(
380
+ text,
381
+ seq,
382
+ start_timestamp=raw_block.get("start_timestamp"),
383
+ stop_timestamp=raw_block.get("stop_timestamp"),
384
+ )
385
+
386
+ def _block_tool_use(self, raw_block: Dict, seq: int) -> Optional[ContentBlock]:
387
+ name = raw_block.get("name", "unknown")
388
+ input_data = raw_block.get("input", {})
389
+ return self.create_tool_use_block(
390
+ name,
391
+ input_data,
392
+ seq,
393
+ message=raw_block.get("message"),
394
+ display_content=raw_block.get("display_content"),
395
+ start_timestamp=raw_block.get("start_timestamp"),
396
+ stop_timestamp=raw_block.get("stop_timestamp"),
397
+ )
398
+
399
+ def _block_tool_result(self, raw_block: Dict, seq: int) -> Optional[ContentBlock]:
400
+ name = raw_block.get("name", "unknown")
401
+ content = raw_block.get("content")
402
+ is_error = raw_block.get("is_error", False)
403
+ return self.create_tool_result_block(
404
+ name,
405
+ content,
406
+ seq,
407
+ is_error=is_error,
408
+ display_content=raw_block.get("display_content"),
409
+ )
410
+
411
+ def _block_token_budget(self, raw_block: Dict, seq: int) -> Optional[ContentBlock]:
412
+ # Store token budget as system_metadata instead of dropping
413
+ block: ContentBlock = {
414
+ "type": "system_metadata",
415
+ "metadata_type": "token_budget",
416
+ "data": raw_block,
417
+ "seq": seq,
418
+ }
419
+ return block
420
+
421
+ def _block_thinking(self, raw_block: Dict, seq: int) -> Optional[ContentBlock]:
422
+ thinking_text = raw_block.get("thinking", "")
423
+ if not thinking_text:
424
+ return None
425
+ block: ContentBlock = {
426
+ "type": "thinking",
427
+ "text": thinking_text,
428
+ "seq": seq,
429
+ }
430
+ # Preserve signature if present
431
+ if raw_block.get("signature"):
432
+ cast(Dict[str, Any], block)["signature"] = raw_block["signature"]
433
+ return block
434
+
435
+ def _block_unknown(self, raw_block: Dict, seq: int) -> Optional[ContentBlock]:
436
+ # Unknown block type - preserve as text if it has content
437
+ text = raw_block.get("text") or raw_block.get("content")
438
+ if text and isinstance(text, str):
439
+ return self.create_text_block(text, seq)
440
+ return None
441
+
442
+ # parse_export_with_validation is inherited from ProviderParser (shared logic
443
+ # driving self._validators); no provider-specific override needed.