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,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 ""
|