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,365 @@
|
|
|
1
|
+
"""Pure content-block builders for the Cursor parser.
|
|
2
|
+
|
|
3
|
+
These are the stateless helper bodies extracted verbatim from
|
|
4
|
+
``CursorParser`` — every Cursor content shape (pre-parsed ``content_blocks``,
|
|
5
|
+
the legacy ``thinking``/``tool_calls``/``tool_results`` fields, and the v2
|
|
6
|
+
export's ``code_blocks``/``tool_call``/``thinking``) plus the fallback and
|
|
7
|
+
tool-input normalization. ``CursorParser`` delegates to these so the class keeps
|
|
8
|
+
its method surface while the heavy bodies live here.
|
|
9
|
+
|
|
10
|
+
Block construction uses the ``ProviderParser`` static factory methods
|
|
11
|
+
(``create_text_block`` etc.), so the emitted dict/list shapes and the running
|
|
12
|
+
``seq`` numbering are byte-identical to the original in-class form.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from typing import Any, Dict, List, cast
|
|
17
|
+
|
|
18
|
+
from ..tool_names import FILE_TOOLS, PROVIDER_PATH_FIELDS, normalize_tool_name
|
|
19
|
+
from .base import ContentBlock, ProviderParser
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_content_blocks(
|
|
23
|
+
msg: Dict[str, Any],
|
|
24
|
+
raw_blocks: List[Dict],
|
|
25
|
+
fallback_content: Any,
|
|
26
|
+
) -> List[ContentBlock]:
|
|
27
|
+
"""Build normalized content blocks from Cursor message data.
|
|
28
|
+
|
|
29
|
+
Each Cursor content shape (pre-parsed raw_blocks, legacy
|
|
30
|
+
thinking/tool_calls/tool_results, the v2 export's code_blocks/tool_call/
|
|
31
|
+
thinking) is appended in turn by a dedicated helper, all threading the
|
|
32
|
+
same running ``seq`` counter so block ordering and seq numbering are
|
|
33
|
+
unchanged from the single-function form.
|
|
34
|
+
"""
|
|
35
|
+
blocks: List[ContentBlock] = []
|
|
36
|
+
seq = 0
|
|
37
|
+
|
|
38
|
+
seq = append_raw_blocks(blocks, raw_blocks, seq)
|
|
39
|
+
seq = append_legacy_thinking(blocks, msg, seq)
|
|
40
|
+
seq = append_legacy_tool_calls(blocks, msg, seq)
|
|
41
|
+
seq = append_legacy_tool_results(blocks, msg, seq)
|
|
42
|
+
seq = append_v2_code_blocks(blocks, msg, seq)
|
|
43
|
+
seq = append_v2_tool_call(blocks, msg, seq)
|
|
44
|
+
seq = append_v2_thinking(blocks, msg, seq)
|
|
45
|
+
|
|
46
|
+
# If no blocks yet, create from fallback content
|
|
47
|
+
if not blocks and fallback_content:
|
|
48
|
+
append_fallback(blocks, fallback_content, seq)
|
|
49
|
+
|
|
50
|
+
return blocks
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def append_raw_blocks(
|
|
54
|
+
blocks: List[ContentBlock], raw_blocks: List[Dict], seq: int
|
|
55
|
+
) -> int:
|
|
56
|
+
"""Append blocks from Cursor's pre-parsed ``content_blocks`` array.
|
|
57
|
+
|
|
58
|
+
Each ``raw_block`` is dispatched by its ``type`` to a dedicated appender
|
|
59
|
+
that returns the number of blocks it added; ``seq`` advances by that
|
|
60
|
+
count, preserving the original ordering and seq numbering.
|
|
61
|
+
"""
|
|
62
|
+
appenders = {
|
|
63
|
+
"text": append_raw_text,
|
|
64
|
+
"tool_use": append_raw_tool_use,
|
|
65
|
+
"tool_result": append_raw_tool_result,
|
|
66
|
+
"thinking": append_raw_thinking,
|
|
67
|
+
"code": append_raw_code,
|
|
68
|
+
}
|
|
69
|
+
for raw_block in raw_blocks or []:
|
|
70
|
+
block_type = raw_block.get("type", "text")
|
|
71
|
+
appender = appenders.get(block_type, append_raw_unknown)
|
|
72
|
+
seq += appender(blocks, raw_block, seq)
|
|
73
|
+
return seq
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def append_raw_text(blocks: List[ContentBlock], raw_block: Dict, seq: int) -> int:
|
|
77
|
+
text = raw_block.get("text", "")
|
|
78
|
+
if text:
|
|
79
|
+
blocks.append(ProviderParser.create_text_block(text, seq))
|
|
80
|
+
return 1
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def append_raw_tool_use(blocks: List[ContentBlock], raw_block: Dict, seq: int) -> int:
|
|
85
|
+
original_name = raw_block.get("name", "unknown")
|
|
86
|
+
canonical_name = normalize_tool_name(original_name)
|
|
87
|
+
input_data = raw_block.get("input", {})
|
|
88
|
+
block = ProviderParser.create_tool_use_block(canonical_name, input_data, seq)
|
|
89
|
+
if canonical_name != original_name:
|
|
90
|
+
block["provider_tool_name"] = original_name
|
|
91
|
+
blocks.append(block)
|
|
92
|
+
return 1
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def append_raw_tool_result(
|
|
96
|
+
blocks: List[ContentBlock], raw_block: Dict, seq: int
|
|
97
|
+
) -> int:
|
|
98
|
+
name = raw_block.get("name", "unknown")
|
|
99
|
+
content = raw_block.get("content")
|
|
100
|
+
is_error = raw_block.get("is_error", False)
|
|
101
|
+
blocks.append(ProviderParser.create_tool_result_block(name, content, seq, is_error))
|
|
102
|
+
return 1
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def append_raw_thinking(blocks: List[ContentBlock], raw_block: Dict, seq: int) -> int:
|
|
106
|
+
text = raw_block.get("text", "")
|
|
107
|
+
if text:
|
|
108
|
+
blocks.append(ProviderParser.create_thinking_block(text, seq))
|
|
109
|
+
return 1
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def append_raw_code(blocks: List[ContentBlock], raw_block: Dict, seq: int) -> int:
|
|
114
|
+
code = raw_block.get("code", "")
|
|
115
|
+
language = raw_block.get("language")
|
|
116
|
+
blocks.append({
|
|
117
|
+
"type": "code",
|
|
118
|
+
"code": code,
|
|
119
|
+
"language": language,
|
|
120
|
+
"seq": seq,
|
|
121
|
+
})
|
|
122
|
+
return 1
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def append_raw_unknown(blocks: List[ContentBlock], raw_block: Dict, seq: int) -> int:
|
|
126
|
+
"""Unknown type - try to extract text."""
|
|
127
|
+
text = raw_block.get("text") or raw_block.get("content", "")
|
|
128
|
+
if text and isinstance(text, str):
|
|
129
|
+
blocks.append(ProviderParser.create_text_block(text, seq))
|
|
130
|
+
return 1
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def append_legacy_thinking(
|
|
135
|
+
blocks: List[ContentBlock], msg: Dict[str, Any], seq: int
|
|
136
|
+
) -> int:
|
|
137
|
+
"""Append thinking blocks from the legacy ``thinking``/``reasoning`` fields."""
|
|
138
|
+
thinking = msg.get("thinking") or msg.get("reasoning")
|
|
139
|
+
if thinking:
|
|
140
|
+
if isinstance(thinking, str):
|
|
141
|
+
blocks.append(ProviderParser.create_thinking_block(thinking, seq))
|
|
142
|
+
seq += 1
|
|
143
|
+
elif isinstance(thinking, list):
|
|
144
|
+
for t in thinking:
|
|
145
|
+
if isinstance(t, str):
|
|
146
|
+
blocks.append(ProviderParser.create_thinking_block(t, seq))
|
|
147
|
+
seq += 1
|
|
148
|
+
elif isinstance(t, dict):
|
|
149
|
+
text = t.get("text", "")
|
|
150
|
+
if text:
|
|
151
|
+
blocks.append(ProviderParser.create_thinking_block(text, seq))
|
|
152
|
+
seq += 1
|
|
153
|
+
return seq
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def append_legacy_tool_calls(
|
|
157
|
+
blocks: List[ContentBlock], msg: Dict[str, Any], seq: int
|
|
158
|
+
) -> int:
|
|
159
|
+
"""Append tool_use blocks from the legacy ``tool_calls``/``toolCalls`` array."""
|
|
160
|
+
tool_calls = msg.get("tool_calls") or msg.get("toolCalls", [])
|
|
161
|
+
if tool_calls:
|
|
162
|
+
for tc in tool_calls:
|
|
163
|
+
if isinstance(tc, dict):
|
|
164
|
+
original_name = tc.get("name") or tc.get("function", {}).get("name", "unknown")
|
|
165
|
+
canonical_name = normalize_tool_name(original_name)
|
|
166
|
+
input_data = tc.get("arguments") or tc.get("input") or tc.get("function", {}).get("arguments", {})
|
|
167
|
+
# Parse JSON string arguments if needed
|
|
168
|
+
if isinstance(input_data, str):
|
|
169
|
+
try:
|
|
170
|
+
input_data = json.loads(input_data)
|
|
171
|
+
except (json.JSONDecodeError, TypeError):
|
|
172
|
+
pass
|
|
173
|
+
input_data = normalize_tool_input(original_name, canonical_name, input_data, msg)
|
|
174
|
+
block = ProviderParser.create_tool_use_block(canonical_name, input_data, seq)
|
|
175
|
+
if canonical_name != original_name:
|
|
176
|
+
block["provider_tool_name"] = original_name
|
|
177
|
+
blocks.append(block)
|
|
178
|
+
seq += 1
|
|
179
|
+
return seq
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def append_legacy_tool_results(
|
|
183
|
+
blocks: List[ContentBlock], msg: Dict[str, Any], seq: int
|
|
184
|
+
) -> int:
|
|
185
|
+
"""Append tool_result blocks from the legacy ``tool_results``/``toolResults`` array."""
|
|
186
|
+
tool_results = msg.get("tool_results") or msg.get("toolResults", [])
|
|
187
|
+
if tool_results:
|
|
188
|
+
for tr in tool_results:
|
|
189
|
+
if isinstance(tr, dict):
|
|
190
|
+
name = tr.get("name", "unknown")
|
|
191
|
+
content = tr.get("content") or tr.get("result")
|
|
192
|
+
is_error = tr.get("is_error", False) or tr.get("isError", False)
|
|
193
|
+
blocks.append(ProviderParser.create_tool_result_block(name, content, seq, is_error))
|
|
194
|
+
seq += 1
|
|
195
|
+
return seq
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def append_v2_code_blocks(
|
|
199
|
+
blocks: List[ContentBlock], msg: Dict[str, Any], seq: int
|
|
200
|
+
) -> int:
|
|
201
|
+
"""Append code blocks from the v2 export ``code_blocks`` array (cursor_export.py output)."""
|
|
202
|
+
code_blocks = msg.get("code_blocks", [])
|
|
203
|
+
if code_blocks:
|
|
204
|
+
for cb in code_blocks:
|
|
205
|
+
if isinstance(cb, dict):
|
|
206
|
+
# v2 format: {uri, version, codeBlockIdx, content, languageId}
|
|
207
|
+
code = cb.get("content", "")
|
|
208
|
+
language = cb.get("languageId") or cb.get("language")
|
|
209
|
+
uri = cb.get("uri", {})
|
|
210
|
+
file_path = uri.get("path") if isinstance(uri, dict) else None
|
|
211
|
+
blocks.append(cast(ContentBlock, {
|
|
212
|
+
"type": "code",
|
|
213
|
+
"code": code,
|
|
214
|
+
"language": language,
|
|
215
|
+
"file_path": file_path,
|
|
216
|
+
"seq": seq,
|
|
217
|
+
}))
|
|
218
|
+
seq += 1
|
|
219
|
+
return seq
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def append_v2_tool_call(
|
|
223
|
+
blocks: List[ContentBlock], msg: Dict[str, Any], seq: int
|
|
224
|
+
) -> int:
|
|
225
|
+
"""Append tool_use (+ optional tool_result) from the v2 ``tool_call`` object.
|
|
226
|
+
|
|
227
|
+
This contains the structured tool call data from toolFormerData.
|
|
228
|
+
"""
|
|
229
|
+
tool_call = msg.get("tool_call")
|
|
230
|
+
if tool_call and isinstance(tool_call, dict):
|
|
231
|
+
original_name = tool_call.get("name", "unknown")
|
|
232
|
+
canonical_name = normalize_tool_name(original_name)
|
|
233
|
+
args = tool_call.get("args")
|
|
234
|
+
result = tool_call.get("result")
|
|
235
|
+
status = tool_call.get("status")
|
|
236
|
+
|
|
237
|
+
# Parse JSON string args if needed
|
|
238
|
+
if isinstance(args, str):
|
|
239
|
+
try:
|
|
240
|
+
args = json.loads(args)
|
|
241
|
+
except (json.JSONDecodeError, TypeError):
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
args = normalize_tool_input(original_name, canonical_name, args, msg)
|
|
245
|
+
|
|
246
|
+
# Add tool use block
|
|
247
|
+
tool_block: dict = {
|
|
248
|
+
"type": "tool_use",
|
|
249
|
+
"name": canonical_name,
|
|
250
|
+
"input": args,
|
|
251
|
+
"tool_call_id": tool_call.get("call_id"),
|
|
252
|
+
"seq": seq,
|
|
253
|
+
}
|
|
254
|
+
if canonical_name != original_name:
|
|
255
|
+
tool_block["provider_tool_name"] = original_name
|
|
256
|
+
blocks.append(cast(ContentBlock, tool_block))
|
|
257
|
+
seq += 1
|
|
258
|
+
|
|
259
|
+
# Add tool result if present
|
|
260
|
+
if result:
|
|
261
|
+
result_content = result
|
|
262
|
+
if isinstance(result, str):
|
|
263
|
+
try:
|
|
264
|
+
result_content = json.loads(result)
|
|
265
|
+
except (json.JSONDecodeError, TypeError):
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
blocks.append(cast(ContentBlock, {
|
|
269
|
+
"type": "tool_result",
|
|
270
|
+
"name": canonical_name,
|
|
271
|
+
"content": result_content,
|
|
272
|
+
"status": status,
|
|
273
|
+
"user_decision": tool_call.get("user_decision"),
|
|
274
|
+
"seq": seq,
|
|
275
|
+
}))
|
|
276
|
+
if canonical_name != original_name:
|
|
277
|
+
cast(Dict[str, Any], blocks[-1])["provider_tool_name"] = original_name
|
|
278
|
+
seq += 1
|
|
279
|
+
return seq
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def append_v2_thinking(
|
|
283
|
+
blocks: List[ContentBlock], msg: Dict[str, Any], seq: int
|
|
284
|
+
) -> int:
|
|
285
|
+
"""Append the v2-export thinking block (only when ``thinking_duration_ms`` is present).
|
|
286
|
+
|
|
287
|
+
This is the actual thinking text from thinking.text. Only used when
|
|
288
|
+
thinking_duration_ms is present (the v2 indicator); otherwise the legacy
|
|
289
|
+
handler (append_legacy_thinking) already covered it. Note both fire for
|
|
290
|
+
a plain-string v2 thinking, by design preserved here.
|
|
291
|
+
"""
|
|
292
|
+
thinking_text = msg.get("thinking")
|
|
293
|
+
thinking_duration = msg.get("thinking_duration_ms")
|
|
294
|
+
if thinking_text and isinstance(thinking_text, str) and thinking_duration is not None:
|
|
295
|
+
blocks.append(cast(ContentBlock, {
|
|
296
|
+
"type": "thinking",
|
|
297
|
+
"text": thinking_text,
|
|
298
|
+
"duration_ms": thinking_duration,
|
|
299
|
+
"seq": seq,
|
|
300
|
+
}))
|
|
301
|
+
seq += 1
|
|
302
|
+
return seq
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def append_fallback(
|
|
306
|
+
blocks: List[ContentBlock], fallback_content: Any, seq: int
|
|
307
|
+
) -> int:
|
|
308
|
+
"""Append text blocks from fallback content when no other blocks were built."""
|
|
309
|
+
if isinstance(fallback_content, str):
|
|
310
|
+
blocks.append(ProviderParser.create_text_block(fallback_content, seq))
|
|
311
|
+
elif isinstance(fallback_content, list):
|
|
312
|
+
for item in fallback_content:
|
|
313
|
+
if isinstance(item, str):
|
|
314
|
+
blocks.append(ProviderParser.create_text_block(item, seq))
|
|
315
|
+
seq += 1
|
|
316
|
+
elif isinstance(item, dict):
|
|
317
|
+
text = item.get("text", "")
|
|
318
|
+
if text:
|
|
319
|
+
blocks.append(ProviderParser.create_text_block(text, seq))
|
|
320
|
+
seq += 1
|
|
321
|
+
return seq
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def normalize_tool_input(
|
|
325
|
+
original_name: str,
|
|
326
|
+
canonical_name: str,
|
|
327
|
+
input_data: Any,
|
|
328
|
+
msg: Dict[str, Any],
|
|
329
|
+
) -> Any:
|
|
330
|
+
"""Normalize tool input to Thread's canonical format.
|
|
331
|
+
|
|
332
|
+
- Recovers file_path from code_blocks when input is null/missing
|
|
333
|
+
- Normalizes provider-specific path fields (e.g. input.path → input.file_path)
|
|
334
|
+
"""
|
|
335
|
+
if canonical_name not in FILE_TOOLS:
|
|
336
|
+
return input_data
|
|
337
|
+
|
|
338
|
+
# Ensure input is a dict
|
|
339
|
+
if input_data is None:
|
|
340
|
+
input_data = {}
|
|
341
|
+
if not isinstance(input_data, dict):
|
|
342
|
+
return input_data
|
|
343
|
+
|
|
344
|
+
# Already has file_path — nothing to do
|
|
345
|
+
if input_data.get("file_path"):
|
|
346
|
+
return input_data
|
|
347
|
+
|
|
348
|
+
# Try provider-specific path field (e.g. read_file_v2 uses "path")
|
|
349
|
+
path_field = PROVIDER_PATH_FIELDS.get(original_name)
|
|
350
|
+
if path_field and input_data.get(path_field):
|
|
351
|
+
input_data["file_path"] = input_data[path_field]
|
|
352
|
+
return input_data
|
|
353
|
+
|
|
354
|
+
# Recover from code_blocks on the same message (Cursor v2 format)
|
|
355
|
+
# code_blocks contain the files the tool operated on
|
|
356
|
+
code_blocks = msg.get("code_blocks", [])
|
|
357
|
+
if code_blocks:
|
|
358
|
+
for cb in code_blocks:
|
|
359
|
+
if isinstance(cb, dict):
|
|
360
|
+
uri = cb.get("uri", {})
|
|
361
|
+
if isinstance(uri, dict) and uri.get("path"):
|
|
362
|
+
input_data["file_path"] = uri["path"]
|
|
363
|
+
return input_data
|
|
364
|
+
|
|
365
|
+
return input_data
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pipeline infrastructure for parser composition.
|
|
3
|
+
|
|
4
|
+
The pipeline pattern separates parsing into distinct phases:
|
|
5
|
+
1. Parse: Raw provider data -> RawMessage (intermediate representation)
|
|
6
|
+
2. Transform: RawMessage[] -> RawMessage[] (coalescing, merging, etc.)
|
|
7
|
+
3. Normalize: RawMessage -> NormalizedMessage (canonical format)
|
|
8
|
+
4. Validate: NormalizedMessage[] -> validation results
|
|
9
|
+
|
|
10
|
+
Each phase has a Protocol definition for pluggability.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .base import ParserPipeline
|
|
14
|
+
from .interfaces import (
|
|
15
|
+
Normalizer,
|
|
16
|
+
Parser,
|
|
17
|
+
RawMessage,
|
|
18
|
+
Transformer,
|
|
19
|
+
Validator,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"RawMessage",
|
|
24
|
+
"Parser",
|
|
25
|
+
"Transformer",
|
|
26
|
+
"Normalizer",
|
|
27
|
+
"Validator",
|
|
28
|
+
"ParserPipeline",
|
|
29
|
+
]
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ParserPipeline implementation.
|
|
3
|
+
|
|
4
|
+
Composes Parser -> Transformer[] -> Normalizer -> Validator[] into
|
|
5
|
+
a single processing pipeline.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, Generic, List, Optional, TypeVar
|
|
9
|
+
|
|
10
|
+
from ..base import ImportResult, NormalizedMessage
|
|
11
|
+
from ..config import ProviderConfig
|
|
12
|
+
from .interfaces import (
|
|
13
|
+
Normalizer,
|
|
14
|
+
Parser,
|
|
15
|
+
Transformer,
|
|
16
|
+
ValidationContext,
|
|
17
|
+
Validator,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
T_Input = TypeVar("T_Input")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ParserPipeline(Generic[T_Input]):
|
|
24
|
+
"""Composes parsing phases into a complete pipeline.
|
|
25
|
+
|
|
26
|
+
The pipeline processes data through:
|
|
27
|
+
1. Parse: Raw data -> RawMessage[]
|
|
28
|
+
2. Transform: RawMessage[] -> RawMessage[] (optional, multiple)
|
|
29
|
+
3. Normalize: RawMessage -> NormalizedMessage (for each message)
|
|
30
|
+
4. Validate: NormalizedMessage[] -> validation results
|
|
31
|
+
|
|
32
|
+
Example usage:
|
|
33
|
+
pipeline = ParserPipeline(
|
|
34
|
+
parser=ChatGPTRawParser(),
|
|
35
|
+
transformers=[
|
|
36
|
+
ActivePathTransformer(),
|
|
37
|
+
ToolCoalescingTransformer(),
|
|
38
|
+
],
|
|
39
|
+
normalizer=ChatGPTNormalizer(),
|
|
40
|
+
validators=[
|
|
41
|
+
ThinkingBlockValidator(CHATGPT_CONFIG),
|
|
42
|
+
ReferentialIntegrityValidator(),
|
|
43
|
+
],
|
|
44
|
+
config=CHATGPT_CONFIG,
|
|
45
|
+
)
|
|
46
|
+
result = pipeline.process(data)
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
parser: Parser[T_Input],
|
|
52
|
+
normalizer: Normalizer,
|
|
53
|
+
config: ProviderConfig,
|
|
54
|
+
transformers: Optional[List[Transformer]] = None,
|
|
55
|
+
validators: Optional[List[Validator]] = None,
|
|
56
|
+
):
|
|
57
|
+
"""Initialize the pipeline.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
parser: Parser for converting raw data to RawMessages
|
|
61
|
+
normalizer: Normalizer for converting to NormalizedMessages
|
|
62
|
+
config: Provider-specific configuration
|
|
63
|
+
transformers: Optional list of transformers (applied in order)
|
|
64
|
+
validators: Optional list of validators (all applied)
|
|
65
|
+
"""
|
|
66
|
+
self.parser = parser
|
|
67
|
+
self.transformers = transformers or []
|
|
68
|
+
self.normalizer = normalizer
|
|
69
|
+
self.validators = validators or []
|
|
70
|
+
self.config = config
|
|
71
|
+
|
|
72
|
+
def process(self, data: T_Input) -> ImportResult:
|
|
73
|
+
"""Process data through the complete pipeline.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
data: Raw provider export data
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
ImportResult with messages, errors, warnings, and coverage stats
|
|
80
|
+
"""
|
|
81
|
+
# Phase 1: Parse
|
|
82
|
+
raw_messages = self.parser.parse(data)
|
|
83
|
+
|
|
84
|
+
# Phase 2: Transform
|
|
85
|
+
for transformer in self.transformers:
|
|
86
|
+
raw_messages = transformer.transform(raw_messages)
|
|
87
|
+
|
|
88
|
+
# Phase 3: Normalize
|
|
89
|
+
normalized_messages: List[NormalizedMessage] = []
|
|
90
|
+
for raw in raw_messages:
|
|
91
|
+
normalized = self.normalizer.normalize(raw)
|
|
92
|
+
normalized_messages.append(normalized)
|
|
93
|
+
|
|
94
|
+
# Phase 4: Validate
|
|
95
|
+
all_errors, all_warnings = self._validate_all(normalized_messages)
|
|
96
|
+
|
|
97
|
+
# Calculate field coverage
|
|
98
|
+
field_coverage = self._calculate_field_coverage(normalized_messages)
|
|
99
|
+
|
|
100
|
+
# Count unique conversations
|
|
101
|
+
conversations = set(
|
|
102
|
+
msg.get("provider_conversation_id", "unknown")
|
|
103
|
+
for msg in normalized_messages
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return ImportResult(
|
|
107
|
+
messages=normalized_messages,
|
|
108
|
+
validation_errors=all_errors,
|
|
109
|
+
validation_warnings=all_warnings,
|
|
110
|
+
field_coverage=field_coverage,
|
|
111
|
+
conversations_processed=len(conversations),
|
|
112
|
+
messages_processed=len(normalized_messages),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def _validate_all(
|
|
116
|
+
self, messages: List[NormalizedMessage]
|
|
117
|
+
) -> tuple[List[str], List[str]]:
|
|
118
|
+
"""Run all validators on messages.
|
|
119
|
+
|
|
120
|
+
Groups messages by conversation and validates each group.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Tuple of (errors, warnings)
|
|
124
|
+
"""
|
|
125
|
+
# Group by conversation
|
|
126
|
+
conversations: Dict[str, List[NormalizedMessage]] = {}
|
|
127
|
+
for msg in messages:
|
|
128
|
+
conv_id = msg.get("provider_conversation_id", "unknown")
|
|
129
|
+
if conv_id not in conversations:
|
|
130
|
+
conversations[conv_id] = []
|
|
131
|
+
conversations[conv_id].append(msg)
|
|
132
|
+
|
|
133
|
+
all_errors: List[str] = []
|
|
134
|
+
all_warnings: List[str] = []
|
|
135
|
+
|
|
136
|
+
# Validate each conversation
|
|
137
|
+
for conv_id, conv_messages in conversations.items():
|
|
138
|
+
context = ValidationContext(
|
|
139
|
+
conversation_id=conv_id,
|
|
140
|
+
source_provider=self.config.provider_name,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Sort by message_order for proper validation
|
|
144
|
+
sorted_msgs = sorted(
|
|
145
|
+
conv_messages, key=lambda m: m.get("message_order") or 0
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Run each validator
|
|
149
|
+
for validator in self.validators:
|
|
150
|
+
validator.validate(sorted_msgs, context)
|
|
151
|
+
|
|
152
|
+
all_errors.extend(context.errors)
|
|
153
|
+
all_warnings.extend(context.warnings)
|
|
154
|
+
|
|
155
|
+
return all_errors, all_warnings
|
|
156
|
+
|
|
157
|
+
def _calculate_field_coverage(
|
|
158
|
+
self, messages: List[NormalizedMessage]
|
|
159
|
+
) -> Dict[str, float]:
|
|
160
|
+
"""Calculate percentage of messages with each field populated."""
|
|
161
|
+
if not messages:
|
|
162
|
+
return {}
|
|
163
|
+
|
|
164
|
+
tracked_fields = [
|
|
165
|
+
"created_at",
|
|
166
|
+
"updated_at",
|
|
167
|
+
"content_text",
|
|
168
|
+
"content_blocks",
|
|
169
|
+
"provider_parent_id",
|
|
170
|
+
"message_order",
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
coverage: Dict[str, int] = {f: 0 for f in tracked_fields}
|
|
174
|
+
total = len(messages)
|
|
175
|
+
|
|
176
|
+
for msg in messages:
|
|
177
|
+
for field_name in tracked_fields:
|
|
178
|
+
value = msg.get(field_name) # type: ignore
|
|
179
|
+
if value is not None and value != "" and value != []:
|
|
180
|
+
coverage[field_name] += 1
|
|
181
|
+
|
|
182
|
+
return {f: count / total for f, count in coverage.items()}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class BaseNormalizer:
|
|
186
|
+
"""Base class for normalizers with common utilities.
|
|
187
|
+
|
|
188
|
+
Provides helper methods for creating content blocks, hashing,
|
|
189
|
+
and other common normalization tasks.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def __init__(self, config: ProviderConfig):
|
|
193
|
+
"""Initialize with provider config.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
config: Provider-specific configuration
|
|
197
|
+
"""
|
|
198
|
+
self.config = config
|
|
199
|
+
|
|
200
|
+
def normalize_role(self, role: Optional[str]) -> str:
|
|
201
|
+
"""Normalize role to standard values."""
|
|
202
|
+
if not role:
|
|
203
|
+
return "unknown"
|
|
204
|
+
role_lower = role.lower()
|
|
205
|
+
if role_lower in ("user", "human"):
|
|
206
|
+
return "user"
|
|
207
|
+
elif role_lower in ("assistant", "ai", "bot"):
|
|
208
|
+
return "assistant"
|
|
209
|
+
elif role_lower in ("system", "tool"):
|
|
210
|
+
return role_lower
|
|
211
|
+
return role_lower
|
|
212
|
+
|
|
213
|
+
def hash_message(
|
|
214
|
+
self, msg_id: str, role: Optional[str], content: Any, create_time: Any
|
|
215
|
+
) -> str:
|
|
216
|
+
"""Create deterministic hash for deduplication."""
|
|
217
|
+
import hashlib
|
|
218
|
+
import json
|
|
219
|
+
import math
|
|
220
|
+
|
|
221
|
+
# Normalize create_time
|
|
222
|
+
normalized_create_time = create_time
|
|
223
|
+
if isinstance(create_time, float):
|
|
224
|
+
if math.isfinite(create_time) and create_time.is_integer():
|
|
225
|
+
normalized_create_time = int(create_time)
|
|
226
|
+
|
|
227
|
+
hash_input = json.dumps(
|
|
228
|
+
{
|
|
229
|
+
"id": msg_id,
|
|
230
|
+
"role": role,
|
|
231
|
+
"content": content,
|
|
232
|
+
"create_time": normalized_create_time,
|
|
233
|
+
},
|
|
234
|
+
sort_keys=True,
|
|
235
|
+
)
|
|
236
|
+
return hashlib.sha256(hash_input.encode()).hexdigest()
|
|
237
|
+
|
|
238
|
+
def extract_text_from_blocks(self, blocks: List[Any]) -> str:
|
|
239
|
+
"""Extract primary display text from content blocks."""
|
|
240
|
+
text_parts = []
|
|
241
|
+
for block in blocks:
|
|
242
|
+
block_type = block.get("type", "")
|
|
243
|
+
if block_type == "text":
|
|
244
|
+
text = block.get("text", "")
|
|
245
|
+
if text:
|
|
246
|
+
text_parts.append(text)
|
|
247
|
+
elif block_type == "system_context":
|
|
248
|
+
text = block.get("text", "")
|
|
249
|
+
if text:
|
|
250
|
+
text_parts.append(text)
|
|
251
|
+
return "\n\n".join(text_parts).strip()
|