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.
- atif_analytics/__init__.py +9 -0
- atif_analytics/application/__init__.py +3 -0
- atif_analytics/application/analyze.py +158 -0
- atif_analytics/application/prompts.py +750 -0
- atif_analytics/application/use_cases/__init__.py +3 -0
- atif_analytics/application/use_cases/_shared.py +264 -0
- atif_analytics/application/use_cases/classify.py +430 -0
- atif_analytics/application/use_cases/conflicts.py +448 -0
- atif_analytics/application/use_cases/friction.py +634 -0
- atif_analytics/application/use_cases/perceived.py +451 -0
- atif_analytics/domain/__init__.py +3 -0
- atif_analytics/domain/authorship.py +166 -0
- atif_analytics/domain/config.py +30 -0
- atif_analytics/domain/costs.py +50 -0
- atif_analytics/domain/friction.py +117 -0
- atif_analytics/domain/layout.py +94 -0
- atif_analytics/domain/models.py +385 -0
- atif_analytics/domain/ports.py +100 -0
- atif_analytics/domain/transcript.py +359 -0
- atif_analytics/infrastructure/__init__.py +3 -0
- atif_analytics/infrastructure/corpus_reader.py +546 -0
- atif_analytics/infrastructure/parquet_cache.py +186 -0
- atif_analytics/infrastructure/settings.py +114 -0
- atif_analytics/infrastructure/sqlite_state/__init__.py +3 -0
- atif_analytics/infrastructure/sqlite_state/checkpointer.py +326 -0
- atif_analytics/infrastructure/sqlite_state/retry_queue.py +296 -0
- atif_analytics/py.typed +0 -0
- atif_cli/__init__.py +3 -0
- atif_cli/__main__.py +8 -0
- atif_cli/app.py +2459 -0
- atif_cli/converter_adapter.py +151 -0
- atif_cli/corpus.py +409 -0
- atif_cli/cron.py +311 -0
- atif_cli/duck_errors.py +97 -0
- atif_cli/embed_lake.py +101 -0
- atif_cli/errors.py +89 -0
- atif_cli/lake.py +373 -0
- atif_cli/lake_sessions.py +270 -0
- atif_cli/output.py +297 -0
- atif_cli/py.typed +0 -0
- atif_converter/__init__.py +3 -0
- atif_converter/application/__init__.py +3 -0
- atif_converter/application/convert_and_audit.py +294 -0
- atif_converter/application/convert_codex.py +210 -0
- atif_converter/domain/__init__.py +9 -0
- atif_converter/domain/agents.py +40 -0
- atif_converter/domain/atif/__init__.py +55 -0
- atif_converter/domain/atif/agent.py +37 -0
- atif_converter/domain/atif/content.py +161 -0
- atif_converter/domain/atif/final_metrics.py +45 -0
- atif_converter/domain/atif/metrics.py +49 -0
- atif_converter/domain/atif/observation.py +21 -0
- atif_converter/domain/atif/observation_result.py +47 -0
- atif_converter/domain/atif/step.py +144 -0
- atif_converter/domain/atif/subagent_trajectory_ref.py +90 -0
- atif_converter/domain/atif/tool_call.py +36 -0
- atif_converter/domain/atif/trajectory.py +211 -0
- atif_converter/domain/atif/trajectory_validator.py +299 -0
- atif_converter/domain/blobs.py +510 -0
- atif_converter/domain/claude_code_conversion.py +1077 -0
- atif_converter/domain/codex_conversion.py +1036 -0
- atif_converter/domain/codex_edges.py +192 -0
- atif_converter/domain/codex_enrichment.py +419 -0
- atif_converter/domain/codex_fidelity.py +124 -0
- atif_converter/domain/codex_nested_calls.py +165 -0
- atif_converter/domain/edges.py +113 -0
- atif_converter/domain/enrichment.py +412 -0
- atif_converter/domain/errors.py +60 -0
- atif_converter/domain/fidelity.py +171 -0
- atif_converter/domain/model_prices.json +8538 -0
- atif_converter/domain/pricing.py +790 -0
- atif_converter/domain/result_signals.py +774 -0
- atif_converter/domain/schema_version.py +90 -0
- atif_converter/domain/session_events.py +408 -0
- atif_converter/infrastructure/__init__.py +3 -0
- atif_converter/infrastructure/census.py +89 -0
- atif_converter/infrastructure/claude_code_converter.py +127 -0
- atif_converter/infrastructure/codex_adapter.py +132 -0
- atif_converter/infrastructure/codex_census.py +100 -0
- atif_converter/infrastructure/codex_converter.py +82 -0
- atif_converter/infrastructure/harbor_adapter.py +187 -0
- atif_converter/infrastructure/raw_records.py +426 -0
- atif_converter/infrastructure/source_archive.py +154 -0
- atif_converter/py.typed +0 -0
- atif_corpus/__init__.py +13 -0
- atif_corpus/application/__init__.py +7 -0
- atif_corpus/application/materialize.py +1826 -0
- atif_corpus/domain/__init__.py +9 -0
- atif_corpus/domain/agents.py +33 -0
- atif_corpus/domain/generation.py +34 -0
- atif_corpus/domain/layout.py +168 -0
- atif_corpus/domain/ports.py +175 -0
- atif_corpus/domain/session_id.py +68 -0
- atif_corpus/domain/sessions.py +228 -0
- atif_corpus/domain/slug.py +72 -0
- atif_corpus/domain/source_layout.py +143 -0
- atif_corpus/domain/watermark.py +76 -0
- atif_corpus/infrastructure/__init__.py +7 -0
- atif_corpus/infrastructure/atomic.py +266 -0
- atif_corpus/infrastructure/compress_artifacts.py +161 -0
- atif_corpus/infrastructure/fake_converter.py +174 -0
- atif_corpus/infrastructure/scanner.py +316 -0
- atif_corpus/infrastructure/settings.py +130 -0
- atif_corpus/infrastructure/source_archive.py +202 -0
- atif_corpus/py.typed +0 -0
- atif_duck/__init__.py +43 -0
- atif_duck/domain/__init__.py +32 -0
- atif_duck/domain/artifacts.py +66 -0
- atif_duck/domain/authorship.py +165 -0
- atif_duck/domain/catalog.py +626 -0
- atif_duck/domain/columnar.py +149 -0
- atif_duck/domain/embedding_guard.py +88 -0
- atif_duck/domain/examples.py +265 -0
- atif_duck/domain/lake.py +427 -0
- atif_duck/domain/lake_sessions.py +97 -0
- atif_duck/domain/raw_readers.py +140 -0
- atif_duck/domain/session_id.py +69 -0
- atif_duck/domain/sql_literal.py +50 -0
- atif_duck/infrastructure/__init__.py +34 -0
- atif_duck/infrastructure/analytics.py +469 -0
- atif_duck/infrastructure/authorship.py +139 -0
- atif_duck/infrastructure/columnar.py +641 -0
- atif_duck/infrastructure/lake.py +1383 -0
- atif_duck/infrastructure/lake_sessions.py +194 -0
- atif_duck/infrastructure/lake_settings.py +63 -0
- atif_duck/infrastructure/lake_steps.py +225 -0
- atif_duck/infrastructure/projections.py +221 -0
- atif_duck/infrastructure/registry.py +2194 -0
- atif_duck/infrastructure/stored_artifacts.py +115 -0
- atif_duck/py.typed +0 -0
- atif_embed/__init__.py +12 -0
- atif_embed/application/__init__.py +3 -0
- atif_embed/application/embed.py +293 -0
- atif_embed/application/prune.py +80 -0
- atif_embed/domain/__init__.py +3 -0
- atif_embed/domain/discovery.py +70 -0
- atif_embed/domain/embedding_guard.py +83 -0
- atif_embed/domain/errors.py +94 -0
- atif_embed/domain/ports.py +223 -0
- atif_embed/domain/sql_literal.py +49 -0
- atif_embed/domain/text_stamp.py +71 -0
- atif_embed/infrastructure/__init__.py +3 -0
- atif_embed/infrastructure/cohere_bedrock.py +433 -0
- atif_embed/infrastructure/corpus_text_rows.py +273 -0
- atif_embed/infrastructure/gemma_local.py +355 -0
- atif_embed/infrastructure/lake_text_rows.py +203 -0
- atif_embed/infrastructure/lance_store.py +440 -0
- atif_embed/infrastructure/providers.py +30 -0
- atif_embed/infrastructure/settings.py +154 -0
- atif_embed/py.typed +0 -0
- atif_models/__init__.py +14 -0
- atif_models/domain/__init__.py +9 -0
- atif_models/domain/ports.py +143 -0
- atif_models/domain/registry.py +151 -0
- atif_models/domain/schema.py +109 -0
- atif_models/infrastructure/__init__.py +8 -0
- atif_models/infrastructure/openai_bedrock.py +314 -0
- atif_models/infrastructure/settings.py +85 -0
- atif_models/py.typed +0 -0
- atif_sql-0.2.0.dist-info/METADATA +272 -0
- atif_sql-0.2.0.dist-info/RECORD +164 -0
- atif_sql-0.2.0.dist-info/WHEEL +4 -0
- atif_sql-0.2.0.dist-info/entry_points.txt +2 -0
- atif_sql-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Read the raw records of a session, main JSONL plus every side-file, in one pass.
|
|
4
|
+
|
|
5
|
+
The single raw-record reader the converter, the census, the edges emitter and
|
|
6
|
+
the enrichment pass share. Discovery mirrors the contract: rglob over the
|
|
7
|
+
session's sibling dir filtered to ``*.jsonl`` (``*.meta.json`` excluded by the
|
|
8
|
+
suffix filter), which includes workflow-nested
|
|
9
|
+
``subagents/workflows/wf_*/agent-*.jsonl``. File reads only: no harbor, no
|
|
10
|
+
env, no network.
|
|
11
|
+
|
|
12
|
+
ONE READ, ONE PARSE. :func:`load_session` opens each discovered file once and
|
|
13
|
+
streams its bytes through a digest on the way to the JSON parser, so the
|
|
14
|
+
fingerprint it records is the fingerprint of exactly the bytes that were
|
|
15
|
+
parsed. The parsed records are handed to the converter AND to the audit
|
|
16
|
+
(census, edges, enrichment), so the three artifacts cannot describe different
|
|
17
|
+
bytes: they come from one list. Before this pass existed the converter and the
|
|
18
|
+
audit each parsed the files and the snapshot hashed them separately, three
|
|
19
|
+
hash passes and two parses of a 135 MB session.
|
|
20
|
+
|
|
21
|
+
SIDECARS. Claude Code writes one ``agent-<id>.meta.json`` beside each subagent
|
|
22
|
+
transcript, recording the subagent's type, its description and the
|
|
23
|
+
``toolUseId`` of the Task/Agent call that spawned it. They are not JSONL and
|
|
24
|
+
carry no records, so they stay out of :attr:`LoadedSession.records_by_file`
|
|
25
|
+
(the census and edges count records, and a sidecar is not one). The same pass
|
|
26
|
+
reads each one once, fingerprints it, and parses it into
|
|
27
|
+
:attr:`LoadedSession.sidecars`, and the snapshot re-check covers them too, so a
|
|
28
|
+
sidecar rewritten mid-conversion refuses the session exactly as a transcript
|
|
29
|
+
would.
|
|
30
|
+
|
|
31
|
+
A :class:`SessionSnapshot` names a fixed set of source files and carries the
|
|
32
|
+
change evidence for each, an mtime/size/content-digest fingerprint, and
|
|
33
|
+
NOTHING else. Claude Code appends to a live session at any moment, so the
|
|
34
|
+
snapshot is re-checked with :func:`mutated_files` once the artifacts are
|
|
35
|
+
built; an unchanged fingerprint set is the evidence that the session held
|
|
36
|
+
still for the whole window, including while it was being read. The digest is
|
|
37
|
+
what catches a same-length rewrite inside one mtime tick, so the re-check
|
|
38
|
+
hashes the bytes again rather than trusting the stat pair.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import hashlib
|
|
44
|
+
import io
|
|
45
|
+
import json
|
|
46
|
+
from dataclasses import dataclass, field
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from typing import TYPE_CHECKING, Any, NamedTuple, Protocol, override
|
|
49
|
+
|
|
50
|
+
from loguru import logger
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
from collections.abc import Buffer, Callable, Iterable
|
|
54
|
+
|
|
55
|
+
from atif_converter.infrastructure.source_archive import SourceArchiveWriter
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def discover_session_files(session_jsonl: Path) -> list[Path]:
|
|
59
|
+
"""The main session JSONL plus every ``*.jsonl`` under its sibling dir.
|
|
60
|
+
|
|
61
|
+
Deterministic order: main file first, then sorted side-files.
|
|
62
|
+
"""
|
|
63
|
+
files = [session_jsonl]
|
|
64
|
+
side_dir = session_jsonl.parent / session_jsonl.stem
|
|
65
|
+
if side_dir.is_dir():
|
|
66
|
+
files.extend(sorted(side_dir.rglob("*.jsonl")))
|
|
67
|
+
return files
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def discover_sidecar_files(session_jsonl: Path) -> list[Path]:
|
|
71
|
+
"""Every subagent ``agent-*.meta.json`` under the session's sibling dir, sorted.
|
|
72
|
+
|
|
73
|
+
Workflow-nested sidecars (``subagents/workflows/wf_*/agent-*.meta.json``)
|
|
74
|
+
included, as the transcripts they describe are.
|
|
75
|
+
"""
|
|
76
|
+
side_dir = session_jsonl.parent / session_jsonl.stem
|
|
77
|
+
if not side_dir.is_dir():
|
|
78
|
+
return []
|
|
79
|
+
return sorted(side_dir.rglob("agent-*.meta.json"))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class FileFingerprint(NamedTuple):
|
|
83
|
+
"""Change evidence for one source file: mtime, size, and content digest.
|
|
84
|
+
|
|
85
|
+
Each field covers a case the others miss.
|
|
86
|
+
|
|
87
|
+
Size catches the append that mtime misses: two appends inside one
|
|
88
|
+
filesystem timestamp tick share an ``st_mtime_ns``, and a session resuming
|
|
89
|
+
mid-conversion is exactly that fast, but an append always changes size.
|
|
90
|
+
|
|
91
|
+
mtime catches most in-place edits that leave the length alone.
|
|
92
|
+
|
|
93
|
+
The digest catches what neither does: a same-length rewrite landing inside
|
|
94
|
+
one mtime tick. That is not hypothetical here: on the xfs volume this runs
|
|
95
|
+
on, 45 consecutive same-size rewrites were absorbed into a single
|
|
96
|
+
``st_mtime_ns``, so ``{"uuid": "aaa"}`` becoming ``{"uuid": "bbb"}`` is
|
|
97
|
+
invisible to the stat pair. Different bytes behind an identical stat is
|
|
98
|
+
precisely the state this snapshot exists to refuse, so the bytes are read.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
mtime_ns: int
|
|
102
|
+
size: int
|
|
103
|
+
content_hash: bytes
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class _Digest(Protocol):
|
|
107
|
+
"""The two methods of a ``hashlib`` object this module uses."""
|
|
108
|
+
|
|
109
|
+
def update(self, data: Buffer, /) -> None: ...
|
|
110
|
+
|
|
111
|
+
def digest(self) -> bytes: ...
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _new_digest() -> _Digest:
|
|
115
|
+
"""A fresh content digest.
|
|
116
|
+
|
|
117
|
+
sha256 rather than blake2b: the digest is never persisted or compared
|
|
118
|
+
across hosts, so only its speed matters, and sha256 has hardware
|
|
119
|
+
instructions on current x86 (SHA-NI) and arm64 (SHA2) while blake2b does
|
|
120
|
+
not. Measured on the 76 MB benchmark session: 1.6 GB/s against 0.9 GB/s.
|
|
121
|
+
"""
|
|
122
|
+
return hashlib.sha256()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
#: Digest chunk size; the transcripts are streamed rather than read whole.
|
|
126
|
+
_HASH_CHUNK_BYTES = 1024 * 1024
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _DigestingReader(io.RawIOBase):
|
|
130
|
+
"""A raw byte stream that folds every byte it hands out into a digest.
|
|
131
|
+
|
|
132
|
+
Sits between the file and the text decoder so the one read that feeds the
|
|
133
|
+
JSON parser also produces the content fingerprint. The digest is complete
|
|
134
|
+
once the reader has been drained, which iterating the wrapping text handle
|
|
135
|
+
to its end guarantees.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(self, raw: io.RawIOBase, digest: _Digest) -> None:
|
|
139
|
+
super().__init__()
|
|
140
|
+
self._raw = raw
|
|
141
|
+
self._digest = digest
|
|
142
|
+
|
|
143
|
+
@override
|
|
144
|
+
def readable(self) -> bool:
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
@override
|
|
148
|
+
def readinto(self, buffer: Buffer, /) -> int | None:
|
|
149
|
+
count = self._raw.readinto(buffer)
|
|
150
|
+
if count is None:
|
|
151
|
+
return None
|
|
152
|
+
if count:
|
|
153
|
+
self._digest.update(memoryview(buffer)[:count])
|
|
154
|
+
return int(count)
|
|
155
|
+
|
|
156
|
+
@override
|
|
157
|
+
def close(self) -> None:
|
|
158
|
+
self._raw.close()
|
|
159
|
+
super().close()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _content_hash(path: Path, tee: Callable[[bytes], object] | None = None) -> bytes:
|
|
163
|
+
"""Digest of ``path``'s bytes; each chunk also goes to ``tee`` when one is given."""
|
|
164
|
+
digest = _new_digest()
|
|
165
|
+
with path.open("rb") as handle:
|
|
166
|
+
for chunk in iter(lambda: handle.read(_HASH_CHUNK_BYTES), b""):
|
|
167
|
+
digest.update(chunk)
|
|
168
|
+
if tee is not None:
|
|
169
|
+
tee(chunk)
|
|
170
|
+
return digest.digest()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _fingerprint(path: Path, tee: Callable[[bytes], object] | None = None) -> FileFingerprint:
|
|
174
|
+
stat = path.stat()
|
|
175
|
+
return FileFingerprint(
|
|
176
|
+
mtime_ns=stat.st_mtime_ns,
|
|
177
|
+
size=stat.st_size,
|
|
178
|
+
content_hash=_content_hash(path, tee),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def parse_jsonl_records(lines: Iterable[str], path: Path) -> list[Any]:
|
|
183
|
+
"""Parse JSONL lines the way harbor's reading loop does.
|
|
184
|
+
|
|
185
|
+
Blank lines are skipped; a line that is not JSON is logged at debug and
|
|
186
|
+
skipped rather than failing the session. A line that IS JSON but not an
|
|
187
|
+
object is kept as-is, as harbor keeps it (the converter fails on it later,
|
|
188
|
+
in the same place harbor fails); the audit readers filter to objects.
|
|
189
|
+
|
|
190
|
+
Iterating a text handle splits on newlines only, where ``str.splitlines``
|
|
191
|
+
would also split on U+2028 / U+2029 / form feed, which are legal unescaped
|
|
192
|
+
inside a JSON string. Callers pass the handle, never ``read().splitlines()``.
|
|
193
|
+
"""
|
|
194
|
+
records: list[Any] = []
|
|
195
|
+
for line in lines:
|
|
196
|
+
stripped = line.strip()
|
|
197
|
+
if not stripped:
|
|
198
|
+
continue
|
|
199
|
+
try:
|
|
200
|
+
records.append(json.loads(stripped))
|
|
201
|
+
except json.JSONDecodeError as exc:
|
|
202
|
+
logger.debug("Skipping malformed JSONL line in {}: {}", path, exc)
|
|
203
|
+
return records
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _read_and_fingerprint(path: Path) -> tuple[FileFingerprint, list[Any]]:
|
|
207
|
+
"""One pass over ``path``: stat, then hash and parse the same bytes.
|
|
208
|
+
|
|
209
|
+
The stat is taken BEFORE the read so an append that lands between the two
|
|
210
|
+
still shows as movement when the post-conversion re-check compares stats;
|
|
211
|
+
the digest covers the bytes the parser actually saw.
|
|
212
|
+
"""
|
|
213
|
+
stat = path.stat()
|
|
214
|
+
digest = _new_digest()
|
|
215
|
+
with (
|
|
216
|
+
path.open("rb", buffering=0) as raw,
|
|
217
|
+
io.TextIOWrapper(
|
|
218
|
+
io.BufferedReader(_DigestingReader(raw, digest), _HASH_CHUNK_BYTES),
|
|
219
|
+
encoding="utf-8",
|
|
220
|
+
) as handle,
|
|
221
|
+
):
|
|
222
|
+
records = parse_jsonl_records(handle, path)
|
|
223
|
+
fingerprint = FileFingerprint(
|
|
224
|
+
mtime_ns=stat.st_mtime_ns,
|
|
225
|
+
size=stat.st_size,
|
|
226
|
+
content_hash=digest.digest(),
|
|
227
|
+
)
|
|
228
|
+
return fingerprint, records
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _read_sidecar(path: Path) -> tuple[FileFingerprint, Any]:
|
|
232
|
+
"""One pass over a small JSON sidecar: stat, then hash and parse the same bytes.
|
|
233
|
+
|
|
234
|
+
A sidecar that is not valid UTF-8 JSON parses to ``None`` (logged at debug)
|
|
235
|
+
rather than failing the session: it only ever adds linkage, and the
|
|
236
|
+
subagent's transcript converts without it.
|
|
237
|
+
"""
|
|
238
|
+
stat = path.stat()
|
|
239
|
+
data = path.read_bytes()
|
|
240
|
+
digest = _new_digest()
|
|
241
|
+
digest.update(data)
|
|
242
|
+
try:
|
|
243
|
+
value: Any = json.loads(data.decode("utf-8"))
|
|
244
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
245
|
+
logger.debug("Skipping unreadable sidecar {}: {}", path, exc)
|
|
246
|
+
value = None
|
|
247
|
+
fingerprint = FileFingerprint(
|
|
248
|
+
mtime_ns=stat.st_mtime_ns,
|
|
249
|
+
size=stat.st_size,
|
|
250
|
+
content_hash=digest.digest(),
|
|
251
|
+
)
|
|
252
|
+
return fingerprint, value
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass(frozen=True, slots=True)
|
|
256
|
+
class SessionSnapshot:
|
|
257
|
+
"""The fixed file list of one session plus each file's change evidence.
|
|
258
|
+
|
|
259
|
+
Holds no records. A :class:`LoadedSession` pairs one with the records
|
|
260
|
+
parsed from those same bytes; :func:`read_snapshot_records` re-parses
|
|
261
|
+
exactly the snapshot's file list for a caller that holds only the snapshot.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
session_jsonl: Path
|
|
265
|
+
|
|
266
|
+
#: Fingerprint per discovered file, sampled by the read that parsed it (or
|
|
267
|
+
#: by :func:`take_session_snapshot`, which reads for the digest alone).
|
|
268
|
+
fingerprints: dict[Path, FileFingerprint]
|
|
269
|
+
|
|
270
|
+
#: Fingerprint per discovered ``agent-*.meta.json`` sidecar. Kept apart
|
|
271
|
+
#: from :attr:`fingerprints` because :attr:`files` is the RECORD-bearing
|
|
272
|
+
#: file list every JSONL reader walks, and a sidecar holds no records.
|
|
273
|
+
sidecar_fingerprints: dict[Path, FileFingerprint] = field(default_factory=dict)
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def files(self) -> tuple[Path, ...]:
|
|
277
|
+
"""Every discovered file, main first then sorted side-files."""
|
|
278
|
+
return tuple(self.fingerprints)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@dataclass(frozen=True, slots=True)
|
|
282
|
+
class LoadedSession:
|
|
283
|
+
"""One session's snapshot plus every record parsed from those same bytes.
|
|
284
|
+
|
|
285
|
+
``records_by_file`` keeps each file's records in file order under the
|
|
286
|
+
file's path, main first then sorted side-files, so the converter can
|
|
287
|
+
address the main transcript and the staged side-files by name while the
|
|
288
|
+
audit walks every record of every file. Values are whatever JSON the lines
|
|
289
|
+
held, objects or not, as harbor keeps them; :meth:`record_pairs` is the
|
|
290
|
+
objects-only view the census, edges and enrichment consume.
|
|
291
|
+
"""
|
|
292
|
+
|
|
293
|
+
snapshot: SessionSnapshot
|
|
294
|
+
records_by_file: dict[Path, list[Any]]
|
|
295
|
+
|
|
296
|
+
#: Each ``agent-*.meta.json`` sidecar's parsed JSON (``None`` when it did
|
|
297
|
+
#: not parse), keyed by path, sorted. Read in the same pass as the records.
|
|
298
|
+
sidecars: dict[Path, Any] = field(default_factory=dict)
|
|
299
|
+
|
|
300
|
+
@property
|
|
301
|
+
def main_records(self) -> list[Any]:
|
|
302
|
+
"""The main transcript's records, in file order."""
|
|
303
|
+
return self.records_by_file[self.snapshot.session_jsonl]
|
|
304
|
+
|
|
305
|
+
def record_pairs(self) -> list[tuple[dict[str, Any], str]]:
|
|
306
|
+
"""Every OBJECT record with its source file, in file order.
|
|
307
|
+
|
|
308
|
+
``source_file`` is relative to the session JSONL's parent directory,
|
|
309
|
+
the contract's edge field. Non-object JSON values are dropped here, as
|
|
310
|
+
the audit never counted them.
|
|
311
|
+
"""
|
|
312
|
+
base = self.snapshot.session_jsonl.parent
|
|
313
|
+
pairs: list[tuple[dict[str, Any], str]] = []
|
|
314
|
+
for path, records in self.records_by_file.items():
|
|
315
|
+
source_file = path.relative_to(base).as_posix()
|
|
316
|
+
pairs.extend((record, source_file) for record in records if isinstance(record, dict))
|
|
317
|
+
return pairs
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def load_session(session_jsonl: Path) -> LoadedSession:
|
|
321
|
+
"""Read every discovered file once: fingerprint and parse in a single pass.
|
|
322
|
+
|
|
323
|
+
Raises
|
|
324
|
+
------
|
|
325
|
+
OSError: a discovered file cannot be opened or read.
|
|
326
|
+
UnicodeDecodeError: a file is not UTF-8.
|
|
327
|
+
"""
|
|
328
|
+
fingerprints: dict[Path, FileFingerprint] = {}
|
|
329
|
+
records_by_file: dict[Path, list[Any]] = {}
|
|
330
|
+
for path in discover_session_files(session_jsonl):
|
|
331
|
+
fingerprints[path], records_by_file[path] = _read_and_fingerprint(path)
|
|
332
|
+
sidecar_fingerprints: dict[Path, FileFingerprint] = {}
|
|
333
|
+
sidecars: dict[Path, Any] = {}
|
|
334
|
+
for path in discover_sidecar_files(session_jsonl):
|
|
335
|
+
sidecar_fingerprints[path], sidecars[path] = _read_sidecar(path)
|
|
336
|
+
return LoadedSession(
|
|
337
|
+
snapshot=SessionSnapshot(
|
|
338
|
+
session_jsonl=session_jsonl,
|
|
339
|
+
fingerprints=fingerprints,
|
|
340
|
+
sidecar_fingerprints=sidecar_fingerprints,
|
|
341
|
+
),
|
|
342
|
+
records_by_file=records_by_file,
|
|
343
|
+
sidecars=sidecars,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def take_session_snapshot(session_jsonl: Path) -> SessionSnapshot:
|
|
348
|
+
"""Fingerprint every discovered file; holds digests, never parsed records.
|
|
349
|
+
|
|
350
|
+
The use cases go through :func:`load_session`, which fingerprints as a
|
|
351
|
+
by-product of the read. This is for a caller that wants the change
|
|
352
|
+
evidence alone.
|
|
353
|
+
"""
|
|
354
|
+
return SessionSnapshot(
|
|
355
|
+
session_jsonl=session_jsonl,
|
|
356
|
+
fingerprints={path: _fingerprint(path) for path in discover_session_files(session_jsonl)},
|
|
357
|
+
sidecar_fingerprints={
|
|
358
|
+
path: _fingerprint(path) for path in discover_sidecar_files(session_jsonl)
|
|
359
|
+
},
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def read_snapshot_records(snapshot: SessionSnapshot) -> list[tuple[dict[str, Any], str]]:
|
|
364
|
+
"""Parse every object record of exactly the snapshot's file list.
|
|
365
|
+
|
|
366
|
+
Returns ``(record, source_file)`` pairs in file order, ``source_file``
|
|
367
|
+
relative to the session JSONL's parent directory (the contract's edge
|
|
368
|
+
field). Malformed lines are skipped (same policy as harbor itself). Files
|
|
369
|
+
added since the snapshot are NOT read: the snapshot's list is the whole
|
|
370
|
+
input, and :func:`mutated_files` reports the newcomer afterwards.
|
|
371
|
+
"""
|
|
372
|
+
base = snapshot.session_jsonl.parent
|
|
373
|
+
pairs: list[tuple[dict[str, Any], str]] = []
|
|
374
|
+
for path in snapshot.files:
|
|
375
|
+
source_file = path.relative_to(base).as_posix()
|
|
376
|
+
with path.open(encoding="utf-8") as handle:
|
|
377
|
+
records = parse_jsonl_records(handle, path)
|
|
378
|
+
pairs.extend((record, source_file) for record in records if isinstance(record, dict))
|
|
379
|
+
return pairs
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def mutated_files(
|
|
383
|
+
snapshot: SessionSnapshot,
|
|
384
|
+
*,
|
|
385
|
+
archive: SourceArchiveWriter | None = None,
|
|
386
|
+
) -> tuple[Path, ...]:
|
|
387
|
+
"""Files that changed, vanished, or appeared since the snapshot was taken.
|
|
388
|
+
|
|
389
|
+
Re-stats AND re-hashes every file: the digest is the half of the evidence
|
|
390
|
+
that catches a same-length rewrite inside one mtime tick. A newly APPEARED
|
|
391
|
+
side-file counts: it carries records the snapshot never saw, so artifacts
|
|
392
|
+
derived from the snapshot are already incomplete.
|
|
393
|
+
|
|
394
|
+
With ``archive``, every byte this re-hash reads is also streamed into the
|
|
395
|
+
session's source archive, so archiving costs no extra open. The archive is
|
|
396
|
+
only meaningful when the result is empty: an unchanged digest is what proves
|
|
397
|
+
the archived bytes are the bytes that were parsed. A caller that gets a
|
|
398
|
+
non-empty result must discard it.
|
|
399
|
+
"""
|
|
400
|
+
moved: list[Path] = []
|
|
401
|
+
for path, fingerprint in (
|
|
402
|
+
*snapshot.fingerprints.items(),
|
|
403
|
+
*snapshot.sidecar_fingerprints.items(),
|
|
404
|
+
):
|
|
405
|
+
try:
|
|
406
|
+
if archive is None:
|
|
407
|
+
current = _fingerprint(path)
|
|
408
|
+
else:
|
|
409
|
+
with archive.member(path) as sink:
|
|
410
|
+
current = _fingerprint(path, sink)
|
|
411
|
+
archive.record(path, size=current.size, sha256=current.content_hash)
|
|
412
|
+
if current != fingerprint:
|
|
413
|
+
moved.append(path)
|
|
414
|
+
except OSError:
|
|
415
|
+
moved.append(path)
|
|
416
|
+
moved.extend(
|
|
417
|
+
path
|
|
418
|
+
for path in discover_session_files(snapshot.session_jsonl)
|
|
419
|
+
if path not in snapshot.fingerprints
|
|
420
|
+
)
|
|
421
|
+
moved.extend(
|
|
422
|
+
path
|
|
423
|
+
for path in discover_sidecar_files(snapshot.session_jsonl)
|
|
424
|
+
if path not in snapshot.sidecar_fingerprints
|
|
425
|
+
)
|
|
426
|
+
return tuple(moved)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Write a zstd-compressed copy of a session's raw source files while they are verified.
|
|
4
|
+
|
|
5
|
+
The corpus keeps every session's artifacts after the source transcript is gone,
|
|
6
|
+
and a future converter can only re-run on a session whose raw bytes survived.
|
|
7
|
+
So each successful conversion also leaves an ARCHIVE: one ``<relative path>.zst``
|
|
8
|
+
per source file, relative to the main transcript's parent directory, in a
|
|
9
|
+
directory the caller names.
|
|
10
|
+
|
|
11
|
+
WHERE THE BYTES COME FROM. The transcripts are read once for parsing (see
|
|
12
|
+
:mod:`atif_converter.infrastructure.raw_records`), and the second, verifying
|
|
13
|
+
read re-hashes every byte to prove nothing moved. The archive rides that second
|
|
14
|
+
read: :func:`~atif_converter.infrastructure.raw_records.mutated_files` hands each
|
|
15
|
+
chunk it hashes to :meth:`SourceArchiveWriter.member` as well. No file is opened
|
|
16
|
+
a third time, and the digest that vouches for the archived bytes is the one the
|
|
17
|
+
snapshot check compares against the parse's digest, so an archived transcript is
|
|
18
|
+
byte-for-byte the input the artifacts were built from. If anything moved, the
|
|
19
|
+
conversion raises and the caller throws the half-written archive away.
|
|
20
|
+
|
|
21
|
+
The ``agent-*.meta.json`` sidecars are parsed in the same pass as the
|
|
22
|
+
transcripts and re-hashed by the same verifying read, so they ride it too. The
|
|
23
|
+
other files under the session's side directory (the ``tool-results/`` spill
|
|
24
|
+
files, workflow state) are not parsed by any converter today, so
|
|
25
|
+
:meth:`SourceArchiveWriter.archive_side_files` reads each of them once, here.
|
|
26
|
+
They are archived anyway because a future converter may want them and the
|
|
27
|
+
originals expire with the transcript.
|
|
28
|
+
|
|
29
|
+
Level 3 is deliberate: measured on a 76 MB transcript, level 3 compresses at
|
|
30
|
+
about 300 MB/s to 2.8x and level 19 reaches 2.9x at 5 MB/s. The archive sits on
|
|
31
|
+
the conversion hot path, so the extra 3% is not worth sixty times the CPU.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import hashlib
|
|
37
|
+
import os
|
|
38
|
+
from contextlib import contextmanager
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
from typing import TYPE_CHECKING, BinaryIO
|
|
41
|
+
|
|
42
|
+
import zstandard
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from collections.abc import Callable, Collection, Generator
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
#: zstd level for every archived file; see the module docstring.
|
|
49
|
+
ARCHIVE_ZSTD_LEVEL = 3
|
|
50
|
+
|
|
51
|
+
#: Suffix appended to each archived file's relative path.
|
|
52
|
+
ARCHIVE_SUFFIX = ".zst"
|
|
53
|
+
|
|
54
|
+
#: Archive files hold raw transcript bytes, so they get the transcripts' own
|
|
55
|
+
#: owner-only mode rather than whatever the umask allows.
|
|
56
|
+
_ARCHIVE_FILE_MODE = 0o600
|
|
57
|
+
|
|
58
|
+
#: Read size for side files, the same 1 MiB chunk the transcript reader uses.
|
|
59
|
+
_CHUNK_BYTES = 1024 * 1024
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class ArchivedSourceFile:
|
|
64
|
+
"""One archived file: where it came from, and what its uncompressed bytes were."""
|
|
65
|
+
|
|
66
|
+
#: POSIX path relative to the main transcript's parent directory.
|
|
67
|
+
relative_path: str
|
|
68
|
+
#: Uncompressed size in bytes.
|
|
69
|
+
size: int
|
|
70
|
+
#: Hex sha256 of the uncompressed bytes.
|
|
71
|
+
sha256: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SourceArchiveWriter:
|
|
75
|
+
"""Collects one session's archive under ``archive_dir``.
|
|
76
|
+
|
|
77
|
+
``base`` is the main transcript's parent directory; every member's name is
|
|
78
|
+
its path relative to ``base``, so the archive restores to the same shape
|
|
79
|
+
(``<session>.jsonl`` beside ``<session>/subagents/...``) the converter
|
|
80
|
+
discovers side files from.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, archive_dir: Path, *, base: Path) -> None:
|
|
84
|
+
self.archive_dir = archive_dir
|
|
85
|
+
self.base = base
|
|
86
|
+
self._files: dict[str, ArchivedSourceFile] = {}
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def files(self) -> tuple[ArchivedSourceFile, ...]:
|
|
90
|
+
"""Every archived file, sorted by relative path."""
|
|
91
|
+
return tuple(self._files[name] for name in sorted(self._files))
|
|
92
|
+
|
|
93
|
+
def _relative(self, path: Path) -> str:
|
|
94
|
+
return path.relative_to(self.base).as_posix()
|
|
95
|
+
|
|
96
|
+
def _open_member(self, relative_path: str) -> BinaryIO:
|
|
97
|
+
target = self.archive_dir / f"{relative_path}{ARCHIVE_SUFFIX}"
|
|
98
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, _ARCHIVE_FILE_MODE)
|
|
100
|
+
return os.fdopen(fd, "wb")
|
|
101
|
+
|
|
102
|
+
@contextmanager
|
|
103
|
+
def member(self, path: Path) -> Generator[Callable[[bytes], object]]:
|
|
104
|
+
"""Stream one source file into the archive; yields the chunk sink.
|
|
105
|
+
|
|
106
|
+
The caller hashes the same chunks itself and reports the digest through
|
|
107
|
+
:meth:`record`, which is how the verifying read's digest, not a second
|
|
108
|
+
one, vouches for the archived bytes.
|
|
109
|
+
"""
|
|
110
|
+
with (
|
|
111
|
+
self._open_member(self._relative(path)) as raw,
|
|
112
|
+
zstandard.ZstdCompressor(level=ARCHIVE_ZSTD_LEVEL).stream_writer(
|
|
113
|
+
raw, closefd=False
|
|
114
|
+
) as compressed,
|
|
115
|
+
):
|
|
116
|
+
yield compressed.write
|
|
117
|
+
|
|
118
|
+
def record(self, path: Path, *, size: int, sha256: bytes) -> None:
|
|
119
|
+
"""Note the uncompressed size and digest of a member written through :meth:`member`."""
|
|
120
|
+
relative = self._relative(path)
|
|
121
|
+
self._files[relative] = ArchivedSourceFile(
|
|
122
|
+
relative_path=relative, size=size, sha256=sha256.hex()
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def archive_side_files(self, session_jsonl: Path, *, already: Collection[Path]) -> None:
|
|
126
|
+
"""Archive every other regular file under the session's side directory.
|
|
127
|
+
|
|
128
|
+
``already`` holds the files the verifying read archived (the
|
|
129
|
+
transcripts and the parsed sidecars); they are skipped. Symlinks are skipped too: following one would copy bytes from
|
|
130
|
+
outside the session into its archive.
|
|
131
|
+
"""
|
|
132
|
+
side_dir = session_jsonl.parent / session_jsonl.stem
|
|
133
|
+
if not side_dir.is_dir() or side_dir.is_symlink():
|
|
134
|
+
return
|
|
135
|
+
skip = set(already)
|
|
136
|
+
for path in sorted(side_dir.rglob("*")):
|
|
137
|
+
if path in skip or path.is_symlink() or not path.is_file():
|
|
138
|
+
continue
|
|
139
|
+
digest = hashlib.sha256()
|
|
140
|
+
size = 0
|
|
141
|
+
with self.member(path) as sink, path.open("rb") as handle:
|
|
142
|
+
for chunk in iter(lambda: handle.read(_CHUNK_BYTES), b""):
|
|
143
|
+
digest.update(chunk)
|
|
144
|
+
sink(chunk)
|
|
145
|
+
size += len(chunk)
|
|
146
|
+
self.record(path, size=size, sha256=digest.digest())
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
__all__ = [
|
|
150
|
+
"ARCHIVE_SUFFIX",
|
|
151
|
+
"ARCHIVE_ZSTD_LEVEL",
|
|
152
|
+
"ArchivedSourceFile",
|
|
153
|
+
"SourceArchiveWriter",
|
|
154
|
+
]
|
atif_converter/py.typed
ADDED
|
File without changes
|
atif_corpus/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""atif-corpus: corpus materialization for ATIF trajectories.
|
|
4
|
+
|
|
5
|
+
Owns CONTRACT.md §Materialized corpus layout — session discovery over the
|
|
6
|
+
raw transcript corpus, per-session watermarks (convert only what changed),
|
|
7
|
+
quiescence detection (skip sessions still being written), and atomic
|
|
8
|
+
artifact writes. Hexagonal: ``domain`` (pure decisions) < ``infrastructure``
|
|
9
|
+
(fs/env effects) < ``application`` (the materialize use case). Conversion
|
|
10
|
+
happens behind :class:`atif_corpus.domain.ports.ConverterPort`; atif-cli
|
|
11
|
+
adapts the real converter (independence contract: this package never
|
|
12
|
+
imports atif-converter or atif-duck).
|
|
13
|
+
"""
|