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,810 @@
1
+ """Event builder for converting NormalizedMessage to Thread events.
2
+
3
+ This module provides the single source of truth for transforming
4
+ NormalizedMessage objects (from thread_archive._thread_import) into ThreadEvent objects
5
+ that can be written to the Thread event log.
6
+
7
+ The EventBuilder protocol decouples event creation from database writes,
8
+ making the logic testable and reusable across different import paths.
9
+ """
10
+
11
+ import hashlib
12
+ import json
13
+ import uuid
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timezone
16
+ from typing import Any, Mapping, Optional, Protocol, cast
17
+
18
+ from thread_archive._thread_import.timestamps import parse_timestamp
19
+
20
+ from .parsers.base import NormalizedMessage
21
+
22
+ # Payload keys that carry an event's *semantic content*. The dedup key hashes
23
+ # only these — never the timestamp — so an exact re-import collapses to one row,
24
+ # while an edited turn (changed content, same provider_message_id) produces a
25
+ # DIFFERENT key and is kept as a new event. Import must never be the thing that
26
+ # discards either version of a turn; the log is append-only.
27
+ _DEDUP_CONTENT_KEYS = (
28
+ "content", "text", "thinking", "output", "error", "content_blocks",
29
+ "input", "data", "files", "summarizes", "model",
30
+ )
31
+
32
+
33
+ def compute_content_hash(payload: dict) -> str:
34
+ """Timestamp-free hash of an event's semantic content (see _DEDUP_CONTENT_KEYS)."""
35
+ material = {k: payload[k] for k in _DEDUP_CONTENT_KEYS if k in payload}
36
+ blob = json.dumps(material, sort_keys=True, default=str, ensure_ascii=False)
37
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]
38
+
39
+
40
+ def compute_dedup_key(provider_message_id: str, event_type: str, payload: dict) -> str:
41
+ """Deterministic natural identity for one event — stable across re-imports
42
+ and across producers.
43
+
44
+ Form: ``{provider_message_id|content_anchor}:{event_type}:{block}:{content_hash}``.
45
+ The key is NOT prefixed with the thread id: dedup is thread-scoped by the
46
+ ``WHERE thread_id = ...`` clause in the importer, so the prefix would be
47
+ redundant — and every stored key is bare (``denamespace_dedup_keys``
48
+ normalizes any prefixed stragglers); keep this bare.
49
+ Same id + same content → same key (idempotent). Same id + edited content →
50
+ different key (both versions kept). Events with no provider id fall back to a
51
+ content anchor so they still dedup on content+type+position.
52
+ """
53
+ content_hash = compute_content_hash(payload)
54
+ if payload.get("tool_call_id"):
55
+ block = f"tool={payload['tool_call_id']}"
56
+ elif payload.get("block_index") is not None:
57
+ block = f"blk={payload['block_index']}"
58
+ else:
59
+ block = ""
60
+ anchor = provider_message_id or f"c={content_hash}"
61
+ return f"{anchor}:{event_type}:{block}:{content_hash}"
62
+
63
+
64
+ @dataclass
65
+ class ThreadEvent:
66
+ """Portable event representation - not tied to DB schema.
67
+
68
+ This mirrors backend/event_log.py's Event but is independent
69
+ of the database layer, allowing event creation logic to be
70
+ tested without database access.
71
+ """
72
+ event_type: str
73
+ payload: dict
74
+ stream_id: str
75
+ api_call_id: Optional[str] = None
76
+ # tz-aware UTC: a naive local-time default sorted wrong against the aware
77
+ # timestamps every builder path stores (SQLite compares them as text).
78
+ occurred_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
79
+
80
+ # Deterministic natural-key identity (timestamp-free, content-inclusive),
81
+ # set by build_events. Bare (not thread-id-prefixed) — dedup is thread-scoped
82
+ # by the importer's WHERE clause. Observe-only until the dedup constraint
83
+ # lands; see compute_dedup_key.
84
+ dedup_key: Optional[str] = None
85
+
86
+ # Metadata for tracking
87
+ caused_by_event_id: Optional[str] = None
88
+ correlation_id: Optional[str] = None
89
+
90
+
91
+ class EventBuilder(Protocol):
92
+ """Protocol for transforming NormalizedMessage into Thread events.
93
+
94
+ Implementations convert a single NormalizedMessage into the sequence
95
+ of ThreadEvent objects needed to represent it in the event log.
96
+
97
+ For user messages: returns [USER_MESSAGE_SENT]
98
+ For assistant messages: returns [API_REQUEST_STARTED,
99
+ THINKING_COMPLETE*, TEXT_COMPLETE*, TOOL_USE_COMPLETE*,
100
+ API_REQUEST_COMPLETED, STREAM_COMPLETED]
101
+ """
102
+
103
+ def build_events(
104
+ self,
105
+ message: NormalizedMessage,
106
+ stream_id: str,
107
+ api_call_id: Optional[str] = None,
108
+ ) -> list[ThreadEvent]:
109
+ """Convert a normalized message to Thread events.
110
+
111
+ Args:
112
+ message: The normalized message from archive
113
+ stream_id: The stream ID for this message
114
+ api_call_id: Optional API call ID (generated if not provided)
115
+
116
+ Returns:
117
+ List of ThreadEvent objects to be written to the event log
118
+ """
119
+ ...
120
+
121
+
122
+ class DefaultEventBuilder:
123
+ """Standard event builder for all providers.
124
+
125
+ The single source of truth for how NormalizedMessage becomes Thread events.
126
+ """
127
+
128
+ def build_events(
129
+ self,
130
+ message: NormalizedMessage,
131
+ stream_id: str,
132
+ api_call_id: Optional[str] = None,
133
+ prev_occurred_at: Optional[datetime] = None,
134
+ ) -> list[ThreadEvent]:
135
+ """Convert a normalized message to Thread events.
136
+
137
+ ``prev_occurred_at`` is the previous turn's timestamp; when this
138
+ message carries no source timestamp it is inherited (monotonic, not
139
+ fabricated) rather than stamped with ``now()``.
140
+ """
141
+ role = message.get("role", "")
142
+ occurred_at, ts_provenance = self._resolve_occurred_at(message, prev_occurred_at)
143
+
144
+ if role == "user":
145
+ events = self._build_user_events(message, stream_id, occurred_at)
146
+ elif role == "assistant":
147
+ events = self._build_assistant_events(
148
+ message, stream_id, api_call_id, occurred_at
149
+ )
150
+ elif role == "system":
151
+ events = self._build_system_events(message, stream_id, occurred_at)
152
+ else:
153
+ events = self._build_generic_message_events(message, stream_id, occurred_at)
154
+
155
+ provider_message_id = message.get("provider_message_id", "") or ""
156
+ for event in events:
157
+ # Flag any event whose time wasn't a real source timestamp, so a
158
+ # gap stays visible (never a silent now()).
159
+ if ts_provenance is not None:
160
+ event.payload.setdefault("timestamp_inferred", True)
161
+ event.payload.setdefault("timestamp_source", ts_provenance)
162
+ event.dedup_key = compute_dedup_key(
163
+ provider_message_id, event.event_type, event.payload
164
+ )
165
+ return events
166
+
167
+ def _resolve_occurred_at(
168
+ self,
169
+ message: NormalizedMessage,
170
+ prev_occurred_at: Optional[datetime],
171
+ ) -> tuple[datetime, Optional[str]]:
172
+ """Resolve a message's timestamp without ever silently fabricating one.
173
+
174
+ Returns ``(occurred_at, provenance)`` — provenance is ``None`` for a
175
+ real source timestamp, else a marker string. A hard requirement:
176
+ message timestamps must never be silently invented; a gap must stay
177
+ visible so it can be re-exported.
178
+ """
179
+ ts = self._parse_timestamp(message.get("created_at"))
180
+ if ts is not None:
181
+ return ts, None
182
+ if prev_occurred_at is not None:
183
+ # Inherit the prior turn's time — monotonic, not invented.
184
+ return prev_occurred_at, "inferred_prev_turn"
185
+ # No timestamp anywhere. Flag it loudly so it's queryable; the planned
186
+ # escalation is an importer-level hard-fail/notify (Decision 2).
187
+ return datetime.now(timezone.utc), "fabricated_no_source"
188
+
189
+ # Text-only content from tool loading confirmations (not real user messages)
190
+ _TOOL_LOAD_CONFIRMATIONS = frozenset({
191
+ "Tool loaded.",
192
+ })
193
+
194
+ # User content-block types already represented by a dedicated event (or folded
195
+ # into the user_message_sent payload, for images). Any *other* block type on a
196
+ # user turn is preserved as its own content_block event so nothing is dropped.
197
+ _USER_BLOCKS_ALREADY_EMITTED = frozenset({
198
+ "text", "tool_result", "ide_context", "model_change", "image",
199
+ })
200
+
201
+ # Prefix → injection source. Order matters only to mirror the original
202
+ # if/elif fall-through (no prefix here is a prefix of another).
203
+ _INJECTED_PREFIXES: tuple[tuple[str, str], ...] = (
204
+ ("This session is being continued", "context_continuation"),
205
+ ("Caveat:", "caveat"),
206
+ ("[Request interrupted", "request_interrupted"),
207
+ ("[Image:", "image_placeholder"),
208
+ ("Base directory for this skill", "skill_doc"),
209
+ ("# Update Config Skill", "skill_doc"),
210
+ ("# Keybindings Skill", "skill_doc"),
211
+ ("<steward_checkin", "steward_checkin"),
212
+ )
213
+
214
+ # Substring (anywhere) → injection source. session_briefing only counts
215
+ # within the first 200 chars (handled in the loop).
216
+ _INJECTED_SUBSTRINGS: tuple[tuple[str, str], ...] = (
217
+ ("<session_briefing", "session_briefing"),
218
+ ("<task-notification>", "task_notification"),
219
+ ("<bash-notification>", "bash_notification"),
220
+ )
221
+
222
+ @classmethod
223
+ def _detect_injected(cls, content: str) -> tuple[bool, str | None]:
224
+ """Detect system-injected content masquerading as user messages.
225
+
226
+ Returns (is_injected, source) where source describes the injection type.
227
+ """
228
+ if not content:
229
+ return False, None
230
+
231
+ # Warmup is special-cased: prefix AND a short-length guard.
232
+ if content.startswith("Warmup") and len(content) < 20:
233
+ return True, "warmup"
234
+
235
+ for prefix, source in cls._INJECTED_PREFIXES:
236
+ if content.startswith(prefix):
237
+ return True, source
238
+
239
+ # session_briefing is limited to the head; the rest match anywhere.
240
+ head = content[:200]
241
+ for needle, source in cls._INJECTED_SUBSTRINGS:
242
+ haystack = head if source == "session_briefing" else content
243
+ if needle in haystack:
244
+ return True, source
245
+
246
+ return False, None
247
+
248
+ def _build_user_events(
249
+ self,
250
+ message: NormalizedMessage,
251
+ stream_id: str,
252
+ occurred_at: datetime,
253
+ ) -> list[ThreadEvent]:
254
+ """Create events for a user message. Returns [USER_MESSAGE_SENT]."""
255
+ content_text = message.get("content_text", "")
256
+ content_blocks = message.get("content_blocks", [])
257
+ provider_message_id = message.get("provider_message_id", "")
258
+
259
+ # Check for tool_result blocks (CC JSONL: tool results are user messages
260
+ # with tool_result content blocks but no user text)
261
+ tool_result_blocks = [
262
+ b for b in content_blocks
263
+ if isinstance(b, dict) and b.get("type") == "tool_result"
264
+ ]
265
+ # IDE context blocks (opened files, selections) the parser lifted out of the
266
+ # raw turn — "what Ella was looking at when she sent this". Preserved as their
267
+ # own events below rather than stripped away with the tags.
268
+ ide_context_blocks = [
269
+ b for b in content_blocks
270
+ if isinstance(b, dict) and b.get("type") == "ide_context"
271
+ ]
272
+ # A manual `/model` switch the parser preserved (its command text stripped to
273
+ # empty) — recorded as its own event so the archive can mark the switch.
274
+ model_change_blocks = [
275
+ b for b in content_blocks
276
+ if isinstance(b, dict) and b.get("type") == "model_change"
277
+ ]
278
+
279
+ # Keep any turn that carries *any* content — text, or any content block
280
+ # (tool_result / ide_context / model_change / image / an unmodeled block).
281
+ # Only a genuinely empty turn (no text and no blocks at all) is skipped;
282
+ # everything else is preserved below, unknown block types included.
283
+ if not content_text.strip() and not content_blocks:
284
+ return []
285
+
286
+ # Detect tool-loading confirmation turns: content blocks are mostly
287
+ # tool_result/tool_reference with only a trivial text confirmation.
288
+ # These are system artifacts, not real user messages.
289
+ has_tool_results = any(
290
+ isinstance(b, dict) and b.get("type") in ("tool_result", "tool_reference")
291
+ for b in content_blocks
292
+ )
293
+ if has_tool_results and content_text.strip() in self._TOOL_LOAD_CONFIRMATIONS:
294
+ return [
295
+ ThreadEvent(
296
+ event_type="tool_loaded",
297
+ payload={
298
+ "content": content_text,
299
+ "provider_data": {"provider_message_id": provider_message_id},
300
+ },
301
+ stream_id=stream_id,
302
+ occurred_at=occurred_at,
303
+ )
304
+ ]
305
+
306
+ # Check for multimodal content (images)
307
+ has_images = any(
308
+ isinstance(b, dict) and b.get("type") == "image"
309
+ for b in content_blocks
310
+ )
311
+
312
+ if has_images:
313
+ # Extract images in the format the live path uses (UserMessagePayload.images)
314
+ payload = {
315
+ "content": content_text,
316
+ "content_type": "multimodal",
317
+ "images": self._extract_user_images(content_blocks),
318
+ "provider_data": {"provider_message_id": provider_message_id},
319
+ }
320
+ else:
321
+ payload = {
322
+ "content": content_text,
323
+ "provider_data": {"provider_message_id": provider_message_id},
324
+ }
325
+
326
+ events = []
327
+
328
+ # Create user_message_sent when there's real user text OR images — an
329
+ # image-only turn (a screenshot paste with no caption) must not be
330
+ # dropped; its images ride on the multimodal payload built above.
331
+ if content_text.strip() or has_images:
332
+ if content_text.strip():
333
+ # Tag injected/system content so we can distinguish real user input
334
+ injected, source = self._detect_injected(content_text)
335
+ if injected:
336
+ payload["injected"] = True
337
+ payload["source"] = source
338
+
339
+ # Tag steering messages (typed mid-turn, queued, consumed as an
340
+ # attachment injection). The read path uses this to slot a
341
+ # late-backfilled steering event into its chronological position.
342
+ if (message.get("provider_data") or {}).get("queued_command"):
343
+ payload["queued"] = True
344
+
345
+ events.append(ThreadEvent(
346
+ event_type="user_message_sent",
347
+ payload=payload,
348
+ stream_id=stream_id,
349
+ occurred_at=occurred_at,
350
+ ))
351
+
352
+ # Extract tool results embedded in user messages (CC JSONL format:
353
+ # tool results come as content blocks on user-role messages)
354
+ for block in tool_result_blocks:
355
+ events.append(self._tool_result_event(block, stream_id, None, occurred_at))
356
+
357
+ # Preserve IDE context (opened files, selections) as its own events, so a
358
+ # turn's editor context survives import — and a context-only turn isn't lost.
359
+ for block in ide_context_blocks:
360
+ events.append(self._ide_context_event(block, stream_id, occurred_at))
361
+
362
+ # A manual model switch (`/model X`) becomes its own event so the reader can
363
+ # render a "switched to X" marker between turns.
364
+ for block in model_change_blocks:
365
+ events.append(ThreadEvent(
366
+ event_type="model_change",
367
+ payload={
368
+ "to": block.get("to_model"),
369
+ "trigger": block.get("trigger", "user"),
370
+ },
371
+ stream_id=stream_id,
372
+ occurred_at=occurred_at,
373
+ ))
374
+
375
+ # Preserve any content block the cases above didn't already emit — an
376
+ # unmodeled user block (document / mcp_tool_use / a future kind, or the
377
+ # parser's "raw" catch-all) becomes its own content_block event rather
378
+ # than silently vanishing. Mirrors the assistant path (_process_content_block).
379
+ for idx, block in enumerate(content_blocks):
380
+ if not isinstance(block, dict):
381
+ continue
382
+ block_type = block.get("type", "")
383
+ if block_type in self._USER_BLOCKS_ALREADY_EMITTED:
384
+ continue
385
+ events.append(ThreadEvent(
386
+ event_type="content_block",
387
+ payload={
388
+ "block_type": block_type,
389
+ "data": block,
390
+ "block_index": block.get("seq", idx),
391
+ },
392
+ stream_id=stream_id,
393
+ occurred_at=occurred_at,
394
+ ))
395
+
396
+ return events
397
+
398
+ @staticmethod
399
+ def _extract_user_images(content_blocks: list) -> list:
400
+ """Extract base64 image blocks into the live UserMessagePayload.images shape."""
401
+ images = []
402
+ for b in content_blocks:
403
+ if isinstance(b, dict) and b.get("type") == "image":
404
+ source = b.get("source", {})
405
+ if source.get("type") == "base64" and source.get("data"):
406
+ images.append({
407
+ "media_type": source.get("media_type", "image/png"),
408
+ "data": source["data"],
409
+ })
410
+ return images
411
+
412
+ @staticmethod
413
+ def _tool_result_event(
414
+ block: Mapping[str, Any],
415
+ stream_id: str,
416
+ api_call_id: Optional[str],
417
+ occurred_at: datetime,
418
+ ) -> "ThreadEvent":
419
+ """Build the event for a tool_result block.
420
+
421
+ An error result becomes ``tool_execution_error`` (with the failure text
422
+ under ``error``), NOT ``tool_execution_completed`` — because the display
423
+ reader only recognizes a tool failure via the distinct
424
+ ``tool_execution_error`` event type; an is_error flag on
425
+ ``tool_execution_completed`` is silently rendered as success
426
+ (message_view._build_tool_result_lookup). This is the canonical form
427
+ every producer must converge on.
428
+ """
429
+ tool_call_id = block.get("tool_use_id")
430
+ name = block.get("name", "unknown")
431
+ content = block.get("content", "")
432
+ if block.get("is_error"):
433
+ event_type = "tool_execution_error"
434
+ payload: dict = {"tool_call_id": tool_call_id, "tool_name": name, "error": content}
435
+ else:
436
+ event_type = "tool_execution_completed"
437
+ payload = {
438
+ "tool_call_id": tool_call_id,
439
+ "tool_name": name,
440
+ "output": content,
441
+ "is_error": False,
442
+ }
443
+ # A missing id can never pair to its call. Flag it rather than minting a
444
+ # uuid that's guaranteed to dangle.
445
+ if not tool_call_id:
446
+ payload["unpaired"] = True
447
+ return ThreadEvent(
448
+ event_type=event_type,
449
+ payload=payload,
450
+ stream_id=stream_id,
451
+ api_call_id=api_call_id,
452
+ occurred_at=occurred_at,
453
+ )
454
+
455
+ @staticmethod
456
+ def _ide_context_event(
457
+ block: Mapping[str, Any],
458
+ stream_id: str,
459
+ occurred_at: datetime,
460
+ ) -> "ThreadEvent":
461
+ """Build the event for an ``ide_context`` block — an opened file or a code
462
+ selection the parser lifted out of the raw user turn. Kept as a first-class
463
+ event so "what was open / selected when this turn was sent" survives import
464
+ rather than being stripped away with the tags. ``block_index`` (the block's
465
+ seq) namespaces the dedup key so several context blocks on one turn don't
466
+ collapse into one."""
467
+ payload: dict = {
468
+ "context_type": block.get("context_type"),
469
+ "content": block.get("raw_content", ""),
470
+ "block_index": block.get("seq"),
471
+ }
472
+ file_path = block.get("file_path")
473
+ if file_path:
474
+ payload["file_path"] = file_path
475
+ return ThreadEvent(
476
+ event_type="ide_context",
477
+ payload=payload,
478
+ stream_id=stream_id,
479
+ occurred_at=occurred_at,
480
+ )
481
+
482
+ def _build_generic_message_events(
483
+ self,
484
+ message: NormalizedMessage,
485
+ stream_id: str,
486
+ occurred_at: datetime,
487
+ ) -> list[ThreadEvent]:
488
+ """Preserve a message whose role isn't one of user/assistant/system.
489
+
490
+ Other harnesses emit roles the builder doesn't model — ``tool``,
491
+ ``function``, ``developer``, ``model``, or anything ``normalize_role`` passes
492
+ through unchanged. Rather than drop the turn, keep it verbatim as a single
493
+ ``message`` event carrying the role, its text, and its raw content blocks, so
494
+ nothing is lost on import and it stays searchable + re-exportable."""
495
+ role = message.get("role", "") or "unknown"
496
+ content_text = message.get("content_text", "")
497
+ content_blocks = message.get("content_blocks", [])
498
+ if not content_text.strip() and not content_blocks:
499
+ return []
500
+ payload: dict = {"role": role, "content": content_text}
501
+ if content_blocks:
502
+ payload["content_blocks"] = content_blocks
503
+ return [ThreadEvent(
504
+ event_type="message",
505
+ payload=payload,
506
+ stream_id=stream_id,
507
+ occurred_at=occurred_at,
508
+ )]
509
+
510
+ def _build_system_events(
511
+ self,
512
+ message: NormalizedMessage,
513
+ stream_id: str,
514
+ occurred_at: datetime,
515
+ ) -> list[ThreadEvent]:
516
+ """Create events for system/metadata messages.
517
+
518
+ Maps content block types to event types:
519
+ - context_summary → CONTEXT_SUMMARY
520
+ - file_snapshot → FILE_SNAPSHOT
521
+ - Other system messages → stored with their block type
522
+ """
523
+ content_blocks = message.get("content_blocks", [])
524
+ content_text = message.get("content_text", "")
525
+ provider_data = message.get("provider_data", {})
526
+
527
+ if not content_blocks:
528
+ return []
529
+
530
+ # Determine event type from the first content block
531
+ first_block = content_blocks[0] if content_blocks else {}
532
+ block_type = first_block.get("type", "") if isinstance(first_block, dict) else ""
533
+
534
+ if block_type == "context_summary":
535
+ return [ThreadEvent(
536
+ event_type="context_summary",
537
+ payload={
538
+ "content": first_block.get("text", content_text),
539
+ "summary_type": provider_data.get("summary_type", "context_continuation"),
540
+ "summarizes": first_block.get("summarizes"),
541
+ },
542
+ stream_id=stream_id,
543
+ occurred_at=occurred_at,
544
+ )]
545
+ elif block_type == "file_snapshot":
546
+ return [ThreadEvent(
547
+ event_type="file_snapshot",
548
+ payload={
549
+ "files": first_block.get("files", []),
550
+ "message_id": message.get("provider_message_id", ""),
551
+ "snapshot_timestamp": first_block.get("timestamp"),
552
+ },
553
+ stream_id=stream_id,
554
+ occurred_at=occurred_at,
555
+ )]
556
+ elif block_type == "queue_operation":
557
+ return [ThreadEvent(
558
+ event_type="queue_operation",
559
+ payload=first_block.get("data", {}),
560
+ stream_id=stream_id,
561
+ occurred_at=occurred_at,
562
+ )]
563
+ elif block_type == "progress":
564
+ return [ThreadEvent(
565
+ event_type="progress",
566
+ payload={
567
+ "data": first_block.get("data", {}),
568
+ "tool_use_id": first_block.get("tool_use_id"),
569
+ },
570
+ stream_id=stream_id,
571
+ occurred_at=occurred_at,
572
+ )]
573
+ else:
574
+ # Generic system event — preserve whatever data is there
575
+ return [ThreadEvent(
576
+ event_type="context_summary",
577
+ payload={
578
+ "content": content_text,
579
+ "system_type": block_type,
580
+ "provider_data": provider_data,
581
+ },
582
+ stream_id=stream_id,
583
+ occurred_at=occurred_at,
584
+ )]
585
+
586
+ def _build_assistant_events(
587
+ self,
588
+ message: NormalizedMessage,
589
+ stream_id: str,
590
+ api_call_id: Optional[str],
591
+ occurred_at: datetime,
592
+ ) -> list[ThreadEvent]:
593
+ """Create events for an assistant message.
594
+
595
+ Returns sequence: API_REQUEST_STARTED, [content events],
596
+ API_REQUEST_COMPLETED, STREAM_COMPLETED
597
+ """
598
+ events = []
599
+ api_call_id = api_call_id or str(uuid.uuid4())
600
+
601
+ content_text = message.get("content_text", "")
602
+ content_blocks = message.get("content_blocks", [])
603
+ provider_data = message.get("provider_data", {})
604
+ provider_message_id = message.get("provider_message_id", "")
605
+ model = provider_data.get("model", "unknown")
606
+
607
+ # API_REQUEST_STARTED
608
+ events.append(ThreadEvent(
609
+ event_type="api_request_started",
610
+ payload={"model": model, "provider_data": {"provider_message_id": provider_message_id}},
611
+ stream_id=stream_id,
612
+ api_call_id=api_call_id,
613
+ occurred_at=occurred_at,
614
+ ))
615
+
616
+ # Process content blocks. A block's own start_timestamp may refine the
617
+ # time forward (never backward — blocks are emitted in content order and
618
+ # `id` is the true ordinal), so the running timestamp stays monotonic
619
+ # non-decreasing across the turn.
620
+ api_blocks = []
621
+ running_ts = occurred_at
622
+ for idx, block in enumerate(content_blocks):
623
+ if isinstance(block, dict) and block.get("start_timestamp"):
624
+ block_ts = self._parse_timestamp(cast(Mapping[str, Any], block)["start_timestamp"])
625
+ if block_ts is not None and block_ts >= running_ts:
626
+ running_ts = block_ts
627
+ block_events, block_api = self._process_content_block(
628
+ block, idx, stream_id, api_call_id, running_ts
629
+ )
630
+ events.extend(block_events)
631
+ if block_api:
632
+ api_blocks.append(block_api)
633
+
634
+ # Fallback: if no blocks but has content_text
635
+ if not content_blocks and content_text:
636
+ api_blocks.append({"type": "text", "text": content_text})
637
+ events.append(ThreadEvent(
638
+ event_type="text_complete",
639
+ payload={"text": content_text, "block_index": 0},
640
+ stream_id=stream_id,
641
+ api_call_id=api_call_id,
642
+ occurred_at=occurred_at,
643
+ ))
644
+
645
+ # API_REQUEST_COMPLETED — completes after every block, so it carries the
646
+ # running (max) timestamp, keeping the whole turn monotonic.
647
+ usage = provider_data.get("usage", {})
648
+ events.append(ThreadEvent(
649
+ event_type="api_request_completed",
650
+ payload={
651
+ "stop_reason": provider_data.get("stop_reason", "end_turn"),
652
+ "content_blocks": api_blocks,
653
+ "model": model,
654
+ "input_tokens": usage.get("input_tokens", 0),
655
+ "output_tokens": usage.get("output_tokens", 0),
656
+ "thinking_tokens": usage.get("thinking_tokens", 0),
657
+ },
658
+ stream_id=stream_id,
659
+ api_call_id=api_call_id,
660
+ occurred_at=running_ts,
661
+ ))
662
+
663
+ # STREAM_COMPLETED
664
+ events.append(ThreadEvent(
665
+ event_type="stream_completed",
666
+ payload={},
667
+ stream_id=stream_id,
668
+ api_call_id=api_call_id,
669
+ occurred_at=running_ts,
670
+ ))
671
+
672
+ return events
673
+
674
+ def _process_content_block(
675
+ self,
676
+ block: Any,
677
+ idx: int,
678
+ stream_id: str,
679
+ api_call_id: str,
680
+ occurred_at: datetime,
681
+ ) -> tuple[list[ThreadEvent], Optional[dict]]:
682
+ """Process a single content block.
683
+
684
+ Returns:
685
+ Tuple of (events to emit, API block representation)
686
+ """
687
+ events = []
688
+ api_block = None
689
+
690
+ # occurred_at is resolved by the caller (monotonic non-decreasing across
691
+ # the turn); this method maps a block to its events at that timestamp.
692
+
693
+ # Handle raw text string
694
+ if isinstance(block, str):
695
+ api_block = {"type": "text", "text": block}
696
+ events.append(ThreadEvent(
697
+ event_type="text_complete",
698
+ payload={"text": block, "block_index": idx},
699
+ stream_id=stream_id,
700
+ api_call_id=api_call_id,
701
+ occurred_at=occurred_at,
702
+ ))
703
+ return events, api_block
704
+
705
+ block_type = block.get("type", "")
706
+
707
+ if block_type == "thinking":
708
+ text = block.get("text", "")
709
+ api_block = {"type": "thinking", "thinking": text}
710
+ events.append(ThreadEvent(
711
+ event_type="thinking_complete",
712
+ payload={"text": text, "block_index": idx},
713
+ stream_id=stream_id,
714
+ api_call_id=api_call_id,
715
+ occurred_at=occurred_at,
716
+ ))
717
+
718
+ elif block_type == "text":
719
+ text = block.get("text", "")
720
+ api_block = {"type": "text", "text": text}
721
+ events.append(ThreadEvent(
722
+ event_type="text_complete",
723
+ payload={"text": text, "block_index": idx},
724
+ stream_id=stream_id,
725
+ api_call_id=api_call_id,
726
+ occurred_at=occurred_at,
727
+ ))
728
+
729
+ elif block_type == "tool_use":
730
+ tool_call_id = block.get("tool_call_id") or block.get("id")
731
+ name = block.get("name", "unknown")
732
+ input_data = block.get("input", {})
733
+ api_block = {
734
+ "type": "tool_use",
735
+ "id": tool_call_id or "",
736
+ "name": name,
737
+ "input": input_data,
738
+ }
739
+ payload = {
740
+ "tool_call_id": tool_call_id,
741
+ "tool_name": name,
742
+ "input": input_data,
743
+ "block_index": idx,
744
+ }
745
+ # A tool_use with no id can't be paired to its result. Flag it
746
+ # rather than minting a uuid that's guaranteed to dangle.
747
+ if not tool_call_id:
748
+ payload["unpaired"] = True
749
+ if block.get("provider_tool_name"):
750
+ payload["provider_data"] = {"provider_tool_name": block["provider_tool_name"]}
751
+ events.append(ThreadEvent(
752
+ event_type="tool_use_complete",
753
+ payload=payload,
754
+ stream_id=stream_id,
755
+ api_call_id=api_call_id,
756
+ occurred_at=occurred_at,
757
+ ))
758
+
759
+ elif block_type == "tool_result":
760
+ events.append(self._tool_result_event(block, stream_id, api_call_id, occurred_at))
761
+ # No api_block for tool_result - it's handled separately
762
+
763
+ else:
764
+ # A block type the builder doesn't specifically model. Other harnesses
765
+ # emit server_tool_use, web_search_tool_result, redacted_thinking, image,
766
+ # document, mcp_tool_use, … — preserve them verbatim rather than dropping:
767
+ # as their own content_block event, and unchanged in the api summary.
768
+ api_block = block
769
+ events.append(ThreadEvent(
770
+ event_type="content_block",
771
+ payload={"block_type": block_type, "data": block, "block_index": idx},
772
+ stream_id=stream_id,
773
+ api_call_id=api_call_id,
774
+ occurred_at=occurred_at,
775
+ ))
776
+
777
+ return events, api_block
778
+
779
+ def _to_api_blocks(
780
+ self,
781
+ content_blocks: list[dict],
782
+ for_user: bool = False
783
+ ) -> list[dict]:
784
+ """Convert content blocks to Anthropic API format."""
785
+ api_blocks = []
786
+ for block in content_blocks:
787
+ if isinstance(block, str):
788
+ api_blocks.append({"type": "text", "text": block})
789
+ continue
790
+
791
+ block_type = block.get("type")
792
+ if block_type == "text":
793
+ api_blocks.append({"type": "text", "text": block.get("text", "")})
794
+ elif block_type == "image" and for_user:
795
+ source = block.get("source", {})
796
+ if source:
797
+ api_blocks.append({
798
+ "type": "image",
799
+ "source": source,
800
+ })
801
+ return api_blocks
802
+
803
+ def _parse_timestamp(
804
+ self, ts: Optional[str], default: Optional[datetime] = None
805
+ ) -> Optional[datetime]:
806
+ """Parse an ISO/epoch timestamp; return ``default`` (None) on a
807
+ missing/unparseable value. Never fabricates ``datetime.now()`` —
808
+ callers own the fallback so a missing timestamp stays visible
809
+ (never silently invented)."""
810
+ return parse_timestamp(ts, default=default)