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.
- package/CHANGELOG.md +63 -0
- package/README.md +6 -4
- package/bin/_cctally_alerts.py +1 -1
- package/bin/_cctally_cache.py +203 -22
- package/bin/_cctally_cache_report.py +7 -5
- package/bin/_cctally_core.py +125 -14
- package/bin/_cctally_dashboard.py +495 -4893
- package/bin/_cctally_dashboard_cache_report.py +714 -0
- package/bin/_cctally_dashboard_conversation.py +830 -0
- package/bin/_cctally_dashboard_envelope.py +1520 -0
- package/bin/_cctally_dashboard_share.py +2012 -0
- package/bin/_cctally_db.py +158 -5
- package/bin/_cctally_doctor.py +104 -3
- package/bin/_cctally_five_hour.py +19 -3
- package/bin/_cctally_forecast.py +118 -161
- package/bin/_cctally_parser.py +1003 -777
- package/bin/_cctally_percent_breakdown.py +1 -1
- package/bin/_cctally_pricing_check.py +39 -0
- package/bin/_cctally_project.py +18 -42
- package/bin/_cctally_record.py +279 -274
- package/bin/_cctally_reporting.py +1 -1
- package/bin/_cctally_setup.py +150 -43
- package/bin/_cctally_sync_week.py +1 -1
- package/bin/_cctally_telemetry.py +3 -5
- package/bin/_cctally_transcript.py +191 -0
- package/bin/_cctally_tui.py +42 -18
- package/bin/_lib_alert_dispatch.py +1 -1
- package/bin/_lib_blocks.py +6 -1
- package/bin/_lib_conversation_anon.py +254 -0
- package/bin/_lib_conversation_query.py +66 -0
- package/bin/_lib_credit.py +133 -0
- package/bin/_lib_diff_kernel.py +19 -7
- package/bin/_lib_doctor.py +150 -1
- package/bin/_lib_five_hour.py +61 -0
- package/bin/_lib_forecast.py +144 -0
- package/bin/_lib_json_envelope.py +38 -0
- package/bin/_lib_jsonl.py +115 -73
- package/bin/_lib_log.py +96 -0
- package/bin/_lib_pricing.py +47 -1
- package/bin/_lib_pricing_check.py +25 -3
- package/bin/_lib_record.py +179 -0
- package/bin/_lib_render.py +26 -11
- package/bin/_lib_snapshot_cache.py +61 -0
- package/bin/_lib_view_models.py +7 -1
- package/bin/cctally +59 -7
- package/bin/cctally-dashboard +2 -2
- package/bin/cctally-statusline +1 -0
- package/bin/cctally-transcript +5 -0
- package/bin/cctally-tui +2 -2
- package/dashboard/static/assets/index-BybNp_Di.js +80 -0
- package/dashboard/static/assets/{index-DQWNrIqu.css → index-DgAmLK65.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +13 -1
- package/dashboard/static/assets/index-CJTUCpKt.js +0 -80
package/bin/_cctally_core.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
917
|
-
|
|
918
|
-
#
|
|
919
|
-
#
|
|
920
|
-
#
|
|
921
|
-
#
|
|
922
|
-
#
|
|
923
|
-
#
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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,
|