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.
- package/CHANGELOG.md +32 -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 +56 -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_pricing.py +27 -3
- 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
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
|
package/bin/_cctally_alerts.py
CHANGED
|
@@ -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)
|