cctally 1.91.0 → 1.92.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 +39 -0
- package/bin/_cctally_cache.py +863 -74
- package/bin/_cctally_config.py +57 -0
- package/bin/_cctally_core.py +39 -8
- package/bin/_cctally_dashboard.py +146 -5
- package/bin/_cctally_dashboard_conversation.py +164 -18
- package/bin/_cctally_dashboard_envelope.py +2 -0
- package/bin/_cctally_db.py +372 -10
- package/bin/_cctally_doctor.py +18 -1
- package/bin/_cctally_journal.py +535 -13
- package/bin/_cctally_journal_repair.py +6 -0
- package/bin/_cctally_parser.py +6 -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_store.py +311 -6
- package/bin/_cctally_transcript.py +32 -2
- package/bin/_lib_cache_report.py +8 -3
- package/bin/_lib_codex_conversation.py +851 -81
- package/bin/_lib_codex_conversation_query.py +2005 -95
- package/bin/_lib_codex_find_projection.py +370 -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 +153 -21
- 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/cctally +5 -0
- package/dashboard/static/assets/index-BEzzJtUd.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 +7 -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", "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")
|
|
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":
|
|
@@ -378,7 +385,7 @@ def _handle_qualified_facets(handler, qs_raw, source):
|
|
|
378
385
|
speed = _resolve_effective_speed()
|
|
379
386
|
disp = _conversation_dispatch()
|
|
380
387
|
ok, body = handler._run_conversation_query(
|
|
381
|
-
lambda conn: disp.
|
|
388
|
+
lambda conn: disp.neutral_facets(
|
|
382
389
|
conn, source=source, effective_speed=speed),
|
|
383
390
|
"/api/conversations/facets")
|
|
384
391
|
if not ok:
|
|
@@ -469,6 +476,15 @@ def _run_conversation_query_impl(handler, kernel_call, log_label):
|
|
|
469
476
|
)
|
|
470
477
|
return False, None
|
|
471
478
|
try:
|
|
479
|
+
import urllib.parse as _u
|
|
480
|
+
account_vals = _u.parse_qs(
|
|
481
|
+
handler.path.partition("?")[2], keep_blank_values=True
|
|
482
|
+
).get("account")
|
|
483
|
+
if account_vals is not None:
|
|
484
|
+
if len(account_vals) != 1 or not account_vals[0]:
|
|
485
|
+
handler._respond_json(400, {"error": "invalid account"})
|
|
486
|
+
return False, None
|
|
487
|
+
scope_conversations_db_to_account(conn, account_vals[0])
|
|
472
488
|
body = kernel_call(conn)
|
|
473
489
|
except Exception as exc: # noqa: BLE001
|
|
474
490
|
handler.log_error("%s failed: %r", log_label, exc)
|
|
@@ -794,25 +810,59 @@ def _run_conversation_events_stream(
|
|
|
794
810
|
idle = 0.0
|
|
795
811
|
|
|
796
812
|
|
|
797
|
-
def _bare_conversation_events(
|
|
813
|
+
def _bare_conversation_events(
|
|
814
|
+
handler, session_id: str, account_key: str | None = None,
|
|
815
|
+
) -> None:
|
|
798
816
|
"""Bare legacy Claude live-tail — today's no-preflight, ``sessionId``-framed
|
|
799
817
|
behavior, byte-identical (spec §5.2 reserves this for bare streams)."""
|
|
800
818
|
cq = handler._conversation_query()
|
|
801
|
-
_send_sse_headers(handler)
|
|
802
819
|
passive = bool(type(handler).no_sync)
|
|
803
820
|
try:
|
|
804
821
|
conn = sys.modules["_cctally_dashboard"].open_conversations_db()
|
|
805
822
|
except (sqlite3.DatabaseError, OSError):
|
|
806
|
-
|
|
807
|
-
|
|
823
|
+
if account_key is not None:
|
|
824
|
+
# A qualified account boundary must be established before any SSE
|
|
825
|
+
# bytes. If it cannot be proven, fail closed as JSON.
|
|
826
|
+
handler._respond_json(500, {"error": "internal error"})
|
|
827
|
+
return
|
|
828
|
+
# Unqualified legacy behavior: degrade to keep-alive only; the client
|
|
829
|
+
# backstop tick still surfaces turns.
|
|
808
830
|
passive = True
|
|
809
831
|
conn = None
|
|
832
|
+
if conn is not None and account_key is not None:
|
|
833
|
+
scope_conversations_db_to_account(conn, account_key)
|
|
834
|
+
if conn.execute(
|
|
835
|
+
"SELECT 1 FROM conversation_messages WHERE session_id=? LIMIT 1",
|
|
836
|
+
(session_id,),
|
|
837
|
+
).fetchone() is None:
|
|
838
|
+
conn.close()
|
|
839
|
+
handler._respond_json(404, {"error": "conversation not found"})
|
|
840
|
+
return
|
|
841
|
+
_send_sse_headers(handler)
|
|
810
842
|
|
|
811
843
|
def _resolve():
|
|
812
844
|
return cq.session_source_paths(conn, session_id) if conn else []
|
|
813
845
|
|
|
814
846
|
def _ingest(changed):
|
|
815
|
-
|
|
847
|
+
if account_key is None:
|
|
848
|
+
return sync_claude_conversations(conn, only_paths=set(changed))
|
|
849
|
+
before = conn.execute(
|
|
850
|
+
"SELECT COUNT(*),MAX(id) FROM conversation_messages "
|
|
851
|
+
"WHERE session_id=?",
|
|
852
|
+
(session_id,),
|
|
853
|
+
).fetchone()
|
|
854
|
+
writer = sys.modules["_cctally_dashboard"].open_conversations_db()
|
|
855
|
+
try:
|
|
856
|
+
stats = sync_claude_conversations(writer, only_paths=set(changed))
|
|
857
|
+
finally:
|
|
858
|
+
writer.close()
|
|
859
|
+
after = conn.execute(
|
|
860
|
+
"SELECT COUNT(*),MAX(id) FROM conversation_messages "
|
|
861
|
+
"WHERE session_id=?",
|
|
862
|
+
(session_id,),
|
|
863
|
+
).fetchone()
|
|
864
|
+
stats.targeted_visible = after != before
|
|
865
|
+
return stats
|
|
816
866
|
|
|
817
867
|
try:
|
|
818
868
|
_run_conversation_events_stream(
|
|
@@ -895,7 +945,9 @@ def _make_codex_discovery_step(handler, conn, conversation_key, cq_codex):
|
|
|
895
945
|
return _discovery
|
|
896
946
|
|
|
897
947
|
|
|
898
|
-
def _qualified_conversation_events(
|
|
948
|
+
def _qualified_conversation_events(
|
|
949
|
+
handler, key: str, account_key: str | None = None,
|
|
950
|
+
) -> None:
|
|
899
951
|
"""Qualified (``v1.``) live-tail (spec §5.2): a neutral preflight — resolve →
|
|
900
952
|
normalization authority (Codex) → existence — answered as plain JSON per
|
|
901
953
|
§2.3 BEFORE any SSE bytes; only on ``ok`` are SSE headers committed and the
|
|
@@ -924,6 +976,8 @@ def _qualified_conversation_events(handler, key: str) -> None:
|
|
|
924
976
|
except Exception as exc: # noqa: BLE001
|
|
925
977
|
handler.log_error("api/conversation/events stream failed: %r", exc)
|
|
926
978
|
return
|
|
979
|
+
if account_key is not None:
|
|
980
|
+
scope_conversations_db_to_account(conn, account_key)
|
|
927
981
|
|
|
928
982
|
try:
|
|
929
983
|
preflight = disp.neutral_events_preflight(conn, key)
|
|
@@ -959,10 +1013,43 @@ def _qualified_conversation_events(handler, key: str) -> None:
|
|
|
959
1013
|
return cq_codex.codex_conversation_source_paths(conn, key)
|
|
960
1014
|
|
|
961
1015
|
def _ingest(changed):
|
|
962
|
-
|
|
1016
|
+
if account_key is None:
|
|
1017
|
+
return sync_codex_conversations(conn, only_paths=set(changed))
|
|
1018
|
+
before = conn.execute(
|
|
1019
|
+
"SELECT COUNT(*),MAX(id) FROM codex_conversation_messages "
|
|
1020
|
+
"WHERE conversation_key=?",
|
|
1021
|
+
(key,),
|
|
1022
|
+
).fetchone()
|
|
1023
|
+
# The accounting cache owns the durable physical account-range
|
|
1024
|
+
# decision. Advance it first so transcript ingest never treats the
|
|
1025
|
+
# previous range as open-ended across an account switch.
|
|
1026
|
+
accounting = open_cache_db()
|
|
1027
|
+
try:
|
|
1028
|
+
accounting_stats = sync_codex_cache(
|
|
1029
|
+
accounting, only_paths=set(changed)
|
|
1030
|
+
)
|
|
1031
|
+
finally:
|
|
1032
|
+
accounting.close()
|
|
1033
|
+
if not accounting_stats.targeted_clean:
|
|
1034
|
+
return accounting_stats
|
|
1035
|
+
writer = sys.modules["_cctally_dashboard"].open_conversations_db()
|
|
1036
|
+
try:
|
|
1037
|
+
stats = sync_codex_conversations(writer, only_paths=set(changed))
|
|
1038
|
+
finally:
|
|
1039
|
+
writer.close()
|
|
1040
|
+
after = conn.execute(
|
|
1041
|
+
"SELECT COUNT(*),MAX(id) FROM codex_conversation_messages "
|
|
1042
|
+
"WHERE conversation_key=?",
|
|
1043
|
+
(key,),
|
|
1044
|
+
).fetchone()
|
|
1045
|
+
stats.targeted_visible = after != before
|
|
1046
|
+
return stats
|
|
963
1047
|
|
|
964
1048
|
cached = lambda paths: _codex_cached_file_sigs(conn, paths)
|
|
965
|
-
discovery =
|
|
1049
|
+
discovery = (
|
|
1050
|
+
None if account_key is not None
|
|
1051
|
+
else _make_codex_discovery_step(handler, conn, key, cq_codex)
|
|
1052
|
+
)
|
|
966
1053
|
else: # claude — reuse the Claude mechanics, speak qualified frames.
|
|
967
1054
|
cq = handler._conversation_query()
|
|
968
1055
|
|
|
@@ -970,7 +1057,25 @@ def _qualified_conversation_events(handler, key: str) -> None:
|
|
|
970
1057
|
return cq.session_source_paths(conn, native)
|
|
971
1058
|
|
|
972
1059
|
def _ingest(changed):
|
|
973
|
-
|
|
1060
|
+
if account_key is None:
|
|
1061
|
+
return sync_claude_conversations(conn, only_paths=set(changed))
|
|
1062
|
+
before = conn.execute(
|
|
1063
|
+
"SELECT COUNT(*),MAX(id) FROM conversation_messages "
|
|
1064
|
+
"WHERE session_id=?",
|
|
1065
|
+
(native,),
|
|
1066
|
+
).fetchone()
|
|
1067
|
+
writer = sys.modules["_cctally_dashboard"].open_conversations_db()
|
|
1068
|
+
try:
|
|
1069
|
+
stats = sync_claude_conversations(writer, only_paths=set(changed))
|
|
1070
|
+
finally:
|
|
1071
|
+
writer.close()
|
|
1072
|
+
after = conn.execute(
|
|
1073
|
+
"SELECT COUNT(*),MAX(id) FROM conversation_messages "
|
|
1074
|
+
"WHERE session_id=?",
|
|
1075
|
+
(native,),
|
|
1076
|
+
).fetchone()
|
|
1077
|
+
stats.targeted_visible = after != before
|
|
1078
|
+
return stats
|
|
974
1079
|
|
|
975
1080
|
cached = lambda paths: _cached_file_sigs(conn, paths)
|
|
976
1081
|
discovery = None
|
|
@@ -999,14 +1104,23 @@ def _handle_get_conversation_events_impl(handler, path: str) -> None:
|
|
|
999
1104
|
if not handler._require_transcripts_allowed():
|
|
1000
1105
|
return
|
|
1001
1106
|
import urllib.parse as _u
|
|
1107
|
+
account_vals = _u.parse_qs(
|
|
1108
|
+
handler.path.partition("?")[2], keep_blank_values=True
|
|
1109
|
+
).get("account")
|
|
1110
|
+
if account_vals is not None and (
|
|
1111
|
+
len(account_vals) != 1 or not account_vals[0]
|
|
1112
|
+
):
|
|
1113
|
+
handler._respond_json(400, {"error": "invalid account"})
|
|
1114
|
+
return
|
|
1115
|
+
account_key = account_vals[0] if account_vals else None
|
|
1002
1116
|
key = _u.unquote(path[len("/api/conversation/"):-len("/events")])
|
|
1003
1117
|
if not key:
|
|
1004
1118
|
handler.send_error(404, "conversation not found")
|
|
1005
1119
|
return
|
|
1006
1120
|
if key.startswith("v1."):
|
|
1007
|
-
_qualified_conversation_events(handler, key)
|
|
1121
|
+
_qualified_conversation_events(handler, key, account_key)
|
|
1008
1122
|
else:
|
|
1009
|
-
_bare_conversation_events(handler, key)
|
|
1123
|
+
_bare_conversation_events(handler, key, account_key)
|
|
1010
1124
|
|
|
1011
1125
|
|
|
1012
1126
|
def _handle_get_conversation_search_impl(handler) -> None:
|
|
@@ -1407,10 +1521,42 @@ def _handle_get_conversation_find_impl(handler, path: str) -> None:
|
|
|
1407
1521
|
return
|
|
1408
1522
|
if session_id.startswith("v1."):
|
|
1409
1523
|
disp = _conversation_dispatch()
|
|
1524
|
+
cref = disp.resolve_conversation_ref(session_id)
|
|
1525
|
+
paging = {}
|
|
1526
|
+
if cref is not None and cref.source == "codex":
|
|
1527
|
+
raw_limit = _qs_str(q, "limit", None)
|
|
1528
|
+
if raw_limit is None:
|
|
1529
|
+
limit = 100
|
|
1530
|
+
else:
|
|
1531
|
+
try:
|
|
1532
|
+
limit = int(raw_limit)
|
|
1533
|
+
except (TypeError, ValueError):
|
|
1534
|
+
handler._respond_json(400, {"error": "invalid find limit"})
|
|
1535
|
+
return
|
|
1536
|
+
if not 1 <= limit <= 200:
|
|
1537
|
+
handler._respond_json(400, {"error": "invalid find limit"})
|
|
1538
|
+
return
|
|
1539
|
+
cursor = _qs_str(q, "cursor", None)
|
|
1540
|
+
around = _qs_str(q, "around", None)
|
|
1541
|
+
direction = _qs_str(q, "direction", "next")
|
|
1542
|
+
if direction not in ("next", "previous"):
|
|
1543
|
+
handler._respond_json(400, {"error": "invalid find direction"})
|
|
1544
|
+
return
|
|
1545
|
+
if cursor is not None and around is not None:
|
|
1546
|
+
handler._respond_json(
|
|
1547
|
+
400, {"error": "find cursor and around are mutually exclusive"})
|
|
1548
|
+
return
|
|
1549
|
+
paging = {
|
|
1550
|
+
"limit": limit,
|
|
1551
|
+
"cursor": cursor,
|
|
1552
|
+
"direction": direction,
|
|
1553
|
+
"around": around,
|
|
1554
|
+
}
|
|
1410
1555
|
_serve_qualified_entity(
|
|
1411
1556
|
handler,
|
|
1412
1557
|
lambda conn: disp.neutral_find(
|
|
1413
|
-
conn, session_id, query, kind=kind, regex=regex, case=case
|
|
1558
|
+
conn, session_id, query, kind=kind, regex=regex, case=case,
|
|
1559
|
+
**paging),
|
|
1414
1560
|
"/api/conversation/find")
|
|
1415
1561
|
return
|
|
1416
1562
|
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
|