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
package/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.82.0] - 2026-07-24
9
+
10
+ ### Added
11
+ - Embedded pricing for Claude Opus 5 (`claude-opus-5`) so Opus 5 sessions are costed correctly instead of contributing $0 as an unrecognized model. Anthropic ships it at the standard Opus rate — $5 / MTok input, $25 / MTok output, $6.25 / MTok 5-minute cache write, $0.50 / MTok cache read — with the full 1M-token context window at standard pricing (no long-context premium), verified against Anthropic's published pricing table; the pricing snapshot date moves to 2026-07-24. LiteLLM carries no Opus 5 entry yet, so the embedded table is simply ahead of it and `pricing-check` does not report drift.
12
+
13
+ ### Fixed
14
+ - `cctally statusline`'s 🧠 context segment now measures a `claude-opus-5[1m]` session against its real 1,000,000-token window instead of silently falling back to the 200K family default, which inflated the reported context percentage roughly fivefold.
15
+
16
+ ## [1.81.0] - 2026-07-24
17
+
18
+ ### Added
19
+ - cctally now tracks usage per account for each provider: if you use more than one Claude or Codex account on this machine, each account's percent, 5-hour, and quota milestones — and their alerts — are recorded and fire independently instead of silently colliding. (#341)
20
+ - New `cctally account list|show|label` subcommand shows every observed account (provider, label, email, plan, first/last seen, and which is currently active) and lets you set a durable friendly label that survives a stats rebuild. (#341)
21
+ - Optional per-account weekly budgets via `config set budget.accounts` (Claude) and `config set budget.codex.accounts` (Codex); a budget targets an immutable account even after you rename its label, and a Codex per-account budget works without a vendor-wide amount. (#341)
22
+ - `cctally doctor` gains an Accounts section reporting whether the active identity is readable, the registry is consistent, and whether usage is landing under the correct account. (#341)
23
+ - New `--account <ref>` filter scopes a command's output to one account (by label, email, or account-key prefix). It works on the Claude usage/analytics commands — `report`, `forecast`, `weekly`, `percent-breakdown`, `five-hour-blocks`, `five-hour-breakdown`, `daily`, `monthly`, `session`, `project`, `diff`, `range-cost`, `cache-report` — and the `codex quota` views; `--json` output gains `accountKey`/`accountLabel` when an account is selected. (#341)
24
+ - The dashboard gains a multi-account view: when a provider has more than one real account, an account chip row appears under the source switcher (keyboard `a` cycles), the "All accounts" view shows a per-account hero card (weekly/5-hour bars, plan, weekly spend, reset countdown), and selecting a chip focuses that account. Single-account dashboards are pixel-identical. (#341)
25
+
26
+ ### Changed
27
+ - Multi-account is byte-stable: if you use a single account per provider (the common case), every report, alert, and status line is unchanged. Account labels and columns appear only once a provider has more than one real account. (#341)
28
+ - Alert notifications gain a `[label]` prefix only when a provider has more than one real account, and `alerts.log` now records the account on every line. (#341)
29
+ - `record-credit` records the credit under the currently active account (and asks you to retry if that identity can't be read mid-write); `sync-week` attributes its cost snapshot to the active account. (#341)
30
+
31
+ ### Fixed
32
+ - Concurrent Claude and Codex hook syncs now serialize every `cache.db` write and WAL checkpoint through one global-first lock order. Forced writer death resumes from truthful per-file cursors, and quota-journal and migration writers use the same discipline, closing the cross-provider write/checkpoint overlap consistent with the recurring page-one corruption. (#344)
33
+ - Corrupt `cache.db` rebuilds now recover without manual file surgery, including
34
+ corruption discovered after open and repairs interrupted at any phase.
35
+ Dead, malformed, or PID-reused repair owners self-clear safely, `doctor`
36
+ reports live versus stale maintenance, and the dashboard distinguishes
37
+ actionable server cache failures from disconnects and failed manual sync
38
+ requests. (#344)
39
+
8
40
  ## [1.80.4] - 2026-07-23
9
41
 
10
42
  ### Fixed
@@ -0,0 +1,397 @@
1
+ """``cctally account`` subcommand + the account-decoration helpers (#341, spec §3).
2
+
3
+ Read surface over the ``accounts`` registry (derived from the journal by the
4
+ Task-1 rebuild appliers) plus the providers' own on-disk active-account state:
5
+
6
+ * ``list [--json]`` — every observed account with label / provider / email /
7
+ plan / first-last-seen and a live ``active`` marker per provider.
8
+ * ``show <ref> [--json]`` — one account's identity + a small attribution
9
+ summary (usage-snapshot / percent-milestone counts scoped to the key).
10
+ * ``label <ref> <name>`` — append an ``account_label`` op (user provenance,
11
+ top of the ``user > switcher > auto`` precedence) and run an authoritative
12
+ ingest so the rename folds durably (survives ``db rebuild --db stats``).
13
+
14
+ Ref resolution is the pure ``_lib_accounts.resolve_account_ref`` (case-insensitive
15
+ label -> email -> unique key prefix; literal ``unattributed`` accepted); an
16
+ ambiguous/unknown ref raises ``AccountRefError`` -> exit 2 with candidates on
17
+ stderr (native-usage error per ``docs/cli-contract.md``).
18
+
19
+ This module is ALSO the single home for the R8 decoration gate + label helpers
20
+ consumed by the alerts log/label prefix, the ``--account`` render decoration,
21
+ the doctor ``accounts.*`` legs, and (Task 4) the dashboard read model:
22
+ ``real_account_count`` / ``provider_is_decorated`` / ``account_label`` /
23
+ ``resolve_active_account_keys``. Keeping them here means the ">1 real account"
24
+ trigger and the key->label map have exactly one definition.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import sqlite3
31
+
32
+ import _cctally_core
33
+ import _lib_accounts
34
+
35
+
36
+ def _cctally():
37
+ import sys
38
+ return sys.modules["cctally"]
39
+
40
+
41
+ def eprint(*args, **kwargs):
42
+ import sys
43
+ print(*args, file=sys.stderr, **kwargs)
44
+
45
+
46
+ # --------------------------------------------------------------------------
47
+ # registry read helpers (shared decoration surface)
48
+ # --------------------------------------------------------------------------
49
+
50
+ _ACCOUNT_COLUMNS = (
51
+ "account_key", "provider", "natural_id", "email", "label", "plan_type",
52
+ "label_source", "first_seen_utc", "last_seen_utc",
53
+ )
54
+
55
+
56
+ def load_accounts(conn, provider: "str | None" = None) -> "list[dict]":
57
+ """Every registry row (optionally one provider), deterministically ordered
58
+ by ``(provider, first_seen_utc, account_key)`` so renders are stable."""
59
+ sql = (
60
+ "SELECT account_key, provider, natural_id, email, label, plan_type, "
61
+ "label_source, first_seen_utc, last_seen_utc FROM accounts"
62
+ )
63
+ params: tuple = ()
64
+ if provider is not None:
65
+ sql += " WHERE provider = ?"
66
+ params = (provider,)
67
+ sql += " ORDER BY provider, first_seen_utc, account_key"
68
+ return [
69
+ {col: row[i] for i, col in enumerate(_ACCOUNT_COLUMNS)}
70
+ for row in conn.execute(sql, params).fetchall()
71
+ ]
72
+
73
+
74
+ def real_account_count(conn, provider: str) -> int:
75
+ """Number of REAL accounts for one provider (excludes the ``unattributed``
76
+ sentinel). The R8 decoration trigger: ``> 1`` means the provider decorates."""
77
+ row = conn.execute(
78
+ "SELECT COUNT(*) FROM accounts WHERE provider = ? AND account_key != ?",
79
+ (provider, _lib_accounts.UNATTRIBUTED),
80
+ ).fetchone()
81
+ return int(row[0]) if row else 0
82
+
83
+
84
+ def provider_is_decorated(conn, provider: str) -> bool:
85
+ """R8 gate: does this provider render account decoration? True iff it holds
86
+ more than one REAL account (a lone ``unattributed`` bucket never triggers)."""
87
+ return real_account_count(conn, provider) > 1
88
+
89
+
90
+ def account_label(conn, account_key: str) -> str:
91
+ """A human label for ``account_key``: the registry label, else the email,
92
+ else an 8-char key prefix. The reserved sentinels render as words so alert
93
+ prefixes / renders read cleanly."""
94
+ if account_key == _lib_accounts.UNATTRIBUTED:
95
+ return "Unattributed"
96
+ if account_key == _lib_accounts.VENDOR_WIDE:
97
+ return "All accounts"
98
+ row = conn.execute(
99
+ "SELECT label, email FROM accounts WHERE account_key = ?", (account_key,)
100
+ ).fetchone()
101
+ if row is not None:
102
+ if row[0]:
103
+ return row[0]
104
+ if row[1]:
105
+ return row[1]
106
+ return account_key[:8]
107
+
108
+
109
+ def resolve_account_filter(args, provider: str = "claude", *,
110
+ needs_cache: bool = False) -> "tuple[str | None, int | None]":
111
+ """Resolve the ``--account <ref>`` render filter (#341, spec §3) to an
112
+ ``account_key``.
113
+
114
+ Returns ``(account_key | None, exit_code | None)``:
115
+ * no ``--account`` flag -> ``(None, None)`` (merged view, today's
116
+ byte-identical output — R8);
117
+ * resolved ref -> ``(key, None)``;
118
+ * ambiguous/unknown ref -> ``(None, 2)`` (candidates on stderr, a
119
+ native-usage error per ``docs/cli-contract.md``);
120
+ * ``needs_cache`` + cache down -> ``(None, 3)`` — the stamped-entry family
121
+ (``daily``/``session``/…) fails closed when the entry cache is
122
+ unavailable, because the direct-JSONL fallback carries NO account
123
+ identity and must never be stamped with the current login at read time.
124
+
125
+ ``provider`` scopes ref resolution to one provider's registry (``claude`` for
126
+ the Claude usage/analytics family, ``codex`` for ``codex quota``)."""
127
+ ref = getattr(args, "account", None)
128
+ if ref is None:
129
+ return (None, None)
130
+ conn = _cctally_core.open_db()
131
+ try:
132
+ try:
133
+ key = _lib_accounts.resolve_account_ref(conn, ref, provider)
134
+ except _lib_accounts.AccountRefError as exc:
135
+ eprint(f"account: --account {ref!r} is ambiguous or unknown")
136
+ if exc.candidates:
137
+ eprint("candidates:")
138
+ for cand in exc.candidates:
139
+ eprint(f" {cand}")
140
+ return (None, 2)
141
+ finally:
142
+ conn.close()
143
+ if needs_cache:
144
+ try:
145
+ import _cctally_cache
146
+ _cctally_cache.open_cache_db().close()
147
+ except Exception:
148
+ eprint("account attribution unavailable (cache required)")
149
+ return (None, 3)
150
+ return (key, None)
151
+
152
+
153
+ def account_json_fields(account_key: "str | None") -> dict:
154
+ """R8 JSON decoration for an account-aware invocation (#341, spec §3).
155
+
156
+ Returns ``{"accountKey": <key>, "accountLabel": <label>}`` for a resolved
157
+ ``--account`` key, else ``{}``. Emitted only under an explicitly account-aware
158
+ invocation (``--account`` set), so a default (no-flag) render stays
159
+ byte-identical (R8). camelCase + additive; no ``schemaVersion`` bump."""
160
+ if account_key is None:
161
+ return {}
162
+ conn = _cctally_core.open_db()
163
+ try:
164
+ label = account_label(conn, account_key)
165
+ finally:
166
+ conn.close()
167
+ return {"accountKey": account_key, "accountLabel": label}
168
+
169
+
170
+ def resolve_active_account_keys() -> "set[str]":
171
+ """The set of account keys that are CURRENTLY active per the providers' own
172
+ on-disk credential state (never guessed). Claude from ``~/.claude.json``;
173
+ Codex from each provider root's ``auth.json``. Absent / api-key / torn reads
174
+ contribute nothing. Read-only, best-effort — any failure yields an empty
175
+ contribution rather than raising into a render path."""
176
+ active: "set[str]" = set()
177
+ try:
178
+ claude = _cctally_core._resolve_active_claude_account()
179
+ if claude and claude != _lib_accounts.UNATTRIBUTED:
180
+ active.add(claude)
181
+ except Exception:
182
+ pass
183
+ try:
184
+ import _cctally_cache
185
+ for root in _cctally_cache._codex_provider_roots():
186
+ res = _cctally_cache._resolve_codex_account_for_root(root.provider_root)
187
+ if getattr(res, "status", None) == "identified" and res.account_key:
188
+ active.add(res.account_key)
189
+ except Exception:
190
+ pass
191
+ return active
192
+
193
+
194
+ # --------------------------------------------------------------------------
195
+ # small deterministic table renderer (content-sized columns)
196
+ # --------------------------------------------------------------------------
197
+
198
+ def _render_table(headers: "list[str]", rows: "list[list[str]]") -> str:
199
+ widths = [len(h) for h in headers]
200
+ for row in rows:
201
+ for i, cell in enumerate(row):
202
+ widths[i] = max(widths[i], len(cell))
203
+ def _fmt(cells):
204
+ return " ".join(c.ljust(widths[i]) for i, c in enumerate(cells)).rstrip()
205
+ out = [_fmt(headers)]
206
+ for row in rows:
207
+ out.append(_fmt(row))
208
+ return "\n".join(out)
209
+
210
+
211
+ def _dash(value: "str | None") -> str:
212
+ return value if value else "-"
213
+
214
+
215
+ def _date_only(iso: "str | None") -> str:
216
+ if not iso:
217
+ return "-"
218
+ return iso[:10]
219
+
220
+
221
+ # --------------------------------------------------------------------------
222
+ # command dispatch
223
+ # --------------------------------------------------------------------------
224
+
225
+ def cmd_account(args: argparse.Namespace) -> int:
226
+ action = getattr(args, "account_action", None)
227
+ if action == "list":
228
+ return _cmd_account_list(args)
229
+ if action == "show":
230
+ return _cmd_account_show(args)
231
+ if action == "label":
232
+ return _cmd_account_label(args)
233
+ eprint("account: unknown action")
234
+ return 2
235
+
236
+
237
+ def _cmd_account_list(args: argparse.Namespace) -> int:
238
+ conn = _cctally_core.open_db()
239
+ try:
240
+ accounts = load_accounts(conn)
241
+ finally:
242
+ conn.close()
243
+ active = resolve_active_account_keys()
244
+ if getattr(args, "emit_json", False):
245
+ payload = {
246
+ "accounts": [
247
+ {
248
+ "accountKey": a["account_key"],
249
+ "provider": a["provider"],
250
+ "label": a["label"],
251
+ "email": a["email"],
252
+ "planType": a["plan_type"],
253
+ "labelSource": a["label_source"],
254
+ "firstSeenUtc": a["first_seen_utc"],
255
+ "lastSeenUtc": a["last_seen_utc"],
256
+ "active": a["account_key"] in active,
257
+ }
258
+ for a in accounts
259
+ ]
260
+ }
261
+ print(json.dumps(_cctally().stamp_schema_version(payload)))
262
+ return 0
263
+
264
+ if not accounts:
265
+ print("No accounts observed yet.")
266
+ return 0
267
+ headers = ["PROVIDER", "LABEL", "EMAIL", "PLAN", "FIRST SEEN",
268
+ "LAST SEEN", "ACTIVE"]
269
+ rows = []
270
+ for a in accounts:
271
+ rows.append([
272
+ a["provider"] or "-",
273
+ account_label_from_row(a),
274
+ _dash(a["email"]),
275
+ _dash(a["plan_type"]),
276
+ _date_only(a["first_seen_utc"]),
277
+ _date_only(a["last_seen_utc"]),
278
+ "*" if a["account_key"] in active else "",
279
+ ])
280
+ print(_render_table(headers, rows))
281
+ return 0
282
+
283
+
284
+ def account_label_from_row(a: dict) -> str:
285
+ """Label for a loaded registry row without a second DB round-trip."""
286
+ if a["label"]:
287
+ return a["label"]
288
+ if a["email"]:
289
+ return a["email"]
290
+ return (a["account_key"] or "")[:8] or "-"
291
+
292
+
293
+ def _resolve_ref_or_exit(conn, ref: str) -> "str | None":
294
+ """Resolve a ref, printing candidates + returning None on error (exit 2)."""
295
+ try:
296
+ return _lib_accounts.resolve_account_ref(conn, ref)
297
+ except _lib_accounts.AccountRefError as exc:
298
+ eprint(f"account: ref {ref!r} is ambiguous or unknown")
299
+ if exc.candidates:
300
+ eprint("candidates:")
301
+ for cand in exc.candidates:
302
+ eprint(f" {cand}")
303
+ return None
304
+
305
+
306
+ def _cmd_account_show(args: argparse.Namespace) -> int:
307
+ ref = getattr(args, "ref", None)
308
+ conn = _cctally_core.open_db()
309
+ try:
310
+ key = _resolve_ref_or_exit(conn, ref)
311
+ if key is None:
312
+ return 2
313
+ row = conn.execute(
314
+ "SELECT account_key, provider, natural_id, email, label, plan_type, "
315
+ "label_source, first_seen_utc, last_seen_utc FROM accounts "
316
+ "WHERE account_key = ?", (key,)
317
+ ).fetchone()
318
+ a = ({col: row[i] for i, col in enumerate(_ACCOUNT_COLUMNS)}
319
+ if row is not None else None)
320
+ snap_count = _count_scoped(conn, "weekly_usage_snapshots", key)
321
+ milestone_count = _count_scoped(conn, "percent_milestones", key)
322
+ finally:
323
+ conn.close()
324
+ active = resolve_active_account_keys()
325
+ is_active = key in active
326
+ if getattr(args, "emit_json", False):
327
+ payload = {
328
+ "accountKey": key,
329
+ "provider": (a["provider"] if a else None),
330
+ "label": (a["label"] if a else None),
331
+ "email": (a["email"] if a else None),
332
+ "planType": (a["plan_type"] if a else None),
333
+ "labelSource": (a["label_source"] if a else None),
334
+ "firstSeenUtc": (a["first_seen_utc"] if a else None),
335
+ "lastSeenUtc": (a["last_seen_utc"] if a else None),
336
+ "active": is_active,
337
+ "attribution": {
338
+ "usageSnapshots": snap_count,
339
+ "percentMilestones": milestone_count,
340
+ },
341
+ }
342
+ print(json.dumps(_cctally().stamp_schema_version(payload)))
343
+ return 0
344
+
345
+ label = (account_label_from_row(a) if a else
346
+ ("Unattributed" if key == _lib_accounts.UNATTRIBUTED else key[:8]))
347
+ lines = [
348
+ f"Account: {label}",
349
+ f"Key: {key}",
350
+ f"Provider: {_dash(a['provider'] if a else None)}",
351
+ f"Email: {_dash(a['email'] if a else None)}",
352
+ f"Plan: {_dash(a['plan_type'] if a else None)}",
353
+ f"Label from: {_dash(a['label_source'] if a else None)}",
354
+ f"First seen: {_dash(a['first_seen_utc'] if a else None)}",
355
+ f"Last seen: {_dash(a['last_seen_utc'] if a else None)}",
356
+ f"Active: {'yes' if is_active else 'no'}",
357
+ f"Attribution: {snap_count} usage snapshot(s), "
358
+ f"{milestone_count} percent milestone(s)",
359
+ ]
360
+ print("\n".join(lines))
361
+ return 0
362
+
363
+
364
+ def _count_scoped(conn, table: str, account_key: str) -> int:
365
+ try:
366
+ row = conn.execute(
367
+ f"SELECT COUNT(*) FROM {table} WHERE account_key = ?", (account_key,)
368
+ ).fetchone()
369
+ return int(row[0]) if row else 0
370
+ except sqlite3.DatabaseError:
371
+ return 0
372
+
373
+
374
+ def _cmd_account_label(args: argparse.Namespace) -> int:
375
+ ref = getattr(args, "ref", None)
376
+ label = getattr(args, "label", None)
377
+ conn = _cctally_core.open_db()
378
+ try:
379
+ key = _resolve_ref_or_exit(conn, ref)
380
+ if key is None:
381
+ return 2
382
+ provider_row = conn.execute(
383
+ "SELECT provider FROM accounts WHERE account_key = ?", (key,)
384
+ ).fetchone()
385
+ provider = provider_row[0] if provider_row is not None else None
386
+ finally:
387
+ conn.close()
388
+
389
+ import _cctally_journal as _jr
390
+ import _lib_journal as _lj
391
+ at = (_cctally_core._command_as_of()
392
+ .isoformat(timespec="seconds").replace("+00:00", "Z"))
393
+ _jr.append_record(_lj.make_account_label(
394
+ at=at, account_key=key, label=label, provider=provider))
395
+ _jr.run_stats_ingest(mode="authoritative")
396
+ print(f"Labeled {key[:8]} -> {label}")
397
+ return 0
@@ -68,6 +68,7 @@ def _load_lib(name: str):
68
68
  return mod
69
69
 
70
70
 
71
+ _lib_accounts = _load_lib("_lib_accounts")
71
72
  _lib_alerts_payload = _load_lib("_lib_alerts_payload")
72
73
  _alert_text_weekly = _lib_alerts_payload._alert_text_weekly
73
74
  _alert_text_five_hour = _lib_alerts_payload._alert_text_five_hour
@@ -89,8 +90,10 @@ def _build_alert_payload_quota(
89
90
  observed_slot: str, window_minutes: int, resets_at_utc: str,
90
91
  threshold: int, kind: str, crossed_at_utc: str,
91
92
  qualifying_percent: float | None, projected_percent: float | None,
93
+ account_key: str = _lib_accounts.UNATTRIBUTED,
92
94
  ) -> dict:
93
- """Build the provider-neutral durable quota alert payload."""
95
+ """Build the provider-neutral durable quota alert payload. ``account_key``
96
+ (#341) is the observation's account (per-account family)."""
94
97
  context = {
95
98
  "source": str(source), "source_root_key": str(source_root_key),
96
99
  "logical_limit_key": str(logical_limit_key), "observed_slot": str(observed_slot),
@@ -111,6 +114,7 @@ def _build_alert_payload_quota(
111
114
  ),
112
115
  "axis": "quota", "threshold": int(threshold), "kind": str(kind),
113
116
  "crossed_at": crossed_at_utc, "alerted_at": crossed_at_utc,
117
+ "account_key": str(account_key),
114
118
  **context, "context": context,
115
119
  }
116
120
 
@@ -158,6 +162,48 @@ def load_config(*args, **kwargs):
158
162
  from _cctally_core import now_utc_iso
159
163
 
160
164
 
165
+ # Axis -> the provider whose account registry drives the R8 `[label]` prefix.
166
+ # projected weekly_pct is Claude; its `*`-scoped vendor-budget metrics never take
167
+ # a prefix (account_key stays `*`), so mapping the whole axis to claude is safe.
168
+ _AXIS_VENDOR = {
169
+ "weekly": "claude", "five_hour": "claude", "budget": "claude",
170
+ "projected": "claude", "project_budget": "claude",
171
+ "codex_budget": "codex", "quota": "codex",
172
+ }
173
+
174
+
175
+ def _alert_label_prefix(axis: str, account_key: "str | None") -> str:
176
+ """Return ``"[<label>] "`` for an account-specific crossing when the vendor
177
+ has >1 real account (R8), else ``""``. Best-effort + never-raise: a missing
178
+ DB / read error / vendor-wide (`*`) row yields no prefix, so a ≤1-real-account
179
+ install's alert text stays byte-identical to today.
180
+
181
+ Delegates the R8 gate + label precedence to the single-definition helpers
182
+ ``_cctally_account.real_account_count`` / ``account_label`` (P2-CQ1) so the
183
+ ">1 real account" trigger and the key->label map have exactly one home; this
184
+ wrapper only opens the RO connection and stays best-effort/never-raise."""
185
+ if not account_key or account_key == _lib_accounts.VENDOR_WIDE:
186
+ return ""
187
+ vendor = _AXIS_VENDOR.get(axis)
188
+ if vendor is None:
189
+ return ""
190
+ try:
191
+ import sqlite3 as _sq
192
+ import _cctally_account
193
+ db_path = _cctally_core.DB_PATH
194
+ if not db_path.exists():
195
+ return ""
196
+ conn = _sq.connect(f"file:{db_path}?mode=ro", uri=True)
197
+ try:
198
+ if _cctally_account.real_account_count(conn, vendor) <= 1:
199
+ return ""
200
+ return f"[{_cctally_account.account_label(conn, account_key)}] "
201
+ finally:
202
+ conn.close()
203
+ except Exception:
204
+ return ""
205
+
206
+
161
207
  def _alerts_log_path() -> "pathlib.Path":
162
208
  """Return ``~/.local/share/cctally/logs/alerts.log`` (parent dirs created).
163
209
 
@@ -240,6 +286,12 @@ def _dispatch_alert_notification(
240
286
  f"axis={axis} threshold={payload.get('threshold')}",
241
287
  )
242
288
 
289
+ # R8 label prefix (#341): an account-specific crossing gains a `[<label>] `
290
+ # title prefix ONLY when the vendor has >1 real account. Byte-identical at
291
+ # <=1 real account (empty prefix). Applied to the notification title only.
292
+ account_key = payload.get("account_key")
293
+ title = _alert_label_prefix(axis, account_key) + title
294
+
243
295
  # Severity (3-tier) drives both the notify-send urgency token and the
244
296
  # trailing log column. A missing threshold (defensive — shouldn't happen for
245
297
  # a real crossing) floors at "info".
@@ -308,9 +360,13 @@ def _dispatch_alert_notification(
308
360
  or ctx.get("period_start_at")
309
361
  or ""
310
362
  )
363
+ # 8th tab field (#341, spec §6): the account_key the crossing wrote
364
+ # under (`*` for vendor-wide rows). UNCONDITIONAL — the log is runtime
365
+ # state, exempt from the R8 byte-stability contract.
366
+ log_account_key = account_key or _lib_accounts.VENDOR_WIDE
311
367
  line = (
312
368
  f"{now_utc_iso()}\t{axis}\t{payload.get('threshold')}\t{window_key}"
313
- f"\t{mode}\t{status}\t{severity}\n"
369
+ f"\t{mode}\t{status}\t{severity}\t{log_account_key}\n"
314
370
  )
315
371
  with open(log_path, "a") as f:
316
372
  f.write(line)