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,415 @@
1
+ """Pure content-block / message-list helpers for the Claude Code parser.
2
+
3
+ These are the stateless parsing helpers extracted out of
4
+ ``ClaudeCodeParser`` (see ``claude_code.py``). They take their inputs
5
+ explicitly and call only the shared static helpers on ``ProviderParser``
6
+ (``create_text_block``), so they carry no instance state. ``ClaudeCodeParser``
7
+ keeps thin delegating methods that call into here, preserving the class
8
+ surface (interface + test-called private methods) byte-for-byte.
9
+
10
+ Behavior is identical to the in-class originals: every emitted dict / list /
11
+ content-block shape and return value is unchanged.
12
+ """
13
+
14
+ import re
15
+ import uuid as _uuid
16
+ from typing import Any, Dict, List, Tuple, cast
17
+
18
+ from .base import ContentBlock, NormalizedMessage, ProviderParser
19
+ from .claude_code_ide import _extract_ide_context
20
+
21
+ # Regex for parsing XML-format tool calls (the `<function_calls>` form),
22
+ # co-located with the XML parser that uses them and also exposed as class
23
+ # attributes on ClaudeCodeParser.
24
+ FUNCTION_CALLS_SPLIT = re.compile(
25
+ r'(<function_calls>.*?</function_calls>)', re.DOTALL
26
+ )
27
+ INVOKE_PATTERN = re.compile(
28
+ r'<invoke\s+name="([^"]+)">(.*?)</invoke>', re.DOTALL
29
+ )
30
+ PARAMETER_PATTERN = re.compile(
31
+ r'<parameter\s+name="([^"]+)">(.*?)</parameter>', re.DOTALL
32
+ )
33
+
34
+
35
+ def extract_tool_result_text(content: Any) -> str:
36
+ """Extract text from tool result content."""
37
+ if isinstance(content, str):
38
+ return content
39
+ if isinstance(content, list):
40
+ texts = []
41
+ for item in content:
42
+ if isinstance(item, str):
43
+ texts.append(item)
44
+ elif isinstance(item, dict):
45
+ text = item.get("text", "")
46
+ if text:
47
+ texts.append(text)
48
+ return "\n".join(texts)
49
+ return ""
50
+
51
+
52
+ def user_content_blocks_from_list(
53
+ content: list,
54
+ ) -> "tuple[List[ContentBlock], str, List[Dict[str, Any]]]":
55
+ """Build content blocks from a user message's list content.
56
+
57
+ Returns ``(content_blocks, content_text, ide_context_blocks)``. Handles
58
+ the tool_result / image / text block shapes; text blocks also yield IDE
59
+ context that the caller appends (renumbered) after the main blocks.
60
+ """
61
+ content_blocks: List[ContentBlock] = []
62
+ content_text = ""
63
+ ide_context_blocks: List[Dict[str, Any]] = []
64
+ # Array of content blocks (tool results, text blocks)
65
+ seq = 0
66
+ for block in content:
67
+ if isinstance(block, dict):
68
+ block_type = block.get("type", "")
69
+ if block_type == "tool_result":
70
+ tool_content = block.get("content", [])
71
+ # Extract text from tool result content
72
+ result_text = extract_tool_result_text(tool_content)
73
+ content_blocks.append(cast(ContentBlock, {
74
+ "type": "tool_result",
75
+ "tool_use_id": block.get("tool_use_id"),
76
+ "content": tool_content,
77
+ "text": result_text,
78
+ "is_error": block.get("is_error", False),
79
+ "seq": seq,
80
+ }))
81
+ seq += 1
82
+ elif block_type == "image":
83
+ # Preserve image blocks (base64-encoded screenshots, etc.)
84
+ content_blocks.append(cast(ContentBlock, {
85
+ "type": "image",
86
+ "source": block.get("source", {}),
87
+ "seq": seq,
88
+ }))
89
+ seq += 1
90
+ elif block_type == "text":
91
+ text = block.get("text", "")
92
+ if text:
93
+ # Extract IDE context from text blocks
94
+ cleaned_text, extracted_ide = _extract_ide_context(text)
95
+ ide_context_blocks.extend(extracted_ide)
96
+
97
+ if cleaned_text:
98
+ content_text = cleaned_text if not content_text else f"{content_text}\n{cleaned_text}"
99
+ content_blocks.append(ProviderParser.create_text_block(cleaned_text, seq))
100
+ seq += 1
101
+ elif block_type:
102
+ # Preserve unknown user block types — "Archivist, Not Filter"
103
+ # (mirrors append_assistant_block). document / redacted_thinking /
104
+ # mcp_tool_use / future kinds keep their own block instead of
105
+ # vanishing from content_blocks (they'd otherwise survive only in
106
+ # the raw provider_data.line blob).
107
+ content_blocks.append(cast(ContentBlock, {
108
+ "type": block_type,
109
+ "raw": block,
110
+ "seq": seq,
111
+ }))
112
+ seq += 1
113
+ elif isinstance(block, str):
114
+ # A bare string inside a user content list — keep it as text rather
115
+ # than skip it.
116
+ if block:
117
+ content_text = block if not content_text else f"{content_text}\n{block}"
118
+ content_blocks.append(ProviderParser.create_text_block(block, seq))
119
+ seq += 1
120
+ return content_blocks, content_text, ide_context_blocks
121
+
122
+
123
+ def parse_xml_function_calls(
124
+ raw_content: str, seq: int
125
+ ) -> Tuple[List[Dict[str, Any]], List[str], int]:
126
+ """Parse old-format <function_calls> XML from string content.
127
+
128
+ Old Claude Code sessions (pre-2026) stored tool calls as XML text
129
+ within the assistant response string. This splits the string into
130
+ interleaved text and tool_use content blocks.
131
+
132
+ Returns:
133
+ (content_blocks, content_text_parts, updated_seq)
134
+ """
135
+ content_blocks: List[Dict[str, Any]] = []
136
+ content_text_parts: List[str] = []
137
+
138
+ segments = FUNCTION_CALLS_SPLIT.split(raw_content)
139
+
140
+ for segment in segments:
141
+ if not segment.strip():
142
+ continue
143
+
144
+ if segment.startswith('<function_calls>'):
145
+ # Parse each <invoke> within the block
146
+ for match in INVOKE_PATTERN.finditer(segment):
147
+ tool_name = match.group(1)
148
+ invoke_body = match.group(2)
149
+
150
+ # Extract parameters
151
+ input_data = {}
152
+ for param in PARAMETER_PATTERN.finditer(invoke_body):
153
+ input_data[param.group(1)] = param.group(2)
154
+
155
+ # Deterministic ID from content
156
+ tool_call_id = str(_uuid.uuid5(
157
+ _uuid.NAMESPACE_URL,
158
+ f"{tool_name}:{seq}:{list(input_data.values())[:1]}"
159
+ ))
160
+
161
+ content_blocks.append({
162
+ "type": "tool_use",
163
+ "tool_call_id": tool_call_id,
164
+ "name": tool_name,
165
+ "input": input_data,
166
+ "seq": seq,
167
+ })
168
+ seq += 1
169
+ else:
170
+ # Text segment
171
+ text = segment.strip()
172
+ if text:
173
+ content_text_parts.append(text)
174
+ content_blocks.append(cast(Dict[str, Any], ProviderParser.create_text_block(text, seq)))
175
+ seq += 1
176
+
177
+ return content_blocks, content_text_parts, seq
178
+
179
+
180
+ def append_assistant_block(
181
+ block: Dict[str, Any],
182
+ content_blocks: List[ContentBlock],
183
+ content_text_parts: List[str],
184
+ seq: int,
185
+ ) -> int:
186
+ """Append one list-shaped assistant block; return the next seq value."""
187
+ block_type = block.get("type", "")
188
+
189
+ if block_type == "thinking":
190
+ thinking_text = block.get("thinking", "")
191
+ if thinking_text:
192
+ content_blocks.append(cast(ContentBlock, {
193
+ "type": "thinking",
194
+ "text": thinking_text,
195
+ "signature": block.get("signature"),
196
+ "seq": seq,
197
+ }))
198
+ return seq + 1
199
+
200
+ elif block_type == "text":
201
+ text = block.get("text", "")
202
+ if text:
203
+ content_text_parts.append(text)
204
+ content_blocks.append(ProviderParser.create_text_block(text, seq))
205
+ return seq + 1
206
+
207
+ elif block_type == "tool_use":
208
+ content_blocks.append({
209
+ "type": "tool_use",
210
+ "tool_call_id": block.get("id"),
211
+ "name": block.get("name", "unknown"),
212
+ "input": block.get("input", {}),
213
+ "seq": seq,
214
+ })
215
+ return seq + 1
216
+
217
+ elif block_type:
218
+ # Preserve unknown block types - "Archivist, Not Filter"
219
+ content_blocks.append(cast(ContentBlock, {
220
+ "type": block_type,
221
+ "raw": block,
222
+ "seq": seq,
223
+ }))
224
+ return seq + 1
225
+
226
+ return seq
227
+
228
+
229
+ def assistant_content(
230
+ raw_content: Any,
231
+ ) -> Tuple[List[ContentBlock], List[str]]:
232
+ """Build (content_blocks, content_text_parts) for an assistant message.
233
+
234
+ Handles both the list-of-blocks shape (thinking/text/tool_use/unknown)
235
+ and the legacy string shape (plain or old-format <function_calls> XML).
236
+ """
237
+ content_blocks: List[ContentBlock] = []
238
+ content_text_parts: List[str] = []
239
+ seq = 0
240
+
241
+ if isinstance(raw_content, list):
242
+ for block in raw_content:
243
+ if not isinstance(block, dict):
244
+ continue
245
+ seq = append_assistant_block(
246
+ block, content_blocks, content_text_parts, seq
247
+ )
248
+ elif isinstance(raw_content, str):
249
+ if '<function_calls>' in raw_content:
250
+ blocks, text_parts, seq = parse_xml_function_calls(
251
+ raw_content, seq
252
+ )
253
+ content_blocks.extend(cast(List[ContentBlock], blocks))
254
+ content_text_parts.extend(text_parts)
255
+ else:
256
+ content_text_parts.append(raw_content)
257
+ content_blocks.append(ProviderParser.create_text_block(raw_content, 0))
258
+
259
+ return content_blocks, content_text_parts
260
+
261
+
262
+ def dedup_compaction_replays(
263
+ messages: List[NormalizedMessage],
264
+ ) -> List[NormalizedMessage]:
265
+ """Drop compaction-replay duplicates.
266
+
267
+ Claude Code context compaction replays earlier messages in the JSONL
268
+ with new UUIDs but identical content+timestamps. Keep the first
269
+ occurrence of each (role, content_text, created_at) for user/assistant.
270
+ """
271
+ seen: set[tuple[str, str, str | None, str | None]] = set()
272
+ deduped: List[NormalizedMessage] = []
273
+ for msg in messages:
274
+ role = msg.get("role", "")
275
+ if role in ("user", "assistant"):
276
+ content_text = msg.get("content_text", "")
277
+ created_at = msg.get("created_at")
278
+ # A `/model` switch has empty content_text and shares its timestamp with the
279
+ # command line beside it — so include the switch target in the key, else the
280
+ # marker-bearing turn collides with an empty one and is wrongly deduped away.
281
+ model_change = next(
282
+ (b.get("to_model") for b in msg.get("content_blocks", [])
283
+ if isinstance(b, dict) and b.get("type") == "model_change"),
284
+ None,
285
+ )
286
+ key = (role, content_text, str(created_at) if created_at else None, model_change)
287
+ if key in seen:
288
+ continue
289
+ seen.add(key)
290
+ deduped.append(msg)
291
+ return deduped
292
+
293
+
294
+ def apply_derived_title(messages: List[NormalizedMessage]) -> None:
295
+ """Derive a conversation title from the first user message and apply it.
296
+
297
+ ChatGPT has explicit titles; Claude Code doesn't, so derive from content
298
+ (first line, max 100 chars, ellipsised when truncated). Mutates each
299
+ message's ``conversation_title`` in place.
300
+ """
301
+ first_user_content = None
302
+ for msg in messages:
303
+ if msg.get("role") == "user" and msg.get("content_text"):
304
+ first_user_content = msg.get("content_text")
305
+ break
306
+
307
+ if first_user_content:
308
+ # Truncate to reasonable title length (first line, max 100 chars)
309
+ title = first_user_content.split("\n")[0][:100]
310
+ if len(first_user_content) > 100 or "\n" in first_user_content:
311
+ title = title.rstrip() + "..."
312
+ # Update all messages with the conversation title
313
+ for msg in messages:
314
+ msg["conversation_title"] = title
315
+
316
+
317
+ def build_chain_indices(
318
+ messages: List[NormalizedMessage],
319
+ ) -> Tuple[Dict[str, NormalizedMessage], Dict[str, List[NormalizedMessage]]]:
320
+ """Build (by_id, children_of) indices over the message list."""
321
+ by_id: Dict[str, NormalizedMessage] = {}
322
+ for m in messages:
323
+ mid = m.get("provider_message_id")
324
+ if mid:
325
+ by_id[mid] = m
326
+
327
+ children_of: Dict[str, List[NormalizedMessage]] = {}
328
+ for m in messages:
329
+ parent_id = m.get("provider_parent_id")
330
+ if parent_id:
331
+ if parent_id not in children_of:
332
+ children_of[parent_id] = []
333
+ children_of[parent_id].append(m)
334
+
335
+ return by_id, children_of
336
+
337
+
338
+ def find_chain_roots(
339
+ messages: List[NormalizedMessage],
340
+ by_id: Dict[str, NormalizedMessage],
341
+ ) -> List[NormalizedMessage]:
342
+ """Find chain roots: assistant messages whose parent is NOT an assistant
343
+ (i.e., parent is user, system, or doesn't exist)."""
344
+ chain_roots = []
345
+ for m in messages:
346
+ if m.get("role") != "assistant":
347
+ continue
348
+ parent_id = m.get("provider_parent_id")
349
+ if not parent_id:
350
+ chain_roots.append(m)
351
+ elif parent_id in by_id:
352
+ parent = by_id[parent_id]
353
+ if parent.get("role") != "assistant":
354
+ chain_roots.append(m)
355
+ else:
356
+ # Parent not in this session, treat as root
357
+ chain_roots.append(m)
358
+ return chain_roots
359
+
360
+
361
+ def merge_chain(
362
+ root: NormalizedMessage,
363
+ children_of: Dict[str, List[NormalizedMessage]],
364
+ to_remove: set,
365
+ ) -> None:
366
+ """BFS-collect all descendant assistant blocks into ``root`` in place,
367
+ marking merged children in ``to_remove``."""
368
+ root_id = root.get("provider_message_id")
369
+ if not root_id or root_id in to_remove:
370
+ return
371
+
372
+ all_blocks = list(root.get("content_blocks", []))
373
+ queue = children_of.get(root_id, [])[:]
374
+
375
+ while queue:
376
+ child = queue.pop(0)
377
+ if child.get("role") != "assistant":
378
+ continue
379
+ child_id = child.get("provider_message_id")
380
+ if child_id in to_remove:
381
+ continue
382
+
383
+ # Add this child's blocks
384
+ all_blocks.extend(child.get("content_blocks", []))
385
+ to_remove.add(child_id)
386
+
387
+ # Add this child's children to queue
388
+ queue.extend(children_of.get(cast(str, child_id), []))
389
+
390
+ # Update root's blocks
391
+ root["content_blocks"] = all_blocks
392
+
393
+
394
+ def coalesce_assistant_chains(
395
+ messages: List[NormalizedMessage],
396
+ ) -> List[NormalizedMessage]:
397
+ """Coalesce linked assistant messages into single messages.
398
+
399
+ In Claude Code JSONL, one assistant "turn" is stored as multiple lines
400
+ linked by parentUuid (thinking -> text -> tool_use -> tool_use...).
401
+ This merges them into a single message with combined content_blocks.
402
+ """
403
+ by_id, children_of = build_chain_indices(messages)
404
+ chain_roots = find_chain_roots(messages, by_id)
405
+
406
+ # For each chain root, collect all descendant assistant messages
407
+ to_remove: set = set()
408
+ for root in chain_roots:
409
+ merge_chain(root, children_of, to_remove)
410
+
411
+ # Return messages with merged ones removed
412
+ return [
413
+ m for m in messages
414
+ if m.get("provider_message_id") not in to_remove
415
+ ]
@@ -0,0 +1,122 @@
1
+ """IDE-context extraction and timestamp helpers for the Claude Code parser.
2
+
3
+ Stateless, pure helpers shared by the Claude Code parser modules: the
4
+ IDE-context tag extraction (opened files, selections) plus the small
5
+ timestamp-normalization helpers. They take their inputs explicitly and carry
6
+ no instance state. ``claude_code.py`` re-imports them so ``parent.<name>``
7
+ access and import sites resolve to the same objects;
8
+ ``claude_code_blocks.py`` imports ``_extract_ide_context`` from here at
9
+ module level (no deferred import — there is no circular-import cycle).
10
+ """
11
+
12
+ import re
13
+ from typing import Any, Dict, List, Optional, Tuple
14
+
15
+ from thread_archive._thread_import.timestamps import parse_timestamp, parse_timestamp_iso
16
+
17
+
18
+ def _parse_iso_timestamp(ts: Any) -> Optional[str]:
19
+ """Parse ISO timestamp to normalized format."""
20
+ return parse_timestamp_iso(ts)
21
+
22
+
23
+ def _timestamp_to_order(ts: str) -> int:
24
+ """Convert ISO timestamp to microsecond-precision order value."""
25
+ dt = parse_timestamp(ts)
26
+ return int(dt.timestamp() * 1_000_000) if dt else 0
27
+
28
+
29
+ # Regex patterns for IDE context tags
30
+ _IDE_OPENED_FILE_PATTERN = re.compile(
31
+ r'<ide_opened_file>(.*?)</ide_opened_file>',
32
+ re.DOTALL
33
+ )
34
+ _IDE_SELECTION_PATTERN = re.compile(
35
+ r'<ide_selection>(.*?)</ide_selection>',
36
+ re.DOTALL
37
+ )
38
+
39
+ # Regex patterns for command context tags (local command execution)
40
+ _COMMAND_TAGS_PATTERN = re.compile(
41
+ r'<(command-name|command-message|command-args|local-command-stdout|local-command-caveat|local-command-stderr)>(.*?)</\1>',
42
+ re.DOTALL
43
+ )
44
+ # Generic pattern for other system/context tags to strip from display
45
+ _SYSTEM_CONTEXT_PATTERN = re.compile(
46
+ r'<(system-reminder|file_contents|context|selection|environment_details)>(.*?)</\1>',
47
+ re.DOTALL
48
+ )
49
+
50
+
51
+ # A manual model switch: Claude Code runs `/model X` and records the confirmation as
52
+ # `<local-command-stdout>Set model to X</local-command-stdout>` on a user line whose
53
+ # command tags are otherwise stripped to nothing (and the turn dropped). Capture the
54
+ # target so the archive can show the same "Switched to X" marker the CLI does.
55
+ _MODEL_SET_PATTERN = re.compile(
56
+ r'<local-command-stdout>\s*Set model to\s+(.+?)\s*</local-command-stdout>',
57
+ re.IGNORECASE | re.DOTALL,
58
+ )
59
+
60
+
61
+ def _extract_model_change(text: str) -> Optional[str]:
62
+ """Return the model a ``/model`` command switched to, if ``text`` is that command's
63
+ ``<local-command-stdout>Set model to X</local-command-stdout>`` confirmation; else None."""
64
+ if not text:
65
+ return None
66
+ m = _MODEL_SET_PATTERN.search(text)
67
+ return m.group(1).strip() if m else None
68
+
69
+
70
+ def _extract_ide_context(text: str) -> Tuple[str, List[Dict[str, Any]]]:
71
+ """Extract IDE context tags from message text.
72
+
73
+ Claude Code embeds IDE context like opened files and selections as XML-like
74
+ tags in user messages. This function extracts them as structured blocks
75
+ and returns the cleaned text.
76
+
77
+ Returns:
78
+ Tuple of (cleaned_text, list of ide_context blocks)
79
+ """
80
+ ide_blocks: List[Dict[str, Any]] = []
81
+ seq = 0
82
+
83
+ # Extract <ide_opened_file> tags
84
+ for match in _IDE_OPENED_FILE_PATTERN.finditer(text):
85
+ content = match.group(1).strip()
86
+ # Parse the content - typically "The user opened the file {path} in the IDE..."
87
+ file_path = None
88
+ if "opened the file " in content:
89
+ # Extract path between "opened the file " and " in the IDE"
90
+ path_match = re.search(r'opened the file ([^\s]+)', content)
91
+ if path_match:
92
+ file_path = path_match.group(1)
93
+
94
+ ide_blocks.append({
95
+ "type": "ide_context",
96
+ "context_type": "opened_file",
97
+ "file_path": file_path,
98
+ "raw_content": content,
99
+ "seq": seq,
100
+ })
101
+ seq += 1
102
+
103
+ # Extract <ide_selection> tags
104
+ for match in _IDE_SELECTION_PATTERN.finditer(text):
105
+ content = match.group(1).strip()
106
+ ide_blocks.append({
107
+ "type": "ide_context",
108
+ "context_type": "selection",
109
+ "raw_content": content,
110
+ "seq": seq,
111
+ })
112
+ seq += 1
113
+
114
+ # Remove the tags from the text
115
+ cleaned = _IDE_OPENED_FILE_PATTERN.sub('', text)
116
+ cleaned = _IDE_SELECTION_PATTERN.sub('', cleaned)
117
+ # Also strip command context tags and system context tags
118
+ cleaned = _COMMAND_TAGS_PATTERN.sub('', cleaned)
119
+ cleaned = _SYSTEM_CONTEXT_PATTERN.sub('', cleaned)
120
+ cleaned = cleaned.strip()
121
+
122
+ return cleaned, ide_blocks
@@ -0,0 +1,90 @@
1
+ """Filesystem discovery and loading of Claude Code session files.
2
+
3
+ These two helpers locate session JSONL files under ``~/.claude/projects/`` and
4
+ read one into a lines/parse-errors dict. They are independent of
5
+ ``ClaudeCodeParser`` (no class state) and are re-exported by ``claude_code`` so
6
+ callers can reach them as ``claude_code.<name>``.
7
+ """
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional
12
+
13
+
14
+ def find_claude_code_sessions(
15
+ base_path: Optional[str] = None,
16
+ include_subagents: bool = True,
17
+ ) -> List[Path]:
18
+ """
19
+ Find all Claude Code session files.
20
+
21
+ Args:
22
+ base_path: Optional base path. Defaults to ~/.claude/projects/
23
+ include_subagents: Whether to include agent-*.jsonl files (default: True)
24
+
25
+ Returns:
26
+ List of paths to session JSONL files
27
+ """
28
+ if base_path:
29
+ base = Path(base_path).expanduser()
30
+ else:
31
+ base = Path.home() / ".claude" / "projects"
32
+
33
+ if not base.exists():
34
+ return []
35
+
36
+ sessions = []
37
+ for project_dir in base.iterdir():
38
+ if project_dir.is_dir():
39
+ for session_file in project_dir.glob("*.jsonl"):
40
+ is_subagent = session_file.name.startswith("agent-")
41
+ if is_subagent and not include_subagents:
42
+ continue
43
+ sessions.append(session_file)
44
+
45
+ return sorted(sessions, key=lambda p: p.stat().st_mtime, reverse=True)
46
+
47
+
48
+ def load_claude_code_session(path: Path) -> Dict[str, Any]:
49
+ """
50
+ Load a Claude Code session file.
51
+
52
+ Args:
53
+ path: Path to the session JSONL file
54
+
55
+ Returns:
56
+ Dict with session_id, lines, parse_errors, and is_subagent flag
57
+ """
58
+ lines = []
59
+ parse_errors = []
60
+ session_id = path.stem # Use filename without extension as session ID
61
+ is_subagent = path.name.startswith("agent-")
62
+
63
+ # Extract parent session ID from agent filename if applicable
64
+ # Format: agent-{parent_session_id}.jsonl
65
+ parent_session_id = None
66
+ if is_subagent and session_id.startswith("agent-"):
67
+ parent_session_id = session_id[6:] # Remove "agent-" prefix
68
+
69
+ with open(path, "r", encoding="utf-8") as f:
70
+ for line_num, line in enumerate(f, start=1):
71
+ line = line.strip()
72
+ if line:
73
+ try:
74
+ lines.append(json.loads(line))
75
+ except json.JSONDecodeError as e:
76
+ # Store parse error instead of dropping
77
+ parse_errors.append({
78
+ "line_number": line_num,
79
+ "raw_text": line[:1000], # Truncate very long lines
80
+ "error": str(e),
81
+ })
82
+
83
+ return {
84
+ "session_id": session_id,
85
+ "path": str(path),
86
+ "lines": lines,
87
+ "parse_errors": parse_errors,
88
+ "is_subagent": is_subagent,
89
+ "parent_session_id": parent_session_id,
90
+ }
@@ -0,0 +1,26 @@
1
+ """
2
+ Provider configuration definitions.
3
+
4
+ Each provider has specific characteristics that affect parsing and validation.
5
+ ProviderConfig captures these in a type-safe, declarative way.
6
+ """
7
+
8
+ from .base import (
9
+ CHATGPT_CONFIG,
10
+ CLAUDE_CODE_CONFIG,
11
+ CLAUDE_CONFIG,
12
+ CURSOR_CONFIG,
13
+ ProviderConfig,
14
+ ThinkingExpectation,
15
+ get_provider_config,
16
+ )
17
+
18
+ __all__ = [
19
+ "ProviderConfig",
20
+ "ThinkingExpectation",
21
+ "CHATGPT_CONFIG",
22
+ "CLAUDE_CONFIG",
23
+ "CLAUDE_CODE_CONFIG",
24
+ "CURSOR_CONFIG",
25
+ "get_provider_config",
26
+ ]