atif-sql 0.2.0__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 (164) hide show
  1. atif_analytics/__init__.py +9 -0
  2. atif_analytics/application/__init__.py +3 -0
  3. atif_analytics/application/analyze.py +158 -0
  4. atif_analytics/application/prompts.py +750 -0
  5. atif_analytics/application/use_cases/__init__.py +3 -0
  6. atif_analytics/application/use_cases/_shared.py +264 -0
  7. atif_analytics/application/use_cases/classify.py +430 -0
  8. atif_analytics/application/use_cases/conflicts.py +448 -0
  9. atif_analytics/application/use_cases/friction.py +634 -0
  10. atif_analytics/application/use_cases/perceived.py +451 -0
  11. atif_analytics/domain/__init__.py +3 -0
  12. atif_analytics/domain/authorship.py +166 -0
  13. atif_analytics/domain/config.py +30 -0
  14. atif_analytics/domain/costs.py +50 -0
  15. atif_analytics/domain/friction.py +117 -0
  16. atif_analytics/domain/layout.py +94 -0
  17. atif_analytics/domain/models.py +385 -0
  18. atif_analytics/domain/ports.py +100 -0
  19. atif_analytics/domain/transcript.py +359 -0
  20. atif_analytics/infrastructure/__init__.py +3 -0
  21. atif_analytics/infrastructure/corpus_reader.py +546 -0
  22. atif_analytics/infrastructure/parquet_cache.py +186 -0
  23. atif_analytics/infrastructure/settings.py +114 -0
  24. atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
  25. atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
  26. atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
  27. atif_analytics/py.typed +0 -0
  28. atif_cli/__init__.py +3 -0
  29. atif_cli/__main__.py +8 -0
  30. atif_cli/app.py +2459 -0
  31. atif_cli/converter_adapter.py +151 -0
  32. atif_cli/corpus.py +409 -0
  33. atif_cli/cron.py +311 -0
  34. atif_cli/duck_errors.py +97 -0
  35. atif_cli/embed_lake.py +101 -0
  36. atif_cli/errors.py +89 -0
  37. atif_cli/lake.py +373 -0
  38. atif_cli/lake_sessions.py +270 -0
  39. atif_cli/output.py +297 -0
  40. atif_cli/py.typed +0 -0
  41. atif_converter/__init__.py +3 -0
  42. atif_converter/application/__init__.py +3 -0
  43. atif_converter/application/convert_and_audit.py +294 -0
  44. atif_converter/application/convert_codex.py +210 -0
  45. atif_converter/domain/__init__.py +9 -0
  46. atif_converter/domain/agents.py +40 -0
  47. atif_converter/domain/atif/__init__.py +55 -0
  48. atif_converter/domain/atif/agent.py +37 -0
  49. atif_converter/domain/atif/content.py +161 -0
  50. atif_converter/domain/atif/final_metrics.py +45 -0
  51. atif_converter/domain/atif/metrics.py +49 -0
  52. atif_converter/domain/atif/observation.py +21 -0
  53. atif_converter/domain/atif/observation_result.py +47 -0
  54. atif_converter/domain/atif/step.py +144 -0
  55. atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
  56. atif_converter/domain/atif/tool_call.py +36 -0
  57. atif_converter/domain/atif/trajectory.py +211 -0
  58. atif_converter/domain/atif/trajectory_validator.py +299 -0
  59. atif_converter/domain/blobs.py +510 -0
  60. atif_converter/domain/claude_code_conversion.py +1077 -0
  61. atif_converter/domain/codex_conversion.py +1036 -0
  62. atif_converter/domain/codex_edges.py +192 -0
  63. atif_converter/domain/codex_enrichment.py +419 -0
  64. atif_converter/domain/codex_fidelity.py +124 -0
  65. atif_converter/domain/codex_nested_calls.py +165 -0
  66. atif_converter/domain/edges.py +113 -0
  67. atif_converter/domain/enrichment.py +412 -0
  68. atif_converter/domain/errors.py +60 -0
  69. atif_converter/domain/fidelity.py +171 -0
  70. atif_converter/domain/model_prices.json +8538 -0
  71. atif_converter/domain/pricing.py +790 -0
  72. atif_converter/domain/result_signals.py +774 -0
  73. atif_converter/domain/schema_version.py +90 -0
  74. atif_converter/domain/session_events.py +408 -0
  75. atif_converter/infrastructure/__init__.py +3 -0
  76. atif_converter/infrastructure/census.py +89 -0
  77. atif_converter/infrastructure/claude_code_converter.py +127 -0
  78. atif_converter/infrastructure/codex_adapter.py +132 -0
  79. atif_converter/infrastructure/codex_census.py +100 -0
  80. atif_converter/infrastructure/codex_converter.py +82 -0
  81. atif_converter/infrastructure/harbor_adapter.py +187 -0
  82. atif_converter/infrastructure/raw_records.py +426 -0
  83. atif_converter/infrastructure/source_archive.py +154 -0
  84. atif_converter/py.typed +0 -0
  85. atif_corpus/__init__.py +13 -0
  86. atif_corpus/application/__init__.py +7 -0
  87. atif_corpus/application/materialize.py +1826 -0
  88. atif_corpus/domain/__init__.py +9 -0
  89. atif_corpus/domain/agents.py +33 -0
  90. atif_corpus/domain/generation.py +34 -0
  91. atif_corpus/domain/layout.py +168 -0
  92. atif_corpus/domain/ports.py +175 -0
  93. atif_corpus/domain/session_id.py +68 -0
  94. atif_corpus/domain/sessions.py +228 -0
  95. atif_corpus/domain/slug.py +72 -0
  96. atif_corpus/domain/source_layout.py +143 -0
  97. atif_corpus/domain/watermark.py +76 -0
  98. atif_corpus/infrastructure/__init__.py +7 -0
  99. atif_corpus/infrastructure/atomic.py +266 -0
  100. atif_corpus/infrastructure/compress_artifacts.py +161 -0
  101. atif_corpus/infrastructure/fake_converter.py +174 -0
  102. atif_corpus/infrastructure/scanner.py +316 -0
  103. atif_corpus/infrastructure/settings.py +130 -0
  104. atif_corpus/infrastructure/source_archive.py +202 -0
  105. atif_corpus/py.typed +0 -0
  106. atif_duck/__init__.py +43 -0
  107. atif_duck/domain/__init__.py +32 -0
  108. atif_duck/domain/artifacts.py +66 -0
  109. atif_duck/domain/authorship.py +165 -0
  110. atif_duck/domain/catalog.py +626 -0
  111. atif_duck/domain/columnar.py +149 -0
  112. atif_duck/domain/embedding_guard.py +88 -0
  113. atif_duck/domain/examples.py +265 -0
  114. atif_duck/domain/lake.py +427 -0
  115. atif_duck/domain/lake_sessions.py +97 -0
  116. atif_duck/domain/raw_readers.py +140 -0
  117. atif_duck/domain/session_id.py +69 -0
  118. atif_duck/domain/sql_literal.py +50 -0
  119. atif_duck/infrastructure/__init__.py +34 -0
  120. atif_duck/infrastructure/analytics.py +469 -0
  121. atif_duck/infrastructure/authorship.py +139 -0
  122. atif_duck/infrastructure/columnar.py +641 -0
  123. atif_duck/infrastructure/lake.py +1383 -0
  124. atif_duck/infrastructure/lake_sessions.py +194 -0
  125. atif_duck/infrastructure/lake_settings.py +63 -0
  126. atif_duck/infrastructure/lake_steps.py +225 -0
  127. atif_duck/infrastructure/projections.py +221 -0
  128. atif_duck/infrastructure/registry.py +2194 -0
  129. atif_duck/infrastructure/stored_artifacts.py +115 -0
  130. atif_duck/py.typed +0 -0
  131. atif_embed/__init__.py +12 -0
  132. atif_embed/application/__init__.py +3 -0
  133. atif_embed/application/embed.py +293 -0
  134. atif_embed/application/prune.py +80 -0
  135. atif_embed/domain/__init__.py +3 -0
  136. atif_embed/domain/discovery.py +70 -0
  137. atif_embed/domain/embedding_guard.py +83 -0
  138. atif_embed/domain/errors.py +94 -0
  139. atif_embed/domain/ports.py +223 -0
  140. atif_embed/domain/sql_literal.py +49 -0
  141. atif_embed/domain/text_stamp.py +71 -0
  142. atif_embed/infrastructure/__init__.py +3 -0
  143. atif_embed/infrastructure/cohere_bedrock.py +433 -0
  144. atif_embed/infrastructure/corpus_text_rows.py +273 -0
  145. atif_embed/infrastructure/gemma_local.py +355 -0
  146. atif_embed/infrastructure/lake_text_rows.py +203 -0
  147. atif_embed/infrastructure/lance_store.py +440 -0
  148. atif_embed/infrastructure/providers.py +30 -0
  149. atif_embed/infrastructure/settings.py +154 -0
  150. atif_embed/py.typed +0 -0
  151. atif_models/__init__.py +14 -0
  152. atif_models/domain/__init__.py +9 -0
  153. atif_models/domain/ports.py +143 -0
  154. atif_models/domain/registry.py +151 -0
  155. atif_models/domain/schema.py +109 -0
  156. atif_models/infrastructure/__init__.py +8 -0
  157. atif_models/infrastructure/openai_bedrock.py +314 -0
  158. atif_models/infrastructure/settings.py +85 -0
  159. atif_models/py.typed +0 -0
  160. atif_sql-0.2.0.dist-info/METADATA +272 -0
  161. atif_sql-0.2.0.dist-info/RECORD +164 -0
  162. atif_sql-0.2.0.dist-info/WHEEL +4 -0
  163. atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
  164. atif_sql-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,510 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Inline base64 attachments -> content-addressed blobs plus a short text placeholder.
4
+
5
+ Claude Code writes every image a tool returned (a ``Read`` of a PNG, a
6
+ screenshot) and every image a user pasted into the transcript as base64, and
7
+ the conversion used to carry that text all the way into ``trajectory.json``
8
+ and the ``tool_results.content`` column, where it was about half of all tool
9
+ result text. Anything reading that text (embeddings, an LLM prompt, a regex)
10
+ paid for megabytes of noise per image.
11
+
12
+ This module lifts the bytes out BEFORE conversion. Each attachment is decoded
13
+ once, named by the SHA-256 of its bytes, handed to the caller as a
14
+ :class:`Blob` to store, and replaced in the raw record by a short, stable
15
+ placeholder::
16
+
17
+ [image sha256:<64 hex> image/png 123456 bytes]
18
+
19
+ so the converter, the enrichment pass and every text consumer see the
20
+ placeholder and never the base64. The rewrite runs on the parsed records the
21
+ converter and the audit share (one read, see
22
+ :mod:`atif_converter.infrastructure.raw_records`), so every artifact agrees
23
+ about it.
24
+
25
+ WHICH SHAPES. Measured over the local transcripts on 2026-09-27, three Claude
26
+ Code shapes carry base64 and each is handled:
27
+
28
+ * a ``tool_result`` block's content item
29
+ ``{"type": "image"|"document", "source": {"type": "base64", ...}}``
30
+ (``Read`` results, 10,798 of them);
31
+ * the same block shape directly in a user message's content (pasted images
32
+ and PDFs);
33
+ * ``toolUseResult.file.base64`` on the ``Read`` record, the harness's own copy
34
+ of the same file, which the converter used to dump into the result text a
35
+ second time as ``[metadata]``.
36
+
37
+ In a Codex rollout the shape is ``{"type": "input_image", "image_url":
38
+ "data:<media>;base64,<data>"}`` inside a ``response_item`` message's content
39
+ or a tool output list. A tool output list can also carry an MCP image block,
40
+ ``{"type": "image", "data": "<base64>", "mimeType": "image/png"}``. The
41
+ converter's harbor port names every inline image in a tool output it can save
42
+ by a placeholder (:func:`codex_tool_image_ref` is its rule: a supported image
43
+ type, a payload that decodes once whitespace is removed, data URL parameters
44
+ allowed), so a tool output's MCP image, and an ``input_image`` the strict data
45
+ URL match above misses, is lifted by that same rule and the placeholder names
46
+ bytes the collector holds. A base64 value anywhere else (a tool CALL's own
47
+ arguments, an ``image`` block in message content) is left alone.
48
+
49
+ Replacement is by block: an attachment block becomes a TEXT block holding the
50
+ placeholder (``input_text`` for Codex), which is the one block type every
51
+ downstream text rule already renders verbatim. A value that does not decode as
52
+ strict base64 is left exactly as it was: a malformed attachment is kept, never
53
+ silently dropped.
54
+
55
+ Pure: no I/O. The one library call is :mod:`imagesize`, which reads the width
56
+ and height out of the image header in memory.
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import base64
62
+ import binascii
63
+ import hashlib
64
+ import io
65
+ import re
66
+ from dataclasses import dataclass, field
67
+ from typing import TYPE_CHECKING, Any
68
+
69
+ import imagesize # type: ignore[import-untyped]
70
+ from loguru import logger
71
+
72
+ if TYPE_CHECKING:
73
+ from collections.abc import Iterable, Sequence
74
+
75
+ #: File extension per media type; anything else is stored as ``.bin``.
76
+ _EXTENSIONS: dict[str, str] = {
77
+ "image/png": "png",
78
+ "image/jpeg": "jpg",
79
+ "image/gif": "gif",
80
+ "image/webp": "webp",
81
+ "application/pdf": "pdf",
82
+ }
83
+
84
+ #: A media type is kept only when it looks like one; the placeholder and the
85
+ #: typed columns must not carry arbitrary transcript text.
86
+ _MEDIA_TYPE_RE = re.compile(r"^[a-z]+/[a-z0-9][a-z0-9.+-]{0,126}$")
87
+
88
+ #: The fallback media type for an attachment whose declared type is missing or malformed.
89
+ _OCTET_STREAM = "application/octet-stream"
90
+
91
+ #: A Codex data URL: ``data:<media>;base64,<payload>``.
92
+ _DATA_URL_RE = re.compile(r"^data:([^;,]{1,128});base64,", re.ASCII)
93
+
94
+ #: The image types harbor 0.24.0 saves from a Codex tool output
95
+ #: (``Codex._image_media_type``); any other type is ``[image omitted]`` there.
96
+ CODEX_TOOL_IMAGE_MEDIA_TYPES = frozenset({"image/png", "image/jpeg", "image/gif", "image/webp"})
97
+
98
+ #: harbor's data URL for a Codex tool output image: parameters allowed before ``;base64``.
99
+ _TOOL_IMAGE_DATA_URL = re.compile(
100
+ r"data:(?P<media_type>[^;,]+)(?:;[^;,]*)*;base64,(?P<data>.*)", re.DOTALL
101
+ )
102
+
103
+
104
+ @dataclass(frozen=True, slots=True)
105
+ class BlobRef:
106
+ """What a trajectory keeps of one attachment once its bytes are stored."""
107
+
108
+ #: Lowercase hex SHA-256 of the decoded bytes: the blob's name.
109
+ sha256: str
110
+ #: The declared media type (``image/png``), or ``application/octet-stream``.
111
+ media_type: str
112
+ #: Decoded length in bytes.
113
+ byte_count: int
114
+ #: Pixel width from the image header, when the format is one it can read.
115
+ width: int | None = None
116
+ #: Pixel height, same rule.
117
+ height: int | None = None
118
+
119
+ @property
120
+ def extension(self) -> str:
121
+ """The stored file's extension (``png``, ``jpg``, ..., ``bin``)."""
122
+ return _EXTENSIONS.get(self.media_type, "bin")
123
+
124
+ @property
125
+ def is_image(self) -> bool:
126
+ """True for an ``image/*`` media type."""
127
+ return self.media_type.startswith("image/")
128
+
129
+ def placeholder(self) -> str:
130
+ """The stable text left where the base64 was."""
131
+ kind = "image" if self.is_image else "file"
132
+ return f"[{kind} sha256:{self.sha256} {self.media_type} {self.byte_count} bytes]"
133
+
134
+ def to_json(self) -> dict[str, Any]:
135
+ """The ``extra.images[]`` entry the enrichment pass writes into the trajectory."""
136
+ return {
137
+ "sha256": self.sha256,
138
+ "media_type": self.media_type,
139
+ "bytes": self.byte_count,
140
+ "width": self.width,
141
+ "height": self.height,
142
+ "extension": self.extension,
143
+ }
144
+
145
+
146
+ @dataclass(frozen=True, slots=True)
147
+ class Blob:
148
+ """One attachment's bytes, to be written to the corpus blob store under ``ref``."""
149
+
150
+ ref: BlobRef
151
+ data: bytes
152
+
153
+
154
+ def _media_type(value: Any) -> str:
155
+ lowered = str(value).strip().lower() if isinstance(value, str) else ""
156
+ return lowered if _MEDIA_TYPE_RE.match(lowered) else _OCTET_STREAM
157
+
158
+
159
+ def _dimensions(data: bytes) -> tuple[int | None, int | None]:
160
+ try:
161
+ width, height = imagesize.get(io.BytesIO(data))
162
+ except (ValueError, OSError, IndexError) as exc: # a truncated or odd header
163
+ logger.debug("blobs: no dimensions from image header: {}", exc)
164
+ return None, None
165
+ # imagesize answers (-1, -1) for a format it cannot read.
166
+ if width < 0 or height < 0:
167
+ return None, None
168
+ return width, height
169
+
170
+
171
+ @dataclass(slots=True)
172
+ class BlobCollector:
173
+ """Accumulates one session's decoded attachments, deduplicated by hash."""
174
+
175
+ _blobs: dict[str, Blob] = field(default_factory=dict)
176
+ #: How many attachments were replaced (a repeated image counts every time).
177
+ references: int = 0
178
+
179
+ def add_base64(self, text: str, media_type: Any) -> BlobRef | None:
180
+ """Decode ``text`` and register it; ``None`` when it is not strict base64.
181
+
182
+ Strict means :func:`base64.b64decode` with ``validate=True``: a value
183
+ carrying anything outside the base64 alphabet is not an attachment
184
+ this module understands, and is left in place by the caller.
185
+ """
186
+ try:
187
+ data = base64.b64decode(text, validate=True)
188
+ except (binascii.Error, ValueError):
189
+ return None
190
+ if not data:
191
+ return None
192
+ digest = hashlib.sha256(data).hexdigest()
193
+ existing = self._blobs.get(digest)
194
+ declared = _media_type(media_type)
195
+ if existing is not None and existing.ref.media_type == declared:
196
+ self.references += 1
197
+ return existing.ref
198
+ width, height = _dimensions(data) if declared.startswith("image/") else (None, None)
199
+ ref = BlobRef(
200
+ sha256=digest,
201
+ media_type=declared,
202
+ byte_count=len(data),
203
+ width=width,
204
+ height=height,
205
+ )
206
+ # The same bytes declared under two media types keep the first one's
207
+ # blob; both refs name the same content, which is what the hash promises.
208
+ self._blobs.setdefault(digest, Blob(ref=ref, data=data))
209
+ self.references += 1
210
+ return ref
211
+
212
+ @property
213
+ def blobs(self) -> tuple[Blob, ...]:
214
+ """Every distinct blob, ordered by hash so the output is deterministic."""
215
+ return tuple(self._blobs[key] for key in sorted(self._blobs))
216
+
217
+
218
+ @dataclass(slots=True)
219
+ class BlobIndex:
220
+ """Where each replaced attachment sat, so enrichment can type it per row."""
221
+
222
+ #: Tool call id -> the attachments in that call's result, in order, deduplicated.
223
+ by_tool_call_id: dict[str, list[BlobRef]] = field(default_factory=dict)
224
+ #: Record key (Claude Code ``uuid``; Codex edge uuid) -> attachments in
225
+ #: that record's own message content (not inside a tool result).
226
+ by_record_key: dict[str, list[BlobRef]] = field(default_factory=dict)
227
+
228
+ def add_tool_result(self, call_id: Any, refs: Sequence[BlobRef]) -> None:
229
+ """Attach ``refs`` to one tool call's result."""
230
+ if not isinstance(call_id, str) or not call_id or not refs:
231
+ return
232
+ _extend_unique(self.by_tool_call_id.setdefault(call_id, []), refs)
233
+
234
+ def add_record(self, key: Any, refs: Sequence[BlobRef]) -> None:
235
+ """Attach ``refs`` to one record's message content."""
236
+ if not isinstance(key, str) or not key or not refs:
237
+ return
238
+ _extend_unique(self.by_record_key.setdefault(key, []), refs)
239
+
240
+
241
+ def _extend_unique(target: list[BlobRef], refs: Iterable[BlobRef]) -> None:
242
+ seen = {ref.sha256 for ref in target}
243
+ for ref in refs:
244
+ if ref.sha256 not in seen:
245
+ target.append(ref)
246
+ seen.add(ref.sha256)
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # Claude Code
251
+ # ---------------------------------------------------------------------------
252
+
253
+
254
+ def _anthropic_block_ref(block: Any, collector: BlobCollector) -> BlobRef | None:
255
+ """The ref for an ``image``/``document`` block with a base64 source, else ``None``."""
256
+ if not isinstance(block, dict) or _str(block.get("type")) not in {"image", "document"}:
257
+ return None
258
+ source = block.get("source")
259
+ if not isinstance(source, dict) or source.get("type") != "base64":
260
+ return None
261
+ data = source.get("data")
262
+ if not isinstance(data, str):
263
+ return None
264
+ return collector.add_base64(data, source.get("media_type"))
265
+
266
+
267
+ def _rewrite_anthropic_blocks(blocks: list[Any], collector: BlobCollector) -> list[BlobRef]:
268
+ """Replace every base64 block in ``blocks`` in place; return the refs in order."""
269
+ refs: list[BlobRef] = []
270
+ for index, block in enumerate(blocks):
271
+ ref = _anthropic_block_ref(block, collector)
272
+ if ref is None:
273
+ continue
274
+ blocks[index] = {"type": "text", "text": ref.placeholder()}
275
+ refs.append(ref)
276
+ return refs
277
+
278
+
279
+ def _rewrite_tool_use_result(tool_use_result: Any, collector: BlobCollector) -> list[BlobRef]:
280
+ """Replace ``toolUseResult.file.base64`` (the ``Read`` shape) with a ``blob`` placeholder."""
281
+ if not isinstance(tool_use_result, dict):
282
+ return []
283
+ file_value = tool_use_result.get("file")
284
+ if not isinstance(file_value, dict):
285
+ return []
286
+ data = file_value.get("base64")
287
+ if not isinstance(data, str):
288
+ return []
289
+ media_type = file_value.get("type")
290
+ if not isinstance(media_type, str) and tool_use_result.get("type") == "pdf":
291
+ media_type = "application/pdf"
292
+ ref = collector.add_base64(data, media_type)
293
+ if ref is None:
294
+ return []
295
+ del file_value["base64"]
296
+ file_value["blob"] = ref.placeholder()
297
+ return [ref]
298
+
299
+
300
+ def extract_claude_code_blobs(records: Iterable[Any], collector: BlobCollector) -> BlobIndex:
301
+ """Rewrite every inline attachment in ``records`` IN PLACE; index where each one was.
302
+
303
+ ``records`` are the session's parsed records (every file, main and side),
304
+ the same objects the converter and the audit will read next.
305
+ """
306
+ index = BlobIndex()
307
+ for record in records:
308
+ if not isinstance(record, dict):
309
+ continue
310
+ message = record.get("message")
311
+ content = message.get("content") if isinstance(message, dict) else None
312
+ result_ids: list[str] = []
313
+ if isinstance(content, list):
314
+ own_refs = _rewrite_anthropic_blocks(content, collector)
315
+ index.add_record(record.get("uuid"), own_refs)
316
+ for block in content:
317
+ if not isinstance(block, dict) or block.get("type") != "tool_result":
318
+ continue
319
+ call_id = block.get("tool_use_id")
320
+ if isinstance(call_id, str) and call_id:
321
+ result_ids.append(call_id)
322
+ inner = block.get("content")
323
+ if isinstance(inner, list):
324
+ index.add_tool_result(call_id, _rewrite_anthropic_blocks(inner, collector))
325
+ file_refs = _rewrite_tool_use_result(record.get("toolUseResult"), collector)
326
+ # ``toolUseResult`` describes the record's one tool result. A record
327
+ # carrying several results cannot say which one it belongs to, so its
328
+ # attachment is still replaced but attributed to none of them.
329
+ if file_refs and len(result_ids) == 1:
330
+ index.add_tool_result(result_ids[0], file_refs)
331
+ return index
332
+
333
+
334
+ # ---------------------------------------------------------------------------
335
+ # Codex
336
+ # ---------------------------------------------------------------------------
337
+
338
+
339
+ def codex_tool_image_media_type(value: str) -> str | None:
340
+ """The supported image MIME type for ``value``, as harbor's ``Codex._image_media_type`` maps it."""
341
+ media_type = value.strip().lower()
342
+ if media_type == "image/jpg":
343
+ media_type = "image/jpeg"
344
+ return media_type if media_type in CODEX_TOOL_IMAGE_MEDIA_TYPES else None
345
+
346
+
347
+ def codex_tool_image_ref(data: str, media_type: Any, collector: BlobCollector) -> BlobRef | None:
348
+ """Register one inline Codex tool output image the way harbor 0.24.0 would save it.
349
+
350
+ ``data`` is base64 or a ``data:`` URL (whose media type then wins over
351
+ ``media_type``); harbor saves it only when the media type is one of
352
+ :data:`CODEX_TOOL_IMAGE_MEDIA_TYPES` and the payload, with whitespace
353
+ removed, decodes. ``None`` means harbor would write ``[image omitted]``:
354
+ nothing is registered. The converter's port and the pre-pass both call
355
+ this, so the placeholder text and the stored blob agree byte for byte.
356
+ """
357
+ data_url = _TOOL_IMAGE_DATA_URL.fullmatch(data)
358
+ base64_text = data
359
+ if data_url is not None:
360
+ media_type, base64_text = data_url.group("media_type"), str(data_url.group("data"))
361
+ if not isinstance(media_type, str):
362
+ return None
363
+ valid_media_type = codex_tool_image_media_type(media_type)
364
+ if valid_media_type is None:
365
+ return None
366
+ return collector.add_base64("".join(base64_text.split()), valid_media_type)
367
+
368
+
369
+ def _mcp_image_ref(item: dict[str, Any], collector: BlobCollector) -> BlobRef | None:
370
+ """An MCP ``image`` block's ref, read the way the converter's port reads it."""
371
+ data = item.get("data")
372
+ if not isinstance(data, str) or ("://" in data and not data.startswith("data:")):
373
+ return None
374
+ return codex_tool_image_ref(data, item.get("mimeType") or item.get("mime_type"), collector)
375
+
376
+
377
+ def _tool_input_image_ref(item: dict[str, Any], collector: BlobCollector) -> BlobRef | None:
378
+ """A tool output ``input_image``'s ref by the port's rule (see :func:`codex_tool_image_ref`)."""
379
+ url = item.get("image_url")
380
+ if not isinstance(url, str) or ("://" in url and not url.startswith("data:")):
381
+ return None
382
+ return codex_tool_image_ref(url, None, collector)
383
+
384
+
385
+ def _input_image_ref(item: dict[str, Any], collector: BlobCollector) -> BlobRef | None:
386
+ """An ``input_image`` item's ref when its ``image_url`` is a base64 data URL."""
387
+ url = item.get("image_url")
388
+ if not isinstance(url, str):
389
+ return None
390
+ match = _DATA_URL_RE.match(url)
391
+ if match is None:
392
+ return None
393
+ return collector.add_base64(url[match.end() :], match.group(1))
394
+
395
+
396
+ def _codex_image_ref(item: Any, collector: BlobCollector, *, tool_output: bool) -> BlobRef | None:
397
+ if not isinstance(item, dict):
398
+ return None
399
+ item_type = item.get("type")
400
+ if item_type == "input_image":
401
+ ref = _input_image_ref(item, collector)
402
+ if ref is None and tool_output:
403
+ # The port's rule is wider (data URL parameters, whitespace in
404
+ # the payload); whatever it would name, the collector must hold.
405
+ ref = _tool_input_image_ref(item, collector)
406
+ return ref
407
+ if item_type == "image" and tool_output:
408
+ return _mcp_image_ref(item, collector)
409
+ return None
410
+
411
+
412
+ def _rewrite_codex_items(
413
+ items: list[Any], collector: BlobCollector, *, tool_output: bool = False
414
+ ) -> list[BlobRef]:
415
+ refs: list[BlobRef] = []
416
+ for position, item in enumerate(items):
417
+ ref = _codex_image_ref(item, collector, tool_output=tool_output)
418
+ if ref is None:
419
+ continue
420
+ items[position] = {"type": "input_text", "text": ref.placeholder()}
421
+ refs.append(ref)
422
+ return refs
423
+
424
+
425
+ def _rewrite_nested_mcp_result(
426
+ payload: dict[str, Any], collector: BlobCollector, index: BlobIndex
427
+ ) -> None:
428
+ """Lift the images out of a completed ``McpToolCall`` item's result content.
429
+
430
+ A code-mode script's nested MCP call becomes a tool call of its own
431
+ (:mod:`atif_converter.domain.codex_nested_calls`), keyed by the item's
432
+ ``id``, so its images are indexed under that id, by the tool output rule.
433
+ """
434
+ item = payload.get("item") if payload.get("type") == "item_completed" else None
435
+ if not isinstance(item, dict) or item.get("type") != "McpToolCall":
436
+ return
437
+ result = item.get("result")
438
+ content = result.get("content") if isinstance(result, dict) else None
439
+ if isinstance(content, list):
440
+ index.add_tool_result(
441
+ item.get("id"), _rewrite_codex_items(content, collector, tool_output=True)
442
+ )
443
+
444
+
445
+ def extract_codex_blobs(
446
+ records: Sequence[Any],
447
+ collector: BlobCollector,
448
+ record_keys: Sequence[str] | None = None,
449
+ ) -> BlobIndex:
450
+ """Rewrite every inline image in a rollout's response items.
451
+
452
+ An ``input_image`` data URL is lifted from message content and tool
453
+ outputs; an MCP ``image`` block with inline base64 only from a tool output
454
+ list, by :func:`codex_tool_image_ref`'s rule. The result content of a
455
+ completed ``McpToolCall`` item (a code-mode script's nested MCP call) is a
456
+ tool output list too, indexed by the item's ``id``.
457
+
458
+ ``record_keys`` (parallel to ``records``) names each record the way the
459
+ enrichment pass names it in ``source_uuids``; without it, attachments in
460
+ message content are replaced but indexed nowhere. A tool output's
461
+ attachments are indexed by the output's ``call_id``.
462
+ """
463
+ index = BlobIndex()
464
+ for position, record in enumerate(records):
465
+ if not isinstance(record, dict):
466
+ continue
467
+ payload = record.get("payload")
468
+ if not isinstance(payload, dict):
469
+ continue
470
+ if record.get("type") == "event_msg":
471
+ _rewrite_nested_mcp_result(payload, collector, index)
472
+ continue
473
+ if record.get("type") != "response_item":
474
+ continue
475
+ payload_type = payload.get("type")
476
+ if payload_type == "message" and isinstance(payload.get("content"), list):
477
+ refs = _rewrite_codex_items(payload["content"], collector)
478
+ if record_keys is not None and refs:
479
+ index.add_record(record_keys[position], refs)
480
+ elif _str(payload_type) in {"function_call_output", "custom_tool_call_output"}:
481
+ output = payload.get("output")
482
+ if isinstance(output, list):
483
+ index.add_tool_result(
484
+ payload.get("call_id"),
485
+ _rewrite_codex_items(output, collector, tool_output=True),
486
+ )
487
+ return index
488
+
489
+
490
+ def _str(value: object) -> str | None:
491
+ """``value`` when it is a string, else ``None``: a set-membership key that cannot raise.
492
+
493
+ A transcript field can hold any JSON value, and an object or a list is unhashable, so
494
+ testing one against a set raised ``TypeError`` past the converter (found by the fuzzer).
495
+ A non-string value never matched one of these string sets, so no result changes.
496
+ """
497
+ return value if isinstance(value, str) else None
498
+
499
+
500
+ __all__ = [
501
+ "CODEX_TOOL_IMAGE_MEDIA_TYPES",
502
+ "Blob",
503
+ "BlobCollector",
504
+ "BlobIndex",
505
+ "BlobRef",
506
+ "codex_tool_image_media_type",
507
+ "codex_tool_image_ref",
508
+ "extract_claude_code_blobs",
509
+ "extract_codex_blobs",
510
+ ]