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
atif_cli/output.py ADDED
@@ -0,0 +1,297 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Agent-friendly output formatting for the atif-sql CLI.
4
+
5
+ One rule holds across every command: output goes through :func:`emit_rows`
6
+ (tabular) or :func:`emit_json` (structured), ``--format auto`` resolves to a
7
+ human table on a TTY and machine-readable JSON on a pipe, and classified
8
+ errors land on stderr as one readable line (TTY) or a JSON envelope (pipe)
9
+ with a stable exit code. A command that writes to stdout directly breaks the
10
+ pipe contract an agent depends on.
11
+
12
+ No dataframe library. The tabular surface is ``(columns, rows)`` straight off
13
+ the DuckDB cursor, so the table renderer is a small width-aligned formatter
14
+ and JSON/CSV go through the stdlib. That is what keeps this module importable
15
+ on the lean path — no duckdb, no dataframe stack — which the
16
+ fresh-interpreter lean-import test pins.
17
+
18
+ Unbounded results go through :func:`emit_cursor`, which walks the cursor in
19
+ ``fetchmany`` batches and writes each batch straight to stdout, so the
20
+ client holds one batch of Python tuples instead of every row. DuckDB's
21
+ ``memory_limit`` does not bound that client-side copy at all — it caps the
22
+ engine's own heap.
23
+
24
+ What streaming buys is measured, and it is narrower than "less memory
25
+ always". It wins when a large result comes off a cheap scan: 3,000,000 rows
26
+ out of ``range()`` peak near 18 MB of RSS above baseline streamed, against
27
+ roughly 540 MB under ``fetchall``. It does NOT win on a JSON-parsing view.
28
+ ``SELECT * FROM steps`` over a real corpus (115,265 rows, 149 MB of JSON)
29
+ holds the view's scan resident near 3 GB for the whole write, and paired
30
+ runs put streaming and ``fetchall`` in one overlapping band with no
31
+ consistent ordering between them. Streaming is the default because it is
32
+ the mode whose client-side cost stays flat as a result grows, not because
33
+ it lowers the peak on every query.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import csv
39
+ import json
40
+ import sys
41
+ from enum import StrEnum
42
+ from typing import TYPE_CHECKING, Any
43
+
44
+ if TYPE_CHECKING:
45
+ from collections.abc import Iterable, Iterator, Sequence
46
+
47
+ from atif_cli.errors import ClassifiedError
48
+
49
+ #: Cell truncation width, TABLE format ONLY. JSON and CSV emit the full value:
50
+ #: truncation is a terminal-legibility device, and silently shortening a value
51
+ #: on a machine-readable path would corrupt the data an agent reads back.
52
+ _TABLE_CELL_MAX = 120
53
+
54
+ #: Rows per ``fetchmany`` in :func:`emit_cursor`. Big enough that the
55
+ #: per-batch overhead is noise and the table renderer measures its column
56
+ #: widths over a representative sample; small enough that peak resident rows
57
+ #: stay bounded regardless of result size.
58
+ _STREAM_BATCH_ROWS = 10_000
59
+
60
+
61
+ class OutputFormat(StrEnum):
62
+ """Supported output formats.
63
+
64
+ ``AUTO`` resolves to ``TABLE`` when stdout is a TTY and ``JSON``
65
+ otherwise. A string Enum is what lets cyclopts parse ``--format json``
66
+ with no custom converter. The contract names ``auto|json|csv``; ``table``
67
+ is what ``auto`` resolves to on a TTY, and it is accepted explicitly so a
68
+ caller can force the human grid onto a pipe.
69
+ """
70
+
71
+ AUTO = "auto"
72
+ TABLE = "table"
73
+ JSON = "json"
74
+ CSV = "csv"
75
+
76
+
77
+ def resolve_format(fmt: OutputFormat | str) -> OutputFormat:
78
+ """Resolve ``AUTO`` against the current stdout; no-op for explicit formats."""
79
+ # `OutputFormat` is a `StrEnum`, so a member IS a str and the constructor is
80
+ # the identity on one — no branch is needed to accept either input shape.
81
+ resolved = OutputFormat(fmt)
82
+ if resolved is not OutputFormat.AUTO:
83
+ return resolved
84
+ return OutputFormat.TABLE if sys.stdout.isatty() else OutputFormat.JSON
85
+
86
+
87
+ def _cell(value: Any) -> str:
88
+ """Render one table cell: NULL marker, truncated, newline-flattened."""
89
+ if value is None:
90
+ return "∅"
91
+ text = str(value).replace("\n", "\\n")
92
+ if len(text) > _TABLE_CELL_MAX:
93
+ return text[: _TABLE_CELL_MAX - 1] + "…"
94
+ return text
95
+
96
+
97
+ def json_object_keys(columns: Sequence[str]) -> list[str]:
98
+ """Column names made unique, so a JSON row object keeps every column.
99
+
100
+ A JSON row object is keyed by column name, and SQL column names are not
101
+ unique: any un-aliased self-join
102
+ (``SELECT s.session_id, m.session_id FROM sessions s JOIN messages m
103
+ USING (session_id)``) returns two columns with one name. Keying a dict
104
+ by them keeps only the LAST value and returns fewer columns than were
105
+ asked for, with no error. Repeats get a ``_<n>`` suffix instead, counting
106
+ from 1 in column order and skipping suffixes already taken by an
107
+ explicit alias, so the mapping is a pure function of ``columns``.
108
+ """
109
+ taken = set(columns)
110
+ seen: set[str] = set()
111
+ keys: list[str] = []
112
+ for column in columns:
113
+ if column not in seen:
114
+ seen.add(column)
115
+ keys.append(column)
116
+ continue
117
+ suffix = 1
118
+ while f"{column}_{suffix}" in taken or f"{column}_{suffix}" in seen:
119
+ suffix += 1
120
+ key = f"{column}_{suffix}"
121
+ seen.add(key)
122
+ keys.append(key)
123
+ return keys
124
+
125
+
126
+ def _batched(rows: Sequence[Sequence[Any]], size: int) -> Iterator[Sequence[Sequence[Any]]]:
127
+ """Slice an in-memory row sequence into batches of at most ``size``."""
128
+ for start in range(0, len(rows), size):
129
+ yield rows[start : start + size]
130
+
131
+
132
+ def emit_rows(
133
+ columns: Sequence[str],
134
+ rows: Sequence[Sequence[Any]],
135
+ fmt: OutputFormat | str = OutputFormat.AUTO,
136
+ ) -> None:
137
+ """Write an in-memory query result to stdout in the requested format.
138
+
139
+ Parameters
140
+ ----------
141
+ columns
142
+ Column names, from ``cursor.description``.
143
+ rows
144
+ Row tuples, from ``cursor.fetchall()``.
145
+ fmt
146
+ One of :class:`OutputFormat`; ``AUTO`` resolves per
147
+ :func:`resolve_format`.
148
+
149
+ JSON emits an ARRAY OF ROW OBJECTS, not a columns/rows pair: an agent
150
+ parsing the result should not have to zip two lists to read a field. CSV
151
+ emits a header + rows; TABLE a width-aligned plain-text grid with a
152
+ trailing row count. Results whose size is not known to be small belong in
153
+ :func:`emit_cursor`.
154
+ """
155
+ emit_row_batches(columns, _batched(rows, _STREAM_BATCH_ROWS), fmt)
156
+
157
+
158
+ def emit_cursor(cursor: Any, fmt: OutputFormat | str = OutputFormat.AUTO) -> None:
159
+ """Write a DuckDB cursor's whole result to stdout without materializing it.
160
+
161
+ Reads ``cursor.description`` for the column names, then drains
162
+ ``cursor.fetchmany`` in :data:`_STREAM_BATCH_ROWS` batches through
163
+ :func:`emit_row_batches`. Duck-typed on purpose: this module stays off
164
+ the ``import duckdb`` path.
165
+
166
+ Output is written as batches arrive, so a driver error raised partway
167
+ through the scan surfaces AFTER some rows are already on stdout.
168
+ """
169
+ columns = [d[0] for d in cursor.description or ()]
170
+
171
+ def batches() -> Iterator[Sequence[Sequence[Any]]]:
172
+ while True:
173
+ batch = cursor.fetchmany(_STREAM_BATCH_ROWS)
174
+ if not batch:
175
+ return
176
+ yield batch
177
+
178
+ emit_row_batches(columns, batches(), fmt)
179
+
180
+
181
+ def emit_row_batches(
182
+ columns: Sequence[str],
183
+ batches: Iterable[Sequence[Sequence[Any]]],
184
+ fmt: OutputFormat | str = OutputFormat.AUTO,
185
+ ) -> None:
186
+ """Write successive row batches to stdout in the requested format.
187
+
188
+ Each batch is serialized and written before the next is pulled, so peak
189
+ memory is one batch rather than the whole result. The JSON array
190
+ brackets and commas, the CSV header, and the TABLE header/rule/row-count
191
+ are emitted around the stream.
192
+ """
193
+ resolved = resolve_format(fmt)
194
+ if resolved is OutputFormat.JSON:
195
+ _write_json_stream(columns, batches)
196
+ return
197
+ if resolved is OutputFormat.CSV:
198
+ writer = csv.writer(sys.stdout)
199
+ writer.writerow(columns)
200
+ for batch in batches:
201
+ writer.writerows(batch)
202
+ return
203
+ _write_table_stream(columns, batches)
204
+
205
+
206
+ def _write_json_stream(
207
+ columns: Sequence[str],
208
+ batches: Iterable[Sequence[Sequence[Any]]],
209
+ ) -> None:
210
+ """Emit a JSON array of row objects, one batch of elements at a time."""
211
+ keys = json_object_keys(columns)
212
+ sys.stdout.write("[")
213
+ first = True
214
+ for batch in batches:
215
+ for row in batch:
216
+ if not first:
217
+ sys.stdout.write(", ")
218
+ first = False
219
+ sys.stdout.write(json.dumps(dict(zip(keys, row, strict=True)), default=str))
220
+ sys.stdout.write("]\n")
221
+
222
+
223
+ def _write_table_stream(
224
+ columns: Sequence[str],
225
+ batches: Iterable[Sequence[Sequence[Any]]],
226
+ ) -> None:
227
+ """Emit a width-aligned plain-text grid, sizing columns off the first batch.
228
+
229
+ Column widths are fixed by the header plus the first batch, which is the
230
+ widest window available without holding the whole result. A later batch
231
+ with a wider cell overflows its column rather than being truncated —
232
+ misalignment is preferable to hiding a value.
233
+ """
234
+ stream = iter(batches)
235
+ head = [[_cell(v) for v in row] for row in next(stream, ())]
236
+ widths = [
237
+ max(len(str(col)), *(len(r[i]) for r in head)) if head else len(str(col))
238
+ for i, col in enumerate(columns)
239
+ ]
240
+
241
+ def line(cells: Sequence[str]) -> str:
242
+ return " | ".join(cell.ljust(w) for cell, w in zip(cells, widths, strict=True))
243
+
244
+ sys.stdout.write(line([str(col) for col in columns]) + "\n")
245
+ sys.stdout.write("-+-".join("-" * w for w in widths) + "\n")
246
+ total = 0
247
+ for row in head:
248
+ sys.stdout.write(line(row) + "\n")
249
+ total += 1
250
+ for batch in stream:
251
+ for raw_row in batch:
252
+ sys.stdout.write(line([_cell(v) for v in raw_row]) + "\n")
253
+ total += 1
254
+ sys.stdout.write(f"({total} row{'s' if total != 1 else ''})\n")
255
+
256
+
257
+ def emit_json(payload: Any, fmt: OutputFormat | str = OutputFormat.AUTO) -> None:
258
+ """Write a non-tabular payload as pretty JSON (schema, status, reports).
259
+
260
+ Every format reduces to one JSON document here, because CSV over a nested
261
+ dict has no meaning. ``fmt`` is accepted for call-site symmetry with
262
+ :func:`emit_rows` and is never branched on.
263
+ Callers that want a bespoke human layout handle the TABLE branch
264
+ themselves before calling this.
265
+ """
266
+ del fmt
267
+ sys.stdout.write(json.dumps(payload, indent=2, default=str))
268
+ sys.stdout.write("\n")
269
+
270
+
271
+ def emit_error(err: ClassifiedError, fmt: OutputFormat | str = OutputFormat.AUTO) -> None:
272
+ """Write a classified error to stderr in the requested format.
273
+
274
+ Agents running ``--format json`` (or piping) get the structured
275
+ envelope; humans on a TTY get a readable line plus the hint. The caller
276
+ exits with :attr:`ClassifiedError.exit_code`.
277
+ """
278
+ resolved = resolve_format(fmt)
279
+ if resolved is OutputFormat.TABLE:
280
+ sys.stderr.write(f"[{err.kind}] {err.message}\n")
281
+ if err.hint:
282
+ sys.stderr.write(f"hint: {err.hint}\n")
283
+ else:
284
+ sys.stderr.write(json.dumps(err.to_payload(), default=str))
285
+ sys.stderr.write("\n")
286
+
287
+
288
+ __all__ = [
289
+ "OutputFormat",
290
+ "emit_cursor",
291
+ "emit_error",
292
+ "emit_json",
293
+ "emit_row_batches",
294
+ "emit_rows",
295
+ "json_object_keys",
296
+ "resolve_format",
297
+ ]
atif_cli/py.typed ADDED
File without changes
@@ -0,0 +1,3 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """atif-converter: Claude Code -> ATIF conversion with an explicit fidelity policy."""
@@ -0,0 +1,3 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Application layer: use cases composing census + harbor adapter."""
@@ -0,0 +1,294 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Use case: convert one session AND account for what the conversion lost.
4
+
5
+ Composes the raw-side census (:mod:`atif_converter.infrastructure.census`)
6
+ with the harbor adapter
7
+ (:mod:`atif_converter.infrastructure.harbor_adapter`) into a
8
+ ``(ConversionResult, LossReport)`` pair, the honest answer to "what did we
9
+ just materialize, and what did upstream drop on the floor?".
10
+
11
+ ONE READ. The session's files are read once, by
12
+ :func:`~atif_converter.infrastructure.harbor_adapter.read_session`, which
13
+ fingerprints and parses each file in the same pass. The converter, the census,
14
+ the edges emitter and the enrichment pass all consume that one list of
15
+ records, so the trajectory, the loss report and ``edges.jsonl`` describe the
16
+ same bytes by construction. Before this, the converter and the audit each
17
+ parsed the files and the snapshot hashed them three times over.
18
+
19
+ SNAPSHOT DISCIPLINE: the fingerprints taken by that read are re-checked once
20
+ the artifacts are built. A Claude Code session that resumes writing during
21
+ the read, or anywhere before the check, fails the session: a stat pair alone
22
+ would miss a same-length rewrite inside one mtime tick, so the re-check hashes
23
+ the bytes again. That second hash pass is the one read the audit still pays
24
+ beyond the parse.
25
+
26
+ ATTACHMENTS AND TYPED SIGNALS. Before conversion, every inline base64
27
+ attachment in the loaded records is replaced by a placeholder and its bytes
28
+ collected (:mod:`atif_converter.domain.blobs`), so neither the trajectory nor
29
+ the loss report nor the edges ever carry base64. After enrichment,
30
+ :mod:`atif_converter.domain.result_signals` writes the typed tool-outcome,
31
+ attachment and subagent fields, the subagent links read from the same pass's
32
+ ``agent-*.meta.json`` sidecars.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from pathlib import Path
38
+ from typing import Any
39
+
40
+ from loguru import logger
41
+
42
+ from atif_converter.domain.blobs import BlobCollector, extract_claude_code_blobs
43
+ from atif_converter.domain.edges import edges_jsonl_lines
44
+ from atif_converter.domain.enrichment import enrich_trajectory
45
+ from atif_converter.domain.errors import SourceMutatedDuringConversion
46
+ from atif_converter.domain.fidelity import (
47
+ CONVERTIBLE_RECORD_TYPES,
48
+ AnyRecordType,
49
+ FidelityGap,
50
+ LossReport,
51
+ )
52
+ from atif_converter.domain.result_signals import annotate_claude_code_trajectory
53
+ from atif_converter.domain.session_events import (
54
+ claude_reported_cost,
55
+ claude_session_events,
56
+ session_events_jsonl_lines,
57
+ )
58
+ from atif_converter.infrastructure.census import SessionCensus, census_from_snapshot
59
+ from atif_converter.infrastructure.harbor_adapter import (
60
+ ConversionResult,
61
+ convert_loaded_session,
62
+ read_session,
63
+ validate_trajectory,
64
+ )
65
+ from atif_converter.infrastructure.raw_records import (
66
+ LoadedSession,
67
+ SessionSnapshot,
68
+ mutated_files,
69
+ )
70
+ from atif_converter.infrastructure.source_archive import SourceArchiveWriter
71
+
72
+ #: ``agent-<id>.meta.json``: the sidecar's name carries the subagent id.
73
+ _SIDECAR_PREFIX = "agent-"
74
+ _SIDECAR_SUFFIX = ".meta.json"
75
+
76
+ #: Gaps every conversion exhibits regardless of session content. The parent
77
+ #: chain is flattened into a timestamp-sorted step list, and nothing after
78
+ #: conversion rebuilds it inside the trajectory (``edges.jsonl`` keeps it).
79
+ _STRUCTURAL_GAPS: frozenset[FidelityGap] = frozenset({FidelityGap.PARENT_CHAIN_FLATTENED})
80
+
81
+ _ENRICHMENT_REFUSAL_KEYS: tuple[str, ...] = (
82
+ "enrichment_unattributed_steps",
83
+ "enrichment_truncated_at_step",
84
+ )
85
+
86
+
87
+ def _attributed_uuids(trajectory: dict[str, Any]) -> set[str]:
88
+ uuids: set[str] = set()
89
+ for step in trajectory.get("steps") or []:
90
+ extra = step.get("extra") if isinstance(step, dict) else None
91
+ source_uuids = extra.get("source_uuids") if isinstance(extra, dict) else None
92
+ if isinstance(source_uuids, list):
93
+ uuids.update(uuid for uuid in source_uuids if isinstance(uuid, str))
94
+ return uuids
95
+
96
+
97
+ def _has_cache_creation_usage(records: list[dict[str, Any]]) -> bool:
98
+ for record in records:
99
+ if record.get("type") != "assistant":
100
+ continue
101
+ message = record.get("message")
102
+ usage = message.get("usage") if isinstance(message, dict) else None
103
+ if isinstance(usage, dict) and isinstance(usage.get("cache_creation_input_tokens"), int):
104
+ return True
105
+ return False
106
+
107
+
108
+ def _unrepaired_gaps(enriched: dict[str, Any], records: list[dict[str, Any]]) -> set[FidelityGap]:
109
+ """The three gaps enrichment repairs, reported only where it DIDN'T.
110
+
111
+ * ``uuid_not_preserved``: enrichment refused to attribute some step
112
+ (an unattributed-steps count or a truncation marker on the trajectory).
113
+ * ``compact_summary_unhandled``: a compaction-summary record exists and
114
+ its uuid landed in no step's ``source_uuids``, so no step carries the
115
+ ``is_compact_summary`` flag for it.
116
+ * ``cache_split_partial``: assistant usage carries
117
+ ``cache_creation_input_tokens`` but the trajectory has no
118
+ ``cache_creation_total``.
119
+ """
120
+ gaps: set[FidelityGap] = set()
121
+ extra = enriched.get("extra")
122
+ extra = extra if isinstance(extra, dict) else {}
123
+ if any(extra.get(key) is not None for key in _ENRICHMENT_REFUSAL_KEYS):
124
+ gaps.add(FidelityGap.UUID_NOT_PRESERVED)
125
+ compact_uuids = {
126
+ record["uuid"]
127
+ for record in records
128
+ if record.get("isCompactSummary") and isinstance(record.get("uuid"), str)
129
+ }
130
+ if compact_uuids and not compact_uuids <= _attributed_uuids(enriched):
131
+ gaps.add(FidelityGap.COMPACT_SUMMARY_UNHANDLED)
132
+ if _has_cache_creation_usage(records) and "cache_creation_total" not in extra:
133
+ gaps.add(FidelityGap.CACHE_SPLIT_PARTIAL)
134
+ return gaps
135
+
136
+
137
+ def _loss_report(
138
+ census: SessionCensus,
139
+ *,
140
+ captured: int,
141
+ unrepaired: set[FidelityGap],
142
+ ) -> LossReport:
143
+ converted = sum(
144
+ count
145
+ for record_type, count in census.record_counts.items()
146
+ if record_type in CONVERTIBLE_RECORD_TYPES
147
+ )
148
+ total = sum(census.record_counts.values())
149
+ dropped = total - converted - captured
150
+
151
+ gaps = set(_STRUCTURAL_GAPS) | unrepaired
152
+ if dropped:
153
+ gaps.add(FidelityGap.NON_MESSAGE_RECORDS_DROPPED)
154
+ if census.subagent_files:
155
+ gaps.add(FidelityGap.SUBAGENTS_INLINED)
156
+ # Named rather than inlined: LossReport is shared by both agents, so its
157
+ # key type is the UNION of the two record taxonomies and a same-typed dict
158
+ # of one agent's members does not match the constructor overload without
159
+ # being widened here.
160
+ record_counts: dict[AnyRecordType, int] = {**census.record_counts}
161
+ return LossReport(
162
+ record_counts=record_counts,
163
+ records_converted=converted,
164
+ records_captured=captured,
165
+ records_dropped=dropped,
166
+ gaps_observed=frozenset(gaps),
167
+ subagent_files_found=len(census.subagent_files) + len(census.workflow_subagent_files),
168
+ # Our converter discovers every side file itself, workflow-nested ones
169
+ # included, so all of them are convertible.
170
+ subagent_files_convertible=len(census.subagent_files) + len(census.workflow_subagent_files),
171
+ workflow_subagent_files_found=len(census.workflow_subagent_files),
172
+ )
173
+
174
+
175
+ def _stamp_reported_cost(enriched: dict[str, Any], reported: dict[str, Any]) -> None:
176
+ """Put Claude Code's own cost figure beside the computed estimate."""
177
+ if not reported:
178
+ return
179
+ final_metrics = enriched.setdefault("final_metrics", {})
180
+ extra = final_metrics.get("extra")
181
+ if not isinstance(extra, dict):
182
+ extra = {}
183
+ final_metrics["extra"] = extra
184
+ extra.update(reported)
185
+
186
+
187
+ def _sidecars_by_agent_id(loaded: LoadedSession) -> dict[str, Any]:
188
+ """Parsed ``agent-<id>.meta.json`` sidecars keyed by ``<id>``; first path wins."""
189
+ by_agent: dict[str, Any] = {}
190
+ for path, value in loaded.sidecars.items():
191
+ name = path.name
192
+ if name.startswith(_SIDECAR_PREFIX) and name.endswith(_SIDECAR_SUFFIX):
193
+ agent_id = name[len(_SIDECAR_PREFIX) : -len(_SIDECAR_SUFFIX)]
194
+ if agent_id:
195
+ by_agent.setdefault(agent_id, value)
196
+ return by_agent
197
+
198
+
199
+ def _refuse_if_mutated(
200
+ snapshot: SessionSnapshot, archive: SourceArchiveWriter | None = None
201
+ ) -> None:
202
+ mutated = mutated_files(snapshot, archive=archive)
203
+ if not mutated:
204
+ if archive is not None:
205
+ # The verifying read already archived the transcripts AND the
206
+ # parsed sidecars; the sweep takes only files nothing parsed.
207
+ archive.archive_side_files(
208
+ snapshot.session_jsonl,
209
+ already=(*snapshot.files, *snapshot.sidecar_fingerprints),
210
+ )
211
+ return
212
+ logger.warning(
213
+ "convert_and_audit: {} source file(s) changed while converting {}; refusing",
214
+ len(mutated),
215
+ snapshot.session_jsonl,
216
+ )
217
+ raise SourceMutatedDuringConversion(snapshot.session_jsonl, mutated)
218
+
219
+
220
+ def convert_and_audit(
221
+ session_jsonl: Path,
222
+ *,
223
+ include_subagents: bool = True,
224
+ archive: SourceArchiveWriter | None = None,
225
+ ) -> tuple[ConversionResult, LossReport]:
226
+ """Convert ``session_jsonl`` to ATIF and produce its loss accounting.
227
+
228
+ The returned :class:`ConversionResult` carries the ENRICHED trajectory
229
+ (``source_uuids`` / ``is_compact_summary`` on steps,
230
+ ``cache_creation_total`` on the trajectory, see
231
+ :func:`~atif_converter.domain.enrichment.enrich_trajectory`) plus the
232
+ ready-to-write ``edges.jsonl`` lines derived from the RAW records, per
233
+ the corpus contract. Validation is re-run post-enrichment.
234
+
235
+ The census counts EVERY raw record, so ``LossReport.records_dropped``
236
+ is an upper bound on what the materialized trajectory is missing.
237
+
238
+ Note: ``records_converted`` counts raw user/assistant RECORDS, not ATIF
239
+ steps. harbor legitimately bundles several assistant events (one
240
+ ``message.id``) into a single agent step, so the two numbers differ by
241
+ design.
242
+
243
+ With ``archive``, the verifying re-read also streams every source file
244
+ into that writer (see
245
+ :mod:`atif_converter.infrastructure.source_archive`): the archive costs
246
+ no extra open of a transcript and holds exactly the bytes that were
247
+ parsed. It is complete only when this function returns; on any raise the
248
+ caller must discard it.
249
+
250
+ Raises
251
+ ------
252
+ InvalidSessionInput: ``session_jsonl`` is not an existing ``.jsonl`` file.
253
+ SourceMutatedDuringConversion: a source file changed anywhere between
254
+ the read and the end of the audit, so the artifacts would not
255
+ describe the session as it stands.
256
+ """
257
+ loaded = read_session(session_jsonl)
258
+ snapshot = loaded.snapshot
259
+ collector = BlobCollector()
260
+ blob_index = extract_claude_code_blobs(
261
+ (record for records in loaded.records_by_file.values() for record in records),
262
+ collector,
263
+ )
264
+ result = convert_loaded_session(loaded, include_subagents=include_subagents)
265
+
266
+ pairs = loaded.record_pairs()
267
+ census = census_from_snapshot(snapshot, pairs)
268
+ edges_lines = tuple(edges_jsonl_lines(pairs))
269
+ events = claude_session_events(pairs)
270
+ events_lines = tuple(session_events_jsonl_lines(events))
271
+ records = [record for record, _src in pairs]
272
+ # The harbor trajectory has no other consumer, so enrich in place rather
273
+ # than deep-copying a whole large transcript's worth of steps.
274
+ enriched = enrich_trajectory(result.trajectory, records, copy_input=False)
275
+ annotate_claude_code_trajectory(
276
+ enriched, records, blob_index, sidecars=_sidecars_by_agent_id(loaded)
277
+ )
278
+ _stamp_reported_cost(enriched, claude_reported_cost(pairs))
279
+ report = _loss_report(
280
+ census,
281
+ captured=len(events),
282
+ unrepaired=_unrepaired_gaps(enriched, records),
283
+ )
284
+ del pairs, loaded, records, events
285
+ _refuse_if_mutated(snapshot, archive)
286
+
287
+ enriched_result = ConversionResult(
288
+ trajectory=enriched,
289
+ validation_errors=validate_trajectory(enriched),
290
+ edges_lines=edges_lines,
291
+ events_lines=events_lines,
292
+ blobs=collector.blobs,
293
+ )
294
+ return enriched_result, report