thread-archive 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- thread_archive/__init__.py +34 -0
- thread_archive/_api.py +2235 -0
- thread_archive/_config.py +126 -0
- thread_archive/_importers/__init__.py +81 -0
- thread_archive/_importers/_continuation.py +136 -0
- thread_archive/_importers/_cursor.py +103 -0
- thread_archive/_importers/_events.py +244 -0
- thread_archive/_importers/_line_stream.py +193 -0
- thread_archive/_importers/_read.py +82 -0
- thread_archive/_importers/_result.py +17 -0
- thread_archive/_importers/_sidecar.py +113 -0
- thread_archive/_importers/_state.py +240 -0
- thread_archive/_importers/_titles.py +179 -0
- thread_archive/_importers/antigravity.py +254 -0
- thread_archive/_importers/claude_code.py +293 -0
- thread_archive/_importers/claude_science.py +330 -0
- thread_archive/_importers/cloth.py +20 -0
- thread_archive/_importers/codex.py +324 -0
- thread_archive/_importers/cowork.py +107 -0
- thread_archive/_importers/cursor.py +432 -0
- thread_archive/_importers/exports.py +389 -0
- thread_archive/_importers/grok.py +600 -0
- thread_archive/_importers/opencode.py +538 -0
- thread_archive/_knowledge/__init__.py +67 -0
- thread_archive/_knowledge/_claims.py +116 -0
- thread_archive/_knowledge/_community.py +75 -0
- thread_archive/_knowledge/graph.py +208 -0
- thread_archive/_knowledge/materialize.py +212 -0
- thread_archive/_knowledge/write.py +438 -0
- thread_archive/_launchd.py +167 -0
- thread_archive/_mcp/__init__.py +1 -0
- thread_archive/_mcp/librarian.py +151 -0
- thread_archive/_mcp/server.py +307 -0
- thread_archive/_retrieval/__init__.py +280 -0
- thread_archive/_retrieval/_classify.py +80 -0
- thread_archive/_retrieval/_codex.py +157 -0
- thread_archive/_retrieval/_context.py +123 -0
- thread_archive/_retrieval/_extract.py +184 -0
- thread_archive/_retrieval/embed.py +186 -0
- thread_archive/_retrieval/format.py +149 -0
- thread_archive/_retrieval/fts.py +538 -0
- thread_archive/_retrieval/rank.py +228 -0
- thread_archive/_retrieval/read.py +908 -0
- thread_archive/_retrieval/rerank.py +143 -0
- thread_archive/_retrieval/vectors.py +611 -0
- thread_archive/_scripts/__init__.py +1 -0
- thread_archive/_scripts/backfill_recompute.py +328 -0
- thread_archive/_scripts/backfill_reconcile.py +296 -0
- thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
- thread_archive/_scripts/recover_dropped_events.py +190 -0
- thread_archive/_scripts/repair_grok_tool_names.py +118 -0
- thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
- thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
- thread_archive/_setup/__init__.py +12 -0
- thread_archive/_setup/clients.py +89 -0
- thread_archive/_setup/wizard.py +474 -0
- thread_archive/_store/__init__.py +45 -0
- thread_archive/_store/_base.py +173 -0
- thread_archive/_store/_defaults.py +57 -0
- thread_archive/_store/_types.py +38 -0
- thread_archive/_store/models.py +370 -0
- thread_archive/_store/schema.py +84 -0
- thread_archive/_thread_import/__init__.py +40 -0
- thread_archive/_thread_import/api.py +78 -0
- thread_archive/_thread_import/event_builder.py +810 -0
- thread_archive/_thread_import/exporters/__init__.py +9 -0
- thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
- thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
- thread_archive/_thread_import/exporters/cursor.py +582 -0
- thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
- thread_archive/_thread_import/parsers/__init__.py +191 -0
- thread_archive/_thread_import/parsers/base.py +698 -0
- thread_archive/_thread_import/parsers/chatgpt.py +722 -0
- thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
- thread_archive/_thread_import/parsers/claude.py +443 -0
- thread_archive/_thread_import/parsers/claude_code.py +1035 -0
- thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
- thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
- thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
- thread_archive/_thread_import/parsers/config/__init__.py +26 -0
- thread_archive/_thread_import/parsers/config/base.py +220 -0
- thread_archive/_thread_import/parsers/cursor.py +538 -0
- thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
- thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
- thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
- thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
- thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
- thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
- thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
- thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
- thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
- thread_archive/_thread_import/parsers/types/__init__.py +56 -0
- thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
- thread_archive/_thread_import/parsers/types/claude.py +120 -0
- thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
- thread_archive/_thread_import/parsers/types/cursor.py +109 -0
- thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
- thread_archive/_thread_import/parsers/validators/base.py +168 -0
- thread_archive/_thread_import/parsers/validators/content.py +80 -0
- thread_archive/_thread_import/parsers/validators/referential.py +57 -0
- thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
- thread_archive/_thread_import/parsers/validators/types.py +87 -0
- thread_archive/_thread_import/schemas/__init__.py +231 -0
- thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
- thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
- thread_archive/_thread_import/schemas/claude/v1.json +188 -0
- thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
- thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
- thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
- thread_archive/_thread_import/timestamps.py +57 -0
- thread_archive/_thread_import/tool_names.py +48 -0
- thread_archive/_truth/__init__.py +40 -0
- thread_archive/_truth/jsonl_log.py +2340 -0
- thread_archive/_truth/repair.py +322 -0
- thread_archive/_watcher/__init__.py +57 -0
- thread_archive/_watcher/base.py +65 -0
- thread_archive/_watcher/daemon.py +289 -0
- thread_archive/_watcher/export_drop.py +203 -0
- thread_archive/_watcher/exthost.py +316 -0
- thread_archive/_watcher/lazy.py +117 -0
- thread_archive/_watcher/sources.py +616 -0
- thread_archive/_web/__init__.py +15 -0
- thread_archive/_web/server.py +299 -0
- thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
- thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
- thread_archive/_web/static/index.html +13 -0
- thread_archive/cli.py +746 -0
- thread_archive-0.0.2.dist-info/METADATA +296 -0
- thread_archive-0.0.2.dist-info/RECORD +132 -0
- thread_archive-0.0.2.dist-info/WHEEL +4 -0
- thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
- thread_archive-0.0.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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.
|