cctally 1.65.0 → 1.66.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 (42) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/bin/_cctally_alerts.py +1 -1
  3. package/bin/_cctally_cache.py +203 -22
  4. package/bin/_cctally_cache_report.py +7 -5
  5. package/bin/_cctally_core.py +49 -14
  6. package/bin/_cctally_dashboard.py +481 -4891
  7. package/bin/_cctally_dashboard_cache_report.py +714 -0
  8. package/bin/_cctally_dashboard_conversation.py +771 -0
  9. package/bin/_cctally_dashboard_envelope.py +1520 -0
  10. package/bin/_cctally_dashboard_share.py +2012 -0
  11. package/bin/_cctally_db.py +127 -5
  12. package/bin/_cctally_doctor.py +104 -3
  13. package/bin/_cctally_five_hour.py +2 -1
  14. package/bin/_cctally_forecast.py +78 -135
  15. package/bin/_cctally_parser.py +919 -784
  16. package/bin/_cctally_percent_breakdown.py +1 -1
  17. package/bin/_cctally_pricing_check.py +39 -0
  18. package/bin/_cctally_project.py +8 -5
  19. package/bin/_cctally_record.py +279 -274
  20. package/bin/_cctally_reporting.py +1 -1
  21. package/bin/_cctally_sync_week.py +1 -1
  22. package/bin/_cctally_telemetry.py +3 -5
  23. package/bin/_cctally_tui.py +42 -18
  24. package/bin/_lib_alert_dispatch.py +1 -1
  25. package/bin/_lib_blocks.py +6 -1
  26. package/bin/_lib_credit.py +133 -0
  27. package/bin/_lib_doctor.py +150 -1
  28. package/bin/_lib_five_hour.py +34 -0
  29. package/bin/_lib_forecast.py +144 -0
  30. package/bin/_lib_json_envelope.py +38 -0
  31. package/bin/_lib_jsonl.py +115 -73
  32. package/bin/_lib_log.py +96 -0
  33. package/bin/_lib_pricing.py +47 -1
  34. package/bin/_lib_pricing_check.py +25 -3
  35. package/bin/_lib_record.py +179 -0
  36. package/bin/_lib_render.py +18 -10
  37. package/bin/_lib_snapshot_cache.py +61 -0
  38. package/bin/cctally +51 -7
  39. package/bin/cctally-dashboard +2 -2
  40. package/bin/cctally-statusline +1 -0
  41. package/bin/cctally-tui +2 -2
  42. package/package.json +10 -1
@@ -0,0 +1,771 @@
1
+ """Dashboard conversation-viewer handlers (#279 S5 F4 — line-budget seam).
2
+
3
+ Consumer-only sibling of ``bin/_cctally_dashboard.py`` — it re-imports every
4
+ name below, so ``bin/cctally``'s re-exports and the conversation pytest
5
+ files (``tests/test_conversation_endpoints.py``,
6
+ ``tests/test_conversation_query.py``) keep resolving unchanged (spec §6).
7
+
8
+ What lives here (spec §6):
9
+ - the conversation constants/helpers ``_CONV_SEARCH_KINDS`` /
10
+ ``_CONV_FIND_KINDS`` / ``_BadConversationFilter`` / ``_cached_file_sigs``
11
+ (the ``/api/debug/backend`` helpers + ``_DEBUG_CACHE_TABLES`` +
12
+ ``_handle_get_debug_backend`` are debug-coupled and STAY in the dashboard,
13
+ gate P3-2);
14
+ - the query plumbing (``_conversation_query_impl`` / ``_parse_search_kind_impl``
15
+ / ``_run_conversation_query_impl`` / ``_parse_conversation_filters_impl``);
16
+ - the eleven ``_handle_get_conversation*_impl(handler, …)`` handlers
17
+ (including ``_handle_get_conversation_events_impl``, the live-tail SSE
18
+ watch loop, with its ``_lib_conversation_watch`` usage). The dashboard
19
+ keeps thin bound delegators on ``DashboardHTTPHandler``; the privacy gates
20
+ (``_require_transcripts_allowed`` / ``_transcript_gate`` / …) STAY on the
21
+ class and are reached via the ``handler`` parameter (spec §6).
22
+
23
+ Late-binding (spec §6): the two bare ``open_cache_db`` calls (in
24
+ ``_run_conversation_query_impl`` + the events handler) reach
25
+ ``sys.modules["_cctally_dashboard"].open_cache_db(...)`` — patched on the
26
+ dashboard module object at ``tests/test_conversation_endpoints.py:674``.
27
+
28
+ Cross-module reaches (spec §2.1 "fully-qualify cross-module refs"): the
29
+ cctally-forwarding shims the moved code called by bare name
30
+ (``_resolve_display_tz_obj`` / ``_apply_display_tz_override`` / ``load_config``)
31
+ are inlined to their ``sys.modules["cctally"].X`` call-time reach — behavior
32
+ + ns["X"] patch surface preserved (none is dashboard-object-patched;
33
+ audited). The generic query-string helpers ``_qs_str`` / ``_qs_int`` stay in
34
+ the dashboard (used across every handler); they have 24 call sites here, so
35
+ they are reached via module-local forwarders (below) rather than 24 inline
36
+ edits — the forwarders hit ``sys.modules["_cctally_dashboard"]`` at call time
37
+ (no cycle: the dashboard is fully loaded before any handler runs).
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import json
42
+ import socket
43
+ import sqlite3
44
+ import sys
45
+
46
+ from _cctally_cache import sync_cache
47
+
48
+ # Live-tail watch-loop tuning — used ONLY by _handle_get_conversation_events_impl
49
+ # below, so moved here with the events handler (spec §4.1 / §6).
50
+ _LIVE_TAIL_POLL_INTERVAL = 1.0 # seconds between stat polls of the open file(s)
51
+ _LIVE_TAIL_DEBOUNCE = 0.25 # settle window after first detected growth
52
+ _LIVE_TAIL_KEEPALIVE = 15.0 # idle keep-alive cadence (proxy guard)
53
+ _LIVE_TAIL_FILE_RESET_EVERY = 10 # re-resolve the session file set every N cycles
54
+
55
+
56
+ # Module-local forwarders for the two generic query-string helpers that STAY
57
+ # in the dashboard (24 call sites in the moved handlers; the dashboard is fully
58
+ # loaded before any handler runs, so the call-time reach is cycle-free).
59
+ def _qs_str(*args, **kwargs):
60
+ return sys.modules["_cctally_dashboard"]._qs_str(*args, **kwargs)
61
+
62
+
63
+ def _qs_int(*args, **kwargs):
64
+ return sys.modules["_cctally_dashboard"]._qs_int(*args, **kwargs)
65
+
66
+
67
+ # #177 S6 / #217 S2: valid kind facets for the conversation routes. Kept in
68
+ # lockstep with the kernel (``_lib_conversation_query._SEARCH_KINDS`` /
69
+ # ``_FIND_KINDS``; the kernel re-raises ValueError on an unknown kind, and the
70
+ # handlers reject with a 400 BEFORE the call — ``_run_conversation_query``
71
+ # collapses every kernel exception to a 500, so a per-route 4xx must be decided
72
+ # in the handler, not via try/except around the kernel).
73
+ #
74
+ # P1-1 (load-bearing kind-validation SPLIT): the cross-session search route
75
+ # accepts ``title`` and ``files``; the in-conversation ``/find`` route does NOT —
76
+ # its kernel (``find_in_conversation``) indexes ``_FIND_KIND_COLUMNS[kind]``,
77
+ # which has no ``title``/``files`` entry, so accepting them there would be a 500
78
+ # KeyError. Two distinct tuples keep ``/find?kind=title`` and ``/find?kind=files``
79
+ # a clean 400.
80
+ _CONV_SEARCH_KINDS = (
81
+ "all", "prompts", "assistant", "tools", "thinking", "title", "files")
82
+ _CONV_FIND_KINDS = ("all", "prompts", "assistant", "tools", "thinking")
83
+
84
+
85
+ class _BadConversationFilter(Exception):
86
+ """Internal sentinel: a browse-filter query param failed validation. The
87
+ parse helper has ALREADY sent the 400 response when this is raised, so the
88
+ caller just unwinds and returns (the conversation routes all 400 on bad
89
+ input, consistent with the search ``kind`` facet). Module-private."""
90
+
91
+
92
+ def _cached_file_sigs(conn, paths):
93
+ """{path: size_bytes} from session_files for the given paths — the cache's
94
+ own view of how far each file is ingested. Size-only by design, matching the
95
+ watch kernel's size-only signature (`file_sig`) and sync_cache's size-only
96
+ delta signal: mtime is NOT consulted, because a size-unchanged ingest does
97
+ not refresh session_files.mtime_ns and a stale mtime would re-detect
98
+ 'changed' every cycle forever. Used to baseline the live-tail watch so a file
99
+ the cache hasn't caught up on reads as 'changed' on cycle 1 (spec §2.4).
100
+ Paths with no row are simply absent → treated as changed."""
101
+ out = {}
102
+ if not paths:
103
+ return out
104
+ placeholders = ",".join("?" for _ in paths)
105
+ try:
106
+ rows = conn.execute(
107
+ f"SELECT path, size_bytes FROM session_files "
108
+ f"WHERE path IN ({placeholders})", list(paths)).fetchall()
109
+ except sqlite3.OperationalError:
110
+ return out
111
+ for p, size in rows:
112
+ out[p] = size
113
+ return out
114
+
115
+
116
+ def _conversation_query_impl():
117
+ """Lazy-load the pure conversation query kernel (Plan 2, §3)."""
118
+ return sys.modules["cctally"]._load_sibling("_lib_conversation_query")
119
+
120
+ def _parse_search_kind_impl(handler, q, valid=_CONV_SEARCH_KINDS):
121
+ """Read + validate the ``kind`` facet for a conversation route (#177 S6 /
122
+ #217 S2). Returns the kind on success, or ``None`` after having ALREADY
123
+ sent a 400 — callers just ``return`` on ``None``.
124
+
125
+ ``valid`` is the per-route kind set (P1-1 split): the cross-session search
126
+ route passes ``_CONV_SEARCH_KINDS`` (includes ``title``), the
127
+ in-conversation ``/find`` route passes ``_CONV_FIND_KINDS`` (excludes
128
+ ``title``/``files``), so ``/find?kind=title`` is a 400 here — never a 500
129
+ KeyError downstream in ``find_in_conversation``. Kept in lockstep with the
130
+ kernel's ``_SEARCH_KINDS`` / ``_FIND_KINDS`` (the kernel module is
131
+ resolved lazily per-request, so the handler keeps literal tuples rather
132
+ than reaching across that import edge for a nit)."""
133
+ kind = _qs_str(q, "kind", "all")
134
+ if kind not in valid:
135
+ handler._respond_json(400, {"error": f"unknown kind: {kind}"})
136
+ return None
137
+ return kind
138
+
139
+ def _run_conversation_query_impl(handler, kernel_call, log_label):
140
+ """Open cache.db, run ``kernel_call(conn)``, close — with the uniform
141
+ 500 envelopes the three conversation routes share (#151).
142
+
143
+ Collapses the triplicated open-cache → try/except/finally → 500
144
+ scaffold to one site. Returns ``(ok, body)``: ``ok=False`` means a 500
145
+ has ALREADY been sent and the caller must just ``return``; ``ok=True``
146
+ carries the kernel result (which may itself be ``None`` — the reader's
147
+ 404 sentinel — so the explicit flag, not ``body is None``, signals
148
+ failure). An ``open_cache_db`` failure is a ``cache unavailable:`` 500;
149
+ a kernel exception is logged as ``<log_label> failed: %r`` and returned
150
+ as a ``{type}: {msg}`` 500 — byte-identical to the inlined handlers.
151
+ """
152
+ try:
153
+ conn = sys.modules["_cctally_dashboard"].open_cache_db() # late-binding: patched at test_conversation_endpoints.py:674
154
+ except (sqlite3.DatabaseError, OSError) as exc:
155
+ handler._respond_json(500, {"error": f"cache unavailable: {exc}"})
156
+ return False, None
157
+ try:
158
+ body = kernel_call(conn)
159
+ except Exception as exc: # noqa: BLE001
160
+ handler.log_error("%s failed: %r", log_label, exc)
161
+ handler._respond_json(500, {"error": f"{type(exc).__name__}: {exc}"})
162
+ return False, None
163
+ finally:
164
+ conn.close()
165
+ return True, body
166
+
167
+ def _parse_conversation_filters_impl(handler, q):
168
+ """Parse the browse-list filter params (spec §2) from a ``parse_qs``
169
+ mapping. On any malformed value this sends a **400** and returns
170
+ ``None`` — the caller just ``return``s (the conversation routes all 400
171
+ on bad input). On success returns a dict of ``list_conversations``
172
+ kwargs: ``date_from``/``date_to`` (UTC-ISO bounds), ``projects``
173
+ (list[str] | None), ``cost_min``/``cost_max`` (float | None),
174
+ ``rebuild_min`` (int | None), ``models`` (list[str] | None — the #278
175
+ Theme C model-family axis). Empty/blank params drop to ``None``.
176
+
177
+ Numeric axes validate strictly (a non-numeric cost / non-integer
178
+ rebuild threshold is a hard 400). Date bounds route through the pure
179
+ ``_lib_dashboard_dates.parse_filter_date_range`` helper, which resolves
180
+ naive date-only bounds in ``display.tz`` and raises ``ValueError`` (→
181
+ 400) on a malformed date. Projects AND models accept BOTH repeated
182
+ ``?projects=a&projects=b`` and a single comma-joined ``?projects=a,b``.
183
+ """
184
+ def _float(name):
185
+ v = _qs_str(q, name, "")
186
+ if v is None or v == "":
187
+ return None
188
+ try:
189
+ return float(v)
190
+ except ValueError:
191
+ handler._respond_json(400, {"error": f"bad {name}: {v}"})
192
+ raise _BadConversationFilter
193
+
194
+ def _int(name):
195
+ v = _qs_str(q, name, "")
196
+ if v is None or v == "":
197
+ return None
198
+ try:
199
+ return int(v)
200
+ except ValueError:
201
+ handler._respond_json(400, {"error": f"bad {name}: {v}"})
202
+ raise _BadConversationFilter
203
+
204
+ try:
205
+ cost_min = _float("cost_min")
206
+ cost_max = _float("cost_max")
207
+ rebuild_min = _int("rebuild_min")
208
+ except _BadConversationFilter:
209
+ return None # 400 already sent
210
+
211
+ projects = [p for p in q.get("projects", []) if p] or None
212
+ # Single comma-joined value -> split (the client may send either form).
213
+ if projects and len(projects) == 1 and "," in projects[0]:
214
+ projects = [s for s in projects[0].split(",") if s] or None
215
+
216
+ # #278 Theme C: the model-family axis, mirroring projects. Accepts both
217
+ # repeated ?models=opus&models=sonnet and a single comma-joined
218
+ # ?models=opus,sonnet; blank/empty -> None. No numeric validation (enum-ish
219
+ # strings); an unknown/typo'd family is a PRESENT axis that resolves to
220
+ # zero ids in _model_clause -> zero results, never a silent unrestrict.
221
+ models = [m for m in q.get("models", []) if m] or None
222
+ if models and len(models) == 1 and "," in models[0]:
223
+ models = [s for s in models[0].split(",") if s] or None
224
+
225
+ date_from = _qs_str(q, "date_from", "") or None
226
+ date_to = _qs_str(q, "date_to", "") or None
227
+ if date_from or date_to:
228
+ from importlib import import_module
229
+ tz = sys.modules["cctally"]._resolve_display_tz_obj(
230
+ sys.modules["cctally"]._apply_display_tz_override(
231
+ sys.modules["cctally"].load_config(), type(handler).display_tz_pref_override
232
+ )
233
+ ).key
234
+ try:
235
+ df, dtt = import_module(
236
+ "_lib_dashboard_dates"
237
+ ).parse_filter_date_range(date_from, date_to, tz_name=tz)
238
+ except ValueError as exc:
239
+ handler._respond_json(400, {"error": str(exc)})
240
+ return None
241
+ else:
242
+ df = dtt = None
243
+
244
+ return {
245
+ "date_from": df,
246
+ "date_to": dtt,
247
+ "projects": projects,
248
+ "cost_min": cost_min,
249
+ "cost_max": cost_max,
250
+ "rebuild_min": rebuild_min,
251
+ "models": models,
252
+ }
253
+
254
+ def _handle_get_conversations_impl(handler) -> None:
255
+ """``GET /api/conversations`` — the browse rail (spec §3.1).
256
+
257
+ Gated first (loopback / Host allowlist). ``sort``/``limit``/``offset``
258
+ are read from the query string; the kernel clamps bounds. The browse
259
+ filters (date/project/cost/rebuild — spec §2) are parsed/validated here
260
+ (malformed → 400) and threaded into the kernel. Cache-open failures are
261
+ 500s, never 5xx-with-stacktrace.
262
+ """
263
+ if not handler._require_transcripts_allowed():
264
+ return
265
+ import urllib.parse as _u
266
+ q = _u.parse_qs(handler.path.partition("?")[2])
267
+ sort = _qs_str(q, "sort", "recent")
268
+ limit = _qs_int(q, "limit", 50)
269
+ offset = _qs_int(q, "offset", 0)
270
+ filters = handler._parse_conversation_filters(q)
271
+ if filters is None:
272
+ return # a 400 has already been sent
273
+ ok, body = handler._run_conversation_query(
274
+ lambda conn: handler._conversation_query().list_conversations(
275
+ conn, sort=sort, limit=limit, offset=offset, **filters),
276
+ "/api/conversations")
277
+ if not ok:
278
+ return
279
+ handler._respond_json(200, body)
280
+
281
+ def _handle_get_conversations_facets_impl(handler) -> None:
282
+ """``GET /api/conversations/facets`` — distinct project labels + their
283
+ conversation counts, for the browse filter's project multi-select (spec
284
+ §2). Behind the SAME loopback/Host privacy gate as the list route; a
285
+ cheap indexed GROUP BY over the rollup. The popover loads its options
286
+ once from here (deriving from a paginated page would be incomplete).
287
+ """
288
+ if not handler._require_transcripts_allowed():
289
+ return
290
+ ok, body = handler._run_conversation_query(
291
+ lambda conn: handler._conversation_query().list_conversation_facets(conn),
292
+ "/api/conversations/facets")
293
+ if not ok:
294
+ return
295
+ handler._respond_json(200, body)
296
+
297
+ def _handle_get_conversation_detail_impl(handler, path: str) -> None:
298
+ """``GET /api/conversation/<session-id>`` — the reader (spec §3.2).
299
+
300
+ The id is percent-decoded so clients that encode reserved chars
301
+ round-trip. Unknown id → 404. ``after``/``before``/``tail``/``limit``
302
+ page the items; ``after``/``before``/``tail`` are mutually exclusive
303
+ (>1 supplied → 400). ``tail=1`` opens at the bottom; ``before=<id>``
304
+ pages backward (#217 S2 / U4).
305
+ """
306
+ if not handler._require_transcripts_allowed():
307
+ return
308
+ import urllib.parse as _u
309
+ # ``path`` is already query-stripped by ``do_GET`` (``self.path.split("?")``),
310
+ # so the cursor params (?after=/?before=/?tail=/?limit=) live ONLY on the
311
+ # raw ``self.path``. Sibling handlers read ``self.path`` directly — the
312
+ # detail route must too, or every request re-serves the head and
313
+ # pagination is dead.
314
+ query_str = handler.path.partition("?")[2]
315
+ session_id = _u.unquote(path[len("/api/conversation/"):])
316
+ if not session_id:
317
+ handler.send_error(404, "conversation not found")
318
+ return
319
+ q = _u.parse_qs(query_str)
320
+ after = _qs_str(q, "after", None)
321
+ before = _qs_str(q, "before", None)
322
+ tail = _qs_str(q, "tail", None) in ("1", "true", "yes")
323
+ limit = _qs_int(q, "limit", 500)
324
+ # Mutual-exclusion 400 (#217 S2 / U4). The kernel ALSO raises ValueError
325
+ # on >1 cursor as its own invariant, but ``_run_conversation_query``
326
+ # collapses every kernel exception to a 500, so the 400 must be decided
327
+ # HERE, before the kernel call — this explicit pre-call check is the
328
+ # authoritative backstop for the handler path.
329
+ if sum(1 for x in (after is not None, before is not None, tail) if x) > 1:
330
+ handler.send_error(400, "after/before/tail are mutually exclusive")
331
+ return
332
+ ok, body = handler._run_conversation_query(
333
+ lambda conn: handler._conversation_query().get_conversation(
334
+ conn, session_id, after=after, before=before, tail=tail,
335
+ limit=limit),
336
+ "/api/conversation")
337
+ if not ok:
338
+ return
339
+ if body is None:
340
+ handler.send_error(404, "conversation not found")
341
+ return
342
+ handler._respond_json(200, body)
343
+
344
+ def _handle_get_conversation_events_impl(handler, path: str) -> None:
345
+ """``GET /api/conversation/<id>/events`` — per-conversation live-tail
346
+ SSE (spec §2). Fail-closed behind the same transcript privacy gate as
347
+ the other conversation routes. Watches only this session's file(s);
348
+ emits ``event: tail`` on growth, ``: keep-alive`` when idle. Passive
349
+ (no ingest, no emit) under ``--no-sync``."""
350
+ if not handler._require_transcripts_allowed():
351
+ return
352
+ import time as _time
353
+ import urllib.parse as _u
354
+ watch = sys.modules["cctally"]._load_sibling("_lib_conversation_watch")
355
+ cq = handler._conversation_query()
356
+ session_id = _u.unquote(path[len("/api/conversation/"):-len("/events")])
357
+ if not session_id:
358
+ handler.send_error(404, "conversation not found")
359
+ return
360
+
361
+ handler.send_response(200)
362
+ handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
363
+ handler.send_header("Cache-Control", "no-cache")
364
+ handler.send_header("Connection", "keep-alive")
365
+ handler.send_header("X-Accel-Buffering", "no")
366
+ handler.end_headers()
367
+
368
+ passive = bool(type(handler).no_sync)
369
+
370
+ try:
371
+ conn = sys.modules["_cctally_dashboard"].open_cache_db() # late-binding: patched at test_conversation_endpoints.py:674
372
+ except (sqlite3.DatabaseError, OSError):
373
+ # Cache unavailable — degrade to keep-alive only; client backstop
374
+ # tick still surfaces turns. (Headers already sent; can't 500.)
375
+ passive = True
376
+ conn = None
377
+
378
+ def _resolve():
379
+ return cq.session_source_paths(conn, session_id) if conn else []
380
+
381
+ def _ingest(changed):
382
+ return sync_cache(conn, only_paths=set(changed))
383
+
384
+ try:
385
+ if passive:
386
+ # Frozen-data contract: no ingest, no emit. Keep-alive only.
387
+ while True:
388
+ _time.sleep(_LIVE_TAIL_KEEPALIVE)
389
+ handler.wfile.write(b": keep-alive\n\n")
390
+ handler.wfile.flush()
391
+
392
+ # #278 Theme B: signal that this connection is ACTIVELY live-tailing
393
+ # (not degraded to keep-alive). The client sets `live` only on this, so
394
+ # passive/cache-open-failed streams fall back to the memo-backed global
395
+ # tick instead of stranding updates (Codex F4). Passive branch above
396
+ # emits only ': keep-alive', so 'ready' is unambiguous.
397
+ handler.wfile.write(b"event: ready\ndata: {}\n\n")
398
+ handler.wfile.flush()
399
+
400
+ files = _resolve()
401
+ # Best-effort connect ingest for immediacy, then baseline `seen`
402
+ # from the cache's own offsets (session_files) so any pre-connect
403
+ # growth the connect-ingest declined is still caught on cycle 1.
404
+ try:
405
+ if files:
406
+ sync_cache(conn, only_paths=set(files))
407
+ except sqlite3.DatabaseError:
408
+ pass
409
+ seen = _cached_file_sigs(conn, files)
410
+
411
+ idle = 0.0
412
+ cycles = 0
413
+ while True:
414
+ _time.sleep(_LIVE_TAIL_POLL_INTERVAL)
415
+ cycles += 1
416
+ changed = watch.changed_paths(files, seen)
417
+ if changed:
418
+ _time.sleep(_LIVE_TAIL_DEBOUNCE)
419
+ new_seen, emitted = watch.watch_step(
420
+ files, seen, ingest_fn=_ingest,
421
+ committed_sig_fn=lambda p: _cached_file_sigs(conn, [p]).get(p))
422
+ seen = new_seen
423
+ if emitted:
424
+ handler.wfile.write(
425
+ ("event: tail\ndata: "
426
+ + json.dumps({"sessionId": session_id})
427
+ + "\n\n").encode("utf-8"))
428
+ handler.wfile.flush()
429
+ idle = 0.0
430
+ # §6 P2-H — a brand-new subagent file's FIRST content was
431
+ # just ingested by this emitting cycle, so the session's
432
+ # source-path set may have grown. Re-resolve it now (vs
433
+ # waiting up to _LIVE_TAIL_FILE_RESET_EVERY cycles) so the
434
+ # new thread (incl. a skill invoked inside it) live-tails
435
+ # promptly. A new path seeds seen=None (cur lacks a row),
436
+ # so changed_paths flags it next cycle → it ingests + emits.
437
+ # setdefault never disturbs an existing cursor.
438
+ new_files = _resolve()
439
+ if set(new_files) != set(files):
440
+ files = new_files
441
+ cur = _cached_file_sigs(conn, files)
442
+ for p in files:
443
+ seen.setdefault(p, cur.get(p))
444
+ continue
445
+ idle += _LIVE_TAIL_POLL_INTERVAL
446
+ if idle >= _LIVE_TAIL_KEEPALIVE:
447
+ handler.wfile.write(b": keep-alive\n\n")
448
+ handler.wfile.flush()
449
+ idle = 0.0
450
+ if cycles % _LIVE_TAIL_FILE_RESET_EVERY == 0:
451
+ files = _resolve()
452
+ seen = {p: s for p, s in seen.items() if p in set(files)}
453
+ except (BrokenPipeError, ConnectionResetError,
454
+ ConnectionAbortedError, socket.timeout):
455
+ # #279 S1 F3: a stalled send past the handler timeout raises
456
+ # socket.timeout inside the SSE loop — treat it as a client
457
+ # disconnect (same as the other peer-gone classes), not an error.
458
+ pass # client disconnect is normal
459
+ except Exception as exc: # noqa: BLE001
460
+ # #279 S5 F6.2 (spec §8): a genuine bug mid-stream used to kill the
461
+ # live-tail SSE silently via handle_error. Headers are already
462
+ # committed — route the operator signal through the _lib_log chokepoint
463
+ # (handler.log_error) + a deliberate clean close via the finally below.
464
+ handler.log_error("api/conversation/events stream failed: %r", exc)
465
+ finally:
466
+ if conn is not None:
467
+ conn.close()
468
+
469
+ def _handle_get_conversation_search_impl(handler) -> None:
470
+ """``GET /api/conversation/search?q=...&kind=...`` — cross-session
471
+ FTS/LIKE search (spec §3.3). Matched BEFORE the ``<id>`` reader in
472
+ ``do_GET``. ``kind`` (#177 S6) is validated to ``_CONV_SEARCH_KINDS``
473
+ (else 400) before the kernel call.
474
+
475
+ #217 S2 / Filtered-search: the browse filters (date/project/cost/rebuild)
476
+ are parsed by the SAME ``_parse_conversation_filters`` the browse rail uses
477
+ (malformed → 400 already sent) and threaded into the kernel, applied as a
478
+ session-scope restriction across every kind. The 400s (bad kind, bad
479
+ filter) are decided HERE, before the kernel call — ``_run_conversation_query``
480
+ collapses kernel exceptions to a 500.
481
+ """
482
+ if not handler._require_transcripts_allowed():
483
+ return
484
+ import urllib.parse as _u
485
+ q = _u.parse_qs(handler.path.partition("?")[2])
486
+ query = _qs_str(q, "q", "")
487
+ limit = _qs_int(q, "limit", 50)
488
+ offset = _qs_int(q, "offset", 0)
489
+ kind = handler._parse_search_kind(q)
490
+ if kind is None:
491
+ return
492
+ filters = handler._parse_conversation_filters(q)
493
+ if filters is None:
494
+ return # a 400 has already been sent
495
+ ok, body = handler._run_conversation_query(
496
+ lambda conn: handler._conversation_query().search_conversations(
497
+ conn, query, limit=limit, offset=offset, kind=kind, **filters),
498
+ "/api/conversation/search")
499
+ if not ok:
500
+ return
501
+ handler._respond_json(200, body)
502
+
503
+ def _handle_get_conversation_payload_impl(handler, path: str) -> None:
504
+ """``GET /api/conversation/<sid>/payload?tool_use_id=<id>&which=<result|input>``
505
+ — the #178 on-demand load-full route. Re-reads the source JSONL line so
506
+ a clipped result/input can be expanded without enlarging the cache.
507
+
508
+ Gated FIRST by the same loopback/Host transcript privacy predicate the
509
+ three other conversation routes use (fail-closed 403). ``locate_tool_payload``
510
+ runs against cache.db (via the shared 500-envelope scaffold); the actual
511
+ full body is re-read from disk by ``read_full_payload`` (no cache conn).
512
+ ``which`` is validated to ``result``/``input`` (else 400); a missing
513
+ tool_use_id is 400; an unknown id is 404; a gone/unparseable source line
514
+ is 410 (the documented consequence of storing only capped text).
515
+ """
516
+ if not handler._require_transcripts_allowed():
517
+ return
518
+ import urllib.parse as _u
519
+ session_id = _u.unquote(
520
+ path[len("/api/conversation/"):-len("/payload")])
521
+ q = _u.parse_qs(handler.path.partition("?")[2])
522
+ tool_use_id = _qs_str(q, "tool_use_id", "")
523
+ which = _qs_str(q, "which", "result")
524
+ if not session_id or which not in ("result", "input") or not tool_use_id:
525
+ handler._respond_json(400, {"error": "bad request"})
526
+ return
527
+ cq = handler._conversation_query()
528
+ ok, loc = handler._run_conversation_query(
529
+ lambda conn: cq.locate_tool_payload(
530
+ conn, session_id, tool_use_id, which),
531
+ "/api/conversation/payload")
532
+ if not ok:
533
+ return
534
+ if loc is None:
535
+ handler._respond_json(404, {"error": "not found"})
536
+ return
537
+ payload = cq.read_full_payload(loc[0], loc[1], tool_use_id, which)
538
+ if payload is None:
539
+ handler._respond_json(410, {"error": "source no longer available"})
540
+ return
541
+ handler._respond_json(200, payload)
542
+
543
+ def _handle_get_conversation_outline_impl(handler, path: str) -> None:
544
+ """``GET /api/conversation/<sid>/outline`` — full-session skeleton +
545
+ session stats (#177 S5). Same fail-closed privacy gate; unknown id → 404.
546
+ """
547
+ if not handler._require_transcripts_allowed():
548
+ return
549
+ import urllib.parse as _u
550
+ session_id = _u.unquote(path[len("/api/conversation/"):-len("/outline")])
551
+ if not session_id:
552
+ handler.send_error(404, "conversation not found")
553
+ return
554
+ ok, body = handler._run_conversation_query(
555
+ lambda conn: handler._conversation_query().get_conversation_outline(conn, session_id),
556
+ "/api/conversation/outline")
557
+ if not ok:
558
+ return
559
+ if body is None:
560
+ handler.send_error(404, "conversation not found")
561
+ return
562
+ handler._respond_json(200, body)
563
+
564
+ def _handle_get_conversation_prompts_impl(handler, path: str) -> None:
565
+ """``GET /api/conversation/<sid>/prompts`` — ordered main-thread human
566
+ prompts + full text (#217 S7 F10, the session-comparison spine). Same
567
+ fail-closed transcript privacy gate as ``/outline`` —
568
+ ``_require_transcripts_allowed()`` ONLY (no ``_check_origin_csrf``: the
569
+ sibling transcript GETs gate on this predicate alone). Unknown id → 404.
570
+ """
571
+ if not handler._require_transcripts_allowed():
572
+ return
573
+ import urllib.parse as _u
574
+ session_id = _u.unquote(path[len("/api/conversation/"):-len("/prompts")])
575
+ if not session_id:
576
+ handler.send_error(404, "conversation not found")
577
+ return
578
+ ok, body = handler._run_conversation_query(
579
+ lambda conn: handler._conversation_query().get_conversation_prompts(conn, session_id),
580
+ "/api/conversation/prompts")
581
+ if not ok:
582
+ return
583
+ if body is None:
584
+ handler.send_error(404, "conversation not found")
585
+ return
586
+ handler._respond_json(200, body)
587
+
588
+ _CONV_EXPORT_SCOPES = ("all", "prompts", "chat", "recipe")
589
+
590
+ def _handle_get_conversation_export_impl(handler, path: str) -> None:
591
+ """``GET /api/conversation/<sid>/export?scope=<all|prompts|chat|recipe>``
592
+ — whole-session Markdown (issue #217 S5 F1/F5).
593
+
594
+ Same fail-closed transcript privacy gate as ``/outline`` / ``/payload``
595
+ / ``/find`` — ``_require_transcripts_allowed()`` ONLY. **No
596
+ ``_check_origin_csrf``** (Codex P0-1): the sibling transcript GETs gate
597
+ on this predicate alone; ``_check_origin_csrf`` rejects a missing
598
+ ``Origin`` and would make export STRICTER than its sibling reader routes.
599
+
600
+ ``scope`` is validated HERE, BEFORE the kernel (the
601
+ ``_run_conversation_query``-collapses-kernel-exceptions-to-500 gotcha —
602
+ an invalid scope is a clean 400, never a 500). Unknown session → 404.
603
+ Emits ``text/markdown; charset=utf-8`` (the client builds the download
604
+ Blob/filename, so no ``Content-Disposition`` is needed)."""
605
+ if not handler._require_transcripts_allowed():
606
+ return
607
+ import urllib.parse as _u
608
+ session_id = _u.unquote(path[len("/api/conversation/"):-len("/export")])
609
+ q = _u.parse_qs(handler.path.partition("?")[2])
610
+ scope = _qs_str(q, "scope", "all")
611
+ if scope not in _CONV_EXPORT_SCOPES:
612
+ handler._respond_json(400, {"error": f"unknown scope: {scope}"})
613
+ return
614
+ if not session_id:
615
+ handler.send_error(404, "conversation not found")
616
+ return
617
+ ok, body = handler._run_conversation_query(
618
+ lambda conn: handler._conversation_query().get_conversation_export(
619
+ conn, session_id, scope),
620
+ "/api/conversation/export")
621
+ if not ok:
622
+ return
623
+ if body is None:
624
+ handler.send_error(404, "conversation not found")
625
+ return
626
+ data = body.encode("utf-8")
627
+ handler.send_response(200)
628
+ handler.send_header("Content-Type", "text/markdown; charset=utf-8")
629
+ handler.send_header("Content-Length", str(len(data)))
630
+ handler.end_headers()
631
+ handler.wfile.write(data)
632
+
633
+ def _handle_get_conversation_find_impl(handler, path: str) -> None:
634
+ """``GET /api/conversation/<sid>/find?q=...&kind=...`` — in-conversation
635
+ find → document-ordered rendered-turn anchors (#177 S6). Same fail-closed
636
+ privacy gate as the sibling routes; unknown id → 404; an invalid ``kind``
637
+ → 400. Matched BEFORE the ``<id>`` reader catch-all in ``do_GET``.
638
+
639
+ P1-1: validates against ``_CONV_FIND_KINDS`` (NOT the search set), so the
640
+ cross-session-only ``kind=title``/``files`` return 400 here, never a 500.
641
+
642
+ #217 S4 / I-1.2: ``regex``/``case`` are truthy params. An invalid regex
643
+ is PRE-COMPILED here, BEFORE dispatching to the kernel — exactly as the
644
+ detail route pre-validates ``after/before/tail`` — because
645
+ ``_run_conversation_query`` collapses every kernel exception to a 500, so
646
+ a ``re.error`` from the kernel's ``re.compile`` would otherwise leak as a
647
+ 500 instead of the actionable 400 the client maps to "invalid regex".
648
+ """
649
+ if not handler._require_transcripts_allowed():
650
+ return
651
+ import re as _re
652
+ import urllib.parse as _u
653
+ session_id = _u.unquote(path[len("/api/conversation/"):-len("/find")])
654
+ if not session_id:
655
+ handler.send_error(404, "conversation not found")
656
+ return
657
+ q = _u.parse_qs(handler.path.partition("?")[2])
658
+ query = _qs_str(q, "q", "")
659
+ kind = handler._parse_search_kind(q, valid=_CONV_FIND_KINDS)
660
+ if kind is None:
661
+ return
662
+ regex = _qs_str(q, "regex", None) in ("1", "true", "yes")
663
+ case = _qs_str(q, "case", None) in ("1", "true", "yes")
664
+ # Pre-validate the regex HERE (Codex P1): the kernel compiles the same
665
+ # pattern, but its ``re.error`` would be swallowed into the generic 500
666
+ # envelope below. Compiling first turns a bad pattern into a clean 400.
667
+ if regex:
668
+ try:
669
+ _re.compile(query, 0 if case else _re.IGNORECASE)
670
+ except _re.error as e:
671
+ handler._respond_json(400, {"error": f"invalid regex: {e}"})
672
+ return
673
+ ok, body = handler._run_conversation_query(
674
+ lambda conn: handler._conversation_query().find_in_conversation(
675
+ conn, session_id, query, kind=kind, regex=regex, case=case),
676
+ "/api/conversation/find")
677
+ if not ok:
678
+ return
679
+ if body is None:
680
+ handler.send_error(404, "conversation not found")
681
+ return
682
+ handler._respond_json(200, body)
683
+
684
+ _MEDIA_FETCH_SITE_ALLOWED = ("same-origin", "same-site", "none")
685
+
686
+ def _handle_get_conversation_media_impl(handler, path: str) -> None:
687
+ """``GET /api/conversation/<sid>/media?tool_use_id=<id>&index=N`` or
688
+ ``?uuid=<uuid>&index=N`` (#177 S4) — serves decoded image/PDF bytes by
689
+ re-reading the source JSONL line (the #178 mechanism). Nothing is ever
690
+ written to cache.db or disk; no outbound requests.
691
+
692
+ Gated FIRST by the transcript privacy predicate (fail-closed 403),
693
+ then by Fetch-Metadata: unlike the JSON routes, images embed
694
+ cross-origin (an <img src> on any website the user visits passes the
695
+ Host/loopback gate and leaks existence + dimensions via
696
+ onload/naturalWidth), so a PRESENT Sec-Fetch-Site header must be
697
+ same-origin/same-site/none; an absent header (curl, older browsers)
698
+ is allowed — defense-in-depth, not the primary gate (Codex F1).
699
+ Exactly one addressing key (tool_use_id XOR uuid) + a non-negative
700
+ integer index, else 400. Content-Type is the kernel's allowlist
701
+ constant; images get CSP default-src 'none'; PDFs get inline
702
+ Content-Disposition instead (a CSP sandbox would break native PDF
703
+ viewers)."""
704
+ if not handler._require_transcripts_allowed():
705
+ return
706
+ sfs = (handler.headers.get("Sec-Fetch-Site") or "").strip().lower()
707
+ if sfs and sfs not in _MEDIA_FETCH_SITE_ALLOWED:
708
+ handler._respond_json(403, {"error": "cross-site media fetch not allowed"})
709
+ return
710
+ import urllib.parse as _u
711
+ session_id = _u.unquote(path[len("/api/conversation/"):-len("/media")])
712
+ q = _u.parse_qs(handler.path.partition("?")[2])
713
+ tool_use_id = _qs_str(q, "tool_use_id", "")
714
+ uuid = _qs_str(q, "uuid", "")
715
+ index_raw = _qs_str(q, "index", "")
716
+ if (not session_id or bool(tool_use_id) == bool(uuid)
717
+ or not index_raw.isdigit()):
718
+ handler._respond_json(400, {"error": "bad request"})
719
+ return
720
+ index = int(index_raw)
721
+ key = ({"tool_use_id": tool_use_id} if tool_use_id else {"uuid": uuid})
722
+ cq = handler._conversation_query()
723
+ ok, loc = handler._run_conversation_query(
724
+ lambda conn: cq.locate_media(conn, session_id, index=index, **key),
725
+ "/api/conversation/media")
726
+ if not ok:
727
+ return
728
+ if loc is None:
729
+ handler._respond_json(404, {"error": "not found"})
730
+ return
731
+ # Defensive envelope parity with the sibling byte-serving handlers
732
+ # (`_handle_get_doctor` / `_serve_static_file`): `locate_media` already
733
+ # runs inside the `_run_conversation_query` 500-envelope, but the
734
+ # `read_media_bytes` read + the byte emission did not. `read_media_bytes`
735
+ # is internally defensive (OSError/ValueError → `gone`), so this guards
736
+ # only an UNEXPECTED escape — but an unguarded one would kill the handler
737
+ # thread with no logged 500. `response_started` tracks the commit point:
738
+ # an exception BEFORE `send_response(200)` sends a clean logged 500; one
739
+ # AFTER (mid-`wfile.write`, headers already out) can't re-send a status,
740
+ # so it's logged only — never a silent thread death.
741
+ response_started = False
742
+ try:
743
+ status, media_type, raw = cq.read_media_bytes(
744
+ loc[0], loc[1], index=index, **key)
745
+ if status == "unsupported":
746
+ handler._respond_json(404, {"error": "not found"})
747
+ return
748
+ if status == "too_large":
749
+ handler._respond_json(413, {"error": "media too large"})
750
+ return
751
+ if status != "ok":
752
+ handler._respond_json(410, {"error": "source no longer available"})
753
+ return
754
+ handler.send_response(200)
755
+ response_started = True
756
+ handler.send_header("Content-Type", media_type)
757
+ handler.send_header("Content-Length", str(len(raw)))
758
+ handler.send_header("X-Content-Type-Options", "nosniff")
759
+ handler.send_header("Cache-Control", "private, max-age=86400")
760
+ if media_type == "application/pdf":
761
+ handler.send_header("Content-Disposition",
762
+ f'inline; filename="attachment-{index}.pdf"')
763
+ else:
764
+ handler.send_header("Content-Security-Policy", "default-src 'none'")
765
+ handler.end_headers()
766
+ handler.wfile.write(raw)
767
+ except Exception as exc: # noqa: BLE001
768
+ handler.log_error("/api/conversation/media failed: %r", exc)
769
+ if not response_started:
770
+ handler._respond_json(
771
+ 500, {"error": f"{type(exc).__name__}: {exc}"})