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
|
@@ -43,7 +43,7 @@ import socket
|
|
|
43
43
|
import sqlite3
|
|
44
44
|
import sys
|
|
45
45
|
|
|
46
|
-
from _cctally_cache import sync_cache
|
|
46
|
+
from _cctally_cache import sync_cache, sync_codex_cache, _codex_provider_roots
|
|
47
47
|
|
|
48
48
|
# Live-tail watch-loop tuning — used ONLY by _handle_get_conversation_events_impl
|
|
49
49
|
# below, so moved here with the events handler (spec §4.1 / §6).
|
|
@@ -113,10 +113,309 @@ def _cached_file_sigs(conn, paths):
|
|
|
113
113
|
return out
|
|
114
114
|
|
|
115
115
|
|
|
116
|
+
def _codex_cached_file_sigs(conn, paths):
|
|
117
|
+
"""{path: size_bytes} from ``codex_session_files`` for the given paths — the
|
|
118
|
+
Codex analogue of ``_cached_file_sigs`` (spec §5.2). Size-only, matching the
|
|
119
|
+
watch kernel's size-only signature and ``sync_codex_cache``'s size-only
|
|
120
|
+
delta. Paths with no row are absent → treated as changed. Baselines the
|
|
121
|
+
Codex live-tail watch against the cache's own committed cursor so growth
|
|
122
|
+
during an ingest is re-detected next cycle (the per-path committed-cursor
|
|
123
|
+
invariant — the S6 physical mutation sequence is at most an extra
|
|
124
|
+
certificate, never this cursor's replacement)."""
|
|
125
|
+
out = {}
|
|
126
|
+
if not paths:
|
|
127
|
+
return out
|
|
128
|
+
placeholders = ",".join("?" for _ in paths)
|
|
129
|
+
try:
|
|
130
|
+
rows = conn.execute(
|
|
131
|
+
f"SELECT path, size_bytes FROM codex_session_files "
|
|
132
|
+
f"WHERE path IN ({placeholders})", list(paths)).fetchall()
|
|
133
|
+
except sqlite3.OperationalError:
|
|
134
|
+
return out
|
|
135
|
+
for p, size in rows:
|
|
136
|
+
out[p] = size
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _codex_all_committed_sizes(conn):
|
|
141
|
+
"""{path: size_bytes} for EVERY tracked Codex file — fed to the frontier as
|
|
142
|
+
both the ``known_paths`` diff set and the ``committed_sizes`` growth
|
|
143
|
+
baseline. A plain SELECT (no lock), so it never widens the SSE lock scope."""
|
|
144
|
+
try:
|
|
145
|
+
rows = conn.execute(
|
|
146
|
+
"SELECT path, size_bytes FROM codex_session_files").fetchall()
|
|
147
|
+
except sqlite3.OperationalError:
|
|
148
|
+
return {}
|
|
149
|
+
return {p: size for (p, size) in rows}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _codex_classified_paths(conn, paths):
|
|
153
|
+
"""Of ``paths``, those whose targeted ingest now carries a conversation key
|
|
154
|
+
(``codex_session_files.last_conversation_key`` non-null) — i.e. classified
|
|
155
|
+
(child or non-child). The driver reaps these from the frontier's pending set
|
|
156
|
+
(§5.4): a child has already widened the file set; a non-child needs no
|
|
157
|
+
further attention. Paths still unclassified (incomplete session_meta / a
|
|
158
|
+
dirty first ingest) stay pending and are retried."""
|
|
159
|
+
if not paths:
|
|
160
|
+
return set()
|
|
161
|
+
placeholders = ",".join("?" for _ in paths)
|
|
162
|
+
try:
|
|
163
|
+
rows = conn.execute(
|
|
164
|
+
f"SELECT path FROM codex_session_files "
|
|
165
|
+
f"WHERE path IN ({placeholders}) AND last_conversation_key IS NOT NULL",
|
|
166
|
+
list(paths)).fetchall()
|
|
167
|
+
except sqlite3.OperationalError:
|
|
168
|
+
return set()
|
|
169
|
+
return {r[0] for r in rows}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _codex_walk_root_for_conversation(conn, conversation_key):
|
|
173
|
+
"""The configured ``walk_root`` of the conversation's OWN provider root (the
|
|
174
|
+
frontier's scope — §5.4). Resolves the conversation's ``source_root_key`` via
|
|
175
|
+
its thread facts, then matches it to a currently-configured provider root.
|
|
176
|
+
``None`` when the conversation has no thread row or its root is no longer
|
|
177
|
+
configured (the frontier is then skipped — DB re-resolve alone still runs)."""
|
|
178
|
+
cq = _conversation_query_impl_codex()
|
|
179
|
+
thread = cq._thread_facts(conn, conversation_key)
|
|
180
|
+
if thread is None:
|
|
181
|
+
return None
|
|
182
|
+
source_root_key = thread[3]
|
|
183
|
+
for root in _codex_provider_roots():
|
|
184
|
+
if root.source_root_key == source_root_key:
|
|
185
|
+
return str(root.walk_root)
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _conversation_query_impl_codex():
|
|
190
|
+
"""Lazy-load the Codex conversation query kernel (source-path resolver +
|
|
191
|
+
existence probe + thread facts for the live-tail file set)."""
|
|
192
|
+
return sys.modules["cctally"]._load_sibling("_lib_codex_conversation_query")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _conversation_dispatch_impl():
|
|
196
|
+
"""Lazy-load the provider-neutral dispatch kernel (the SSE preflight)."""
|
|
197
|
+
return sys.modules["cctally"]._load_sibling("_lib_conversation_dispatch")
|
|
198
|
+
|
|
199
|
+
|
|
116
200
|
def _conversation_query_impl():
|
|
117
201
|
"""Lazy-load the pure conversation query kernel (Plan 2, §3)."""
|
|
118
202
|
return sys.modules["cctally"]._load_sibling("_lib_conversation_query")
|
|
119
203
|
|
|
204
|
+
|
|
205
|
+
# ── #294 S7 — dual-form conversation routes (spec §2) ─────────────────────────
|
|
206
|
+
# Entity routes qualify lexically (an id beginning ``v1.`` opts into the neutral
|
|
207
|
+
# envelope contract); the three collection routes qualify by an explicit strict
|
|
208
|
+
# ``?source={claude,codex}``. Absence of qualification keeps today's Claude path
|
|
209
|
+
# byte-identical (the legacy code never touches the resolver). This is C1/C2.
|
|
210
|
+
|
|
211
|
+
# Every conversation param recognized across the three collection routes (§2.2).
|
|
212
|
+
# A qualified request rejects (400) any RECOGNIZED param not in that route's
|
|
213
|
+
# accepted whitelist; a GENUINELY unknown param (not in this set) is ignored,
|
|
214
|
+
# matching legacy leniency. Entity-only params (after/before/tail/scope/regex/…)
|
|
215
|
+
# are deliberately absent — they are meaningless on a collection route, so they
|
|
216
|
+
# fall to "genuinely unknown → ignored".
|
|
217
|
+
_RECOGNIZED_CONVERSATION_PARAMS = (
|
|
218
|
+
"source", "project_key", "model", "limit", "cursor", "q", "kind",
|
|
219
|
+
"sort", "offset", "date_from", "date_to", "projects",
|
|
220
|
+
"cost_min", "cost_max", "rebuild_min", "models",
|
|
221
|
+
)
|
|
222
|
+
_QUALIFIED_BROWSE_ACCEPTED = ("source", "project_key", "model", "limit", "cursor")
|
|
223
|
+
_QUALIFIED_SEARCH_ACCEPTED = ("source", "q", "kind", "limit", "cursor")
|
|
224
|
+
_QUALIFIED_FACETS_ACCEPTED = ("source",)
|
|
225
|
+
# A raw browse cursor is a conversation key — printable + URL-safe by construction
|
|
226
|
+
# (§2.2). Syntactic-only validation: reject whitespace/control/empty; echo raw.
|
|
227
|
+
import re as _re_qs # noqa: E402 — module-level compile for the browse-cursor check
|
|
228
|
+
_BROWSE_CURSOR_RE = _re_qs.compile(r"\A[!-~]+\Z")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _resolve_effective_speed():
|
|
232
|
+
"""§2.4: resolve ``auto`` → a concrete speed ONCE at the route I/O boundary,
|
|
233
|
+
via the SAME chokepoint the Codex reporting commands use. Search never prices;
|
|
234
|
+
detail/outline/export thread it down (Claude ignores it — cost is materialized)."""
|
|
235
|
+
return sys.modules["cctally"]._resolve_codex_speed("auto")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _conversation_dispatch():
|
|
239
|
+
"""Lazy-load the provider-neutral dispatch kernel (the S7 entity ops)."""
|
|
240
|
+
return _conversation_dispatch_impl()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _parse_source_param(handler, qs_raw):
|
|
244
|
+
"""Strict ``?source=`` parse (§2.2). Returns ``(qualified, source)``:
|
|
245
|
+
|
|
246
|
+
- ``(False, None)`` — the param is absent → the caller runs the legacy path
|
|
247
|
+
byte-identically;
|
|
248
|
+
- ``(True, "claude"|"codex")`` — exactly one literal value;
|
|
249
|
+
|
|
250
|
+
or ``None`` after having ALREADY sent a 400 (blank, duplicated, ``all``, or any
|
|
251
|
+
other unknown value)."""
|
|
252
|
+
import urllib.parse as _u
|
|
253
|
+
vals = _u.parse_qs(qs_raw, keep_blank_values=True).get("source")
|
|
254
|
+
if vals is None:
|
|
255
|
+
return (False, None)
|
|
256
|
+
if len(vals) != 1 or vals[0] not in ("claude", "codex"):
|
|
257
|
+
handler._respond_json(400, {"error": f"invalid source: {vals}"})
|
|
258
|
+
return None
|
|
259
|
+
return (True, vals[0])
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _validate_qualified_params(handler, qs_raw, accepted):
|
|
263
|
+
"""§2.2 strict qualified-param validation. Rejects (400) any RECOGNIZED
|
|
264
|
+
conversation param not in ``accepted``, and any accepted param that appears
|
|
265
|
+
more than once. Genuinely-unknown params (outside
|
|
266
|
+
``_RECOGNIZED_CONVERSATION_PARAMS``) are ignored. Returns the
|
|
267
|
+
``keep_blank_values`` parse map on success, or ``None`` after a 400."""
|
|
268
|
+
import urllib.parse as _u
|
|
269
|
+
parsed = _u.parse_qs(qs_raw, keep_blank_values=True)
|
|
270
|
+
accepted_set = set(accepted)
|
|
271
|
+
for name, vals in parsed.items():
|
|
272
|
+
if name in _RECOGNIZED_CONVERSATION_PARAMS and name not in accepted_set:
|
|
273
|
+
handler._respond_json(400, {"error": f"unexpected param: {name}"})
|
|
274
|
+
return None
|
|
275
|
+
if name in accepted_set and len(vals) != 1:
|
|
276
|
+
handler._respond_json(400, {"error": f"duplicate param: {name}"})
|
|
277
|
+
return None
|
|
278
|
+
return parsed
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _parse_qualified_limit(handler, parsed):
|
|
282
|
+
"""Strict qualified ``limit`` (§2.2): a base-10 integer 1..500. Absent → the
|
|
283
|
+
kernel default (50); malformed or out of range → 400. Returns ``(ok, limit)``
|
|
284
|
+
(``ok=False`` means a 400 was already sent)."""
|
|
285
|
+
vals = parsed.get("limit")
|
|
286
|
+
if vals is None:
|
|
287
|
+
return (True, 50)
|
|
288
|
+
raw = vals[0]
|
|
289
|
+
if not (raw.isascii() and raw.isdigit()): # strict base-10, no sign/space
|
|
290
|
+
handler._respond_json(400, {"error": f"bad limit: {raw}"})
|
|
291
|
+
return (False, None)
|
|
292
|
+
n = int(raw)
|
|
293
|
+
if not (1 <= n <= 500):
|
|
294
|
+
handler._respond_json(400, {"error": f"limit out of range: {n}"})
|
|
295
|
+
return (False, None)
|
|
296
|
+
return (True, n)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _parse_qualified_browse_cursor(handler, parsed):
|
|
300
|
+
"""The browse ``cursor`` is a raw conversation key — passed verbatim both
|
|
301
|
+
directions (§2.2). Syntactic-only: blank → no cursor; a non-printable /
|
|
302
|
+
whitespace value → 400. Returns ``(ok, cursor)``."""
|
|
303
|
+
vals = parsed.get("cursor")
|
|
304
|
+
if vals is None or vals[0] == "":
|
|
305
|
+
return (True, None)
|
|
306
|
+
cur = vals[0]
|
|
307
|
+
if not _BROWSE_CURSOR_RE.match(cur):
|
|
308
|
+
handler._respond_json(400, {"error": "malformed cursor"})
|
|
309
|
+
return (False, None)
|
|
310
|
+
return (True, cur)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _respond_qualified_json(handler, env):
|
|
314
|
+
"""Map a neutral entity envelope's ``status`` → the §2.3 HTTP JSON transport:
|
|
315
|
+
``ok`` / ``normalization_pending`` → 200; ``gone`` → 410; ``validation_error``
|
|
316
|
+
→ 400; anything else (``not_found`` + unknown) → 404. Body is the envelope."""
|
|
317
|
+
status = (env or {}).get("status")
|
|
318
|
+
if status in ("ok", "normalization_pending"):
|
|
319
|
+
handler._respond_json(200, env)
|
|
320
|
+
elif status == "gone":
|
|
321
|
+
handler._respond_json(410, env)
|
|
322
|
+
elif status == "validation_error":
|
|
323
|
+
handler._respond_json(400, env)
|
|
324
|
+
else:
|
|
325
|
+
handler._respond_json(404, env)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _serve_qualified_entity(handler, dispatch_call, log_label):
|
|
329
|
+
"""Run a neutral entity dispatch through the shared 500-envelope scaffold and
|
|
330
|
+
map its status to the §2.3 JSON transport. For the JSON entity legs (detail,
|
|
331
|
+
outline, prompts, find, payload); export/anon-map/media serve non-JSON or a
|
|
332
|
+
bespoke existence probe, so they do not route through here."""
|
|
333
|
+
ok, env = handler._run_conversation_query(dispatch_call, log_label)
|
|
334
|
+
if not ok:
|
|
335
|
+
return
|
|
336
|
+
_respond_qualified_json(handler, env)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _handle_qualified_browse(handler, qs_raw, source):
|
|
340
|
+
"""``GET /api/conversations?source=…`` (§2.2). Strict param whitelist; the
|
|
341
|
+
neutral browse envelope served verbatim (codex ``normalization_pending`` is a
|
|
342
|
+
legitimate 200 empty state)."""
|
|
343
|
+
parsed = _validate_qualified_params(handler, qs_raw, _QUALIFIED_BROWSE_ACCEPTED)
|
|
344
|
+
if parsed is None:
|
|
345
|
+
return
|
|
346
|
+
ok, limit = _parse_qualified_limit(handler, parsed)
|
|
347
|
+
if not ok:
|
|
348
|
+
return
|
|
349
|
+
ok, cursor = _parse_qualified_browse_cursor(handler, parsed)
|
|
350
|
+
if not ok:
|
|
351
|
+
return
|
|
352
|
+
project_key = (parsed.get("project_key", [None])[0] or None)
|
|
353
|
+
model = (parsed.get("model", [None])[0] or None)
|
|
354
|
+
speed = _resolve_effective_speed()
|
|
355
|
+
disp = _conversation_dispatch()
|
|
356
|
+
ok, body = handler._run_conversation_query(
|
|
357
|
+
lambda conn: disp.neutral_browse(
|
|
358
|
+
conn, source=source, effective_speed=speed,
|
|
359
|
+
project_key=project_key, model=model, limit=limit, cursor=cursor),
|
|
360
|
+
"/api/conversations")
|
|
361
|
+
if not ok:
|
|
362
|
+
return
|
|
363
|
+
handler._respond_json(200, body)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _handle_qualified_facets(handler, qs_raw, source):
|
|
367
|
+
"""``GET /api/conversations/facets?source=…`` (§2.2). Accepts ``source`` only;
|
|
368
|
+
serves the status-tagged facets-only envelope (pending → empty facet lists)."""
|
|
369
|
+
parsed = _validate_qualified_params(handler, qs_raw, _QUALIFIED_FACETS_ACCEPTED)
|
|
370
|
+
if parsed is None:
|
|
371
|
+
return
|
|
372
|
+
speed = _resolve_effective_speed()
|
|
373
|
+
disp = _conversation_dispatch()
|
|
374
|
+
ok, body = handler._run_conversation_query(
|
|
375
|
+
lambda conn: disp.neutral_browse(
|
|
376
|
+
conn, source=source, effective_speed=speed),
|
|
377
|
+
"/api/conversations/facets")
|
|
378
|
+
if not ok:
|
|
379
|
+
return
|
|
380
|
+
facets = (body or {}).get("facets") or {"projects": [], "models": []}
|
|
381
|
+
handler._respond_json(
|
|
382
|
+
200, {"status": (body or {}).get("status"), "facets": facets})
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _handle_qualified_search(handler, qs_raw, source):
|
|
386
|
+
"""``GET /api/conversation/search?source=…`` (§2.2). Strict whitelist; the
|
|
387
|
+
search ``cursor`` must decode as base64url (else 400); the neutral search
|
|
388
|
+
envelope (with the codec'd ``page.cursor``) is served verbatim."""
|
|
389
|
+
parsed = _validate_qualified_params(handler, qs_raw, _QUALIFIED_SEARCH_ACCEPTED)
|
|
390
|
+
if parsed is None:
|
|
391
|
+
return
|
|
392
|
+
query = (parsed.get("q", [""])[0] or "")
|
|
393
|
+
kind = (parsed.get("kind", ["all"])[0] or "all")
|
|
394
|
+
if kind not in _CONV_SEARCH_KINDS:
|
|
395
|
+
handler._respond_json(400, {"error": f"unknown kind: {kind}"})
|
|
396
|
+
return
|
|
397
|
+
ok, limit = _parse_qualified_limit(handler, parsed)
|
|
398
|
+
if not ok:
|
|
399
|
+
return
|
|
400
|
+
cursor = (parsed.get("cursor", [None])[0] or None)
|
|
401
|
+
disp = _conversation_dispatch()
|
|
402
|
+
if cursor is not None:
|
|
403
|
+
try:
|
|
404
|
+
disp.decode_search_cursor(cursor)
|
|
405
|
+
except disp.InvalidSearchCursor:
|
|
406
|
+
handler._respond_json(400, {"error": "invalid cursor"})
|
|
407
|
+
return
|
|
408
|
+
speed = _resolve_effective_speed()
|
|
409
|
+
ok, body = handler._run_conversation_query(
|
|
410
|
+
lambda conn: disp.neutral_search(
|
|
411
|
+
conn, query, source=source, kind=kind,
|
|
412
|
+
effective_speed=speed, limit=limit, cursor=cursor),
|
|
413
|
+
"/api/conversation/search")
|
|
414
|
+
if not ok:
|
|
415
|
+
return
|
|
416
|
+
handler._respond_json(200, body)
|
|
417
|
+
|
|
418
|
+
|
|
120
419
|
def _parse_search_kind_impl(handler, q, valid=_CONV_SEARCH_KINDS):
|
|
121
420
|
"""Read + validate the ``kind`` facet for a conversation route (#177 S6 /
|
|
122
421
|
#217 S2). Returns the kind on success, or ``None`` after having ALREADY
|
|
@@ -263,7 +562,14 @@ def _handle_get_conversations_impl(handler) -> None:
|
|
|
263
562
|
if not handler._require_transcripts_allowed():
|
|
264
563
|
return
|
|
265
564
|
import urllib.parse as _u
|
|
266
|
-
|
|
565
|
+
qs_raw = handler.path.partition("?")[2]
|
|
566
|
+
parsed_src = _parse_source_param(handler, qs_raw)
|
|
567
|
+
if parsed_src is None:
|
|
568
|
+
return # a 400 has already been sent
|
|
569
|
+
qualified, source = parsed_src
|
|
570
|
+
if qualified:
|
|
571
|
+
return _handle_qualified_browse(handler, qs_raw, source)
|
|
572
|
+
q = _u.parse_qs(qs_raw)
|
|
267
573
|
sort = _qs_str(q, "sort", "recent")
|
|
268
574
|
limit = _qs_int(q, "limit", 50)
|
|
269
575
|
offset = _qs_int(q, "offset", 0)
|
|
@@ -292,6 +598,13 @@ def _handle_get_conversations_facets_impl(handler) -> None:
|
|
|
292
598
|
"""
|
|
293
599
|
if not handler._require_transcripts_allowed():
|
|
294
600
|
return
|
|
601
|
+
qs_raw = handler.path.partition("?")[2]
|
|
602
|
+
parsed_src = _parse_source_param(handler, qs_raw)
|
|
603
|
+
if parsed_src is None:
|
|
604
|
+
return # a 400 has already been sent
|
|
605
|
+
qualified, source = parsed_src
|
|
606
|
+
if qualified:
|
|
607
|
+
return _handle_qualified_facets(handler, qs_raw, source)
|
|
295
608
|
ok, body = handler._run_conversation_query(
|
|
296
609
|
lambda conn: handler._conversation_query().list_conversation_facets(conn),
|
|
297
610
|
"/api/conversations/facets")
|
|
@@ -334,6 +647,17 @@ def _handle_get_conversation_detail_impl(handler, path: str) -> None:
|
|
|
334
647
|
if sum(1 for x in (after is not None, before is not None, tail) if x) > 1:
|
|
335
648
|
handler.send_error(400, "after/before/tail are mutually exclusive")
|
|
336
649
|
return
|
|
650
|
+
if session_id.startswith("v1."):
|
|
651
|
+
# Qualified (v1.) → neutral detail envelope (§2.1 / §2.3).
|
|
652
|
+
speed = _resolve_effective_speed()
|
|
653
|
+
disp = _conversation_dispatch()
|
|
654
|
+
_serve_qualified_entity(
|
|
655
|
+
handler,
|
|
656
|
+
lambda conn: disp.neutral_detail(
|
|
657
|
+
conn, session_id, effective_speed=speed,
|
|
658
|
+
after=after, before=before, tail=tail, limit=limit),
|
|
659
|
+
"/api/conversation")
|
|
660
|
+
return
|
|
337
661
|
ok, body = handler._run_conversation_query(
|
|
338
662
|
lambda conn: handler._conversation_query().get_conversation(
|
|
339
663
|
conn, session_id, after=after, before=before, tail=tail,
|
|
@@ -346,23 +670,9 @@ def _handle_get_conversation_detail_impl(handler, path: str) -> None:
|
|
|
346
670
|
return
|
|
347
671
|
handler._respond_json(200, body)
|
|
348
672
|
|
|
349
|
-
def
|
|
350
|
-
"""
|
|
351
|
-
|
|
352
|
-
the other conversation routes. Watches only this session's file(s);
|
|
353
|
-
emits ``event: tail`` on growth, ``: keep-alive`` when idle. Passive
|
|
354
|
-
(no ingest, no emit) under ``--no-sync``."""
|
|
355
|
-
if not handler._require_transcripts_allowed():
|
|
356
|
-
return
|
|
357
|
-
import time as _time
|
|
358
|
-
import urllib.parse as _u
|
|
359
|
-
watch = sys.modules["cctally"]._load_sibling("_lib_conversation_watch")
|
|
360
|
-
cq = handler._conversation_query()
|
|
361
|
-
session_id = _u.unquote(path[len("/api/conversation/"):-len("/events")])
|
|
362
|
-
if not session_id:
|
|
363
|
-
handler.send_error(404, "conversation not found")
|
|
364
|
-
return
|
|
365
|
-
|
|
673
|
+
def _send_sse_headers(handler) -> None:
|
|
674
|
+
"""The exact SSE header set both the bare and qualified live-tail streams
|
|
675
|
+
commit (kept in one place so bare stream bytes stay byte-identical)."""
|
|
366
676
|
handler.send_response(200)
|
|
367
677
|
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
368
678
|
handler.send_header("Cache-Control", "no-cache")
|
|
@@ -370,8 +680,110 @@ def _handle_get_conversation_events_impl(handler, path: str) -> None:
|
|
|
370
680
|
handler.send_header("X-Accel-Buffering", "no")
|
|
371
681
|
handler.end_headers()
|
|
372
682
|
|
|
373
|
-
passive = bool(type(handler).no_sync)
|
|
374
683
|
|
|
684
|
+
def _run_conversation_events_stream(
|
|
685
|
+
handler, conn, *, passive, tail_data, resolve, ingest, cached_sigs,
|
|
686
|
+
discovery_step=None,
|
|
687
|
+
) -> None:
|
|
688
|
+
"""Shared per-conversation live-tail SSE loop (spec §5.2), driving both the
|
|
689
|
+
bare (``sessionId``) and qualified (``conversationKey``) streams from ONE
|
|
690
|
+
body so their vocabulary — ``event: ready`` once, ``event: tail`` on
|
|
691
|
+
committed growth, ``: keep-alive`` when idle — stays in lockstep and the bare
|
|
692
|
+
stream bytes are byte-identical to today (only ``tail_data`` differs).
|
|
693
|
+
|
|
694
|
+
``resolve()`` → the watched file list; ``ingest(changed)`` → the targeted
|
|
695
|
+
ingest (Claude ``sync_cache`` / Codex ``sync_codex_cache``) whose stats carry
|
|
696
|
+
``targeted_clean``; ``cached_sigs(paths)`` → ``{path: committed cursor}`` (the
|
|
697
|
+
per-path committed-cursor baseline, so growth during an ingest is re-detected
|
|
698
|
+
next cycle); ``discovery_step(files, seen) → (files, emitted)`` is the
|
|
699
|
+
~10-cycle Codex child-discovery frontier (``None`` for Claude — a bare or
|
|
700
|
+
qualified-Claude stream never runs it). The cache lock is held ONLY inside
|
|
701
|
+
``ingest`` / ``discovery_step``, never across a sleep (the #297 WAL
|
|
702
|
+
discipline)."""
|
|
703
|
+
import time as _time
|
|
704
|
+
watch = sys.modules["cctally"]._load_sibling("_lib_conversation_watch")
|
|
705
|
+
tail_frame = (
|
|
706
|
+
"event: tail\ndata: " + json.dumps(tail_data) + "\n\n").encode("utf-8")
|
|
707
|
+
|
|
708
|
+
if passive:
|
|
709
|
+
# Frozen-data contract: no ingest, no emit. Keep-alive only.
|
|
710
|
+
while True:
|
|
711
|
+
_time.sleep(_LIVE_TAIL_KEEPALIVE)
|
|
712
|
+
handler.wfile.write(b": keep-alive\n\n")
|
|
713
|
+
handler.wfile.flush()
|
|
714
|
+
|
|
715
|
+
# #278 Theme B: signal that this connection is ACTIVELY live-tailing (not
|
|
716
|
+
# degraded to keep-alive). The client sets `live` only on this 'ready'.
|
|
717
|
+
handler.wfile.write(b"event: ready\ndata: {}\n\n")
|
|
718
|
+
handler.wfile.flush()
|
|
719
|
+
|
|
720
|
+
files = resolve()
|
|
721
|
+
# Best-effort connect ingest for immediacy, then baseline `seen` from the
|
|
722
|
+
# cache's own committed cursor so any pre-connect growth the connect-ingest
|
|
723
|
+
# declined is still caught on cycle 1.
|
|
724
|
+
try:
|
|
725
|
+
if files:
|
|
726
|
+
ingest(files)
|
|
727
|
+
except sqlite3.DatabaseError:
|
|
728
|
+
pass
|
|
729
|
+
seen = cached_sigs(files)
|
|
730
|
+
|
|
731
|
+
idle = 0.0
|
|
732
|
+
cycles = 0
|
|
733
|
+
while True:
|
|
734
|
+
_time.sleep(_LIVE_TAIL_POLL_INTERVAL)
|
|
735
|
+
cycles += 1
|
|
736
|
+
changed = watch.changed_paths(files, seen)
|
|
737
|
+
if changed:
|
|
738
|
+
_time.sleep(_LIVE_TAIL_DEBOUNCE)
|
|
739
|
+
new_seen, emitted = watch.watch_step(
|
|
740
|
+
files, seen, ingest_fn=ingest,
|
|
741
|
+
committed_sig_fn=lambda p: cached_sigs([p]).get(p))
|
|
742
|
+
seen = new_seen
|
|
743
|
+
if emitted:
|
|
744
|
+
handler.wfile.write(tail_frame)
|
|
745
|
+
handler.wfile.flush()
|
|
746
|
+
idle = 0.0
|
|
747
|
+
# A brand-new child file's FIRST content was just ingested, so
|
|
748
|
+
# the conversation's source-path set may have grown. Re-resolve
|
|
749
|
+
# now (vs waiting up to _LIVE_TAIL_FILE_RESET_EVERY cycles) so the
|
|
750
|
+
# new thread live-tails promptly. A new path seeds seen=None (cur
|
|
751
|
+
# lacks a row) → changed_paths flags it next cycle → it emits.
|
|
752
|
+
new_files = resolve()
|
|
753
|
+
if set(new_files) != set(files):
|
|
754
|
+
files = new_files
|
|
755
|
+
cur = cached_sigs(files)
|
|
756
|
+
for p in files:
|
|
757
|
+
seen.setdefault(p, cur.get(p))
|
|
758
|
+
continue
|
|
759
|
+
idle += _LIVE_TAIL_POLL_INTERVAL
|
|
760
|
+
if idle >= _LIVE_TAIL_KEEPALIVE:
|
|
761
|
+
handler.wfile.write(b": keep-alive\n\n")
|
|
762
|
+
handler.wfile.flush()
|
|
763
|
+
idle = 0.0
|
|
764
|
+
if cycles % _LIVE_TAIL_FILE_RESET_EVERY == 0:
|
|
765
|
+
# Layer 1 (both providers): DB re-resolve so a child ingested by ANY
|
|
766
|
+
# other writer joins the watch immediately.
|
|
767
|
+
files = resolve()
|
|
768
|
+
seen = {p: s for p, s in seen.items() if p in set(files)}
|
|
769
|
+
# Layer 2 (Codex only): bounded filesystem child discovery for
|
|
770
|
+
# brand-new files no table yet knows (§5.4). A widened file set emits
|
|
771
|
+
# a `tail` — the client refetches detail, whose children list
|
|
772
|
+
# surfaces the new child (no new frame type).
|
|
773
|
+
if discovery_step is not None:
|
|
774
|
+
files, disc_emitted = discovery_step(files, seen)
|
|
775
|
+
if disc_emitted:
|
|
776
|
+
handler.wfile.write(tail_frame)
|
|
777
|
+
handler.wfile.flush()
|
|
778
|
+
idle = 0.0
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _bare_conversation_events(handler, session_id: str) -> None:
|
|
782
|
+
"""Bare legacy Claude live-tail — today's no-preflight, ``sessionId``-framed
|
|
783
|
+
behavior, byte-identical (spec §5.2 reserves this for bare streams)."""
|
|
784
|
+
cq = handler._conversation_query()
|
|
785
|
+
_send_sse_headers(handler)
|
|
786
|
+
passive = bool(type(handler).no_sync)
|
|
375
787
|
try:
|
|
376
788
|
conn = sys.modules["_cctally_dashboard"].open_cache_db() # late-binding: patched at test_conversation_endpoints.py:674
|
|
377
789
|
except (sqlite3.DatabaseError, OSError):
|
|
@@ -387,89 +799,177 @@ def _handle_get_conversation_events_impl(handler, path: str) -> None:
|
|
|
387
799
|
return sync_cache(conn, only_paths=set(changed))
|
|
388
800
|
|
|
389
801
|
try:
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
802
|
+
_run_conversation_events_stream(
|
|
803
|
+
handler, conn, passive=passive,
|
|
804
|
+
tail_data={"sessionId": session_id},
|
|
805
|
+
resolve=_resolve, ingest=_ingest,
|
|
806
|
+
cached_sigs=lambda paths: _cached_file_sigs(conn, paths))
|
|
807
|
+
except (BrokenPipeError, ConnectionResetError,
|
|
808
|
+
ConnectionAbortedError, socket.timeout):
|
|
809
|
+
# #279 S1 F3: a stalled send past the handler timeout raises
|
|
810
|
+
# socket.timeout — treat as a client disconnect, not an error.
|
|
811
|
+
pass
|
|
812
|
+
except Exception as exc: # noqa: BLE001
|
|
813
|
+
# #279 S5 F6.2 (spec §8): headers are already committed, so route the
|
|
814
|
+
# operator signal through the _lib_log chokepoint + a clean close.
|
|
815
|
+
handler.log_error("api/conversation/events stream failed: %r", exc)
|
|
816
|
+
finally:
|
|
817
|
+
if conn is not None:
|
|
818
|
+
conn.close()
|
|
819
|
+
|
|
396
820
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
821
|
+
def _make_codex_discovery_step(handler, conn, conversation_key, cq_codex):
|
|
822
|
+
"""Build the ~10-cycle Codex child-discovery frontier step (spec §5.4), or
|
|
823
|
+
``None`` when the conversation's own root is not currently configured (the
|
|
824
|
+
frontier is then skipped — the DB re-resolve of layer 1 still runs). The
|
|
825
|
+
frontier is constructed ONCE per SSE connection so its directory index,
|
|
826
|
+
pending-candidate set, and rotation cursors accumulate across cycles."""
|
|
827
|
+
walk_root = _codex_walk_root_for_conversation(conn, conversation_key)
|
|
828
|
+
if walk_root is None:
|
|
829
|
+
return None
|
|
830
|
+
fw = sys.modules["cctally"]._load_sibling("_lib_codex_conversation_watch")
|
|
831
|
+
frontier = fw.CodexChildFrontier(walk_root)
|
|
832
|
+
|
|
833
|
+
def _discovery(files, seen):
|
|
834
|
+
# Diff brand-new *.jsonl against every tracked Codex file; the same
|
|
835
|
+
# {path: size} feeds both the frontier's known-set and its committed-size
|
|
836
|
+
# growth baseline (a plain SELECT — no lock across the sleep).
|
|
837
|
+
known = _codex_all_committed_sizes(conn)
|
|
838
|
+
to_ingest = frontier.cycle(
|
|
839
|
+
known_paths=set(known.keys()), committed_sizes=known)
|
|
840
|
+
if not to_ingest:
|
|
841
|
+
return files, False
|
|
409
842
|
try:
|
|
410
|
-
|
|
411
|
-
sync_cache(conn, only_paths=set(files))
|
|
843
|
+
sync_codex_cache(conn, only_paths=set(to_ingest))
|
|
412
844
|
except sqlite3.DatabaseError:
|
|
845
|
+
return files, False
|
|
846
|
+
# Reap the now-classified candidates (child → already widened; non-child
|
|
847
|
+
# → done). Unclassified ones stay pending and are retried next cycle.
|
|
848
|
+
frontier.reap(_codex_classified_paths(conn, to_ingest))
|
|
849
|
+
new_files = cq_codex.codex_conversation_source_paths(conn, conversation_key)
|
|
850
|
+
if set(new_files) == set(files):
|
|
851
|
+
return files, False
|
|
852
|
+
cur = _codex_cached_file_sigs(conn, new_files)
|
|
853
|
+
for p in new_files:
|
|
854
|
+
seen.setdefault(p, cur.get(p))
|
|
855
|
+
return new_files, True
|
|
856
|
+
|
|
857
|
+
return _discovery
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _qualified_conversation_events(handler, key: str) -> None:
|
|
861
|
+
"""Qualified (``v1.``) live-tail (spec §5.2): a neutral preflight — resolve →
|
|
862
|
+
normalization authority (Codex) → existence — answered as plain JSON per
|
|
863
|
+
§2.3 BEFORE any SSE bytes; only on ``ok`` are SSE headers committed and the
|
|
864
|
+
``conversationKey``-framed stream entered. A qualified Claude key reuses the
|
|
865
|
+
existing Claude ingestion/watch mechanics internally; a Codex key uses
|
|
866
|
+
targeted ingest + the directory-frontier child discovery."""
|
|
867
|
+
disp = _conversation_dispatch_impl()
|
|
868
|
+
try:
|
|
869
|
+
conn = sys.modules["_cctally_dashboard"].open_cache_db() # late-binding: patched at test_conversation_endpoints.py:674
|
|
870
|
+
except (sqlite3.DatabaseError, OSError):
|
|
871
|
+
conn = None
|
|
872
|
+
|
|
873
|
+
if conn is None:
|
|
874
|
+
# Cache unavailable — existence is unknowable, so we cannot preflight.
|
|
875
|
+
# Degrade to a passive keep-alive stream (the client's backstop tick
|
|
876
|
+
# still surfaces turns), mirroring the bare path's conn-failure fallback.
|
|
877
|
+
_send_sse_headers(handler)
|
|
878
|
+
try:
|
|
879
|
+
_run_conversation_events_stream(
|
|
880
|
+
handler, None, passive=True, tail_data={"conversationKey": key},
|
|
881
|
+
resolve=lambda: [], ingest=lambda c: None,
|
|
882
|
+
cached_sigs=lambda paths: {})
|
|
883
|
+
except (BrokenPipeError, ConnectionResetError,
|
|
884
|
+
ConnectionAbortedError, socket.timeout):
|
|
413
885
|
pass
|
|
414
|
-
|
|
886
|
+
except Exception as exc: # noqa: BLE001
|
|
887
|
+
handler.log_error("api/conversation/events stream failed: %r", exc)
|
|
888
|
+
return
|
|
415
889
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
890
|
+
try:
|
|
891
|
+
preflight = disp.neutral_events_preflight(conn, key)
|
|
892
|
+
except Exception as exc: # noqa: BLE001
|
|
893
|
+
# A preflight failure is a pre-headers error, so a JSON 500 is still
|
|
894
|
+
# possible (unlike a mid-stream failure).
|
|
895
|
+
handler.log_error("api/conversation/events preflight failed: %r", exc)
|
|
896
|
+
handler._respond_json(500, {"error": "internal error"})
|
|
897
|
+
conn.close()
|
|
898
|
+
return
|
|
899
|
+
status = preflight.get("status")
|
|
900
|
+
if status == "normalization_pending":
|
|
901
|
+
# A legitimate empty state (migration 025 not yet stamped) — 200 JSON
|
|
902
|
+
# envelope, never a 200-SSE stream with nothing to say (§2.3).
|
|
903
|
+
handler._respond_json(200, preflight)
|
|
904
|
+
conn.close()
|
|
905
|
+
return
|
|
906
|
+
if status != "ok":
|
|
907
|
+
# not_found (unresolvable ref or no rows) → 404 JSON (§2.3).
|
|
908
|
+
handler._respond_json(404, preflight)
|
|
909
|
+
conn.close()
|
|
910
|
+
return
|
|
911
|
+
source = preflight["source"]
|
|
912
|
+
native = preflight["native_key"]
|
|
913
|
+
|
|
914
|
+
# ok → commit SSE headers and enter the qualified watch loop.
|
|
915
|
+
_send_sse_headers(handler)
|
|
916
|
+
passive = bool(type(handler).no_sync)
|
|
917
|
+
if source == "codex":
|
|
918
|
+
cq_codex = _conversation_query_impl_codex()
|
|
919
|
+
|
|
920
|
+
def _resolve():
|
|
921
|
+
return cq_codex.codex_conversation_source_paths(conn, key)
|
|
922
|
+
|
|
923
|
+
def _ingest(changed):
|
|
924
|
+
return sync_codex_cache(conn, only_paths=set(changed))
|
|
925
|
+
|
|
926
|
+
cached = lambda paths: _codex_cached_file_sigs(conn, paths)
|
|
927
|
+
discovery = _make_codex_discovery_step(handler, conn, key, cq_codex)
|
|
928
|
+
else: # claude — reuse the Claude mechanics, speak qualified frames.
|
|
929
|
+
cq = handler._conversation_query()
|
|
930
|
+
|
|
931
|
+
def _resolve():
|
|
932
|
+
return cq.session_source_paths(conn, native)
|
|
933
|
+
|
|
934
|
+
def _ingest(changed):
|
|
935
|
+
return sync_cache(conn, only_paths=set(changed))
|
|
936
|
+
|
|
937
|
+
cached = lambda paths: _cached_file_sigs(conn, paths)
|
|
938
|
+
discovery = None
|
|
939
|
+
|
|
940
|
+
try:
|
|
941
|
+
_run_conversation_events_stream(
|
|
942
|
+
handler, conn, passive=passive,
|
|
943
|
+
tail_data={"conversationKey": key},
|
|
944
|
+
resolve=_resolve, ingest=_ingest, cached_sigs=cached,
|
|
945
|
+
discovery_step=discovery)
|
|
458
946
|
except (BrokenPipeError, ConnectionResetError,
|
|
459
947
|
ConnectionAbortedError, socket.timeout):
|
|
460
|
-
|
|
461
|
-
# socket.timeout inside the SSE loop — treat it as a client
|
|
462
|
-
# disconnect (same as the other peer-gone classes), not an error.
|
|
463
|
-
pass # client disconnect is normal
|
|
948
|
+
pass
|
|
464
949
|
except Exception as exc: # noqa: BLE001
|
|
465
|
-
# #279 S5 F6.2 (spec §8): a genuine bug mid-stream used to kill the
|
|
466
|
-
# live-tail SSE silently via handle_error. Headers are already
|
|
467
|
-
# committed — route the operator signal through the _lib_log chokepoint
|
|
468
|
-
# (handler.log_error) + a deliberate clean close via the finally below.
|
|
469
950
|
handler.log_error("api/conversation/events stream failed: %r", exc)
|
|
470
951
|
finally:
|
|
471
|
-
|
|
472
|
-
|
|
952
|
+
conn.close()
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _handle_get_conversation_events_impl(handler, path: str) -> None:
|
|
956
|
+
"""``GET /api/conversation/<id>/events`` — per-conversation live-tail SSE
|
|
957
|
+
(spec §5.2). The transcript privacy gate is the first act, source-
|
|
958
|
+
independent. A ``v1.`` key gets the neutral preflight + qualified
|
|
959
|
+
(``conversationKey``) frames for BOTH providers; every other id keeps
|
|
960
|
+
today's no-preflight, ``sessionId``-framed bare behavior byte-identical."""
|
|
961
|
+
if not handler._require_transcripts_allowed():
|
|
962
|
+
return
|
|
963
|
+
import urllib.parse as _u
|
|
964
|
+
key = _u.unquote(path[len("/api/conversation/"):-len("/events")])
|
|
965
|
+
if not key:
|
|
966
|
+
handler.send_error(404, "conversation not found")
|
|
967
|
+
return
|
|
968
|
+
if key.startswith("v1."):
|
|
969
|
+
_qualified_conversation_events(handler, key)
|
|
970
|
+
else:
|
|
971
|
+
_bare_conversation_events(handler, key)
|
|
972
|
+
|
|
473
973
|
|
|
474
974
|
def _handle_get_conversation_search_impl(handler) -> None:
|
|
475
975
|
"""``GET /api/conversation/search?q=...&kind=...`` — cross-session
|
|
@@ -487,7 +987,14 @@ def _handle_get_conversation_search_impl(handler) -> None:
|
|
|
487
987
|
if not handler._require_transcripts_allowed():
|
|
488
988
|
return
|
|
489
989
|
import urllib.parse as _u
|
|
490
|
-
|
|
990
|
+
qs_raw = handler.path.partition("?")[2]
|
|
991
|
+
parsed_src = _parse_source_param(handler, qs_raw)
|
|
992
|
+
if parsed_src is None:
|
|
993
|
+
return # a 400 has already been sent
|
|
994
|
+
qualified, source = parsed_src
|
|
995
|
+
if qualified:
|
|
996
|
+
return _handle_qualified_search(handler, qs_raw, source)
|
|
997
|
+
q = _u.parse_qs(qs_raw)
|
|
491
998
|
query = _qs_str(q, "q", "")
|
|
492
999
|
limit = _qs_int(q, "limit", 50)
|
|
493
1000
|
offset = _qs_int(q, "offset", 0)
|
|
@@ -524,6 +1031,21 @@ def _handle_get_conversation_payload_impl(handler, path: str) -> None:
|
|
|
524
1031
|
session_id = _u.unquote(
|
|
525
1032
|
path[len("/api/conversation/"):-len("/payload")])
|
|
526
1033
|
q = _u.parse_qs(handler.path.partition("?")[2])
|
|
1034
|
+
if session_id.startswith("v1."):
|
|
1035
|
+
# Qualified payload readback (§3.4): Codex uses block_key + which={call,
|
|
1036
|
+
# output}; a v1.claude key keeps the tool_use_id + which={input,result}
|
|
1037
|
+
# selector. neutral_payload picks per the resolved source; gone → 410.
|
|
1038
|
+
which_q = _qs_str(q, "which", "")
|
|
1039
|
+
tool_use_id_q = _qs_str(q, "tool_use_id", "") or None
|
|
1040
|
+
block_key_q = _qs_str(q, "block_key", "") or None
|
|
1041
|
+
disp = _conversation_dispatch()
|
|
1042
|
+
_serve_qualified_entity(
|
|
1043
|
+
handler,
|
|
1044
|
+
lambda conn: disp.neutral_payload(
|
|
1045
|
+
conn, session_id, which=which_q,
|
|
1046
|
+
tool_use_id=tool_use_id_q, block_key=block_key_q),
|
|
1047
|
+
"/api/conversation/payload")
|
|
1048
|
+
return
|
|
527
1049
|
tool_use_id = _qs_str(q, "tool_use_id", "")
|
|
528
1050
|
which = _qs_str(q, "which", "result")
|
|
529
1051
|
if not session_id or which not in ("result", "input") or not tool_use_id:
|
|
@@ -556,6 +1078,15 @@ def _handle_get_conversation_outline_impl(handler, path: str) -> None:
|
|
|
556
1078
|
if not session_id:
|
|
557
1079
|
handler.send_error(404, "conversation not found")
|
|
558
1080
|
return
|
|
1081
|
+
if session_id.startswith("v1."):
|
|
1082
|
+
speed = _resolve_effective_speed()
|
|
1083
|
+
disp = _conversation_dispatch()
|
|
1084
|
+
_serve_qualified_entity(
|
|
1085
|
+
handler,
|
|
1086
|
+
lambda conn: disp.neutral_outline(
|
|
1087
|
+
conn, session_id, effective_speed=speed),
|
|
1088
|
+
"/api/conversation/outline")
|
|
1089
|
+
return
|
|
559
1090
|
ok, body = handler._run_conversation_query(
|
|
560
1091
|
lambda conn: handler._conversation_query().get_conversation_outline(conn, session_id),
|
|
561
1092
|
"/api/conversation/outline")
|
|
@@ -580,6 +1111,13 @@ def _handle_get_conversation_prompts_impl(handler, path: str) -> None:
|
|
|
580
1111
|
if not session_id:
|
|
581
1112
|
handler.send_error(404, "conversation not found")
|
|
582
1113
|
return
|
|
1114
|
+
if session_id.startswith("v1."):
|
|
1115
|
+
disp = _conversation_dispatch()
|
|
1116
|
+
_serve_qualified_entity(
|
|
1117
|
+
handler,
|
|
1118
|
+
lambda conn: disp.neutral_prompts(conn, session_id),
|
|
1119
|
+
"/api/conversation/prompts")
|
|
1120
|
+
return
|
|
583
1121
|
ok, body = handler._run_conversation_query(
|
|
584
1122
|
lambda conn: handler._conversation_query().get_conversation_prompts(conn, session_id),
|
|
585
1123
|
"/api/conversation/prompts")
|
|
@@ -633,6 +1171,48 @@ def _handle_get_conversation_export_impl(handler, path: str) -> None:
|
|
|
633
1171
|
handler.send_error(404, "conversation not found")
|
|
634
1172
|
return
|
|
635
1173
|
|
|
1174
|
+
if session_id.startswith("v1."):
|
|
1175
|
+
# Qualified export (§2.3 markdown leg / §3.6 provider-aware anon):
|
|
1176
|
+
# neutral_export serves the raw markdown member; ``anonymize=1`` scrubs
|
|
1177
|
+
# with the QUALIFIED provider-aware plan (byte-parity with the CLI export).
|
|
1178
|
+
speed = _resolve_effective_speed()
|
|
1179
|
+
disp = _conversation_dispatch()
|
|
1180
|
+
cref = disp.resolve_conversation_ref(session_id)
|
|
1181
|
+
|
|
1182
|
+
def _q_kernel(conn):
|
|
1183
|
+
env = disp.neutral_export(
|
|
1184
|
+
conn, session_id, scope=scope, effective_speed=speed)
|
|
1185
|
+
if env.get("status") != "ok" or not anonymize:
|
|
1186
|
+
return env
|
|
1187
|
+
cq = handler._conversation_query()
|
|
1188
|
+
anon = sys.modules["cctally"]._load_sibling("_lib_conversation_anon")
|
|
1189
|
+
srcs = {cref.source} if cref else set()
|
|
1190
|
+
plan = cq.build_anon_plan_for_sources(
|
|
1191
|
+
conn, home_dir=_os.path.expanduser("~"), sources=srcs)
|
|
1192
|
+
return {**env, "markdown": anon.scrub_text(env["markdown"], plan)}
|
|
1193
|
+
|
|
1194
|
+
ok, env = handler._run_conversation_query(
|
|
1195
|
+
_q_kernel, "/api/conversation/export")
|
|
1196
|
+
if not ok:
|
|
1197
|
+
return
|
|
1198
|
+
status = (env or {}).get("status")
|
|
1199
|
+
if status == "ok":
|
|
1200
|
+
data = env["markdown"].encode("utf-8")
|
|
1201
|
+
handler.send_response(200)
|
|
1202
|
+
handler.send_header("Content-Type", "text/markdown; charset=utf-8")
|
|
1203
|
+
handler.send_header("Content-Length", str(len(data)))
|
|
1204
|
+
handler.end_headers()
|
|
1205
|
+
handler.wfile.write(data)
|
|
1206
|
+
return
|
|
1207
|
+
if status == "validation_error":
|
|
1208
|
+
handler._respond_json(400, env)
|
|
1209
|
+
return
|
|
1210
|
+
if status == "normalization_pending":
|
|
1211
|
+
handler._respond_json(200, env)
|
|
1212
|
+
return
|
|
1213
|
+
handler._respond_json(404, env) # not_found
|
|
1214
|
+
return
|
|
1215
|
+
|
|
636
1216
|
def _kernel(conn):
|
|
637
1217
|
cq = handler._conversation_query()
|
|
638
1218
|
md = cq.get_conversation_export(conn, session_id, scope)
|
|
@@ -678,6 +1258,40 @@ def _handle_get_conversation_anon_map_impl(handler, path: str) -> None:
|
|
|
678
1258
|
return
|
|
679
1259
|
anon = sys.modules["cctally"]._load_sibling("_lib_conversation_anon")
|
|
680
1260
|
|
|
1261
|
+
if session_id.startswith("v1."):
|
|
1262
|
+
# Qualified anon-map (§3.6): existence probe against the ref's OWN
|
|
1263
|
+
# provider tables, then the provider-aware plan (never the legacy builder).
|
|
1264
|
+
disp = _conversation_dispatch()
|
|
1265
|
+
cref = disp.resolve_conversation_ref(session_id)
|
|
1266
|
+
if cref is None:
|
|
1267
|
+
handler._respond_json(
|
|
1268
|
+
404, {"status": "not_found", "conversation_key": session_id})
|
|
1269
|
+
return
|
|
1270
|
+
|
|
1271
|
+
def _q_kernel(conn):
|
|
1272
|
+
cq = handler._conversation_query()
|
|
1273
|
+
if cref.source == "codex":
|
|
1274
|
+
exists = _conversation_query_impl_codex().codex_conversation_exists(
|
|
1275
|
+
conn, cref.conversation_key)
|
|
1276
|
+
else:
|
|
1277
|
+
exists = cq.conversation_exists(conn, cref.native_key)
|
|
1278
|
+
if not exists:
|
|
1279
|
+
return None
|
|
1280
|
+
plan = cq.build_anon_plan_for_sources(
|
|
1281
|
+
conn, home_dir=_os.path.expanduser("~"), sources={cref.source})
|
|
1282
|
+
return anon.plan_to_wire(plan)
|
|
1283
|
+
|
|
1284
|
+
ok, body = handler._run_conversation_query(
|
|
1285
|
+
_q_kernel, "/api/conversation/anon-map")
|
|
1286
|
+
if not ok:
|
|
1287
|
+
return
|
|
1288
|
+
if body is None:
|
|
1289
|
+
handler._respond_json(
|
|
1290
|
+
404, {"status": "not_found", "conversation_key": session_id})
|
|
1291
|
+
return
|
|
1292
|
+
handler._respond_json(200, body)
|
|
1293
|
+
return
|
|
1294
|
+
|
|
681
1295
|
def _kernel(conn):
|
|
682
1296
|
cq = handler._conversation_query()
|
|
683
1297
|
if not cq.conversation_exists(conn, session_id):
|
|
@@ -734,6 +1348,14 @@ def _handle_get_conversation_find_impl(handler, path: str) -> None:
|
|
|
734
1348
|
except _re.error as e:
|
|
735
1349
|
handler._respond_json(400, {"error": f"invalid regex: {e}"})
|
|
736
1350
|
return
|
|
1351
|
+
if session_id.startswith("v1."):
|
|
1352
|
+
disp = _conversation_dispatch()
|
|
1353
|
+
_serve_qualified_entity(
|
|
1354
|
+
handler,
|
|
1355
|
+
lambda conn: disp.neutral_find(
|
|
1356
|
+
conn, session_id, query, kind=kind, regex=regex, case=case),
|
|
1357
|
+
"/api/conversation/find")
|
|
1358
|
+
return
|
|
737
1359
|
ok, body = handler._run_conversation_query(
|
|
738
1360
|
lambda conn: handler._conversation_query().find_in_conversation(
|
|
739
1361
|
conn, session_id, query, kind=kind, regex=regex, case=case),
|
|
@@ -773,6 +1395,21 @@ def _handle_get_conversation_media_impl(handler, path: str) -> None:
|
|
|
773
1395
|
return
|
|
774
1396
|
import urllib.parse as _u
|
|
775
1397
|
session_id = _u.unquote(path[len("/api/conversation/"):-len("/media")])
|
|
1398
|
+
if session_id.startswith("v1."):
|
|
1399
|
+
# §3.5 / §2.3: Codex media is capability-gated (explicit 404, never a
|
|
1400
|
+
# silent zero-fill). A v1.claude key serves via its native Claude session
|
|
1401
|
+
# (the existing byte-serving mechanics); an unresolvable v1 → neutral 404.
|
|
1402
|
+
disp = _conversation_dispatch()
|
|
1403
|
+
cref = disp.resolve_conversation_ref(session_id)
|
|
1404
|
+
if cref is None:
|
|
1405
|
+
handler._respond_json(
|
|
1406
|
+
404, {"status": "not_found", "conversation_key": session_id})
|
|
1407
|
+
return
|
|
1408
|
+
if cref.source == "codex":
|
|
1409
|
+
handler._respond_json(
|
|
1410
|
+
404, {"status": "capability_unsupported", "source": "codex"})
|
|
1411
|
+
return
|
|
1412
|
+
session_id = cref.native_key
|
|
776
1413
|
q = _u.parse_qs(handler.path.partition("?")[2])
|
|
777
1414
|
tool_use_id = _qs_str(q, "tool_use_id", "")
|
|
778
1415
|
uuid = _qs_str(q, "uuid", "")
|