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,698 @@
1
+ """
2
+ Base class for provider-specific chat export parsers.
3
+
4
+ Defines the unified ContentBlock schema used across all providers:
5
+ - ChatGPT: Tool calls, thinking, and results are coalesced into parent message blocks
6
+ - Claude: Content blocks are normalized to the common schema
7
+
8
+ Also provides explicit field mapping infrastructure for semantic correctness:
9
+ - FieldMapping: Declares how provider fields map to our model
10
+ - ValidationContext: Tracks validation state across conversation import
11
+ - ImportResult: Rich result with messages, errors, warnings, and coverage stats
12
+
13
+ Validation is now handled by pluggable validators in validators/.
14
+ """
15
+
16
+ import hashlib
17
+ import json
18
+ import math
19
+ from abc import ABC, abstractmethod
20
+ from dataclasses import dataclass, field
21
+ from enum import Enum
22
+ from typing import Any, Callable, Dict, List, Literal, Optional, TypedDict, Union
23
+
24
+ # =============================================================================
25
+ # Content Block Schema Version
26
+ # =============================================================================
27
+ # See apps/chat_import/migrations/content_blocks.py for migration logic
28
+ CURRENT_CONTENT_BLOCKS_VERSION = 1
29
+
30
+
31
+ # =============================================================================
32
+ # Content Block Types - Unified schema for all providers
33
+ # =============================================================================
34
+
35
+ class TextBlock(TypedDict, total=False):
36
+ """Plain text content block."""
37
+ type: Literal["text"]
38
+ text: str
39
+ seq: int
40
+ # Optional timing (Claude)
41
+ start_timestamp: Optional[str]
42
+ stop_timestamp: Optional[str]
43
+ # Optional provider-specific ID (for annotation targeting)
44
+ provider_block_id: Optional[str]
45
+
46
+
47
+ class ThinkingBlock(TypedDict, total=False):
48
+ """Model thinking/reasoning block."""
49
+ type: Literal["thinking"]
50
+ text: str
51
+ seq: int
52
+ # ChatGPT content_type variants: thoughts, analysis, reasoning_recap
53
+ thinking_type: Optional[str]
54
+ provider_block_id: Optional[str]
55
+
56
+
57
+ class ToolUseBlock(TypedDict, total=False):
58
+ """Tool/function call block."""
59
+ type: Literal["tool_use"]
60
+ name: str # Canonical tool name (Edit, Write, Read, Bash, etc.)
61
+ provider_tool_name: Optional[str] # Original provider name (edit_file_v2, etc.)
62
+ tool_call_id: Optional[str] # Unique ID for this tool call (links to tool_result)
63
+ input: Any # Tool arguments
64
+ seq: int
65
+ # Optional display content (Claude)
66
+ message: Optional[str]
67
+ display_content: Optional[Any]
68
+ start_timestamp: Optional[str]
69
+ stop_timestamp: Optional[str]
70
+ # If this came from a separate ChatGPT message, store its ID
71
+ provider_message_id: Optional[str]
72
+ provider_block_id: Optional[str]
73
+
74
+
75
+ class ToolResultBlock(TypedDict, total=False):
76
+ """Tool/function result block."""
77
+ type: Literal["tool_result"]
78
+ tool_use_id: Optional[str] # Links to corresponding tool_use block's tool_call_id
79
+ name: str
80
+ content: Any # Result content
81
+ is_error: bool
82
+ seq: int
83
+ display_content: Optional[Any]
84
+ # If this came from a separate ChatGPT message, store its ID
85
+ provider_message_id: Optional[str]
86
+ provider_block_id: Optional[str]
87
+
88
+
89
+ class ImageBlock(TypedDict, total=False):
90
+ """Image content block."""
91
+ type: Literal["image"]
92
+ url: Optional[str]
93
+ asset_pointer: Optional[str] # ChatGPT asset reference
94
+ mime_type: Optional[str]
95
+ seq: int
96
+ provider_block_id: Optional[str]
97
+
98
+
99
+ class FileBlock(TypedDict, total=False):
100
+ """File attachment block."""
101
+ type: Literal["file"]
102
+ name: str
103
+ url: Optional[str]
104
+ mime_type: Optional[str]
105
+ size: Optional[int]
106
+ seq: int
107
+ provider_block_id: Optional[str]
108
+
109
+
110
+ class CodeBlock(TypedDict, total=False):
111
+ """Code execution block."""
112
+ type: Literal["code"]
113
+ language: Optional[str]
114
+ code: str
115
+ seq: int
116
+ provider_block_id: Optional[str]
117
+
118
+
119
+ class SystemContextBlock(TypedDict, total=False):
120
+ """System/user context block (custom instructions, etc.)."""
121
+ type: Literal["system_context"]
122
+ text: str
123
+ context_type: Optional[str] # user_editable_context, model_editable_context, etc.
124
+ seq: int
125
+ provider_block_id: Optional[str]
126
+
127
+
128
+ class ParseErrorBlock(TypedDict, total=False):
129
+ """Preserved malformed data with error context.
130
+
131
+ Instead of silently dropping data that fails to parse, we store it here.
132
+ This ensures nothing is lost and makes parse failures visible.
133
+ """
134
+ type: Literal["parse_error"]
135
+ raw_text: str # The original text that failed to parse
136
+ error: str # The error message
137
+ line_number: Optional[int] # Line number in source file, if applicable
138
+ seq: int
139
+ provider_block_id: Optional[str]
140
+
141
+
142
+ class FileSnapshotBlock(TypedDict, total=False):
143
+ """File state snapshot (Claude Code file-history-snapshot).
144
+
145
+ Captures the state of files at a point in the conversation.
146
+ Used by Claude Code to track file changes during a session.
147
+ """
148
+ type: Literal["file_snapshot"]
149
+ files: List[Dict[str, Any]] # List of file states
150
+ timestamp: Optional[str]
151
+ seq: int
152
+ provider_block_id: Optional[str]
153
+
154
+
155
+ class ContextSummaryBlock(TypedDict, total=False):
156
+ """Context continuation summary (Claude Code summary type).
157
+
158
+ When a conversation runs out of context, Claude creates a summary
159
+ of prior messages. This block preserves that summary and tracks
160
+ which messages it replaced.
161
+ """
162
+ type: Literal["context_summary"]
163
+ text: str # The summary text
164
+ summarizes: Optional[List[str]] # Message IDs that were summarized
165
+ seq: int
166
+ provider_block_id: Optional[str]
167
+
168
+
169
+ class SystemMetadataBlock(TypedDict, total=False):
170
+ """Internal system metadata (token_budget, etc.).
171
+
172
+ Preserves provider-specific internal data that doesn't fit
173
+ other block types but shouldn't be dropped.
174
+ """
175
+ type: Literal["system_metadata"]
176
+ metadata_type: str # e.g., "token_budget"
177
+ data: Dict[str, Any] # The actual metadata
178
+ seq: int
179
+ provider_block_id: Optional[str]
180
+
181
+
182
+ # Union of all block types
183
+ ContentBlock = Union[
184
+ TextBlock,
185
+ ThinkingBlock,
186
+ ToolUseBlock,
187
+ ToolResultBlock,
188
+ ImageBlock,
189
+ FileBlock,
190
+ CodeBlock,
191
+ SystemContextBlock,
192
+ ParseErrorBlock,
193
+ FileSnapshotBlock,
194
+ ContextSummaryBlock,
195
+ SystemMetadataBlock,
196
+ ]
197
+
198
+
199
+ # =============================================================================
200
+ # Field Mapping Infrastructure
201
+ # =============================================================================
202
+
203
+
204
+ class ValidationSeverity(str, Enum):
205
+ """Severity level for validation issues."""
206
+
207
+ error = "error" # Fatal - import should abort
208
+ warning = "warning" # Non-fatal - import continues with warning
209
+
210
+
211
+ @dataclass
212
+ class FieldMapping:
213
+ """
214
+ Explicit mapping from provider field(s) to our model field.
215
+
216
+ This ensures we're intentional about semantic meaning, not just
217
+ matching field names. Each mapping documents what the field MEANS
218
+ in our model, not just where it comes from.
219
+
220
+ Examples:
221
+ # Simple field rename
222
+ FieldMapping(
223
+ model_field="created_at",
224
+ extractor=lambda msg: msg.get("create_time"),
225
+ semantic_doc="When the message was originally sent (immutable)"
226
+ )
227
+
228
+ # Complex extraction from nested structure
229
+ FieldMapping(
230
+ model_field="content_text",
231
+ extractor=extract_content_text, # dedicated function
232
+ semantic_doc="Primary display text for the message"
233
+ )
234
+
235
+ # Computed from multiple fields
236
+ FieldMapping(
237
+ model_field="message_order",
238
+ extractor=lambda msg: int(msg.get("timestamp", 0) * 1_000_000),
239
+ semantic_doc="Microsecond-precision ordering within conversation"
240
+ )
241
+ """
242
+
243
+ model_field: str
244
+ extractor: Callable[[Dict[str, Any]], Any]
245
+ required: bool = True
246
+ default: Any = None
247
+ semantic_doc: str = ""
248
+
249
+ def extract(self, raw_data: Dict[str, Any]) -> Any:
250
+ """Extract the field value from raw provider data."""
251
+ try:
252
+ value = self.extractor(raw_data)
253
+ if value is None and self.default is not None:
254
+ return self.default
255
+ return value
256
+ except Exception:
257
+ if self.required:
258
+ raise
259
+ return self.default
260
+
261
+
262
+ @dataclass
263
+ class SemanticCheck:
264
+ """
265
+ Defines a semantic validation rule that spans messages.
266
+
267
+ Unlike FieldMapping which extracts values, SemanticCheck validates
268
+ higher-level requirements like "first user message must have context".
269
+ """
270
+
271
+ name: str
272
+ description: str
273
+ applies_to: str # "first_user_message", "assistant_messages", "all_messages", etc.
274
+ severity: ValidationSeverity
275
+ check_fn: Optional[Callable[["NormalizedMessage", "ValidationContext"], bool]] = None
276
+
277
+
278
+ @dataclass
279
+ class ValidationContext:
280
+ """
281
+ Tracks validation state across a conversation import.
282
+
283
+ This context is passed through as messages are processed, allowing
284
+ validation rules to reason about the conversation as a whole.
285
+ """
286
+
287
+ conversation_id: str
288
+ source_provider: str
289
+ first_user_msg_seen: bool = False
290
+ first_user_msg_has_context: bool = False
291
+ message_count: int = 0
292
+ assistant_msg_count: int = 0
293
+ assistant_msgs_with_thinking: int = 0
294
+ assistant_msgs_with_tools: int = 0
295
+ # Model-specific tracking for fine-grained validation
296
+ thinking_required_msgs: int = 0 # Messages where thinking is required
297
+ thinking_required_msgs_with_thinking: int = 0
298
+ warnings: List[str] = field(default_factory=list)
299
+ errors: List[str] = field(default_factory=list)
300
+
301
+ def add_error(self, msg: str) -> None:
302
+ """Add a fatal validation error."""
303
+ self.errors.append(msg)
304
+
305
+ def add_warning(self, msg: str) -> None:
306
+ """Add a non-fatal validation warning."""
307
+ self.warnings.append(msg)
308
+
309
+ def add_issue(self, msg: str, severity: ValidationSeverity) -> None:
310
+ """Add an issue with specified severity."""
311
+ if severity == ValidationSeverity.error:
312
+ self.add_error(msg)
313
+ else:
314
+ self.add_warning(msg)
315
+
316
+ @property
317
+ def has_errors(self) -> bool:
318
+ """Check if any fatal errors occurred."""
319
+ return len(self.errors) > 0
320
+
321
+
322
+ @dataclass
323
+ class ImportResult:
324
+ """
325
+ Rich result from parsing a provider export.
326
+
327
+ Contains the parsed messages plus validation diagnostics and coverage stats.
328
+ """
329
+
330
+ messages: List["NormalizedMessage"]
331
+ validation_errors: List[str] = field(default_factory=list)
332
+ validation_warnings: List[str] = field(default_factory=list)
333
+ field_coverage: Dict[str, float] = field(default_factory=dict)
334
+ conversations_processed: int = 0
335
+ messages_processed: int = 0
336
+
337
+ @property
338
+ def success(self) -> bool:
339
+ """Check if import succeeded (no fatal errors)."""
340
+ return len(self.validation_errors) == 0
341
+
342
+ def merge(self, other: "ImportResult") -> "ImportResult":
343
+ """Merge another ImportResult into this one."""
344
+ self.messages.extend(other.messages)
345
+ self.validation_errors.extend(other.validation_errors)
346
+ self.validation_warnings.extend(other.validation_warnings)
347
+ self.conversations_processed += other.conversations_processed
348
+ self.messages_processed += other.messages_processed
349
+ # Merge field coverage (average)
350
+ for field_name, coverage in other.field_coverage.items():
351
+ if field_name in self.field_coverage:
352
+ # Weighted average based on message count
353
+ total = self.messages_processed + other.messages_processed
354
+ if total > 0:
355
+ self.field_coverage[field_name] = (
356
+ self.field_coverage[field_name] * self.messages_processed
357
+ + coverage * other.messages_processed
358
+ ) / total
359
+ else:
360
+ self.field_coverage[field_name] = coverage
361
+ return self
362
+
363
+
364
+ # =============================================================================
365
+ # Normalized Message Schema
366
+ # =============================================================================
367
+
368
+ class NormalizedMessage(TypedDict, total=False):
369
+ """
370
+ Unified message format output by all parsers.
371
+
372
+ This is the canonical format stored in the database.
373
+ """
374
+ # Core identifiers
375
+ source_provider: str
376
+ provider_message_id: str
377
+ provider_conversation_id: str
378
+ content_hash: str
379
+
380
+ # Normalized fields
381
+ role: str # 'user', 'assistant', 'system'
382
+ content_text: str # Primary display text (extracted from blocks)
383
+ content_blocks: List[ContentBlock] # Structured content
384
+ is_visually_hidden: bool # Display hint — system/snapshot/meta rows hidden by default
385
+
386
+ # Timestamps
387
+ created_at: Optional[str] # ISO format
388
+ updated_at: Optional[str] # ISO format
389
+ message_order: Optional[int] # Ordering within conversation
390
+
391
+ # Provider data preservation (exact original structure)
392
+ provider_data: Dict[str, Any]
393
+
394
+ # ChatGPT-specific (for branching)
395
+ provider_parent_id: Optional[str]
396
+ provider_parent_id_lower: Optional[str]
397
+ provider_message_id_lower: Optional[str]
398
+ is_active_path: Optional[bool]
399
+
400
+ # Conversation metadata
401
+ conversation_title: Optional[str]
402
+ conversation_metadata: Optional[Dict[str, Any]]
403
+
404
+
405
+ class ProviderParser(ABC):
406
+ """
407
+ Base class for provider-specific parsers.
408
+
409
+ Subclasses should define:
410
+ - PROVIDER_NAME: str - Identifier for this provider
411
+ - FIELD_MAPPINGS: List[FieldMapping] - Explicit field mappings (optional, for new-style parsers)
412
+ - SEMANTIC_CHECKS: List[SemanticCheck] - Semantic validation rules (optional)
413
+
414
+ And implement:
415
+ - parse_export() - Parse provider data into normalized messages
416
+ - parse_export_with_validation() - Parse with full validation (optional override)
417
+ """
418
+
419
+ # Override in subclasses
420
+ PROVIDER_NAME: str = "unknown"
421
+ FIELD_MAPPINGS: List[FieldMapping] = []
422
+ SEMANTIC_CHECKS: List[SemanticCheck] = []
423
+
424
+ def __init__(self, strict: bool = False):
425
+ """
426
+ Initialize the parser.
427
+
428
+ Args:
429
+ strict: If True, validation warnings become errors
430
+ """
431
+ self.strict = strict
432
+ # Concrete subclasses populate this with provider-specific validators;
433
+ # parse_export_with_validation drives them. Local import avoids the
434
+ # parsers.base <-> parsers.validators load-time cycle.
435
+ from .validators import BaseValidator
436
+
437
+ self._validators: List["BaseValidator"] = []
438
+
439
+ @abstractmethod
440
+ def parse_export(self, data: Any) -> List[NormalizedMessage]:
441
+ """
442
+ Parse provider export data into normalized message format.
443
+
444
+ Returns list of NormalizedMessage dicts with:
445
+ - source_provider: str - Provider identifier (chatgpt, claude, cursor)
446
+ - provider_message_id: str - Original message ID from provider
447
+ - provider_conversation_id: str - Original conversation ID
448
+ - content_hash: str - Deterministic hash for deduplication
449
+ - role: str - Normalized role (user, assistant, system)
450
+ - content_text: str - Primary display text
451
+ - content_blocks: List[ContentBlock] - Structured content blocks
452
+ - provider_data: dict - Exact original data for reconstruction
453
+ - created_at/updated_at: str (ISO format)
454
+ - message_order: int - Ordering within conversation
455
+ """
456
+ pass
457
+
458
+ def parse_export_with_validation(self, data: Any) -> ImportResult:
459
+ """Parse with validation using pluggable validators from validators/.
460
+
461
+ Subclasses initialize ``self._validators`` in ``__init__``; this shared
462
+ implementation drives them. Override only for genuinely provider-specific
463
+ validation flow.
464
+ """
465
+ # Local import avoids the parsers.base <-> parsers.validators import cycle
466
+ # (validators import NormalizedMessage from this module at module load).
467
+ from .validators import ValidationContext
468
+
469
+ messages = self.parse_export(data)
470
+
471
+ # Group messages by conversation
472
+ conversations: Dict[str, List[NormalizedMessage]] = {}
473
+ for msg in messages:
474
+ conv_id = msg.get("provider_conversation_id", "unknown")
475
+ if conv_id not in conversations:
476
+ conversations[conv_id] = []
477
+ conversations[conv_id].append(msg)
478
+
479
+ # Validate each conversation using new validators
480
+ all_errors: List[str] = []
481
+ all_warnings: List[str] = []
482
+
483
+ for conv_id, conv_messages in conversations.items():
484
+ context = ValidationContext(
485
+ conversation_id=conv_id,
486
+ source_provider=self.PROVIDER_NAME,
487
+ strict=self.strict,
488
+ )
489
+
490
+ # Sort by message_order
491
+ sorted_msgs = sorted(
492
+ conv_messages, key=lambda m: m.get("message_order") or 0
493
+ )
494
+
495
+ # Run each validator
496
+ for validator in self._validators:
497
+ validator.validate(sorted_msgs, context)
498
+
499
+ all_errors.extend(context.errors)
500
+ all_warnings.extend(context.warnings)
501
+
502
+ # Calculate field coverage
503
+ field_coverage = self._calculate_field_coverage(messages)
504
+
505
+ return ImportResult(
506
+ messages=messages,
507
+ validation_errors=all_errors,
508
+ validation_warnings=all_warnings,
509
+ field_coverage=field_coverage,
510
+ conversations_processed=len(conversations),
511
+ messages_processed=len(messages),
512
+ )
513
+
514
+ def _calculate_field_coverage(
515
+ self, messages: List[NormalizedMessage]
516
+ ) -> Dict[str, float]:
517
+ """Calculate the percentage of messages with each field populated."""
518
+ if not messages:
519
+ return {}
520
+
521
+ # Fields to track
522
+ tracked_fields = [
523
+ "created_at",
524
+ "updated_at",
525
+ "content_text",
526
+ "content_blocks",
527
+ "provider_parent_id",
528
+ "message_order",
529
+ ]
530
+
531
+ coverage: Dict[str, int] = {f: 0 for f in tracked_fields}
532
+ total = len(messages)
533
+
534
+ for msg in messages:
535
+ for field_name in tracked_fields:
536
+ value = msg.get(field_name) # type: ignore
537
+ if value is not None and value != "" and value != []:
538
+ coverage[field_name] += 1
539
+
540
+ return {f: count / total for f, count in coverage.items()}
541
+
542
+ def apply_field_mappings(
543
+ self, raw_data: Dict[str, Any], mappings: Optional[List[FieldMapping]] = None
544
+ ) -> Dict[str, Any]:
545
+ """
546
+ Apply field mappings to extract values from raw provider data.
547
+
548
+ Args:
549
+ raw_data: Raw provider data dictionary
550
+ mappings: List of FieldMapping to apply (uses self.FIELD_MAPPINGS if None)
551
+
552
+ Returns:
553
+ Dictionary with model field names as keys and extracted values
554
+ """
555
+ if mappings is None:
556
+ mappings = self.FIELD_MAPPINGS
557
+
558
+ result: Dict[str, Any] = {}
559
+ for mapping in mappings:
560
+ try:
561
+ value = mapping.extract(raw_data)
562
+ result[mapping.model_field] = value
563
+ except Exception as e:
564
+ if mapping.required:
565
+ raise ValueError(
566
+ f"Failed to extract required field '{mapping.model_field}': {e}"
567
+ )
568
+ result[mapping.model_field] = mapping.default
569
+
570
+ return result
571
+
572
+ @staticmethod
573
+ def hash_message(msg_id: str, role: str, content: Any, create_time: Any) -> str:
574
+ """Create deterministic hash for deduplication."""
575
+ # Normalize create_time so equivalent numeric values hash the same.
576
+ # Chat export files sometimes represent timestamps as ints or floats
577
+ # (e.g. 1700000000 vs 1700000000.0).
578
+ normalized_create_time = create_time
579
+ if isinstance(create_time, float):
580
+ if math.isfinite(create_time) and create_time.is_integer():
581
+ normalized_create_time = int(create_time)
582
+ hash_input = json.dumps(
583
+ {
584
+ "id": msg_id,
585
+ "role": role,
586
+ "content": content,
587
+ "create_time": normalized_create_time,
588
+ },
589
+ sort_keys=True,
590
+ )
591
+ return hashlib.sha256(hash_input.encode()).hexdigest()
592
+
593
+ @staticmethod
594
+ def extract_text_from_blocks(blocks: List[ContentBlock]) -> str:
595
+ """Extract primary display text from content blocks."""
596
+ text_parts = []
597
+ for block in blocks:
598
+ block_type = block.get("type", "")
599
+ if block_type == "text":
600
+ text = block.get("text", "")
601
+ if text:
602
+ text_parts.append(text)
603
+ elif block_type == "system_context":
604
+ # Include system context in display text
605
+ text = block.get("text", "")
606
+ if text:
607
+ text_parts.append(text)
608
+ return "\n\n".join(text_parts).strip()
609
+
610
+ @staticmethod
611
+ def normalize_role(role: Optional[str], sender: Optional[str] = None) -> str:
612
+ """Normalize role/sender to standard values."""
613
+ if role:
614
+ role_lower = role.lower()
615
+ if role_lower in ("user", "human"):
616
+ return "user"
617
+ elif role_lower == "assistant":
618
+ return "assistant"
619
+ elif role_lower in ("system", "tool"):
620
+ return role_lower
621
+ if sender:
622
+ sender_lower = sender.lower()
623
+ if sender_lower == "human":
624
+ return "user"
625
+ elif sender_lower == "assistant":
626
+ return "assistant"
627
+ return role or "unknown"
628
+
629
+ @staticmethod
630
+ def create_text_block(text: str, seq: int, **kwargs) -> TextBlock:
631
+ """Create a text content block."""
632
+ block: TextBlock = {"type": "text", "text": text, "seq": seq}
633
+ for key, value in kwargs.items():
634
+ if value is not None:
635
+ block[key] = value # type: ignore
636
+ return block
637
+
638
+ @staticmethod
639
+ def create_tool_use_block(
640
+ name: str,
641
+ input_data: Any,
642
+ seq: int,
643
+ provider_message_id: Optional[str] = None,
644
+ **kwargs
645
+ ) -> ToolUseBlock:
646
+ """Create a tool use content block."""
647
+ block: ToolUseBlock = {
648
+ "type": "tool_use",
649
+ "name": name,
650
+ "input": input_data,
651
+ "seq": seq,
652
+ }
653
+ if provider_message_id:
654
+ block["provider_message_id"] = provider_message_id
655
+ for key, value in kwargs.items():
656
+ if value is not None:
657
+ block[key] = value # type: ignore
658
+ return block
659
+
660
+ @staticmethod
661
+ def create_tool_result_block(
662
+ name: str,
663
+ content: Any,
664
+ seq: int,
665
+ is_error: bool = False,
666
+ provider_message_id: Optional[str] = None,
667
+ **kwargs
668
+ ) -> ToolResultBlock:
669
+ """Create a tool result content block."""
670
+ block: ToolResultBlock = {
671
+ "type": "tool_result",
672
+ "name": name,
673
+ "content": content,
674
+ "is_error": is_error,
675
+ "seq": seq,
676
+ }
677
+ if provider_message_id:
678
+ block["provider_message_id"] = provider_message_id
679
+ for key, value in kwargs.items():
680
+ if value is not None:
681
+ block[key] = value # type: ignore
682
+ return block
683
+
684
+ @staticmethod
685
+ def create_thinking_block(
686
+ text: str,
687
+ seq: int,
688
+ thinking_type: Optional[str] = None,
689
+ **kwargs
690
+ ) -> ThinkingBlock:
691
+ """Create a thinking/reasoning content block."""
692
+ block: ThinkingBlock = {"type": "thinking", "text": text, "seq": seq}
693
+ if thinking_type:
694
+ block["thinking_type"] = thinking_type
695
+ for key, value in kwargs.items():
696
+ if value is not None:
697
+ block[key] = value # type: ignore
698
+ return block