cctally 1.80.4 → 1.82.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 (53) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/bin/_cctally_account.py +397 -0
  3. package/bin/_cctally_alerts.py +58 -2
  4. package/bin/_cctally_cache.py +696 -166
  5. package/bin/_cctally_cache_report.py +21 -3
  6. package/bin/_cctally_config.py +119 -8
  7. package/bin/_cctally_core.py +343 -48
  8. package/bin/_cctally_dashboard.py +32 -7
  9. package/bin/_cctally_dashboard_conversation.py +6 -2
  10. package/bin/_cctally_dashboard_envelope.py +49 -0
  11. package/bin/_cctally_dashboard_share.py +78 -1
  12. package/bin/_cctally_dashboard_sources.py +434 -48
  13. package/bin/_cctally_db.py +659 -152
  14. package/bin/_cctally_diff.py +9 -0
  15. package/bin/_cctally_doctor.py +201 -26
  16. package/bin/_cctally_five_hour.py +110 -69
  17. package/bin/_cctally_forecast.py +112 -43
  18. package/bin/_cctally_journal.py +591 -80
  19. package/bin/_cctally_milestones.py +180 -67
  20. package/bin/_cctally_parser.py +87 -0
  21. package/bin/_cctally_percent_breakdown.py +24 -15
  22. package/bin/_cctally_project.py +12 -1
  23. package/bin/_cctally_quota.py +150 -39
  24. package/bin/_cctally_record.py +491 -141
  25. package/bin/_cctally_reporting.py +66 -14
  26. package/bin/_cctally_setup.py +9 -1
  27. package/bin/_cctally_source_analytics.py +56 -7
  28. package/bin/_cctally_statusline.py +56 -8
  29. package/bin/_cctally_store.py +20 -8
  30. package/bin/_cctally_sync_week.py +30 -10
  31. package/bin/_cctally_tui.py +99 -18
  32. package/bin/_cctally_weekrefs.py +38 -15
  33. package/bin/_lib_accounts.py +325 -0
  34. package/bin/_lib_alerts_payload.py +25 -4
  35. package/bin/_lib_cache_writer_lock.py +77 -0
  36. package/bin/_lib_codex_hooks.py +40 -1
  37. package/bin/_lib_diff_kernel.py +28 -14
  38. package/bin/_lib_doctor.py +160 -0
  39. package/bin/_lib_journal.py +65 -2
  40. package/bin/_lib_pricing.py +27 -3
  41. package/bin/_lib_quota.py +81 -4
  42. package/bin/_lib_render.py +19 -5
  43. package/bin/_lib_share.py +25 -0
  44. package/bin/_lib_snapshot_cache.py +8 -0
  45. package/bin/_lib_subscription_weeks.py +16 -2
  46. package/bin/_lib_view_models.py +18 -9
  47. package/bin/cctally +43 -4
  48. package/dashboard/static/assets/index-DJP4gEB7.js +92 -0
  49. package/dashboard/static/assets/index-Dk1nplOz.css +1 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +4 -1
  52. package/dashboard/static/assets/index-BzWujBS3.js +0 -92
  53. 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(since, until, project=args.project)
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)
@@ -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
- value = block[leaf]
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
- if leaf != "amount_usd":
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` is an object {git-root: usd};
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 back
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.