cctally 1.72.0 → 1.73.0
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.
- package/CHANGELOG.md +11 -0
- package/bin/_cctally_cache.py +245 -50
- package/bin/_cctally_core.py +20 -40
- package/bin/_cctally_dashboard_conversation.py +731 -94
- package/bin/_cctally_doctor.py +59 -0
- package/bin/_cctally_parser.py +17 -4
- package/bin/_cctally_record.py +162 -33
- package/bin/_cctally_refresh.py +58 -36
- package/bin/_cctally_statusline.py +811 -82
- package/bin/_cctally_transcript.py +223 -7
- package/bin/_lib_codex_conversation_export.py +124 -0
- package/bin/_lib_codex_conversation_query.py +500 -28
- package/bin/_lib_codex_conversation_watch.py +323 -0
- package/bin/_lib_conversation_dispatch.py +269 -7
- package/bin/_lib_conversation_query.py +88 -0
- package/bin/_lib_doctor.py +62 -0
- package/bin/_lib_statusline_candidates.py +734 -0
- package/bin/cctally +31 -2
- package/package.json +4 -1
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Pure directory-frontier kernel for Codex live-tail child discovery (spec §5.4).
|
|
2
|
+
|
|
3
|
+
Layout-agnostic, budget-bounded, incremental discovery of brand-new Codex
|
|
4
|
+
rollout files that no table yet knows — the second of the two live-tail child-
|
|
5
|
+
discovery layers (the first being the driver's DB re-resolve of the watched
|
|
6
|
+
conversation's file set). This module has NO I/O of its own: the filesystem
|
|
7
|
+
``scandir`` / directory-``mtime`` / file-``size`` reads and the clock are
|
|
8
|
+
injected as callables, so the whole cycle is unit-testable without a real tree,
|
|
9
|
+
real timing, or a DB. The thin SSE driver in
|
|
10
|
+
``bin/_cctally_dashboard_conversation.py`` owns the DB re-resolve, the targeted
|
|
11
|
+
ingest of the paths this kernel surfaces, and the child classification that
|
|
12
|
+
feeds ``reap``; this kernel owns only the directory frontier, the pending-
|
|
13
|
+
candidate set, and the shared per-cycle operation budget.
|
|
14
|
+
|
|
15
|
+
All six §5.4 bullets are contract:
|
|
16
|
+
|
|
17
|
+
1. **walk_root-only seed.** The frontier index starts as JUST the configured
|
|
18
|
+
``CodexProviderRoot.walk_root`` — construction walks nothing. Directories are
|
|
19
|
+
discovered incrementally as the frontier is enumerated.
|
|
20
|
+
2. **One shared per-cycle operation budget** covering directory ``stat``s,
|
|
21
|
+
directory enumerations, and pending-candidate ``stat``s, with round-robin
|
|
22
|
+
fairness across the three work classes and rotating continuation cursors, so
|
|
23
|
+
a large tree or a large candidate population can neither starve the frontier
|
|
24
|
+
nor blow the cycle's ceiling. Every known directory / candidate is
|
|
25
|
+
*eventually* visited — not all of them every cycle.
|
|
26
|
+
3. **Re-enumeration on own-mtime change** or new frontier membership — never on
|
|
27
|
+
an ancestor's mtime (POSIX/APFS bumps only the immediate parent's mtime on
|
|
28
|
+
entry creation, so ancestor-mtime pruning would be unsound).
|
|
29
|
+
4. **Symlink boundary.** Descendant directory/file entries are traversed with
|
|
30
|
+
``follow_symlinks=False`` semantics: a symlinked entry is NEVER traversed, so
|
|
31
|
+
every directory the frontier reaches is connected to ``walk_root`` by a chain
|
|
32
|
+
of physical (non-symlink) parent links and therefore stays physically inside
|
|
33
|
+
the canonical ``walk_root`` — the realpath-containment guarantee, achieved
|
|
34
|
+
structurally. The configured ``walk_root`` spelling itself is preserved (it is
|
|
35
|
+
the seed and is never realpath-rewritten), matching discovery's configured-
|
|
36
|
+
spelling-vs-physical distinction.
|
|
37
|
+
5. **Rotation watermark pinned to rotation-start.** A "rotation" is one full pass
|
|
38
|
+
of the stat cursor over the frontier. The completed-rotation watermark
|
|
39
|
+
advances only when a rotation completes and is pinned to that rotation's
|
|
40
|
+
START time, so an entry created mid-rotation (which changes its own
|
|
41
|
+
directory's mtime) is re-examined on the next rotation.
|
|
42
|
+
6. **Pending candidates.** Every newly discovered file joins ``pending`` BEFORE
|
|
43
|
+
its first ingest attempt (so a discovered file is never lost even if its
|
|
44
|
+
first ingest is dirty/lock-contended and leaves no cursor row). A path is
|
|
45
|
+
retried while unclassified: with no committed cursor it is surfaced every
|
|
46
|
+
time it is visited; with a committed cursor it is surfaced only when its size
|
|
47
|
+
exceeds that cursor (an incomplete-``session_meta`` tail completing without
|
|
48
|
+
any directory-mtime change). A path leaves the set when the driver ``reap``s
|
|
49
|
+
it (it gained a thread row — child or non-child), when it vanishes, or on a
|
|
50
|
+
bounded per-candidate expiry (handed to the next full sync).
|
|
51
|
+
"""
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import os
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ── production-default injected callables ─────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def default_scandir(dirpath):
|
|
61
|
+
"""Yield ``(name, path, is_dir, is_symlink)`` for the entries of ``dirpath``.
|
|
62
|
+
|
|
63
|
+
``is_dir`` uses ``follow_symlinks=False`` (a symlink-to-dir reads as NOT a
|
|
64
|
+
dir), and ``is_symlink`` flags any symlink so the frontier can refuse to
|
|
65
|
+
traverse it. Returns ``[]`` on any ``OSError`` (vanished / permission)."""
|
|
66
|
+
out = []
|
|
67
|
+
try:
|
|
68
|
+
with os.scandir(dirpath) as it:
|
|
69
|
+
for de in it:
|
|
70
|
+
try:
|
|
71
|
+
is_sym = de.is_symlink()
|
|
72
|
+
is_dir = de.is_dir(follow_symlinks=False)
|
|
73
|
+
except OSError:
|
|
74
|
+
continue
|
|
75
|
+
out.append((de.name, de.path, is_dir, is_sym))
|
|
76
|
+
except OSError:
|
|
77
|
+
return []
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def default_dir_mtime(dirpath):
|
|
82
|
+
"""The directory's own mtime (``st_mtime_ns``), or ``None`` if it can't be
|
|
83
|
+
stat'd. Follows a symlinked ``walk_root`` (the seed is intentionally the
|
|
84
|
+
configured spelling)."""
|
|
85
|
+
try:
|
|
86
|
+
return os.stat(dirpath).st_mtime_ns
|
|
87
|
+
except OSError:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def default_file_size(filepath):
|
|
92
|
+
"""Regular-file size in bytes, or ``None`` if the path is gone or not a
|
|
93
|
+
regular file (size-only, matching sync_codex_cache's append-only delta)."""
|
|
94
|
+
try:
|
|
95
|
+
st = os.stat(filepath)
|
|
96
|
+
except OSError:
|
|
97
|
+
return None
|
|
98
|
+
import stat as _stat
|
|
99
|
+
if not _stat.S_ISREG(st.st_mode):
|
|
100
|
+
return None
|
|
101
|
+
return st.st_size
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_SENTINEL = object()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class CodexChildFrontier:
|
|
108
|
+
"""The budgeted directory frontier + pending-candidate set for one watched
|
|
109
|
+
Codex conversation's root (spec §5.4). See the module docstring for the
|
|
110
|
+
contract. ``cycle`` is the only per-tick entry point; ``reap`` is called by
|
|
111
|
+
the driver after a targeted ingest classifies discovered candidates."""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
walk_root,
|
|
116
|
+
*,
|
|
117
|
+
op_budget: int = 64,
|
|
118
|
+
pending_expiry_cycles: int = 50,
|
|
119
|
+
scandir_fn=default_scandir,
|
|
120
|
+
dir_mtime_fn=default_dir_mtime,
|
|
121
|
+
file_size_fn=default_file_size,
|
|
122
|
+
clock_fn=None,
|
|
123
|
+
):
|
|
124
|
+
self._walk_root = str(walk_root)
|
|
125
|
+
self._op_budget = max(1, int(op_budget))
|
|
126
|
+
self._pending_expiry = max(1, int(pending_expiry_cycles))
|
|
127
|
+
self._scandir = scandir_fn
|
|
128
|
+
self._dir_mtime = dir_mtime_fn
|
|
129
|
+
self._file_size = file_size_fn
|
|
130
|
+
self._clock = clock_fn or (lambda: 0)
|
|
131
|
+
|
|
132
|
+
# Frontier index — seeded with JUST walk_root (no enumeration at start).
|
|
133
|
+
self._dirs: list[str] = [self._walk_root]
|
|
134
|
+
self._dir_set: set[str] = {self._walk_root}
|
|
135
|
+
# dir -> mtime observed when it was last enumerated (absent => never).
|
|
136
|
+
self._enum_mtime: dict[str, int] = {}
|
|
137
|
+
# dirs awaiting a scandir, each carried with the mtime that triggered it.
|
|
138
|
+
self._enum_queue: list[tuple[str, int]] = []
|
|
139
|
+
|
|
140
|
+
# Pending candidates: path -> age (cycles since registration).
|
|
141
|
+
self._pending: dict[str, int] = {}
|
|
142
|
+
|
|
143
|
+
# Rotating continuation cursors (persist across cycles).
|
|
144
|
+
self._stat_cursor = 0
|
|
145
|
+
self._pending_rr = 0 # where the pending sweep resumes
|
|
146
|
+
self._rotation_start = None
|
|
147
|
+
self._completed_rotation_watermark = None
|
|
148
|
+
self._rotation_count = 0
|
|
149
|
+
|
|
150
|
+
# ── introspection (tests / diagnostics) ──────────────────────────────────
|
|
151
|
+
|
|
152
|
+
def known_directories(self) -> set[str]:
|
|
153
|
+
return set(self._dir_set)
|
|
154
|
+
|
|
155
|
+
def pending_candidates(self) -> set[str]:
|
|
156
|
+
return set(self._pending)
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def rotation_watermark(self):
|
|
160
|
+
return self._completed_rotation_watermark
|
|
161
|
+
|
|
162
|
+
# ── driver feedback ───────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
def reap(self, classified_paths) -> None:
|
|
165
|
+
"""Drop pending candidates the driver has classified (they gained a
|
|
166
|
+
thread row — child or non-child). A child is now in the watched file set
|
|
167
|
+
and tailed by the main loop; a non-child needs no further attention."""
|
|
168
|
+
for p in classified_paths:
|
|
169
|
+
self._pending.pop(p, None)
|
|
170
|
+
|
|
171
|
+
# ── one discovery cycle ───────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
def cycle(self, *, known_paths, committed_sizes) -> set[str]:
|
|
174
|
+
"""One budgeted discovery cycle (§5.4).
|
|
175
|
+
|
|
176
|
+
``known_paths`` — every path already tracked in ``codex_session_files``,
|
|
177
|
+
so a newly enumerated file already known to some table is not
|
|
178
|
+
re-registered. ``committed_sizes`` — ``{path: size_bytes}`` committed
|
|
179
|
+
cursors, for the pending-growth check.
|
|
180
|
+
|
|
181
|
+
Returns the set of candidate paths to targeted-ingest this cycle (new
|
|
182
|
+
discoveries whose first ingest hasn't landed + pending retries that grew
|
|
183
|
+
or remain unclassified). Bounded to ``op_budget`` filesystem operations,
|
|
184
|
+
round-robin fair across directory stats, enumerations, and pending
|
|
185
|
+
checks."""
|
|
186
|
+
self._known = set(known_paths)
|
|
187
|
+
self._committed = committed_sizes or {}
|
|
188
|
+
ingest: set[str] = set()
|
|
189
|
+
|
|
190
|
+
# Start (or continue) a rotation of the stat cursor over the frontier.
|
|
191
|
+
if self._rotation_start is None or self._stat_cursor >= len(self._dirs):
|
|
192
|
+
self._begin_rotation()
|
|
193
|
+
|
|
194
|
+
# Per-cycle pending sweep window: a stable snapshot of the pending keys
|
|
195
|
+
# plus a persistent rotating start offset, so a large candidate
|
|
196
|
+
# population is visited over successive cycles (never all every cycle).
|
|
197
|
+
self._pending_keys = list(self._pending.keys())
|
|
198
|
+
self._pending_start = (
|
|
199
|
+
self._pending_rr % len(self._pending_keys)
|
|
200
|
+
if self._pending_keys else 0)
|
|
201
|
+
self._pending_checked = 0
|
|
202
|
+
|
|
203
|
+
ops = 0
|
|
204
|
+
stalled = 0
|
|
205
|
+
lane_idx = 0
|
|
206
|
+
while ops < self._op_budget and stalled < 3:
|
|
207
|
+
lane = lane_idx % 3
|
|
208
|
+
if lane == 0:
|
|
209
|
+
did = self._op_stat()
|
|
210
|
+
elif lane == 1:
|
|
211
|
+
did = self._op_enum(ingest)
|
|
212
|
+
else:
|
|
213
|
+
did = self._op_pending(ingest)
|
|
214
|
+
if did:
|
|
215
|
+
ops += 1
|
|
216
|
+
stalled = 0
|
|
217
|
+
else:
|
|
218
|
+
stalled += 1
|
|
219
|
+
lane_idx += 1
|
|
220
|
+
|
|
221
|
+
# Persist the pending rotation offset so the next cycle resumes past the
|
|
222
|
+
# candidates already visited this cycle (round-robin continuation).
|
|
223
|
+
if self._pending_keys:
|
|
224
|
+
self._pending_rr = self._pending_start + self._pending_checked
|
|
225
|
+
|
|
226
|
+
self._age_pending()
|
|
227
|
+
return ingest
|
|
228
|
+
|
|
229
|
+
# ── work lanes (each does at most ONE filesystem op per call) ─────────────
|
|
230
|
+
|
|
231
|
+
def _op_stat(self) -> bool:
|
|
232
|
+
"""Stat one frontier directory (rotating cursor). A directory that is new
|
|
233
|
+
to the frontier or whose own mtime changed since its last enumeration is
|
|
234
|
+
queued for (re-)enumeration (§5.4 bullet 3). Returns True iff it consumed
|
|
235
|
+
a filesystem op."""
|
|
236
|
+
if self._stat_cursor >= len(self._dirs):
|
|
237
|
+
return False
|
|
238
|
+
d = self._dirs[self._stat_cursor]
|
|
239
|
+
self._stat_cursor += 1
|
|
240
|
+
mt = self._dir_mtime(d) # 1 fs op
|
|
241
|
+
if mt is not None:
|
|
242
|
+
prev = self._enum_mtime.get(d, _SENTINEL)
|
|
243
|
+
if (prev is _SENTINEL or prev != mt) and not self._is_queued(d):
|
|
244
|
+
self._enum_queue.append((d, mt))
|
|
245
|
+
if self._stat_cursor >= len(self._dirs):
|
|
246
|
+
self._complete_rotation()
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
def _op_enum(self, ingest: set) -> bool:
|
|
250
|
+
"""Enumerate one queued directory (scandir). Records the pre-enumeration
|
|
251
|
+
mtime as the dir's baseline (so a create landing during the enumeration
|
|
252
|
+
is re-examined next rotation), skips symlinked descendants (the
|
|
253
|
+
``follow_symlinks=False`` boundary), adds real subdirectories to the
|
|
254
|
+
frontier, and registers brand-new ``*.jsonl`` files in ``pending`` BEFORE
|
|
255
|
+
their first ingest — surfacing each for that first ingest attempt in the
|
|
256
|
+
SAME cycle it is discovered (the pending sweep then owns the retries).
|
|
257
|
+
Returns True iff it consumed a filesystem op."""
|
|
258
|
+
if not self._enum_queue:
|
|
259
|
+
return False
|
|
260
|
+
d, mt = self._enum_queue.pop(0)
|
|
261
|
+
entries = self._scandir(d) # 1 fs op
|
|
262
|
+
self._enum_mtime[d] = mt
|
|
263
|
+
for (name, path, is_dir, is_sym) in entries:
|
|
264
|
+
if is_sym:
|
|
265
|
+
continue # never traverse a symlink
|
|
266
|
+
if is_dir:
|
|
267
|
+
if path not in self._dir_set:
|
|
268
|
+
self._dir_set.add(path)
|
|
269
|
+
self._dirs.append(path) # new subdir joins frontier
|
|
270
|
+
elif path.endswith(".jsonl"):
|
|
271
|
+
if path not in self._known and path not in self._pending:
|
|
272
|
+
self._pending[path] = 0 # register BEFORE first ingest
|
|
273
|
+
ingest.add(path) # first ingest attempt now
|
|
274
|
+
return True
|
|
275
|
+
|
|
276
|
+
def _op_pending(self, ingest: set) -> bool:
|
|
277
|
+
"""Check one pending candidate (rotating cursor). Vanished → dropped; no
|
|
278
|
+
committed cursor (brand-new / lock-contended first ingest) → surfaced for
|
|
279
|
+
(re-)ingest; grown beyond its committed cursor → surfaced. Returns True
|
|
280
|
+
iff it consumed a filesystem op."""
|
|
281
|
+
keys = self._pending_keys
|
|
282
|
+
n = len(keys)
|
|
283
|
+
while self._pending_checked < n:
|
|
284
|
+
idx = (self._pending_start + self._pending_checked) % n
|
|
285
|
+
self._pending_checked += 1
|
|
286
|
+
p = keys[idx]
|
|
287
|
+
if p not in self._pending: # reaped/dropped mid-cycle
|
|
288
|
+
continue
|
|
289
|
+
size = self._file_size(p) # 1 fs op
|
|
290
|
+
if size is None:
|
|
291
|
+
self._pending.pop(p, None) # vanished → drop
|
|
292
|
+
else:
|
|
293
|
+
committed = self._committed.get(p)
|
|
294
|
+
if committed is None or size > committed:
|
|
295
|
+
ingest.add(p)
|
|
296
|
+
return True
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
# ── rotation + expiry bookkeeping (no filesystem ops) ─────────────────────
|
|
300
|
+
|
|
301
|
+
def _begin_rotation(self) -> None:
|
|
302
|
+
self._stat_cursor = 0
|
|
303
|
+
self._rotation_start = self._clock()
|
|
304
|
+
|
|
305
|
+
def _complete_rotation(self) -> None:
|
|
306
|
+
# Pinned to the rotation's START time (§5.4 bullet 5): an entry created
|
|
307
|
+
# mid-rotation bumps its own directory's mtime and is re-examined next
|
|
308
|
+
# rotation. The cursor stays at len(_dirs); the next cycle's
|
|
309
|
+
# _begin_rotation resets it.
|
|
310
|
+
self._completed_rotation_watermark = self._rotation_start
|
|
311
|
+
self._rotation_count += 1
|
|
312
|
+
|
|
313
|
+
def _is_queued(self, d: str) -> bool:
|
|
314
|
+
return any(qd == d for (qd, _mt) in self._enum_queue)
|
|
315
|
+
|
|
316
|
+
def _age_pending(self) -> None:
|
|
317
|
+
expired = []
|
|
318
|
+
for p in list(self._pending.keys()):
|
|
319
|
+
self._pending[p] += 1
|
|
320
|
+
if self._pending[p] > self._pending_expiry:
|
|
321
|
+
expired.append(p)
|
|
322
|
+
for p in expired:
|
|
323
|
+
self._pending.pop(p, None)
|
|
@@ -433,6 +433,34 @@ def _claude_outline(
|
|
|
433
433
|
# ── Claude adapter: search (§5.6 / §6.2) ──────────────────────────────────────
|
|
434
434
|
|
|
435
435
|
|
|
436
|
+
def _claude_search_hit_fields(conn: sqlite3.Connection, session_ids: list) -> dict:
|
|
437
|
+
"""``{session_id: (last_activity_utc, project_label)}`` for the search hits'
|
|
438
|
+
conversations (§3.7). ``last_activity_utc`` is the conversation's
|
|
439
|
+
``MAX(timestamp_utc)`` (conversation-level, explicitly NOT the matched row's
|
|
440
|
+
own time); ``project_label`` is the session's display-only project attribution.
|
|
441
|
+
Resolved once per distinct non-empty session id."""
|
|
442
|
+
sids = [s for s in dict.fromkeys(session_ids) if s]
|
|
443
|
+
if not sids:
|
|
444
|
+
return {}
|
|
445
|
+
last_map: dict = {}
|
|
446
|
+
placeholders = ",".join("?" for _ in sids)
|
|
447
|
+
try:
|
|
448
|
+
for sid, last in conn.execute(
|
|
449
|
+
f"SELECT session_id, MAX(timestamp_utc) FROM conversation_messages "
|
|
450
|
+
f"WHERE session_id IN ({placeholders}) GROUP BY session_id", sids):
|
|
451
|
+
last_map[sid] = last
|
|
452
|
+
except sqlite3.OperationalError:
|
|
453
|
+
pass
|
|
454
|
+
meta = lcq._session_latest_meta_map(conn, sids)
|
|
455
|
+
attribution_cache: dict = {}
|
|
456
|
+
out: dict = {}
|
|
457
|
+
for sid in sids:
|
|
458
|
+
cwd = meta.get(sid, (None, None))[0]
|
|
459
|
+
_pk, project_label = _claude_project_attribution(cwd, attribution_cache)
|
|
460
|
+
out[sid] = (last_map.get(sid), project_label)
|
|
461
|
+
return out
|
|
462
|
+
|
|
463
|
+
|
|
436
464
|
def _claude_search(
|
|
437
465
|
conn: sqlite3.Connection,
|
|
438
466
|
query: str,
|
|
@@ -451,16 +479,23 @@ def _claude_search(
|
|
|
451
479
|
offset = 0
|
|
452
480
|
res = lcq.search_conversations(
|
|
453
481
|
conn, query, kind=kind, limit=limit, offset=max(0, offset))
|
|
482
|
+
raw_hits = res.get("hits", [])
|
|
483
|
+
# §3.7 conversation-level fields, resolved once per distinct session.
|
|
484
|
+
fields = _claude_search_hit_fields(
|
|
485
|
+
conn, [h.get("session_id") for h in raw_hits])
|
|
454
486
|
hits = []
|
|
455
|
-
for h in
|
|
487
|
+
for h in raw_hits:
|
|
456
488
|
sid = h.get("session_id")
|
|
457
489
|
uuid = h.get("uuid")
|
|
490
|
+
last_act, project_label = fields.get(sid, (None, None))
|
|
458
491
|
hits.append({
|
|
459
492
|
"conversation_key": _mint_claude_conversation_key(sid) if sid else None,
|
|
460
493
|
"item_key": _claude_item_key(sid, uuid) if (sid and uuid) else None,
|
|
461
494
|
"title": h.get("title"),
|
|
462
495
|
"snippet": h.get("snippet"),
|
|
463
496
|
"badges": list(h.get("match_kinds") or []),
|
|
497
|
+
"last_activity_utc": last_act,
|
|
498
|
+
"project_label": project_label,
|
|
464
499
|
})
|
|
465
500
|
total = res.get("total", len(hits))
|
|
466
501
|
returned = len(hits)
|
|
@@ -536,12 +571,239 @@ def neutral_search(
|
|
|
536
571
|
effective_speed: str | None = None, limit: int = 20, cursor: str | None = None,
|
|
537
572
|
) -> dict:
|
|
538
573
|
"""Search envelope for one source (§5.6). Search is per-source by
|
|
539
|
-
construction — the kernels never merge providers.
|
|
574
|
+
construction — the kernels never merge providers.
|
|
575
|
+
|
|
576
|
+
§4.3 external cursor codec: the ``cursor`` argument is the EXTERNAL
|
|
577
|
+
(base64url) form and the returned ``page.cursor`` is likewise external — the
|
|
578
|
+
kernel's raw cursor (a NUL-separated ``conversation_key\\x00item_key`` for
|
|
579
|
+
Codex; an offset for Claude) never leaves the process. An undecodable incoming
|
|
580
|
+
cursor raises :class:`InvalidSearchCursor` (the route/CLI maps it to 400 /
|
|
581
|
+
exit 2)."""
|
|
540
582
|
speed = effective_speed or _DEFAULT_SPEED
|
|
583
|
+
raw_cursor = decode_search_cursor(cursor) if cursor is not None else None
|
|
541
584
|
if source == "codex":
|
|
542
|
-
|
|
585
|
+
env = q.search_codex_conversations(
|
|
543
586
|
conn, query, kind=kind, effective_speed=speed,
|
|
544
|
-
limit=limit, cursor=
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
587
|
+
limit=limit, cursor=raw_cursor)
|
|
588
|
+
elif source == "claude":
|
|
589
|
+
env = _claude_search(conn, query, kind=kind, limit=limit, cursor=raw_cursor)
|
|
590
|
+
else:
|
|
591
|
+
raise ValueError(f"unknown source: {source!r}")
|
|
592
|
+
page = env.get("page")
|
|
593
|
+
if isinstance(page, dict) and page.get("cursor") is not None:
|
|
594
|
+
page["cursor"] = encode_search_cursor(page["cursor"])
|
|
595
|
+
return env
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def neutral_events_preflight(conn: sqlite3.Connection, ref: str) -> dict:
|
|
599
|
+
"""Live-tail SSE preflight for a ``v1.`` key (spec §5.2): resolve →
|
|
600
|
+
normalization authority (Codex) → conversation existence. The ``/events``
|
|
601
|
+
route answers a non-``ok`` result as plain JSON per §2.3 BEFORE committing
|
|
602
|
+
any SSE bytes — never a 200-SSE stream with nothing to say.
|
|
603
|
+
|
|
604
|
+
Returns a status dict:
|
|
605
|
+
- ``ok`` — carries ``source`` (``"claude"``/``"codex"``) and ``native_key``
|
|
606
|
+
for the watch loop's provider-specific mechanics;
|
|
607
|
+
- ``normalization_pending`` — Codex only, migration 025 not yet stamped (a
|
|
608
|
+
legitimate empty state → HTTP 200 JSON);
|
|
609
|
+
- ``not_found`` — an unresolvable/garbage ref, or a resolved ref whose
|
|
610
|
+
conversation has no rows (→ HTTP 404 JSON).
|
|
611
|
+
|
|
612
|
+
Cheap: a resolve + one authority probe + one existence probe — never a full
|
|
613
|
+
detail assembly. Uniform for both providers (a ``v1.`` Claude key preflights
|
|
614
|
+
the same way, then reuses the existing Claude mechanics internally)."""
|
|
615
|
+
cref = resolve_conversation_ref(ref)
|
|
616
|
+
if cref is None:
|
|
617
|
+
return {"status": "not_found", "conversation_key": ref}
|
|
618
|
+
if cref.source == "codex":
|
|
619
|
+
if not q.codex_normalization_authoritative(conn):
|
|
620
|
+
return {"status": "normalization_pending",
|
|
621
|
+
"conversation_key": cref.conversation_key}
|
|
622
|
+
if not q.codex_conversation_exists(conn, cref.conversation_key):
|
|
623
|
+
return {"status": "not_found",
|
|
624
|
+
"conversation_key": cref.conversation_key}
|
|
625
|
+
return {"status": "ok", "conversation_key": cref.conversation_key,
|
|
626
|
+
"source": "codex", "native_key": cref.native_key}
|
|
627
|
+
# claude — the bare-session path reached via a v1.claude key.
|
|
628
|
+
if not lcq.conversation_exists(conn, cref.native_key):
|
|
629
|
+
return {"status": "not_found", "conversation_key": cref.conversation_key}
|
|
630
|
+
return {"status": "ok", "conversation_key": cref.conversation_key,
|
|
631
|
+
"source": "claude", "native_key": cref.native_key}
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# ── external search-cursor codec (§4.3) ───────────────────────────────────────
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
class InvalidSearchCursor(ValueError):
|
|
638
|
+
"""An external search cursor that is not well-formed unpadded base64url over a
|
|
639
|
+
UTF-8 kernel cursor (§4.3). The route/CLI maps it to 400 / exit 2."""
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def encode_search_cursor(raw: str | None) -> str | None:
|
|
643
|
+
"""Encode a raw kernel search cursor to its external unpadded-base64url form
|
|
644
|
+
(§4.3). ``None`` (no next page) stays ``None``. The kernel cursor embeds a NUL
|
|
645
|
+
separator for Codex, so the raw value never leaves the process."""
|
|
646
|
+
if raw is None:
|
|
647
|
+
return None
|
|
648
|
+
return base64.urlsafe_b64encode(raw.encode("utf-8")).decode("ascii").rstrip("=")
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def decode_search_cursor(external: str | None) -> str | None:
|
|
652
|
+
"""Decode an external unpadded-base64url search cursor back to the raw kernel
|
|
653
|
+
cursor (§4.3). ``None`` stays ``None``; an undecodable value raises
|
|
654
|
+
:class:`InvalidSearchCursor`."""
|
|
655
|
+
if external is None:
|
|
656
|
+
return None
|
|
657
|
+
if not isinstance(external, str):
|
|
658
|
+
raise InvalidSearchCursor(external)
|
|
659
|
+
padded = external + "=" * (-len(external) % 4)
|
|
660
|
+
try:
|
|
661
|
+
return base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")
|
|
662
|
+
except (ValueError, TypeError):
|
|
663
|
+
raise InvalidSearchCursor(external)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
# ── S7 entity operations: find / prompts / export / payload (§3) ──────────────
|
|
667
|
+
|
|
668
|
+
# Shared in-conversation find taxonomy (both providers; byte-equal tuples).
|
|
669
|
+
_FIND_KINDS = lcq._FIND_KINDS
|
|
670
|
+
# The whole-conversation export scope (the only scope a Codex ref accepts, §3.3).
|
|
671
|
+
_EXPORT_DEFAULT_SCOPE = "all"
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _claude_find(
|
|
675
|
+
conn: sqlite3.Connection, session_id: str, conversation_key: str, query: str,
|
|
676
|
+
*, kind: str, regex: bool, case: bool,
|
|
677
|
+
) -> dict:
|
|
678
|
+
"""Claude ``neutral_find`` adapter (§3.1): wraps ``find_in_conversation`` and
|
|
679
|
+
re-anchors each uuid anchor to its neutral ``item_key`` — byte-equal to the
|
|
680
|
+
ones detail serves — so S8's FindBar navigates both providers with one
|
|
681
|
+
contract. Never emits ``normalization_pending`` (Claude is authoritative)."""
|
|
682
|
+
res = lcq.find_in_conversation(
|
|
683
|
+
conn, session_id, query, kind=kind, regex=regex, case=case)
|
|
684
|
+
if res is None:
|
|
685
|
+
return {"status": "not_found", "conversation_key": conversation_key}
|
|
686
|
+
anchors = [
|
|
687
|
+
{"item_key": _claude_item_key(session_id, a["uuid"]),
|
|
688
|
+
"match_kinds": a["match_kinds"]}
|
|
689
|
+
for a in res["anchors"]
|
|
690
|
+
]
|
|
691
|
+
return {
|
|
692
|
+
"status": "ok", "conversation_key": conversation_key,
|
|
693
|
+
"total": res["total"], "anchors": anchors,
|
|
694
|
+
"anchors_truncated": res["anchors_truncated"],
|
|
695
|
+
"search_depth": res["search_depth"], "kind": res["kind"], "mode": res["mode"],
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _claude_prompts(
|
|
700
|
+
conn: sqlite3.Connection, session_id: str, conversation_key: str
|
|
701
|
+
) -> dict:
|
|
702
|
+
"""Claude ``neutral_prompts`` adapter (§3.2): wraps ``get_conversation_prompts``
|
|
703
|
+
and re-anchors each uuid to its neutral ``item_key``."""
|
|
704
|
+
res = lcq.get_conversation_prompts(conn, session_id)
|
|
705
|
+
if res is None:
|
|
706
|
+
return {"status": "not_found", "conversation_key": conversation_key}
|
|
707
|
+
prompts = [
|
|
708
|
+
{"item_key": _claude_item_key(session_id, p["uuid"]), "text": p["text"]}
|
|
709
|
+
for p in res["prompts"]
|
|
710
|
+
]
|
|
711
|
+
return {"status": "ok", "conversation_key": conversation_key, "prompts": prompts}
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _claude_export(
|
|
715
|
+
conn: sqlite3.Connection, session_id: str, conversation_key: str, scope: str
|
|
716
|
+
) -> dict:
|
|
717
|
+
"""Claude ``neutral_export`` adapter (§3.3): wraps ``get_conversation_export``,
|
|
718
|
+
passing ``scope`` through — all existing Claude scopes keep working unchanged."""
|
|
719
|
+
md = lcq.get_conversation_export(conn, session_id, scope)
|
|
720
|
+
if md is None:
|
|
721
|
+
return {"status": "not_found", "conversation_key": conversation_key}
|
|
722
|
+
return {"status": "ok", "conversation_key": conversation_key, "markdown": md}
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def neutral_find(
|
|
726
|
+
conn: sqlite3.Connection, ref: str, query: str, *, kind: str = "all",
|
|
727
|
+
regex: bool = False, case: bool = False, effective_speed: str | None = None,
|
|
728
|
+
) -> dict:
|
|
729
|
+
"""In-conversation find for a neutral reference (§3.1). An unknown ``kind``
|
|
730
|
+
raises ``ValueError`` (route → 400); an unknown/garbage ref → ``not_found``.
|
|
731
|
+
Find never prices (``effective_speed`` accepted for signature symmetry)."""
|
|
732
|
+
del effective_speed # find never prices
|
|
733
|
+
if kind not in _FIND_KINDS:
|
|
734
|
+
raise ValueError(f"unknown kind: {kind}")
|
|
735
|
+
cref = resolve_conversation_ref(ref)
|
|
736
|
+
if cref is None:
|
|
737
|
+
return {"status": "not_found", "conversation_key": ref}
|
|
738
|
+
if cref.source == "codex":
|
|
739
|
+
return q.find_in_codex_conversation(
|
|
740
|
+
conn, cref.conversation_key, query, kind=kind, regex=regex, case=case)
|
|
741
|
+
return _claude_find(
|
|
742
|
+
conn, cref.native_key, cref.conversation_key, query,
|
|
743
|
+
kind=kind, regex=regex, case=case)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def neutral_prompts(conn: sqlite3.Connection, ref: str) -> dict:
|
|
747
|
+
"""Prompt spine for a neutral reference (§3.2)."""
|
|
748
|
+
cref = resolve_conversation_ref(ref)
|
|
749
|
+
if cref is None:
|
|
750
|
+
return {"status": "not_found", "conversation_key": ref}
|
|
751
|
+
if cref.source == "codex":
|
|
752
|
+
return q.codex_conversation_prompts(conn, cref.conversation_key)
|
|
753
|
+
return _claude_prompts(conn, cref.native_key, cref.conversation_key)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def neutral_export(
|
|
757
|
+
conn: sqlite3.Connection, ref: str, *, scope: str = _EXPORT_DEFAULT_SCOPE,
|
|
758
|
+
effective_speed: str | None = None,
|
|
759
|
+
) -> dict:
|
|
760
|
+
"""Whole-conversation Markdown export for a neutral reference (§3.3).
|
|
761
|
+
|
|
762
|
+
Claude passes ``scope`` through unchanged. The Codex adapter accepts ONLY the
|
|
763
|
+
default whole-conversation scope; any other scope value for a Codex ref is a
|
|
764
|
+
``validation_error`` status (the route/CLI maps it to 400 / exit 2), never a
|
|
765
|
+
silent fallback."""
|
|
766
|
+
cref = resolve_conversation_ref(ref)
|
|
767
|
+
if cref is None:
|
|
768
|
+
return {"status": "not_found", "conversation_key": ref}
|
|
769
|
+
if cref.source == "codex":
|
|
770
|
+
if scope not in (None, _EXPORT_DEFAULT_SCOPE):
|
|
771
|
+
return {"status": "validation_error",
|
|
772
|
+
"conversation_key": cref.conversation_key,
|
|
773
|
+
"reason": "scope", "detail": scope}
|
|
774
|
+
speed = effective_speed or _DEFAULT_SPEED
|
|
775
|
+
return q.get_codex_conversation_export(
|
|
776
|
+
conn, cref.conversation_key, effective_speed=speed)
|
|
777
|
+
return _claude_export(
|
|
778
|
+
conn, cref.native_key, cref.conversation_key,
|
|
779
|
+
scope if scope is not None else _EXPORT_DEFAULT_SCOPE)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def neutral_payload(
|
|
783
|
+
conn: sqlite3.Connection, ref: str, *, which: str,
|
|
784
|
+
tool_use_id: str | None = None, block_key: str | None = None,
|
|
785
|
+
) -> dict:
|
|
786
|
+
"""Full-payload readback for a neutral reference (§3.4).
|
|
787
|
+
|
|
788
|
+
Provider-specific selectors: Claude keeps its existing contract (``tool_use_id``
|
|
789
|
+
+ ``which ∈ {input, result}``), Codex uses ``block_key`` + ``which ∈ {call,
|
|
790
|
+
output}``. Statuses: ``ok`` | ``not_found`` (404) | ``gone`` (410 — the physical
|
|
791
|
+
record moved/mutated)."""
|
|
792
|
+
cref = resolve_conversation_ref(ref)
|
|
793
|
+
if cref is None:
|
|
794
|
+
return {"status": "not_found", "conversation_key": ref}
|
|
795
|
+
if cref.source == "codex":
|
|
796
|
+
if not block_key or which not in ("call", "output"):
|
|
797
|
+
return {"status": "not_found", "block_key": block_key, "which": which}
|
|
798
|
+
return q.read_codex_payload(conn, cref.conversation_key, block_key, which)
|
|
799
|
+
# Claude: tool_use_id + which={input,result}, contract unchanged.
|
|
800
|
+
if not tool_use_id or which not in ("input", "result"):
|
|
801
|
+
return {"status": "not_found", "tool_use_id": tool_use_id, "which": which}
|
|
802
|
+
loc = lcq.locate_tool_payload(conn, cref.native_key, tool_use_id, which)
|
|
803
|
+
if loc is None:
|
|
804
|
+
return {"status": "not_found", "tool_use_id": tool_use_id, "which": which}
|
|
805
|
+
source_path, byte_offset = loc
|
|
806
|
+
payload = lcq.read_full_payload(source_path, byte_offset, tool_use_id, which)
|
|
807
|
+
if payload is None:
|
|
808
|
+
return {"status": "gone", "tool_use_id": tool_use_id, "which": which}
|
|
809
|
+
return {"status": "ok", **payload}
|