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/bin/_cctally_core.py
CHANGED
|
@@ -78,7 +78,7 @@ def _init_paths_from_env() -> None:
|
|
|
78
78
|
global UPDATE_STATE_PATH, UPDATE_SUPPRESS_PATH
|
|
79
79
|
global UPDATE_LOCK_PATH, UPDATE_LOG_PATH, UPDATE_LOG_ROTATED_PATH
|
|
80
80
|
global UPDATE_CHECK_LAST_FETCH_PATH, CLAUDE_SETTINGS_PATH
|
|
81
|
-
global CLAUDE_PROJECTS_DIR
|
|
81
|
+
global CLAUDE_PROJECTS_DIR, CLAUDE_JSON_PATH
|
|
82
82
|
global TELEMETRY_INSTALL_ID_PATH, TELEMETRY_LAST_BEAT_PATH
|
|
83
83
|
global TELEMETRY_NOTICE_SHOWN_PATH, TELEMETRY_FIRST_SEEN_PATH
|
|
84
84
|
|
|
@@ -196,6 +196,14 @@ def _init_paths_from_env() -> None:
|
|
|
196
196
|
|
|
197
197
|
CLAUDE_SETTINGS_PATH = home / ".claude" / "settings.json"
|
|
198
198
|
|
|
199
|
+
# Claude's own identity file (#341): `~/.claude.json` carries the
|
|
200
|
+
# `oauthAccount` block (accountUuid / emailAddress / plan) maintained by
|
|
201
|
+
# Claude Code. cctally reads it READ-ONLY for account attribution; never
|
|
202
|
+
# written. Module constant so tests can `monkeypatch.setattr(_cctally_core,
|
|
203
|
+
# "CLAUDE_JSON_PATH", ...)`. Rewritten in place by Claude Code, so every read
|
|
204
|
+
# goes through the stat-read-stat stable-read protocol.
|
|
205
|
+
CLAUDE_JSON_PATH = home / ".claude.json"
|
|
206
|
+
|
|
199
207
|
# Claude session JSONL root. Production path is `~/.claude/projects`;
|
|
200
208
|
# exposed as a module-level constant so cross-DB migrations (e.g.
|
|
201
209
|
# stats migration 008) and the dispatcher's empty-disk fallback can
|
|
@@ -323,7 +331,14 @@ STATS_WAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024 # 16777216
|
|
|
323
331
|
# that ``open_db`` cuts over to the epoch on first open (spec §8). Schema change
|
|
324
332
|
# = bump ``STATS_INDEX_EPOCH`` (never a new stats migration — the registry is
|
|
325
333
|
# frozen).
|
|
326
|
-
|
|
334
|
+
#
|
|
335
|
+
# 1000 -> 1001 (#341, multi-account): the stats index gains the account
|
|
336
|
+
# dimension (account_key on every derived table + the accounts registry). An
|
|
337
|
+
# existing epoch-1000 index resolves the mismatch through the account
|
|
338
|
+
# epoch-transition coordinator (``_cctally_journal.run_epoch_transition``):
|
|
339
|
+
# resolve the cutover identity, append the canonical cutover op, then rebuild
|
|
340
|
+
# account-scoped. See docs/superpowers/specs/2026-07-23-multi-account-design.md §2.
|
|
341
|
+
STATS_INDEX_EPOCH = 1001
|
|
327
342
|
LEGACY_STATS_HEAD = 13
|
|
328
343
|
|
|
329
344
|
|
|
@@ -817,7 +832,7 @@ BUDGET_PERIODS = ("subscription-week", "calendar-week", "calendar-month")
|
|
|
817
832
|
CODEX_BUDGET_PERIODS = ("calendar-week", "calendar-month")
|
|
818
833
|
CODEX_BUDGET_LEAVES = (
|
|
819
834
|
"amount_usd", "period", "alerts_enabled", "alert_thresholds",
|
|
820
|
-
"projected_enabled",
|
|
835
|
+
"projected_enabled", "accounts",
|
|
821
836
|
)
|
|
822
837
|
_BUDGET_DEFAULTS = {
|
|
823
838
|
"weekly_usd": None, # None = no budget (default)
|
|
@@ -827,6 +842,7 @@ _BUDGET_DEFAULTS = {
|
|
|
827
842
|
"period": "subscription-week", # Claude period; default = existing behavior
|
|
828
843
|
"projects": {}, # per-project weekly $ budgets, keyed by git-root
|
|
829
844
|
"project_alerts_enabled": False, # per-project alerts opt-in (#19/#121); default OFF
|
|
845
|
+
"accounts": {}, # per-account weekly $ budgets, keyed by account_key (#341)
|
|
830
846
|
"codex": None, # None = no Codex budget (nested block when set)
|
|
831
847
|
}
|
|
832
848
|
_BUDGET_CONFIG_VALID_KEYS = {
|
|
@@ -837,10 +853,41 @@ _BUDGET_CONFIG_VALID_KEYS = {
|
|
|
837
853
|
"period",
|
|
838
854
|
"projects",
|
|
839
855
|
"project_alerts_enabled",
|
|
856
|
+
"accounts",
|
|
840
857
|
"codex",
|
|
841
858
|
}
|
|
842
859
|
|
|
843
860
|
|
|
861
|
+
def _validate_account_budget_map(v: object, label: str) -> "dict[str, float]":
|
|
862
|
+
"""Validate a per-account ``{account_key: usd}`` budget map (#341, spec §6).
|
|
863
|
+
|
|
864
|
+
Mirrors the ``budget.projects`` value rule: keys are strings (immutable
|
|
865
|
+
account keys — refs are normalized to keys at ``config set`` write time),
|
|
866
|
+
each value a non-bool finite number > 0. Returns a cleaned copy."""
|
|
867
|
+
if not isinstance(v, dict):
|
|
868
|
+
raise _BudgetConfigError(
|
|
869
|
+
f"{label} must be an object, got {type(v).__name__}"
|
|
870
|
+
)
|
|
871
|
+
cleaned: "dict[str, float]" = {}
|
|
872
|
+
for acc_key, acc_val in v.items():
|
|
873
|
+
if not isinstance(acc_key, str) or not acc_key:
|
|
874
|
+
raise _BudgetConfigError(
|
|
875
|
+
f"{label} keys must be non-empty strings (account keys)"
|
|
876
|
+
)
|
|
877
|
+
if isinstance(acc_val, bool) or not isinstance(acc_val, (int, float)):
|
|
878
|
+
raise _BudgetConfigError(
|
|
879
|
+
f"{label} values must be numbers, "
|
|
880
|
+
f"got {type(acc_val).__name__} for key {acc_key!r}"
|
|
881
|
+
)
|
|
882
|
+
if not math.isfinite(float(acc_val)) or float(acc_val) <= 0:
|
|
883
|
+
raise _BudgetConfigError(
|
|
884
|
+
f"{label} values must be finite numbers > 0, "
|
|
885
|
+
f"got {acc_val!r} for key {acc_key!r}"
|
|
886
|
+
)
|
|
887
|
+
cleaned[acc_key] = float(acc_val)
|
|
888
|
+
return cleaned
|
|
889
|
+
|
|
890
|
+
|
|
844
891
|
def _get_budget_config(cfg: dict) -> dict:
|
|
845
892
|
"""Return the validated, defaults-filled budget block.
|
|
846
893
|
|
|
@@ -938,6 +985,11 @@ def _get_budget_config(cfg: dict) -> dict:
|
|
|
938
985
|
)
|
|
939
986
|
out["project_alerts_enabled"] = v
|
|
940
987
|
|
|
988
|
+
if "accounts" in block:
|
|
989
|
+
out["accounts"] = _validate_account_budget_map(
|
|
990
|
+
block["accounts"], "budget.accounts"
|
|
991
|
+
)
|
|
992
|
+
|
|
941
993
|
if "codex" in block:
|
|
942
994
|
out["codex"] = _validate_codex_budget_block(block["codex"])
|
|
943
995
|
|
|
@@ -994,15 +1046,23 @@ def _validate_codex_budget_block(v: object) -> "dict | None":
|
|
|
994
1046
|
"alert_thresholds": [90, 100],
|
|
995
1047
|
"projected_enabled": False,
|
|
996
1048
|
}
|
|
997
|
-
# amount_usd —
|
|
998
|
-
#
|
|
999
|
-
#
|
|
1000
|
-
#
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
v["amount_usd"], "budget.codex.amount_usd"
|
|
1049
|
+
# amount_usd — the vendor-wide Codex budget, finite > 0. Required UNLESS a
|
|
1050
|
+
# non-empty per-account map (`accounts`, #341) is present: a Codex block may
|
|
1051
|
+
# be per-account-only (no vendor-wide amount), in which case amount_usd stays
|
|
1052
|
+
# None. Shares the positive-amount rule with weekly_usd / projects.
|
|
1053
|
+
_codex_accounts_raw = v.get("accounts")
|
|
1054
|
+
_has_codex_accounts = (
|
|
1055
|
+
isinstance(_codex_accounts_raw, dict) and len(_codex_accounts_raw) > 0
|
|
1005
1056
|
)
|
|
1057
|
+
if "amount_usd" in v and v["amount_usd"] is not None:
|
|
1058
|
+
out["amount_usd"] = _validate_positive_budget_amount(
|
|
1059
|
+
v["amount_usd"], "budget.codex.amount_usd"
|
|
1060
|
+
)
|
|
1061
|
+
elif not _has_codex_accounts:
|
|
1062
|
+
raise _BudgetConfigError(
|
|
1063
|
+
"budget.codex.amount_usd is required (or set a per-account "
|
|
1064
|
+
"budget.codex.accounts map)"
|
|
1065
|
+
)
|
|
1006
1066
|
|
|
1007
1067
|
if "period" in v:
|
|
1008
1068
|
p = v["period"]
|
|
@@ -1035,6 +1095,13 @@ def _validate_codex_budget_block(v: object) -> "dict | None":
|
|
|
1035
1095
|
)
|
|
1036
1096
|
out["projected_enabled"] = pe
|
|
1037
1097
|
|
|
1098
|
+
# Per-account Codex budgets (#341, spec §6). Conditionally present so an
|
|
1099
|
+
# existing codex block (no accounts) validates byte-identically.
|
|
1100
|
+
if "accounts" in v:
|
|
1101
|
+
out["accounts"] = _validate_account_budget_map(
|
|
1102
|
+
v["accounts"], "budget.codex.accounts"
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1038
1105
|
return out
|
|
1039
1106
|
|
|
1040
1107
|
|
|
@@ -1056,6 +1123,11 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
|
|
|
1056
1123
|
013 calls this same helper for old databases while ``open_db`` calls it for
|
|
1057
1124
|
fresh installs before the migration dispatcher stamps the migration.
|
|
1058
1125
|
"""
|
|
1126
|
+
# account_key (#341 Task 2): every quota identity is (source_root_key,
|
|
1127
|
+
# account_key)-qualified — the UNIQUEs/PK are extended so two accounts on one
|
|
1128
|
+
# physical window never merge, and each carries `DEFAULT 'unattributed'` so a
|
|
1129
|
+
# NULL/single-account cache renders byte-identically (spec §2). No new epoch
|
|
1130
|
+
# bump: this rides Task 1's STATS_INDEX_EPOCH 1000->1001 rebuild.
|
|
1059
1131
|
conn.executescript(
|
|
1060
1132
|
"""
|
|
1061
1133
|
CREATE TABLE IF NOT EXISTS quota_window_blocks (
|
|
@@ -1077,11 +1149,12 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
|
|
|
1077
1149
|
last_line_offset INTEGER NOT NULL,
|
|
1078
1150
|
generation TEXT NOT NULL,
|
|
1079
1151
|
orphaned_at TEXT,
|
|
1080
|
-
|
|
1081
|
-
|
|
1152
|
+
account_key TEXT NOT NULL DEFAULT 'unattributed',
|
|
1153
|
+
UNIQUE(source, source_root_key, account_key, logical_limit_key,
|
|
1154
|
+
observed_slot, window_minutes, resets_at_utc)
|
|
1082
1155
|
);
|
|
1083
1156
|
CREATE INDEX IF NOT EXISTS idx_quota_blocks_active
|
|
1084
|
-
ON quota_window_blocks(source, source_root_key, orphaned_at,
|
|
1157
|
+
ON quota_window_blocks(source, source_root_key, account_key, orphaned_at,
|
|
1085
1158
|
logical_limit_key, observed_slot,
|
|
1086
1159
|
window_minutes, resets_at_utc);
|
|
1087
1160
|
|
|
@@ -1100,11 +1173,12 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
|
|
|
1100
1173
|
high_water_percent INTEGER NOT NULL CHECK(high_water_percent BETWEEN 1 AND 100),
|
|
1101
1174
|
generation TEXT NOT NULL,
|
|
1102
1175
|
orphaned_at TEXT,
|
|
1103
|
-
|
|
1104
|
-
|
|
1176
|
+
account_key TEXT NOT NULL DEFAULT 'unattributed',
|
|
1177
|
+
UNIQUE(source, source_root_key, account_key, logical_limit_key,
|
|
1178
|
+
observed_slot, window_minutes, resets_at_utc, percent_threshold)
|
|
1105
1179
|
);
|
|
1106
1180
|
CREATE INDEX IF NOT EXISTS idx_quota_milestones_active
|
|
1107
|
-
ON quota_percent_milestones(source, source_root_key, orphaned_at,
|
|
1181
|
+
ON quota_percent_milestones(source, source_root_key, account_key, orphaned_at,
|
|
1108
1182
|
logical_limit_key, observed_slot,
|
|
1109
1183
|
window_minutes, resets_at_utc,
|
|
1110
1184
|
percent_threshold);
|
|
@@ -1127,21 +1201,24 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
|
|
|
1127
1201
|
alerted_at TEXT,
|
|
1128
1202
|
suppressed_at TEXT,
|
|
1129
1203
|
orphaned_at TEXT,
|
|
1204
|
+
account_key TEXT NOT NULL DEFAULT 'unattributed',
|
|
1130
1205
|
CHECK((disposition = 'alerted' AND alerted_at IS NOT NULL AND suppressed_at IS NULL)
|
|
1131
1206
|
OR (disposition = 'suppressed_backfill' AND suppressed_at IS NOT NULL AND alerted_at IS NULL)),
|
|
1132
|
-
UNIQUE(source, source_root_key,
|
|
1133
|
-
window_minutes, resets_at_utc, threshold)
|
|
1207
|
+
UNIQUE(source, source_root_key, account_key, logical_limit_key,
|
|
1208
|
+
observed_slot, window_minutes, resets_at_utc, threshold)
|
|
1134
1209
|
);
|
|
1135
1210
|
CREATE INDEX IF NOT EXISTS idx_quota_threshold_events_active
|
|
1136
|
-
ON quota_threshold_events(source, source_root_key, orphaned_at,
|
|
1211
|
+
ON quota_threshold_events(source, source_root_key, account_key, orphaned_at,
|
|
1137
1212
|
logical_limit_key, observed_slot,
|
|
1138
1213
|
window_minutes, resets_at_utc, threshold);
|
|
1139
1214
|
|
|
1140
1215
|
CREATE TABLE IF NOT EXISTS quota_projection_state (
|
|
1141
|
-
source_root_key TEXT
|
|
1216
|
+
source_root_key TEXT NOT NULL,
|
|
1217
|
+
account_key TEXT NOT NULL DEFAULT 'unattributed',
|
|
1142
1218
|
generation TEXT NOT NULL,
|
|
1143
1219
|
physical_signature TEXT NOT NULL,
|
|
1144
|
-
completed_at_utc TEXT NOT NULL
|
|
1220
|
+
completed_at_utc TEXT NOT NULL,
|
|
1221
|
+
PRIMARY KEY(source_root_key, account_key)
|
|
1145
1222
|
);
|
|
1146
1223
|
|
|
1147
1224
|
CREATE TABLE IF NOT EXISTS quota_alert_arming (
|
|
@@ -1153,11 +1230,39 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
|
|
|
1153
1230
|
window_minutes INTEGER NOT NULL CHECK(window_minutes > 0),
|
|
1154
1231
|
rule_fingerprint TEXT NOT NULL,
|
|
1155
1232
|
activated_at_utc TEXT NOT NULL,
|
|
1156
|
-
|
|
1157
|
-
|
|
1233
|
+
account_key TEXT NOT NULL DEFAULT 'unattributed',
|
|
1234
|
+
UNIQUE(source, source_root_key, account_key, logical_limit_key,
|
|
1235
|
+
observed_slot, window_minutes)
|
|
1158
1236
|
);
|
|
1159
1237
|
"""
|
|
1160
1238
|
)
|
|
1239
|
+
# account_key backstops (#341): idempotent no-ops on the fresh CREATEs above
|
|
1240
|
+
# (which already carry the column), but they keep an already-1001 stats.db
|
|
1241
|
+
# that predates the column consistent. quota_projection_state widened its
|
|
1242
|
+
# PRIMARY KEY to (source_root_key, account_key) — a PK cannot be added by
|
|
1243
|
+
# ALTER, so a pre-#341-quota shape (disposable, re-derivable coherence index)
|
|
1244
|
+
# is DROP+recreated rather than back-filled.
|
|
1245
|
+
_c = sys.modules.get("cctally")
|
|
1246
|
+
add_column_if_missing = _c.add_column_if_missing if _c is not None else None
|
|
1247
|
+
_proj_cols = {
|
|
1248
|
+
str(row[1]) for row in conn.execute("PRAGMA table_info(quota_projection_state)")
|
|
1249
|
+
}
|
|
1250
|
+
if "account_key" not in _proj_cols:
|
|
1251
|
+
conn.execute("DROP TABLE quota_projection_state")
|
|
1252
|
+
conn.execute(
|
|
1253
|
+
"CREATE TABLE quota_projection_state ("
|
|
1254
|
+
" source_root_key TEXT NOT NULL,"
|
|
1255
|
+
" account_key TEXT NOT NULL DEFAULT 'unattributed',"
|
|
1256
|
+
" generation TEXT NOT NULL,"
|
|
1257
|
+
" physical_signature TEXT NOT NULL,"
|
|
1258
|
+
" completed_at_utc TEXT NOT NULL,"
|
|
1259
|
+
" PRIMARY KEY(source_root_key, account_key))"
|
|
1260
|
+
)
|
|
1261
|
+
if add_column_if_missing is not None:
|
|
1262
|
+
for _tbl in ("quota_window_blocks", "quota_percent_milestones",
|
|
1263
|
+
"quota_threshold_events", "quota_alert_arming"):
|
|
1264
|
+
add_column_if_missing(
|
|
1265
|
+
conn, _tbl, "account_key", "TEXT NOT NULL DEFAULT 'unattributed'")
|
|
1161
1266
|
|
|
1162
1267
|
|
|
1163
1268
|
def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
@@ -1329,7 +1434,8 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
1329
1434
|
weekly_percent REAL NOT NULL,
|
|
1330
1435
|
page_url TEXT,
|
|
1331
1436
|
source TEXT NOT NULL DEFAULT 'userscript',
|
|
1332
|
-
payload_json TEXT NOT NULL
|
|
1437
|
+
payload_json TEXT NOT NULL,
|
|
1438
|
+
account_key TEXT NOT NULL DEFAULT 'unattributed'
|
|
1333
1439
|
)
|
|
1334
1440
|
"""
|
|
1335
1441
|
)
|
|
@@ -1353,7 +1459,8 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
1353
1459
|
cost_usd REAL NOT NULL,
|
|
1354
1460
|
source TEXT NOT NULL DEFAULT 'cctally-range-cost',
|
|
1355
1461
|
mode TEXT NOT NULL DEFAULT 'auto',
|
|
1356
|
-
project TEXT
|
|
1462
|
+
project TEXT,
|
|
1463
|
+
account_key TEXT NOT NULL DEFAULT 'unattributed'
|
|
1357
1464
|
)
|
|
1358
1465
|
"""
|
|
1359
1466
|
)
|
|
@@ -1366,6 +1473,14 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
1366
1473
|
|
|
1367
1474
|
add_column_if_missing(conn, "weekly_usage_snapshots", "week_start_at", "TEXT")
|
|
1368
1475
|
add_column_if_missing(conn, "weekly_usage_snapshots", "week_end_at", "TEXT")
|
|
1476
|
+
# account_key (#341): the account dimension rides the STATS_INDEX_EPOCH bump
|
|
1477
|
+
# (a fresh rebuild carries it via the CREATE TABLE above); this backstop keeps
|
|
1478
|
+
# an already-1001 DB that predates the column consistent. DEFAULT
|
|
1479
|
+
# 'unattributed' — every production writer passes the key explicitly (rev 4.1
|
|
1480
|
+
# defensive-backstop rule), enforced by the structural writer-audit test.
|
|
1481
|
+
add_column_if_missing(
|
|
1482
|
+
conn, "weekly_usage_snapshots", "account_key",
|
|
1483
|
+
"TEXT NOT NULL DEFAULT 'unattributed'")
|
|
1369
1484
|
add_column_if_missing(conn, "weekly_usage_snapshots", "five_hour_percent", "REAL")
|
|
1370
1485
|
add_column_if_missing(conn, "weekly_usage_snapshots", "five_hour_resets_at", "TEXT")
|
|
1371
1486
|
# five_hour_window_key — canonical (10-min-floored epoch) key for
|
|
@@ -1425,6 +1540,9 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
1425
1540
|
add_column_if_missing(conn, "weekly_cost_snapshots", "week_end_at", "TEXT")
|
|
1426
1541
|
add_column_if_missing(conn, "weekly_cost_snapshots", "range_start_iso", "TEXT")
|
|
1427
1542
|
add_column_if_missing(conn, "weekly_cost_snapshots", "range_end_iso", "TEXT")
|
|
1543
|
+
add_column_if_missing(
|
|
1544
|
+
conn, "weekly_cost_snapshots", "account_key",
|
|
1545
|
+
"TEXT NOT NULL DEFAULT 'unattributed'")
|
|
1428
1546
|
|
|
1429
1547
|
conn.execute(
|
|
1430
1548
|
"""
|
|
@@ -1454,12 +1572,16 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
1454
1572
|
usage_snapshot_id INTEGER NOT NULL,
|
|
1455
1573
|
cost_snapshot_id INTEGER NOT NULL,
|
|
1456
1574
|
reset_event_id INTEGER NOT NULL DEFAULT 0,
|
|
1457
|
-
|
|
1575
|
+
account_key TEXT NOT NULL DEFAULT 'unattributed',
|
|
1576
|
+
UNIQUE(account_key, week_start_date, percent_threshold, reset_event_id)
|
|
1458
1577
|
)
|
|
1459
1578
|
"""
|
|
1460
1579
|
)
|
|
1461
1580
|
|
|
1462
1581
|
add_column_if_missing(conn, "percent_milestones", "five_hour_percent_at_crossing", "REAL")
|
|
1582
|
+
add_column_if_missing(
|
|
1583
|
+
conn, "percent_milestones", "account_key",
|
|
1584
|
+
"TEXT NOT NULL DEFAULT 'unattributed'")
|
|
1463
1585
|
# reset_event_id: segment column added by migration 005. Fresh-install
|
|
1464
1586
|
# DBs get it via the live CREATE TABLE above + the dispatcher
|
|
1465
1587
|
# fast-stamps the migration. Existing pre-005 DBs trip the migration's
|
|
@@ -1495,10 +1617,14 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
1495
1617
|
new_week_end_at TEXT NOT NULL,
|
|
1496
1618
|
effective_reset_at_utc TEXT NOT NULL,
|
|
1497
1619
|
observed_pre_credit_pct REAL,
|
|
1498
|
-
|
|
1620
|
+
account_key TEXT NOT NULL DEFAULT 'unattributed',
|
|
1621
|
+
UNIQUE(account_key, old_week_end_at, new_week_end_at)
|
|
1499
1622
|
)
|
|
1500
1623
|
"""
|
|
1501
1624
|
)
|
|
1625
|
+
add_column_if_missing(
|
|
1626
|
+
conn, "week_reset_events", "account_key",
|
|
1627
|
+
"TEXT NOT NULL DEFAULT 'unattributed'")
|
|
1502
1628
|
_backfill_week_reset_events(conn)
|
|
1503
1629
|
|
|
1504
1630
|
# ── five_hour_reset_events (Anthropic-issued in-place 5h credits) ──
|
|
@@ -1533,17 +1659,21 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
1533
1659
|
prior_percent REAL NOT NULL,
|
|
1534
1660
|
post_percent REAL NOT NULL,
|
|
1535
1661
|
effective_reset_at_utc TEXT NOT NULL,
|
|
1536
|
-
|
|
1662
|
+
account_key TEXT NOT NULL DEFAULT 'unattributed',
|
|
1663
|
+
UNIQUE(account_key, five_hour_window_key, effective_reset_at_utc)
|
|
1537
1664
|
)
|
|
1538
1665
|
"""
|
|
1539
1666
|
)
|
|
1667
|
+
add_column_if_missing(
|
|
1668
|
+
conn, "five_hour_reset_events", "account_key",
|
|
1669
|
+
"TEXT NOT NULL DEFAULT 'unattributed'")
|
|
1540
1670
|
|
|
1541
1671
|
# ── five_hour_blocks (rollup, one row per API-anchored 5h block) ──
|
|
1542
1672
|
conn.execute(
|
|
1543
1673
|
"""
|
|
1544
1674
|
CREATE TABLE IF NOT EXISTS five_hour_blocks (
|
|
1545
1675
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1546
|
-
five_hour_window_key INTEGER NOT NULL
|
|
1676
|
+
five_hour_window_key INTEGER NOT NULL,
|
|
1547
1677
|
five_hour_resets_at TEXT NOT NULL,
|
|
1548
1678
|
block_start_at TEXT NOT NULL,
|
|
1549
1679
|
first_observed_at_utc TEXT NOT NULL,
|
|
@@ -1559,10 +1689,15 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
1559
1689
|
total_cost_usd REAL NOT NULL DEFAULT 0,
|
|
1560
1690
|
is_closed INTEGER NOT NULL DEFAULT 0,
|
|
1561
1691
|
created_at_utc TEXT NOT NULL,
|
|
1562
|
-
last_updated_at_utc TEXT NOT NULL
|
|
1692
|
+
last_updated_at_utc TEXT NOT NULL,
|
|
1693
|
+
account_key TEXT NOT NULL DEFAULT 'unattributed',
|
|
1694
|
+
UNIQUE(account_key, five_hour_window_key)
|
|
1563
1695
|
)
|
|
1564
1696
|
"""
|
|
1565
1697
|
)
|
|
1698
|
+
add_column_if_missing(
|
|
1699
|
+
conn, "five_hour_blocks", "account_key",
|
|
1700
|
+
"TEXT NOT NULL DEFAULT 'unattributed'")
|
|
1566
1701
|
conn.execute(
|
|
1567
1702
|
"""
|
|
1568
1703
|
CREATE INDEX IF NOT EXISTS idx_five_hour_blocks_block_start
|
|
@@ -1588,7 +1723,8 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
1588
1723
|
marginal_cost_usd REAL,
|
|
1589
1724
|
seven_day_pct_at_crossing REAL,
|
|
1590
1725
|
reset_event_id INTEGER NOT NULL DEFAULT 0,
|
|
1591
|
-
|
|
1726
|
+
account_key TEXT NOT NULL DEFAULT 'unattributed',
|
|
1727
|
+
UNIQUE(account_key, five_hour_window_key, percent_threshold, reset_event_id),
|
|
1592
1728
|
FOREIGN KEY (block_id) REFERENCES five_hour_blocks(id)
|
|
1593
1729
|
)
|
|
1594
1730
|
"""
|
|
@@ -1607,6 +1743,9 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
1607
1743
|
# "alerts disabled at moment of crossing OR threshold not configured"
|
|
1608
1744
|
# — never "delivery failed".
|
|
1609
1745
|
add_column_if_missing(conn, "five_hour_milestones", "alerted_at", "TEXT")
|
|
1746
|
+
add_column_if_missing(
|
|
1747
|
+
conn, "five_hour_milestones", "account_key",
|
|
1748
|
+
"TEXT NOT NULL DEFAULT 'unattributed'")
|
|
1610
1749
|
|
|
1611
1750
|
# reset_event_id: segment column added by migration 006. Fresh-install
|
|
1612
1751
|
# DBs get it via the live CREATE TABLE above + the dispatcher fast-stamps
|
|
@@ -1636,11 +1775,15 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
1636
1775
|
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
1637
1776
|
cost_usd REAL NOT NULL DEFAULT 0,
|
|
1638
1777
|
entry_count INTEGER NOT NULL DEFAULT 0,
|
|
1639
|
-
|
|
1778
|
+
account_key TEXT NOT NULL DEFAULT 'unattributed',
|
|
1779
|
+
UNIQUE(account_key, five_hour_window_key, model),
|
|
1640
1780
|
FOREIGN KEY (block_id) REFERENCES five_hour_blocks(id)
|
|
1641
1781
|
)
|
|
1642
1782
|
"""
|
|
1643
1783
|
)
|
|
1784
|
+
add_column_if_missing(
|
|
1785
|
+
conn, "five_hour_block_models", "account_key",
|
|
1786
|
+
"TEXT NOT NULL DEFAULT 'unattributed'")
|
|
1644
1787
|
conn.execute(
|
|
1645
1788
|
"""
|
|
1646
1789
|
CREATE INDEX IF NOT EXISTS idx_five_hour_block_models_block
|
|
@@ -1670,11 +1813,15 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
1670
1813
|
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
1671
1814
|
cost_usd REAL NOT NULL DEFAULT 0,
|
|
1672
1815
|
entry_count INTEGER NOT NULL DEFAULT 0,
|
|
1673
|
-
|
|
1816
|
+
account_key TEXT NOT NULL DEFAULT 'unattributed',
|
|
1817
|
+
UNIQUE(account_key, five_hour_window_key, project_path),
|
|
1674
1818
|
FOREIGN KEY (block_id) REFERENCES five_hour_blocks(id)
|
|
1675
1819
|
)
|
|
1676
1820
|
"""
|
|
1677
1821
|
)
|
|
1822
|
+
add_column_if_missing(
|
|
1823
|
+
conn, "five_hour_block_projects", "account_key",
|
|
1824
|
+
"TEXT NOT NULL DEFAULT 'unattributed'")
|
|
1678
1825
|
conn.execute(
|
|
1679
1826
|
"""
|
|
1680
1827
|
CREATE INDEX IF NOT EXISTS idx_five_hour_block_projects_block
|
|
@@ -1726,10 +1873,13 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
1726
1873
|
consumption_pct REAL NOT NULL,
|
|
1727
1874
|
crossed_at_utc TEXT NOT NULL,
|
|
1728
1875
|
alerted_at TEXT,
|
|
1729
|
-
|
|
1876
|
+
account_key TEXT NOT NULL DEFAULT '*',
|
|
1877
|
+
UNIQUE(vendor, account_key, period_start_at, period, threshold)
|
|
1730
1878
|
)
|
|
1731
1879
|
"""
|
|
1732
1880
|
)
|
|
1881
|
+
add_column_if_missing(
|
|
1882
|
+
conn, "budget_milestones", "account_key", "TEXT NOT NULL DEFAULT '*'")
|
|
1733
1883
|
|
|
1734
1884
|
# ── projected_milestones (week-average-pace projection crossings — #121) ──
|
|
1735
1885
|
# Write-once, forward-only — same posture as `budget_milestones` (no
|
|
@@ -1758,10 +1908,13 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
1758
1908
|
denominator REAL NOT NULL, -- target_usd (budget / codex_budget) | 100.0 (weekly)
|
|
1759
1909
|
crossed_at_utc TEXT NOT NULL,
|
|
1760
1910
|
alerted_at TEXT,
|
|
1761
|
-
|
|
1911
|
+
account_key TEXT NOT NULL DEFAULT '*', -- '*' for vendor-budget metrics; real account for weekly_pct (Task 3)
|
|
1912
|
+
UNIQUE(account_key, week_start_at, period, metric, threshold)
|
|
1762
1913
|
)
|
|
1763
1914
|
"""
|
|
1764
1915
|
)
|
|
1916
|
+
add_column_if_missing(
|
|
1917
|
+
conn, "projected_milestones", "account_key", "TEXT NOT NULL DEFAULT '*'")
|
|
1765
1918
|
|
|
1766
1919
|
# ── project_budget_milestones (per-project equiv-$ budget crossings) ──────
|
|
1767
1920
|
# Plain CREATE TABLE IF NOT EXISTS, NO migration handler / backfill — the
|
|
@@ -1794,10 +1947,14 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
1794
1947
|
consumption_pct REAL NOT NULL,
|
|
1795
1948
|
crossed_at_utc TEXT NOT NULL,
|
|
1796
1949
|
alerted_at TEXT,
|
|
1797
|
-
|
|
1950
|
+
account_key TEXT NOT NULL DEFAULT '*', -- account-blind this epic (spec §6): always '*'
|
|
1951
|
+
UNIQUE(account_key, week_start_at, project_key, threshold)
|
|
1798
1952
|
)
|
|
1799
1953
|
"""
|
|
1800
1954
|
)
|
|
1955
|
+
add_column_if_missing(
|
|
1956
|
+
conn, "project_budget_milestones", "account_key",
|
|
1957
|
+
"TEXT NOT NULL DEFAULT '*'")
|
|
1801
1958
|
|
|
1802
1959
|
# In-place weekly partial-credit floor (issue #209, record-credit M2).
|
|
1803
1960
|
# Plain CREATE TABLE IF NOT EXISTS, NO migration handler / NO user_version
|
|
@@ -1822,7 +1979,39 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
1822
1979
|
effective_at_utc TEXT NOT NULL,
|
|
1823
1980
|
observed_pre_credit_pct REAL NOT NULL,
|
|
1824
1981
|
applied_at_utc TEXT NOT NULL,
|
|
1825
|
-
|
|
1982
|
+
account_key TEXT NOT NULL DEFAULT 'unattributed',
|
|
1983
|
+
UNIQUE(account_key, week_start_date, effective_at_utc)
|
|
1984
|
+
)
|
|
1985
|
+
"""
|
|
1986
|
+
)
|
|
1987
|
+
add_column_if_missing(
|
|
1988
|
+
conn, "weekly_credit_floors", "account_key",
|
|
1989
|
+
"TEXT NOT NULL DEFAULT 'unattributed'")
|
|
1990
|
+
|
|
1991
|
+
# ── accounts registry (multi-account epic #341, spec §1/§2) ───────────────
|
|
1992
|
+
# Derived from the journal like all stats.db state: `account_observe` op
|
|
1993
|
+
# lines fold into rows here (via _apply_op_account_observe), `account_label`
|
|
1994
|
+
# ops set the user label. Framework-untracked (plain CREATE TABLE IF NOT
|
|
1995
|
+
# EXISTS, no migration / no user_version bump — same posture as
|
|
1996
|
+
# weekly_credit_floors above), so it never touches `schema_migrations` and
|
|
1997
|
+
# the stats-schema change rides the STATS_INDEX_EPOCH bump + rebuild, not a
|
|
1998
|
+
# new stats migration (the frozen-registry rule). `last_seen_utc` is derived
|
|
1999
|
+
# at fold time from the max `at` of any account-stamped line, NOT carried by
|
|
2000
|
+
# the observe record. `label_source` records provenance for the
|
|
2001
|
+
# user > switcher > auto precedence rule. A new empty table is byte-invisible
|
|
2002
|
+
# to every existing render, preserving the R8 byte-stability contract.
|
|
2003
|
+
conn.execute(
|
|
2004
|
+
"""
|
|
2005
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
2006
|
+
account_key TEXT PRIMARY KEY,
|
|
2007
|
+
provider TEXT NOT NULL,
|
|
2008
|
+
natural_id TEXT,
|
|
2009
|
+
email TEXT,
|
|
2010
|
+
label TEXT,
|
|
2011
|
+
plan_type TEXT,
|
|
2012
|
+
label_source TEXT NOT NULL DEFAULT 'auto',
|
|
2013
|
+
first_seen_utc TEXT,
|
|
2014
|
+
last_seen_utc TEXT
|
|
1826
2015
|
)
|
|
1827
2016
|
"""
|
|
1828
2017
|
)
|
|
@@ -2056,28 +2245,38 @@ def _get_latest_row_for_week(
|
|
|
2056
2245
|
table_name: str,
|
|
2057
2246
|
week_ref: WeekRef,
|
|
2058
2247
|
as_of_utc: str | None = None,
|
|
2248
|
+
*,
|
|
2249
|
+
account_key: str | None = None,
|
|
2059
2250
|
) -> sqlite3.Row | None:
|
|
2251
|
+
"""Latest week row from a weekly snapshot table.
|
|
2252
|
+
|
|
2253
|
+
``account_key`` (#341): ``None`` = the account-blind merged read (today's
|
|
2254
|
+
behavior, byte-stable); a real key scopes to that account's rows — the
|
|
2255
|
+
``--account`` render consumers and the write-path milestone-cost read (P2-1)
|
|
2256
|
+
pass it explicitly."""
|
|
2257
|
+
acct_pred = "" if account_key is None else " AND account_key = ?"
|
|
2258
|
+
acct_params: tuple = () if account_key is None else (account_key,)
|
|
2060
2259
|
if as_of_utc is None:
|
|
2061
2260
|
return conn.execute(
|
|
2062
2261
|
f"""
|
|
2063
2262
|
SELECT *
|
|
2064
2263
|
FROM {table_name}
|
|
2065
|
-
WHERE week_start_date = ?
|
|
2264
|
+
WHERE week_start_date = ?{acct_pred}
|
|
2066
2265
|
ORDER BY captured_at_utc DESC, id DESC
|
|
2067
2266
|
LIMIT 1
|
|
2068
2267
|
""",
|
|
2069
|
-
(week_ref.week_start.isoformat(),),
|
|
2268
|
+
(week_ref.week_start.isoformat(),) + acct_params,
|
|
2070
2269
|
).fetchone()
|
|
2071
2270
|
return conn.execute(
|
|
2072
2271
|
f"""
|
|
2073
2272
|
SELECT *
|
|
2074
2273
|
FROM {table_name}
|
|
2075
2274
|
WHERE week_start_date = ?
|
|
2076
|
-
AND captured_at_utc <= ?
|
|
2275
|
+
AND captured_at_utc <= ?{acct_pred}
|
|
2077
2276
|
ORDER BY captured_at_utc DESC, id DESC
|
|
2078
2277
|
LIMIT 1
|
|
2079
2278
|
""",
|
|
2080
|
-
(week_ref.week_start.isoformat(), as_of_utc),
|
|
2279
|
+
(week_ref.week_start.isoformat(), as_of_utc) + acct_params,
|
|
2081
2280
|
).fetchone()
|
|
2082
2281
|
|
|
2083
2282
|
|
|
@@ -2086,11 +2285,19 @@ def _reset_aware_floor(
|
|
|
2086
2285
|
week_start_date: str,
|
|
2087
2286
|
week_start_at: str,
|
|
2088
2287
|
week_end_at: str,
|
|
2288
|
+
*,
|
|
2289
|
+
account_key: str | None,
|
|
2089
2290
|
) -> str | None:
|
|
2090
2291
|
"""Return the latest in-week clamp floor (an ISO timestamp) across BOTH
|
|
2091
2292
|
`week_reset_events` and `weekly_credit_floors`, or None when neither has a
|
|
2092
2293
|
row for this week.
|
|
2093
2294
|
|
|
2295
|
+
``account_key`` (#341, review finding 11): MANDATORY account context — no
|
|
2296
|
+
silent global fallback. A real key scopes both legs to that account's
|
|
2297
|
+
reset/credit rows (so one account's mid-week credit never clamps another's
|
|
2298
|
+
week); ``None`` is the explicit "all accounts" (merged) read used by the
|
|
2299
|
+
analytics floor path, byte-identical to today on a single-account install.
|
|
2300
|
+
|
|
2094
2301
|
This is the single chokepoint the four MAX-clamp sites consult to floor the
|
|
2095
2302
|
current 7d % to the most-recent in-place credit / reset effective moment
|
|
2096
2303
|
(record-credit M2, issue #209, spec §4a):
|
|
@@ -2109,27 +2316,32 @@ def _reset_aware_floor(
|
|
|
2109
2316
|
spellings (`Z` / `+00:00`), and a lexical MAX would silently mis-order them
|
|
2110
2317
|
on a non-UTC host (the same gotcha as the statusline clamp / 5h-block
|
|
2111
2318
|
cross-reset flag; see the comment at bin/_cctally_statusline.py)."""
|
|
2319
|
+
acct_pred = "" if account_key is None else " AND account_key = ?"
|
|
2320
|
+
reset_params: tuple = (week_start_at, week_end_at) + (
|
|
2321
|
+
() if account_key is None else (account_key,))
|
|
2322
|
+
floor_params: tuple = (week_start_date,) + (
|
|
2323
|
+
() if account_key is None else (account_key,))
|
|
2112
2324
|
row = conn.execute(
|
|
2113
|
-
"""
|
|
2325
|
+
f"""
|
|
2114
2326
|
SELECT floor_at FROM (
|
|
2115
2327
|
SELECT effective_reset_at_utc AS floor_at
|
|
2116
2328
|
FROM week_reset_events
|
|
2117
2329
|
WHERE unixepoch(effective_reset_at_utc) >= unixepoch(?)
|
|
2118
|
-
AND unixepoch(effective_reset_at_utc) < unixepoch(?)
|
|
2330
|
+
AND unixepoch(effective_reset_at_utc) < unixepoch(?){acct_pred}
|
|
2119
2331
|
UNION ALL
|
|
2120
2332
|
SELECT effective_at_utc AS floor_at
|
|
2121
2333
|
FROM weekly_credit_floors
|
|
2122
|
-
WHERE week_start_date = ?
|
|
2334
|
+
WHERE week_start_date = ?{acct_pred}
|
|
2123
2335
|
)
|
|
2124
2336
|
ORDER BY unixepoch(floor_at) DESC
|
|
2125
2337
|
LIMIT 1
|
|
2126
2338
|
""",
|
|
2127
|
-
|
|
2339
|
+
reset_params + floor_params,
|
|
2128
2340
|
).fetchone()
|
|
2129
2341
|
return row[0] if row and row[0] else None
|
|
2130
2342
|
|
|
2131
2343
|
|
|
2132
|
-
def _floored_week_max(conn, rows):
|
|
2344
|
+
def _floored_week_max(conn, rows, *, account_key=None):
|
|
2133
2345
|
"""Return {week_key -> per-week reset-aware-floored maximum weekly_percent}.
|
|
2134
2346
|
|
|
2135
2347
|
``rows`` is an iterable of
|
|
@@ -2174,7 +2386,11 @@ def _floored_week_max(conn, rows):
|
|
|
2174
2386
|
# Pass 2: resolve floor once per week, drop pre-floor captures, take max.
|
|
2175
2387
|
result: dict = {}
|
|
2176
2388
|
for wk, b in buckets.items():
|
|
2177
|
-
|
|
2389
|
+
# Analytics floor path: ``account_key=None`` merges across accounts —
|
|
2390
|
+
# byte-identical to today on a single-account install (#341); a real key
|
|
2391
|
+
# scopes the floor to that account (the ``forecast --account`` dpp read).
|
|
2392
|
+
floor_iso = _reset_aware_floor(
|
|
2393
|
+
conn, b["wsd"], b["ws_at"], b["we_at"], account_key=account_key)
|
|
2178
2394
|
floor_epoch = None
|
|
2179
2395
|
if floor_iso:
|
|
2180
2396
|
try:
|
|
@@ -2205,11 +2421,90 @@ def _floored_week_max(conn, rows):
|
|
|
2205
2421
|
return result
|
|
2206
2422
|
|
|
2207
2423
|
|
|
2424
|
+
# --------------------------------------------------------------------------
|
|
2425
|
+
# Active Claude account resolution (#341, spec §1 observe-and-stamp) — the
|
|
2426
|
+
# identity source for record-usage / statusline / hook-tick obs stamping. A
|
|
2427
|
+
# stat-read-stat over ``~/.claude.json`` (rewritten in place by Claude Code),
|
|
2428
|
+
# mtime-cached so a hot status-line loop reads it at most once per file change.
|
|
2429
|
+
# --------------------------------------------------------------------------
|
|
2430
|
+
|
|
2431
|
+
_ACTIVE_CLAUDE_ACCOUNT_CACHE: dict = {"sig": None, "identity": None}
|
|
2432
|
+
|
|
2433
|
+
|
|
2434
|
+
def _resolve_active_claude_identity() -> dict:
|
|
2435
|
+
"""Resolve the active Claude identity from ``~/.claude.json`` via the
|
|
2436
|
+
stable-read protocol, mtime-cached. Returns a dict
|
|
2437
|
+
``{account_key, natural_id, email, plan_type}`` — ``account_key`` is the
|
|
2438
|
+
reserved ``unattributed`` sentinel and the rest ``None`` when stably-absent /
|
|
2439
|
+
api-key mode / torn mid-write. Never raises; never invents a guess."""
|
|
2440
|
+
import json as _json
|
|
2441
|
+
import _lib_accounts
|
|
2442
|
+
|
|
2443
|
+
path = str(CLAUDE_JSON_PATH)
|
|
2444
|
+
try:
|
|
2445
|
+
st = os.stat(path)
|
|
2446
|
+
sig = (st.st_ino, st.st_size, st.st_mtime_ns)
|
|
2447
|
+
except OSError:
|
|
2448
|
+
sig = None
|
|
2449
|
+
cache = _ACTIVE_CLAUDE_ACCOUNT_CACHE
|
|
2450
|
+
if sig is not None and cache.get("sig") == sig:
|
|
2451
|
+
return cache["identity"]
|
|
2452
|
+
|
|
2453
|
+
def _reader(data: bytes):
|
|
2454
|
+
try:
|
|
2455
|
+
obj = _json.loads(data)
|
|
2456
|
+
except (ValueError, TypeError):
|
|
2457
|
+
raise _lib_accounts.TornRead()
|
|
2458
|
+
if not isinstance(obj, dict):
|
|
2459
|
+
raise _lib_accounts.TornRead()
|
|
2460
|
+
oauth = obj.get("oauthAccount")
|
|
2461
|
+
nat = _lib_accounts.claude_natural_id(oauth)
|
|
2462
|
+
if nat is None:
|
|
2463
|
+
return None
|
|
2464
|
+
return {
|
|
2465
|
+
"natural_id": nat,
|
|
2466
|
+
"email": _lib_accounts.claude_email(oauth),
|
|
2467
|
+
"plan_type": (oauth.get("plan") if isinstance(oauth, dict) else None),
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
result = _lib_accounts.stable_read_identity(path, _reader)
|
|
2471
|
+
if result.status == "identified":
|
|
2472
|
+
info = result.value
|
|
2473
|
+
identity = {
|
|
2474
|
+
"account_key": _lib_accounts.account_key("claude", info["natural_id"]),
|
|
2475
|
+
"natural_id": info["natural_id"],
|
|
2476
|
+
"email": info.get("email"),
|
|
2477
|
+
"plan_type": info.get("plan_type"),
|
|
2478
|
+
# The three-valued read status (spec §1). ``account_key`` collapses
|
|
2479
|
+
# torn+stably_absent to the sentinel; consumers that must tell a
|
|
2480
|
+
# RESOLVED absence (single-account / api-key -> unattributed) from a
|
|
2481
|
+
# TRANSIENT torn read (defer / exit 2 for record-credit) read this.
|
|
2482
|
+
"status": "identified",
|
|
2483
|
+
}
|
|
2484
|
+
else:
|
|
2485
|
+
identity = {"account_key": _lib_accounts.UNATTRIBUTED,
|
|
2486
|
+
"natural_id": None, "email": None, "plan_type": None,
|
|
2487
|
+
"status": result.status}
|
|
2488
|
+
if sig is not None:
|
|
2489
|
+
cache["sig"] = sig
|
|
2490
|
+
cache["identity"] = identity
|
|
2491
|
+
return identity
|
|
2492
|
+
|
|
2493
|
+
|
|
2494
|
+
def _resolve_active_claude_account() -> str:
|
|
2495
|
+
"""The active Claude ``account_key`` (or ``unattributed``). Thin key-only
|
|
2496
|
+
accessor over :func:`_resolve_active_claude_identity` (mtime-cached)."""
|
|
2497
|
+
return _resolve_active_claude_identity()["account_key"]
|
|
2498
|
+
|
|
2499
|
+
|
|
2208
2500
|
def get_latest_usage_for_week(
|
|
2209
2501
|
conn: sqlite3.Connection,
|
|
2210
2502
|
week_ref: WeekRef,
|
|
2211
2503
|
as_of_utc: str | None = None,
|
|
2504
|
+
*,
|
|
2505
|
+
account_key: str | None = None,
|
|
2212
2506
|
) -> sqlite3.Row | None:
|
|
2213
2507
|
return _get_latest_row_for_week(
|
|
2214
2508
|
conn, "weekly_usage_snapshots", week_ref, as_of_utc=as_of_utc,
|
|
2509
|
+
account_key=account_key,
|
|
2215
2510
|
)
|