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,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)
|