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.
- package/CHANGELOG.md +47 -0
- package/bin/_cctally_cache.py +233 -17
- package/bin/_cctally_dashboard.py +169 -26
- package/bin/_cctally_db.py +348 -11
- package/bin/_cctally_doctor.py +69 -0
- package/bin/_cctally_record.py +36 -16
- package/bin/_lib_conversation.py +231 -12
- package/bin/_lib_conversation_export.py +325 -0
- package/bin/_lib_conversation_query.py +1191 -331
- package/bin/_lib_doctor.py +85 -0
- package/dashboard/static/assets/index-DXzPdFUE.js +78 -0
- package/dashboard/static/assets/index-Yau7oYp8.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-CqBt0IM7.js +0 -68
- package/dashboard/static/assets/index-klTHJA52.css +0 -1
|
@@ -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``,
|
|
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
|
|
5288
|
-
# lockstep with ``_lib_conversation_query._SEARCH_KINDS``
|
|
5289
|
-
# ValueError on an unknown kind
|
|
5290
|
-
|
|
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
|
|
7456
|
-
|
|
7457
|
-
|
|
7458
|
-
|
|
7459
|
-
|
|
7460
|
-
|
|
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
|
|
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``
|
|
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
|
|
7625
|
-
# Sibling handlers read ``self.path`` directly — the
|
|
7626
|
-
# or every request re-serves the head and
|
|
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,
|
|
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
|
-
#
|
|
8476
|
-
#
|
|
8477
|
-
#
|
|
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
|