cctally 1.80.4 → 1.81.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 +24 -0
- package/bin/_cctally_account.py +397 -0
- package/bin/_cctally_alerts.py +58 -2
- package/bin/_cctally_cache.py +696 -166
- package/bin/_cctally_cache_report.py +21 -3
- package/bin/_cctally_config.py +119 -8
- package/bin/_cctally_core.py +343 -48
- package/bin/_cctally_dashboard.py +32 -7
- package/bin/_cctally_dashboard_conversation.py +6 -2
- package/bin/_cctally_dashboard_envelope.py +49 -0
- package/bin/_cctally_dashboard_share.py +78 -1
- package/bin/_cctally_dashboard_sources.py +434 -48
- package/bin/_cctally_db.py +659 -152
- package/bin/_cctally_diff.py +9 -0
- package/bin/_cctally_doctor.py +201 -26
- package/bin/_cctally_five_hour.py +110 -69
- package/bin/_cctally_forecast.py +112 -43
- package/bin/_cctally_journal.py +591 -80
- package/bin/_cctally_milestones.py +180 -67
- package/bin/_cctally_parser.py +87 -0
- package/bin/_cctally_percent_breakdown.py +24 -15
- package/bin/_cctally_project.py +12 -1
- package/bin/_cctally_quota.py +150 -39
- package/bin/_cctally_record.py +491 -141
- package/bin/_cctally_reporting.py +66 -14
- package/bin/_cctally_setup.py +9 -1
- package/bin/_cctally_source_analytics.py +56 -7
- package/bin/_cctally_statusline.py +55 -8
- package/bin/_cctally_store.py +20 -8
- package/bin/_cctally_sync_week.py +30 -10
- package/bin/_cctally_tui.py +99 -18
- package/bin/_cctally_weekrefs.py +38 -15
- package/bin/_lib_accounts.py +325 -0
- package/bin/_lib_alerts_payload.py +25 -4
- package/bin/_lib_cache_writer_lock.py +77 -0
- package/bin/_lib_codex_hooks.py +40 -1
- package/bin/_lib_diff_kernel.py +28 -14
- package/bin/_lib_doctor.py +160 -0
- package/bin/_lib_journal.py +65 -2
- package/bin/_lib_quota.py +81 -4
- package/bin/_lib_render.py +19 -5
- package/bin/_lib_share.py +25 -0
- package/bin/_lib_snapshot_cache.py +8 -0
- package/bin/_lib_subscription_weeks.py +16 -2
- package/bin/_lib_view_models.py +18 -9
- package/bin/cctally +43 -4
- package/dashboard/static/assets/index-DJP4gEB7.js +92 -0
- package/dashboard/static/assets/index-Dk1nplOz.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-BzWujBS3.js +0 -92
- package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
|
@@ -679,6 +679,7 @@ def _aggregate_cache_by_day(
|
|
|
679
679
|
project: str | None = None,
|
|
680
680
|
*,
|
|
681
681
|
display_tz: "ZoneInfo | None" = None,
|
|
682
|
+
account_key: "str | None" = None,
|
|
682
683
|
) -> list["crk.CacheRow"]:
|
|
683
684
|
"""CLI adapter: pulls entries from ``get_entries`` and delegates to the
|
|
684
685
|
pure-fn kernel ``_lib_cache_report._aggregate_cache_by_day``.
|
|
@@ -695,7 +696,8 @@ def _aggregate_cache_by_day(
|
|
|
695
696
|
the I/O query here; the kernel itself trusts the caller's pre-filter.
|
|
696
697
|
"""
|
|
697
698
|
c = _cctally()
|
|
698
|
-
entries = list(c.get_entries(since, until, project=project
|
|
699
|
+
entries = list(c.get_entries(since, until, project=project,
|
|
700
|
+
account_key=account_key))
|
|
699
701
|
return crk._aggregate_cache_by_day(
|
|
700
702
|
entries,
|
|
701
703
|
display_tz=display_tz,
|
|
@@ -708,6 +710,8 @@ def _aggregate_cache_by_session(
|
|
|
708
710
|
since: dt.datetime,
|
|
709
711
|
until: dt.datetime,
|
|
710
712
|
project: str | None = None,
|
|
713
|
+
*,
|
|
714
|
+
account_key: "str | None" = None,
|
|
711
715
|
) -> list["crk.CacheRow"]:
|
|
712
716
|
"""CLI adapter: pulls Claude session entries from
|
|
713
717
|
``get_claude_session_entries`` and delegates to the pure-fn kernel
|
|
@@ -723,7 +727,8 @@ def _aggregate_cache_by_session(
|
|
|
723
727
|
itself trusts the caller's pre-filter.
|
|
724
728
|
"""
|
|
725
729
|
c = _cctally()
|
|
726
|
-
entries = c.get_claude_session_entries(since, until, project=project
|
|
730
|
+
entries = c.get_claude_session_entries(since, until, project=project,
|
|
731
|
+
account_key=account_key)
|
|
727
732
|
if not entries:
|
|
728
733
|
return []
|
|
729
734
|
|
|
@@ -1140,6 +1145,14 @@ def cmd_cache_report(args: argparse.Namespace) -> int:
|
|
|
1140
1145
|
eprint(f"Error: {msg}")
|
|
1141
1146
|
return 1
|
|
1142
1147
|
|
|
1148
|
+
# #341 --account: resolve the render filter (provider=claude; fail closed
|
|
1149
|
+
# with exit 3 when the entry cache is unavailable). None = merged.
|
|
1150
|
+
acct_key, acct_exit = c.resolve_account_filter(
|
|
1151
|
+
args, "claude", needs_cache=True)
|
|
1152
|
+
if acct_exit is not None:
|
|
1153
|
+
return acct_exit
|
|
1154
|
+
_acct_fields = c.account_json_fields(acct_key) # #341 R8 (empty unless --account)
|
|
1155
|
+
|
|
1143
1156
|
# Issue #89 Pattern C: deferred loader scoped to the rendered window
|
|
1144
1157
|
# (project filter mirrors what the cache-aggregator uses).
|
|
1145
1158
|
c._emit_debug_samples_if_set(
|
|
@@ -1157,6 +1170,7 @@ def cmd_cache_report(args: argparse.Namespace) -> int:
|
|
|
1157
1170
|
{top_key: [], "totals": None,
|
|
1158
1171
|
"generatedAt": now_utc_iso(now_utc=now_utc)}
|
|
1159
1172
|
)
|
|
1173
|
+
payload.update(_acct_fields) # #341 R8 decoration
|
|
1160
1174
|
sink = getattr(args, "_source_result_sink", None)
|
|
1161
1175
|
if sink is not None:
|
|
1162
1176
|
sink(payload)
|
|
@@ -1167,7 +1181,8 @@ def cmd_cache_report(args: argparse.Namespace) -> int:
|
|
|
1167
1181
|
return 0
|
|
1168
1182
|
|
|
1169
1183
|
if mode == "session":
|
|
1170
|
-
rows = _aggregate_cache_by_session(
|
|
1184
|
+
rows = _aggregate_cache_by_session(
|
|
1185
|
+
since, until, project=args.project, account_key=acct_key)
|
|
1171
1186
|
else:
|
|
1172
1187
|
# Task A3: pass the resolved display_tz so day buckets match the
|
|
1173
1188
|
# ``--tz`` flag (closes the pre-existing minor bug where the
|
|
@@ -1175,6 +1190,7 @@ def cmd_cache_report(args: argparse.Namespace) -> int:
|
|
|
1175
1190
|
# spec §1.6 / plan A3).
|
|
1176
1191
|
rows = _aggregate_cache_by_day(
|
|
1177
1192
|
since, until, project=args.project, display_tz=tz,
|
|
1193
|
+
account_key=acct_key,
|
|
1178
1194
|
)
|
|
1179
1195
|
|
|
1180
1196
|
if not rows:
|
|
@@ -1183,6 +1199,7 @@ def cmd_cache_report(args: argparse.Namespace) -> int:
|
|
|
1183
1199
|
{top_key: [], "totals": None,
|
|
1184
1200
|
"generatedAt": now_utc_iso(now_utc=now_utc)}
|
|
1185
1201
|
)
|
|
1202
|
+
payload.update(_acct_fields) # #341 R8 decoration
|
|
1186
1203
|
sink = getattr(args, "_source_result_sink", None)
|
|
1187
1204
|
if sink is not None:
|
|
1188
1205
|
sink(payload)
|
|
@@ -1206,6 +1223,7 @@ def cmd_cache_report(args: argparse.Namespace) -> int:
|
|
|
1206
1223
|
|
|
1207
1224
|
if args.json:
|
|
1208
1225
|
payload = _cache_report_json_payload(rows, mode, now_utc=now_utc)
|
|
1226
|
+
payload.update(_acct_fields) # #341 R8 decoration
|
|
1209
1227
|
sink = getattr(args, "_source_result_sink", None)
|
|
1210
1228
|
if sink is not None:
|
|
1211
1229
|
sink(payload)
|
package/bin/_cctally_config.py
CHANGED
|
@@ -321,12 +321,14 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
321
321
|
"budget.period",
|
|
322
322
|
"budget.projects",
|
|
323
323
|
"budget.project_alerts_enabled",
|
|
324
|
+
"budget.accounts",
|
|
324
325
|
"budget.codex",
|
|
325
326
|
"budget.codex.amount_usd",
|
|
326
327
|
"budget.codex.period",
|
|
327
328
|
"budget.codex.alerts_enabled",
|
|
328
329
|
"budget.codex.alert_thresholds",
|
|
329
330
|
"budget.codex.projected_enabled",
|
|
331
|
+
"budget.codex.accounts",
|
|
330
332
|
"telemetry.enabled",
|
|
331
333
|
"conversation.retention_days",
|
|
332
334
|
)
|
|
@@ -422,15 +424,89 @@ def _config_codex_leaf_value(config: dict, key: str) -> object:
|
|
|
422
424
|
"alerts_enabled": False,
|
|
423
425
|
"alert_thresholds": [90, 100],
|
|
424
426
|
"projected_enabled": False,
|
|
427
|
+
"accounts": {},
|
|
425
428
|
}
|
|
426
429
|
value = defaults[leaf]
|
|
427
430
|
else:
|
|
428
|
-
|
|
431
|
+
# `accounts` is conditionally present in the validated block (#341); an
|
|
432
|
+
# absent map reads as the empty default rather than KeyError-ing.
|
|
433
|
+
value = block.get(leaf, {} if leaf == "accounts" else None)
|
|
429
434
|
return list(value) if isinstance(value, list) else value
|
|
430
435
|
|
|
431
436
|
|
|
437
|
+
def _resolve_one_account_budget_ref(conn, ref: object, provider: str,
|
|
438
|
+
label: str) -> str:
|
|
439
|
+
"""Resolve one per-account-budget ref to an immutable account_key at WRITE
|
|
440
|
+
time (#341, spec §6 — a later label rename must never retarget the budget).
|
|
441
|
+
|
|
442
|
+
A raw 32-hex account_key is accepted verbatim; a label / email / key prefix
|
|
443
|
+
is resolved against the accounts registry (``conn`` may be None when the DB
|
|
444
|
+
does not exist yet — only raw keys are then accepted). The reserved
|
|
445
|
+
``unattributed`` / ``*`` buckets are rejected (per-account budgets target
|
|
446
|
+
real accounts only). An unresolvable non-key ref raises _BudgetConfigError."""
|
|
447
|
+
import re as _re
|
|
448
|
+
import _lib_accounts
|
|
449
|
+
if not isinstance(ref, str) or not ref:
|
|
450
|
+
raise _BudgetConfigError(f"{label} keys must be non-empty strings")
|
|
451
|
+
if ref in (_lib_accounts.UNATTRIBUTED, _lib_accounts.VENDOR_WIDE):
|
|
452
|
+
raise _BudgetConfigError(
|
|
453
|
+
f"{label} cannot target the reserved {ref!r} bucket "
|
|
454
|
+
"(per-account budgets target real accounts only)"
|
|
455
|
+
)
|
|
456
|
+
if _re.fullmatch(r"[0-9a-f]{32}", ref):
|
|
457
|
+
return ref # already an immutable account_key
|
|
458
|
+
if conn is None:
|
|
459
|
+
raise _BudgetConfigError(
|
|
460
|
+
f"{label}: cannot resolve account ref {ref!r} — no accounts observed "
|
|
461
|
+
"yet; use the 32-hex account key"
|
|
462
|
+
)
|
|
463
|
+
try:
|
|
464
|
+
return _lib_accounts.resolve_account_ref(conn, ref, provider)
|
|
465
|
+
except _lib_accounts.AccountRefError:
|
|
466
|
+
raise _BudgetConfigError(
|
|
467
|
+
f"{label}: unknown or ambiguous account ref {ref!r}"
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _normalize_account_budget_refs(raw_obj: dict, provider: str,
|
|
472
|
+
label: str) -> dict:
|
|
473
|
+
"""Normalize every ref key in a ``{ref: usd}`` account-budget object to its
|
|
474
|
+
immutable account_key (write-time resolution). Values pass through unchanged
|
|
475
|
+
(validated numerically by ``_validate_account_budget_map`` under the lock)."""
|
|
476
|
+
import sqlite3
|
|
477
|
+
conn = None
|
|
478
|
+
try:
|
|
479
|
+
db_path = _cctally_core.DB_PATH
|
|
480
|
+
if db_path.exists():
|
|
481
|
+
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
|
482
|
+
out: dict = {}
|
|
483
|
+
for ref, val in raw_obj.items():
|
|
484
|
+
key = _resolve_one_account_budget_ref(conn, ref, provider, label)
|
|
485
|
+
out[key] = val
|
|
486
|
+
return out
|
|
487
|
+
finally:
|
|
488
|
+
if conn is not None:
|
|
489
|
+
conn.close()
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _parse_account_budget_value(raw_value: str, provider: str,
|
|
493
|
+
label: str) -> dict:
|
|
494
|
+
"""Parse a ``config set`` account-budget value: JSON object of ``{ref: usd}``
|
|
495
|
+
with each ref normalized to its account_key at write time."""
|
|
496
|
+
try:
|
|
497
|
+
parsed = json.loads(raw_value)
|
|
498
|
+
except (json.JSONDecodeError, ValueError):
|
|
499
|
+
raise _BudgetConfigError(f"{label} must be a JSON object, got {raw_value!r}")
|
|
500
|
+
if not isinstance(parsed, dict):
|
|
501
|
+
raise _BudgetConfigError(f"{label} must be a JSON object")
|
|
502
|
+
return _normalize_account_budget_refs(parsed, provider, label)
|
|
503
|
+
|
|
504
|
+
|
|
432
505
|
def _parse_codex_budget_leaf_value(leaf: str, raw_value: str) -> object:
|
|
433
506
|
"""Parse exactly the command-line wire format for one Codex leaf."""
|
|
507
|
+
if leaf == "accounts":
|
|
508
|
+
return _parse_account_budget_value(
|
|
509
|
+
raw_value, "codex", "budget.codex.accounts")
|
|
434
510
|
if leaf == "amount_usd":
|
|
435
511
|
try:
|
|
436
512
|
return float(raw_value)
|
|
@@ -480,7 +556,9 @@ def _set_codex_budget_leaf(config: dict, key: str, raw_value: str) -> dict:
|
|
|
480
556
|
raise _BudgetConfigError("budget must be an object")
|
|
481
557
|
existing = (budget or {}).get("codex")
|
|
482
558
|
if existing is None:
|
|
483
|
-
|
|
559
|
+
# `accounts` (#341) is valid without a vendor-wide amount_usd, so it —
|
|
560
|
+
# like amount_usd — may bootstrap a fresh codex block.
|
|
561
|
+
if leaf not in ("amount_usd", "accounts"):
|
|
484
562
|
raise _BudgetConfigError(
|
|
485
563
|
"budget.codex.amount_usd must be configured before setting "
|
|
486
564
|
f"budget.codex.{leaf}"
|
|
@@ -930,6 +1008,7 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
930
1008
|
"budget.period",
|
|
931
1009
|
"budget.projects",
|
|
932
1010
|
"budget.project_alerts_enabled",
|
|
1011
|
+
"budget.accounts",
|
|
933
1012
|
"budget.codex",
|
|
934
1013
|
):
|
|
935
1014
|
inner = key.split(".", 1)[1]
|
|
@@ -963,7 +1042,7 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
963
1042
|
def _coerce(k: str, v: "object") -> "object":
|
|
964
1043
|
if k in (
|
|
965
1044
|
"alerts.command_template", "alerts.quota", "budget.projects",
|
|
966
|
-
"budget.codex",
|
|
1045
|
+
"budget.accounts", "budget.codex",
|
|
967
1046
|
) or k.startswith(_CODEX_BUDGET_LEAF_PREFIX):
|
|
968
1047
|
return v
|
|
969
1048
|
return v if v is not None else ""
|
|
@@ -1007,14 +1086,18 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
1007
1086
|
# round-trips via `config set alerts.enabled <plain-text>` work.
|
|
1008
1087
|
if k in (
|
|
1009
1088
|
"alerts.command_template", "alerts.quota", "budget.projects",
|
|
1010
|
-
"budget.codex",
|
|
1089
|
+
"budget.accounts", "budget.codex",
|
|
1011
1090
|
):
|
|
1012
1091
|
# JSON-encoded so `config get` output round-trips through the
|
|
1013
1092
|
# matching `config set` branch (both JSON-parse their value).
|
|
1014
1093
|
# `alerts.command_template` is a list-of-strings|null;
|
|
1015
|
-
# `budget.projects`
|
|
1094
|
+
# `budget.projects` / `budget.accounts` are objects;
|
|
1016
1095
|
# `budget.codex` is an object|null (the no-budget sentinel).
|
|
1017
1096
|
rendered = json.dumps(v)
|
|
1097
|
+
elif k.startswith(_CODEX_BUDGET_LEAF_PREFIX) and isinstance(v, dict):
|
|
1098
|
+
# `budget.codex.accounts` (#341) is a JSON object; round-trips
|
|
1099
|
+
# through the codex-leaf setter's JSON parse.
|
|
1100
|
+
rendered = json.dumps(v)
|
|
1018
1101
|
elif k.startswith(_CODEX_BUDGET_LEAF_PREFIX) and v is None:
|
|
1019
1102
|
rendered = "null"
|
|
1020
1103
|
elif isinstance(v, bool):
|
|
@@ -1611,6 +1694,7 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
1611
1694
|
"budget.period",
|
|
1612
1695
|
"budget.projects",
|
|
1613
1696
|
"budget.project_alerts_enabled",
|
|
1697
|
+
"budget.accounts",
|
|
1614
1698
|
"budget.codex",
|
|
1615
1699
|
):
|
|
1616
1700
|
inner_key = key.split(".", 1)[1]
|
|
@@ -1675,6 +1759,18 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
1675
1759
|
)
|
|
1676
1760
|
return 2
|
|
1677
1761
|
new_val = parsed_codex
|
|
1762
|
+
elif inner_key == "accounts":
|
|
1763
|
+
# `budget.accounts` is a dict {account_key: usd} (#341). Accepts refs
|
|
1764
|
+
# (label/email/prefix) but normalizes to immutable account keys HERE,
|
|
1765
|
+
# at write time, so a later `account label` rename never retargets the
|
|
1766
|
+
# budget (spec §6). The per-value numeric rule is enforced by
|
|
1767
|
+
# _get_budget_config under the lock below.
|
|
1768
|
+
try:
|
|
1769
|
+
new_val = _parse_account_budget_value(
|
|
1770
|
+
raw, "claude", "budget.accounts")
|
|
1771
|
+
except _BudgetConfigError as exc:
|
|
1772
|
+
eprint(f"cctally config: {exc}")
|
|
1773
|
+
return 2
|
|
1678
1774
|
elif inner_key == "projects":
|
|
1679
1775
|
# `budget.projects` is a dict {git-root: usd}, which the plain
|
|
1680
1776
|
# number/bool/list leaves can't round-trip — JSON-parse it (mirrors
|
|
@@ -1800,9 +1896,9 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
1800
1896
|
else:
|
|
1801
1897
|
if isinstance(out_val, bool):
|
|
1802
1898
|
rendered = "true" if out_val else "false"
|
|
1803
|
-
elif inner_key in ("projects", "codex"):
|
|
1804
|
-
# JSON so `config get budget.{projects,codex}` round-trips
|
|
1805
|
-
# through this branch (str(dict)/None is not valid JSON; the
|
|
1899
|
+
elif inner_key in ("projects", "codex", "accounts"):
|
|
1900
|
+
# JSON so `config get budget.{projects,codex,accounts}` round-trips
|
|
1901
|
+
# back through this branch (str(dict)/None is not valid JSON; the
|
|
1806
1902
|
# codex no-budget sentinel renders as `null`).
|
|
1807
1903
|
rendered = json.dumps(out_val)
|
|
1808
1904
|
else:
|
|
@@ -1847,6 +1943,20 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
1847
1943
|
return 0
|
|
1848
1944
|
prospective = dict(validated_existing)
|
|
1849
1945
|
prospective.pop(leaf, None)
|
|
1946
|
+
# A per-account-only Codex block (#341) whose accounts (or a
|
|
1947
|
+
# bare block whose amount) are now gone has nothing left to
|
|
1948
|
+
# anchor -> drop the whole codex block rather than re-raise the
|
|
1949
|
+
# "amount_usd required" validation.
|
|
1950
|
+
if prospective.get("amount_usd") is None and not (
|
|
1951
|
+
prospective.get("accounts") or {}
|
|
1952
|
+
):
|
|
1953
|
+
block.pop("codex", None)
|
|
1954
|
+
if block:
|
|
1955
|
+
config["budget"] = block
|
|
1956
|
+
else:
|
|
1957
|
+
config.pop("budget", None)
|
|
1958
|
+
save_config(config)
|
|
1959
|
+
return 0
|
|
1850
1960
|
configured = _validate_codex_budget_block(prospective)
|
|
1851
1961
|
assert configured is not None
|
|
1852
1962
|
block["codex"] = configured
|
|
@@ -2065,6 +2175,7 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
2065
2175
|
"budget.period",
|
|
2066
2176
|
"budget.projects",
|
|
2067
2177
|
"budget.project_alerts_enabled",
|
|
2178
|
+
"budget.accounts",
|
|
2068
2179
|
"budget.codex",
|
|
2069
2180
|
):
|
|
2070
2181
|
# Drop only the named leaf; preserve sibling budget.* keys (e.g.
|