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,228 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Value objects describing what a materialization pass sees and decides.
|
|
4
|
+
|
|
5
|
+
The decision pipeline is three pure pieces:
|
|
6
|
+
|
|
7
|
+
* :class:`SessionSource` — one session's source files and their mtimes, as
|
|
8
|
+
scanned (infrastructure feeds this in; the domain never stats a file).
|
|
9
|
+
* :class:`QuiescencePolicy` — the "is this session done being written?"
|
|
10
|
+
rule. A transcript is appended to many times per turn; converting a
|
|
11
|
+
session mid-write wastes a conversion and materializes a half-turn. A
|
|
12
|
+
session qualifies only once its newest source mtime is at least
|
|
13
|
+
``quiesce_seconds`` old.
|
|
14
|
+
* :class:`MaterializationPlan` / :func:`build_plan` — the deterministic
|
|
15
|
+
partition of scanned sessions into to-materialize / up-to-date /
|
|
16
|
+
skipped-live, given the recorded watermark and "now".
|
|
17
|
+
|
|
18
|
+
Clock discipline: all ``*_ns`` values are **epoch nanoseconds** — mtimes from
|
|
19
|
+
``os.stat().st_mtime_ns``, "now" from ``time.time_ns()`` passed in by the
|
|
20
|
+
caller. The domain never reads a clock, so the same inputs always yield the
|
|
21
|
+
same plan (pinned by the determinism tests).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
from loguru import logger
|
|
30
|
+
|
|
31
|
+
from atif_corpus.domain.watermark import diff_source_mtimes
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from collections.abc import Collection, Mapping, Sequence
|
|
35
|
+
|
|
36
|
+
#: Nanoseconds per second; policies are configured in seconds and convert
|
|
37
|
+
#: once at the boundary because ``st_mtime_ns`` is the native unit.
|
|
38
|
+
NANOS_PER_SECOND: int = 1_000_000_000
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class SessionSource:
|
|
43
|
+
"""One discovered session: its id and every source file with its mtime.
|
|
44
|
+
|
|
45
|
+
``source_mtimes`` covers the main ``<session_id>.jsonl`` PLUS every
|
|
46
|
+
side-file under the session dir (``subagents/agent-*.jsonl``,
|
|
47
|
+
``subagents/workflows/wf_*/agent-*.jsonl``, and any deeper future
|
|
48
|
+
nesting) — the contract requires the watermark to notice a workflow
|
|
49
|
+
side-file changing even when the main transcript did not.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
#: The session UUID (the main JSONL's stem).
|
|
53
|
+
session_id: str
|
|
54
|
+
#: Absolute path of the main ``<session_id>.jsonl`` transcript.
|
|
55
|
+
session_jsonl: str
|
|
56
|
+
#: ``{absolute path: epoch-ns mtime}`` for the main file + all side-files.
|
|
57
|
+
source_mtimes: Mapping[str, int]
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def newest_mtime_ns(self) -> int:
|
|
61
|
+
"""The most recent write across all of this session's source files."""
|
|
62
|
+
return max(self.source_mtimes.values())
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def source_files(self) -> tuple[str, ...]:
|
|
66
|
+
"""Sorted source paths — deterministic, for meta.json and diffing."""
|
|
67
|
+
return tuple(sorted(self.source_mtimes))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class QuiescencePolicy:
|
|
72
|
+
"""The rule for when a session is settled enough to convert.
|
|
73
|
+
|
|
74
|
+
A session is *quiescent* when its newest source mtime is at least
|
|
75
|
+
``quiesce_seconds`` in the past. Nothing announces that a writer finished,
|
|
76
|
+
so silence is the only available signal.
|
|
77
|
+
|
|
78
|
+
SCOPE: the quiet period is per-SESSION, not per-file. One late write to
|
|
79
|
+
any of a session's source files holds the whole session back, which is
|
|
80
|
+
what keeps a multi-file session from being materialized half-written.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
#: Seconds of source silence required before a session may materialize.
|
|
84
|
+
quiesce_seconds: int = 300
|
|
85
|
+
#: How far in the future an mtime may sit before it earns the clock warning.
|
|
86
|
+
#: The decision never uses it (a future mtime is never quiescent); it only
|
|
87
|
+
#: keeps sub-second jitter, such as a coarse filesystem timestamp or a stat
|
|
88
|
+
#: racing the clock read, from reading as a misconfigured host.
|
|
89
|
+
future_warn_seconds: float = 1.0
|
|
90
|
+
|
|
91
|
+
def is_quiescent(self, newest_mtime_ns: int, now_ns: int) -> bool:
|
|
92
|
+
"""True when the newest source write is at least ``quiesce_seconds`` old.
|
|
93
|
+
|
|
94
|
+
The boundary is inclusive (``>=``): a session exactly at the
|
|
95
|
+
threshold counts as quiescent, so the edge is testable without
|
|
96
|
+
racing a clock.
|
|
97
|
+
|
|
98
|
+
A source mtime in the FUTURE (a clock step on the writing host, a
|
|
99
|
+
copied file carrying a bogus stamp) never satisfies the threshold,
|
|
100
|
+
so the session would starve in ``skipped_live`` silently. It stays
|
|
101
|
+
skipped — converting is still the wrong call — but it warns every
|
|
102
|
+
pass so the starvation is visible instead of mute. Only an mtime more
|
|
103
|
+
than ``future_warn_seconds`` ahead warns: the caller reads "now" after
|
|
104
|
+
the scan, so a smaller lead is jitter, not a clock problem.
|
|
105
|
+
"""
|
|
106
|
+
age_ns = now_ns - newest_mtime_ns
|
|
107
|
+
if -age_ns > self.future_warn_seconds * NANOS_PER_SECOND:
|
|
108
|
+
logger.warning(
|
|
109
|
+
"quiescence: newest source mtime is {:.1f}s in the FUTURE; "
|
|
110
|
+
"session stays skipped until the clock catches up "
|
|
111
|
+
"(check the writing host's clock)",
|
|
112
|
+
-age_ns / NANOS_PER_SECOND,
|
|
113
|
+
)
|
|
114
|
+
return age_ns >= self.quiesce_seconds * NANOS_PER_SECOND
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True, slots=True)
|
|
118
|
+
class MaterializationPlan:
|
|
119
|
+
"""The deterministic work order for one materialization pass.
|
|
120
|
+
|
|
121
|
+
All three partitions are sorted by session id so the same scan always
|
|
122
|
+
yields the same plan (and the same log output, and the same failure
|
|
123
|
+
ordering).
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
#: Sessions to (re)convert: quiescent AND stale relative to the watermark.
|
|
127
|
+
to_materialize: tuple[SessionSource, ...]
|
|
128
|
+
#: Sessions whose artifacts already reflect their sources; nothing to do.
|
|
129
|
+
up_to_date: tuple[SessionSource, ...]
|
|
130
|
+
#: Sessions still being written (not quiescent); revisit next pass.
|
|
131
|
+
skipped_live: tuple[SessionSource, ...]
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def is_noop(self) -> bool:
|
|
135
|
+
"""True when the pass has nothing to convert."""
|
|
136
|
+
return not self.to_materialize
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def owns_path(session_jsonl: str, path: str) -> bool:
|
|
140
|
+
"""True when ``path`` is ``session_jsonl`` itself or one of its side-files.
|
|
141
|
+
|
|
142
|
+
The single definition of a session's watermark scope: the main JSONL
|
|
143
|
+
plus everything under ``<main-jsonl-stem>/``. Staleness and watermark
|
|
144
|
+
retention both key off this, so the two can never disagree about which
|
|
145
|
+
entries belong to a session. Keyed on the main path rather than a whole
|
|
146
|
+
:class:`SessionSource` so a session the scanner could NOT read — it has
|
|
147
|
+
a path but no mtimes — gets scoped by the same rule.
|
|
148
|
+
"""
|
|
149
|
+
stem = session_jsonl.rsplit(".", 1)[0]
|
|
150
|
+
return path == session_jsonl or path.startswith(f"{stem}/")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _is_stale(session: SessionSource, watermark: Mapping[str, int]) -> bool:
|
|
154
|
+
"""True when the recorded watermark fails to describe this session's sources.
|
|
155
|
+
|
|
156
|
+
Delegates the comparison to
|
|
157
|
+
:func:`atif_corpus.domain.watermark.diff_source_mtimes` over this
|
|
158
|
+
session's scope: a source file added, changed in either mtime direction,
|
|
159
|
+
or *removed* since the last pass all make the session stale. Removal
|
|
160
|
+
counts because the materialized trajectory still embeds the vanished
|
|
161
|
+
file's records.
|
|
162
|
+
"""
|
|
163
|
+
recorded = {
|
|
164
|
+
path: mtime for path, mtime in watermark.items() if owns_path(session.session_jsonl, path)
|
|
165
|
+
}
|
|
166
|
+
return not diff_source_mtimes(recorded, session.source_mtimes).is_empty
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def build_plan(
|
|
170
|
+
sessions: Sequence[SessionSource],
|
|
171
|
+
*,
|
|
172
|
+
watermark: Mapping[str, int],
|
|
173
|
+
policy: QuiescencePolicy,
|
|
174
|
+
now_ns: int,
|
|
175
|
+
force: bool = False,
|
|
176
|
+
unmaterialized_session_ids: Collection[str] = (),
|
|
177
|
+
generation_stale_session_ids: Collection[str] = (),
|
|
178
|
+
) -> MaterializationPlan:
|
|
179
|
+
"""Partition scanned sessions into the three plan buckets.
|
|
180
|
+
|
|
181
|
+
Decision order per the contract: a session is (re)materialized when its
|
|
182
|
+
newest source mtime is quiescent AND the watermark says its sources
|
|
183
|
+
moved. ``force`` overrides ONLY the staleness check — a live session is
|
|
184
|
+
never converted even under force, because converting a half-written
|
|
185
|
+
transcript produces a wrong artifact rather than a stale one.
|
|
186
|
+
|
|
187
|
+
Parameters
|
|
188
|
+
----------
|
|
189
|
+
sessions
|
|
190
|
+
Every session the scanner discovered under the source root.
|
|
191
|
+
watermark
|
|
192
|
+
The recorded ``{path: mtime_ns}`` map from the previous pass
|
|
193
|
+
(``watermark.json``); empty on first run.
|
|
194
|
+
policy
|
|
195
|
+
The quiescence rule.
|
|
196
|
+
now_ns
|
|
197
|
+
Epoch nanoseconds "now", read once by the caller — never here.
|
|
198
|
+
force
|
|
199
|
+
Re-materialize every quiescent session regardless of the watermark.
|
|
200
|
+
unmaterialized_session_ids
|
|
201
|
+
Sessions the watermark claims are current but whose artifact dir is
|
|
202
|
+
absent on disk (a pass killed mid-swap). Treated as stale so the
|
|
203
|
+
artifacts come back; the watermark alone cannot express this, since
|
|
204
|
+
it records SOURCE mtimes and knows nothing about the corpus dir.
|
|
205
|
+
generation_stale_session_ids
|
|
206
|
+
Sessions whose recorded provenance (``converter_schema``,
|
|
207
|
+
``columnar_schema``) differs from what this pass would stamp, per
|
|
208
|
+
:func:`atif_corpus.domain.generation.generation_matches`. Treated as
|
|
209
|
+
stale: the source did not move, but the code that turns it into
|
|
210
|
+
artifacts did.
|
|
211
|
+
"""
|
|
212
|
+
ordered = sorted(sessions, key=lambda s: s.session_id)
|
|
213
|
+
missing = set(unmaterialized_session_ids) | set(generation_stale_session_ids)
|
|
214
|
+
to_materialize: list[SessionSource] = []
|
|
215
|
+
up_to_date: list[SessionSource] = []
|
|
216
|
+
skipped_live: list[SessionSource] = []
|
|
217
|
+
for session in ordered:
|
|
218
|
+
if not policy.is_quiescent(session.newest_mtime_ns, now_ns):
|
|
219
|
+
skipped_live.append(session)
|
|
220
|
+
elif force or session.session_id in missing or _is_stale(session, watermark):
|
|
221
|
+
to_materialize.append(session)
|
|
222
|
+
else:
|
|
223
|
+
up_to_date.append(session)
|
|
224
|
+
return MaterializationPlan(
|
|
225
|
+
to_materialize=tuple(to_materialize),
|
|
226
|
+
up_to_date=tuple(up_to_date),
|
|
227
|
+
skipped_live=tuple(skipped_live),
|
|
228
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Map one corpus source root to a stable, human-legible directory key.
|
|
4
|
+
|
|
5
|
+
The slug is an ON-DISK NAME, not an internal identifier: it is the
|
|
6
|
+
``<slug>`` in ``~/.atif-sql/corpus/<slug>/``, so this function's output is
|
|
7
|
+
part of every installed user's filesystem layout.
|
|
8
|
+
|
|
9
|
+
Changing the algorithm therefore ORPHANS existing corpora rather than
|
|
10
|
+
migrating them. ``materialize`` starts writing a fresh tree, ``status``
|
|
11
|
+
reports an empty corpus, ``query`` binds zero sessions, and the watermarks
|
|
12
|
+
and LanceDB embedding store at the no-longer-computed path are stranded,
|
|
13
|
+
re-earned only by a full re-materialize and re-embed. ``tests/test_slug.py``
|
|
14
|
+
freezes concrete outputs so such a change fails a test, not a user's install.
|
|
15
|
+
|
|
16
|
+
If a change here is genuinely wanted, it needs all three of: the frozen
|
|
17
|
+
values in ``tests/test_slug.py`` updated, a migration for corpora already on
|
|
18
|
+
disk, and the two call sites that compose the path
|
|
19
|
+
(``_default_corpus_root`` in :mod:`atif_corpus.infrastructure.settings` and
|
|
20
|
+
the ``--corpus-root`` fallback in ``atif_cli.app``) reviewed together.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import re
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
#: Reserved slug for the interactive corpus at ``~/.claude`` — the root nearly
|
|
30
|
+
#: every user has. It is reserved rather than hashed so the common case has a
|
|
31
|
+
#: dirname a human can recognize in ``ls``.
|
|
32
|
+
DEFAULT_CORPUS_KEY = "default"
|
|
33
|
+
|
|
34
|
+
#: Reserved slug for the Codex rollout corpus at ``~/.codex/sessions``. Same
|
|
35
|
+
#: reasoning as :data:`DEFAULT_CORPUS_KEY`, and safe to add rather than a
|
|
36
|
+
#: breaking change: this workspace could not read a Codex rollout before
|
|
37
|
+
#: 2026-09-11, so no corpus can already occupy the hashed name this replaces
|
|
38
|
+
#: (``sessions-<8 hex>``). Reserved on the SESSIONS dir rather than on
|
|
39
|
+
#: ``~/.codex`` because the sessions dir is the source root a scan walks.
|
|
40
|
+
CODEX_CORPUS_KEY = "codex"
|
|
41
|
+
|
|
42
|
+
#: Any run of characters outside ``[a-z0-9]`` collapses to a single ``-``.
|
|
43
|
+
_SLUG_SANITIZE_RE = re.compile(r"[^a-z0-9]+")
|
|
44
|
+
|
|
45
|
+
#: Cap on the legible dirname component; the hash suffix carries uniqueness.
|
|
46
|
+
_SLUG_MAX_NAME_LEN = 32
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def corpus_slug(corpus_root: Path | str) -> str:
|
|
50
|
+
"""Map one corpus root to a stable, human-legible directory key.
|
|
51
|
+
|
|
52
|
+
Pure: no I/O beyond path resolution. The interactive corpus
|
|
53
|
+
(``~/.claude``, however it is spelled) maps to the reserved key
|
|
54
|
+
:data:`DEFAULT_CORPUS_KEY`. Every other root maps to
|
|
55
|
+
``<sanitized-dirname>-<8-hex sha256 of the resolved path>`` — legible
|
|
56
|
+
enough to eyeball in ``ls``, hashed enough that two roots sharing a
|
|
57
|
+
dirname (``…/alice/.claude`` vs ``…/bob/.claude``) never collide.
|
|
58
|
+
|
|
59
|
+
The Codex rollout root (``~/.codex/sessions``) maps to
|
|
60
|
+
:data:`CODEX_CORPUS_KEY` for the same legibility reason.
|
|
61
|
+
|
|
62
|
+
The root is ``expanduser().resolve()``-normalized first so symlinked
|
|
63
|
+
spellings of the same corpus agree on one key.
|
|
64
|
+
"""
|
|
65
|
+
resolved = Path(corpus_root).expanduser().resolve()
|
|
66
|
+
if resolved == Path("~/.claude").expanduser().resolve():
|
|
67
|
+
return DEFAULT_CORPUS_KEY
|
|
68
|
+
if resolved == Path("~/.codex/sessions").expanduser().resolve():
|
|
69
|
+
return CODEX_CORPUS_KEY
|
|
70
|
+
name = _SLUG_SANITIZE_RE.sub("-", resolved.name.lower()).strip("-")[:_SLUG_MAX_NAME_LEN]
|
|
71
|
+
digest = hashlib.sha256(str(resolved).encode("utf-8")).hexdigest()[:8]
|
|
72
|
+
return f"{name}-{digest}" if name else digest
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Where an agent's transcripts live, as one value object per agent.
|
|
4
|
+
|
|
5
|
+
Discovery used to be a single hardcoded shape — project directories one level
|
|
6
|
+
under the source root, one JSONL per session — because Claude Code was the only
|
|
7
|
+
agent. Codex CLI writes nothing like it: rollouts sit three levels deep under
|
|
8
|
+
``$CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/`` and the session id is a uuid inside
|
|
9
|
+
the FILENAME rather than the whole stem. Both shapes are now described here, as
|
|
10
|
+
pure data plus pure functions, so the scanner walks one algorithm and the
|
|
11
|
+
per-agent knowledge lives in exactly one place.
|
|
12
|
+
|
|
13
|
+
Pure module: no filesystem access, no env, no logging. Everything it returns is
|
|
14
|
+
derived from a path's TEXT.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import PurePosixPath
|
|
21
|
+
|
|
22
|
+
from atif_corpus.domain.agents import AgentSource
|
|
23
|
+
|
|
24
|
+
#: A Codex rollout filename's fixed prefix, and the number of hyphen-separated
|
|
25
|
+
#: groups its trailing uuid occupies: ``rollout-<ts>-<8>-<4>-<4>-<4>-<12>.jsonl``.
|
|
26
|
+
_ROLLOUT_PREFIX = "rollout-"
|
|
27
|
+
_UUID_GROUPS = 5
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class SourceLayout:
|
|
32
|
+
"""How one agent's transcripts are arranged under a source root.
|
|
33
|
+
|
|
34
|
+
``transcript_depth`` is how many directory levels sit between the source
|
|
35
|
+
root and a transcript file: 1 for Claude Code (``<project>/<session>.jsonl``)
|
|
36
|
+
and 3 for Codex (``<YYYY>/<MM>/<DD>/rollout-*.jsonl``). The scanner descends
|
|
37
|
+
exactly that many levels, listing each one explicitly, which is what keeps a
|
|
38
|
+
directory it could not OPEN distinguishable from an empty one at every level
|
|
39
|
+
rather than only at the first.
|
|
40
|
+
|
|
41
|
+
``has_side_files`` says whether a session can own a sibling directory of
|
|
42
|
+
additional transcripts. Claude Code sessions do (subagent and workflow
|
|
43
|
+
side-files, which the watermark must notice); a Codex sub-agent writes its
|
|
44
|
+
own rollout under its own session id, so a rollout is always alone.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
agent: AgentSource
|
|
48
|
+
transcript_depth: int
|
|
49
|
+
has_side_files: bool
|
|
50
|
+
|
|
51
|
+
def is_transcript(self, name: str) -> bool:
|
|
52
|
+
"""Whether a filename in a transcript directory is one of this agent's."""
|
|
53
|
+
if not name.endswith(".jsonl"):
|
|
54
|
+
return False
|
|
55
|
+
if self.agent is AgentSource.CODEX:
|
|
56
|
+
return name.startswith(_ROLLOUT_PREFIX)
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
def session_id(self, name: str) -> str | None:
|
|
60
|
+
"""The session id a transcript filename carries, or ``None`` if it carries none.
|
|
61
|
+
|
|
62
|
+
Claude Code names the file after the session, so the stem IS the id.
|
|
63
|
+
Codex prefixes a timestamp, so the id is the stem's trailing uuid — and
|
|
64
|
+
a name whose shape does not yield one is not a session this layout can
|
|
65
|
+
materialize, which the caller must skip rather than guess at.
|
|
66
|
+
"""
|
|
67
|
+
if not self.is_transcript(name):
|
|
68
|
+
return None
|
|
69
|
+
stem = name.removesuffix(".jsonl")
|
|
70
|
+
if self.agent is not AgentSource.CODEX:
|
|
71
|
+
return stem or None
|
|
72
|
+
parts = stem.split("-")
|
|
73
|
+
if len(parts) < _UUID_GROUPS + 1:
|
|
74
|
+
return None
|
|
75
|
+
return "-".join(parts[-_UUID_GROUPS:])
|
|
76
|
+
|
|
77
|
+
def session_from_watermark_path(self, source_root: str, path: str) -> tuple[str, str] | None:
|
|
78
|
+
"""``(session_id, main transcript path)`` for a recorded watermark path.
|
|
79
|
+
|
|
80
|
+
Used when a directory could not be LISTED: nothing was discovered
|
|
81
|
+
there, so the watermark is the only record of which sessions lived
|
|
82
|
+
inside it, and each recorded path must resolve back to a session id
|
|
83
|
+
WITHOUT touching the filesystem.
|
|
84
|
+
|
|
85
|
+
Returns ``None`` when the path is not under ``source_root`` or its shape
|
|
86
|
+
does not name a session.
|
|
87
|
+
"""
|
|
88
|
+
root_prefix = f"{source_root.rstrip('/')}/"
|
|
89
|
+
if not path.startswith(root_prefix):
|
|
90
|
+
return None
|
|
91
|
+
segments = PurePosixPath(path[len(root_prefix) :]).parts
|
|
92
|
+
if len(segments) <= self.transcript_depth:
|
|
93
|
+
return None
|
|
94
|
+
if self.agent is AgentSource.CODEX:
|
|
95
|
+
# A rollout owns no side-files, so a recorded path IS a main
|
|
96
|
+
# transcript and its own name carries the id.
|
|
97
|
+
session_id = self.session_id(segments[self.transcript_depth])
|
|
98
|
+
return (session_id, path) if session_id else None
|
|
99
|
+
# Claude Code: `<project>/<session>.jsonl`, or a side-file under
|
|
100
|
+
# `<project>/<session>/...`. Either way the second segment names the
|
|
101
|
+
# session, so the main transcript is derivable from position alone.
|
|
102
|
+
project, entry = segments[0], segments[1]
|
|
103
|
+
session_id = entry.removesuffix(".jsonl")
|
|
104
|
+
if not session_id:
|
|
105
|
+
return None
|
|
106
|
+
return session_id, f"{root_prefix}{project}/{session_id}.jsonl"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
#: Claude Code: `<source_root>/<project>/<session>.jsonl` plus a side-file tree
|
|
110
|
+
#: under `<source_root>/<project>/<session>/`.
|
|
111
|
+
CLAUDE_CODE_LAYOUT = SourceLayout(
|
|
112
|
+
agent=AgentSource.CLAUDE_CODE,
|
|
113
|
+
transcript_depth=1,
|
|
114
|
+
has_side_files=True,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
#: Codex CLI: `<source_root>/<YYYY>/<MM>/<DD>/rollout-<ts>-<uuid>.jsonl`, one
|
|
118
|
+
#: file per session and no side-files.
|
|
119
|
+
CODEX_LAYOUT = SourceLayout(
|
|
120
|
+
agent=AgentSource.CODEX,
|
|
121
|
+
transcript_depth=3,
|
|
122
|
+
has_side_files=False,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
_LAYOUTS: dict[AgentSource, SourceLayout] = {
|
|
126
|
+
AgentSource.CLAUDE_CODE: CLAUDE_CODE_LAYOUT,
|
|
127
|
+
AgentSource.CODEX: CODEX_LAYOUT,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def layout_for(agent: AgentSource) -> SourceLayout:
|
|
132
|
+
"""The layout for ``agent``.
|
|
133
|
+
|
|
134
|
+
Every :class:`~atif_corpus.domain.agents.AgentSource` member has one, and
|
|
135
|
+
the lookup raises rather than defaulting: a new agent added to the enum
|
|
136
|
+
without a layout must fail loudly at the first scan instead of silently
|
|
137
|
+
scanning with Claude Code's shape and reporting an empty corpus.
|
|
138
|
+
"""
|
|
139
|
+
try:
|
|
140
|
+
return _LAYOUTS[agent]
|
|
141
|
+
except KeyError as exc: # pragma: no cover — unreachable while the enum is closed
|
|
142
|
+
msg = f"no source layout declared for agent {agent!r}"
|
|
143
|
+
raise ValueError(msg) from exc
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
"""Pure watermark math: which source files moved between two scans.
|
|
4
|
+
|
|
5
|
+
Compare two ``{path: mtime_ns}`` maps and partition into added / modified /
|
|
6
|
+
removed. ``touched`` (added then modified) are the sessions whose artifacts
|
|
7
|
+
must be re-materialized; ``removed`` are sessions whose source vanished. Pure
|
|
8
|
+
so the delta can be tested without a filesystem and so two identical scans
|
|
9
|
+
always produce the same plan.
|
|
10
|
+
|
|
11
|
+
Clock discipline: every ``mtime_ns`` here is a filesystem modification time
|
|
12
|
+
in **epoch nanoseconds** (``os.stat().st_mtime_ns``). Nothing in this module
|
|
13
|
+
reads a clock; "now" is always passed in by the caller.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Mapping
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class SourceDelta:
|
|
27
|
+
"""Which source files moved between two watermark snapshots.
|
|
28
|
+
|
|
29
|
+
All three partitions are sorted tuples so a delta is deterministic and
|
|
30
|
+
comparable — the materialization plan embeds these paths, and set
|
|
31
|
+
ordering would make two identical scans plan different work orders.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
#: Paths present in the current scan and absent from the previous one.
|
|
35
|
+
added: tuple[str, ...]
|
|
36
|
+
#: Paths present in both whose ``mtime_ns`` changed (either direction).
|
|
37
|
+
modified: tuple[str, ...]
|
|
38
|
+
#: Paths present in the previous scan and absent from the current one.
|
|
39
|
+
removed: tuple[str, ...]
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def touched(self) -> tuple[str, ...]:
|
|
43
|
+
"""Paths whose artifacts must be re-materialized (``added`` then ``modified``)."""
|
|
44
|
+
return (*self.added, *self.modified)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_empty(self) -> bool:
|
|
48
|
+
"""True when nothing moved, so a materialization pass would be a no-op."""
|
|
49
|
+
return not (self.added or self.modified or self.removed)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def changed_count(self) -> int:
|
|
53
|
+
"""Total number of paths in the delta, across all three partitions."""
|
|
54
|
+
return len(self.added) + len(self.modified) + len(self.removed)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def diff_source_mtimes(
|
|
58
|
+
previous: Mapping[str, int],
|
|
59
|
+
current: Mapping[str, int],
|
|
60
|
+
) -> SourceDelta:
|
|
61
|
+
"""Partition two ``{path: mtime_ns}`` maps into added / modified / removed.
|
|
62
|
+
|
|
63
|
+
A path is ``modified`` only when its mtime **changed**. An mtime that went
|
|
64
|
+
backwards (a restored backup, a clock step on the writing host, a file
|
|
65
|
+
replaced by an older copy) also counts as modified: the recorded watermark
|
|
66
|
+
no longer describes the bytes on disk, so the session must be re-read.
|
|
67
|
+
Only an exactly-equal mtime is treated as unchanged.
|
|
68
|
+
"""
|
|
69
|
+
added = sorted(path for path in current if path not in previous)
|
|
70
|
+
removed = sorted(path for path in previous if path not in current)
|
|
71
|
+
modified = sorted(
|
|
72
|
+
path
|
|
73
|
+
for path, mtime_ns in current.items()
|
|
74
|
+
if path in previous and previous[path] != mtime_ns
|
|
75
|
+
)
|
|
76
|
+
return SourceDelta(added=tuple(added), modified=tuple(modified), removed=tuple(removed))
|