cctally 1.82.0 → 1.83.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 (52) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +52 -74
  3. package/bin/_cctally_alerts.py +8 -1
  4. package/bin/_cctally_cache.py +963 -149
  5. package/bin/_cctally_config.py +43 -4
  6. package/bin/_cctally_core.py +933 -759
  7. package/bin/_cctally_dashboard.py +157 -47
  8. package/bin/_cctally_dashboard_cache_report.py +13 -6
  9. package/bin/_cctally_dashboard_conversation.py +1 -0
  10. package/bin/_cctally_dashboard_envelope.py +186 -8
  11. package/bin/_cctally_dashboard_share.py +60 -20
  12. package/bin/_cctally_dashboard_sources.py +427 -128
  13. package/bin/_cctally_db.py +605 -128
  14. package/bin/_cctally_doctor.py +413 -28
  15. package/bin/_cctally_five_hour.py +12 -5
  16. package/bin/_cctally_journal.py +2050 -156
  17. package/bin/_cctally_journal_repair.py +519 -0
  18. package/bin/_cctally_milestone_history.py +142 -56
  19. package/bin/_cctally_milestones.py +179 -111
  20. package/bin/_cctally_parser.py +42 -0
  21. package/bin/_cctally_project.py +24 -18
  22. package/bin/_cctally_quota.py +139 -25
  23. package/bin/_cctally_record.py +279 -108
  24. package/bin/_cctally_rederive.py +1052 -0
  25. package/bin/_cctally_reporting.py +58 -53
  26. package/bin/_cctally_setup.py +1 -0
  27. package/bin/_cctally_source_analytics.py +4 -1
  28. package/bin/_cctally_statusline.py +11 -11
  29. package/bin/_cctally_store.py +1039 -31
  30. package/bin/_cctally_sync_week.py +17 -8
  31. package/bin/_cctally_tui.py +421 -54
  32. package/bin/_cctally_update.py +133 -8
  33. package/bin/_cctally_weekrefs.py +14 -0
  34. package/bin/_lib_aggregators.py +10 -6
  35. package/bin/_lib_cache_report.py +101 -9
  36. package/bin/_lib_codex_pools.py +82 -0
  37. package/bin/_lib_conversation_query.py +126 -33
  38. package/bin/_lib_dashboard_sources.py +126 -1
  39. package/bin/_lib_diff_kernel.py +28 -15
  40. package/bin/_lib_doctor.py +342 -4
  41. package/bin/_lib_journal.py +924 -2
  42. package/bin/_lib_jsonl.py +43 -14
  43. package/bin/_lib_pricing.py +140 -21
  44. package/bin/_lib_readme_refresh.py +401 -0
  45. package/bin/_lib_rederive.py +395 -0
  46. package/bin/_lib_share.py +58 -2
  47. package/bin/cctally +56 -8
  48. package/dashboard/static/assets/{index-DJP4gEB7.js → index-3bgCMVHb.js} +52 -52
  49. package/dashboard/static/assets/index-D27EIHEI.css +1 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +6 -1
  52. package/dashboard/static/assets/index-Dk1nplOz.css +0 -1
@@ -16,6 +16,7 @@ reached via the call-time _cctally() accessor (ns-patchable). No _lib_ kernel.
16
16
  """
17
17
  from __future__ import annotations
18
18
 
19
+ import contextlib
19
20
  import datetime as dt
20
21
  import sqlite3
21
22
  import sys
@@ -40,6 +41,34 @@ def _cctally():
40
41
  return sys.modules["cctally"]
41
42
 
42
43
 
44
+ def _own_conn_stats_guard():
45
+ """The sanctioned regime for the three ``own_conn`` reconcile branches (#386).
46
+
47
+ ``_reconcile_budget_on_config_write``, its Codex twin, and
48
+ ``_reconcile_project_budget_milestones_on_write`` each accept ``conn=None``
49
+ and then ``open_db()`` → write → ``commit()`` → ``close()``. That branch
50
+ writes stats.db OUTSIDE the ingest cycle and outside every lock — the
51
+ R1-RESIDUAL the #386 mutation inventory recorded. No production caller
52
+ reaches it today (all nine route through ``reconcile_budget_config`` with
53
+ ``conn=ctx.conn``; only ``bin/cctally-reconcile-test`` calls it directly),
54
+ but it is one careless caller away from being real, and the whole body sits
55
+ inside a best-effort ``except Exception`` that would swallow the authorizer's
56
+ denial into a log line — a silently-dropped milestone, not a loud failure.
57
+
58
+ Rather than delete a branch a live harness depends on, the branch is ROUTED:
59
+ it becomes a genuine spec §3.1 administrative writer, holding
60
+ ``stats.db.maintenance.lock`` exclusive (re-entrant — a caller that already
61
+ owns it is not made to wait on itself) for the duration and declaring the
62
+ sanctioned scope the authorizer checks. Self-sanctioning WITHOUT the lock
63
+ was the option rejected: it would have made the guard agree with a writer
64
+ that is still unserialized.
65
+ """
66
+ import _cctally_store
67
+
68
+ return _cctally_store.stats_open_time_guard()
69
+
70
+
71
+
43
72
  @dataclass
44
73
  class WeekCostResult:
45
74
  week_start: dt.date
@@ -59,9 +88,17 @@ def compute_week_cost(
59
88
  end_iso_override: str | None = None,
60
89
  *,
61
90
  account_key: "str | None" = None,
91
+ as_of: "str | None" = None,
62
92
  ) -> WeekCostResult:
93
+ # `as_of` (#372 Task B) makes historical rederivation range-bounded by the
94
+ # retained triggering record instead of leaking later cache rows through the
95
+ # host wall clock. Normal callers omit it and retain the existing behavior.
63
96
  # internal fallback: host-local intentional
64
- now_local = dt.datetime.now().astimezone()
97
+ now_local = (
98
+ parse_iso_datetime(as_of, "asOf").astimezone()
99
+ if as_of is not None
100
+ else dt.datetime.now().astimezone()
101
+ )
65
102
  start_dt_override = (
66
103
  parse_iso_datetime(start_iso_override, "weekStartAt")
67
104
  if start_iso_override
@@ -82,22 +119,35 @@ def compute_week_cost(
82
119
  start_iso = format_local_iso(week_start, end_of_day=False)
83
120
 
84
121
  if end_dt_override is not None:
85
- in_current_window = (
86
- start_dt_override is not None
87
- and start_dt_override <= now_local < end_dt_override
88
- )
89
- end_iso = (
90
- now_local.isoformat(timespec="seconds")
91
- if in_current_window
92
- else end_dt_override.isoformat(timespec="seconds")
93
- )
122
+ if as_of is not None:
123
+ # A historical custom sync may name a window that starts after the
124
+ # op. The retained clock is an unconditional upper bound.
125
+ end_iso = min(now_local, end_dt_override).isoformat(
126
+ timespec="seconds"
127
+ )
128
+ else:
129
+ in_current_window = (
130
+ start_dt_override is not None
131
+ and start_dt_override <= now_local < end_dt_override
132
+ )
133
+ end_iso = (
134
+ now_local.isoformat(timespec="seconds")
135
+ if in_current_window
136
+ else end_dt_override.isoformat(timespec="seconds")
137
+ )
94
138
  else:
95
- is_current_week = week_start <= now_local.date() <= week_end
96
- end_iso = (
97
- now_local.isoformat(timespec="seconds")
98
- if is_current_week
99
- else format_local_iso(week_end, end_of_day=True)
139
+ declared_end = parse_iso_datetime(
140
+ format_local_iso(week_end, end_of_day=True), "weekEnd"
100
141
  )
142
+ if as_of is not None:
143
+ end_iso = min(now_local, declared_end).isoformat(timespec="seconds")
144
+ else:
145
+ is_current_week = week_start <= now_local.date() <= week_end
146
+ end_iso = (
147
+ now_local.isoformat(timespec="seconds")
148
+ if is_current_week
149
+ else declared_end.isoformat(timespec="seconds")
150
+ )
101
151
 
102
152
  start_dt = parse_iso_datetime(start_iso, "start")
103
153
  end_dt = parse_iso_datetime(end_iso, "end")
@@ -857,32 +907,38 @@ def _reconcile_codex_budget_on_config_write(validated_budget, *, conn=None, as_o
857
907
  config = c.load_config()
858
908
  tz = c.resolve_display_tz(argparse.Namespace(tz=None), config)
859
909
  now_utc = _as_of_or_command(as_of)
860
- if own_conn:
861
- conn = open_db()
862
- try:
863
- ladders = []
864
- if amount_usd is not None:
865
- ladders.append(("*", amount_usd))
866
- ladders.extend((k, v) for k, v in accounts.items())
867
- for acct_key, acct_usd in ladders:
868
- _reconcile_budget_milestones_on_set(
869
- conn,
870
- vendor="codex",
871
- target=acct_usd,
872
- thresholds=thresholds,
873
- now_utc=now_utc,
874
- period=codex["period"],
875
- config=config,
876
- tz=tz,
877
- as_of=as_of,
878
- commit=False,
879
- account_key=acct_key,
880
- )
881
- if own_conn:
882
- conn.commit()
883
- finally:
910
+ # #386 R1-RESIDUAL: an own_conn write is an ADMINISTRATIVE stats
911
+ # writer — outside the ingest cycle and, before this, outside every
912
+ # lock. It now takes the maintenance lock + the sanctioned scope.
913
+ # See _own_conn_stats_guard.
914
+ with (_own_conn_stats_guard() if own_conn
915
+ else contextlib.nullcontext()):
884
916
  if own_conn:
885
- conn.close()
917
+ conn = open_db()
918
+ try:
919
+ ladders = []
920
+ if amount_usd is not None:
921
+ ladders.append(("*", amount_usd))
922
+ ladders.extend((k, v) for k, v in accounts.items())
923
+ for acct_key, acct_usd in ladders:
924
+ _reconcile_budget_milestones_on_set(
925
+ conn,
926
+ vendor="codex",
927
+ target=acct_usd,
928
+ thresholds=thresholds,
929
+ now_utc=now_utc,
930
+ period=codex["period"],
931
+ config=config,
932
+ tz=tz,
933
+ as_of=as_of,
934
+ commit=False,
935
+ account_key=acct_key,
936
+ )
937
+ if own_conn:
938
+ conn.commit()
939
+ finally:
940
+ if own_conn:
941
+ conn.close()
886
942
  except Exception as exc: # best-effort; never fail the write
887
943
  eprint(f"[codex-budget-milestone] reconcile on set failed: {exc}")
888
944
 
@@ -915,32 +971,38 @@ def _reconcile_budget_on_config_write(validated_budget, *, conn=None, as_of=None
915
971
  config = c.load_config()
916
972
  tz = c.resolve_display_tz(argparse.Namespace(tz=None), config)
917
973
  now_utc = _as_of_or_command(as_of)
918
- if own_conn:
919
- conn = open_db()
920
- try:
921
- ladders = []
922
- if weekly_usd is not None:
923
- ladders.append(("*", weekly_usd))
924
- ladders.extend((k, v) for k, v in accounts.items())
925
- for acct_key, acct_usd in ladders:
926
- _reconcile_budget_milestones_on_set(
927
- conn,
928
- vendor="claude",
929
- target=acct_usd,
930
- thresholds=thresholds,
931
- now_utc=now_utc,
932
- period=period,
933
- config=config,
934
- tz=tz,
935
- as_of=as_of,
936
- commit=False,
937
- account_key=acct_key,
938
- )
939
- if own_conn:
940
- conn.commit()
941
- finally:
974
+ # #386 R1-RESIDUAL: an own_conn write is an ADMINISTRATIVE stats
975
+ # writer — outside the ingest cycle and, before this, outside every
976
+ # lock. It now takes the maintenance lock + the sanctioned scope.
977
+ # See _own_conn_stats_guard.
978
+ with (_own_conn_stats_guard() if own_conn
979
+ else contextlib.nullcontext()):
942
980
  if own_conn:
943
- conn.close()
981
+ conn = open_db()
982
+ try:
983
+ ladders = []
984
+ if weekly_usd is not None:
985
+ ladders.append(("*", weekly_usd))
986
+ ladders.extend((k, v) for k, v in accounts.items())
987
+ for acct_key, acct_usd in ladders:
988
+ _reconcile_budget_milestones_on_set(
989
+ conn,
990
+ vendor="claude",
991
+ target=acct_usd,
992
+ thresholds=thresholds,
993
+ now_utc=now_utc,
994
+ period=period,
995
+ config=config,
996
+ tz=tz,
997
+ as_of=as_of,
998
+ commit=False,
999
+ account_key=acct_key,
1000
+ )
1001
+ if own_conn:
1002
+ conn.commit()
1003
+ finally:
1004
+ if own_conn:
1005
+ conn.close()
944
1006
  except Exception as exc: # best-effort; never fail the write
945
1007
  eprint(f"[budget-milestone] reconcile on set failed: {exc}")
946
1008
 
@@ -1018,53 +1080,59 @@ def _reconcile_project_budget_milestones_on_write(
1018
1080
  c = _cctally()
1019
1081
  own_conn = conn is None
1020
1082
  try:
1021
- if own_conn:
1022
- conn = open_db()
1023
- try:
1024
- now_utc = _as_of_or_command(as_of)
1025
- window = c._resolve_current_budget_window(conn, now_utc)
1026
- if window is None:
1027
- return
1028
- week_start_at, _week_end_at = window
1029
- week_key = week_start_at.isoformat(timespec="seconds")
1030
- by_proj = c._sum_cost_by_project(week_start_at, now_utc, mode="auto")
1031
- items = (
1032
- projects.items()
1033
- if touched_projects is None
1034
- else [
1035
- (k, v) for k, v in projects.items()
1036
- if k in touched_projects
1037
- ]
1038
- )
1039
- # Same crossing arithmetic as firing, via the shared generator
1040
- # (#130). Reconcile differs ONLY in the tail: UPDATE alerted_at with
1041
- # NO dispatch (retroactive-storm suppression). `items` already
1042
- # honors touched_projects filtering above.
1043
- for project_key, t, spent, target, consumption_pct in c._project_crossings(
1044
- items, thresholds, by_proj
1045
- ):
1046
- insert_project_budget_milestone(
1047
- conn,
1048
- week_start_at=week_key,
1049
- project_key=project_key,
1050
- threshold=t,
1051
- budget_usd=target,
1052
- spent_usd=spent,
1053
- consumption_pct=consumption_pct,
1054
- commit=False,
1055
- as_of=as_of,
1056
- )
1057
- conn.execute(
1058
- "UPDATE project_budget_milestones SET alerted_at = ? "
1059
- "WHERE week_start_at = ? AND project_key = ? "
1060
- " AND threshold = ? AND alerted_at IS NULL",
1061
- (as_of or now_utc_iso(), week_key, project_key, t),
1062
- )
1083
+ # #386 R1-RESIDUAL: an own_conn write is an ADMINISTRATIVE stats
1084
+ # writer — outside the ingest cycle and, before this, outside every
1085
+ # lock. It now takes the maintenance lock + the sanctioned scope.
1086
+ # See _own_conn_stats_guard.
1087
+ with (_own_conn_stats_guard() if own_conn
1088
+ else contextlib.nullcontext()):
1063
1089
  if own_conn:
1064
- conn.commit()
1065
- finally:
1066
- if own_conn:
1067
- conn.close()
1090
+ conn = open_db()
1091
+ try:
1092
+ now_utc = _as_of_or_command(as_of)
1093
+ window = c._resolve_current_budget_window(conn, now_utc)
1094
+ if window is None:
1095
+ return
1096
+ week_start_at, _week_end_at = window
1097
+ week_key = week_start_at.isoformat(timespec="seconds")
1098
+ by_proj = c._sum_cost_by_project(week_start_at, now_utc, mode="auto")
1099
+ items = (
1100
+ projects.items()
1101
+ if touched_projects is None
1102
+ else [
1103
+ (k, v) for k, v in projects.items()
1104
+ if k in touched_projects
1105
+ ]
1106
+ )
1107
+ # Same crossing arithmetic as firing, via the shared generator
1108
+ # (#130). Reconcile differs ONLY in the tail: UPDATE alerted_at with
1109
+ # NO dispatch (retroactive-storm suppression). `items` already
1110
+ # honors touched_projects filtering above.
1111
+ for project_key, t, spent, target, consumption_pct in c._project_crossings(
1112
+ items, thresholds, by_proj
1113
+ ):
1114
+ insert_project_budget_milestone(
1115
+ conn,
1116
+ week_start_at=week_key,
1117
+ project_key=project_key,
1118
+ threshold=t,
1119
+ budget_usd=target,
1120
+ spent_usd=spent,
1121
+ consumption_pct=consumption_pct,
1122
+ commit=False,
1123
+ as_of=as_of,
1124
+ )
1125
+ conn.execute(
1126
+ "UPDATE project_budget_milestones SET alerted_at = ? "
1127
+ "WHERE week_start_at = ? AND project_key = ? "
1128
+ " AND threshold = ? AND alerted_at IS NULL",
1129
+ (as_of or now_utc_iso(), week_key, project_key, t),
1130
+ )
1131
+ if own_conn:
1132
+ conn.commit()
1133
+ finally:
1134
+ if own_conn:
1135
+ conn.close()
1068
1136
  except Exception as exc: # best-effort; never fail the write
1069
1137
  eprint(
1070
1138
  f"[project-budget-milestone] reconcile on write failed: {exc}"
@@ -2951,6 +2951,48 @@ def _build_db_parser(subparsers, name, *, help_text, xref=None):
2951
2951
  help="Emit JSON to stdout",
2952
2952
  )
2953
2953
  db_rebuild.set_defaults(func=c.cmd_db_rebuild)
2954
+ db_rederive = db_sub.add_parser(
2955
+ "rederive",
2956
+ help="Preview or apply an append-only correction plan from retained truth",
2957
+ )
2958
+ db_rederive.add_argument(
2959
+ "--family",
2960
+ required=True,
2961
+ help="Closed derivation family to re-run (initially: claude-usage)",
2962
+ )
2963
+ db_rederive.add_argument(
2964
+ "--yes",
2965
+ action="store_true",
2966
+ help="Append the correction batch and rebuild stats.db (default: preview)",
2967
+ )
2968
+ db_rederive.add_argument(
2969
+ "--json",
2970
+ action="store_true",
2971
+ help="Emit schemaVersion 1 JSON to stdout",
2972
+ )
2973
+ db_rederive.set_defaults(func=c.cmd_db_rederive)
2974
+ db_journal_repair = db_sub.add_parser(
2975
+ "journal-repair",
2976
+ help="Preview or acknowledge exact structural journal violations",
2977
+ )
2978
+ db_journal_repair.add_argument(
2979
+ "--violation",
2980
+ action="append",
2981
+ default=[],
2982
+ metavar="FINGERPRINT",
2983
+ help="Exact violation fingerprint to acknowledge (repeatable)",
2984
+ )
2985
+ db_journal_repair.add_argument(
2986
+ "--yes",
2987
+ action="store_true",
2988
+ help="Append the audit decision and rebuild stats.db (default: preview)",
2989
+ )
2990
+ db_journal_repair.add_argument(
2991
+ "--json",
2992
+ action="store_true",
2993
+ help="Emit schemaVersion 1 JSON to stdout",
2994
+ )
2995
+ db_journal_repair.set_defaults(func=c.cmd_db_journal_repair)
2954
2996
  db_repair = db_sub.add_parser(
2955
2997
  "repair",
2956
2998
  help="Recover a malformed pre-cutover stats.db through a verified fresh "
@@ -135,12 +135,14 @@ def _sum_cost_by_project(
135
135
  continue
136
136
  cost = c._calculate_entry_cost(
137
137
  entry.model,
138
- {
139
- "input_tokens": entry.input_tokens,
140
- "output_tokens": entry.output_tokens,
141
- "cache_creation_input_tokens": entry.cache_creation_tokens,
142
- "cache_read_input_tokens": entry.cache_read_tokens,
143
- },
138
+ c.claude_usage_dict( # #195 chokepoint
139
+ input_tokens=entry.input_tokens,
140
+ output_tokens=entry.output_tokens,
141
+ cache_creation_tokens=entry.cache_creation_tokens,
142
+ cache_read_tokens=entry.cache_read_tokens,
143
+ cache_1h_tokens=getattr(entry, "cache_1h_tokens", None),
144
+ speed=getattr(entry, "speed", None),
145
+ ),
144
146
  mode=mode,
145
147
  cost_usd=entry.cost_usd,
146
148
  )
@@ -208,12 +210,14 @@ def _accumulate_entry_into_bucket(
208
210
  else:
209
211
  cost = c._calculate_entry_cost(
210
212
  entry.model,
211
- {
212
- "input_tokens": entry.input_tokens,
213
- "output_tokens": entry.output_tokens,
214
- "cache_creation_input_tokens": entry.cache_creation_tokens,
215
- "cache_read_input_tokens": entry.cache_read_tokens,
216
- },
213
+ c.claude_usage_dict( # #195 chokepoint
214
+ input_tokens=entry.input_tokens,
215
+ output_tokens=entry.output_tokens,
216
+ cache_creation_tokens=entry.cache_creation_tokens,
217
+ cache_read_tokens=entry.cache_read_tokens,
218
+ cache_1h_tokens=getattr(entry, "cache_1h_tokens", None),
219
+ speed=getattr(entry, "speed", None),
220
+ ),
217
221
  mode="auto",
218
222
  cost_usd=entry.cost_usd,
219
223
  )
@@ -555,12 +559,14 @@ def cmd_project(args: argparse.Namespace) -> int:
555
559
 
556
560
  entry_cost = c._calculate_entry_cost(
557
561
  entry.model,
558
- {
559
- "input_tokens": entry.input_tokens,
560
- "output_tokens": entry.output_tokens,
561
- "cache_creation_input_tokens": entry.cache_creation_tokens,
562
- "cache_read_input_tokens": entry.cache_read_tokens,
563
- },
562
+ c.claude_usage_dict( # #195 chokepoint
563
+ input_tokens=entry.input_tokens,
564
+ output_tokens=entry.output_tokens,
565
+ cache_creation_tokens=entry.cache_creation_tokens,
566
+ cache_read_tokens=entry.cache_read_tokens,
567
+ cache_1h_tokens=getattr(entry, "cache_1h_tokens", None),
568
+ speed=getattr(entry, "speed", None),
569
+ ),
564
570
  mode="auto",
565
571
  cost_usd=entry.cost_usd,
566
572
  )