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,109 @@
1
+ """
2
+ TypedDict schemas for Cursor IDE chat export format.
3
+
4
+ Storage location (local machine, not devcontainers):
5
+ - macOS: ~/Library/Application Support/Cursor/User/workspaceStorage/
6
+ - Linux: ~/.config/Cursor/User/workspaceStorage/
7
+ - Windows: %APPDATA%\\Cursor\\User\\workspaceStorage\\
8
+
9
+ Each workspace has a hashed subdirectory containing state.vscdb (SQLite).
10
+
11
+ Export methods:
12
+ 1. cursor-export CLI tool (recommended) -> ZIP with cursor_chats.json
13
+ 2. Cursor UI: Chat menu -> Export Chat -> Markdown file (single chat only)
14
+
15
+ Key characteristics:
16
+ - Multiple format versions (v1 simple, v2 structured)
17
+ - Role normalization needed (human/ai/bot -> user/assistant)
18
+ - Timestamps may be ISO string or Unix (seconds/milliseconds)
19
+ - Linear conversations (no branching)
20
+ """
21
+
22
+ from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
23
+
24
+
25
+ class CursorContentBlock(TypedDict, total=False):
26
+ """A content block in a Cursor message.
27
+
28
+ Block types:
29
+ - text: Plain text content
30
+ - tool_use: Tool/function call
31
+ - tool_result: Tool/function result
32
+ - thinking: Extended thinking (model-dependent)
33
+ - code: Code block with language
34
+ """
35
+ type: str # "text", "tool_use", "tool_result", "thinking", "code"
36
+ text: Optional[str] # For text and thinking blocks
37
+ name: Optional[str] # For tool_use and tool_result
38
+ input: Optional[Dict[str, Any]] # For tool_use
39
+ content: Optional[Any] # For tool_result
40
+ is_error: Optional[bool] # For tool_result
41
+ code: Optional[str] # For code blocks
42
+ language: Optional[str] # For code blocks
43
+
44
+
45
+ class CursorToolCall(TypedDict, total=False):
46
+ """Tool call structure in Cursor messages."""
47
+ name: str
48
+ arguments: Optional[Union[str, Dict[str, Any]]]
49
+ input: Optional[Dict[str, Any]]
50
+ args: Optional[Union[str, Dict[str, Any]]]
51
+ call_id: Optional[str]
52
+ result: Optional[Any]
53
+ status: Optional[str]
54
+ user_decision: Optional[str]
55
+ function: Optional[Dict[str, Any]] # OpenAI-style nesting
56
+
57
+
58
+ class CursorCodeBlock(TypedDict, total=False):
59
+ """Code block from v2 export format (cursor_export.py output)."""
60
+ uri: Optional[Dict[str, Any]] # {path: str, ...}
61
+ version: Optional[int]
62
+ codeBlockIdx: Optional[int]
63
+ content: str
64
+ languageId: Optional[str]
65
+ language: Optional[str]
66
+
67
+
68
+ class CursorMessage(TypedDict, total=False):
69
+ """A single message in a Cursor conversation."""
70
+ id: Optional[str]
71
+ role: str # "user", "human", "assistant", "ai", "bot", "system", "tool"
72
+ content: Union[str, List[Any]] # String or array of content items
73
+ content_blocks: Optional[List[CursorContentBlock]]
74
+ created_at: Optional[Union[str, int, float]] # ISO or Unix timestamp
75
+ model: Optional[str]
76
+
77
+ # Tool-related fields
78
+ tool_calls: Optional[List[CursorToolCall]]
79
+ toolCalls: Optional[List[CursorToolCall]] # Alternative casing
80
+ tool_results: Optional[List[Dict[str, Any]]]
81
+ toolResults: Optional[List[Dict[str, Any]]] # Alternative casing
82
+ tool_call: Optional[CursorToolCall] # v2 single tool call
83
+
84
+ # Thinking
85
+ thinking: Optional[Union[str, List[Any]]]
86
+ reasoning: Optional[Union[str, List[Any]]]
87
+ thinking_duration_ms: Optional[int]
88
+
89
+ # Code blocks (v2 format)
90
+ code_blocks: Optional[List[CursorCodeBlock]]
91
+
92
+
93
+ class CursorConversation(TypedDict, total=False):
94
+ """A single Cursor conversation."""
95
+ id: str
96
+ title: Optional[str]
97
+ created_at: Optional[Union[str, int, float]]
98
+ updated_at: Optional[Union[str, int, float]]
99
+ workspace_hash: Optional[str]
100
+ workspace_name: Optional[str]
101
+ workspace_uri: Optional[str]
102
+ messages: List[CursorMessage]
103
+
104
+
105
+ class CursorExport(TypedDict, total=False):
106
+ """Cursor export bundle from cursor-export CLI tool."""
107
+ provider: Literal["cursor"]
108
+ conversations: List[CursorConversation]
109
+ export_metadata: Optional[Dict[str, Any]]
@@ -0,0 +1,83 @@
1
+ """
2
+ Pluggable validators for the parser pipeline.
3
+
4
+ Validators check messages against rules and report errors/warnings.
5
+ Each validator focuses on a single concern for testability.
6
+ """
7
+
8
+ import logging
9
+
10
+ from ..config import ProviderConfig, get_provider_config
11
+ from .base import BaseValidator, ValidationContext
12
+ from .content import ContentValidator
13
+ from .referential import ReferentialIntegrityValidator
14
+ from .thinking import ThinkingBlockValidator
15
+ from .types import TypeValidator
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ __all__ = [
20
+ "ValidationContext",
21
+ "BaseValidator",
22
+ "ThinkingBlockValidator",
23
+ "ReferentialIntegrityValidator",
24
+ "TypeValidator",
25
+ "ContentValidator",
26
+ "validate_messages",
27
+ ]
28
+
29
+ # Run order: content/type first (per-message shape), then thinking/referential
30
+ # (aggregate). All are read-only — they only collect issues into the context.
31
+ _VALIDATORS = (
32
+ ContentValidator,
33
+ TypeValidator,
34
+ ThinkingBlockValidator,
35
+ ReferentialIntegrityValidator,
36
+ )
37
+
38
+
39
+ # Validators with no cross-message / conversation-aggregate state — safe to run
40
+ # on a *partial* conversation (an incremental import batch) without false
41
+ # positives. TypeValidator only scans each block/role against the known set, so
42
+ # it catches provider format drift (a new block type / role) on any slice.
43
+ _BATCH_SAFE_VALIDATORS = (TypeValidator,)
44
+
45
+
46
+ def validate_messages(messages, conversation_id, source_provider, *, strict=False, batch_safe=False):
47
+ """Run the validators over one parsed/assembled conversation and return the
48
+ ValidationContext (``.errors`` / ``.warnings``). Pure — no I/O; the caller
49
+ decides what to do with the issues (we log them, never reject).
50
+
51
+ An unknown provider falls back to a permissive default config, so the
52
+ universal checks (unknown block types, missing content/timestamps) still run
53
+ everywhere; provider-specific checks (thinking expectation, expected roles)
54
+ only kick in for the configured providers.
55
+
56
+ ``batch_safe=True`` runs only the validators with no conversation-aggregate
57
+ state (just format-drift type checks), so it's correct on a *partial*
58
+ conversation — used by the incremental watcher path, where each poll imports
59
+ only a slice and the aggregate checks ("0% thinking", "no user messages")
60
+ would otherwise false-fire.
61
+ """
62
+ try:
63
+ config = get_provider_config(source_provider)
64
+ except KeyError:
65
+ config = ProviderConfig(provider_name=source_provider)
66
+ context = ValidationContext(
67
+ conversation_id=conversation_id or "",
68
+ source_provider=source_provider,
69
+ strict=strict,
70
+ )
71
+ for validator_cls in (_BATCH_SAFE_VALIDATORS if batch_safe else _VALIDATORS):
72
+ try:
73
+ validator_cls(config, strict=strict).validate(messages, context)
74
+ except Exception: # one bad validator must never break an import
75
+ # ...but it must never be silent either: surface the crash loudly
76
+ # (with traceback) while still letting the import proceed.
77
+ logger.exception(
78
+ "Validator %s crashed for conversation %r (provider %r); skipping it",
79
+ validator_cls.__name__,
80
+ conversation_id,
81
+ source_provider,
82
+ )
83
+ return context
@@ -0,0 +1,168 @@
1
+ """
2
+ Base validation infrastructure.
3
+
4
+ Provides ValidationContext and BaseValidator for building validators.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from enum import Enum
9
+ from typing import List, Optional
10
+
11
+ from ..base import NormalizedMessage
12
+ from ..config import ProviderConfig
13
+
14
+
15
+ class ValidationSeverity(str, Enum):
16
+ """Severity level for validation issues."""
17
+ error = "error"
18
+ warning = "warning"
19
+
20
+
21
+ @dataclass
22
+ class ValidationContext:
23
+ """Context for validation across a conversation.
24
+
25
+ Tracks state as messages are processed and collects errors/warnings.
26
+ """
27
+
28
+ conversation_id: str
29
+ source_provider: str
30
+ strict: bool = False # If True, warnings become errors
31
+
32
+ # Counters
33
+ message_count: int = 0
34
+ first_user_msg_seen: bool = False
35
+ first_user_msg_has_context: bool = False
36
+ assistant_msg_count: int = 0
37
+ assistant_msgs_with_thinking: int = 0
38
+ assistant_msgs_with_tools: int = 0
39
+
40
+ # Model-specific tracking
41
+ thinking_required_msgs: int = 0
42
+ thinking_required_msgs_with_thinking: int = 0
43
+
44
+ # Results
45
+ errors: List[str] = field(default_factory=list)
46
+ warnings: List[str] = field(default_factory=list)
47
+
48
+ def add_error(self, msg: str) -> None:
49
+ """Add a validation error."""
50
+ self.errors.append(msg)
51
+
52
+ def add_warning(self, msg: str) -> None:
53
+ """Add a validation warning."""
54
+ if self.strict:
55
+ self.errors.append(msg)
56
+ else:
57
+ self.warnings.append(msg)
58
+
59
+ def add_issue(self, msg: str, severity: ValidationSeverity) -> None:
60
+ """Add an issue with specified severity."""
61
+ if severity == ValidationSeverity.error:
62
+ self.add_error(msg)
63
+ else:
64
+ self.add_warning(msg)
65
+
66
+ @property
67
+ def has_errors(self) -> bool:
68
+ """Check if any errors were recorded."""
69
+ return len(self.errors) > 0
70
+
71
+
72
+ class BaseValidator:
73
+ """Base class for validators with common utilities.
74
+
75
+ Subclasses should override validate() and optionally
76
+ validate_conversation_end().
77
+ """
78
+
79
+ def __init__(self, config: ProviderConfig, strict: bool = False):
80
+ """Initialize validator.
81
+
82
+ Args:
83
+ config: Provider-specific configuration
84
+ strict: If True, treat warnings as errors
85
+ """
86
+ self.config = config
87
+ self.strict = strict
88
+
89
+ def validate(
90
+ self, messages: List[NormalizedMessage], context: ValidationContext
91
+ ) -> None:
92
+ """Validate messages and update context.
93
+
94
+ Override in subclasses to implement validation logic.
95
+
96
+ Args:
97
+ messages: Messages to validate
98
+ context: Validation context to update
99
+ """
100
+ pass
101
+
102
+ def validate_message(
103
+ self, msg: NormalizedMessage, context: ValidationContext
104
+ ) -> None:
105
+ """Validate a single message.
106
+
107
+ Override in subclasses for per-message validation.
108
+
109
+ Args:
110
+ msg: Message to validate
111
+ context: Validation context to update
112
+ """
113
+ pass
114
+
115
+ def validate_conversation_end(self, context: ValidationContext) -> None:
116
+ """Perform end-of-conversation validation.
117
+
118
+ Override in subclasses for aggregate validation.
119
+
120
+ Args:
121
+ context: Validation context to update
122
+ """
123
+ pass
124
+
125
+ def _add_issue(
126
+ self, context: ValidationContext, msg: str, severity: ValidationSeverity
127
+ ) -> None:
128
+ """Add an issue, respecting strict mode."""
129
+ if self.strict and severity == ValidationSeverity.warning:
130
+ severity = ValidationSeverity.error
131
+ context.add_issue(msg, severity)
132
+
133
+ @staticmethod
134
+ def _has_thinking_blocks(msg: NormalizedMessage) -> bool:
135
+ """Check if message has thinking/reasoning blocks."""
136
+ blocks = msg.get("content_blocks", [])
137
+ return any(b.get("type") == "thinking" for b in blocks)
138
+
139
+ @staticmethod
140
+ def _has_tool_blocks(msg: NormalizedMessage) -> bool:
141
+ """Check if message has tool use or result blocks."""
142
+ blocks = msg.get("content_blocks", [])
143
+ return any(b.get("type") in ("tool_use", "tool_result") for b in blocks)
144
+
145
+ @staticmethod
146
+ def _has_user_context(msg: NormalizedMessage) -> bool:
147
+ """Check if message has user context blocks."""
148
+ blocks = msg.get("content_blocks", [])
149
+ if any(b.get("type") == "system_context" for b in blocks):
150
+ return True
151
+ # Check provider_data for context indicators
152
+ provider_data = msg.get("provider_data", {})
153
+ raw_msg = provider_data.get("message", {})
154
+ metadata = raw_msg.get("metadata", {})
155
+ return bool(metadata.get("is_user_system_message"))
156
+
157
+ @staticmethod
158
+ def _get_model(msg: NormalizedMessage) -> Optional[str]:
159
+ """Extract model name from message."""
160
+ provider_data = msg.get("provider_data", {})
161
+ # ChatGPT stores in message.metadata.model_slug
162
+ raw_msg = provider_data.get("message", {})
163
+ metadata = raw_msg.get("metadata", {})
164
+ model = metadata.get("model_slug") or metadata.get("model")
165
+ if model:
166
+ return model
167
+ # Check top-level provider_data
168
+ return provider_data.get("model")
@@ -0,0 +1,80 @@
1
+ """
2
+ Content validator.
3
+
4
+ Validates message content presence and timestamps.
5
+ """
6
+
7
+ from typing import List
8
+
9
+ from ..base import NormalizedMessage
10
+ from .base import BaseValidator, ValidationContext, ValidationSeverity
11
+
12
+
13
+ class ContentValidator(BaseValidator):
14
+ """Validates message content and metadata.
15
+
16
+ Checks:
17
+ - Messages have content_text or content_blocks
18
+ - Messages have timestamps
19
+ - First user message tracking (for context validation)
20
+
21
+ Empty messages that aren't stubs are flagged as warnings.
22
+ """
23
+
24
+ def validate(
25
+ self, messages: List[NormalizedMessage], context: ValidationContext
26
+ ) -> None:
27
+ """Validate content across all messages."""
28
+ for msg in messages:
29
+ self._validate_message(msg, context)
30
+
31
+ # End-of-conversation check
32
+ self._validate_conversation_end(context)
33
+
34
+ def _validate_message(
35
+ self, msg: NormalizedMessage, context: ValidationContext
36
+ ) -> None:
37
+ """Validate a single message."""
38
+ context.message_count += 1
39
+ role = msg.get("role", "")
40
+
41
+ # Track first user message
42
+ if role == "user" and not context.first_user_msg_seen:
43
+ context.first_user_msg_seen = True
44
+ context.first_user_msg_has_context = self._has_user_context(msg)
45
+
46
+ # Check for content
47
+ has_text = bool(msg.get("content_text"))
48
+ has_blocks = bool(msg.get("content_blocks"))
49
+
50
+ if not has_text and not has_blocks:
51
+ # Skip validation for stub messages (ChatGPT branching artifacts)
52
+ provider_data = msg.get("provider_data", {})
53
+ is_stub = provider_data.get("synthetic_stub") or not provider_data.get(
54
+ "message"
55
+ )
56
+ if not is_stub:
57
+ self._add_issue(
58
+ context,
59
+ f"Message {msg.get('provider_message_id', 'unknown')} "
60
+ f"has no content_text or content_blocks",
61
+ ValidationSeverity.warning,
62
+ )
63
+
64
+ # Check for timestamp
65
+ if not msg.get("created_at"):
66
+ self._add_issue(
67
+ context,
68
+ f"Message {msg.get('provider_message_id', 'unknown')} lacks created_at timestamp",
69
+ ValidationSeverity.warning,
70
+ )
71
+
72
+ def _validate_conversation_end(self, context: ValidationContext) -> None:
73
+ """Perform end-of-conversation validation."""
74
+ # Check if we saw any user messages
75
+ if not context.first_user_msg_seen and context.message_count > 0:
76
+ self._add_issue(
77
+ context,
78
+ f"Conversation {context.conversation_id} has no user messages",
79
+ ValidationSeverity.warning,
80
+ )
@@ -0,0 +1,57 @@
1
+ """
2
+ Referential integrity validator.
3
+
4
+ Validates that parent references resolve to existing messages.
5
+ """
6
+
7
+ from typing import List, Set
8
+
9
+ from ..base import NormalizedMessage
10
+ from .base import BaseValidator, ValidationContext, ValidationSeverity
11
+
12
+ # Known root sentinel values that aren't real missing parents
13
+ ROOT_SENTINELS = {"", "root", "aaa", "00000000-0000-0000-0000-000000000000"}
14
+
15
+
16
+ class ReferentialIntegrityValidator(BaseValidator):
17
+ """Validates parent message references.
18
+
19
+ Missing parent references are common and expected in:
20
+ - ChatGPT: Branching exports may only include active path
21
+ - Claude Code: Context summarization removes old messages
22
+ but keeps parentUuid references
23
+
24
+ These emit warnings, not errors, since data is still usable.
25
+ """
26
+
27
+ def validate(
28
+ self, messages: List[NormalizedMessage], context: ValidationContext
29
+ ) -> None:
30
+ """Validate parent references resolve."""
31
+ if not self.config.has_parent_references:
32
+ # Provider doesn't use parent references
33
+ return
34
+
35
+ # Build set of all message IDs
36
+ message_ids: Set[str] = set()
37
+ for msg in messages:
38
+ msg_id = msg.get("provider_message_id")
39
+ if msg_id:
40
+ message_ids.add(msg_id)
41
+
42
+ # Count orphaned references
43
+ orphan_count = 0
44
+ for msg in messages:
45
+ parent_id = msg.get("provider_parent_id")
46
+ if parent_id and parent_id not in message_ids:
47
+ # Skip root sentinels
48
+ if parent_id not in ROOT_SENTINELS:
49
+ orphan_count += 1
50
+
51
+ if orphan_count > 0:
52
+ self._add_issue(
53
+ context,
54
+ f"Conversation {context.conversation_id}: {orphan_count} message(s) reference "
55
+ f"missing parents (expected for branched/summarized conversations)",
56
+ ValidationSeverity.warning,
57
+ )
@@ -0,0 +1,143 @@
1
+ """
2
+ Thinking block validator.
3
+
4
+ Validates thinking block presence based on provider expectations
5
+ and model-specific requirements.
6
+ """
7
+
8
+ from typing import List
9
+
10
+ from ..base import NormalizedMessage
11
+ from .base import BaseValidator, ValidationContext, ValidationSeverity
12
+
13
+
14
+ class ThinkingBlockValidator(BaseValidator):
15
+ """Validates thinking block presence.
16
+
17
+ Uses ProviderConfig to determine expectations:
18
+ - "never": Error if thinking blocks exist (wrong parser/source)
19
+ - "required": Error if no thinking blocks (incomplete export)
20
+ - "model_specific": Validate per-message based on model
21
+ - "optional": No validation
22
+
23
+ This is a critical validator for catching:
24
+ 1. Data parsed with wrong provider
25
+ 2. Incomplete exports missing thinking data
26
+ """
27
+
28
+ def validate(
29
+ self, messages: List[NormalizedMessage], context: ValidationContext
30
+ ) -> None:
31
+ """Validate thinking blocks across all messages."""
32
+ # First pass: count and track per-message
33
+ for msg in messages:
34
+ self._track_message(msg, context)
35
+
36
+ # Second pass: aggregate validation
37
+ self._validate_aggregate(context)
38
+
39
+ def _track_message(
40
+ self, msg: NormalizedMessage, context: ValidationContext
41
+ ) -> None:
42
+ """Track thinking block presence for a message."""
43
+ role = msg.get("role", "")
44
+ if role != "assistant":
45
+ return
46
+
47
+ context.assistant_msg_count += 1
48
+ has_thinking = self._has_thinking_blocks(msg)
49
+
50
+ if has_thinking:
51
+ context.assistant_msgs_with_thinking += 1
52
+
53
+ # Track model-specific requirements
54
+ if self.config.thinking_expectation == "model_specific":
55
+ model = self._get_model(msg)
56
+ if self.config.requires_thinking(model):
57
+ context.thinking_required_msgs += 1
58
+ if has_thinking:
59
+ context.thinking_required_msgs_with_thinking += 1
60
+
61
+ def _validate_aggregate(self, context: ValidationContext) -> None:
62
+ """Perform aggregate validation at conversation end."""
63
+ if context.assistant_msg_count == 0:
64
+ return
65
+
66
+ thinking_pct = (
67
+ context.assistant_msgs_with_thinking / context.assistant_msg_count * 100
68
+ )
69
+
70
+ expectation = self.config.thinking_expectation
71
+
72
+ if expectation == "never":
73
+ self._validate_never(context, thinking_pct)
74
+ elif expectation == "required":
75
+ self._validate_required(context, thinking_pct)
76
+ elif expectation == "model_specific":
77
+ self._validate_model_specific(context)
78
+
79
+ @staticmethod
80
+ def _is_subagent(context: ValidationContext) -> bool:
81
+ """Subagent sessions (agent-*) use Haiku without extended thinking."""
82
+ return bool(
83
+ context.conversation_id
84
+ and context.conversation_id.startswith("agent-")
85
+ )
86
+
87
+ def _validate_never(
88
+ self, context: ValidationContext, thinking_pct: float
89
+ ) -> None:
90
+ """``never``: any thinking blocks signal the wrong parser/source."""
91
+ if thinking_pct > 0:
92
+ self._add_issue(
93
+ context,
94
+ f"Conversation {context.conversation_id}: {self.config.provider_name} exports "
95
+ f"should NEVER have thinking blocks (found {thinking_pct:.0f}%). "
96
+ f"Wrong parser or data source?",
97
+ ValidationSeverity.error,
98
+ )
99
+
100
+ def _validate_required(
101
+ self, context: ValidationContext, thinking_pct: float
102
+ ) -> None:
103
+ """``required``: every (non-subagent) assistant turn must have thinking."""
104
+ if self._is_subagent(context):
105
+ return
106
+ if thinking_pct == 0:
107
+ self._add_issue(
108
+ context,
109
+ f"Conversation {context.conversation_id}: {self.config.provider_name} exports "
110
+ f"MUST have thinking blocks (found 0%). Incomplete export.",
111
+ ValidationSeverity.error,
112
+ )
113
+ elif thinking_pct < 100:
114
+ self._add_issue(
115
+ context,
116
+ f"Conversation {context.conversation_id}: {thinking_pct:.0f}% of "
117
+ f"assistant messages have thinking blocks (expected 100%)",
118
+ ValidationSeverity.warning,
119
+ )
120
+
121
+ def _validate_model_specific(self, context: ValidationContext) -> None:
122
+ """``model_specific``: only thinking-capable model turns are checked."""
123
+ if self._is_subagent(context) or context.thinking_required_msgs <= 0:
124
+ return
125
+ model_thinking_pct = (
126
+ context.thinking_required_msgs_with_thinking
127
+ / context.thinking_required_msgs
128
+ * 100
129
+ )
130
+ if model_thinking_pct == 0:
131
+ self._add_issue(
132
+ context,
133
+ f"Conversation {context.conversation_id}: thinking-capable model messages "
134
+ f"should have thinking blocks (found 0%). Incomplete export.",
135
+ ValidationSeverity.error,
136
+ )
137
+ elif model_thinking_pct < 100:
138
+ self._add_issue(
139
+ context,
140
+ f"Conversation {context.conversation_id}: {model_thinking_pct:.0f}% of "
141
+ f"thinking-capable model messages have thinking blocks",
142
+ ValidationSeverity.warning,
143
+ )