cctally 1.52.1 → 1.54.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.
@@ -147,7 +147,7 @@ What stays in bin/cctally:
147
147
  c.ORIGINAL_SYS_ARGV = list(sys.argv)``.
148
148
  - ``eprint``, ``now_utc_iso``, ``parse_iso_datetime``, ``open_db``,
149
149
  ``load_config``, ``save_config``, ``_now_utc``, ``_command_as_of``,
150
- ``format_display_dt``, ``resolve_display_tz``,
150
+ ``format_display_dt``,
151
151
  ``normalize_display_tz_value``, ``_render_migration_error_banner``,
152
152
  ``_aggregate_daily``, ``_aggregate_monthly``, ``_aggregate_weekly``,
153
153
  ``_calculate_entry_cost``, ``_canonical_5h_window_key``,
@@ -305,7 +305,6 @@ from _cctally_core import (
305
305
  )
306
306
  from _lib_display_tz import (
307
307
  format_display_dt,
308
- resolve_display_tz,
309
308
  normalize_display_tz_value,
310
309
  _compute_display_block,
311
310
  )
@@ -5284,10 +5283,22 @@ def _qs_str(q: dict, key: str, default: str | None) -> str | None:
5284
5283
  return vals[0] if vals else default
5285
5284
 
5286
5285
 
5287
- # #177 S6: valid kind facets for the conversation search / find routes. Kept in
5288
- # lockstep with ``_lib_conversation_query._SEARCH_KINDS`` (the kernel re-raises
5289
- # ValueError on an unknown kind; the handlers reject with a 400 before the call).
5290
- _CONV_SEARCH_KINDS = ("all", "prompts", "assistant", "tools", "thinking")
5286
+ # #177 S6 / #217 S2: valid kind facets for the conversation routes. Kept in
5287
+ # lockstep with the kernel (``_lib_conversation_query._SEARCH_KINDS`` /
5288
+ # ``_FIND_KINDS``; the kernel re-raises ValueError on an unknown kind, and the
5289
+ # handlers reject with a 400 BEFORE the call — ``_run_conversation_query``
5290
+ # collapses every kernel exception to a 500, so a per-route 4xx must be decided
5291
+ # in the handler, not via try/except around the kernel).
5292
+ #
5293
+ # P1-1 (load-bearing kind-validation SPLIT): the cross-session search route
5294
+ # accepts ``title`` and ``files``; the in-conversation ``/find`` route does NOT —
5295
+ # its kernel (``find_in_conversation``) indexes ``_FIND_KIND_COLUMNS[kind]``,
5296
+ # which has no ``title``/``files`` entry, so accepting them there would be a 500
5297
+ # KeyError. Two distinct tuples keep ``/find?kind=title`` and ``/find?kind=files``
5298
+ # a clean 400.
5299
+ _CONV_SEARCH_KINDS = (
5300
+ "all", "prompts", "assistant", "tools", "thinking", "title", "files")
5301
+ _CONV_FIND_KINDS = ("all", "prompts", "assistant", "tools", "thinking")
5291
5302
 
5292
5303
 
5293
5304
  class _BadConversationFilter(Exception):
@@ -5472,6 +5483,15 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5472
5483
  # Live-tail SSE for the open reader (spec §2). Matched BEFORE the
5473
5484
  # <id> reader catch-all.
5474
5485
  self._handle_get_conversation_events(path)
5486
+ elif path.startswith("/api/conversation/") and path.endswith("/export"):
5487
+ # #217 S5: whole-session Markdown export (F1/F5). Matched BEFORE the
5488
+ # <id> reader catch-all (same precedence as /outline).
5489
+ self._handle_get_conversation_export(path)
5490
+ elif path.startswith("/api/conversation/") and path.endswith("/prompts"):
5491
+ # #217 S7: ordered main-thread prompt spine for session comparison
5492
+ # (F10). Matched BEFORE the <id> reader catch-all (same precedence
5493
+ # as /outline).
5494
+ self._handle_get_conversation_prompts(path)
5475
5495
  elif path.startswith("/api/conversation/"):
5476
5496
  self._handle_get_conversation_detail(path)
5477
5497
  else:
@@ -7451,15 +7471,21 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7451
7471
  """Lazy-load the pure conversation query kernel (Plan 2, §3)."""
7452
7472
  return sys.modules["cctally"]._load_sibling("_lib_conversation_query")
7453
7473
 
7454
- def _parse_search_kind(self, q):
7455
- """Read + validate the ``kind`` facet shared by the conversation search
7456
- and find routes (#177 S6). Returns the kind on success, or ``None`` after
7457
- having ALREADY sent a 400 — callers just ``return`` on ``None``. Kept in
7458
- lockstep with the kernel's ``_SEARCH_KINDS`` via ``_CONV_SEARCH_KINDS``
7459
- (the kernel module is resolved lazily per-request, so the handler keeps a
7460
- literal tuple rather than reaching across that import edge for a nit)."""
7474
+ def _parse_search_kind(self, q, valid=_CONV_SEARCH_KINDS):
7475
+ """Read + validate the ``kind`` facet for a conversation route (#177 S6 /
7476
+ #217 S2). Returns the kind on success, or ``None`` after having ALREADY
7477
+ sent a 400 — callers just ``return`` on ``None``.
7478
+
7479
+ ``valid`` is the per-route kind set (P1-1 split): the cross-session search
7480
+ route passes ``_CONV_SEARCH_KINDS`` (includes ``title``), the
7481
+ in-conversation ``/find`` route passes ``_CONV_FIND_KINDS`` (excludes
7482
+ ``title``/``files``), so ``/find?kind=title`` is a 400 here — never a 500
7483
+ KeyError downstream in ``find_in_conversation``. Kept in lockstep with the
7484
+ kernel's ``_SEARCH_KINDS`` / ``_FIND_KINDS`` (the kernel module is
7485
+ resolved lazily per-request, so the handler keeps literal tuples rather
7486
+ than reaching across that import edge for a nit)."""
7461
7487
  kind = _qs_str(q, "kind", "all")
7462
- if kind not in _CONV_SEARCH_KINDS:
7488
+ if kind not in valid:
7463
7489
  self._respond_json(400, {"error": f"unknown kind: {kind}"})
7464
7490
  return None
7465
7491
  return kind
@@ -7615,15 +7641,19 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7615
7641
  """``GET /api/conversation/<session-id>`` — the reader (spec §3.2).
7616
7642
 
7617
7643
  The id is percent-decoded so clients that encode reserved chars
7618
- round-trip. Unknown id → 404. ``after``/``limit`` page the items.
7644
+ round-trip. Unknown id → 404. ``after``/``before``/``tail``/``limit``
7645
+ page the items; ``after``/``before``/``tail`` are mutually exclusive
7646
+ (>1 supplied → 400). ``tail=1`` opens at the bottom; ``before=<id>``
7647
+ pages backward (#217 S2 / U4).
7619
7648
  """
7620
7649
  if not self._require_transcripts_allowed():
7621
7650
  return
7622
7651
  import urllib.parse as _u
7623
7652
  # ``path`` is already query-stripped by ``do_GET`` (``self.path.split("?")``),
7624
- # so the cursor params (?after=/?limit=) live ONLY on the raw ``self.path``.
7625
- # Sibling handlers read ``self.path`` directly — the detail route must too,
7626
- # or every request re-serves the head and pagination is dead.
7653
+ # so the cursor params (?after=/?before=/?tail=/?limit=) live ONLY on the
7654
+ # raw ``self.path``. Sibling handlers read ``self.path`` directly — the
7655
+ # detail route must too, or every request re-serves the head and
7656
+ # pagination is dead.
7627
7657
  query_str = self.path.partition("?")[2]
7628
7658
  session_id = _u.unquote(path[len("/api/conversation/"):])
7629
7659
  if not session_id:
@@ -7631,10 +7661,21 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7631
7661
  return
7632
7662
  q = _u.parse_qs(query_str)
7633
7663
  after = _qs_str(q, "after", None)
7664
+ before = _qs_str(q, "before", None)
7665
+ tail = _qs_str(q, "tail", None) in ("1", "true", "yes")
7634
7666
  limit = _qs_int(q, "limit", 500)
7667
+ # Mutual-exclusion 400 (#217 S2 / U4). The kernel ALSO raises ValueError
7668
+ # on >1 cursor as its own invariant, but ``_run_conversation_query``
7669
+ # collapses every kernel exception to a 500, so the 400 must be decided
7670
+ # HERE, before the kernel call — this explicit pre-call check is the
7671
+ # authoritative backstop for the handler path.
7672
+ if sum(1 for x in (after is not None, before is not None, tail) if x) > 1:
7673
+ self.send_error(400, "after/before/tail are mutually exclusive")
7674
+ return
7635
7675
  ok, body = self._run_conversation_query(
7636
7676
  lambda conn: self._conversation_query().get_conversation(
7637
- conn, session_id, after=after, limit=limit),
7677
+ conn, session_id, after=after, before=before, tail=tail,
7678
+ limit=limit),
7638
7679
  "/api/conversation")
7639
7680
  if not ok:
7640
7681
  return
@@ -7755,6 +7796,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7755
7796
  FTS/LIKE search (spec §3.3). Matched BEFORE the ``<id>`` reader in
7756
7797
  ``do_GET``. ``kind`` (#177 S6) is validated to ``_CONV_SEARCH_KINDS``
7757
7798
  (else 400) before the kernel call.
7799
+
7800
+ #217 S2 / Filtered-search: the browse filters (date/project/cost/rebuild)
7801
+ are parsed by the SAME ``_parse_conversation_filters`` the browse rail uses
7802
+ (malformed → 400 already sent) and threaded into the kernel, applied as a
7803
+ session-scope restriction across every kind. The 400s (bad kind, bad
7804
+ filter) are decided HERE, before the kernel call — ``_run_conversation_query``
7805
+ collapses kernel exceptions to a 500.
7758
7806
  """
7759
7807
  if not self._require_transcripts_allowed():
7760
7808
  return
@@ -7766,9 +7814,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7766
7814
  kind = self._parse_search_kind(q)
7767
7815
  if kind is None:
7768
7816
  return
7817
+ filters = self._parse_conversation_filters(q)
7818
+ if filters is None:
7819
+ return # a 400 has already been sent
7769
7820
  ok, body = self._run_conversation_query(
7770
7821
  lambda conn: self._conversation_query().search_conversations(
7771
- conn, query, limit=limit, offset=offset, kind=kind),
7822
+ conn, query, limit=limit, offset=offset, kind=kind, **filters),
7772
7823
  "/api/conversation/search")
7773
7824
  if not ok:
7774
7825
  return
@@ -7835,14 +7886,94 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7835
7886
  return
7836
7887
  self._respond_json(200, body)
7837
7888
 
7889
+ def _handle_get_conversation_prompts(self, path: str) -> None:
7890
+ """``GET /api/conversation/<sid>/prompts`` — ordered main-thread human
7891
+ prompts + full text (#217 S7 F10, the session-comparison spine). Same
7892
+ fail-closed transcript privacy gate as ``/outline`` —
7893
+ ``_require_transcripts_allowed()`` ONLY (no ``_check_origin_csrf``: the
7894
+ sibling transcript GETs gate on this predicate alone). Unknown id → 404.
7895
+ """
7896
+ if not self._require_transcripts_allowed():
7897
+ return
7898
+ import urllib.parse as _u
7899
+ session_id = _u.unquote(path[len("/api/conversation/"):-len("/prompts")])
7900
+ if not session_id:
7901
+ self.send_error(404, "conversation not found")
7902
+ return
7903
+ ok, body = self._run_conversation_query(
7904
+ lambda conn: self._conversation_query().get_conversation_prompts(conn, session_id),
7905
+ "/api/conversation/prompts")
7906
+ if not ok:
7907
+ return
7908
+ if body is None:
7909
+ self.send_error(404, "conversation not found")
7910
+ return
7911
+ self._respond_json(200, body)
7912
+
7913
+ _CONV_EXPORT_SCOPES = ("all", "prompts", "chat", "recipe")
7914
+
7915
+ def _handle_get_conversation_export(self, path: str) -> None:
7916
+ """``GET /api/conversation/<sid>/export?scope=<all|prompts|chat|recipe>``
7917
+ — whole-session Markdown (issue #217 S5 F1/F5).
7918
+
7919
+ Same fail-closed transcript privacy gate as ``/outline`` / ``/payload``
7920
+ / ``/find`` — ``_require_transcripts_allowed()`` ONLY. **No
7921
+ ``_check_origin_csrf``** (Codex P0-1): the sibling transcript GETs gate
7922
+ on this predicate alone; ``_check_origin_csrf`` rejects a missing
7923
+ ``Origin`` and would make export STRICTER than its sibling reader routes.
7924
+
7925
+ ``scope`` is validated HERE, BEFORE the kernel (the
7926
+ ``_run_conversation_query``-collapses-kernel-exceptions-to-500 gotcha —
7927
+ an invalid scope is a clean 400, never a 500). Unknown session → 404.
7928
+ Emits ``text/markdown; charset=utf-8`` (the client builds the download
7929
+ Blob/filename, so no ``Content-Disposition`` is needed)."""
7930
+ if not self._require_transcripts_allowed():
7931
+ return
7932
+ import urllib.parse as _u
7933
+ session_id = _u.unquote(path[len("/api/conversation/"):-len("/export")])
7934
+ q = _u.parse_qs(self.path.partition("?")[2])
7935
+ scope = _qs_str(q, "scope", "all")
7936
+ if scope not in self._CONV_EXPORT_SCOPES:
7937
+ self._respond_json(400, {"error": f"unknown scope: {scope}"})
7938
+ return
7939
+ if not session_id:
7940
+ self.send_error(404, "conversation not found")
7941
+ return
7942
+ ok, body = self._run_conversation_query(
7943
+ lambda conn: self._conversation_query().get_conversation_export(
7944
+ conn, session_id, scope),
7945
+ "/api/conversation/export")
7946
+ if not ok:
7947
+ return
7948
+ if body is None:
7949
+ self.send_error(404, "conversation not found")
7950
+ return
7951
+ data = body.encode("utf-8")
7952
+ self.send_response(200)
7953
+ self.send_header("Content-Type", "text/markdown; charset=utf-8")
7954
+ self.send_header("Content-Length", str(len(data)))
7955
+ self.end_headers()
7956
+ self.wfile.write(data)
7957
+
7838
7958
  def _handle_get_conversation_find(self, path: str) -> None:
7839
7959
  """``GET /api/conversation/<sid>/find?q=...&kind=...`` — in-conversation
7840
7960
  find → document-ordered rendered-turn anchors (#177 S6). Same fail-closed
7841
7961
  privacy gate as the sibling routes; unknown id → 404; an invalid ``kind``
7842
7962
  → 400. Matched BEFORE the ``<id>`` reader catch-all in ``do_GET``.
7963
+
7964
+ P1-1: validates against ``_CONV_FIND_KINDS`` (NOT the search set), so the
7965
+ cross-session-only ``kind=title``/``files`` return 400 here, never a 500.
7966
+
7967
+ #217 S4 / I-1.2: ``regex``/``case`` are truthy params. An invalid regex
7968
+ is PRE-COMPILED here, BEFORE dispatching to the kernel — exactly as the
7969
+ detail route pre-validates ``after/before/tail`` — because
7970
+ ``_run_conversation_query`` collapses every kernel exception to a 500, so
7971
+ a ``re.error`` from the kernel's ``re.compile`` would otherwise leak as a
7972
+ 500 instead of the actionable 400 the client maps to "invalid regex".
7843
7973
  """
7844
7974
  if not self._require_transcripts_allowed():
7845
7975
  return
7976
+ import re as _re
7846
7977
  import urllib.parse as _u
7847
7978
  session_id = _u.unquote(path[len("/api/conversation/"):-len("/find")])
7848
7979
  if not session_id:
@@ -7850,12 +7981,23 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7850
7981
  return
7851
7982
  q = _u.parse_qs(self.path.partition("?")[2])
7852
7983
  query = _qs_str(q, "q", "")
7853
- kind = self._parse_search_kind(q)
7984
+ kind = self._parse_search_kind(q, valid=_CONV_FIND_KINDS)
7854
7985
  if kind is None:
7855
7986
  return
7987
+ regex = _qs_str(q, "regex", None) in ("1", "true", "yes")
7988
+ case = _qs_str(q, "case", None) in ("1", "true", "yes")
7989
+ # Pre-validate the regex HERE (Codex P1): the kernel compiles the same
7990
+ # pattern, but its ``re.error`` would be swallowed into the generic 500
7991
+ # envelope below. Compiling first turns a bad pattern into a clean 400.
7992
+ if regex:
7993
+ try:
7994
+ _re.compile(query, 0 if case else _re.IGNORECASE)
7995
+ except _re.error as e:
7996
+ self._respond_json(400, {"error": f"invalid regex: {e}"})
7997
+ return
7856
7998
  ok, body = self._run_conversation_query(
7857
7999
  lambda conn: self._conversation_query().find_in_conversation(
7858
- conn, session_id, query, kind=kind),
8000
+ conn, session_id, query, kind=kind, regex=regex, case=case),
7859
8001
  "/api/conversation/find")
7860
8002
  if not ok:
7861
8003
  return
@@ -8472,11 +8614,12 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
8472
8614
  _c_boot.ORIGINAL_ENTRYPOINT = shutil.which("cctally")
8473
8615
  _c_boot._UPDATE_WORKER = UpdateWorker()
8474
8616
 
8475
- # Resolve display tz (--tz overrides config.display.tz). The dashboard's
8476
- # envelope-emitted display block (Tasks 11-12) reads this; for now,
8477
- # stash on args so downstream selectors can pick it up uniformly.
8617
+ # Load config for the bind-host + expose-transcripts resolution below. (#217
8618
+ # S1 / U7b: dropped the dead ``args._resolved_tz = resolve_display_tz(...)``
8619
+ # write nothing in the dashboard ever read it back; the envelope display
8620
+ # block it was speculatively staged for was never wired. Each report
8621
+ # subcommand resolves + reads its own ``args._resolved_tz`` independently.)
8478
8622
  config = load_config()
8479
- args._resolved_tz = resolve_display_tz(args, config)
8480
8623
 
8481
8624
  # Resolve bind host: --host flag > config.dashboard.bind > argparse default.
8482
8625
  # `--host` defaults to None (Task 2) so we can distinguish "user explicitly