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.
Files changed (42) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +4 -2
  3. package/bin/_cctally_cache.py +903 -74
  4. package/bin/_cctally_config.py +57 -0
  5. package/bin/_cctally_core.py +94 -14
  6. package/bin/_cctally_dashboard.py +217 -19
  7. package/bin/_cctally_dashboard_conversation.py +170 -20
  8. package/bin/_cctally_dashboard_envelope.py +2 -0
  9. package/bin/_cctally_db.py +481 -19
  10. package/bin/_cctally_doctor.py +18 -1
  11. package/bin/_cctally_journal.py +1156 -21
  12. package/bin/_cctally_journal_repair.py +6 -0
  13. package/bin/_cctally_parser.py +26 -0
  14. package/bin/_cctally_quota.py +171 -55
  15. package/bin/_cctally_record.py +13 -1
  16. package/bin/_cctally_rederive.py +4 -0
  17. package/bin/_cctally_statusline.py +6 -6
  18. package/bin/_cctally_store.py +1061 -40
  19. package/bin/_cctally_transcript.py +32 -2
  20. package/bin/_cctally_tui.py +54 -6
  21. package/bin/_lib_cache_report.py +8 -3
  22. package/bin/_lib_codex_conversation.py +851 -81
  23. package/bin/_lib_codex_conversation_query.py +2031 -96
  24. package/bin/_lib_codex_find_projection.py +517 -0
  25. package/bin/_lib_codex_harness_preamble.py +176 -0
  26. package/bin/_lib_codex_hooks.py +5 -3
  27. package/bin/_lib_codex_js_scan.py +254 -0
  28. package/bin/_lib_codex_landmarks.py +309 -0
  29. package/bin/_lib_codex_title_clean.py +116 -0
  30. package/bin/_lib_conversation_dispatch.py +168 -22
  31. package/bin/_lib_conversation_query.py +62 -2
  32. package/bin/_lib_conversation_watch.py +4 -2
  33. package/bin/_lib_doctor.py +64 -0
  34. package/bin/_lib_quota_alert_axes.py +31 -34
  35. package/bin/_lib_stats_damage.py +523 -0
  36. package/bin/_lib_stats_publish.py +243 -0
  37. package/bin/cctally +17 -3
  38. package/dashboard/static/assets/index-Dat-mza6.js +97 -0
  39. package/dashboard/static/assets/{index-Dwirao3Y.css → index-DnWdv8um.css} +1 -1
  40. package/dashboard/static/dashboard.html +2 -2
  41. package/package.json +8 -1
  42. 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 = ("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", "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.neutral_browse(
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, **filters),
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(handler, session_id: str) -> None:
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
- # Cache unavailable — degrade to keep-alive only; client backstop
807
- # tick still surfaces turns. (Headers already sent; can't 500.)
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
- return sync_claude_conversations(conn, only_paths=set(changed))
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(handler, key: str) -> None:
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
- return sync_codex_conversations(conn, only_paths=set(changed))
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 = _make_codex_discovery_step(handler, conn, key, cq_codex)
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
- return sync_claude_conversations(conn, only_paths=set(changed))
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