cctally 1.91.0 → 1.92.1
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 +51 -0
- package/README.md +4 -2
- package/bin/_cctally_cache.py +903 -74
- package/bin/_cctally_config.py +57 -0
- package/bin/_cctally_core.py +94 -14
- package/bin/_cctally_dashboard.py +217 -19
- package/bin/_cctally_dashboard_conversation.py +170 -20
- package/bin/_cctally_dashboard_envelope.py +2 -0
- package/bin/_cctally_db.py +481 -19
- package/bin/_cctally_doctor.py +18 -1
- package/bin/_cctally_journal.py +1156 -21
- package/bin/_cctally_journal_repair.py +6 -0
- package/bin/_cctally_parser.py +26 -0
- package/bin/_cctally_quota.py +171 -55
- package/bin/_cctally_record.py +13 -1
- package/bin/_cctally_rederive.py +4 -0
- package/bin/_cctally_statusline.py +6 -6
- package/bin/_cctally_store.py +1061 -40
- package/bin/_cctally_transcript.py +32 -2
- package/bin/_cctally_tui.py +54 -6
- package/bin/_lib_cache_report.py +8 -3
- package/bin/_lib_codex_conversation.py +851 -81
- package/bin/_lib_codex_conversation_query.py +2031 -96
- package/bin/_lib_codex_find_projection.py +517 -0
- package/bin/_lib_codex_harness_preamble.py +176 -0
- package/bin/_lib_codex_hooks.py +5 -3
- package/bin/_lib_codex_js_scan.py +254 -0
- package/bin/_lib_codex_landmarks.py +309 -0
- package/bin/_lib_codex_title_clean.py +116 -0
- package/bin/_lib_conversation_dispatch.py +168 -22
- package/bin/_lib_conversation_query.py +62 -2
- package/bin/_lib_conversation_watch.py +4 -2
- package/bin/_lib_doctor.py +64 -0
- package/bin/_lib_quota_alert_axes.py +31 -34
- package/bin/_lib_stats_damage.py +523 -0
- package/bin/_lib_stats_publish.py +243 -0
- package/bin/cctally +17 -3
- package/dashboard/static/assets/index-Dat-mza6.js +97 -0
- package/dashboard/static/assets/{index-Dwirao3Y.css → index-DnWdv8um.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +8 -1
- package/dashboard/static/assets/index-CILAoEja.js +0 -90
|
@@ -45,6 +45,7 @@ import sys
|
|
|
45
45
|
|
|
46
46
|
from _cctally_cache import (
|
|
47
47
|
open_cache_db,
|
|
48
|
+
scope_conversations_db_to_account,
|
|
48
49
|
sync_codex_cache,
|
|
49
50
|
sync_claude_conversations,
|
|
50
51
|
sync_codex_conversations,
|
|
@@ -222,13 +223,15 @@ def _conversation_query_impl():
|
|
|
222
223
|
# are deliberately absent — they are meaningless on a collection route, so they
|
|
223
224
|
# fall to "genuinely unknown → ignored".
|
|
224
225
|
_RECOGNIZED_CONVERSATION_PARAMS = (
|
|
225
|
-
"source", "project_key", "model", "limit", "cursor", "q", "kind",
|
|
226
|
+
"source", "account", "project_key", "model", "limit", "cursor", "selected", "q", "kind",
|
|
226
227
|
"sort", "offset", "date_from", "date_to", "projects",
|
|
227
228
|
"cost_min", "cost_max", "rebuild_min", "models",
|
|
228
229
|
)
|
|
229
|
-
_QUALIFIED_BROWSE_ACCEPTED = (
|
|
230
|
-
|
|
231
|
-
|
|
230
|
+
_QUALIFIED_BROWSE_ACCEPTED = (
|
|
231
|
+
"source", "account", "project_key", "model", "limit", "cursor", "selected")
|
|
232
|
+
_QUALIFIED_SEARCH_ACCEPTED = (
|
|
233
|
+
"source", "account", "q", "kind", "limit", "cursor")
|
|
234
|
+
_QUALIFIED_FACETS_ACCEPTED = ("source", "account")
|
|
232
235
|
# A raw browse cursor is a conversation key — printable + URL-safe by construction
|
|
233
236
|
# (§2.2). Syntactic-only validation: reject whitespace/control/empty; echo raw.
|
|
234
237
|
_BROWSE_CURSOR_RE = re.compile(r"\A[!-~]+\Z")
|
|
@@ -321,8 +324,12 @@ def _respond_qualified_json(handler, env):
|
|
|
321
324
|
``ok`` / ``normalization_pending`` → 200; ``gone`` → 410; ``validation_error``
|
|
322
325
|
→ 400; anything else (``not_found`` + unknown) → 404. Body is the envelope."""
|
|
323
326
|
status = (env or {}).get("status")
|
|
324
|
-
if status in ("ok", "normalization_pending"):
|
|
327
|
+
if status in ("ok", "normalization_pending", "ready", "indexing"):
|
|
325
328
|
handler._respond_json(200, env)
|
|
329
|
+
elif status == "invalid_find_cursor":
|
|
330
|
+
handler._respond_json(400, {"error": "invalid find cursor"})
|
|
331
|
+
elif status == "stale_find_cursor":
|
|
332
|
+
handler._respond_json(409, {"error": "stale find cursor"})
|
|
326
333
|
elif status == "gone":
|
|
327
334
|
handler._respond_json(410, env)
|
|
328
335
|
elif status == "validation_error":
|
|
@@ -357,12 +364,14 @@ def _handle_qualified_browse(handler, qs_raw, source):
|
|
|
357
364
|
return
|
|
358
365
|
project_key = (parsed.get("project_key", [None])[0] or None)
|
|
359
366
|
model = (parsed.get("model", [None])[0] or None)
|
|
367
|
+
selected = (parsed.get("selected", [None])[0] or None)
|
|
360
368
|
speed = _resolve_effective_speed()
|
|
361
369
|
disp = _conversation_dispatch()
|
|
362
370
|
ok, body = handler._run_conversation_query(
|
|
363
371
|
lambda conn: disp.neutral_browse(
|
|
364
372
|
conn, source=source, effective_speed=speed,
|
|
365
|
-
project_key=project_key, model=model, limit=limit, cursor=cursor
|
|
373
|
+
project_key=project_key, model=model, limit=limit, cursor=cursor,
|
|
374
|
+
selected=selected),
|
|
366
375
|
"/api/conversations")
|
|
367
376
|
if not ok:
|
|
368
377
|
return
|
|
@@ -378,7 +387,7 @@ def _handle_qualified_facets(handler, qs_raw, source):
|
|
|
378
387
|
speed = _resolve_effective_speed()
|
|
379
388
|
disp = _conversation_dispatch()
|
|
380
389
|
ok, body = handler._run_conversation_query(
|
|
381
|
-
lambda conn: disp.
|
|
390
|
+
lambda conn: disp.neutral_facets(
|
|
382
391
|
conn, source=source, effective_speed=speed),
|
|
383
392
|
"/api/conversations/facets")
|
|
384
393
|
if not ok:
|
|
@@ -469,6 +478,15 @@ def _run_conversation_query_impl(handler, kernel_call, log_label):
|
|
|
469
478
|
)
|
|
470
479
|
return False, None
|
|
471
480
|
try:
|
|
481
|
+
import urllib.parse as _u
|
|
482
|
+
account_vals = _u.parse_qs(
|
|
483
|
+
handler.path.partition("?")[2], keep_blank_values=True
|
|
484
|
+
).get("account")
|
|
485
|
+
if account_vals is not None:
|
|
486
|
+
if len(account_vals) != 1 or not account_vals[0]:
|
|
487
|
+
handler._respond_json(400, {"error": "invalid account"})
|
|
488
|
+
return False, None
|
|
489
|
+
scope_conversations_db_to_account(conn, account_vals[0])
|
|
472
490
|
body = kernel_call(conn)
|
|
473
491
|
except Exception as exc: # noqa: BLE001
|
|
474
492
|
handler.log_error("%s failed: %r", log_label, exc)
|
|
@@ -588,12 +606,14 @@ def _handle_get_conversations_impl(handler) -> None:
|
|
|
588
606
|
sort = _qs_str(q, "sort", "recent")
|
|
589
607
|
limit = _qs_int(q, "limit", 50)
|
|
590
608
|
offset = _qs_int(q, "offset", 0)
|
|
609
|
+
selected = _qs_str(q, "selected", None)
|
|
591
610
|
filters = handler._parse_conversation_filters(q)
|
|
592
611
|
if filters is None:
|
|
593
612
|
return # a 400 has already been sent
|
|
594
613
|
ok, body = handler._run_conversation_query(
|
|
595
614
|
lambda conn: handler._conversation_query().list_conversations(
|
|
596
|
-
conn, sort=sort, limit=limit, offset=offset,
|
|
615
|
+
conn, sort=sort, limit=limit, offset=offset, selected=selected,
|
|
616
|
+
**filters),
|
|
597
617
|
"/api/conversations")
|
|
598
618
|
if not ok:
|
|
599
619
|
return
|
|
@@ -794,25 +814,59 @@ def _run_conversation_events_stream(
|
|
|
794
814
|
idle = 0.0
|
|
795
815
|
|
|
796
816
|
|
|
797
|
-
def _bare_conversation_events(
|
|
817
|
+
def _bare_conversation_events(
|
|
818
|
+
handler, session_id: str, account_key: str | None = None,
|
|
819
|
+
) -> None:
|
|
798
820
|
"""Bare legacy Claude live-tail — today's no-preflight, ``sessionId``-framed
|
|
799
821
|
behavior, byte-identical (spec §5.2 reserves this for bare streams)."""
|
|
800
822
|
cq = handler._conversation_query()
|
|
801
|
-
_send_sse_headers(handler)
|
|
802
823
|
passive = bool(type(handler).no_sync)
|
|
803
824
|
try:
|
|
804
825
|
conn = sys.modules["_cctally_dashboard"].open_conversations_db()
|
|
805
826
|
except (sqlite3.DatabaseError, OSError):
|
|
806
|
-
|
|
807
|
-
|
|
827
|
+
if account_key is not None:
|
|
828
|
+
# A qualified account boundary must be established before any SSE
|
|
829
|
+
# bytes. If it cannot be proven, fail closed as JSON.
|
|
830
|
+
handler._respond_json(500, {"error": "internal error"})
|
|
831
|
+
return
|
|
832
|
+
# Unqualified legacy behavior: degrade to keep-alive only; the client
|
|
833
|
+
# backstop tick still surfaces turns.
|
|
808
834
|
passive = True
|
|
809
835
|
conn = None
|
|
836
|
+
if conn is not None and account_key is not None:
|
|
837
|
+
scope_conversations_db_to_account(conn, account_key)
|
|
838
|
+
if conn.execute(
|
|
839
|
+
"SELECT 1 FROM conversation_messages WHERE session_id=? LIMIT 1",
|
|
840
|
+
(session_id,),
|
|
841
|
+
).fetchone() is None:
|
|
842
|
+
conn.close()
|
|
843
|
+
handler._respond_json(404, {"error": "conversation not found"})
|
|
844
|
+
return
|
|
845
|
+
_send_sse_headers(handler)
|
|
810
846
|
|
|
811
847
|
def _resolve():
|
|
812
848
|
return cq.session_source_paths(conn, session_id) if conn else []
|
|
813
849
|
|
|
814
850
|
def _ingest(changed):
|
|
815
|
-
|
|
851
|
+
if account_key is None:
|
|
852
|
+
return sync_claude_conversations(conn, only_paths=set(changed))
|
|
853
|
+
before = conn.execute(
|
|
854
|
+
"SELECT COUNT(*),MAX(id) FROM conversation_messages "
|
|
855
|
+
"WHERE session_id=?",
|
|
856
|
+
(session_id,),
|
|
857
|
+
).fetchone()
|
|
858
|
+
writer = sys.modules["_cctally_dashboard"].open_conversations_db()
|
|
859
|
+
try:
|
|
860
|
+
stats = sync_claude_conversations(writer, only_paths=set(changed))
|
|
861
|
+
finally:
|
|
862
|
+
writer.close()
|
|
863
|
+
after = conn.execute(
|
|
864
|
+
"SELECT COUNT(*),MAX(id) FROM conversation_messages "
|
|
865
|
+
"WHERE session_id=?",
|
|
866
|
+
(session_id,),
|
|
867
|
+
).fetchone()
|
|
868
|
+
stats.targeted_visible = after != before
|
|
869
|
+
return stats
|
|
816
870
|
|
|
817
871
|
try:
|
|
818
872
|
_run_conversation_events_stream(
|
|
@@ -895,7 +949,9 @@ def _make_codex_discovery_step(handler, conn, conversation_key, cq_codex):
|
|
|
895
949
|
return _discovery
|
|
896
950
|
|
|
897
951
|
|
|
898
|
-
def _qualified_conversation_events(
|
|
952
|
+
def _qualified_conversation_events(
|
|
953
|
+
handler, key: str, account_key: str | None = None,
|
|
954
|
+
) -> None:
|
|
899
955
|
"""Qualified (``v1.``) live-tail (spec §5.2): a neutral preflight — resolve →
|
|
900
956
|
normalization authority (Codex) → existence — answered as plain JSON per
|
|
901
957
|
§2.3 BEFORE any SSE bytes; only on ``ok`` are SSE headers committed and the
|
|
@@ -924,6 +980,8 @@ def _qualified_conversation_events(handler, key: str) -> None:
|
|
|
924
980
|
except Exception as exc: # noqa: BLE001
|
|
925
981
|
handler.log_error("api/conversation/events stream failed: %r", exc)
|
|
926
982
|
return
|
|
983
|
+
if account_key is not None:
|
|
984
|
+
scope_conversations_db_to_account(conn, account_key)
|
|
927
985
|
|
|
928
986
|
try:
|
|
929
987
|
preflight = disp.neutral_events_preflight(conn, key)
|
|
@@ -959,10 +1017,43 @@ def _qualified_conversation_events(handler, key: str) -> None:
|
|
|
959
1017
|
return cq_codex.codex_conversation_source_paths(conn, key)
|
|
960
1018
|
|
|
961
1019
|
def _ingest(changed):
|
|
962
|
-
|
|
1020
|
+
if account_key is None:
|
|
1021
|
+
return sync_codex_conversations(conn, only_paths=set(changed))
|
|
1022
|
+
before = conn.execute(
|
|
1023
|
+
"SELECT COUNT(*),MAX(id) FROM codex_conversation_messages "
|
|
1024
|
+
"WHERE conversation_key=?",
|
|
1025
|
+
(key,),
|
|
1026
|
+
).fetchone()
|
|
1027
|
+
# The accounting cache owns the durable physical account-range
|
|
1028
|
+
# decision. Advance it first so transcript ingest never treats the
|
|
1029
|
+
# previous range as open-ended across an account switch.
|
|
1030
|
+
accounting = open_cache_db()
|
|
1031
|
+
try:
|
|
1032
|
+
accounting_stats = sync_codex_cache(
|
|
1033
|
+
accounting, only_paths=set(changed)
|
|
1034
|
+
)
|
|
1035
|
+
finally:
|
|
1036
|
+
accounting.close()
|
|
1037
|
+
if not accounting_stats.targeted_clean:
|
|
1038
|
+
return accounting_stats
|
|
1039
|
+
writer = sys.modules["_cctally_dashboard"].open_conversations_db()
|
|
1040
|
+
try:
|
|
1041
|
+
stats = sync_codex_conversations(writer, only_paths=set(changed))
|
|
1042
|
+
finally:
|
|
1043
|
+
writer.close()
|
|
1044
|
+
after = conn.execute(
|
|
1045
|
+
"SELECT COUNT(*),MAX(id) FROM codex_conversation_messages "
|
|
1046
|
+
"WHERE conversation_key=?",
|
|
1047
|
+
(key,),
|
|
1048
|
+
).fetchone()
|
|
1049
|
+
stats.targeted_visible = after != before
|
|
1050
|
+
return stats
|
|
963
1051
|
|
|
964
1052
|
cached = lambda paths: _codex_cached_file_sigs(conn, paths)
|
|
965
|
-
discovery =
|
|
1053
|
+
discovery = (
|
|
1054
|
+
None if account_key is not None
|
|
1055
|
+
else _make_codex_discovery_step(handler, conn, key, cq_codex)
|
|
1056
|
+
)
|
|
966
1057
|
else: # claude — reuse the Claude mechanics, speak qualified frames.
|
|
967
1058
|
cq = handler._conversation_query()
|
|
968
1059
|
|
|
@@ -970,7 +1061,25 @@ def _qualified_conversation_events(handler, key: str) -> None:
|
|
|
970
1061
|
return cq.session_source_paths(conn, native)
|
|
971
1062
|
|
|
972
1063
|
def _ingest(changed):
|
|
973
|
-
|
|
1064
|
+
if account_key is None:
|
|
1065
|
+
return sync_claude_conversations(conn, only_paths=set(changed))
|
|
1066
|
+
before = conn.execute(
|
|
1067
|
+
"SELECT COUNT(*),MAX(id) FROM conversation_messages "
|
|
1068
|
+
"WHERE session_id=?",
|
|
1069
|
+
(native,),
|
|
1070
|
+
).fetchone()
|
|
1071
|
+
writer = sys.modules["_cctally_dashboard"].open_conversations_db()
|
|
1072
|
+
try:
|
|
1073
|
+
stats = sync_claude_conversations(writer, only_paths=set(changed))
|
|
1074
|
+
finally:
|
|
1075
|
+
writer.close()
|
|
1076
|
+
after = conn.execute(
|
|
1077
|
+
"SELECT COUNT(*),MAX(id) FROM conversation_messages "
|
|
1078
|
+
"WHERE session_id=?",
|
|
1079
|
+
(native,),
|
|
1080
|
+
).fetchone()
|
|
1081
|
+
stats.targeted_visible = after != before
|
|
1082
|
+
return stats
|
|
974
1083
|
|
|
975
1084
|
cached = lambda paths: _cached_file_sigs(conn, paths)
|
|
976
1085
|
discovery = None
|
|
@@ -999,14 +1108,23 @@ def _handle_get_conversation_events_impl(handler, path: str) -> None:
|
|
|
999
1108
|
if not handler._require_transcripts_allowed():
|
|
1000
1109
|
return
|
|
1001
1110
|
import urllib.parse as _u
|
|
1111
|
+
account_vals = _u.parse_qs(
|
|
1112
|
+
handler.path.partition("?")[2], keep_blank_values=True
|
|
1113
|
+
).get("account")
|
|
1114
|
+
if account_vals is not None and (
|
|
1115
|
+
len(account_vals) != 1 or not account_vals[0]
|
|
1116
|
+
):
|
|
1117
|
+
handler._respond_json(400, {"error": "invalid account"})
|
|
1118
|
+
return
|
|
1119
|
+
account_key = account_vals[0] if account_vals else None
|
|
1002
1120
|
key = _u.unquote(path[len("/api/conversation/"):-len("/events")])
|
|
1003
1121
|
if not key:
|
|
1004
1122
|
handler.send_error(404, "conversation not found")
|
|
1005
1123
|
return
|
|
1006
1124
|
if key.startswith("v1."):
|
|
1007
|
-
_qualified_conversation_events(handler, key)
|
|
1125
|
+
_qualified_conversation_events(handler, key, account_key)
|
|
1008
1126
|
else:
|
|
1009
|
-
_bare_conversation_events(handler, key)
|
|
1127
|
+
_bare_conversation_events(handler, key, account_key)
|
|
1010
1128
|
|
|
1011
1129
|
|
|
1012
1130
|
def _handle_get_conversation_search_impl(handler) -> None:
|
|
@@ -1407,10 +1525,42 @@ def _handle_get_conversation_find_impl(handler, path: str) -> None:
|
|
|
1407
1525
|
return
|
|
1408
1526
|
if session_id.startswith("v1."):
|
|
1409
1527
|
disp = _conversation_dispatch()
|
|
1528
|
+
cref = disp.resolve_conversation_ref(session_id)
|
|
1529
|
+
paging = {}
|
|
1530
|
+
if cref is not None and cref.source == "codex":
|
|
1531
|
+
raw_limit = _qs_str(q, "limit", None)
|
|
1532
|
+
if raw_limit is None:
|
|
1533
|
+
limit = 100
|
|
1534
|
+
else:
|
|
1535
|
+
try:
|
|
1536
|
+
limit = int(raw_limit)
|
|
1537
|
+
except (TypeError, ValueError):
|
|
1538
|
+
handler._respond_json(400, {"error": "invalid find limit"})
|
|
1539
|
+
return
|
|
1540
|
+
if not 1 <= limit <= 200:
|
|
1541
|
+
handler._respond_json(400, {"error": "invalid find limit"})
|
|
1542
|
+
return
|
|
1543
|
+
cursor = _qs_str(q, "cursor", None)
|
|
1544
|
+
around = _qs_str(q, "around", None)
|
|
1545
|
+
direction = _qs_str(q, "direction", "next")
|
|
1546
|
+
if direction not in ("next", "previous"):
|
|
1547
|
+
handler._respond_json(400, {"error": "invalid find direction"})
|
|
1548
|
+
return
|
|
1549
|
+
if cursor is not None and around is not None:
|
|
1550
|
+
handler._respond_json(
|
|
1551
|
+
400, {"error": "find cursor and around are mutually exclusive"})
|
|
1552
|
+
return
|
|
1553
|
+
paging = {
|
|
1554
|
+
"limit": limit,
|
|
1555
|
+
"cursor": cursor,
|
|
1556
|
+
"direction": direction,
|
|
1557
|
+
"around": around,
|
|
1558
|
+
}
|
|
1410
1559
|
_serve_qualified_entity(
|
|
1411
1560
|
handler,
|
|
1412
1561
|
lambda conn: disp.neutral_find(
|
|
1413
|
-
conn, session_id, query, kind=kind, regex=regex, case=case
|
|
1562
|
+
conn, session_id, query, kind=kind, regex=regex, case=case,
|
|
1563
|
+
**paging),
|
|
1414
1564
|
"/api/conversation/find")
|
|
1415
1565
|
return
|
|
1416
1566
|
ok, body = handler._run_conversation_query(
|
|
@@ -1418,9 +1418,11 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
1418
1418
|
_cfg_for_alerts.get("dashboard"), dict) else {}
|
|
1419
1419
|
_cfm = _dash_cfg.get("cache_failure_markers", True)
|
|
1420
1420
|
_lt = _dash_cfg.get("live_tail", True)
|
|
1421
|
+
_lan_auth = _dash_cfg.get("lan_auth", True)
|
|
1421
1422
|
dashboard_prefs = {
|
|
1422
1423
|
"cache_failure_markers": _cfm if isinstance(_cfm, bool) else True,
|
|
1423
1424
|
"live_tail": _lt if isinstance(_lt, bool) else True,
|
|
1425
|
+
"lan_auth": _lan_auth if isinstance(_lan_auth, bool) else True,
|
|
1424
1426
|
}
|
|
1425
1427
|
|
|
1426
1428
|
# Mirror update-state.json + update-suppress.json into the envelope
|