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.
Files changed (37) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/bin/_cctally_cache.py +863 -74
  3. package/bin/_cctally_config.py +57 -0
  4. package/bin/_cctally_core.py +39 -8
  5. package/bin/_cctally_dashboard.py +146 -5
  6. package/bin/_cctally_dashboard_conversation.py +164 -18
  7. package/bin/_cctally_dashboard_envelope.py +2 -0
  8. package/bin/_cctally_db.py +372 -10
  9. package/bin/_cctally_doctor.py +18 -1
  10. package/bin/_cctally_journal.py +535 -13
  11. package/bin/_cctally_journal_repair.py +6 -0
  12. package/bin/_cctally_parser.py +6 -0
  13. package/bin/_cctally_quota.py +171 -55
  14. package/bin/_cctally_record.py +13 -1
  15. package/bin/_cctally_rederive.py +4 -0
  16. package/bin/_cctally_store.py +311 -6
  17. package/bin/_cctally_transcript.py +32 -2
  18. package/bin/_lib_cache_report.py +8 -3
  19. package/bin/_lib_codex_conversation.py +851 -81
  20. package/bin/_lib_codex_conversation_query.py +2005 -95
  21. package/bin/_lib_codex_find_projection.py +370 -0
  22. package/bin/_lib_codex_harness_preamble.py +176 -0
  23. package/bin/_lib_codex_hooks.py +5 -3
  24. package/bin/_lib_codex_js_scan.py +254 -0
  25. package/bin/_lib_codex_landmarks.py +309 -0
  26. package/bin/_lib_codex_title_clean.py +116 -0
  27. package/bin/_lib_conversation_dispatch.py +153 -21
  28. package/bin/_lib_conversation_watch.py +4 -2
  29. package/bin/_lib_doctor.py +64 -0
  30. package/bin/_lib_quota_alert_axes.py +31 -34
  31. package/bin/_lib_stats_damage.py +523 -0
  32. package/bin/cctally +5 -0
  33. package/dashboard/static/assets/index-BEzzJtUd.js +97 -0
  34. package/dashboard/static/assets/{index-Dwirao3Y.css → index-DnWdv8um.css} +1 -1
  35. package/dashboard/static/dashboard.html +2 -2
  36. package/package.json +7 -1
  37. 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 = ("source", "project_key", "model", "limit", "cursor")
230
- _QUALIFIED_SEARCH_ACCEPTED = ("source", "q", "kind", "limit", "cursor")
231
- _QUALIFIED_FACETS_ACCEPTED = ("source",)
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.neutral_browse(
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(handler, session_id: str) -> None:
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
- # Cache unavailable — degrade to keep-alive only; client backstop
807
- # tick still surfaces turns. (Headers already sent; can't 500.)
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
- return sync_claude_conversations(conn, only_paths=set(changed))
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(handler, key: str) -> None:
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
- return sync_codex_conversations(conn, only_paths=set(changed))
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 = _make_codex_discovery_step(handler, conn, key, cq_codex)
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
- return sync_claude_conversations(conn, only_paths=set(changed))
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