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,1035 @@
1
+ """
2
+ Claude Code CLI session parser.
3
+
4
+ Parses session history from Claude Code (Anthropic's CLI tool).
5
+
6
+ ## Format
7
+
8
+ Storage location: ``~/.claude/projects/<project-path>/<session-id>.jsonl``
9
+ Where ``<project-path>`` is the working directory with ``/`` replaced by ``-``.
10
+ Example: ``/home/user/project`` → ``-home-user-project``
11
+
12
+ Sessions are JSONL (one JSON object per line) with message types:
13
+
14
+ - ``type: "user"`` - User messages with ``message.content``
15
+ - ``type: "assistant"`` - Assistant responses with content blocks
16
+ - ``type: "system"`` - System events (commands, file snapshots, compaction)
17
+
18
+ ## Edge Cases
19
+
20
+ Context Compaction (``compact_boundary``)
21
+ When conversation exceeds token limit, Claude Code compacts history.
22
+ A ``system`` message with ``subtype: "compact_boundary"`` marks where
23
+ compaction occurred. The next ``user`` message contains a text summary
24
+ of prior conversation. **All original messages remain in the JSONL** -
25
+ they're just disconnected from the active parent chain.
26
+
27
+ Key fields on compact_boundary:
28
+ - ``parentUuid: null`` - breaks the parent chain
29
+ - ``logicalParentUuid`` - points to last message before compaction
30
+ - ``compactMetadata.preTokens`` - token count before compaction
31
+
32
+ Orphaned Parent References
33
+ After compaction, messages reference parents that exist but aren't in
34
+ the "active" chain. This is expected behavior, not data corruption.
35
+ Validation emits warnings, not errors.
36
+
37
+ Subagent Sessions (``agent-*``)
38
+ Claude Code spawns subagents for complex tasks. These have session IDs
39
+ starting with ``agent-`` (e.g., ``agent-a1b2c3d``). Subagent sessions:
40
+ - Use Haiku model (no extended thinking)
41
+ - Are linked to parent session via naming convention
42
+ - Should NOT fail validation for missing thinking blocks
43
+
44
+ IDE Context Tags
45
+ User messages may contain XML-like tags for IDE state:
46
+ - ``<ide_opened_file>...</ide_opened_file>``
47
+ - ``<ide_selection>...</ide_selection>``
48
+ These are extracted as ``ide_context`` content blocks.
49
+
50
+ Tool Results in User Messages
51
+ Tool execution results appear as ``user`` messages with content blocks
52
+ of type ``tool_result``. These are NOT coalesced (unlike ChatGPT).
53
+
54
+ ## Validation
55
+
56
+ - **Thinking blocks**: Model-specific. Required for Opus models, exempt for
57
+ Sonnet/Haiku and all ``agent-*`` sessions. See ``ProviderConfig``.
58
+ - **Parent references**: May be orphaned after compaction. Warnings only.
59
+ - **Branching**: Tree via ``uuid``/``parentUuid``, but rarely used.
60
+
61
+ ## Field Mapping
62
+
63
+ - ``uuid`` → ``provider_message_id``
64
+ - ``sessionId`` → ``provider_conversation_id``
65
+ - ``parentUuid`` → ``provider_parent_id``
66
+ - ``timestamp`` → ``created_at`` (ISO 8601)
67
+ - ``message.content`` → ``content_blocks``
68
+ """
69
+
70
+ import json
71
+ from typing import Any, Dict, List, Optional, Tuple, cast
72
+
73
+ from . import claude_code_blocks as _blocks
74
+ from .base import (
75
+ ContentBlock,
76
+ NormalizedMessage,
77
+ ProviderParser,
78
+ )
79
+ from .claude_code_ide import (
80
+ _extract_ide_context,
81
+ _extract_model_change,
82
+ _parse_iso_timestamp,
83
+ _timestamp_to_order,
84
+ )
85
+ from .config import CLAUDE_CODE_CONFIG, ProviderConfig
86
+ from .validators import (
87
+ ContentValidator,
88
+ ReferentialIntegrityValidator,
89
+ ThinkingBlockValidator,
90
+ TypeValidator,
91
+ )
92
+
93
+
94
+ class ClaudeCodeParser(ProviderParser):
95
+ """Parser for Claude Code CLI session exports.
96
+
97
+ Handles session JSONL files from `~/.claude/projects/<project>/`.
98
+
99
+ ## Architecture
100
+
101
+ Uses the pipeline architecture with:
102
+ - PROVIDER_CONFIG: Claude Code-specific configuration (model_specific thinking)
103
+ - Validators: ThinkingBlockValidator, ReferentialIntegrityValidator, etc.
104
+ - Types: ClaudeCodeExport, ClaudeCodeSession for typed input
105
+
106
+ ## How to Use
107
+
108
+ 1. Copy session files from `~/.claude/projects/<project-path>/*.jsonl`
109
+ 2. Create a ZIP archive or use directly
110
+ 3. Run: `chat-import --provider claude-code path/to/sessions/`
111
+
112
+ ## Explicit Field Mappings
113
+
114
+ Claude Code uses clear field names that mostly map directly:
115
+ - `timestamp` -> `created_at` (ISO 8601 format)
116
+ - `uuid` -> `provider_message_id`
117
+ - `parentUuid` -> `provider_parent_id`
118
+ - `sessionId` -> `provider_conversation_id`
119
+ - `message.role` -> `role`
120
+ - `message.content` -> structured content blocks
121
+ """
122
+
123
+ PROVIDER_NAME = "claude-code"
124
+
125
+ # Provider configuration (replaces centralized PROVIDER_EXPECTATIONS)
126
+ PROVIDER_CONFIG: ProviderConfig = CLAUDE_CODE_CONFIG
127
+
128
+ def __init__(self, strict: bool = False):
129
+ """Initialize the Claude Code parser."""
130
+ super().__init__(strict=strict)
131
+ # Initialize validators from the new architecture
132
+ self._validators = [
133
+ ThinkingBlockValidator(self.PROVIDER_CONFIG, strict=strict),
134
+ ReferentialIntegrityValidator(self.PROVIDER_CONFIG, strict=strict),
135
+ TypeValidator(self.PROVIDER_CONFIG, strict=strict),
136
+ ContentValidator(self.PROVIDER_CONFIG, strict=strict),
137
+ ]
138
+
139
+ def parse_export(self, data: Any) -> List[NormalizedMessage]:
140
+ """
141
+ Parse Claude Code session data into normalized messages.
142
+
143
+ Args:
144
+ data: Either:
145
+ - A dict with 'sessions' key (bundle of multiple sessions)
146
+ - A dict with 'lines' key (single session as parsed JSONL)
147
+ - A list of session line dicts
148
+ - Raw JSONL string
149
+
150
+ Returns:
151
+ List of NormalizedMessage dictionaries
152
+ """
153
+ # Handle different input formats
154
+ if isinstance(data, str):
155
+ # Raw JSONL string
156
+ return self._parse_jsonl(data)
157
+
158
+ if isinstance(data, dict):
159
+ # Check for bundle format
160
+ if "sessions" in data:
161
+ return self._parse_session_bundle(data)
162
+ # Check for single session with lines
163
+ if "lines" in data:
164
+ return self._parse_session_lines(data["lines"], data.get("session_id"))
165
+ # Check for claude_code_sessions.json format
166
+ if data.get("provider") == "claude-code":
167
+ return self._parse_session_bundle(data)
168
+ # Assume it's a single line/message
169
+ return self._parse_session_lines([data], None)
170
+
171
+ if isinstance(data, list):
172
+ # List of session lines
173
+ return self._parse_session_lines(data, None)
174
+
175
+ return []
176
+
177
+ # parse_export_with_validation is inherited from ProviderParser (shared logic
178
+ # driving self._validators); no provider-specific override needed.
179
+
180
+ def _parse_jsonl(
181
+ self, jsonl: str, session_id: Optional[str] = None
182
+ ) -> List[NormalizedMessage]:
183
+ """Parse raw JSONL string into messages.
184
+
185
+ JSON parse errors are stored as parse_error blocks rather than dropped.
186
+ """
187
+ lines = []
188
+ parse_errors = []
189
+
190
+ for line_num, line in enumerate(jsonl.strip().split("\n"), start=1):
191
+ line = line.strip()
192
+ if line:
193
+ try:
194
+ lines.append(json.loads(line))
195
+ except json.JSONDecodeError as e:
196
+ # Store parse error instead of dropping
197
+ parse_errors.append({
198
+ "line_number": line_num,
199
+ "raw_text": line[:1000], # Truncate very long lines
200
+ "error": str(e),
201
+ })
202
+
203
+ messages = self._parse_session_lines(lines, session_id)
204
+
205
+ # Add parse errors as system messages with parse_error blocks
206
+ for err in parse_errors:
207
+ messages.append(self._create_parse_error_message(
208
+ err["raw_text"],
209
+ err["error"],
210
+ err["line_number"],
211
+ session_id,
212
+ ))
213
+
214
+ return messages
215
+
216
+ def _create_parse_error_message(
217
+ self,
218
+ raw_text: str,
219
+ error: str,
220
+ line_number: Optional[int],
221
+ session_id: Optional[str],
222
+ ) -> NormalizedMessage:
223
+ """Create a message containing a parse error block."""
224
+ import hashlib
225
+
226
+ # Generate a deterministic ID from the raw content
227
+ content_hash = hashlib.sha256(raw_text.encode()).hexdigest()[:16]
228
+ msg_id = f"parse-error-{content_hash}"
229
+
230
+ return {
231
+ "source_provider": self.PROVIDER_NAME,
232
+ "provider_message_id": msg_id,
233
+ "provider_conversation_id": session_id or "unknown",
234
+ "role": "system",
235
+ "content_text": f"[Parse error at line {line_number}]: {error}",
236
+ "content_blocks": [{
237
+ "type": "parse_error",
238
+ "raw_text": raw_text,
239
+ "error": error,
240
+ "line_number": line_number,
241
+ "seq": 0,
242
+ }],
243
+ "content_hash": self.hash_message(msg_id, "system", raw_text, None),
244
+ "provider_data": {
245
+ "parse_error": True,
246
+ "line_number": line_number,
247
+ "raw_text": raw_text,
248
+ "error": error,
249
+ },
250
+ }
251
+
252
+ def _parse_session_bundle(self, bundle: Dict[str, Any]) -> List[NormalizedMessage]:
253
+ """Parse a bundle containing multiple sessions."""
254
+ messages: List[NormalizedMessage] = []
255
+
256
+ sessions = bundle.get("sessions", [])
257
+ for session in sessions:
258
+ session_id = session.get("session_id") or session.get("id")
259
+ lines = session.get("lines", [])
260
+ session_messages = self._parse_session_lines(lines, session_id)
261
+ messages.extend(session_messages)
262
+
263
+ return messages
264
+
265
+ def _parse_session_lines(
266
+ self, lines: List[Dict[str, Any]], session_id: Optional[str]
267
+ ) -> List[NormalizedMessage]:
268
+ """Parse a list of session line objects into messages."""
269
+ messages: List[NormalizedMessage] = []
270
+
271
+ # Track session info from first line
272
+ inferred_session_id = session_id
273
+ project_path = None
274
+ git_branch = None
275
+
276
+ for line in lines:
277
+ line_type = line.get("type", "")
278
+
279
+ # Extract session metadata from any line
280
+ if not inferred_session_id:
281
+ inferred_session_id = line.get("sessionId")
282
+ if not project_path:
283
+ project_path = line.get("cwd")
284
+ if not git_branch:
285
+ git_branch = line.get("gitBranch")
286
+
287
+ msg = self._parse_line(line, line_type, inferred_session_id, project_path)
288
+ if msg:
289
+ messages.append(msg)
290
+
291
+ messages = self._dedup_compaction_replays(messages)
292
+
293
+ # Coalesce linked assistant messages into single messages with combined blocks
294
+ # In Claude Code JSONL, one assistant "turn" spans multiple lines linked by parentUuid
295
+ messages = self._coalesce_assistant_chains(messages)
296
+
297
+ self._apply_derived_title(messages)
298
+
299
+ return messages
300
+
301
+ def _parse_line(
302
+ self,
303
+ line: Dict[str, Any],
304
+ line_type: str,
305
+ session_id: Optional[str],
306
+ project_path: Optional[str],
307
+ ) -> Optional[NormalizedMessage]:
308
+ """Dispatch a single session line to its type-specific parser.
309
+
310
+ ``summary``/``file-history-snapshot`` are stored (not dropped) as their
311
+ own message kinds; ``attachment`` lines carry a ``queued_command``
312
+ sub-kind (a steering message Ella typed mid-turn) that must survive as a
313
+ user message; unrecognized line types yield ``None``.
314
+ """
315
+ # Parsers taking (line, session_id, project_path).
316
+ full_parsers = {
317
+ "summary": self._parse_summary_message,
318
+ "file-history-snapshot": self._parse_file_snapshot_message,
319
+ "user": self._parse_user_message,
320
+ "assistant": self._parse_assistant_message,
321
+ "system": self._parse_system_message,
322
+ "attachment": self._parse_attachment_message,
323
+ }
324
+ parser = full_parsers.get(line_type)
325
+ if parser is not None:
326
+ return parser(line, session_id, project_path)
327
+
328
+ # Parsers taking (line, session_id) only.
329
+ if line_type == "queue-operation":
330
+ return self._parse_queue_operation(line, session_id)
331
+ if line_type == "progress":
332
+ return self._parse_progress_message(line, session_id)
333
+
334
+ # Any other (unrecognized or future) line kind is preserved verbatim
335
+ # rather than dropped — nothing vanishes without a trace, not even a line
336
+ # type Claude Code adds later.
337
+ return self._parse_unknown_line(line, line_type, session_id, project_path)
338
+
339
+ @staticmethod
340
+ def _dedup_compaction_replays(
341
+ messages: List[NormalizedMessage],
342
+ ) -> List[NormalizedMessage]:
343
+ """Drop compaction-replay duplicates (delegates to claude_code_blocks)."""
344
+ return _blocks.dedup_compaction_replays(messages)
345
+
346
+ @staticmethod
347
+ def _apply_derived_title(messages: List[NormalizedMessage]) -> None:
348
+ """Derive + apply the conversation title (delegates to claude_code_blocks)."""
349
+ _blocks.apply_derived_title(messages)
350
+
351
+ def _parse_user_message(
352
+ self,
353
+ line: Dict[str, Any],
354
+ session_id: Optional[str],
355
+ project_path: Optional[str],
356
+ ) -> Optional[NormalizedMessage]:
357
+ """Parse a user message line."""
358
+ msg_data = line.get("message", {})
359
+ if not msg_data:
360
+ return None
361
+
362
+ uuid = line.get("uuid", "")
363
+ parent_uuid = line.get("parentUuid")
364
+ timestamp = line.get("timestamp")
365
+ content = msg_data.get("content", "")
366
+
367
+ # Handle tool result content
368
+ content_blocks: List[ContentBlock] = []
369
+ content_text = ""
370
+ ide_context_blocks: List[Dict[str, Any]] = []
371
+
372
+ if isinstance(content, str):
373
+ # Extract IDE context (opened files, selections) from the content
374
+ cleaned_content, ide_context_blocks = _extract_ide_context(content)
375
+ content_text = cleaned_content
376
+ if cleaned_content:
377
+ content_blocks.append(self.create_text_block(cleaned_content, 0))
378
+ # A manual `/model` switch is otherwise stripped to nothing above (and the
379
+ # turn dropped). Preserve it as a model_change block so the archive can show
380
+ # the switch — the same marker Claude Code renders in its own transcript.
381
+ switched_to = _extract_model_change(content)
382
+ if switched_to:
383
+ content_blocks.append(cast(ContentBlock, {
384
+ "type": "model_change",
385
+ "to_model": switched_to,
386
+ "trigger": "user",
387
+ "seq": len(content_blocks),
388
+ }))
389
+ elif isinstance(content, list):
390
+ content_blocks, content_text, ide_context_blocks = (
391
+ self._user_content_blocks_from_list(content)
392
+ )
393
+
394
+ if not content_text:
395
+ content_text = self.extract_text_from_blocks(content_blocks)
396
+
397
+ # Add IDE context blocks (opened files, selections) after other content
398
+ if ide_context_blocks:
399
+ # Renumber seq values to come after existing blocks
400
+ base_seq = len(content_blocks)
401
+ for i, block in enumerate(ide_context_blocks):
402
+ block["seq"] = base_seq + i
403
+ content_blocks.extend(cast(List[ContentBlock], ide_context_blocks))
404
+
405
+ created_at = _parse_iso_timestamp(timestamp)
406
+ message_order = _timestamp_to_order(timestamp) if timestamp else 0
407
+
408
+ return {
409
+ "source_provider": self.PROVIDER_NAME,
410
+ "provider_message_id": uuid,
411
+ "provider_message_id_lower": uuid.lower() if uuid else None,
412
+ "provider_conversation_id": session_id or line.get("sessionId", ""),
413
+ "provider_parent_id": parent_uuid,
414
+ "provider_parent_id_lower": parent_uuid.lower() if parent_uuid else None,
415
+ "content_hash": self.hash_message(uuid, "user", content, timestamp),
416
+ "role": "user",
417
+ "content_text": content_text,
418
+ "content_blocks": content_blocks,
419
+ "created_at": created_at,
420
+ "updated_at": None,
421
+ "message_order": message_order,
422
+ "is_active_path": not line.get("isSidechain", False),
423
+ "provider_data": {
424
+ "line": line,
425
+ "cwd": project_path or line.get("cwd"),
426
+ "git_branch": line.get("gitBranch"),
427
+ "version": line.get("version"),
428
+ "thinking_metadata": line.get("thinkingMetadata"),
429
+ "todos": line.get("todos"),
430
+ },
431
+ "conversation_title": None,
432
+ "conversation_metadata": {
433
+ "project_path": project_path or line.get("cwd"),
434
+ "git_branch": line.get("gitBranch"),
435
+ },
436
+ }
437
+
438
+ def _parse_attachment_message(
439
+ self,
440
+ line: Dict[str, Any],
441
+ session_id: Optional[str],
442
+ project_path: Optional[str],
443
+ ) -> Optional[NormalizedMessage]:
444
+ """Parse an ``attachment`` line.
445
+
446
+ Claude Code writes several attachment sub-kinds. Most are context
447
+ injections (todo reminders, tool/agent/skill listing deltas) with no
448
+ user signal, and are dropped. The exception is ``queued_command`` — a
449
+ message Ella typed *while the agent was mid-turn* (a "steering"
450
+ message). Its text lives only here: it is delivered to the model but
451
+ never re-emitted as a normal ``user`` line, and the ``queue-operation``
452
+ bookkeeping records carry no content. So if we don't reconstruct it, the
453
+ steering turn is lost from the archive (and the viewer). Rebuild it as a
454
+ user message, stamped with the time it was queued.
455
+ """
456
+ attachment = line.get("attachment") or {}
457
+ if attachment.get("type") != "queued_command":
458
+ # Every other attachment sub-kind (todo reminders, tool/agent/skill
459
+ # listing deltas, context injections) is still real content Claude
460
+ # Code fed the model — preserve it as a hidden system record rather
461
+ # than dropping it. "Archivist, Not Filter."
462
+ return self._preserve_attachment(line, attachment, session_id, project_path)
463
+
464
+ prompt = attachment.get("prompt")
465
+ content_blocks: List[ContentBlock] = []
466
+ if isinstance(prompt, str):
467
+ if prompt:
468
+ content_blocks.append(self.create_text_block(prompt, 0))
469
+ elif isinstance(prompt, list):
470
+ for block in prompt:
471
+ if isinstance(block, dict) and block.get("type") == "text":
472
+ text = block.get("text", "")
473
+ if text:
474
+ content_blocks.append(
475
+ self.create_text_block(text, len(content_blocks))
476
+ )
477
+
478
+ content_text = self.extract_text_from_blocks(content_blocks)
479
+ if not content_text:
480
+ return None
481
+
482
+ uuid = line.get("uuid", "")
483
+ parent_uuid = line.get("parentUuid")
484
+ # The steering message's own time lives on the attachment (when it was
485
+ # queued); fall back to the enclosing line's timestamp.
486
+ timestamp = attachment.get("timestamp") or line.get("timestamp")
487
+ created_at = _parse_iso_timestamp(timestamp)
488
+ message_order = _timestamp_to_order(timestamp) if timestamp else 0
489
+
490
+ return {
491
+ "source_provider": self.PROVIDER_NAME,
492
+ "provider_message_id": uuid,
493
+ "provider_message_id_lower": uuid.lower() if uuid else None,
494
+ "provider_conversation_id": session_id or line.get("sessionId", ""),
495
+ "provider_parent_id": parent_uuid,
496
+ "provider_parent_id_lower": parent_uuid.lower() if parent_uuid else None,
497
+ "content_hash": self.hash_message(uuid, "user", prompt, timestamp),
498
+ "role": "user",
499
+ "content_text": content_text,
500
+ "content_blocks": content_blocks,
501
+ "created_at": created_at,
502
+ "updated_at": None,
503
+ "message_order": message_order,
504
+ "is_active_path": not line.get("isSidechain", False),
505
+ "provider_data": {
506
+ "line": line,
507
+ "cwd": project_path or line.get("cwd"),
508
+ "git_branch": line.get("gitBranch"),
509
+ "version": line.get("version"),
510
+ "queued_command": True,
511
+ "command_mode": attachment.get("commandMode"),
512
+ },
513
+ "conversation_title": None,
514
+ "conversation_metadata": {
515
+ "project_path": project_path or line.get("cwd"),
516
+ "git_branch": line.get("gitBranch"),
517
+ },
518
+ }
519
+
520
+ def _preserve_attachment(
521
+ self,
522
+ line: Dict[str, Any],
523
+ attachment: Dict[str, Any],
524
+ session_id: Optional[str],
525
+ project_path: Optional[str],
526
+ ) -> NormalizedMessage:
527
+ """Preserve a non-``queued_command`` attachment as a hidden system record.
528
+
529
+ These are context injections with no direct user signal, but they are
530
+ still content the model was shown — keep a summary event (with the full
531
+ raw attachment in provider_data) instead of dropping the line."""
532
+ uuid = line.get("uuid", "")
533
+ parent_uuid = line.get("parentUuid")
534
+ timestamp = attachment.get("timestamp") or line.get("timestamp")
535
+ atype = attachment.get("type") or "attachment"
536
+ created_at = _parse_iso_timestamp(timestamp)
537
+ message_order = _timestamp_to_order(timestamp) if timestamp else 0
538
+ return {
539
+ "source_provider": self.PROVIDER_NAME,
540
+ "provider_message_id": uuid,
541
+ "provider_message_id_lower": uuid.lower() if uuid else None,
542
+ "provider_conversation_id": session_id or line.get("sessionId", ""),
543
+ "provider_parent_id": parent_uuid,
544
+ "provider_parent_id_lower": parent_uuid.lower() if parent_uuid else None,
545
+ "content_hash": self.hash_message(uuid, "system", attachment, timestamp),
546
+ "role": "system",
547
+ "content_text": f"[attachment: {atype}]",
548
+ "content_blocks": [cast(ContentBlock, {
549
+ "type": "attachment",
550
+ "attachment_type": atype,
551
+ "raw": attachment,
552
+ "seq": 0,
553
+ })],
554
+ "created_at": created_at,
555
+ "updated_at": None,
556
+ "message_order": message_order,
557
+ "is_active_path": not line.get("isSidechain", False),
558
+ "is_visually_hidden": True,
559
+ "provider_data": {
560
+ "line": line,
561
+ "attachment_type": atype,
562
+ "cwd": project_path or line.get("cwd"),
563
+ },
564
+ "conversation_title": None,
565
+ "conversation_metadata": {"project_path": project_path or line.get("cwd")},
566
+ }
567
+
568
+ def _parse_unknown_line(
569
+ self,
570
+ line: Dict[str, Any],
571
+ line_type: str,
572
+ session_id: Optional[str],
573
+ project_path: Optional[str],
574
+ ) -> NormalizedMessage:
575
+ """Preserve a line whose ``type`` the parser doesn't model.
576
+
577
+ Rather than dropping an unrecognized (or future) Claude Code line kind,
578
+ keep it as a system record carrying the whole raw line — so nothing,
579
+ including line kinds Anthropic adds later, vanishes without a trace."""
580
+ uuid = line.get("uuid", "")
581
+ parent_uuid = line.get("parentUuid")
582
+ timestamp = line.get("timestamp")
583
+ created_at = _parse_iso_timestamp(timestamp)
584
+ message_order = _timestamp_to_order(timestamp) if timestamp else 0
585
+ return {
586
+ "source_provider": self.PROVIDER_NAME,
587
+ "provider_message_id": uuid,
588
+ "provider_message_id_lower": uuid.lower() if uuid else None,
589
+ "provider_conversation_id": session_id or line.get("sessionId", ""),
590
+ "provider_parent_id": parent_uuid,
591
+ "provider_parent_id_lower": parent_uuid.lower() if parent_uuid else None,
592
+ "content_hash": self.hash_message(uuid, "system", line, timestamp),
593
+ "role": "system",
594
+ "content_text": f"[unrecognized line: {line_type or 'unknown'}]",
595
+ "content_blocks": [cast(ContentBlock, {
596
+ "type": "unknown_line",
597
+ "line_type": line_type,
598
+ "raw": line,
599
+ "seq": 0,
600
+ })],
601
+ "created_at": created_at,
602
+ "updated_at": None,
603
+ "message_order": message_order,
604
+ "is_active_path": not line.get("isSidechain", False),
605
+ "provider_data": {"line": line, "line_type": line_type},
606
+ "conversation_title": None,
607
+ "conversation_metadata": {},
608
+ }
609
+
610
+ def _user_content_blocks_from_list(
611
+ self, content: list
612
+ ) -> "tuple[List[ContentBlock], str, List[Dict[str, Any]]]":
613
+ """Build content blocks from a user message's list content.
614
+
615
+ Delegates to ``claude_code_blocks.user_content_blocks_from_list``.
616
+ Returns ``(content_blocks, content_text, ide_context_blocks)``.
617
+ """
618
+ return _blocks.user_content_blocks_from_list(content)
619
+
620
+ def _parse_xml_function_calls(
621
+ self, raw_content: str, seq: int
622
+ ) -> Tuple[List[Dict[str, Any]], List[str], int]:
623
+ """Parse old-format <function_calls> XML from string content.
624
+
625
+ Delegates to ``claude_code_blocks.parse_xml_function_calls``.
626
+ Returns ``(content_blocks, content_text_parts, updated_seq)``.
627
+ """
628
+ return _blocks.parse_xml_function_calls(raw_content, seq)
629
+
630
+ def _assistant_content(
631
+ self, raw_content: Any
632
+ ) -> Tuple[List[ContentBlock], List[str]]:
633
+ """Build (content_blocks, content_text_parts) for an assistant message.
634
+
635
+ Delegates to ``claude_code_blocks.assistant_content``.
636
+ """
637
+ return _blocks.assistant_content(raw_content)
638
+
639
+ def _append_assistant_block(
640
+ self,
641
+ block: Dict[str, Any],
642
+ content_blocks: List[ContentBlock],
643
+ content_text_parts: List[str],
644
+ seq: int,
645
+ ) -> int:
646
+ """Append one list-shaped assistant block; return the next seq value.
647
+
648
+ Delegates to ``claude_code_blocks.append_assistant_block``.
649
+ """
650
+ return _blocks.append_assistant_block(
651
+ block, content_blocks, content_text_parts, seq
652
+ )
653
+
654
+ def _parse_assistant_message(
655
+ self,
656
+ line: Dict[str, Any],
657
+ session_id: Optional[str],
658
+ project_path: Optional[str],
659
+ ) -> Optional[NormalizedMessage]:
660
+ """Parse an assistant message line."""
661
+ msg_data = line.get("message", {})
662
+ if not msg_data:
663
+ return None
664
+
665
+ uuid = line.get("uuid", "")
666
+ parent_uuid = line.get("parentUuid")
667
+ timestamp = line.get("timestamp")
668
+ raw_content = msg_data.get("content", [])
669
+ model = msg_data.get("model", "")
670
+
671
+ content_blocks, content_text_parts = self._assistant_content(raw_content)
672
+ content_text = "\n".join(content_text_parts)
673
+
674
+ created_at = _parse_iso_timestamp(timestamp)
675
+ message_order = _timestamp_to_order(timestamp) if timestamp else 0
676
+
677
+ # Extract usage info
678
+ usage = msg_data.get("usage", {})
679
+
680
+ return {
681
+ "source_provider": self.PROVIDER_NAME,
682
+ "provider_message_id": uuid,
683
+ "provider_message_id_lower": uuid.lower() if uuid else None,
684
+ "provider_conversation_id": session_id or line.get("sessionId", ""),
685
+ "provider_parent_id": parent_uuid,
686
+ "provider_parent_id_lower": parent_uuid.lower() if parent_uuid else None,
687
+ "content_hash": self.hash_message(uuid, "assistant", raw_content, timestamp),
688
+ "role": "assistant",
689
+ "content_text": content_text,
690
+ "content_blocks": content_blocks,
691
+ "created_at": created_at,
692
+ "updated_at": None,
693
+ "message_order": message_order,
694
+ "is_active_path": not line.get("isSidechain", False),
695
+ "provider_data": {
696
+ "line": line,
697
+ "message_id": msg_data.get("id"),
698
+ "model": model,
699
+ "stop_reason": msg_data.get("stop_reason"),
700
+ "usage": usage,
701
+ "request_id": line.get("requestId"),
702
+ "cwd": project_path or line.get("cwd"),
703
+ "git_branch": line.get("gitBranch"),
704
+ "version": line.get("version"),
705
+ },
706
+ "conversation_title": None,
707
+ "conversation_metadata": {
708
+ "project_path": project_path or line.get("cwd"),
709
+ "git_branch": line.get("gitBranch"),
710
+ "model": model,
711
+ },
712
+ }
713
+
714
+ def _parse_system_message(
715
+ self,
716
+ line: Dict[str, Any],
717
+ session_id: Optional[str],
718
+ project_path: Optional[str],
719
+ ) -> Optional[NormalizedMessage]:
720
+ """Parse a system message (command, event, etc.)."""
721
+ uuid = line.get("uuid", "")
722
+ parent_uuid = line.get("parentUuid")
723
+ timestamp = line.get("timestamp")
724
+ content = line.get("content", "")
725
+ subtype = line.get("subtype", "")
726
+
727
+ # A system line with no ``content`` string can still carry meaning in its
728
+ # subtype / level / toolUseResult / compactMetadata (e.g. a
729
+ # ``compact_boundary`` marker recording where compaction happened, and its
730
+ # token counts). Synthesize a summary so the record — and its metadata in
731
+ # provider_data — is preserved rather than dropped; only a wholly-empty
732
+ # line (no content and no metadata at all) is skipped.
733
+ if not content:
734
+ carries_meta = any(
735
+ line.get(k)
736
+ for k in ("subtype", "level", "toolUseResult", "compactMetadata")
737
+ )
738
+ if not carries_meta:
739
+ return None
740
+ content = f"[system: {subtype}]" if subtype else "[system event]"
741
+
742
+ content_blocks: List[ContentBlock] = [
743
+ cast(ContentBlock, {
744
+ "type": "system_context",
745
+ "context_type": subtype or "system",
746
+ "content": content,
747
+ "seq": 0,
748
+ })
749
+ ]
750
+
751
+ created_at = _parse_iso_timestamp(timestamp)
752
+ message_order = _timestamp_to_order(timestamp) if timestamp else 0
753
+
754
+ return {
755
+ "source_provider": self.PROVIDER_NAME,
756
+ "provider_message_id": uuid,
757
+ "provider_message_id_lower": uuid.lower() if uuid else None,
758
+ "provider_conversation_id": session_id or line.get("sessionId", ""),
759
+ "provider_parent_id": parent_uuid,
760
+ "provider_parent_id_lower": parent_uuid.lower() if parent_uuid else None,
761
+ "content_hash": self.hash_message(uuid, "system", content, timestamp),
762
+ "role": "system",
763
+ "content_text": content,
764
+ "content_blocks": content_blocks,
765
+ "created_at": created_at,
766
+ "updated_at": None,
767
+ "message_order": message_order,
768
+ "is_active_path": not line.get("isSidechain", False),
769
+ "is_visually_hidden": True, # System messages are typically hidden
770
+ "provider_data": {
771
+ "line": line,
772
+ "subtype": subtype,
773
+ "level": line.get("level"),
774
+ "cwd": project_path or line.get("cwd"),
775
+ "git_branch": line.get("gitBranch"),
776
+ "version": line.get("version"),
777
+ },
778
+ "conversation_title": None,
779
+ "conversation_metadata": {
780
+ "project_path": project_path or line.get("cwd"),
781
+ "git_branch": line.get("gitBranch"),
782
+ },
783
+ }
784
+
785
+ def _extract_tool_result_text(self, content: Any) -> str:
786
+ """Extract text from tool result content (delegates to claude_code_blocks)."""
787
+ return _blocks.extract_tool_result_text(content)
788
+
789
+ def _parse_summary_message(
790
+ self,
791
+ line: Dict[str, Any],
792
+ session_id: Optional[str],
793
+ project_path: Optional[str],
794
+ ) -> Optional[NormalizedMessage]:
795
+ """Parse a context continuation summary message.
796
+
797
+ When a conversation runs out of context, Claude creates a summary
798
+ of prior messages to continue. These summaries contain valuable
799
+ information about what was discussed and should be preserved.
800
+ """
801
+ uuid = line.get("uuid", "")
802
+ parent_uuid = line.get("parentUuid")
803
+ timestamp = line.get("timestamp")
804
+
805
+ # Summary content is in the 'summary' field or 'content' field
806
+ summary_text = line.get("summary", "") or line.get("content", "")
807
+ if not summary_text and isinstance(line.get("message"), dict):
808
+ summary_text = line["message"].get("content", "")
809
+
810
+ # Extract which messages were summarized, if available
811
+ summarizes = line.get("summarizes", [])
812
+ if not summarizes and line.get("summarizedMessages"):
813
+ summarizes = line.get("summarizedMessages", [])
814
+
815
+ content_blocks: List[ContentBlock] = [{
816
+ "type": "context_summary",
817
+ "text": summary_text if isinstance(summary_text, str) else str(summary_text),
818
+ "summarizes": summarizes if summarizes else None,
819
+ "seq": 0,
820
+ }]
821
+
822
+ created_at = _parse_iso_timestamp(timestamp)
823
+ message_order = _timestamp_to_order(timestamp) if timestamp else 0
824
+
825
+ return {
826
+ "source_provider": self.PROVIDER_NAME,
827
+ "provider_message_id": uuid or f"summary-{message_order}",
828
+ "provider_message_id_lower": uuid.lower() if uuid else None,
829
+ "provider_conversation_id": session_id or line.get("sessionId", ""),
830
+ "provider_parent_id": parent_uuid,
831
+ "provider_parent_id_lower": parent_uuid.lower() if parent_uuid else None,
832
+ "content_hash": self.hash_message(uuid, "system", summary_text, timestamp),
833
+ "role": "system",
834
+ "content_text": f"[Context Summary]: {summary_text[:200]}..." if len(str(summary_text)) > 200 else f"[Context Summary]: {summary_text}",
835
+ "content_blocks": content_blocks,
836
+ "created_at": created_at,
837
+ "updated_at": None,
838
+ "message_order": message_order,
839
+ "is_active_path": not line.get("isSidechain", False),
840
+ "is_visually_hidden": False, # Summaries may be interesting to see
841
+ "provider_data": {
842
+ "line": line,
843
+ "summary_type": "context_continuation",
844
+ "cwd": project_path or line.get("cwd"),
845
+ "git_branch": line.get("gitBranch"),
846
+ },
847
+ "conversation_title": None,
848
+ "conversation_metadata": {
849
+ "project_path": project_path or line.get("cwd"),
850
+ "git_branch": line.get("gitBranch"),
851
+ },
852
+ }
853
+
854
+ def _parse_file_snapshot_message(
855
+ self,
856
+ line: Dict[str, Any],
857
+ session_id: Optional[str],
858
+ project_path: Optional[str],
859
+ ) -> Optional[NormalizedMessage]:
860
+ """Parse a file history snapshot message.
861
+
862
+ Claude Code creates file snapshots to track the state of files
863
+ at various points during a session. These are valuable for
864
+ understanding what changed and when.
865
+ """
866
+ uuid = line.get("uuid", "")
867
+ parent_uuid = line.get("parentUuid")
868
+ timestamp = line.get("timestamp")
869
+
870
+ # File snapshot data can be in various places
871
+ files = line.get("files", [])
872
+ if not files and isinstance(line.get("content"), list):
873
+ files = line.get("content", [])
874
+ if not files and isinstance(line.get("snapshot"), dict):
875
+ files = line.get("snapshot", {}).get("files", [])
876
+
877
+ content_blocks: List[ContentBlock] = [{
878
+ "type": "file_snapshot",
879
+ "files": files,
880
+ "timestamp": timestamp,
881
+ "seq": 0,
882
+ }]
883
+
884
+ created_at = _parse_iso_timestamp(timestamp)
885
+ message_order = _timestamp_to_order(timestamp) if timestamp else 0
886
+
887
+ # Create a summary text for display
888
+ file_count = len(files) if isinstance(files, list) else 0
889
+ content_text = f"[File Snapshot]: {file_count} file(s)"
890
+
891
+ return {
892
+ "source_provider": self.PROVIDER_NAME,
893
+ "provider_message_id": uuid or f"file-snapshot-{message_order}",
894
+ "provider_message_id_lower": uuid.lower() if uuid else None,
895
+ "provider_conversation_id": session_id or line.get("sessionId", ""),
896
+ "provider_parent_id": parent_uuid,
897
+ "provider_parent_id_lower": parent_uuid.lower() if parent_uuid else None,
898
+ "content_hash": self.hash_message(uuid, "system", str(files), timestamp),
899
+ "role": "system",
900
+ "content_text": content_text,
901
+ "content_blocks": content_blocks,
902
+ "created_at": created_at,
903
+ "updated_at": None,
904
+ "message_order": message_order,
905
+ "is_active_path": not line.get("isSidechain", False),
906
+ "is_visually_hidden": True, # File snapshots are usually metadata
907
+ "provider_data": {
908
+ "line": line,
909
+ "snapshot_type": "file-history",
910
+ "cwd": project_path or line.get("cwd"),
911
+ "git_branch": line.get("gitBranch"),
912
+ },
913
+ "conversation_title": None,
914
+ "conversation_metadata": {
915
+ "project_path": project_path or line.get("cwd"),
916
+ "git_branch": line.get("gitBranch"),
917
+ },
918
+ }
919
+
920
+ def _parse_queue_operation(
921
+ self,
922
+ line: Dict[str, Any],
923
+ session_id: Optional[str],
924
+ ) -> Optional[NormalizedMessage]:
925
+ """Parse a queue-operation line (enqueue/dequeue)."""
926
+ timestamp = line.get("timestamp")
927
+ operation = line.get("operation", "unknown")
928
+ created_at = _parse_iso_timestamp(timestamp)
929
+ message_order = _timestamp_to_order(timestamp) if timestamp else 0
930
+
931
+ return {
932
+ "source_provider": self.PROVIDER_NAME,
933
+ "provider_message_id": f"queue-{operation}-{message_order}",
934
+ "provider_conversation_id": session_id or line.get("sessionId", ""),
935
+ "content_hash": self.hash_message(f"queue-{operation}", "system", str(line), timestamp),
936
+ "role": "system",
937
+ "content_text": f"[Queue {operation}]",
938
+ "content_blocks": [cast(ContentBlock, {
939
+ "type": "queue_operation",
940
+ "data": {
941
+ "operation": operation,
942
+ "session_id": line.get("sessionId"),
943
+ "timestamp": timestamp,
944
+ },
945
+ "seq": 0,
946
+ })],
947
+ "created_at": created_at,
948
+ "updated_at": None,
949
+ "message_order": message_order,
950
+ "is_active_path": True,
951
+ "is_visually_hidden": True,
952
+ "provider_data": {"line_type": "queue-operation"},
953
+ }
954
+
955
+ def _parse_progress_message(
956
+ self,
957
+ line: Dict[str, Any],
958
+ session_id: Optional[str],
959
+ ) -> Optional[NormalizedMessage]:
960
+ """Parse a progress line (tool/MCP progress indicators)."""
961
+ timestamp = line.get("timestamp")
962
+ uuid = line.get("uuid", "")
963
+ created_at = _parse_iso_timestamp(timestamp)
964
+ message_order = _timestamp_to_order(timestamp) if timestamp else 0
965
+ data = line.get("data", {})
966
+ tool_use_id = line.get("toolUseID")
967
+
968
+ return {
969
+ "source_provider": self.PROVIDER_NAME,
970
+ "provider_message_id": uuid or f"progress-{message_order}",
971
+ "provider_conversation_id": session_id or line.get("sessionId", ""),
972
+ "content_hash": self.hash_message(uuid or f"progress-{message_order}", "system", str(data), timestamp),
973
+ "role": "system",
974
+ "content_text": f"[Progress: {data.get('type', 'unknown')}]",
975
+ "content_blocks": [cast(ContentBlock, {
976
+ "type": "progress",
977
+ "data": data,
978
+ "tool_use_id": tool_use_id,
979
+ "seq": 0,
980
+ })],
981
+ "created_at": created_at,
982
+ "updated_at": None,
983
+ "message_order": message_order,
984
+ "is_active_path": not line.get("isSidechain", False),
985
+ "is_visually_hidden": True,
986
+ "provider_data": {"line_type": "progress"},
987
+ }
988
+
989
+ def _coalesce_assistant_chains(
990
+ self, messages: List[NormalizedMessage]
991
+ ) -> List[NormalizedMessage]:
992
+ """Coalesce linked assistant messages into single messages.
993
+
994
+ Delegates to ``claude_code_blocks.coalesce_assistant_chains``.
995
+ """
996
+ return _blocks.coalesce_assistant_chains(messages)
997
+
998
+ @staticmethod
999
+ def _build_chain_indices(
1000
+ messages: List[NormalizedMessage],
1001
+ ) -> Tuple[Dict[str, NormalizedMessage], Dict[str, List[NormalizedMessage]]]:
1002
+ """Build (by_id, children_of) indices (delegates to claude_code_blocks)."""
1003
+ return _blocks.build_chain_indices(messages)
1004
+
1005
+ @staticmethod
1006
+ def _find_chain_roots(
1007
+ messages: List[NormalizedMessage],
1008
+ by_id: Dict[str, NormalizedMessage],
1009
+ ) -> List[NormalizedMessage]:
1010
+ """Find chain roots (delegates to claude_code_blocks)."""
1011
+ return _blocks.find_chain_roots(messages, by_id)
1012
+
1013
+ @staticmethod
1014
+ def _merge_chain(
1015
+ root: NormalizedMessage,
1016
+ children_of: Dict[str, List[NormalizedMessage]],
1017
+ to_remove: set,
1018
+ ) -> None:
1019
+ """BFS-merge a chain in place (delegates to claude_code_blocks)."""
1020
+ _blocks.merge_chain(root, children_of, to_remove)
1021
+
1022
+
1023
+ # Session discovery/loading helpers live in claude_code_sessions; re-exported
1024
+ # here so existing ``claude_code.<name>`` access keeps resolving to the same
1025
+ # objects.
1026
+ from .claude_code_sessions import ( # noqa: E402
1027
+ find_claude_code_sessions,
1028
+ load_claude_code_session,
1029
+ )
1030
+
1031
+ __all__ = [
1032
+ "ClaudeCodeParser",
1033
+ "find_claude_code_sessions",
1034
+ "load_claude_code_session",
1035
+ ]