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,191 @@
1
+ """
2
+ Protocol definitions for pipeline components.
3
+
4
+ These Protocols define the interfaces for pluggable pipeline components.
5
+ Each component can be implemented independently and composed together.
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Dict, List, Optional, Protocol, TypeVar
10
+
11
+ # Import from parent to avoid circular imports
12
+ # These will be available once the module structure is in place
13
+ from ..base import ContentBlock, NormalizedMessage
14
+
15
+ # Type variable for generic input type. Contravariant: the Parser Protocol only
16
+ # consumes it (parse(data: T_Input)), never produces it.
17
+ T_Input = TypeVar("T_Input", contravariant=True)
18
+
19
+
20
+ @dataclass
21
+ class RawMessage:
22
+ """Intermediate representation between parse and normalize.
23
+
24
+ This is the provider-specific parsed data before normalization.
25
+ It preserves all provider-specific fields while providing a
26
+ common structure for transformers to operate on.
27
+
28
+ Attributes:
29
+ provider_message_id: Original message ID from provider
30
+ provider_conversation_id: Original conversation ID
31
+ provider_parent_id: Parent message ID (for tree structures)
32
+ role: Raw role value (not yet normalized)
33
+ content: Raw content (provider-specific structure)
34
+ content_type: Content type indicator (if applicable)
35
+ timestamp: Raw timestamp (provider-specific format)
36
+ is_active_path: Whether on main conversation thread
37
+ is_stub: Whether this is a placeholder node
38
+ metadata: Additional provider-specific metadata
39
+ raw_data: Complete original data for reference
40
+ children: IDs of child messages (for tree traversal)
41
+ """
42
+
43
+ provider_message_id: str
44
+ provider_conversation_id: str
45
+ provider_parent_id: Optional[str] = None
46
+ role: Optional[str] = None
47
+ content: Any = None
48
+ content_type: Optional[str] = None
49
+ timestamp: Any = None # Provider-specific format
50
+ timestamp_iso: Optional[str] = None # Normalized ISO format
51
+ message_order: Optional[int] = None
52
+ is_active_path: bool = True
53
+ is_stub: bool = False
54
+ metadata: Dict[str, Any] = field(default_factory=dict)
55
+ raw_data: Dict[str, Any] = field(default_factory=dict)
56
+ children: List[str] = field(default_factory=list)
57
+
58
+ # Content blocks (built during parsing or transformation)
59
+ content_blocks: List[ContentBlock] = field(default_factory=list)
60
+
61
+ # Conversation-level metadata
62
+ conversation_title: Optional[str] = None
63
+ conversation_metadata: Dict[str, Any] = field(default_factory=dict)
64
+
65
+ def __hash__(self) -> int:
66
+ """Allow use in sets (based on message ID)."""
67
+ return hash(self.provider_message_id)
68
+
69
+ def __eq__(self, other: object) -> bool:
70
+ """Equality based on message ID."""
71
+ if not isinstance(other, RawMessage):
72
+ return False
73
+ return self.provider_message_id == other.provider_message_id
74
+
75
+
76
+ class Parser(Protocol[T_Input]):
77
+ """Protocol for parsing raw provider data.
78
+
79
+ Parsers convert provider-specific export data into RawMessage objects.
80
+ This is the first phase of the pipeline.
81
+
82
+ Type Parameters:
83
+ T_Input: The type of raw input data (e.g., ChatGPTExport)
84
+ """
85
+
86
+ def parse(self, data: T_Input) -> List[RawMessage]:
87
+ """Parse raw provider data into intermediate representation.
88
+
89
+ Args:
90
+ data: Raw provider export data
91
+
92
+ Returns:
93
+ List of RawMessage objects
94
+ """
95
+ ...
96
+
97
+
98
+ class Transformer(Protocol):
99
+ """Protocol for transforming messages.
100
+
101
+ Transformers modify the list of RawMessages. Common uses:
102
+ - Coalescing tool calls into parent messages (ChatGPT)
103
+ - Merging thinking blocks into parent (Claude Code)
104
+ - Computing active path (ChatGPT)
105
+ - Extracting IDE context (Claude Code)
106
+
107
+ Transformers are applied in sequence, so order matters.
108
+ """
109
+
110
+ def transform(self, messages: List[RawMessage]) -> List[RawMessage]:
111
+ """Transform a list of messages.
112
+
113
+ Args:
114
+ messages: Input messages
115
+
116
+ Returns:
117
+ Transformed messages (may be modified in place or new list)
118
+ """
119
+ ...
120
+
121
+
122
+ class Normalizer(Protocol):
123
+ """Protocol for normalizing messages to canonical format.
124
+
125
+ Normalizers convert RawMessage objects into NormalizedMessage
126
+ dictionaries that match the unified schema.
127
+ """
128
+
129
+ def normalize(self, raw: RawMessage) -> NormalizedMessage:
130
+ """Convert a RawMessage to NormalizedMessage.
131
+
132
+ Args:
133
+ raw: Intermediate message representation
134
+
135
+ Returns:
136
+ Canonical NormalizedMessage dictionary
137
+ """
138
+ ...
139
+
140
+
141
+ @dataclass
142
+ class ValidationContext:
143
+ """Context for validation across a conversation.
144
+
145
+ A mutable interface that validators update as they process messages.
146
+ """
147
+
148
+ conversation_id: str
149
+ source_provider: str
150
+ message_count: int = 0
151
+ first_user_msg_seen: bool = False
152
+ first_user_msg_has_context: bool = False
153
+ assistant_msg_count: int = 0
154
+ assistant_msgs_with_thinking: int = 0
155
+ assistant_msgs_with_tools: int = 0
156
+ thinking_required_msgs: int = 0
157
+ thinking_required_msgs_with_thinking: int = 0
158
+ errors: List[str] = field(default_factory=list)
159
+ warnings: List[str] = field(default_factory=list)
160
+
161
+ def add_error(self, msg: str) -> None:
162
+ """Add a validation error."""
163
+ self.errors.append(msg)
164
+
165
+ def add_warning(self, msg: str) -> None:
166
+ """Add a validation warning."""
167
+ self.warnings.append(msg)
168
+
169
+ @property
170
+ def has_errors(self) -> bool:
171
+ """Check if any errors were recorded."""
172
+ return len(self.errors) > 0
173
+
174
+
175
+ class Validator(Protocol):
176
+ """Protocol for validating messages.
177
+
178
+ Validators check messages against rules and update the
179
+ ValidationContext with errors/warnings.
180
+ """
181
+
182
+ def validate(
183
+ self, messages: List[NormalizedMessage], context: ValidationContext
184
+ ) -> None:
185
+ """Validate messages and update context.
186
+
187
+ Args:
188
+ messages: Messages to validate
189
+ context: Validation context to update with results
190
+ """
191
+ ...
@@ -0,0 +1,22 @@
1
+ """
2
+ Transformers for the parser pipeline.
3
+
4
+ Transformers modify RawMessage lists between parse and normalize phases.
5
+ Common uses:
6
+ - Coalescing tool messages into parents (ChatGPT)
7
+ - Merging thinking blocks (Claude Code)
8
+ - Computing active paths (ChatGPT)
9
+ - Extracting IDE context (Claude Code)
10
+ """
11
+
12
+ from .active_path import ActivePathTransformer
13
+ from .coalescing import ToolCoalescingTransformer
14
+ from .ide_context import IDEContextTransformer
15
+ from .thinking_merge import ThinkingMergeTransformer
16
+
17
+ __all__ = [
18
+ "ToolCoalescingTransformer",
19
+ "ActivePathTransformer",
20
+ "ThinkingMergeTransformer",
21
+ "IDEContextTransformer",
22
+ ]
@@ -0,0 +1,87 @@
1
+ """
2
+ Active path transformer for ChatGPT.
3
+
4
+ ChatGPT conversations are tree-structured. The "active path" is the
5
+ main thread from root to current_node. This transformer marks messages
6
+ on or off the active path.
7
+ """
8
+
9
+ from typing import Dict, List, Optional, Set
10
+
11
+ from ..pipeline.interfaces import RawMessage
12
+
13
+
14
+ class ActivePathTransformer:
15
+ """Computes and marks the active conversation path.
16
+
17
+ ChatGPT's current_node field identifies the leaf of the "active"
18
+ conversation thread. This transformer:
19
+ 1. Walks from current_node to root to find the active path
20
+ 2. Marks each message with is_active_path flag
21
+
22
+ This is run before coalescing so only active-path messages
23
+ get coalesced into their parents.
24
+ """
25
+
26
+ def __init__(self, current_node: Optional[str] = None):
27
+ """Initialize with optional current_node.
28
+
29
+ Args:
30
+ current_node: Leaf node ID of active path. If not provided,
31
+ tries to extract from messages' conversation metadata.
32
+ """
33
+ self.current_node = current_node
34
+
35
+ def transform(self, messages: List[RawMessage]) -> List[RawMessage]:
36
+ """Mark messages with active path status.
37
+
38
+ Args:
39
+ messages: Messages to transform
40
+
41
+ Returns:
42
+ Same messages with is_active_path updated
43
+ """
44
+ if not messages:
45
+ return messages
46
+
47
+ # Try to get current_node from conversation metadata
48
+ current_node = self.current_node
49
+ if not current_node:
50
+ for msg in messages:
51
+ conv_meta = msg.conversation_metadata
52
+ if conv_meta and "current_node" in conv_meta:
53
+ current_node = conv_meta["current_node"]
54
+ break
55
+
56
+ if not current_node:
57
+ # No current_node - assume all messages are active
58
+ return messages
59
+
60
+ # Build parent lookup
61
+ parent_map: Dict[str, Optional[str]] = {}
62
+ for msg in messages:
63
+ parent_map[msg.provider_message_id] = msg.provider_parent_id
64
+
65
+ # Walk from current_node to root
66
+ active_ids = self._build_active_path(current_node, parent_map)
67
+
68
+ # Mark messages
69
+ for msg in messages:
70
+ msg.is_active_path = msg.provider_message_id in active_ids
71
+
72
+ return messages
73
+
74
+ def _build_active_path(
75
+ self, current_node: str, parent_map: Dict[str, Optional[str]]
76
+ ) -> Set[str]:
77
+ """Walk from current_node to root to build active path set."""
78
+ active_ids: Set[str] = set()
79
+ node_id: Optional[str] = current_node
80
+ hop_guard = 0
81
+
82
+ while node_id and hop_guard < 10_000:
83
+ active_ids.add(node_id)
84
+ node_id = parent_map.get(node_id)
85
+ hop_guard += 1
86
+
87
+ return active_ids
@@ -0,0 +1,389 @@
1
+ """
2
+ Tool coalescing transformer for ChatGPT.
3
+
4
+ ChatGPT exports tool calls, thinking, and results as separate messages
5
+ linked via parent_id. This transformer coalesces them into content_blocks
6
+ on the parent assistant message.
7
+ """
8
+
9
+ from typing import Any, Dict, List, Set, cast
10
+
11
+ from ..base import ContentBlock
12
+ from ..pipeline.interfaces import RawMessage
13
+
14
+ # Content types that indicate thinking/reasoning
15
+ THINKING_CONTENT_TYPES = {"thoughts", "analysis", "reasoning_recap"}
16
+
17
+ # Content types that indicate structural/scaffolding messages
18
+ STRUCTURAL_CONTENT_TYPES = {"code", "commentary", "metadata", "system"}
19
+
20
+ # Content types that represent system context
21
+ CONTEXT_CONTENT_TYPES = {"user_editable_context", "model_editable_context"}
22
+
23
+
24
+ class ToolCoalescingTransformer:
25
+ """Coalesces tool/thinking messages into parent assistant messages.
26
+
27
+ ChatGPT's export format stores:
28
+ - Tool calls as separate assistant messages with tool_calls in metadata
29
+ - Tool results as separate tool-role messages
30
+ - Thinking as separate messages with content_type in THINKING_CONTENT_TYPES
31
+ - System context as separate messages with content_type in CONTEXT_CONTENT_TYPES
32
+
33
+ This transformer:
34
+ 1. Identifies "primary" messages (user/assistant with displayable content)
35
+ 2. Finds child messages that should be coalesced (tool, thinking, context)
36
+ 3. Converts coalesced messages to content_blocks on the parent
37
+ 4. Removes coalesced messages from the output list
38
+
39
+ Only messages on the active path are coalesced. Branch messages remain separate.
40
+ """
41
+
42
+ def transform(self, messages: List[RawMessage]) -> List[RawMessage]:
43
+ """Coalesce tool and thinking messages into parents.
44
+
45
+ Args:
46
+ messages: Messages to transform (should have is_active_path set)
47
+
48
+ Returns:
49
+ Transformed messages with coalesced content
50
+ """
51
+ if not messages:
52
+ return messages
53
+
54
+ # Build lookup structures
55
+ msg_by_id: Dict[str, RawMessage] = {
56
+ m.provider_message_id: m for m in messages
57
+ }
58
+ children_by_parent: Dict[str, List[str]] = {}
59
+ for msg in messages:
60
+ if msg.provider_parent_id:
61
+ children_by_parent.setdefault(msg.provider_parent_id, []).append(
62
+ msg.provider_message_id
63
+ )
64
+
65
+ # Sort by message_order for consistent processing
66
+ sorted_messages = sorted(messages, key=lambda m: m.message_order or 0)
67
+
68
+ result: List[RawMessage] = []
69
+ coalesced_ids: Set[str] = set()
70
+
71
+ for msg in sorted_messages:
72
+ if msg.provider_message_id in coalesced_ids:
73
+ continue
74
+
75
+ if self._is_primary_message(msg):
76
+ # Collect children to coalesce
77
+ children_to_coalesce = self._collect_coalesce_candidates(
78
+ msg.provider_message_id,
79
+ children_by_parent,
80
+ msg_by_id,
81
+ )
82
+
83
+ # Mark as coalesced
84
+ for child_id in children_to_coalesce:
85
+ coalesced_ids.add(child_id)
86
+
87
+ # Build content blocks from primary + children
88
+ self._build_content_blocks(
89
+ msg,
90
+ [msg_by_id[cid] for cid in children_to_coalesce if cid in msg_by_id],
91
+ )
92
+
93
+ result.append(msg)
94
+ else:
95
+ # Non-primary that wasn't coalesced - keep as separate
96
+ result.append(msg)
97
+
98
+ return result
99
+
100
+ def _is_primary_message(self, msg: RawMessage) -> bool:
101
+ """Determine if message should be a primary row.
102
+
103
+ Primary messages:
104
+ - User messages with displayable content
105
+ - Assistant messages with text content
106
+ - System messages
107
+
108
+ Non-primary (coalesce candidates):
109
+ - Tool role messages
110
+ - Thinking/reasoning messages
111
+ - Stubs
112
+ """
113
+ if msg.is_stub:
114
+ return False
115
+
116
+ role = (msg.role or "").lower()
117
+ content_type = (msg.content_type or "").lower()
118
+
119
+ # Tool messages are coalesced
120
+ if role == "tool":
121
+ return False
122
+
123
+ # Thinking/reasoning messages are coalesced
124
+ if content_type in THINKING_CONTENT_TYPES:
125
+ return False
126
+
127
+ # System context is coalesced
128
+ if content_type in CONTEXT_CONTENT_TYPES:
129
+ return False
130
+
131
+ # Structural content is coalesced
132
+ if content_type in STRUCTURAL_CONTENT_TYPES:
133
+ return False
134
+
135
+ # Check for actual displayable content
136
+ content = msg.content
137
+ if isinstance(content, dict):
138
+ parts = content.get("parts", [])
139
+ text = content.get("text", "")
140
+ if not parts and not text:
141
+ return False
142
+
143
+ return role in ("user", "assistant", "system")
144
+
145
+ def _collect_coalesce_candidates(
146
+ self,
147
+ parent_id: str,
148
+ children_by_parent: Dict[str, List[str]],
149
+ msg_by_id: Dict[str, RawMessage],
150
+ ) -> List[str]:
151
+ """Recursively collect child messages to coalesce.
152
+
153
+ Only coalesces messages on the active path that are:
154
+ - Tool messages
155
+ - Thinking/reasoning messages
156
+ - Context messages
157
+ - Structural messages
158
+ """
159
+ result: List[str] = []
160
+ to_process = list(children_by_parent.get(parent_id, []))
161
+
162
+ while to_process:
163
+ child_id = to_process.pop(0)
164
+ child_msg = msg_by_id.get(child_id)
165
+ if not child_msg:
166
+ continue
167
+
168
+ # Only coalesce active path messages
169
+ if not child_msg.is_active_path:
170
+ continue
171
+
172
+ role = (child_msg.role or "").lower()
173
+ content_type = (child_msg.content_type or "").lower()
174
+
175
+ should_coalesce = (
176
+ role == "tool"
177
+ or content_type in THINKING_CONTENT_TYPES
178
+ or content_type in CONTEXT_CONTENT_TYPES
179
+ or content_type in STRUCTURAL_CONTENT_TYPES
180
+ or child_msg.is_stub
181
+ )
182
+
183
+ if should_coalesce:
184
+ result.append(child_id)
185
+ # Continue to this message's children
186
+ to_process.extend(children_by_parent.get(child_id, []))
187
+
188
+ return result
189
+
190
+ def _build_content_blocks(
191
+ self, primary: RawMessage, coalesced: List[RawMessage]
192
+ ) -> None:
193
+ """Build content_blocks from primary and coalesced messages.
194
+
195
+ Modifies primary.content_blocks in place.
196
+ """
197
+ blocks: List[ContentBlock] = []
198
+ seq = 0
199
+
200
+ # Extract from primary
201
+ primary_blocks, seq = self._extract_blocks(primary, seq)
202
+ blocks.extend(primary_blocks)
203
+
204
+ # Add from coalesced
205
+ for child in coalesced:
206
+ child_blocks, seq = self._extract_blocks(child, seq)
207
+ blocks.extend(child_blocks)
208
+
209
+ primary.content_blocks = blocks
210
+
211
+ def _extract_blocks(
212
+ self, msg: RawMessage, start_seq: int
213
+ ) -> tuple[List[ContentBlock], int]:
214
+ """Extract content blocks from a message."""
215
+ if msg.is_stub:
216
+ return [], start_seq
217
+
218
+ content = msg.content
219
+ content_type = (msg.content_type or "").lower()
220
+ role = (msg.role or "").lower()
221
+ msg_id = msg.provider_message_id
222
+ metadata = msg.metadata
223
+
224
+ # Exclusive cases (tool role / thinking / system context) each fully
225
+ # handle the message and return early.
226
+ special = self._extract_special_blocks(
227
+ content, content_type, role, msg_id, metadata, start_seq
228
+ )
229
+ if special is not None:
230
+ return special
231
+
232
+ # Additive sections: metadata tool calls, then regular text, then images.
233
+ blocks: List[ContentBlock] = []
234
+ seq = start_seq
235
+ seq = self._append_metadata_tool_calls(blocks, metadata, msg_id, seq)
236
+ seq = self._append_text(blocks, content, seq)
237
+ seq = self._append_images(blocks, content, seq)
238
+ return blocks, seq
239
+
240
+ def _extract_special_blocks(
241
+ self,
242
+ content: Any,
243
+ content_type: str,
244
+ role: str,
245
+ msg_id: str,
246
+ metadata: Dict[str, Any],
247
+ seq: int,
248
+ ) -> "tuple[List[ContentBlock], int] | None":
249
+ """Handle the exclusive content cases that fully consume a message.
250
+
251
+ Returns ``(blocks, next_seq)`` when one applies, else ``None`` so the
252
+ caller continues with the additive (tool_calls/text/image) sections.
253
+ """
254
+ # Tool role -> tool_result
255
+ if role == "tool":
256
+ tool_name = metadata.get("author_name") or "unknown"
257
+ return [{
258
+ "type": "tool_result",
259
+ "name": tool_name,
260
+ "content": content,
261
+ "seq": seq,
262
+ "provider_message_id": msg_id,
263
+ }], seq + 1
264
+
265
+ # Thinking content
266
+ if content_type in THINKING_CONTENT_TYPES:
267
+ text = self._extract_text(content)
268
+ if text:
269
+ return [cast(ContentBlock, {
270
+ "type": "thinking",
271
+ "text": text,
272
+ "thinking_type": content_type,
273
+ "seq": seq,
274
+ "provider_message_id": msg_id,
275
+ })], seq + 1
276
+
277
+ # System context
278
+ if content_type in CONTEXT_CONTENT_TYPES:
279
+ text = self._extract_text(content)
280
+ if text:
281
+ return [{
282
+ "type": "system_context",
283
+ "text": text,
284
+ "context_type": content_type,
285
+ "seq": seq,
286
+ }], seq + 1
287
+
288
+ return None
289
+
290
+ def _append_metadata_tool_calls(
291
+ self,
292
+ blocks: List[ContentBlock],
293
+ metadata: Dict[str, Any],
294
+ msg_id: str,
295
+ seq: int,
296
+ ) -> int:
297
+ """Append tool_use blocks from metadata tool_calls."""
298
+ tool_calls = metadata.get("tool_calls") or metadata.get("tool_call")
299
+ if tool_calls:
300
+ if isinstance(tool_calls, list):
301
+ for tc in tool_calls:
302
+ if isinstance(tc, dict):
303
+ name = (
304
+ tc.get("name")
305
+ or tc.get("function", {}).get("name")
306
+ or "unknown"
307
+ )
308
+ args = (
309
+ tc.get("args")
310
+ or tc.get("arguments")
311
+ or tc.get("function", {}).get("arguments")
312
+ )
313
+ blocks.append({
314
+ "type": "tool_use",
315
+ "name": name,
316
+ "input": args,
317
+ "seq": seq,
318
+ "provider_message_id": msg_id,
319
+ })
320
+ seq += 1
321
+ elif isinstance(tool_calls, dict):
322
+ name = (
323
+ tool_calls.get("name")
324
+ or tool_calls.get("function", {}).get("name")
325
+ or "unknown"
326
+ )
327
+ args = (
328
+ tool_calls.get("args")
329
+ or tool_calls.get("arguments")
330
+ or tool_calls.get("function", {}).get("arguments")
331
+ )
332
+ blocks.append({
333
+ "type": "tool_use",
334
+ "name": name,
335
+ "input": args,
336
+ "seq": seq,
337
+ "provider_message_id": msg_id,
338
+ })
339
+ seq += 1
340
+ return seq
341
+
342
+ def _append_text(
343
+ self, blocks: List[ContentBlock], content: Any, seq: int
344
+ ) -> int:
345
+ """Append a text block from regular content, if any."""
346
+ text = self._extract_text(content)
347
+ if text:
348
+ blocks.append({
349
+ "type": "text",
350
+ "text": text,
351
+ "seq": seq,
352
+ })
353
+ seq += 1
354
+ return seq
355
+
356
+ def _append_images(
357
+ self, blocks: List[ContentBlock], content: Any, seq: int
358
+ ) -> int:
359
+ """Append image blocks from multimodal content parts."""
360
+ if isinstance(content, dict):
361
+ parts = content.get("parts", [])
362
+ for part in parts:
363
+ if isinstance(part, dict):
364
+ if part.get("content_type", "").startswith("image/"):
365
+ blocks.append({
366
+ "type": "image",
367
+ "asset_pointer": part.get("asset_pointer"),
368
+ "mime_type": part.get("content_type"),
369
+ "seq": seq,
370
+ })
371
+ seq += 1
372
+ return seq
373
+
374
+ def _extract_text(self, content: Any) -> str:
375
+ """Extract text from various content formats."""
376
+ if isinstance(content, str):
377
+ return content
378
+ if isinstance(content, dict):
379
+ if "text" in content:
380
+ return content["text"]
381
+ parts = content.get("parts", [])
382
+ text_parts = []
383
+ for part in parts:
384
+ if isinstance(part, str):
385
+ text_parts.append(part)
386
+ elif isinstance(part, dict) and "text" in part:
387
+ text_parts.append(part["text"])
388
+ return "\n\n".join(text_parts)
389
+ return ""