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,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
|
+
]
|