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,332 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pure content-extraction / message-parsing helpers for ChatGPTParser.
|
|
3
|
+
|
|
4
|
+
These are the stateless bodies of ChatGPTParser's helper methods: content-block
|
|
5
|
+
building, special-case dispatch, text extraction, primary-message classification,
|
|
6
|
+
and coalesce-candidate collection. They carry no instance state — the parser's
|
|
7
|
+
methods are thin delegators over these functions. Content-block construction uses
|
|
8
|
+
ProviderParser's static factory methods (create_text_block, etc.) directly, so
|
|
9
|
+
the emitted block shapes are byte-identical to the inlined originals.
|
|
10
|
+
|
|
11
|
+
Kept in a sibling module (not on the class) purely to keep chatgpt.py lean; the
|
|
12
|
+
class retains every method the base interface, registry, or tests reference.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from .base import ContentBlock, ProviderParser
|
|
18
|
+
|
|
19
|
+
# Content types that indicate thinking/reasoning messages
|
|
20
|
+
THINKING_CONTENT_TYPES = {"thoughts", "analysis", "reasoning_recap"}
|
|
21
|
+
|
|
22
|
+
# Content types that indicate structural/scaffolding messages (not displayed)
|
|
23
|
+
STRUCTURAL_CONTENT_TYPES = {"code", "commentary", "metadata", "system"}
|
|
24
|
+
|
|
25
|
+
# Content types that represent system context
|
|
26
|
+
CONTEXT_CONTENT_TYPES = {"user_editable_context", "model_editable_context"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def extract_text_from_content(content: Any) -> str:
|
|
30
|
+
"""Extract text content from ChatGPT content object."""
|
|
31
|
+
if isinstance(content, str):
|
|
32
|
+
return content
|
|
33
|
+
if isinstance(content, dict):
|
|
34
|
+
# Check for text field. Only trust a string value — a dict/None `text`
|
|
35
|
+
# would otherwise be returned verbatim and crash downstream `.strip()`;
|
|
36
|
+
# fall through to the parts handling (which yields "" if there are none).
|
|
37
|
+
if isinstance(content.get("text"), str):
|
|
38
|
+
return content["text"]
|
|
39
|
+
# Check for parts array
|
|
40
|
+
parts = content.get("parts", [])
|
|
41
|
+
text_parts = []
|
|
42
|
+
for part in parts:
|
|
43
|
+
if isinstance(part, str):
|
|
44
|
+
text_parts.append(part)
|
|
45
|
+
elif isinstance(part, dict) and "text" in part:
|
|
46
|
+
text_parts.append(part["text"])
|
|
47
|
+
return "\n\n".join(text_parts)
|
|
48
|
+
return ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def extract_special_block(
|
|
52
|
+
raw_msg: Dict,
|
|
53
|
+
content: Any,
|
|
54
|
+
content_type: str,
|
|
55
|
+
role: str,
|
|
56
|
+
msg_id: Optional[str],
|
|
57
|
+
seq: int,
|
|
58
|
+
) -> Optional[tuple[List[ContentBlock], int]]:
|
|
59
|
+
"""Handle the exclusive content cases that fully consume a message.
|
|
60
|
+
|
|
61
|
+
Returns ``(blocks, next_seq)`` when one applies, else ``None`` so the
|
|
62
|
+
caller continues with the additive (tool_calls/text/image) sections.
|
|
63
|
+
"""
|
|
64
|
+
# Handle stub messages
|
|
65
|
+
if raw_msg.get("is_stub"):
|
|
66
|
+
return [], seq
|
|
67
|
+
|
|
68
|
+
# Handle tool role messages -> tool_result block
|
|
69
|
+
if role == "tool":
|
|
70
|
+
tool_name = raw_msg.get("author_name") or "unknown"
|
|
71
|
+
block = ProviderParser.create_tool_result_block(
|
|
72
|
+
tool_name,
|
|
73
|
+
content,
|
|
74
|
+
seq,
|
|
75
|
+
provider_message_id=msg_id,
|
|
76
|
+
)
|
|
77
|
+
return [block], seq + 1
|
|
78
|
+
|
|
79
|
+
# Handle thinking/reasoning content
|
|
80
|
+
if content_type in THINKING_CONTENT_TYPES:
|
|
81
|
+
text = extract_text_from_content(content)
|
|
82
|
+
if text:
|
|
83
|
+
block = ProviderParser.create_thinking_block(
|
|
84
|
+
text,
|
|
85
|
+
seq,
|
|
86
|
+
thinking_type=content_type,
|
|
87
|
+
provider_message_id=msg_id,
|
|
88
|
+
)
|
|
89
|
+
return [block], seq + 1
|
|
90
|
+
|
|
91
|
+
# Handle system context content
|
|
92
|
+
if content_type in CONTEXT_CONTENT_TYPES:
|
|
93
|
+
text = extract_text_from_content(content)
|
|
94
|
+
if text:
|
|
95
|
+
ctx_block: ContentBlock = {
|
|
96
|
+
"type": "system_context",
|
|
97
|
+
"text": text,
|
|
98
|
+
"context_type": content_type,
|
|
99
|
+
"seq": seq,
|
|
100
|
+
}
|
|
101
|
+
return [ctx_block], seq + 1
|
|
102
|
+
|
|
103
|
+
# Check for tool calls via recipient field (primary ChatGPT mechanism)
|
|
104
|
+
# When recipient is set to a tool name (not "all"), the assistant is invoking a tool
|
|
105
|
+
# and the text content is the tool input (e.g. Python code for the code interpreter)
|
|
106
|
+
recipient = raw_msg.get("recipient")
|
|
107
|
+
if recipient and recipient != "all" and role == "assistant":
|
|
108
|
+
text = extract_text_from_content(content)
|
|
109
|
+
block = ProviderParser.create_tool_use_block(
|
|
110
|
+
recipient,
|
|
111
|
+
{"code": text} if text else {},
|
|
112
|
+
seq,
|
|
113
|
+
provider_message_id=msg_id,
|
|
114
|
+
)
|
|
115
|
+
return [block], seq + 1
|
|
116
|
+
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def append_metadata_tool_calls(
|
|
121
|
+
blocks: List[ContentBlock],
|
|
122
|
+
msg_metadata: Dict,
|
|
123
|
+
msg_id: Optional[str],
|
|
124
|
+
seq: int,
|
|
125
|
+
) -> int:
|
|
126
|
+
"""Append tool_use blocks from metadata tool_calls (alternate export formats)."""
|
|
127
|
+
tool_calls = msg_metadata.get("tool_calls") or msg_metadata.get("tool_call")
|
|
128
|
+
if tool_calls:
|
|
129
|
+
if isinstance(tool_calls, list):
|
|
130
|
+
for tc in tool_calls:
|
|
131
|
+
if isinstance(tc, dict):
|
|
132
|
+
name = tc.get("name") or tc.get("function", {}).get("name") or "unknown"
|
|
133
|
+
args = tc.get("args") or tc.get("arguments") or tc.get("function", {}).get("arguments")
|
|
134
|
+
block = ProviderParser.create_tool_use_block(
|
|
135
|
+
name,
|
|
136
|
+
args,
|
|
137
|
+
seq,
|
|
138
|
+
provider_message_id=msg_id,
|
|
139
|
+
)
|
|
140
|
+
blocks.append(block)
|
|
141
|
+
seq += 1
|
|
142
|
+
elif isinstance(tool_calls, dict):
|
|
143
|
+
name = tool_calls.get("name") or tool_calls.get("function", {}).get("name") or "unknown"
|
|
144
|
+
args = tool_calls.get("args") or tool_calls.get("arguments") or tool_calls.get("function", {}).get("arguments")
|
|
145
|
+
block = ProviderParser.create_tool_use_block(
|
|
146
|
+
name,
|
|
147
|
+
args,
|
|
148
|
+
seq,
|
|
149
|
+
provider_message_id=msg_id,
|
|
150
|
+
)
|
|
151
|
+
blocks.append(block)
|
|
152
|
+
seq += 1
|
|
153
|
+
return seq
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def append_text_content(blocks: List[ContentBlock], content: Any, seq: int) -> int:
|
|
157
|
+
"""Append a text block from regular content, if any."""
|
|
158
|
+
text = extract_text_from_content(content)
|
|
159
|
+
if text:
|
|
160
|
+
block = ProviderParser.create_text_block(text, seq)
|
|
161
|
+
blocks.append(block)
|
|
162
|
+
seq += 1
|
|
163
|
+
return seq
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def append_image_parts(blocks: List[ContentBlock], content: Any, seq: int) -> int:
|
|
167
|
+
"""Append image blocks from multimodal content parts."""
|
|
168
|
+
if isinstance(content, dict):
|
|
169
|
+
parts = content.get("parts", [])
|
|
170
|
+
for part in parts:
|
|
171
|
+
if isinstance(part, dict):
|
|
172
|
+
# Image part
|
|
173
|
+
if part.get("content_type", "").startswith("image/"):
|
|
174
|
+
img_block: ContentBlock = {
|
|
175
|
+
"type": "image",
|
|
176
|
+
"asset_pointer": part.get("asset_pointer"),
|
|
177
|
+
"mime_type": part.get("content_type"),
|
|
178
|
+
"seq": seq,
|
|
179
|
+
}
|
|
180
|
+
blocks.append(img_block)
|
|
181
|
+
seq += 1
|
|
182
|
+
return seq
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def extract_content_blocks(
|
|
186
|
+
raw_msg: Dict,
|
|
187
|
+
start_seq: int,
|
|
188
|
+
) -> tuple[List[ContentBlock], int]:
|
|
189
|
+
"""
|
|
190
|
+
Extract content blocks from a single raw message.
|
|
191
|
+
Returns (blocks, next_seq).
|
|
192
|
+
"""
|
|
193
|
+
content = raw_msg.get("content", {})
|
|
194
|
+
content_type = (raw_msg.get("content_type") or "").lower()
|
|
195
|
+
role = (raw_msg.get("role") or "").lower()
|
|
196
|
+
msg_id = raw_msg.get("provider_message_id")
|
|
197
|
+
msg_metadata = raw_msg.get("msg_metadata", {})
|
|
198
|
+
|
|
199
|
+
# Exclusive cases (stub / tool role / thinking / system context /
|
|
200
|
+
# recipient tool call) each fully handle the message and return early.
|
|
201
|
+
special = extract_special_block(
|
|
202
|
+
raw_msg, content, content_type, role, msg_id, start_seq
|
|
203
|
+
)
|
|
204
|
+
if special is not None:
|
|
205
|
+
return special
|
|
206
|
+
|
|
207
|
+
# Additive sections: a message can carry any combination of metadata
|
|
208
|
+
# tool calls, regular text, and multimodal images, appended in that order.
|
|
209
|
+
blocks: List[ContentBlock] = []
|
|
210
|
+
seq = start_seq
|
|
211
|
+
seq = append_metadata_tool_calls(blocks, msg_metadata, msg_id, seq)
|
|
212
|
+
seq = append_text_content(blocks, content, seq)
|
|
213
|
+
seq = append_image_parts(blocks, content, seq)
|
|
214
|
+
return blocks, seq
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def build_content_blocks(
|
|
218
|
+
primary_msg: Dict,
|
|
219
|
+
coalesced_msgs: List[Dict],
|
|
220
|
+
) -> List[ContentBlock]:
|
|
221
|
+
"""
|
|
222
|
+
Build content_blocks array from primary message and coalesced children.
|
|
223
|
+
"""
|
|
224
|
+
blocks: List[ContentBlock] = []
|
|
225
|
+
seq = 0
|
|
226
|
+
|
|
227
|
+
# Extract content from primary message
|
|
228
|
+
primary_blocks, seq = extract_content_blocks(primary_msg, seq)
|
|
229
|
+
blocks.extend(primary_blocks)
|
|
230
|
+
|
|
231
|
+
# Add blocks from coalesced messages
|
|
232
|
+
for child_msg in coalesced_msgs:
|
|
233
|
+
child_blocks, seq = extract_content_blocks(child_msg, seq)
|
|
234
|
+
blocks.extend(child_blocks)
|
|
235
|
+
|
|
236
|
+
return blocks
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def is_primary_message(raw_msg: Dict) -> bool:
|
|
240
|
+
"""
|
|
241
|
+
Determine if a message should be a primary row (vs coalesced into parent).
|
|
242
|
+
|
|
243
|
+
Primary messages:
|
|
244
|
+
- User messages with displayable content
|
|
245
|
+
- Assistant messages with text content
|
|
246
|
+
- System messages
|
|
247
|
+
|
|
248
|
+
Non-primary (coalesce candidates):
|
|
249
|
+
- Tool role messages
|
|
250
|
+
- Thinking/reasoning messages (content_type in THINKING_CONTENT_TYPES)
|
|
251
|
+
- Stubs
|
|
252
|
+
"""
|
|
253
|
+
if raw_msg.get("is_stub"):
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
role = (raw_msg.get("role") or "").lower()
|
|
257
|
+
content_type = (raw_msg.get("content_type") or "").lower()
|
|
258
|
+
|
|
259
|
+
# Tool messages are coalesced
|
|
260
|
+
if role == "tool":
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
# Thinking/reasoning messages are coalesced
|
|
264
|
+
if content_type in THINKING_CONTENT_TYPES:
|
|
265
|
+
return False
|
|
266
|
+
|
|
267
|
+
# System context is coalesced
|
|
268
|
+
if content_type in CONTEXT_CONTENT_TYPES:
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
# Structural content is coalesced
|
|
272
|
+
if content_type in STRUCTURAL_CONTENT_TYPES:
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
# Check for actual displayable content
|
|
276
|
+
content = raw_msg.get("content", {})
|
|
277
|
+
if isinstance(content, dict):
|
|
278
|
+
parts = content.get("parts", [])
|
|
279
|
+
text = content.get("text", "")
|
|
280
|
+
if not parts and not text:
|
|
281
|
+
# No content to display
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
return role in ("user", "assistant", "system")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def collect_coalesce_candidates(
|
|
288
|
+
parent_id: str,
|
|
289
|
+
children_by_parent: Dict[str, List[str]],
|
|
290
|
+
msg_by_id: Dict[str, Dict],
|
|
291
|
+
active_path_ids: Any,
|
|
292
|
+
) -> List[str]:
|
|
293
|
+
"""
|
|
294
|
+
Recursively collect child messages that should be coalesced into parent.
|
|
295
|
+
|
|
296
|
+
Only coalesce messages on the active path that are:
|
|
297
|
+
- Tool messages
|
|
298
|
+
- Thinking/reasoning messages
|
|
299
|
+
|
|
300
|
+
Stop recursion at branch points (multiple children on different paths).
|
|
301
|
+
"""
|
|
302
|
+
result: List[str] = []
|
|
303
|
+
to_process = list(children_by_parent.get(parent_id, []))
|
|
304
|
+
|
|
305
|
+
while to_process:
|
|
306
|
+
child_id = to_process.pop(0)
|
|
307
|
+
child_msg = msg_by_id.get(child_id)
|
|
308
|
+
if not child_msg:
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
# Only coalesce messages on the active path
|
|
312
|
+
if child_id not in active_path_ids:
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
# Check if this is a coalesce candidate
|
|
316
|
+
role = (child_msg.get("role") or "").lower()
|
|
317
|
+
content_type = (child_msg.get("content_type") or "").lower()
|
|
318
|
+
|
|
319
|
+
should_coalesce = (
|
|
320
|
+
role == "tool" or
|
|
321
|
+
content_type in THINKING_CONTENT_TYPES or
|
|
322
|
+
content_type in CONTEXT_CONTENT_TYPES or
|
|
323
|
+
content_type in STRUCTURAL_CONTENT_TYPES or
|
|
324
|
+
child_msg.get("is_stub")
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if should_coalesce:
|
|
328
|
+
result.append(child_id)
|
|
329
|
+
# Continue to this message's children
|
|
330
|
+
to_process.extend(children_by_parent.get(child_id, []))
|
|
331
|
+
|
|
332
|
+
return result
|