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,1035 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude Code CLI session parser.
|
|
3
|
+
|
|
4
|
+
Parses session history from Claude Code (Anthropic's CLI tool).
|
|
5
|
+
|
|
6
|
+
## Format
|
|
7
|
+
|
|
8
|
+
Storage location: ``~/.claude/projects/<project-path>/<session-id>.jsonl``
|
|
9
|
+
Where ``<project-path>`` is the working directory with ``/`` replaced by ``-``.
|
|
10
|
+
Example: ``/home/user/project`` → ``-home-user-project``
|
|
11
|
+
|
|
12
|
+
Sessions are JSONL (one JSON object per line) with message types:
|
|
13
|
+
|
|
14
|
+
- ``type: "user"`` - User messages with ``message.content``
|
|
15
|
+
- ``type: "assistant"`` - Assistant responses with content blocks
|
|
16
|
+
- ``type: "system"`` - System events (commands, file snapshots, compaction)
|
|
17
|
+
|
|
18
|
+
## Edge Cases
|
|
19
|
+
|
|
20
|
+
Context Compaction (``compact_boundary``)
|
|
21
|
+
When conversation exceeds token limit, Claude Code compacts history.
|
|
22
|
+
A ``system`` message with ``subtype: "compact_boundary"`` marks where
|
|
23
|
+
compaction occurred. The next ``user`` message contains a text summary
|
|
24
|
+
of prior conversation. **All original messages remain in the JSONL** -
|
|
25
|
+
they're just disconnected from the active parent chain.
|
|
26
|
+
|
|
27
|
+
Key fields on compact_boundary:
|
|
28
|
+
- ``parentUuid: null`` - breaks the parent chain
|
|
29
|
+
- ``logicalParentUuid`` - points to last message before compaction
|
|
30
|
+
- ``compactMetadata.preTokens`` - token count before compaction
|
|
31
|
+
|
|
32
|
+
Orphaned Parent References
|
|
33
|
+
After compaction, messages reference parents that exist but aren't in
|
|
34
|
+
the "active" chain. This is expected behavior, not data corruption.
|
|
35
|
+
Validation emits warnings, not errors.
|
|
36
|
+
|
|
37
|
+
Subagent Sessions (``agent-*``)
|
|
38
|
+
Claude Code spawns subagents for complex tasks. These have session IDs
|
|
39
|
+
starting with ``agent-`` (e.g., ``agent-a1b2c3d``). Subagent sessions:
|
|
40
|
+
- Use Haiku model (no extended thinking)
|
|
41
|
+
- Are linked to parent session via naming convention
|
|
42
|
+
- Should NOT fail validation for missing thinking blocks
|
|
43
|
+
|
|
44
|
+
IDE Context Tags
|
|
45
|
+
User messages may contain XML-like tags for IDE state:
|
|
46
|
+
- ``<ide_opened_file>...</ide_opened_file>``
|
|
47
|
+
- ``<ide_selection>...</ide_selection>``
|
|
48
|
+
These are extracted as ``ide_context`` content blocks.
|
|
49
|
+
|
|
50
|
+
Tool Results in User Messages
|
|
51
|
+
Tool execution results appear as ``user`` messages with content blocks
|
|
52
|
+
of type ``tool_result``. These are NOT coalesced (unlike ChatGPT).
|
|
53
|
+
|
|
54
|
+
## Validation
|
|
55
|
+
|
|
56
|
+
- **Thinking blocks**: Model-specific. Required for Opus models, exempt for
|
|
57
|
+
Sonnet/Haiku and all ``agent-*`` sessions. See ``ProviderConfig``.
|
|
58
|
+
- **Parent references**: May be orphaned after compaction. Warnings only.
|
|
59
|
+
- **Branching**: Tree via ``uuid``/``parentUuid``, but rarely used.
|
|
60
|
+
|
|
61
|
+
## Field Mapping
|
|
62
|
+
|
|
63
|
+
- ``uuid`` → ``provider_message_id``
|
|
64
|
+
- ``sessionId`` → ``provider_conversation_id``
|
|
65
|
+
- ``parentUuid`` → ``provider_parent_id``
|
|
66
|
+
- ``timestamp`` → ``created_at`` (ISO 8601)
|
|
67
|
+
- ``message.content`` → ``content_blocks``
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
import json
|
|
71
|
+
from typing import Any, Dict, List, Optional, Tuple, cast
|
|
72
|
+
|
|
73
|
+
from . import claude_code_blocks as _blocks
|
|
74
|
+
from .base import (
|
|
75
|
+
ContentBlock,
|
|
76
|
+
NormalizedMessage,
|
|
77
|
+
ProviderParser,
|
|
78
|
+
)
|
|
79
|
+
from .claude_code_ide import (
|
|
80
|
+
_extract_ide_context,
|
|
81
|
+
_extract_model_change,
|
|
82
|
+
_parse_iso_timestamp,
|
|
83
|
+
_timestamp_to_order,
|
|
84
|
+
)
|
|
85
|
+
from .config import CLAUDE_CODE_CONFIG, ProviderConfig
|
|
86
|
+
from .validators import (
|
|
87
|
+
ContentValidator,
|
|
88
|
+
ReferentialIntegrityValidator,
|
|
89
|
+
ThinkingBlockValidator,
|
|
90
|
+
TypeValidator,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ClaudeCodeParser(ProviderParser):
|
|
95
|
+
"""Parser for Claude Code CLI session exports.
|
|
96
|
+
|
|
97
|
+
Handles session JSONL files from `~/.claude/projects/<project>/`.
|
|
98
|
+
|
|
99
|
+
## Architecture
|
|
100
|
+
|
|
101
|
+
Uses the pipeline architecture with:
|
|
102
|
+
- PROVIDER_CONFIG: Claude Code-specific configuration (model_specific thinking)
|
|
103
|
+
- Validators: ThinkingBlockValidator, ReferentialIntegrityValidator, etc.
|
|
104
|
+
- Types: ClaudeCodeExport, ClaudeCodeSession for typed input
|
|
105
|
+
|
|
106
|
+
## How to Use
|
|
107
|
+
|
|
108
|
+
1. Copy session files from `~/.claude/projects/<project-path>/*.jsonl`
|
|
109
|
+
2. Create a ZIP archive or use directly
|
|
110
|
+
3. Run: `chat-import --provider claude-code path/to/sessions/`
|
|
111
|
+
|
|
112
|
+
## Explicit Field Mappings
|
|
113
|
+
|
|
114
|
+
Claude Code uses clear field names that mostly map directly:
|
|
115
|
+
- `timestamp` -> `created_at` (ISO 8601 format)
|
|
116
|
+
- `uuid` -> `provider_message_id`
|
|
117
|
+
- `parentUuid` -> `provider_parent_id`
|
|
118
|
+
- `sessionId` -> `provider_conversation_id`
|
|
119
|
+
- `message.role` -> `role`
|
|
120
|
+
- `message.content` -> structured content blocks
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
PROVIDER_NAME = "claude-code"
|
|
124
|
+
|
|
125
|
+
# Provider configuration (replaces centralized PROVIDER_EXPECTATIONS)
|
|
126
|
+
PROVIDER_CONFIG: ProviderConfig = CLAUDE_CODE_CONFIG
|
|
127
|
+
|
|
128
|
+
def __init__(self, strict: bool = False):
|
|
129
|
+
"""Initialize the Claude Code parser."""
|
|
130
|
+
super().__init__(strict=strict)
|
|
131
|
+
# Initialize validators from the new architecture
|
|
132
|
+
self._validators = [
|
|
133
|
+
ThinkingBlockValidator(self.PROVIDER_CONFIG, strict=strict),
|
|
134
|
+
ReferentialIntegrityValidator(self.PROVIDER_CONFIG, strict=strict),
|
|
135
|
+
TypeValidator(self.PROVIDER_CONFIG, strict=strict),
|
|
136
|
+
ContentValidator(self.PROVIDER_CONFIG, strict=strict),
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
def parse_export(self, data: Any) -> List[NormalizedMessage]:
|
|
140
|
+
"""
|
|
141
|
+
Parse Claude Code session data into normalized messages.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
data: Either:
|
|
145
|
+
- A dict with 'sessions' key (bundle of multiple sessions)
|
|
146
|
+
- A dict with 'lines' key (single session as parsed JSONL)
|
|
147
|
+
- A list of session line dicts
|
|
148
|
+
- Raw JSONL string
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
List of NormalizedMessage dictionaries
|
|
152
|
+
"""
|
|
153
|
+
# Handle different input formats
|
|
154
|
+
if isinstance(data, str):
|
|
155
|
+
# Raw JSONL string
|
|
156
|
+
return self._parse_jsonl(data)
|
|
157
|
+
|
|
158
|
+
if isinstance(data, dict):
|
|
159
|
+
# Check for bundle format
|
|
160
|
+
if "sessions" in data:
|
|
161
|
+
return self._parse_session_bundle(data)
|
|
162
|
+
# Check for single session with lines
|
|
163
|
+
if "lines" in data:
|
|
164
|
+
return self._parse_session_lines(data["lines"], data.get("session_id"))
|
|
165
|
+
# Check for claude_code_sessions.json format
|
|
166
|
+
if data.get("provider") == "claude-code":
|
|
167
|
+
return self._parse_session_bundle(data)
|
|
168
|
+
# Assume it's a single line/message
|
|
169
|
+
return self._parse_session_lines([data], None)
|
|
170
|
+
|
|
171
|
+
if isinstance(data, list):
|
|
172
|
+
# List of session lines
|
|
173
|
+
return self._parse_session_lines(data, None)
|
|
174
|
+
|
|
175
|
+
return []
|
|
176
|
+
|
|
177
|
+
# parse_export_with_validation is inherited from ProviderParser (shared logic
|
|
178
|
+
# driving self._validators); no provider-specific override needed.
|
|
179
|
+
|
|
180
|
+
def _parse_jsonl(
|
|
181
|
+
self, jsonl: str, session_id: Optional[str] = None
|
|
182
|
+
) -> List[NormalizedMessage]:
|
|
183
|
+
"""Parse raw JSONL string into messages.
|
|
184
|
+
|
|
185
|
+
JSON parse errors are stored as parse_error blocks rather than dropped.
|
|
186
|
+
"""
|
|
187
|
+
lines = []
|
|
188
|
+
parse_errors = []
|
|
189
|
+
|
|
190
|
+
for line_num, line in enumerate(jsonl.strip().split("\n"), start=1):
|
|
191
|
+
line = line.strip()
|
|
192
|
+
if line:
|
|
193
|
+
try:
|
|
194
|
+
lines.append(json.loads(line))
|
|
195
|
+
except json.JSONDecodeError as e:
|
|
196
|
+
# Store parse error instead of dropping
|
|
197
|
+
parse_errors.append({
|
|
198
|
+
"line_number": line_num,
|
|
199
|
+
"raw_text": line[:1000], # Truncate very long lines
|
|
200
|
+
"error": str(e),
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
messages = self._parse_session_lines(lines, session_id)
|
|
204
|
+
|
|
205
|
+
# Add parse errors as system messages with parse_error blocks
|
|
206
|
+
for err in parse_errors:
|
|
207
|
+
messages.append(self._create_parse_error_message(
|
|
208
|
+
err["raw_text"],
|
|
209
|
+
err["error"],
|
|
210
|
+
err["line_number"],
|
|
211
|
+
session_id,
|
|
212
|
+
))
|
|
213
|
+
|
|
214
|
+
return messages
|
|
215
|
+
|
|
216
|
+
def _create_parse_error_message(
|
|
217
|
+
self,
|
|
218
|
+
raw_text: str,
|
|
219
|
+
error: str,
|
|
220
|
+
line_number: Optional[int],
|
|
221
|
+
session_id: Optional[str],
|
|
222
|
+
) -> NormalizedMessage:
|
|
223
|
+
"""Create a message containing a parse error block."""
|
|
224
|
+
import hashlib
|
|
225
|
+
|
|
226
|
+
# Generate a deterministic ID from the raw content
|
|
227
|
+
content_hash = hashlib.sha256(raw_text.encode()).hexdigest()[:16]
|
|
228
|
+
msg_id = f"parse-error-{content_hash}"
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
"source_provider": self.PROVIDER_NAME,
|
|
232
|
+
"provider_message_id": msg_id,
|
|
233
|
+
"provider_conversation_id": session_id or "unknown",
|
|
234
|
+
"role": "system",
|
|
235
|
+
"content_text": f"[Parse error at line {line_number}]: {error}",
|
|
236
|
+
"content_blocks": [{
|
|
237
|
+
"type": "parse_error",
|
|
238
|
+
"raw_text": raw_text,
|
|
239
|
+
"error": error,
|
|
240
|
+
"line_number": line_number,
|
|
241
|
+
"seq": 0,
|
|
242
|
+
}],
|
|
243
|
+
"content_hash": self.hash_message(msg_id, "system", raw_text, None),
|
|
244
|
+
"provider_data": {
|
|
245
|
+
"parse_error": True,
|
|
246
|
+
"line_number": line_number,
|
|
247
|
+
"raw_text": raw_text,
|
|
248
|
+
"error": error,
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
def _parse_session_bundle(self, bundle: Dict[str, Any]) -> List[NormalizedMessage]:
|
|
253
|
+
"""Parse a bundle containing multiple sessions."""
|
|
254
|
+
messages: List[NormalizedMessage] = []
|
|
255
|
+
|
|
256
|
+
sessions = bundle.get("sessions", [])
|
|
257
|
+
for session in sessions:
|
|
258
|
+
session_id = session.get("session_id") or session.get("id")
|
|
259
|
+
lines = session.get("lines", [])
|
|
260
|
+
session_messages = self._parse_session_lines(lines, session_id)
|
|
261
|
+
messages.extend(session_messages)
|
|
262
|
+
|
|
263
|
+
return messages
|
|
264
|
+
|
|
265
|
+
def _parse_session_lines(
|
|
266
|
+
self, lines: List[Dict[str, Any]], session_id: Optional[str]
|
|
267
|
+
) -> List[NormalizedMessage]:
|
|
268
|
+
"""Parse a list of session line objects into messages."""
|
|
269
|
+
messages: List[NormalizedMessage] = []
|
|
270
|
+
|
|
271
|
+
# Track session info from first line
|
|
272
|
+
inferred_session_id = session_id
|
|
273
|
+
project_path = None
|
|
274
|
+
git_branch = None
|
|
275
|
+
|
|
276
|
+
for line in lines:
|
|
277
|
+
line_type = line.get("type", "")
|
|
278
|
+
|
|
279
|
+
# Extract session metadata from any line
|
|
280
|
+
if not inferred_session_id:
|
|
281
|
+
inferred_session_id = line.get("sessionId")
|
|
282
|
+
if not project_path:
|
|
283
|
+
project_path = line.get("cwd")
|
|
284
|
+
if not git_branch:
|
|
285
|
+
git_branch = line.get("gitBranch")
|
|
286
|
+
|
|
287
|
+
msg = self._parse_line(line, line_type, inferred_session_id, project_path)
|
|
288
|
+
if msg:
|
|
289
|
+
messages.append(msg)
|
|
290
|
+
|
|
291
|
+
messages = self._dedup_compaction_replays(messages)
|
|
292
|
+
|
|
293
|
+
# Coalesce linked assistant messages into single messages with combined blocks
|
|
294
|
+
# In Claude Code JSONL, one assistant "turn" spans multiple lines linked by parentUuid
|
|
295
|
+
messages = self._coalesce_assistant_chains(messages)
|
|
296
|
+
|
|
297
|
+
self._apply_derived_title(messages)
|
|
298
|
+
|
|
299
|
+
return messages
|
|
300
|
+
|
|
301
|
+
def _parse_line(
|
|
302
|
+
self,
|
|
303
|
+
line: Dict[str, Any],
|
|
304
|
+
line_type: str,
|
|
305
|
+
session_id: Optional[str],
|
|
306
|
+
project_path: Optional[str],
|
|
307
|
+
) -> Optional[NormalizedMessage]:
|
|
308
|
+
"""Dispatch a single session line to its type-specific parser.
|
|
309
|
+
|
|
310
|
+
``summary``/``file-history-snapshot`` are stored (not dropped) as their
|
|
311
|
+
own message kinds; ``attachment`` lines carry a ``queued_command``
|
|
312
|
+
sub-kind (a steering message Ella typed mid-turn) that must survive as a
|
|
313
|
+
user message; unrecognized line types yield ``None``.
|
|
314
|
+
"""
|
|
315
|
+
# Parsers taking (line, session_id, project_path).
|
|
316
|
+
full_parsers = {
|
|
317
|
+
"summary": self._parse_summary_message,
|
|
318
|
+
"file-history-snapshot": self._parse_file_snapshot_message,
|
|
319
|
+
"user": self._parse_user_message,
|
|
320
|
+
"assistant": self._parse_assistant_message,
|
|
321
|
+
"system": self._parse_system_message,
|
|
322
|
+
"attachment": self._parse_attachment_message,
|
|
323
|
+
}
|
|
324
|
+
parser = full_parsers.get(line_type)
|
|
325
|
+
if parser is not None:
|
|
326
|
+
return parser(line, session_id, project_path)
|
|
327
|
+
|
|
328
|
+
# Parsers taking (line, session_id) only.
|
|
329
|
+
if line_type == "queue-operation":
|
|
330
|
+
return self._parse_queue_operation(line, session_id)
|
|
331
|
+
if line_type == "progress":
|
|
332
|
+
return self._parse_progress_message(line, session_id)
|
|
333
|
+
|
|
334
|
+
# Any other (unrecognized or future) line kind is preserved verbatim
|
|
335
|
+
# rather than dropped — nothing vanishes without a trace, not even a line
|
|
336
|
+
# type Claude Code adds later.
|
|
337
|
+
return self._parse_unknown_line(line, line_type, session_id, project_path)
|
|
338
|
+
|
|
339
|
+
@staticmethod
|
|
340
|
+
def _dedup_compaction_replays(
|
|
341
|
+
messages: List[NormalizedMessage],
|
|
342
|
+
) -> List[NormalizedMessage]:
|
|
343
|
+
"""Drop compaction-replay duplicates (delegates to claude_code_blocks)."""
|
|
344
|
+
return _blocks.dedup_compaction_replays(messages)
|
|
345
|
+
|
|
346
|
+
@staticmethod
|
|
347
|
+
def _apply_derived_title(messages: List[NormalizedMessage]) -> None:
|
|
348
|
+
"""Derive + apply the conversation title (delegates to claude_code_blocks)."""
|
|
349
|
+
_blocks.apply_derived_title(messages)
|
|
350
|
+
|
|
351
|
+
def _parse_user_message(
|
|
352
|
+
self,
|
|
353
|
+
line: Dict[str, Any],
|
|
354
|
+
session_id: Optional[str],
|
|
355
|
+
project_path: Optional[str],
|
|
356
|
+
) -> Optional[NormalizedMessage]:
|
|
357
|
+
"""Parse a user message line."""
|
|
358
|
+
msg_data = line.get("message", {})
|
|
359
|
+
if not msg_data:
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
uuid = line.get("uuid", "")
|
|
363
|
+
parent_uuid = line.get("parentUuid")
|
|
364
|
+
timestamp = line.get("timestamp")
|
|
365
|
+
content = msg_data.get("content", "")
|
|
366
|
+
|
|
367
|
+
# Handle tool result content
|
|
368
|
+
content_blocks: List[ContentBlock] = []
|
|
369
|
+
content_text = ""
|
|
370
|
+
ide_context_blocks: List[Dict[str, Any]] = []
|
|
371
|
+
|
|
372
|
+
if isinstance(content, str):
|
|
373
|
+
# Extract IDE context (opened files, selections) from the content
|
|
374
|
+
cleaned_content, ide_context_blocks = _extract_ide_context(content)
|
|
375
|
+
content_text = cleaned_content
|
|
376
|
+
if cleaned_content:
|
|
377
|
+
content_blocks.append(self.create_text_block(cleaned_content, 0))
|
|
378
|
+
# A manual `/model` switch is otherwise stripped to nothing above (and the
|
|
379
|
+
# turn dropped). Preserve it as a model_change block so the archive can show
|
|
380
|
+
# the switch — the same marker Claude Code renders in its own transcript.
|
|
381
|
+
switched_to = _extract_model_change(content)
|
|
382
|
+
if switched_to:
|
|
383
|
+
content_blocks.append(cast(ContentBlock, {
|
|
384
|
+
"type": "model_change",
|
|
385
|
+
"to_model": switched_to,
|
|
386
|
+
"trigger": "user",
|
|
387
|
+
"seq": len(content_blocks),
|
|
388
|
+
}))
|
|
389
|
+
elif isinstance(content, list):
|
|
390
|
+
content_blocks, content_text, ide_context_blocks = (
|
|
391
|
+
self._user_content_blocks_from_list(content)
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
if not content_text:
|
|
395
|
+
content_text = self.extract_text_from_blocks(content_blocks)
|
|
396
|
+
|
|
397
|
+
# Add IDE context blocks (opened files, selections) after other content
|
|
398
|
+
if ide_context_blocks:
|
|
399
|
+
# Renumber seq values to come after existing blocks
|
|
400
|
+
base_seq = len(content_blocks)
|
|
401
|
+
for i, block in enumerate(ide_context_blocks):
|
|
402
|
+
block["seq"] = base_seq + i
|
|
403
|
+
content_blocks.extend(cast(List[ContentBlock], ide_context_blocks))
|
|
404
|
+
|
|
405
|
+
created_at = _parse_iso_timestamp(timestamp)
|
|
406
|
+
message_order = _timestamp_to_order(timestamp) if timestamp else 0
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
"source_provider": self.PROVIDER_NAME,
|
|
410
|
+
"provider_message_id": uuid,
|
|
411
|
+
"provider_message_id_lower": uuid.lower() if uuid else None,
|
|
412
|
+
"provider_conversation_id": session_id or line.get("sessionId", ""),
|
|
413
|
+
"provider_parent_id": parent_uuid,
|
|
414
|
+
"provider_parent_id_lower": parent_uuid.lower() if parent_uuid else None,
|
|
415
|
+
"content_hash": self.hash_message(uuid, "user", content, timestamp),
|
|
416
|
+
"role": "user",
|
|
417
|
+
"content_text": content_text,
|
|
418
|
+
"content_blocks": content_blocks,
|
|
419
|
+
"created_at": created_at,
|
|
420
|
+
"updated_at": None,
|
|
421
|
+
"message_order": message_order,
|
|
422
|
+
"is_active_path": not line.get("isSidechain", False),
|
|
423
|
+
"provider_data": {
|
|
424
|
+
"line": line,
|
|
425
|
+
"cwd": project_path or line.get("cwd"),
|
|
426
|
+
"git_branch": line.get("gitBranch"),
|
|
427
|
+
"version": line.get("version"),
|
|
428
|
+
"thinking_metadata": line.get("thinkingMetadata"),
|
|
429
|
+
"todos": line.get("todos"),
|
|
430
|
+
},
|
|
431
|
+
"conversation_title": None,
|
|
432
|
+
"conversation_metadata": {
|
|
433
|
+
"project_path": project_path or line.get("cwd"),
|
|
434
|
+
"git_branch": line.get("gitBranch"),
|
|
435
|
+
},
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
def _parse_attachment_message(
|
|
439
|
+
self,
|
|
440
|
+
line: Dict[str, Any],
|
|
441
|
+
session_id: Optional[str],
|
|
442
|
+
project_path: Optional[str],
|
|
443
|
+
) -> Optional[NormalizedMessage]:
|
|
444
|
+
"""Parse an ``attachment`` line.
|
|
445
|
+
|
|
446
|
+
Claude Code writes several attachment sub-kinds. Most are context
|
|
447
|
+
injections (todo reminders, tool/agent/skill listing deltas) with no
|
|
448
|
+
user signal, and are dropped. The exception is ``queued_command`` — a
|
|
449
|
+
message Ella typed *while the agent was mid-turn* (a "steering"
|
|
450
|
+
message). Its text lives only here: it is delivered to the model but
|
|
451
|
+
never re-emitted as a normal ``user`` line, and the ``queue-operation``
|
|
452
|
+
bookkeeping records carry no content. So if we don't reconstruct it, the
|
|
453
|
+
steering turn is lost from the archive (and the viewer). Rebuild it as a
|
|
454
|
+
user message, stamped with the time it was queued.
|
|
455
|
+
"""
|
|
456
|
+
attachment = line.get("attachment") or {}
|
|
457
|
+
if attachment.get("type") != "queued_command":
|
|
458
|
+
# Every other attachment sub-kind (todo reminders, tool/agent/skill
|
|
459
|
+
# listing deltas, context injections) is still real content Claude
|
|
460
|
+
# Code fed the model — preserve it as a hidden system record rather
|
|
461
|
+
# than dropping it. "Archivist, Not Filter."
|
|
462
|
+
return self._preserve_attachment(line, attachment, session_id, project_path)
|
|
463
|
+
|
|
464
|
+
prompt = attachment.get("prompt")
|
|
465
|
+
content_blocks: List[ContentBlock] = []
|
|
466
|
+
if isinstance(prompt, str):
|
|
467
|
+
if prompt:
|
|
468
|
+
content_blocks.append(self.create_text_block(prompt, 0))
|
|
469
|
+
elif isinstance(prompt, list):
|
|
470
|
+
for block in prompt:
|
|
471
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
472
|
+
text = block.get("text", "")
|
|
473
|
+
if text:
|
|
474
|
+
content_blocks.append(
|
|
475
|
+
self.create_text_block(text, len(content_blocks))
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
content_text = self.extract_text_from_blocks(content_blocks)
|
|
479
|
+
if not content_text:
|
|
480
|
+
return None
|
|
481
|
+
|
|
482
|
+
uuid = line.get("uuid", "")
|
|
483
|
+
parent_uuid = line.get("parentUuid")
|
|
484
|
+
# The steering message's own time lives on the attachment (when it was
|
|
485
|
+
# queued); fall back to the enclosing line's timestamp.
|
|
486
|
+
timestamp = attachment.get("timestamp") or line.get("timestamp")
|
|
487
|
+
created_at = _parse_iso_timestamp(timestamp)
|
|
488
|
+
message_order = _timestamp_to_order(timestamp) if timestamp else 0
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
"source_provider": self.PROVIDER_NAME,
|
|
492
|
+
"provider_message_id": uuid,
|
|
493
|
+
"provider_message_id_lower": uuid.lower() if uuid else None,
|
|
494
|
+
"provider_conversation_id": session_id or line.get("sessionId", ""),
|
|
495
|
+
"provider_parent_id": parent_uuid,
|
|
496
|
+
"provider_parent_id_lower": parent_uuid.lower() if parent_uuid else None,
|
|
497
|
+
"content_hash": self.hash_message(uuid, "user", prompt, timestamp),
|
|
498
|
+
"role": "user",
|
|
499
|
+
"content_text": content_text,
|
|
500
|
+
"content_blocks": content_blocks,
|
|
501
|
+
"created_at": created_at,
|
|
502
|
+
"updated_at": None,
|
|
503
|
+
"message_order": message_order,
|
|
504
|
+
"is_active_path": not line.get("isSidechain", False),
|
|
505
|
+
"provider_data": {
|
|
506
|
+
"line": line,
|
|
507
|
+
"cwd": project_path or line.get("cwd"),
|
|
508
|
+
"git_branch": line.get("gitBranch"),
|
|
509
|
+
"version": line.get("version"),
|
|
510
|
+
"queued_command": True,
|
|
511
|
+
"command_mode": attachment.get("commandMode"),
|
|
512
|
+
},
|
|
513
|
+
"conversation_title": None,
|
|
514
|
+
"conversation_metadata": {
|
|
515
|
+
"project_path": project_path or line.get("cwd"),
|
|
516
|
+
"git_branch": line.get("gitBranch"),
|
|
517
|
+
},
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
def _preserve_attachment(
|
|
521
|
+
self,
|
|
522
|
+
line: Dict[str, Any],
|
|
523
|
+
attachment: Dict[str, Any],
|
|
524
|
+
session_id: Optional[str],
|
|
525
|
+
project_path: Optional[str],
|
|
526
|
+
) -> NormalizedMessage:
|
|
527
|
+
"""Preserve a non-``queued_command`` attachment as a hidden system record.
|
|
528
|
+
|
|
529
|
+
These are context injections with no direct user signal, but they are
|
|
530
|
+
still content the model was shown — keep a summary event (with the full
|
|
531
|
+
raw attachment in provider_data) instead of dropping the line."""
|
|
532
|
+
uuid = line.get("uuid", "")
|
|
533
|
+
parent_uuid = line.get("parentUuid")
|
|
534
|
+
timestamp = attachment.get("timestamp") or line.get("timestamp")
|
|
535
|
+
atype = attachment.get("type") or "attachment"
|
|
536
|
+
created_at = _parse_iso_timestamp(timestamp)
|
|
537
|
+
message_order = _timestamp_to_order(timestamp) if timestamp else 0
|
|
538
|
+
return {
|
|
539
|
+
"source_provider": self.PROVIDER_NAME,
|
|
540
|
+
"provider_message_id": uuid,
|
|
541
|
+
"provider_message_id_lower": uuid.lower() if uuid else None,
|
|
542
|
+
"provider_conversation_id": session_id or line.get("sessionId", ""),
|
|
543
|
+
"provider_parent_id": parent_uuid,
|
|
544
|
+
"provider_parent_id_lower": parent_uuid.lower() if parent_uuid else None,
|
|
545
|
+
"content_hash": self.hash_message(uuid, "system", attachment, timestamp),
|
|
546
|
+
"role": "system",
|
|
547
|
+
"content_text": f"[attachment: {atype}]",
|
|
548
|
+
"content_blocks": [cast(ContentBlock, {
|
|
549
|
+
"type": "attachment",
|
|
550
|
+
"attachment_type": atype,
|
|
551
|
+
"raw": attachment,
|
|
552
|
+
"seq": 0,
|
|
553
|
+
})],
|
|
554
|
+
"created_at": created_at,
|
|
555
|
+
"updated_at": None,
|
|
556
|
+
"message_order": message_order,
|
|
557
|
+
"is_active_path": not line.get("isSidechain", False),
|
|
558
|
+
"is_visually_hidden": True,
|
|
559
|
+
"provider_data": {
|
|
560
|
+
"line": line,
|
|
561
|
+
"attachment_type": atype,
|
|
562
|
+
"cwd": project_path or line.get("cwd"),
|
|
563
|
+
},
|
|
564
|
+
"conversation_title": None,
|
|
565
|
+
"conversation_metadata": {"project_path": project_path or line.get("cwd")},
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
def _parse_unknown_line(
|
|
569
|
+
self,
|
|
570
|
+
line: Dict[str, Any],
|
|
571
|
+
line_type: str,
|
|
572
|
+
session_id: Optional[str],
|
|
573
|
+
project_path: Optional[str],
|
|
574
|
+
) -> NormalizedMessage:
|
|
575
|
+
"""Preserve a line whose ``type`` the parser doesn't model.
|
|
576
|
+
|
|
577
|
+
Rather than dropping an unrecognized (or future) Claude Code line kind,
|
|
578
|
+
keep it as a system record carrying the whole raw line — so nothing,
|
|
579
|
+
including line kinds Anthropic adds later, vanishes without a trace."""
|
|
580
|
+
uuid = line.get("uuid", "")
|
|
581
|
+
parent_uuid = line.get("parentUuid")
|
|
582
|
+
timestamp = line.get("timestamp")
|
|
583
|
+
created_at = _parse_iso_timestamp(timestamp)
|
|
584
|
+
message_order = _timestamp_to_order(timestamp) if timestamp else 0
|
|
585
|
+
return {
|
|
586
|
+
"source_provider": self.PROVIDER_NAME,
|
|
587
|
+
"provider_message_id": uuid,
|
|
588
|
+
"provider_message_id_lower": uuid.lower() if uuid else None,
|
|
589
|
+
"provider_conversation_id": session_id or line.get("sessionId", ""),
|
|
590
|
+
"provider_parent_id": parent_uuid,
|
|
591
|
+
"provider_parent_id_lower": parent_uuid.lower() if parent_uuid else None,
|
|
592
|
+
"content_hash": self.hash_message(uuid, "system", line, timestamp),
|
|
593
|
+
"role": "system",
|
|
594
|
+
"content_text": f"[unrecognized line: {line_type or 'unknown'}]",
|
|
595
|
+
"content_blocks": [cast(ContentBlock, {
|
|
596
|
+
"type": "unknown_line",
|
|
597
|
+
"line_type": line_type,
|
|
598
|
+
"raw": line,
|
|
599
|
+
"seq": 0,
|
|
600
|
+
})],
|
|
601
|
+
"created_at": created_at,
|
|
602
|
+
"updated_at": None,
|
|
603
|
+
"message_order": message_order,
|
|
604
|
+
"is_active_path": not line.get("isSidechain", False),
|
|
605
|
+
"provider_data": {"line": line, "line_type": line_type},
|
|
606
|
+
"conversation_title": None,
|
|
607
|
+
"conversation_metadata": {},
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
def _user_content_blocks_from_list(
|
|
611
|
+
self, content: list
|
|
612
|
+
) -> "tuple[List[ContentBlock], str, List[Dict[str, Any]]]":
|
|
613
|
+
"""Build content blocks from a user message's list content.
|
|
614
|
+
|
|
615
|
+
Delegates to ``claude_code_blocks.user_content_blocks_from_list``.
|
|
616
|
+
Returns ``(content_blocks, content_text, ide_context_blocks)``.
|
|
617
|
+
"""
|
|
618
|
+
return _blocks.user_content_blocks_from_list(content)
|
|
619
|
+
|
|
620
|
+
def _parse_xml_function_calls(
|
|
621
|
+
self, raw_content: str, seq: int
|
|
622
|
+
) -> Tuple[List[Dict[str, Any]], List[str], int]:
|
|
623
|
+
"""Parse old-format <function_calls> XML from string content.
|
|
624
|
+
|
|
625
|
+
Delegates to ``claude_code_blocks.parse_xml_function_calls``.
|
|
626
|
+
Returns ``(content_blocks, content_text_parts, updated_seq)``.
|
|
627
|
+
"""
|
|
628
|
+
return _blocks.parse_xml_function_calls(raw_content, seq)
|
|
629
|
+
|
|
630
|
+
def _assistant_content(
|
|
631
|
+
self, raw_content: Any
|
|
632
|
+
) -> Tuple[List[ContentBlock], List[str]]:
|
|
633
|
+
"""Build (content_blocks, content_text_parts) for an assistant message.
|
|
634
|
+
|
|
635
|
+
Delegates to ``claude_code_blocks.assistant_content``.
|
|
636
|
+
"""
|
|
637
|
+
return _blocks.assistant_content(raw_content)
|
|
638
|
+
|
|
639
|
+
def _append_assistant_block(
|
|
640
|
+
self,
|
|
641
|
+
block: Dict[str, Any],
|
|
642
|
+
content_blocks: List[ContentBlock],
|
|
643
|
+
content_text_parts: List[str],
|
|
644
|
+
seq: int,
|
|
645
|
+
) -> int:
|
|
646
|
+
"""Append one list-shaped assistant block; return the next seq value.
|
|
647
|
+
|
|
648
|
+
Delegates to ``claude_code_blocks.append_assistant_block``.
|
|
649
|
+
"""
|
|
650
|
+
return _blocks.append_assistant_block(
|
|
651
|
+
block, content_blocks, content_text_parts, seq
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
def _parse_assistant_message(
|
|
655
|
+
self,
|
|
656
|
+
line: Dict[str, Any],
|
|
657
|
+
session_id: Optional[str],
|
|
658
|
+
project_path: Optional[str],
|
|
659
|
+
) -> Optional[NormalizedMessage]:
|
|
660
|
+
"""Parse an assistant message line."""
|
|
661
|
+
msg_data = line.get("message", {})
|
|
662
|
+
if not msg_data:
|
|
663
|
+
return None
|
|
664
|
+
|
|
665
|
+
uuid = line.get("uuid", "")
|
|
666
|
+
parent_uuid = line.get("parentUuid")
|
|
667
|
+
timestamp = line.get("timestamp")
|
|
668
|
+
raw_content = msg_data.get("content", [])
|
|
669
|
+
model = msg_data.get("model", "")
|
|
670
|
+
|
|
671
|
+
content_blocks, content_text_parts = self._assistant_content(raw_content)
|
|
672
|
+
content_text = "\n".join(content_text_parts)
|
|
673
|
+
|
|
674
|
+
created_at = _parse_iso_timestamp(timestamp)
|
|
675
|
+
message_order = _timestamp_to_order(timestamp) if timestamp else 0
|
|
676
|
+
|
|
677
|
+
# Extract usage info
|
|
678
|
+
usage = msg_data.get("usage", {})
|
|
679
|
+
|
|
680
|
+
return {
|
|
681
|
+
"source_provider": self.PROVIDER_NAME,
|
|
682
|
+
"provider_message_id": uuid,
|
|
683
|
+
"provider_message_id_lower": uuid.lower() if uuid else None,
|
|
684
|
+
"provider_conversation_id": session_id or line.get("sessionId", ""),
|
|
685
|
+
"provider_parent_id": parent_uuid,
|
|
686
|
+
"provider_parent_id_lower": parent_uuid.lower() if parent_uuid else None,
|
|
687
|
+
"content_hash": self.hash_message(uuid, "assistant", raw_content, timestamp),
|
|
688
|
+
"role": "assistant",
|
|
689
|
+
"content_text": content_text,
|
|
690
|
+
"content_blocks": content_blocks,
|
|
691
|
+
"created_at": created_at,
|
|
692
|
+
"updated_at": None,
|
|
693
|
+
"message_order": message_order,
|
|
694
|
+
"is_active_path": not line.get("isSidechain", False),
|
|
695
|
+
"provider_data": {
|
|
696
|
+
"line": line,
|
|
697
|
+
"message_id": msg_data.get("id"),
|
|
698
|
+
"model": model,
|
|
699
|
+
"stop_reason": msg_data.get("stop_reason"),
|
|
700
|
+
"usage": usage,
|
|
701
|
+
"request_id": line.get("requestId"),
|
|
702
|
+
"cwd": project_path or line.get("cwd"),
|
|
703
|
+
"git_branch": line.get("gitBranch"),
|
|
704
|
+
"version": line.get("version"),
|
|
705
|
+
},
|
|
706
|
+
"conversation_title": None,
|
|
707
|
+
"conversation_metadata": {
|
|
708
|
+
"project_path": project_path or line.get("cwd"),
|
|
709
|
+
"git_branch": line.get("gitBranch"),
|
|
710
|
+
"model": model,
|
|
711
|
+
},
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
def _parse_system_message(
|
|
715
|
+
self,
|
|
716
|
+
line: Dict[str, Any],
|
|
717
|
+
session_id: Optional[str],
|
|
718
|
+
project_path: Optional[str],
|
|
719
|
+
) -> Optional[NormalizedMessage]:
|
|
720
|
+
"""Parse a system message (command, event, etc.)."""
|
|
721
|
+
uuid = line.get("uuid", "")
|
|
722
|
+
parent_uuid = line.get("parentUuid")
|
|
723
|
+
timestamp = line.get("timestamp")
|
|
724
|
+
content = line.get("content", "")
|
|
725
|
+
subtype = line.get("subtype", "")
|
|
726
|
+
|
|
727
|
+
# A system line with no ``content`` string can still carry meaning in its
|
|
728
|
+
# subtype / level / toolUseResult / compactMetadata (e.g. a
|
|
729
|
+
# ``compact_boundary`` marker recording where compaction happened, and its
|
|
730
|
+
# token counts). Synthesize a summary so the record — and its metadata in
|
|
731
|
+
# provider_data — is preserved rather than dropped; only a wholly-empty
|
|
732
|
+
# line (no content and no metadata at all) is skipped.
|
|
733
|
+
if not content:
|
|
734
|
+
carries_meta = any(
|
|
735
|
+
line.get(k)
|
|
736
|
+
for k in ("subtype", "level", "toolUseResult", "compactMetadata")
|
|
737
|
+
)
|
|
738
|
+
if not carries_meta:
|
|
739
|
+
return None
|
|
740
|
+
content = f"[system: {subtype}]" if subtype else "[system event]"
|
|
741
|
+
|
|
742
|
+
content_blocks: List[ContentBlock] = [
|
|
743
|
+
cast(ContentBlock, {
|
|
744
|
+
"type": "system_context",
|
|
745
|
+
"context_type": subtype or "system",
|
|
746
|
+
"content": content,
|
|
747
|
+
"seq": 0,
|
|
748
|
+
})
|
|
749
|
+
]
|
|
750
|
+
|
|
751
|
+
created_at = _parse_iso_timestamp(timestamp)
|
|
752
|
+
message_order = _timestamp_to_order(timestamp) if timestamp else 0
|
|
753
|
+
|
|
754
|
+
return {
|
|
755
|
+
"source_provider": self.PROVIDER_NAME,
|
|
756
|
+
"provider_message_id": uuid,
|
|
757
|
+
"provider_message_id_lower": uuid.lower() if uuid else None,
|
|
758
|
+
"provider_conversation_id": session_id or line.get("sessionId", ""),
|
|
759
|
+
"provider_parent_id": parent_uuid,
|
|
760
|
+
"provider_parent_id_lower": parent_uuid.lower() if parent_uuid else None,
|
|
761
|
+
"content_hash": self.hash_message(uuid, "system", content, timestamp),
|
|
762
|
+
"role": "system",
|
|
763
|
+
"content_text": content,
|
|
764
|
+
"content_blocks": content_blocks,
|
|
765
|
+
"created_at": created_at,
|
|
766
|
+
"updated_at": None,
|
|
767
|
+
"message_order": message_order,
|
|
768
|
+
"is_active_path": not line.get("isSidechain", False),
|
|
769
|
+
"is_visually_hidden": True, # System messages are typically hidden
|
|
770
|
+
"provider_data": {
|
|
771
|
+
"line": line,
|
|
772
|
+
"subtype": subtype,
|
|
773
|
+
"level": line.get("level"),
|
|
774
|
+
"cwd": project_path or line.get("cwd"),
|
|
775
|
+
"git_branch": line.get("gitBranch"),
|
|
776
|
+
"version": line.get("version"),
|
|
777
|
+
},
|
|
778
|
+
"conversation_title": None,
|
|
779
|
+
"conversation_metadata": {
|
|
780
|
+
"project_path": project_path or line.get("cwd"),
|
|
781
|
+
"git_branch": line.get("gitBranch"),
|
|
782
|
+
},
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
def _extract_tool_result_text(self, content: Any) -> str:
|
|
786
|
+
"""Extract text from tool result content (delegates to claude_code_blocks)."""
|
|
787
|
+
return _blocks.extract_tool_result_text(content)
|
|
788
|
+
|
|
789
|
+
def _parse_summary_message(
|
|
790
|
+
self,
|
|
791
|
+
line: Dict[str, Any],
|
|
792
|
+
session_id: Optional[str],
|
|
793
|
+
project_path: Optional[str],
|
|
794
|
+
) -> Optional[NormalizedMessage]:
|
|
795
|
+
"""Parse a context continuation summary message.
|
|
796
|
+
|
|
797
|
+
When a conversation runs out of context, Claude creates a summary
|
|
798
|
+
of prior messages to continue. These summaries contain valuable
|
|
799
|
+
information about what was discussed and should be preserved.
|
|
800
|
+
"""
|
|
801
|
+
uuid = line.get("uuid", "")
|
|
802
|
+
parent_uuid = line.get("parentUuid")
|
|
803
|
+
timestamp = line.get("timestamp")
|
|
804
|
+
|
|
805
|
+
# Summary content is in the 'summary' field or 'content' field
|
|
806
|
+
summary_text = line.get("summary", "") or line.get("content", "")
|
|
807
|
+
if not summary_text and isinstance(line.get("message"), dict):
|
|
808
|
+
summary_text = line["message"].get("content", "")
|
|
809
|
+
|
|
810
|
+
# Extract which messages were summarized, if available
|
|
811
|
+
summarizes = line.get("summarizes", [])
|
|
812
|
+
if not summarizes and line.get("summarizedMessages"):
|
|
813
|
+
summarizes = line.get("summarizedMessages", [])
|
|
814
|
+
|
|
815
|
+
content_blocks: List[ContentBlock] = [{
|
|
816
|
+
"type": "context_summary",
|
|
817
|
+
"text": summary_text if isinstance(summary_text, str) else str(summary_text),
|
|
818
|
+
"summarizes": summarizes if summarizes else None,
|
|
819
|
+
"seq": 0,
|
|
820
|
+
}]
|
|
821
|
+
|
|
822
|
+
created_at = _parse_iso_timestamp(timestamp)
|
|
823
|
+
message_order = _timestamp_to_order(timestamp) if timestamp else 0
|
|
824
|
+
|
|
825
|
+
return {
|
|
826
|
+
"source_provider": self.PROVIDER_NAME,
|
|
827
|
+
"provider_message_id": uuid or f"summary-{message_order}",
|
|
828
|
+
"provider_message_id_lower": uuid.lower() if uuid else None,
|
|
829
|
+
"provider_conversation_id": session_id or line.get("sessionId", ""),
|
|
830
|
+
"provider_parent_id": parent_uuid,
|
|
831
|
+
"provider_parent_id_lower": parent_uuid.lower() if parent_uuid else None,
|
|
832
|
+
"content_hash": self.hash_message(uuid, "system", summary_text, timestamp),
|
|
833
|
+
"role": "system",
|
|
834
|
+
"content_text": f"[Context Summary]: {summary_text[:200]}..." if len(str(summary_text)) > 200 else f"[Context Summary]: {summary_text}",
|
|
835
|
+
"content_blocks": content_blocks,
|
|
836
|
+
"created_at": created_at,
|
|
837
|
+
"updated_at": None,
|
|
838
|
+
"message_order": message_order,
|
|
839
|
+
"is_active_path": not line.get("isSidechain", False),
|
|
840
|
+
"is_visually_hidden": False, # Summaries may be interesting to see
|
|
841
|
+
"provider_data": {
|
|
842
|
+
"line": line,
|
|
843
|
+
"summary_type": "context_continuation",
|
|
844
|
+
"cwd": project_path or line.get("cwd"),
|
|
845
|
+
"git_branch": line.get("gitBranch"),
|
|
846
|
+
},
|
|
847
|
+
"conversation_title": None,
|
|
848
|
+
"conversation_metadata": {
|
|
849
|
+
"project_path": project_path or line.get("cwd"),
|
|
850
|
+
"git_branch": line.get("gitBranch"),
|
|
851
|
+
},
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
def _parse_file_snapshot_message(
|
|
855
|
+
self,
|
|
856
|
+
line: Dict[str, Any],
|
|
857
|
+
session_id: Optional[str],
|
|
858
|
+
project_path: Optional[str],
|
|
859
|
+
) -> Optional[NormalizedMessage]:
|
|
860
|
+
"""Parse a file history snapshot message.
|
|
861
|
+
|
|
862
|
+
Claude Code creates file snapshots to track the state of files
|
|
863
|
+
at various points during a session. These are valuable for
|
|
864
|
+
understanding what changed and when.
|
|
865
|
+
"""
|
|
866
|
+
uuid = line.get("uuid", "")
|
|
867
|
+
parent_uuid = line.get("parentUuid")
|
|
868
|
+
timestamp = line.get("timestamp")
|
|
869
|
+
|
|
870
|
+
# File snapshot data can be in various places
|
|
871
|
+
files = line.get("files", [])
|
|
872
|
+
if not files and isinstance(line.get("content"), list):
|
|
873
|
+
files = line.get("content", [])
|
|
874
|
+
if not files and isinstance(line.get("snapshot"), dict):
|
|
875
|
+
files = line.get("snapshot", {}).get("files", [])
|
|
876
|
+
|
|
877
|
+
content_blocks: List[ContentBlock] = [{
|
|
878
|
+
"type": "file_snapshot",
|
|
879
|
+
"files": files,
|
|
880
|
+
"timestamp": timestamp,
|
|
881
|
+
"seq": 0,
|
|
882
|
+
}]
|
|
883
|
+
|
|
884
|
+
created_at = _parse_iso_timestamp(timestamp)
|
|
885
|
+
message_order = _timestamp_to_order(timestamp) if timestamp else 0
|
|
886
|
+
|
|
887
|
+
# Create a summary text for display
|
|
888
|
+
file_count = len(files) if isinstance(files, list) else 0
|
|
889
|
+
content_text = f"[File Snapshot]: {file_count} file(s)"
|
|
890
|
+
|
|
891
|
+
return {
|
|
892
|
+
"source_provider": self.PROVIDER_NAME,
|
|
893
|
+
"provider_message_id": uuid or f"file-snapshot-{message_order}",
|
|
894
|
+
"provider_message_id_lower": uuid.lower() if uuid else None,
|
|
895
|
+
"provider_conversation_id": session_id or line.get("sessionId", ""),
|
|
896
|
+
"provider_parent_id": parent_uuid,
|
|
897
|
+
"provider_parent_id_lower": parent_uuid.lower() if parent_uuid else None,
|
|
898
|
+
"content_hash": self.hash_message(uuid, "system", str(files), timestamp),
|
|
899
|
+
"role": "system",
|
|
900
|
+
"content_text": content_text,
|
|
901
|
+
"content_blocks": content_blocks,
|
|
902
|
+
"created_at": created_at,
|
|
903
|
+
"updated_at": None,
|
|
904
|
+
"message_order": message_order,
|
|
905
|
+
"is_active_path": not line.get("isSidechain", False),
|
|
906
|
+
"is_visually_hidden": True, # File snapshots are usually metadata
|
|
907
|
+
"provider_data": {
|
|
908
|
+
"line": line,
|
|
909
|
+
"snapshot_type": "file-history",
|
|
910
|
+
"cwd": project_path or line.get("cwd"),
|
|
911
|
+
"git_branch": line.get("gitBranch"),
|
|
912
|
+
},
|
|
913
|
+
"conversation_title": None,
|
|
914
|
+
"conversation_metadata": {
|
|
915
|
+
"project_path": project_path or line.get("cwd"),
|
|
916
|
+
"git_branch": line.get("gitBranch"),
|
|
917
|
+
},
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
def _parse_queue_operation(
|
|
921
|
+
self,
|
|
922
|
+
line: Dict[str, Any],
|
|
923
|
+
session_id: Optional[str],
|
|
924
|
+
) -> Optional[NormalizedMessage]:
|
|
925
|
+
"""Parse a queue-operation line (enqueue/dequeue)."""
|
|
926
|
+
timestamp = line.get("timestamp")
|
|
927
|
+
operation = line.get("operation", "unknown")
|
|
928
|
+
created_at = _parse_iso_timestamp(timestamp)
|
|
929
|
+
message_order = _timestamp_to_order(timestamp) if timestamp else 0
|
|
930
|
+
|
|
931
|
+
return {
|
|
932
|
+
"source_provider": self.PROVIDER_NAME,
|
|
933
|
+
"provider_message_id": f"queue-{operation}-{message_order}",
|
|
934
|
+
"provider_conversation_id": session_id or line.get("sessionId", ""),
|
|
935
|
+
"content_hash": self.hash_message(f"queue-{operation}", "system", str(line), timestamp),
|
|
936
|
+
"role": "system",
|
|
937
|
+
"content_text": f"[Queue {operation}]",
|
|
938
|
+
"content_blocks": [cast(ContentBlock, {
|
|
939
|
+
"type": "queue_operation",
|
|
940
|
+
"data": {
|
|
941
|
+
"operation": operation,
|
|
942
|
+
"session_id": line.get("sessionId"),
|
|
943
|
+
"timestamp": timestamp,
|
|
944
|
+
},
|
|
945
|
+
"seq": 0,
|
|
946
|
+
})],
|
|
947
|
+
"created_at": created_at,
|
|
948
|
+
"updated_at": None,
|
|
949
|
+
"message_order": message_order,
|
|
950
|
+
"is_active_path": True,
|
|
951
|
+
"is_visually_hidden": True,
|
|
952
|
+
"provider_data": {"line_type": "queue-operation"},
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
def _parse_progress_message(
|
|
956
|
+
self,
|
|
957
|
+
line: Dict[str, Any],
|
|
958
|
+
session_id: Optional[str],
|
|
959
|
+
) -> Optional[NormalizedMessage]:
|
|
960
|
+
"""Parse a progress line (tool/MCP progress indicators)."""
|
|
961
|
+
timestamp = line.get("timestamp")
|
|
962
|
+
uuid = line.get("uuid", "")
|
|
963
|
+
created_at = _parse_iso_timestamp(timestamp)
|
|
964
|
+
message_order = _timestamp_to_order(timestamp) if timestamp else 0
|
|
965
|
+
data = line.get("data", {})
|
|
966
|
+
tool_use_id = line.get("toolUseID")
|
|
967
|
+
|
|
968
|
+
return {
|
|
969
|
+
"source_provider": self.PROVIDER_NAME,
|
|
970
|
+
"provider_message_id": uuid or f"progress-{message_order}",
|
|
971
|
+
"provider_conversation_id": session_id or line.get("sessionId", ""),
|
|
972
|
+
"content_hash": self.hash_message(uuid or f"progress-{message_order}", "system", str(data), timestamp),
|
|
973
|
+
"role": "system",
|
|
974
|
+
"content_text": f"[Progress: {data.get('type', 'unknown')}]",
|
|
975
|
+
"content_blocks": [cast(ContentBlock, {
|
|
976
|
+
"type": "progress",
|
|
977
|
+
"data": data,
|
|
978
|
+
"tool_use_id": tool_use_id,
|
|
979
|
+
"seq": 0,
|
|
980
|
+
})],
|
|
981
|
+
"created_at": created_at,
|
|
982
|
+
"updated_at": None,
|
|
983
|
+
"message_order": message_order,
|
|
984
|
+
"is_active_path": not line.get("isSidechain", False),
|
|
985
|
+
"is_visually_hidden": True,
|
|
986
|
+
"provider_data": {"line_type": "progress"},
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
def _coalesce_assistant_chains(
|
|
990
|
+
self, messages: List[NormalizedMessage]
|
|
991
|
+
) -> List[NormalizedMessage]:
|
|
992
|
+
"""Coalesce linked assistant messages into single messages.
|
|
993
|
+
|
|
994
|
+
Delegates to ``claude_code_blocks.coalesce_assistant_chains``.
|
|
995
|
+
"""
|
|
996
|
+
return _blocks.coalesce_assistant_chains(messages)
|
|
997
|
+
|
|
998
|
+
@staticmethod
|
|
999
|
+
def _build_chain_indices(
|
|
1000
|
+
messages: List[NormalizedMessage],
|
|
1001
|
+
) -> Tuple[Dict[str, NormalizedMessage], Dict[str, List[NormalizedMessage]]]:
|
|
1002
|
+
"""Build (by_id, children_of) indices (delegates to claude_code_blocks)."""
|
|
1003
|
+
return _blocks.build_chain_indices(messages)
|
|
1004
|
+
|
|
1005
|
+
@staticmethod
|
|
1006
|
+
def _find_chain_roots(
|
|
1007
|
+
messages: List[NormalizedMessage],
|
|
1008
|
+
by_id: Dict[str, NormalizedMessage],
|
|
1009
|
+
) -> List[NormalizedMessage]:
|
|
1010
|
+
"""Find chain roots (delegates to claude_code_blocks)."""
|
|
1011
|
+
return _blocks.find_chain_roots(messages, by_id)
|
|
1012
|
+
|
|
1013
|
+
@staticmethod
|
|
1014
|
+
def _merge_chain(
|
|
1015
|
+
root: NormalizedMessage,
|
|
1016
|
+
children_of: Dict[str, List[NormalizedMessage]],
|
|
1017
|
+
to_remove: set,
|
|
1018
|
+
) -> None:
|
|
1019
|
+
"""BFS-merge a chain in place (delegates to claude_code_blocks)."""
|
|
1020
|
+
_blocks.merge_chain(root, children_of, to_remove)
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
# Session discovery/loading helpers live in claude_code_sessions; re-exported
|
|
1024
|
+
# here so existing ``claude_code.<name>`` access keeps resolving to the same
|
|
1025
|
+
# objects.
|
|
1026
|
+
from .claude_code_sessions import ( # noqa: E402
|
|
1027
|
+
find_claude_code_sessions,
|
|
1028
|
+
load_claude_code_session,
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
__all__ = [
|
|
1032
|
+
"ClaudeCodeParser",
|
|
1033
|
+
"find_claude_code_sessions",
|
|
1034
|
+
"load_claude_code_session",
|
|
1035
|
+
]
|