cctally 1.71.0 → 1.72.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.
@@ -0,0 +1,547 @@
1
+ """#294 S6 — provider-neutral conversation dispatch + Claude envelope adapter.
2
+
3
+ The thin identity-keyed dispatch S7 wires to routes (§5.1, §5.6). A bare Claude
4
+ ``session_id`` routes to the EXISTING Claude query kernels (``_lib_conversation``
5
+ / ``_lib_conversation_query`` — byte-untouched); an opaque ``IdentityV1``
6
+ conversation key routes by its ``source`` (``codex`` → the Codex kernels;
7
+ ``claude`` → the bare-session path). Both providers return the SAME neutral
8
+ envelope family (browse / detail / outline / search), with provider-truthful
9
+ item/block kinds and a source-tagged ``tokens`` union.
10
+
11
+ The Claude side is a PURE per-field adapter (no Claude kernel edits): it re-shapes
12
+ the existing kernel outputs into the neutral envelopes and performs the
13
+ bidirectional cursor translation between uuid-based neutral ``item_key`` cursors
14
+ and Claude's internal rowid anchors (§5.6). All reads are adapter-level over the
15
+ untouched Claude tables via the public kernel helpers.
16
+
17
+ Public names (imported by the S7 wiring layer — do not rename):
18
+ ``ConversationRef``, ``resolve_conversation_ref``, ``neutral_browse``,
19
+ ``neutral_detail``, ``neutral_outline``, ``neutral_search``.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import base64
24
+ import dataclasses
25
+ import hashlib
26
+ import json
27
+ import re
28
+ import sqlite3
29
+
30
+ import _lib_codex_conversation_query as q
31
+ import _lib_conversation_query as lcq
32
+ from _lib_source_identity import canonical_identity_from_root_key
33
+
34
+ # ── constants ────────────────────────────────────────────────────────────────
35
+
36
+ # A bare Claude session id is a canonical UUID (Claude Code's sessionId shape).
37
+ # Anything that is neither an IdentityV1 key nor a UUID is garbage -> None.
38
+ _UUID_RE = re.compile(
39
+ r"\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
40
+ r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\Z"
41
+ )
42
+
43
+ # Opaque, privacy-safe encoding of Claude's ``(session_id, uuid)`` anchor into a
44
+ # neutral ``item_key`` (§5.6 adapter table). Domain-separated + truncated hash —
45
+ # never a reversible wrap of the raw ids. Resolution is by matching against the
46
+ # assembled items' anchors (canonical rendered-anchor rule), never a decode.
47
+ CLAUDE_ITEM_KEY_DOMAIN = b"cctally-claude-item-key-v1\0"
48
+
49
+ _DEFAULT_SPEED = "standard"
50
+
51
+
52
+ # ── ConversationRef + resolution (§5.1) ───────────────────────────────────────
53
+
54
+
55
+ @dataclasses.dataclass(frozen=True)
56
+ class ConversationRef:
57
+ """A resolved conversation reference.
58
+
59
+ ``source`` — ``"claude"`` | ``"codex"``.
60
+ ``conversation_key`` — opaque IdentityV1 form (minted for bare Claude session
61
+ ids; the input key echoed for IdentityV1 inputs).
62
+ ``native_key`` — bare ``session_id`` (claude) / native thread id (codex).
63
+ """
64
+
65
+ source: str
66
+ conversation_key: str
67
+ native_key: str
68
+
69
+
70
+ def _mint_claude_conversation_key(session_id: str) -> str:
71
+ """IdentityV1 ``claude``/``conversation`` key around a bare session id (§5.6).
72
+ Unqualified (no source root / parent) so S7 can mint qualified variants
73
+ later without a kernel change; both resolve to the same bare-session path."""
74
+ return canonical_identity_from_root_key(
75
+ "claude", "conversation", None, session_id, None)
76
+
77
+
78
+ def _decode_identity_v1(key: str):
79
+ """Decode a ``v1.<b64url>`` IdentityV1 key to its payload dict, or ``None``
80
+ when it is not a well-formed IdentityV1 (§5.1). Never raises."""
81
+ if not isinstance(key, str) or not key.startswith("v1."):
82
+ return None
83
+ b64 = key[3:]
84
+ padded = b64 + "=" * (-len(b64) % 4)
85
+ try:
86
+ raw = base64.urlsafe_b64decode(padded.encode("ascii"))
87
+ payload = json.loads(raw.decode("utf-8"))
88
+ except (ValueError, TypeError, json.JSONDecodeError):
89
+ return None
90
+ if not isinstance(payload, dict) or payload.get("version") != 1:
91
+ return None
92
+ return payload
93
+
94
+
95
+ def resolve_conversation_ref(ref: str) -> "ConversationRef | None":
96
+ """Resolve a neutral reference to its provider (§5.1).
97
+
98
+ - a bare Claude ``session_id`` (UUID shape) → ``claude`` (key minted);
99
+ - an ``IdentityV1`` ``codex``/``conversation`` key → ``codex``;
100
+ - an ``IdentityV1`` ``claude``/``conversation`` key → ``claude`` (resolved to
101
+ the bare-session path via ``nativeKey``);
102
+ - invalid / non-``conversation`` / cross-kind / garbage → ``None``.
103
+
104
+ There is NEVER a cross-provider fallback: a Codex key whose ``nativeKey`` UUID
105
+ also exists as a Claude session resolves ``codex``-only, because routing keys
106
+ solely on the decoded ``source`` field — collisions cannot cross-route by
107
+ construction."""
108
+ if not isinstance(ref, str) or not ref:
109
+ return None
110
+ payload = _decode_identity_v1(ref)
111
+ if payload is not None:
112
+ source = payload.get("source")
113
+ kind = payload.get("resourceKind")
114
+ native = payload.get("nativeKey")
115
+ if kind != "conversation" or source not in ("claude", "codex"):
116
+ return None
117
+ if not isinstance(native, str) or not native:
118
+ return None
119
+ # Both codex and claude IdentityV1 inputs echo the input key; a claude
120
+ # opaque key resolves to its bare-session native id (no re-mint).
121
+ return ConversationRef(source=source, conversation_key=ref, native_key=native)
122
+ if _UUID_RE.match(ref):
123
+ return ConversationRef(
124
+ source="claude",
125
+ conversation_key=_mint_claude_conversation_key(ref),
126
+ native_key=ref,
127
+ )
128
+ return None
129
+
130
+
131
+ # ── Claude adapter: item key + tokens union (§5.6) ────────────────────────────
132
+
133
+
134
+ def _claude_item_key(session_id: str, uuid: str | None) -> str:
135
+ """Opaque neutral ``item_key`` over Claude's ``(session_id, uuid)`` anchor
136
+ (§5.6). Same anchor Claude's kernels already use — the dispatch layer
137
+ guarantees Codex and Claude keys never mix (distinct domain + prefix)."""
138
+ raw = "\x00".join(("claude-row", session_id or "", uuid or "")).encode("utf-8")
139
+ return "cliv1_" + hashlib.sha256(CLAUDE_ITEM_KEY_DOMAIN + raw).hexdigest()[:40]
140
+
141
+
142
+ def _claude_tokens_union(tokens: dict | None):
143
+ """Source-tagged Claude ``tokens`` union member (§5.6). Native Claude cache
144
+ vocabulary only — ``cache_create``/``cache_read``, never Codex fields. The
145
+ assembly stamps ``cache_creation``; the neutral field is ``cache_create``."""
146
+ if not isinstance(tokens, dict):
147
+ return None
148
+ return {
149
+ "source": "claude",
150
+ "input": tokens.get("input", 0) or 0,
151
+ "output": tokens.get("output", 0) or 0,
152
+ "cache_create": tokens.get("cache_creation", 0) or 0,
153
+ "cache_read": tokens.get("cache_read", 0) or 0,
154
+ }
155
+
156
+
157
+ def _sum_claude_tokens(items: list) -> dict:
158
+ """Conversation-level Claude ``tokens`` union, summed over every assistant
159
+ turn's per-turn ``tokens`` (§5.6)."""
160
+ acc = {"input": 0, "output": 0, "cache_create": 0, "cache_read": 0}
161
+ for it in items:
162
+ tok = it.get("tokens")
163
+ if not isinstance(tok, dict):
164
+ continue
165
+ acc["input"] += tok.get("input", 0) or 0
166
+ acc["output"] += tok.get("output", 0) or 0
167
+ acc["cache_create"] += tok.get("cache_creation", 0) or 0
168
+ acc["cache_read"] += tok.get("cache_read", 0) or 0
169
+ return {"source": "claude", **acc}
170
+
171
+
172
+ # ── Claude adapter: project attribution (§5.6) ────────────────────────────────
173
+
174
+
175
+ def _claude_project_attribution(
176
+ cwd: object, cache: dict | None = None
177
+ ) -> tuple[str | None, str | None]:
178
+ """``(project_key, project_label)`` for a Claude session's canonical project
179
+ identity (§5.6 adapter table). Derives the OPAQUE qualified key from the
180
+ observed ``cwd`` git-root via the ``opaque_project_key`` precedent — two
181
+ Claude projects sharing a basename resolve to the SAME display label but
182
+ DISTINCT ``project_key``s (their git-root bucket paths differ), so they never
183
+ merge in neutral facets/filters. The basename label stays display-only.
184
+
185
+ ``cache`` is ``_resolve_project_key``'s memo dict; callers resolving many
186
+ rows MUST share one dict across the batch, or every row repeats the
187
+ realpath + parent-``.git`` stat walk the memo exists to avoid.
188
+
189
+ Degrades to ``(None, None)`` when ``cwd`` is absent or the S3 kernel is
190
+ unavailable — never guesses."""
191
+ if not isinstance(cwd, str) or not cwd:
192
+ return None, None
193
+ try:
194
+ from _cctally_cache import _resolve_project_key
195
+ from _cctally_source_analytics import _project_label
196
+ from _lib_source_identity import source_root_key
197
+ from _lib_source_analytics import opaque_project_key
198
+ except Exception:
199
+ return None, None
200
+ project = _resolve_project_key(cwd, "git-root", cache if cache is not None else {})
201
+ resolved_key = project.bucket_path
202
+ cwd_label = _project_label(cwd)
203
+ project_label = (
204
+ cwd_label if cwd_label in {"(home)", "(root)"}
205
+ else _project_label(project.display_key)
206
+ )
207
+ try:
208
+ # The git-root bucket path fully identifies the project; deriving the
209
+ # root key from it keeps same-basename/different-root projects distinct.
210
+ root_key = source_root_key(resolved_key)
211
+ return opaque_project_key("claude", root_key, resolved_key), project_label
212
+ except ValueError:
213
+ return None, None
214
+
215
+
216
+ # ── Claude adapter: browse (§5.6 / §6.1) ──────────────────────────────────────
217
+
218
+
219
+ def _all_claude_conversations(conn: sqlite3.Connection) -> list[dict]:
220
+ """Every Claude browse row (all history), paging the existing rollup/live
221
+ ``list_conversations`` kernel to completion. Reused so cost/title/models stay
222
+ the kernel's exact values (no re-implementation)."""
223
+ out: list[dict] = []
224
+ offset = 0
225
+ while True:
226
+ res = lcq.list_conversations(conn, sort="recent", limit=200, offset=offset)
227
+ out.extend(res["conversations"])
228
+ page = res["page"]
229
+ if not page.get("has_more"):
230
+ break
231
+ offset = page.get("next_offset")
232
+ if offset is None:
233
+ break
234
+ return out
235
+
236
+
237
+ def _claude_browse_row(
238
+ conn: sqlite3.Connection, conv: dict, cwd: object, attribution_cache: dict
239
+ ) -> dict:
240
+ project_key, project_label = _claude_project_attribution(cwd, attribution_cache)
241
+ return {
242
+ "conversation_key": _mint_claude_conversation_key(conv["session_id"]),
243
+ "title": conv["title"],
244
+ # project_key is the opaque qualified identity; project_label is the
245
+ # display-only basename (may collide across roots — the key does not).
246
+ "project_key": project_key,
247
+ "project_label": project_label if project_label is not None else conv["project_label"],
248
+ "started_utc": conv["started_utc"],
249
+ "last_activity_utc": conv["last_activity_utc"],
250
+ # count = Claude's existing physical message count (provider-defined
251
+ # count semantics, §5.6 adapter table) — NOT the Codex rendered-item count.
252
+ "count": conv["msg_count"],
253
+ "cost_usd": conv["cost_usd"],
254
+ "models": list(conv.get("models") or []),
255
+ # Claude has no native conversation threading — no parent, never a fork.
256
+ "parent": None,
257
+ "is_fork": False,
258
+ }
259
+
260
+
261
+ def _claude_browse(
262
+ conn: sqlite3.Connection,
263
+ *,
264
+ effective_speed: str,
265
+ project_key: str | None = None,
266
+ model: str | None = None,
267
+ limit: int = 50,
268
+ cursor: str | None = None,
269
+ ) -> dict:
270
+ """Claude browse envelope (§5.6 / §6.1). Claude is always authoritative — the
271
+ status is always ``ok`` (never ``normalization_pending``). Facets are built
272
+ over the full row set (before filtering); rows are ordered by last activity
273
+ and paginated by ``conversation_key`` cursor — the SAME facet/sort/paginate
274
+ helpers the Codex browse uses (cross-provider envelope parity)."""
275
+ del effective_speed # Claude cost is already materialized on the rollup rows.
276
+ convs = _all_claude_conversations(conn)
277
+ session_ids = [c["session_id"] for c in convs]
278
+ meta = lcq._session_latest_meta_map(conn, session_ids)
279
+ # One shared memo dict for the whole batch: many sessions share a cwd, and
280
+ # _resolve_project_key's fast-path only helps when the dict persists.
281
+ attribution_cache: dict = {}
282
+ rows = [
283
+ _claude_browse_row(
284
+ conn,
285
+ conv,
286
+ meta.get(conv["session_id"], (None, None))[0],
287
+ attribution_cache,
288
+ )
289
+ for conv in convs
290
+ ]
291
+ facets = q._browse_facets(rows)
292
+ filtered = [
293
+ row for row in rows
294
+ if (project_key is None or row["project_key"] == project_key)
295
+ and (model is None or model in (row["models"] or []))
296
+ ]
297
+ filtered.sort(key=q._recent_sort_key, reverse=True)
298
+ page_rows, page = q._paginate_rows(filtered, cursor=cursor, limit=limit)
299
+ return {"status": "ok", "rows": page_rows, "facets": facets, "page": page}
300
+
301
+
302
+ # ── Claude adapter: detail with cursor translation (§5.6) ─────────────────────
303
+
304
+
305
+ def _map_claude_item(session_id: str, it: dict) -> dict:
306
+ """One Claude assembled item → the neutral detail item shape (§5.6). Claude's
307
+ kinds/blocks pass through untranslated (both vocabularies are provider-truthful
308
+ values of the same required field)."""
309
+ return {
310
+ "item_key": _claude_item_key(session_id, it["anchor"]["uuid"]),
311
+ "kind": it["kind"],
312
+ "timestamp_utc": it.get("ts"),
313
+ "model": it.get("model"),
314
+ "blocks": it.get("blocks", []),
315
+ "cost_usd": it.get("cost_usd"),
316
+ "tokens": _claude_tokens_union(it.get("tokens")),
317
+ }
318
+
319
+
320
+ def _resolve_claude_cursor(items: list, keys: list, cursor: str | None):
321
+ """Translate a neutral ``item_key`` cursor to Claude's internal rowid anchor
322
+ via the canonical rendered-anchor rule (§5.6). Returns
323
+ ``(internal_id, "ok")`` when the cursor matches an emitted item anchor, or
324
+ ``(None, "not_found")`` when the uuid is physically present but folded out of
325
+ the rendered items, or absent entirely (pruned/rewritten history) — never a
326
+ silent restart, never a mistaken-stale from an arbitrary duplicate."""
327
+ if cursor is None:
328
+ return None, "ok"
329
+ try:
330
+ idx = keys.index(cursor)
331
+ except ValueError:
332
+ return None, "not_found"
333
+ return items[idx]["anchor"]["id"], "ok"
334
+
335
+
336
+ def _claude_detail(
337
+ conn: sqlite3.Connection,
338
+ session_id: str,
339
+ conversation_key: str,
340
+ *,
341
+ after: str | None = None,
342
+ before: str | None = None,
343
+ tail: object = None,
344
+ limit: int | None = None,
345
+ ) -> dict:
346
+ """Claude detail envelope (§5.6). Never emits ``normalization_pending``
347
+ (Claude is always authoritative). ``unattributed_cost_usd`` is absent (Codex
348
+ only); ``children``/``parent`` are empty/None (no native threading). Cursors
349
+ round-trip through the uuid-based ``item_key`` anchor."""
350
+ asm = lcq._assemble_session_memoized(conn, session_id)
351
+ if asm is None:
352
+ return {"status": "not_found", "conversation_key": conversation_key}
353
+ items_all = asm["items"]
354
+ # The canonical rendered anchors (assembly already dedups duplicate uuids and
355
+ # promotes the rendered fragment); the cursor must match one of THESE.
356
+ keys = [_claude_item_key(session_id, it["anchor"]["uuid"]) for it in items_all]
357
+ internal_after, st = _resolve_claude_cursor(items_all, keys, after)
358
+ if st == "not_found":
359
+ return {"status": "not_found", "conversation_key": conversation_key}
360
+ internal_before, st = _resolve_claude_cursor(items_all, keys, before)
361
+ if st == "not_found":
362
+ return {"status": "not_found", "conversation_key": conversation_key}
363
+ res = lcq.get_conversation(
364
+ conn, session_id,
365
+ after=internal_after, before=internal_before,
366
+ tail=bool(tail), limit=limit if limit is not None else 500,
367
+ )
368
+ page_items = res["items"]
369
+ neutral_items = [_map_claude_item(session_id, it) for it in page_items]
370
+ if page_items:
371
+ has_after = bool(res["page"]["has_more"])
372
+ has_before = bool(res["page"]["has_prev"])
373
+ after_cur = _claude_item_key(session_id, page_items[-1]["anchor"]["uuid"]) if has_after else None
374
+ before_cur = _claude_item_key(session_id, page_items[0]["anchor"]["uuid"]) if has_before else None
375
+ else:
376
+ has_after = has_before = False
377
+ after_cur = before_cur = None
378
+ page = {
379
+ "total": len(items_all), "returned": len(page_items),
380
+ "before": before_cur, "after": after_cur,
381
+ "has_before": has_before, "has_after": has_after,
382
+ }
383
+ return {
384
+ "status": "ok",
385
+ "conversation_key": conversation_key,
386
+ "title": res["title"],
387
+ "items": neutral_items,
388
+ "page": page,
389
+ "children": [],
390
+ "parent": None,
391
+ "total_cost_usd": res["cost_usd"],
392
+ "tokens": _sum_claude_tokens(items_all),
393
+ }
394
+
395
+
396
+ # ── Claude adapter: outline (§5.6) ────────────────────────────────────────────
397
+
398
+
399
+ def _claude_outline(
400
+ conn: sqlite3.Connection, session_id: str, conversation_key: str,
401
+ ) -> dict:
402
+ """Claude outline envelope (§5.6). Reuses the existing kernel outline for
403
+ ``stats``/``files``; the per-turn ``item_key`` + block-kind counts are built
404
+ from the SAME assembled items the detail pages, so outline and detail item
405
+ keys align exactly. ``children`` is empty (no native threading)."""
406
+ o = lcq.get_conversation_outline(conn, session_id)
407
+ if o is None:
408
+ return {"status": "not_found", "conversation_key": conversation_key}
409
+ asm = lcq._assemble_session_memoized(conn, session_id)
410
+ turns = []
411
+ for it in asm["items"]:
412
+ kinds: dict[str, int] = {}
413
+ for b in it.get("blocks", []):
414
+ bk = b.get("kind")
415
+ if bk:
416
+ kinds[bk] = kinds.get(bk, 0) + 1
417
+ turns.append({
418
+ "item_key": _claude_item_key(session_id, it["anchor"]["uuid"]),
419
+ "label": lcq._outline_label(it.get("text", "")),
420
+ "timestamp_utc": it.get("ts"),
421
+ "kinds": kinds,
422
+ })
423
+ return {
424
+ "status": "ok",
425
+ "conversation_key": conversation_key,
426
+ "turns": turns,
427
+ "stats": o["stats"],
428
+ "files": o["files"],
429
+ "children": [],
430
+ }
431
+
432
+
433
+ # ── Claude adapter: search (§5.6 / §6.2) ──────────────────────────────────────
434
+
435
+
436
+ def _claude_search(
437
+ conn: sqlite3.Connection,
438
+ query: str,
439
+ *,
440
+ kind: str = "all",
441
+ limit: int = 20,
442
+ cursor: str | None = None,
443
+ ) -> dict:
444
+ """Claude search envelope (§5.6). Reuses the existing Claude search kernel
445
+ (FTS5/LIKE, the #149 shape); hits are re-shaped to the neutral union with a
446
+ ``(session_id, uuid)`` ``item_key`` and cursor pagination over the kernel's
447
+ offset paging."""
448
+ try:
449
+ offset = int(cursor) if cursor is not None else 0
450
+ except (TypeError, ValueError):
451
+ offset = 0
452
+ res = lcq.search_conversations(
453
+ conn, query, kind=kind, limit=limit, offset=max(0, offset))
454
+ hits = []
455
+ for h in res.get("hits", []):
456
+ sid = h.get("session_id")
457
+ uuid = h.get("uuid")
458
+ hits.append({
459
+ "conversation_key": _mint_claude_conversation_key(sid) if sid else None,
460
+ "item_key": _claude_item_key(sid, uuid) if (sid and uuid) else None,
461
+ "title": h.get("title"),
462
+ "snippet": h.get("snippet"),
463
+ "badges": list(h.get("match_kinds") or []),
464
+ })
465
+ total = res.get("total", len(hits))
466
+ returned = len(hits)
467
+ next_offset = offset + returned
468
+ next_cursor = str(next_offset) if next_offset < total else None
469
+ return {
470
+ "status": "ok",
471
+ "query": res.get("query", query),
472
+ "hits": hits,
473
+ "total": total,
474
+ "mode": res.get("mode"),
475
+ "depth": res.get("search_depth"),
476
+ "page": {"returned": returned, "cursor": next_cursor},
477
+ }
478
+
479
+
480
+ # ── neutral entry points (§5.6) ───────────────────────────────────────────────
481
+
482
+
483
+ def neutral_browse(
484
+ conn: sqlite3.Connection, *, source: str, effective_speed: str | None = None,
485
+ **filters,
486
+ ) -> dict:
487
+ """Browse envelope for one source (§5.6). Codex routes to
488
+ ``list_codex_conversations`` (with the ``normalization_pending`` status while
489
+ migration 025 has not run); Claude routes to the adapter (always ``ok``).
490
+ ``filters``: ``project_key``, ``model``, ``limit``, ``cursor``."""
491
+ speed = effective_speed or _DEFAULT_SPEED
492
+ if source == "codex":
493
+ return q.list_codex_conversations(conn, effective_speed=speed, **filters)
494
+ if source == "claude":
495
+ return _claude_browse(conn, effective_speed=speed, **filters)
496
+ raise ValueError(f"unknown source: {source!r}")
497
+
498
+
499
+ def neutral_detail(
500
+ conn: sqlite3.Connection, ref: str, *, effective_speed: str | None = None,
501
+ after: str | None = None, before: str | None = None,
502
+ tail: object = None, limit: int | None = None,
503
+ ) -> dict:
504
+ """Detail envelope for a neutral reference (§5.6). Unknown/garbage refs → the
505
+ ``not_found`` envelope echoing the requested reference."""
506
+ cref = resolve_conversation_ref(ref)
507
+ if cref is None:
508
+ return {"status": "not_found", "conversation_key": ref}
509
+ speed = effective_speed or _DEFAULT_SPEED
510
+ if cref.source == "codex":
511
+ return q.get_codex_conversation(
512
+ conn, cref.conversation_key, effective_speed=speed,
513
+ after=after, before=before, tail=tail,
514
+ limit=limit if limit is not None else 200)
515
+ return _claude_detail(
516
+ conn, cref.native_key, cref.conversation_key,
517
+ after=after, before=before, tail=tail, limit=limit)
518
+
519
+
520
+ def neutral_outline(
521
+ conn: sqlite3.Connection, ref: str, *, effective_speed: str | None = None,
522
+ ) -> dict:
523
+ """Outline envelope for a neutral reference (§5.6)."""
524
+ cref = resolve_conversation_ref(ref)
525
+ if cref is None:
526
+ return {"status": "not_found", "conversation_key": ref}
527
+ speed = effective_speed or _DEFAULT_SPEED
528
+ if cref.source == "codex":
529
+ return q.get_codex_conversation_outline(
530
+ conn, cref.conversation_key, effective_speed=speed)
531
+ return _claude_outline(conn, cref.native_key, cref.conversation_key)
532
+
533
+
534
+ def neutral_search(
535
+ conn: sqlite3.Connection, query: str, *, source: str, kind: str = "all",
536
+ effective_speed: str | None = None, limit: int = 20, cursor: str | None = None,
537
+ ) -> dict:
538
+ """Search envelope for one source (§5.6). Search is per-source by
539
+ construction — the kernels never merge providers."""
540
+ speed = effective_speed or _DEFAULT_SPEED
541
+ if source == "codex":
542
+ return q.search_codex_conversations(
543
+ 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}")