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
@@ -81,7 +81,7 @@ def _cmd_transcript_export(args) -> int:
81
81
 
82
82
  if session_id.startswith("v1."):
83
83
  return _cmd_transcript_export_qualified(
84
- c, session_id, scope, raw, output, speed_arg)
84
+ c, args, session_id, scope, raw, output, speed_arg)
85
85
 
86
86
  # Legacy bare Claude path — byte-untouched. --speed is Codex pricing behavior,
87
87
  # so an explicit value on any non-Codex ref is a usage error (resolved-source
@@ -90,8 +90,16 @@ def _cmd_transcript_export(args) -> int:
90
90
  eprint(_SPEED_ONLY_CODEX_MSG)
91
91
  return 2
92
92
 
93
+ account_key, account_error = c.resolve_account_filter(
94
+ args, "claude", needs_cache=True
95
+ )
96
+ if account_error is not None:
97
+ return account_error
98
+
93
99
  conn = c.open_conversations_db()
94
100
  try:
101
+ if account_key is not None:
102
+ c.scope_conversations_db_to_account(conn, account_key)
95
103
  cq = c._load_sibling("_lib_conversation_query")
96
104
  md = cq.get_conversation_export(conn, session_id, scope)
97
105
  if md is None:
@@ -109,7 +117,7 @@ def _cmd_transcript_export(args) -> int:
109
117
 
110
118
 
111
119
  def _cmd_transcript_export_qualified(
112
- c, session_id, scope, raw, output, speed_arg) -> int:
120
+ c, args, session_id, scope, raw, output, speed_arg) -> int:
113
121
  """Qualified (``v1.``) export via the neutral dispatch layer (§4.1). Anonymized
114
122
  by default with the QUALIFIED provider-aware plan (§3.6); ``--raw`` escapes.
115
123
  Byte-matches ``GET /api/conversation/<v1key>/export`` in both modes."""
@@ -121,9 +129,17 @@ def _cmd_transcript_export_qualified(
121
129
  eprint(_SPEED_ONLY_CODEX_MSG)
122
130
  return 2
123
131
  speed = c._resolve_codex_speed(speed_arg or "auto")
132
+ provider = cref.source if cref is not None else "claude"
133
+ account_key, account_error = c.resolve_account_filter(
134
+ args, provider, needs_cache=True
135
+ )
136
+ if account_error is not None:
137
+ return account_error
124
138
 
125
139
  conn = c.open_conversations_db()
126
140
  try:
141
+ if account_key is not None:
142
+ c.scope_conversations_db_to_account(conn, account_key)
127
143
  env = disp.neutral_export(
128
144
  conn, session_id, scope=scope, effective_speed=speed)
129
145
  status = env.get("status")
@@ -176,6 +192,11 @@ def _cmd_transcript_search_claude(args) -> int:
176
192
  cost_min = getattr(args, "cost_min", None)
177
193
  cost_max = getattr(args, "cost_max", None)
178
194
  rebuild_min = getattr(args, "rebuild_min", None)
195
+ account_key, account_error = c.resolve_account_filter(
196
+ args, "claude", needs_cache=True
197
+ )
198
+ if account_error is not None:
199
+ return account_error
179
200
 
180
201
  # Date-only bounds parse through the SAME display-tz-aware helper the HTTP
181
202
  # filter handler uses (no second parser) — reuse, don't reimplement.
@@ -195,6 +216,8 @@ def _cmd_transcript_search_claude(args) -> int:
195
216
 
196
217
  conn = c.open_conversations_db()
197
218
  try:
219
+ if account_key is not None:
220
+ c.scope_conversations_db_to_account(conn, account_key)
198
221
  cq = c._load_sibling("_lib_conversation_query")
199
222
  try:
200
223
  result = cq.search_conversations(
@@ -233,6 +256,11 @@ def _cmd_transcript_search_codex(args) -> int:
233
256
  limit = getattr(args, "limit", 50)
234
257
  cursor = getattr(args, "cursor", None)
235
258
  as_json = bool(getattr(args, "json", False))
259
+ account_key, account_error = c.resolve_account_filter(
260
+ args, "codex", needs_cache=True
261
+ )
262
+ if account_error is not None:
263
+ return account_error
236
264
 
237
265
  # Pagination + filter axes the Codex kernel does not have → exit 2 (silently
238
266
  # ignoring a filter would fabricate results).
@@ -271,6 +299,8 @@ def _cmd_transcript_search_codex(args) -> int:
271
299
 
272
300
  conn = c.open_conversations_db()
273
301
  try:
302
+ if account_key is not None:
303
+ c.scope_conversations_db_to_account(conn, account_key)
274
304
  result = disp.neutral_search(
275
305
  conn, query, source="codex", kind=kind,
276
306
  effective_speed=c._resolve_codex_speed("auto"),
@@ -2966,7 +2966,22 @@ def _tui_build_snapshot(
2966
2966
  # ``_tui_build_snapshot_once`` closes its live stats connection before
2967
2967
  # this boundary. The replacement-capable hook can therefore satisfy
2968
2968
  # the whole-family drain gate, and the retry opens the published family.
2969
- _tui_heal_post_query_stats(fault.cause)
2969
+ #
2970
+ # #496 S3 §6: the heal now DEFERS, and its signal is a
2971
+ # `BaseException`. This call sits before the retry's own `except`, so
2972
+ # the shared deferral base is caught AT the heal-call boundary and the
2973
+ # corruption-typed degraded frame is built directly. The retry must not
2974
+ # spin: after deferral the index is still corrupt, so a second attempt
2975
+ # would only fail again, and the frame converges on a later tick.
2976
+ try:
2977
+ _tui_heal_post_query_stats(fault.cause)
2978
+ except _cctally().StatsRebuildDeferred as deferred:
2979
+ return _tui_stats_retry_degraded_snapshot(
2980
+ now_utc=now_utc or dt.datetime.now(dt.timezone.utc),
2981
+ exc=deferred,
2982
+ precompute_envelope=precompute_envelope,
2983
+ runtime_bind=runtime_bind,
2984
+ )
2970
2985
  try:
2971
2986
  return _tui_build_snapshot_once(
2972
2987
  now_utc=now_utc,
@@ -3279,6 +3294,8 @@ def _tui_build_snapshot_once(
3279
3294
  runtime_bind=runtime_bind, raw_config=raw_config,
3280
3295
  display_tz_pref_override=display_tz_pref_override,
3281
3296
  source_stats_conn=conn,
3297
+ failures=sync_failures,
3298
+ stats_heal_attempted=stats_heal_attempted,
3282
3299
  source_display_tz_name=(
3283
3300
  getattr(_build_display_tz, "key", None)
3284
3301
  if _build_display_tz is not None else None
@@ -4068,7 +4085,9 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
4068
4085
  codex_ingest_contended=False,
4069
4086
  codex_ingest_failed=False,
4070
4087
  claude_ingest_contended=False,
4071
- claude_ingest_failed=False):
4088
+ claude_ingest_failed=False,
4089
+ failures=None,
4090
+ stats_heal_attempted=False):
4072
4091
  """Fresh snapshot reusing ``prior``'s heavy rows, re-patching only the
4073
4092
  time-derived fields + the doctor payload / envelope precompute on each idle
4074
4093
  tick (spec §3 idle path).
@@ -4108,6 +4127,7 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
4108
4127
  envelope_precompute = _tui_precompute_envelope_config(raw_config)
4109
4128
  except Exception as exc: # noqa: BLE001 — never crash the rebuild
4110
4129
  errors.append(f"envelope-precompute: {exc}")
4130
+ idle_failures = failures if failures is not None else []
4111
4131
  source_bundle = prior.source_bundle
4112
4132
  if source_bundle is not None:
4113
4133
  try:
@@ -4180,15 +4200,36 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
4180
4200
  prior_bundle=source_bundle,
4181
4201
  raw_config=raw_config,
4182
4202
  )
4203
+ except _StatsSnapshotCorruption:
4204
+ raise
4183
4205
  except Exception as exc: # noqa: BLE001 — retain prior complete bundle
4184
- errors.append(f"source-clock-refresh: {exc}")
4206
+ # #496 S3 §8 (F16). This branch read stats through
4207
+ # `source_stats_conn` and swallowed the failure into a plain
4208
+ # string, so it built no `SyncFailureAttribution`, never raised
4209
+ # `_StatsSnapshotCorruption`, and never reached the heal — and
4210
+ # `_sync_failure_envelope`, finding no typed attribution, fell to
4211
+ # its raw-text matcher and told the user to run
4212
+ # `cctally cache-sync --rebuild` for a STATS fault. Classify it at
4213
+ # the catch site instead, against the connection that faulted.
4214
+ if source_stats_conn is not None:
4215
+ _tui_capture_sync_failure(
4216
+ source_stats_conn,
4217
+ errors,
4218
+ idle_failures,
4219
+ leg="source-clock-refresh",
4220
+ database="stats_or_cache",
4221
+ exc=exc,
4222
+ stats_heal_attempted=stats_heal_attempted,
4223
+ )
4224
+ else:
4225
+ errors.append(f"source-clock-refresh: {exc}")
4185
4226
  source_bundle = prior.source_bundle
4186
4227
  return dataclasses.replace(
4187
4228
  prior,
4188
4229
  generated_at=now_utc,
4189
4230
  last_sync_at=time.monotonic(),
4190
4231
  last_sync_error=("; ".join(errors) if errors else None),
4191
- sync_failures=(),
4232
+ sync_failures=tuple(idle_failures),
4192
4233
  doctor_payload=doctor_payload,
4193
4234
  envelope_precompute=envelope_precompute,
4194
4235
  source_bundle=source_bundle,
@@ -6611,12 +6652,17 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
6611
6652
  snap = dataclasses.replace(snap, last_sync_at=None, hydrating=False)
6612
6653
  ref.set(snap)
6613
6654
  hub.publish(snap)
6614
- except _cctally().StatsEpochRebuildDeferred as exc:
6655
+ except _cctally().StatsRebuildDeferred as exc:
6615
6656
  # #453: the first periodic tick runs before HTTP bind. Preserve the
6616
6657
  # initial hydrating/degraded frame while the dedicated replay owns
6617
6658
  # stats maintenance; a generic crash frame would clear the latch
6618
6659
  # before any client could observe it. The loop retries normally on
6619
6660
  # its next cadence and publishes a full frame after convergence.
6661
+ #
6662
+ # #496 S3: the two deferral classes must NOT flatten here. A wrong
6663
+ # EPOCH is a readable index (`corruption=False`); a deferred heal is
6664
+ # an index that could not be read, and reporting that as
6665
+ # non-corruption makes the envelope name the wrong fault.
6620
6666
  prev = ref.get()
6621
6667
  pending = dataclasses.replace(
6622
6668
  prev,
@@ -6625,7 +6671,9 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
6625
6671
  SyncFailureAttribution(
6626
6672
  leg="stats-open",
6627
6673
  database="stats",
6628
- corruption=False,
6674
+ corruption=isinstance(
6675
+ exc, _cctally().StatsHealDeferred
6676
+ ),
6629
6677
  ),
6630
6678
  ),
6631
6679
  generated_at=dt.datetime.now(dt.timezone.utc),
@@ -114,6 +114,11 @@ DEFAULT_TIERED_THRESHOLD = 200_000
114
114
  CACHE_REPORT_MIN_BASELINE_DAYS = 5
115
115
  CACHE_REPORT_MIN_BASELINE_SESSIONS = 10
116
116
 
117
+ # Monetary comparisons elsewhere in cctally reconcile within 1e-9 USD. Keep
118
+ # an exactly-cancelling cache row inside that same tolerance so a value that
119
+ # renders as zero cannot raise a non-actionable ``net_negative`` anomaly.
120
+ CACHE_REPORT_NET_NEGATIVE_TOLERANCE_USD = 1e-9
121
+
117
122
 
118
123
  # Anomaly-threshold bounds and default. #443 S3 F17: four sites used to
119
124
  # decide independently what `cache_report.anomaly_threshold_pp` means —
@@ -857,8 +862,8 @@ def _classify_anomalies(
857
862
  ) -> None:
858
863
  """Mutate each row's ``anomaly_triggered`` / ``anomaly_reasons`` in place.
859
864
 
860
- Trigger 1 (``net_negative``): ``net_usd < 0`` (strict). Skipped when the
861
- row has zero cache activity (no-op session, not a bug).
865
+ Trigger 1 (``net_negative``): ``net_usd < -1e-9``. Skipped when the row
866
+ has zero cache activity (no-op session, not a bug).
862
867
 
863
868
  Trigger 2 (``cache_drop``): ``cache_hit_percent`` is ``>= threshold_pp``
864
869
  below the trailing ``window_days`` median of OTHER rows. Requires
@@ -901,7 +906,7 @@ def _classify_anomalies(
901
906
 
902
907
  # Trigger 1: net_negative (no baseline needed; cache-activity guard).
903
908
  if row.cache_creation_tokens + row.cache_read_tokens > 0:
904
- if row.net_usd < 0:
909
+ if row.net_usd < -CACHE_REPORT_NET_NEGATIVE_TOLERANCE_USD:
905
910
  reasons.append("net_negative")
906
911
  else:
907
912
  unevaluated.append("net_negative")