cctally 1.65.0 → 1.67.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 (54) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +6 -4
  3. package/bin/_cctally_alerts.py +1 -1
  4. package/bin/_cctally_cache.py +203 -22
  5. package/bin/_cctally_cache_report.py +7 -5
  6. package/bin/_cctally_core.py +125 -14
  7. package/bin/_cctally_dashboard.py +495 -4893
  8. package/bin/_cctally_dashboard_cache_report.py +714 -0
  9. package/bin/_cctally_dashboard_conversation.py +830 -0
  10. package/bin/_cctally_dashboard_envelope.py +1520 -0
  11. package/bin/_cctally_dashboard_share.py +2012 -0
  12. package/bin/_cctally_db.py +158 -5
  13. package/bin/_cctally_doctor.py +104 -3
  14. package/bin/_cctally_five_hour.py +19 -3
  15. package/bin/_cctally_forecast.py +118 -161
  16. package/bin/_cctally_parser.py +1003 -777
  17. package/bin/_cctally_percent_breakdown.py +1 -1
  18. package/bin/_cctally_pricing_check.py +39 -0
  19. package/bin/_cctally_project.py +18 -42
  20. package/bin/_cctally_record.py +279 -274
  21. package/bin/_cctally_reporting.py +1 -1
  22. package/bin/_cctally_setup.py +150 -43
  23. package/bin/_cctally_sync_week.py +1 -1
  24. package/bin/_cctally_telemetry.py +3 -5
  25. package/bin/_cctally_transcript.py +191 -0
  26. package/bin/_cctally_tui.py +42 -18
  27. package/bin/_lib_alert_dispatch.py +1 -1
  28. package/bin/_lib_blocks.py +6 -1
  29. package/bin/_lib_conversation_anon.py +254 -0
  30. package/bin/_lib_conversation_query.py +66 -0
  31. package/bin/_lib_credit.py +133 -0
  32. package/bin/_lib_diff_kernel.py +19 -7
  33. package/bin/_lib_doctor.py +150 -1
  34. package/bin/_lib_five_hour.py +61 -0
  35. package/bin/_lib_forecast.py +144 -0
  36. package/bin/_lib_json_envelope.py +38 -0
  37. package/bin/_lib_jsonl.py +115 -73
  38. package/bin/_lib_log.py +96 -0
  39. package/bin/_lib_pricing.py +47 -1
  40. package/bin/_lib_pricing_check.py +25 -3
  41. package/bin/_lib_record.py +179 -0
  42. package/bin/_lib_render.py +26 -11
  43. package/bin/_lib_snapshot_cache.py +61 -0
  44. package/bin/_lib_view_models.py +7 -1
  45. package/bin/cctally +59 -7
  46. package/bin/cctally-dashboard +2 -2
  47. package/bin/cctally-statusline +1 -0
  48. package/bin/cctally-transcript +5 -0
  49. package/bin/cctally-tui +2 -2
  50. package/dashboard/static/assets/index-BybNp_Di.js +80 -0
  51. package/dashboard/static/assets/{index-DQWNrIqu.css → index-DgAmLK65.css} +1 -1
  52. package/dashboard/static/dashboard.html +2 -2
  53. package/package.json +13 -1
  54. package/dashboard/static/assets/index-CJTUCpKt.js +0 -80
@@ -149,6 +149,20 @@ def _init_paths_from_env() -> None:
149
149
  CLAUDE_PROJECTS_DIR = home / ".claude" / "projects"
150
150
 
151
151
 
152
+ def _truthy_env(name: str) -> bool:
153
+ """A ``1``/``true``/``yes``/any-other-non-empty env value is truthy;
154
+ unset, empty, ``0``, ``false``, ``no`` are falsey (case-insensitive,
155
+ whitespace-stripped).
156
+
157
+ Canonical home for boolean env-flag parsing (#279 S1 F1) — presence-only
158
+ ``os.environ.get(...)`` checks made ``FLAG=0`` mean *enabled*, which for
159
+ ``CCTALLY_ALLOW_PROD_MIGRATION`` / ``CCTALLY_DISABLE_DEV_AUTODETECT`` was
160
+ the exact opposite of intent. ``_cctally_telemetry._truthy_env`` delegates
161
+ here."""
162
+ v = os.environ.get(name)
163
+ return v is not None and v.strip().lower() not in ("", "0", "false", "no")
164
+
165
+
152
166
  def _repo_root() -> pathlib.Path:
153
167
  """Repo root when running from a source checkout: this file lives at
154
168
  ``<repo>/bin/_cctally_core.py``, so the root is two parents up. Factored
@@ -165,7 +179,7 @@ def _is_dev_checkout() -> bool:
165
179
  the ``setup`` guard (which protects WHICH BINARY gets wired into
166
180
  ~/.claude/settings.json), not the data-dir relocation. The npm/brew
167
181
  install copies ship without ``.git`` so they never read True."""
168
- if os.environ.get("CCTALLY_DISABLE_DEV_AUTODETECT"):
182
+ if _truthy_env("CCTALLY_DISABLE_DEV_AUTODETECT"):
169
183
  return False
170
184
  return (_repo_root() / ".git").exists()
171
185
 
@@ -913,19 +927,40 @@ def open_db() -> sqlite3.Connection:
913
927
  ensure_dirs()
914
928
  conn = sqlite3.connect(DB_PATH)
915
929
  conn.row_factory = sqlite3.Row
916
- conn.execute("PRAGMA journal_mode=WAL")
917
- conn.execute("PRAGMA synchronous=NORMAL")
918
- # Explicit for intent + symmetry with open_cache_db (bin/_cctally_cache.py).
919
- # sqlite3.connect()'s default timeout=5.0 ALREADY maps to busy_timeout=5000,
920
- # so this is not a behavior change it makes the multi-writer retry window
921
- # an explicit contract beside the WAL pragmas instead of an inherited
922
- # default a reader has to know about. NOTE: busy_timeout does NOT absorb
923
- # SQLITE_BUSY_SNAPSHOT (a WAL read-then-write transaction whose snapshot is
924
- # invalidated by a competing commit raises "database is locked" instantly,
925
- # bypassing the busy handler). The write paths defend against that by taking
926
- # the write lock up front — BEGIN IMMEDIATE, or a write as the transaction's
927
- # first DML. See cctally-dev#87.
928
- conn.execute("PRAGMA busy_timeout=5000")
930
+ # #279 S1 F4: probe connect + initial PRAGMAs so a corrupt stats.db (the
931
+ # non-re-derivable DB) surfaces as a one-line diagnosis + exit 2 instead of
932
+ # a raw traceback. The catch boundary is DELIBERATELY narrow — ONLY the
933
+ # connect/PRAGMA/probe below. The DDL + `_run_pending_migrations` region
934
+ # further down is NOT wrapped: migration-handler failures have their own
935
+ # logging/suppression contract and must not be mislabeled as corruption
936
+ # (corruption that only surfaces mid-DDL stays a raw error; the probe
937
+ # catches the common case).
938
+ try:
939
+ conn.execute("PRAGMA journal_mode=WAL")
940
+ conn.execute("PRAGMA synchronous=NORMAL")
941
+ # Explicit for intent + symmetry with open_cache_db (bin/_cctally_cache.py).
942
+ # sqlite3.connect()'s default timeout=5.0 ALREADY maps to busy_timeout=5000,
943
+ # so this is not a behavior change — it makes the multi-writer retry window
944
+ # an explicit contract beside the WAL pragmas instead of an inherited
945
+ # default a reader has to know about. NOTE: busy_timeout does NOT absorb
946
+ # SQLITE_BUSY_SNAPSHOT (a WAL read-then-write transaction whose snapshot is
947
+ # invalidated by a competing commit raises "database is locked" instantly,
948
+ # bypassing the busy handler). The write paths defend against that by taking
949
+ # the write lock up front — BEGIN IMMEDIATE, or a write as the transaction's
950
+ # first DML. See cctally-dev#87.
951
+ conn.execute("PRAGMA busy_timeout=5000")
952
+ conn.execute("SELECT 1").fetchone()
953
+ except sqlite3.DatabaseError as exc:
954
+ try:
955
+ conn.close()
956
+ except Exception:
957
+ pass
958
+ raise c.StatsDbCorruptError(
959
+ f"stats.db appears corrupt or unreadable ({exc}). path: {DB_PATH}. "
960
+ f"Not auto-recreated — it holds your recorded usage history. "
961
+ f'Recovery: back up the file, then try `sqlite3 "{DB_PATH}" ".recover"`, '
962
+ f"or move it aside to start fresh. If this recurs, please file an issue."
963
+ ) from exc
929
964
  conn.execute(
930
965
  """
931
966
  CREATE TABLE IF NOT EXISTS weekly_usage_snapshots (
@@ -1636,6 +1671,82 @@ def _reset_aware_floor(
1636
1671
  return row[0] if row and row[0] else None
1637
1672
 
1638
1673
 
1674
+ def _floored_week_max(conn, rows):
1675
+ """Return {week_key -> per-week reset-aware-floored maximum weekly_percent}.
1676
+
1677
+ ``rows`` is an iterable of
1678
+ (week_key, week_start_date, week_start_at, week_end_at,
1679
+ captured_at_utc, weekly_percent).
1680
+
1681
+ Two-pass so floor resolution is independent of row order (#290): pass 1
1682
+ buckets rows per ``week_key`` and canonicalizes each week's first non-NULL
1683
+ (week_start_at, week_end_at) + its week_start_date; pass 2 resolves
1684
+ ``_reset_aware_floor`` once per week (keyed on week_start_date) and drops
1685
+ captures earlier than that floor before taking the week's maximum
1686
+ ``weekly_percent``. ``week_key`` is the caller's aggregation key (1:1 with a
1687
+ week); floor identity is week_start_date, so a NULL-bound legacy row cannot
1688
+ suppress the reset-event leg for a later anchored row of the same week.
1689
+
1690
+ A NULL ``weekly_percent`` row is skipped. An unparseable ``captured_at_utc``
1691
+ under an active floor is RETAINED (epoch unknown), matching
1692
+ ``_cctally_project._load_week_snapshots``. All-NULL bounds resolve
1693
+ credit-floor-leg-only (the reset leg is inert: unixepoch(NULL) is NULL).
1694
+ A week whose every in-scope row is pre-floor is absent from the result.
1695
+ """
1696
+ # Pass 1: bucket + canonicalize bounds.
1697
+ buckets: dict = {}
1698
+ for wk, wsd, ws_at, we_at, cap, pct in rows:
1699
+ if pct is None:
1700
+ continue
1701
+ b = buckets.get(wk)
1702
+ if b is None:
1703
+ buckets[wk] = {
1704
+ "wsd": wsd, "ws_at": ws_at, "we_at": we_at,
1705
+ "rows": [(cap, float(pct))],
1706
+ }
1707
+ continue
1708
+ if b["wsd"] is None and wsd is not None:
1709
+ b["wsd"] = wsd
1710
+ if b["ws_at"] is None and ws_at is not None:
1711
+ b["ws_at"] = ws_at
1712
+ if b["we_at"] is None and we_at is not None:
1713
+ b["we_at"] = we_at
1714
+ b["rows"].append((cap, float(pct)))
1715
+
1716
+ # Pass 2: resolve floor once per week, drop pre-floor captures, take max.
1717
+ result: dict = {}
1718
+ for wk, b in buckets.items():
1719
+ floor_iso = _reset_aware_floor(conn, b["wsd"], b["ws_at"], b["we_at"])
1720
+ floor_epoch = None
1721
+ if floor_iso:
1722
+ try:
1723
+ floor_epoch = int(
1724
+ parse_iso_datetime(
1725
+ floor_iso, "floored_week_max.floor"
1726
+ ).timestamp()
1727
+ )
1728
+ except ValueError:
1729
+ floor_epoch = None
1730
+ best = None
1731
+ for cap, pct in b["rows"]:
1732
+ if floor_epoch is not None and cap is not None:
1733
+ try:
1734
+ cap_epoch = int(
1735
+ parse_iso_datetime(
1736
+ str(cap), "floored_week_max.cap"
1737
+ ).timestamp()
1738
+ )
1739
+ except ValueError:
1740
+ cap_epoch = None
1741
+ if cap_epoch is not None and cap_epoch < floor_epoch:
1742
+ continue
1743
+ if best is None or pct > best:
1744
+ best = pct
1745
+ if best is not None:
1746
+ result[wk] = best
1747
+ return result
1748
+
1749
+
1639
1750
  def get_latest_usage_for_week(
1640
1751
  conn: sqlite3.Connection,
1641
1752
  week_ref: WeekRef,