cctally 1.72.0 → 1.74.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/_cctally_cache.py +271 -55
  3. package/bin/_cctally_core.py +20 -40
  4. package/bin/_cctally_dashboard.py +35 -1
  5. package/bin/_cctally_dashboard_conversation.py +731 -94
  6. package/bin/_cctally_dashboard_share.py +38 -3
  7. package/bin/_cctally_dashboard_sources.py +812 -74
  8. package/bin/_cctally_db.py +104 -1
  9. package/bin/_cctally_doctor.py +86 -4
  10. package/bin/_cctally_parser.py +17 -4
  11. package/bin/_cctally_record.py +162 -33
  12. package/bin/_cctally_refresh.py +58 -36
  13. package/bin/_cctally_source_analytics.py +288 -3
  14. package/bin/_cctally_statusline.py +811 -82
  15. package/bin/_cctally_transcript.py +223 -7
  16. package/bin/_cctally_tui.py +17 -14
  17. package/bin/_lib_aggregators.py +48 -34
  18. package/bin/_lib_codex_conversation_export.py +124 -0
  19. package/bin/_lib_codex_conversation_query.py +500 -28
  20. package/bin/_lib_codex_conversation_watch.py +323 -0
  21. package/bin/_lib_conversation_dispatch.py +269 -7
  22. package/bin/_lib_conversation_query.py +88 -0
  23. package/bin/_lib_dashboard_sources.py +20 -7
  24. package/bin/_lib_doctor.py +106 -0
  25. package/bin/_lib_jsonl.py +11 -0
  26. package/bin/_lib_pricing.py +7 -4
  27. package/bin/_lib_pricing_check.py +7 -1
  28. package/bin/_lib_quota.py +12 -11
  29. package/bin/_lib_statusline_candidates.py +734 -0
  30. package/bin/_lib_view_models.py +50 -14
  31. package/bin/cctally +31 -2
  32. package/dashboard/static/assets/index-DeQjEMO6.js +80 -0
  33. package/dashboard/static/assets/index-ZiPO8veo.css +1 -0
  34. package/dashboard/static/dashboard.html +2 -2
  35. package/package.json +4 -1
  36. package/dashboard/static/assets/index-BWadAHxC.js +0 -80
  37. package/dashboard/static/assets/index-D2nwo_ln.css +0 -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 res.get("hits", []):
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
- return q.search_codex_conversations(
585
+ env = q.search_codex_conversations(
543
586
  conn, query, kind=kind, effective_speed=speed,
544
- limit=limit, cursor=cursor)
545
- if source == "claude":
546
- return _claude_search(conn, query, kind=kind, limit=limit, cursor=cursor)
547
- raise ValueError(f"unknown source: {source!r}")
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}