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,316 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Filesystem discovery: sessions + side-files with their ``st_mtime_ns``.
4
+
5
+ The one place atif-corpus stats the source corpus. WHERE it looks comes from a
6
+ :class:`~atif_corpus.domain.source_layout.SourceLayout` — one per agent, so
7
+ this module walks one algorithm instead of carrying a branch per transcript
8
+ shape. Discovery follows CONTRACT.md exactly:
9
+
10
+ * transcripts sit ``layout.transcript_depth`` directory levels under the source
11
+ root: ``<source_root>/<project>/<session>.jsonl`` for Claude Code,
12
+ ``<source_root>/<YYYY>/<MM>/<DD>/rollout-<ts>-<uuid>.jsonl`` for Codex;
13
+ * the session id comes from the FILENAME via ``layout.session_id`` — the whole
14
+ stem for Claude Code, the trailing uuid for a Codex rollout — and must pass
15
+ :func:`~atif_corpus.domain.session_id.session_id_rejection` before it is
16
+ used for anything: it becomes a directory name in the corpus and, from
17
+ there, part of the paths atif-duck opens. A name that fails is REJECTED:
18
+ skipped this pass, logged with the reason, and reported in
19
+ :attr:`SourceScan.rejected_session_ids` so the skip is visible;
20
+ * side-files are found by ``rglob`` over the session dir (the directory
21
+ named after the session stem) filtered to ``*.jsonl`` and ``*.meta.json``
22
+ — the rglob deliberately does NOT hardcode ``subagents/`` vs
23
+ ``subagents/workflows/wf_*/`` so any deeper future nesting is still
24
+ watermarked. The ``agent-*.meta.json`` sidecars are watched because the
25
+ converter reads them (subagent type and description), so a sidecar that
26
+ changes on its own has to make the session stale; the other files under the
27
+ side dir (``tool-results/`` spills) are archived but not watched. Only layouts that HAVE side-files are walked for them: a Codex
28
+ sub-agent writes its own rollout under its own session id, so a rollout is
29
+ always alone and looking for siblings would be a stat per session for a
30
+ directory that cannot exist.
31
+
32
+ A scan reports two kinds of absence and they must never be conflated. A
33
+ session whose main transcript is genuinely GONE is marked source-removed:
34
+ its corpus dir is kept and its meta says the source is gone. A session whose
35
+ ``stat`` merely FAILED (EIO, ESTALE, an NFS or permission blip) is only
36
+ unreadable this pass and its source may well still be there — marking it
37
+ would record a live session as deleted over a transient error. :class:`SourceScan` keeps the two in
38
+ separate fields so a caller cannot accidentally treat one as the other.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ from dataclasses import dataclass, field
44
+ from types import MappingProxyType
45
+ from typing import TYPE_CHECKING
46
+
47
+ from loguru import logger
48
+
49
+ from atif_corpus.domain.session_id import session_id_rejection
50
+ from atif_corpus.domain.sessions import SessionSource
51
+ from atif_corpus.domain.source_layout import CLAUDE_CODE_LAYOUT, SourceLayout
52
+
53
+ if TYPE_CHECKING:
54
+ from collections.abc import Mapping
55
+ from pathlib import Path
56
+
57
+ #: The side-dir files whose mtimes are part of a session's watermark: the
58
+ #: side-file transcripts, and the ``agent-*.meta.json`` sidecars the converter
59
+ #: reads beside them.
60
+ WATCHED_SIDE_FILE_GLOBS: tuple[str, ...] = ("*.jsonl", "*.meta.json")
61
+
62
+ #: Shared empty default; a frozen dataclass must not hand out a mutable dict.
63
+ _NO_UNREADABLE: Mapping[str, str] = MappingProxyType[str, str]({})
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class SourceScan:
68
+ """One scan of the source root: sessions found, plus sessions it could not read."""
69
+
70
+ #: Sessions discovered this pass, sorted by session id.
71
+ sessions: tuple[SessionSource, ...] = ()
72
+ #: ``{session_id: main_jsonl}`` for sessions whose sources raised a
73
+ #: non-``FileNotFoundError`` ``OSError``. Absent from ``sessions`` (nothing
74
+ #: converts without an mtime), so callers MUST exclude them from source-removal
75
+ #: removal and retain their watermark entries — the main path is carried
76
+ #: because retention scopes entries by it. Wrapped read-only so the frozen
77
+ #: dataclass is actually frozen rather than frozen-except-this-field.
78
+ unreadable: Mapping[str, str] = field(default_factory=lambda: _NO_UNREADABLE)
79
+ #: Directories whose CONTENTS could not be listed at all (a project dir at
80
+ #: mode 000). Their sessions cannot appear in ``unreadable`` — that map is
81
+ #: keyed by discovered session, and nothing was discovered — so the
82
+ #: directory is reported instead and the caller resolves it to session ids
83
+ #: through the watermark.
84
+ unlistable_dirs: tuple[str, ...] = ()
85
+ #: Transcript names whose derived session id failed the boundary check
86
+ #: (:mod:`atif_corpus.domain.session_id`), sorted. Nothing from them is
87
+ #: materialized, they are never marked source-removed, and they are reported so an
88
+ #: operator can see why a transcript is missing from the corpus.
89
+ rejected_session_ids: tuple[str, ...] = ()
90
+
91
+ @property
92
+ def unreadable_session_ids(self) -> frozenset[str]:
93
+ """Ids of the sessions this scan could not read."""
94
+ return frozenset(self.unreadable)
95
+
96
+ def with_unreadable(self, extra: Mapping[str, str]) -> SourceScan:
97
+ """A copy of this scan carrying more unreadable ``{session_id: main_jsonl}``.
98
+
99
+ Lets a caller that resolved :attr:`unlistable_dirs` to session ids
100
+ fold them in, so source-removal marking, watermark retention and the pass
101
+ report all read one unreadable set instead of three.
102
+ """
103
+ if not extra:
104
+ return self
105
+ return SourceScan(
106
+ sessions=self.sessions,
107
+ unreadable=MappingProxyType({**self.unreadable, **extra}),
108
+ unlistable_dirs=self.unlistable_dirs,
109
+ rejected_session_ids=self.rejected_session_ids,
110
+ )
111
+
112
+
113
+ def _mtime_ns(path: Path) -> int | None:
114
+ """``st_mtime_ns`` for ``path``; ``None`` when it vanished mid-scan.
115
+
116
+ Every other ``OSError`` propagates so the caller can tell "deleted" from
117
+ "could not read".
118
+ """
119
+ try:
120
+ return path.stat().st_mtime_ns
121
+ except FileNotFoundError:
122
+ # A transcript deleted between glob and stat is a normal race on a
123
+ # live corpus, not an error: it simply isn't part of this scan.
124
+ logger.debug("scan: {} vanished mid-scan; skipping", path)
125
+ return None
126
+
127
+
128
+ def _session_mtimes(main_jsonl: Path, layout: SourceLayout) -> dict[str, int] | None:
129
+ """``{path: mtime_ns}`` for one session, or ``None`` when its main file vanished.
130
+
131
+ Side-files are the transcripts and ``*.meta.json`` sidecars under the
132
+ session dir (:data:`WATCHED_SIDE_FILE_GLOBS`). A vanished SIDE-file is
133
+ simply omitted: the session is still part of the scan, and the missing entry is exactly the staleness signal that makes it
134
+ re-materialize. Raises ``OSError`` when any source could not be stat'd.
135
+ """
136
+ main_mtime = _mtime_ns(main_jsonl)
137
+ if main_mtime is None:
138
+ return None
139
+ mtimes = {str(main_jsonl): main_mtime}
140
+
141
+ if not layout.has_side_files:
142
+ return mtimes
143
+ side_dir = main_jsonl.parent / main_jsonl.stem
144
+ if side_dir.is_dir():
145
+ watched = {path for pattern in WATCHED_SIDE_FILE_GLOBS for path in side_dir.rglob(pattern)}
146
+ for side_file in sorted(watched):
147
+ side_mtime = _mtime_ns(side_file)
148
+ if side_mtime is not None:
149
+ mtimes[str(side_file)] = side_mtime
150
+ return mtimes
151
+
152
+
153
+ def _list_dir(directory: Path) -> list[Path] | None:
154
+ """Sorted entries of ``directory``; ``[]`` if it vanished, ``None`` if unlistable.
155
+
156
+ ``Path.glob`` swallows a ``PermissionError`` on a directory it cannot
157
+ open and simply yields nothing, which is indistinguishable from an empty
158
+ directory — and "empty" makes every session under it look deleted. Listing each
159
+ level explicitly is what keeps the two apart.
160
+ """
161
+ try:
162
+ return sorted(directory.iterdir())
163
+ except FileNotFoundError:
164
+ logger.debug("scan: directory {} vanished mid-scan; skipping", directory)
165
+ return []
166
+ except OSError as error:
167
+ logger.warning(
168
+ "scan: cannot list directory {} ({}); its sessions are unreadable "
169
+ "this pass and are NOT treated as deleted",
170
+ directory,
171
+ error,
172
+ )
173
+ return None
174
+
175
+
176
+ def _transcript_dirs(source_root: Path, layout: SourceLayout) -> tuple[list[Path], list[str]]:
177
+ """The directories that hold transcripts, plus every dir that would not list.
178
+
179
+ Descends exactly ``layout.transcript_depth`` levels, listing each one, so an
180
+ unopenable directory at ANY level is reported as unlistable instead of
181
+ reading as empty. Claude Code stops at the project dirs; Codex walks
182
+ year -> month -> day.
183
+ """
184
+ level = [source_root]
185
+ unlistable: list[str] = []
186
+ for _ in range(layout.transcript_depth):
187
+ next_level: list[Path] = []
188
+ for directory in level:
189
+ entries = _list_dir(directory)
190
+ if entries is None:
191
+ unlistable.append(str(directory))
192
+ continue
193
+ next_level.extend(entry for entry in entries if entry.is_dir())
194
+ level = sorted(next_level)
195
+ return level, unlistable
196
+
197
+
198
+ def _transcripts(transcript_dir: Path, layout: SourceLayout) -> list[Path] | None:
199
+ """List one dir's transcript files for this agent, or ``None`` if unlistable."""
200
+ entries = _list_dir(transcript_dir)
201
+ if entries is None:
202
+ return None
203
+ return [entry for entry in entries if layout.is_transcript(entry.name)]
204
+
205
+
206
+ def scan_sources(
207
+ source_root: Path,
208
+ layout: SourceLayout = CLAUDE_CODE_LAYOUT,
209
+ ) -> SourceScan:
210
+ """Discover every session under ``source_root``, separating unreadable and rejected ones.
211
+
212
+ Sessions come back sorted by session id so the scan itself is
213
+ deterministic input to :func:`atif_corpus.domain.sessions.build_plan`.
214
+ An absent or empty root yields an empty scan — a fresh machine is not an
215
+ error condition.
216
+
217
+ Absence is separated from unreadability at EVERY level: a session whose
218
+ ``stat`` failed lands in :attr:`SourceScan.unreadable`, and any directory
219
+ that could not be LISTED — the root, a Claude Code project dir, a Codex
220
+ year/month/day dir — lands in :attr:`SourceScan.unlistable_dirs`. Its
221
+ sessions were never discovered, so only the caller's watermark knows which
222
+ ids they were.
223
+
224
+ ``layout`` defaults to Claude Code's shape, which is what every caller
225
+ predating Codex support meant.
226
+ """
227
+ if not source_root.is_dir():
228
+ logger.debug("scan: source root {} does not exist", source_root)
229
+ return SourceScan()
230
+
231
+ transcript_dirs, unlistable = _transcript_dirs(source_root, layout)
232
+
233
+ sessions: list[SessionSource] = []
234
+ unreadable: dict[str, str] = {}
235
+ rejected: list[str] = []
236
+ for transcript_dir in transcript_dirs:
237
+ transcripts = _transcripts(transcript_dir, layout)
238
+ if transcripts is None:
239
+ unlistable.append(str(transcript_dir))
240
+ continue
241
+ for main_jsonl in transcripts:
242
+ session_id = layout.session_id(main_jsonl.name)
243
+ if session_id is None:
244
+ logger.debug(
245
+ "scan: {} is not a {} transcript name; skipping",
246
+ main_jsonl,
247
+ layout.agent.value,
248
+ )
249
+ continue
250
+ rejection = session_id_rejection(session_id)
251
+ if rejection is not None:
252
+ # The one outside influence on a corpus path. A name that fails
253
+ # here never becomes a directory, a watermark key, or a SQL
254
+ # parameter; it is reported instead of guessed at.
255
+ logger.warning(
256
+ "scan: rejecting session name {!r} from {} ({}); nothing from it "
257
+ "is materialized",
258
+ session_id,
259
+ main_jsonl,
260
+ rejection,
261
+ )
262
+ rejected.append(session_id)
263
+ continue
264
+ try:
265
+ mtimes = _session_mtimes(main_jsonl, layout)
266
+ except OSError as error:
267
+ logger.warning(
268
+ "scan: cannot stat sources of session {} ({}); skipping this pass "
269
+ "and NOT treating it as deleted",
270
+ session_id,
271
+ error,
272
+ )
273
+ unreadable[session_id] = str(main_jsonl)
274
+ continue
275
+ if mtimes is None:
276
+ continue
277
+ sessions.append(
278
+ SessionSource(
279
+ session_id=session_id,
280
+ session_jsonl=str(main_jsonl),
281
+ source_mtimes=mtimes,
282
+ )
283
+ )
284
+ sessions.sort(key=lambda s: s.session_id)
285
+ logger.debug(
286
+ "scan: {} sessions ({} unreadable, {} unlistable dirs, {} rejected names) under {} as {}",
287
+ len(sessions),
288
+ len(unreadable),
289
+ len(unlistable),
290
+ len(rejected),
291
+ source_root,
292
+ layout.agent.value,
293
+ )
294
+ return SourceScan(
295
+ sessions=tuple(sessions),
296
+ unreadable=MappingProxyType(dict(unreadable)),
297
+ unlistable_dirs=tuple(unlistable),
298
+ rejected_session_ids=tuple(sorted(rejected)),
299
+ )
300
+
301
+
302
+ def scan_source_root(
303
+ source_root: Path,
304
+ layout: SourceLayout = CLAUDE_CODE_LAYOUT,
305
+ ) -> tuple[SessionSource, ...]:
306
+ """The sessions found under ``source_root`` — :func:`scan_sources` without diagnostics.
307
+
308
+ For read-only callers that plan but never mark: only source-removal
309
+ marking needs ``unreadable_session_ids``, and a caller that marks must
310
+ reach for :func:`scan_sources` and handle it. Dropping the
311
+ diagnostics loses no operator signal here — :func:`scan_sources` logs each
312
+ unreadable session and unlistable directory at WARNING, and the CLI's sink
313
+ is WARNING-and-up on stderr, so an unreadable source still announces
314
+ itself alongside the status output.
315
+ """
316
+ return scan_sources(source_root, layout).sessions
@@ -0,0 +1,130 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """Runtime configuration for atif-corpus.
4
+
5
+ Pydantic v2 ``BaseSettings`` populated from env vars prefixed with
6
+ ``ATIF_SQL_`` (workspace convention). Reading process env is I/O, so this
7
+ lives in ``infrastructure`` and never in ``domain``. Every ``_default_*()``
8
+ factory reads env at CALL time — not import time — so tests and long-lived
9
+ processes that re-point ``CLAUDE_CONFIG_DIR`` observe the new value without
10
+ a module reload.
11
+
12
+ TWO AGENTS, TWO DEFAULT ROOTS. ``agent`` selects which pair of defaults the
13
+ unset roots take: ``<CLAUDE_CONFIG_DIR>/projects`` for Claude Code,
14
+ ``<CODEX_HOME>/sessions`` for Codex, each with its own slugged corpus root so
15
+ one agent's corpus can never overwrite the other's. An EXPLICIT root — passed
16
+ in or set in the env — always wins over the agent default, which is what keeps
17
+ ``ATIF_SQL_SOURCE_ROOT`` meaning exactly what it meant before Codex existed.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ from pathlib import Path
24
+
25
+ from pydantic import Field, model_validator
26
+ from pydantic_settings import BaseSettings, SettingsConfigDict
27
+
28
+ from atif_corpus.domain.agents import DEFAULT_AGENT, AgentSource
29
+ from atif_corpus.domain.slug import corpus_slug
30
+
31
+
32
+ def _claude_config_root() -> str:
33
+ """Return Claude Code's config-dir root, honoring ``CLAUDE_CONFIG_DIR``."""
34
+ return str(Path(os.environ.get("CLAUDE_CONFIG_DIR", "~/.claude")).expanduser())
35
+
36
+
37
+ def _default_source_root() -> Path:
38
+ """Root directory holding per-project session transcript directories."""
39
+ return Path(_claude_config_root()) / "projects"
40
+
41
+
42
+ def _codex_config_root() -> str:
43
+ """Return Codex CLI's config-dir root, honoring ``CODEX_HOME``.
44
+
45
+ ``CODEX_HOME`` is Codex's own variable, not one this workspace invents, so
46
+ a user who already re-points Codex gets their rollouts discovered with no
47
+ extra configuration.
48
+ """
49
+ return str(Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser())
50
+
51
+
52
+ def _default_codex_source_root() -> Path:
53
+ """Root directory holding Codex's per-day rollout directories."""
54
+ return Path(_codex_config_root()) / "sessions"
55
+
56
+
57
+ def _corpus_root_for(source_root: Path) -> Path:
58
+ """Materialized corpus root: ``~/.atif-sql/corpus/<slug of source root>``.
59
+
60
+ Slugging the SOURCE root (not a fixed name) keeps corpora for different
61
+ inputs separate: re-pointing ``CLAUDE_CONFIG_DIR``, or switching agents,
62
+ must never overwrite another corpus's artifacts.
63
+ """
64
+ return Path.home() / ".atif-sql" / "corpus" / corpus_slug(source_root)
65
+
66
+
67
+ def _default_corpus_root() -> Path:
68
+ """The Claude Code corpus root — the default when no agent is named."""
69
+ return _corpus_root_for(_default_source_root())
70
+
71
+
72
+ #: Ceiling on the default pool size. Conversion is CPU-bound and the corpus
73
+ #: filesystem takes the writes, so past this the pool stops earning its
74
+ #: process starts; an operator who has measured otherwise sets the env var.
75
+ MAX_DEFAULT_MATERIALIZE_WORKERS = 8
76
+
77
+
78
+ def default_materialize_workers() -> int:
79
+ """``min(8, cpu_count)`` — the pool size a bare ``materialize`` uses.
80
+
81
+ Read at CALL time like every other default here. ``os.cpu_count()`` can
82
+ answer ``None`` on an exotic platform, which reads as one worker: the
83
+ inline path, never a pool of nothing.
84
+ """
85
+ return max(1, min(MAX_DEFAULT_MATERIALIZE_WORKERS, os.cpu_count() or 1))
86
+
87
+
88
+ class CorpusSettings(BaseSettings):
89
+ """Env-driven settings for corpus materialization."""
90
+
91
+ model_config = SettingsConfigDict(
92
+ env_prefix="ATIF_SQL_",
93
+ env_file=".env",
94
+ extra="ignore",
95
+ )
96
+
97
+ #: Which agent's transcripts this corpus holds (``ATIF_SQL_AGENT``).
98
+ agent: AgentSource = DEFAULT_AGENT
99
+ #: Where raw transcripts live: ``<config>/projects`` for Claude Code,
100
+ #: ``<CODEX_HOME>/sessions`` for Codex.
101
+ source_root: Path = Field(default_factory=_default_source_root)
102
+ #: Where materialized artifacts are written (CONTRACT.md layout root).
103
+ corpus_root: Path = Field(default_factory=_default_corpus_root)
104
+ #: Seconds of source silence before a session may (re)materialize.
105
+ quiesce_seconds: int = 300
106
+ #: Processes for a materialize pass's convert+write stage
107
+ #: (``ATIF_SQL_MATERIALIZE_WORKERS``). ``1`` is the inline reference path.
108
+ materialize_workers: int = Field(default_factory=default_materialize_workers, ge=1)
109
+
110
+ @model_validator(mode="after")
111
+ def _apply_agent_defaults(self) -> CorpusSettings:
112
+ """Re-derive the roots the caller left unset from ``agent``.
113
+
114
+ ``model_fields_set`` is the test, and it is the right one: a field
115
+ filled by its ``default_factory`` is absent from that set while a field
116
+ populated from the environment or from a keyword is present. So an
117
+ explicit ``ATIF_SQL_SOURCE_ROOT`` (or an explicit ``corpus_root=``)
118
+ still wins over the agent default, and only a genuinely unset root
119
+ moves. With ``agent`` at its default this method changes nothing, which
120
+ is why every pre-Codex caller observes exactly the previous behavior.
121
+ """
122
+ if self.agent is DEFAULT_AGENT:
123
+ return self
124
+ source_root = self.source_root
125
+ if "source_root" not in self.model_fields_set:
126
+ source_root = _default_codex_source_root()
127
+ self.source_root = source_root
128
+ if "corpus_root" not in self.model_fields_set:
129
+ self.corpus_root = _corpus_root_for(source_root)
130
+ return self
@@ -0,0 +1,202 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ """A session's raw source archive: its ``meta.json`` manifest, and restoring it.
4
+
5
+ Every session materialized from a live source carries ``source/`` in its corpus
6
+ dir: one ``<relative path>.zst`` per source file (the main transcript, every
7
+ side-file transcript, and every other file under the session's side directory),
8
+ written by the converter from the exact bytes it parsed. ``meta.json`` describes
9
+ it under ``source_archive``::
10
+
11
+ {
12
+ "codec": "zstd",
13
+ "source_dir": "-home-me-proj", # the main transcript's parent, relative
14
+ # to the source root it was scanned under
15
+ "main": "<session>.jsonl", # the main transcript, relative to source_dir
16
+ "files": [{"path": "<session>.jsonl", "size": 123, "sha256": "..."}, ...],
17
+ }
18
+
19
+ The archive is what lets a future converter re-run a session after Claude Code or
20
+ Codex deleted the original. :func:`restore_session_sources` rebuilds the source
21
+ tree under a directory the caller owns and returns the main transcript's path
22
+ there; materialize uses it to re-convert a source-removed session whose recorded
23
+ generation is stale, and any caller can use it to point a converter at the
24
+ restored copy. ``source_dir`` is restored too because the converters read the
25
+ transcript's parent directory name (the fallback session id).
26
+
27
+ Every path in the manifest becomes a filesystem path, so each one is checked
28
+ before use: relative, no empty, ``.`` or ``..`` part, no backslash or NUL.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import hashlib
34
+ import json
35
+ from pathlib import Path, PurePosixPath
36
+ from typing import TYPE_CHECKING, Any
37
+
38
+ import zstandard
39
+
40
+ from atif_corpus.domain.layout import META_FILENAME, SOURCE_ARCHIVE_DIRNAME
41
+
42
+ if TYPE_CHECKING:
43
+ from collections.abc import Sequence
44
+
45
+ from atif_corpus.domain.ports import ArchivedSource
46
+
47
+ #: The meta.json key that holds the manifest.
48
+ META_SOURCE_ARCHIVE_KEY = "source_archive"
49
+
50
+ #: The only codec written so far.
51
+ ARCHIVE_CODEC = "zstd"
52
+
53
+ #: Suffix of every archived file.
54
+ ARCHIVE_SUFFIX = ".zst"
55
+
56
+ #: Decompression read size.
57
+ _CHUNK_BYTES = 1024 * 1024
58
+
59
+
60
+ class SourceArchiveError(RuntimeError):
61
+ """The archive is missing, malformed, or does not decompress to what it claims."""
62
+
63
+
64
+ def archive_path_rejection(path: str) -> str | None:
65
+ """Why ``path`` may not become a path under the archive, or ``None`` if it may."""
66
+ if not path:
67
+ return "empty path"
68
+ if "\\" in path or "\x00" in path:
69
+ return "backslash or NUL in path"
70
+ pure = PurePosixPath(path)
71
+ if pure.is_absolute():
72
+ return "absolute path"
73
+ if any(part in {"", ".", ".."} for part in path.split("/")):
74
+ return "empty, '.' or '..' component"
75
+ return None
76
+
77
+
78
+ def _require_safe(path: str, what: str) -> str:
79
+ rejection = archive_path_rejection(path)
80
+ if rejection is not None:
81
+ msg = f"source archive {what} {path!r} rejected: {rejection}"
82
+ raise SourceArchiveError(msg)
83
+ return path
84
+
85
+
86
+ def archive_manifest(
87
+ files: Sequence[ArchivedSource],
88
+ *,
89
+ source_dir: str,
90
+ main: str,
91
+ ) -> dict[str, Any]:
92
+ """The ``meta.json`` ``source_archive`` value for one freshly archived session.
93
+
94
+ Raises :class:`SourceArchiveError` when a path is unsafe or the main
95
+ transcript is not among the files, so a converter that archived the wrong
96
+ thing fails the session rather than publishing an archive nothing can
97
+ restore.
98
+ """
99
+ if source_dir != ".":
100
+ _require_safe(source_dir, "source_dir")
101
+ _require_safe(main, "main")
102
+ entries = [
103
+ {"path": _require_safe(f.relative_path, "file"), "size": f.size, "sha256": f.sha256}
104
+ for f in sorted(files, key=lambda f: f.relative_path)
105
+ ]
106
+ if main not in {entry["path"] for entry in entries}:
107
+ msg = f"source archive does not contain its main transcript {main!r}"
108
+ raise SourceArchiveError(msg)
109
+ return {"codec": ARCHIVE_CODEC, "source_dir": source_dir, "main": main, "files": entries}
110
+
111
+
112
+ def verify_archive_on_disk(archive_dir: Path, files: Sequence[ArchivedSource]) -> None:
113
+ """Every file the converter claims to have archived exists under ``archive_dir``."""
114
+ for archived in files:
115
+ _require_safe(archived.relative_path, "file")
116
+ member = archive_dir / f"{archived.relative_path}{ARCHIVE_SUFFIX}"
117
+ if not member.is_file():
118
+ msg = f"converter listed {archived.relative_path!r} but wrote no {member}"
119
+ raise SourceArchiveError(msg)
120
+
121
+
122
+ def read_manifest(session_dir: Path) -> dict[str, Any] | None:
123
+ """The ``source_archive`` manifest of one corpus session dir, or ``None`` without one."""
124
+ try:
125
+ meta = json.loads((session_dir / META_FILENAME).read_text(encoding="utf-8"))
126
+ except (OSError, ValueError) as error:
127
+ msg = f"cannot read {session_dir / META_FILENAME}: {error}"
128
+ raise SourceArchiveError(msg) from error
129
+ if not isinstance(meta, dict):
130
+ msg = f"{session_dir / META_FILENAME} is not a JSON object"
131
+ raise SourceArchiveError(msg)
132
+ manifest = meta.get(META_SOURCE_ARCHIVE_KEY)
133
+ if manifest is None:
134
+ return None
135
+ if not isinstance(manifest, dict):
136
+ msg = f"{session_dir / META_FILENAME} has a malformed {META_SOURCE_ARCHIVE_KEY}"
137
+ raise SourceArchiveError(msg)
138
+ return manifest
139
+
140
+
141
+ def _restore_one(member: Path, target: Path, *, size: int, sha256: str) -> None:
142
+ digest = hashlib.sha256()
143
+ written = 0
144
+ target.parent.mkdir(parents=True, exist_ok=True)
145
+ with member.open("rb") as compressed, target.open("wb") as out:
146
+ reader = zstandard.ZstdDecompressor().stream_reader(compressed)
147
+ for chunk in iter(lambda: reader.read(_CHUNK_BYTES), b""):
148
+ digest.update(chunk)
149
+ out.write(chunk)
150
+ written += len(chunk)
151
+ if written != size or digest.hexdigest() != sha256:
152
+ msg = (
153
+ f"{member} decompressed to {written} bytes with sha256 {digest.hexdigest()}, "
154
+ f"expected {size} bytes with sha256 {sha256}"
155
+ )
156
+ raise SourceArchiveError(msg)
157
+
158
+
159
+ def restore_session_sources(session_dir: Path, dest_root: Path) -> Path:
160
+ """Rebuild one session's raw source tree under ``dest_root``; return the main transcript.
161
+
162
+ ``session_dir`` is a corpus session dir (``<corpus>/sessions/<id>``). Files
163
+ land at ``dest_root/<source_dir>/<path>`` with their original bytes, each
164
+ verified against its recorded size and sha256, so ``dest_root`` stands in
165
+ for the source root the session was scanned under and the returned path
166
+ can be handed to the same converter the live source would have been.
167
+
168
+ ``dest_root`` must be a directory the caller owns and removes; nothing
169
+ here cleans it up. Raises :class:`SourceArchiveError` when the session has
170
+ no archive, the manifest is malformed, or a file fails verification.
171
+ """
172
+ manifest = read_manifest(session_dir)
173
+ if manifest is None:
174
+ msg = f"{session_dir} has no source archive"
175
+ raise SourceArchiveError(msg)
176
+ if manifest.get("codec") != ARCHIVE_CODEC:
177
+ msg = f"{session_dir} source archive codec {manifest.get('codec')!r} is not supported"
178
+ raise SourceArchiveError(msg)
179
+ source_dir = str(manifest.get("source_dir", ""))
180
+ main = _require_safe(str(manifest.get("main", "")), "main")
181
+ base = dest_root if source_dir == "." else dest_root / _require_safe(source_dir, "source_dir")
182
+ files = manifest.get("files")
183
+ if not isinstance(files, list) or not files:
184
+ msg = f"{session_dir} source archive lists no files"
185
+ raise SourceArchiveError(msg)
186
+ archive_dir = session_dir / SOURCE_ARCHIVE_DIRNAME
187
+ for entry in files:
188
+ if not isinstance(entry, dict):
189
+ msg = f"{session_dir} source archive has a malformed file entry"
190
+ raise SourceArchiveError(msg)
191
+ path = _require_safe(str(entry.get("path", "")), "file")
192
+ _restore_one(
193
+ archive_dir / f"{path}{ARCHIVE_SUFFIX}",
194
+ base / path,
195
+ size=int(entry.get("size", -1)),
196
+ sha256=str(entry.get("sha256", "")),
197
+ )
198
+ restored_main = base / main
199
+ if not restored_main.is_file():
200
+ msg = f"{session_dir} source archive does not contain its main transcript {main!r}"
201
+ raise SourceArchiveError(msg)
202
+ return restored_main
atif_corpus/py.typed ADDED
File without changes