cctally 1.27.1 → 1.29.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 +37 -0
- package/bin/_cctally_alerts.py +26 -1
- package/bin/_cctally_cache.py +355 -31
- package/bin/_cctally_config.py +153 -11
- package/bin/_cctally_core.py +204 -42
- package/bin/_cctally_dashboard.py +510 -61
- package/bin/_cctally_db.py +756 -163
- package/bin/_cctally_doctor.py +11 -0
- package/bin/_cctally_forecast.py +700 -57
- package/bin/_cctally_milestones.py +252 -47
- package/bin/_cctally_parser.py +44 -4
- package/bin/_cctally_record.py +380 -133
- package/bin/_cctally_weekrefs.py +30 -6
- package/bin/_lib_alert_axes.py +12 -2
- package/bin/_lib_alerts_payload.py +95 -3
- package/bin/_lib_budget.py +48 -0
- package/bin/_lib_conversation.py +177 -0
- package/bin/_lib_conversation_query.py +620 -0
- package/bin/_lib_doctor.py +60 -1
- package/bin/_lib_jsonl.py +69 -50
- package/bin/_lib_transcript_access.py +80 -0
- package/bin/cctally +29 -2
- package/dashboard/static/assets/index-BGaWg6ys.js +47 -0
- package/dashboard/static/assets/{index-D34qf0LE.css → index-BqQ5xdX0.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-C2F1_Mxt.js +0 -18
package/bin/_lib_doctor.py
CHANGED
|
@@ -140,6 +140,14 @@ class DoctorState:
|
|
|
140
140
|
# token_total); the kernel only reads `.kind`/`.model`/`.entry_count`/
|
|
141
141
|
# `.token_total`, so any duck-typed equivalent works for tests.
|
|
142
142
|
pricing_coverage: Optional[list] = None
|
|
143
|
+
# Conversation viewer (Plan 2, spec §5): the resolved
|
|
144
|
+
# `dashboard.expose_transcripts` opt-in. Only consequential when the bind
|
|
145
|
+
# is LAN — `_check_safety_dashboard_bind` then surfaces an extra
|
|
146
|
+
# "transcripts exposed on LAN" detail on top of the existing LAN-bind
|
|
147
|
+
# WARN. Defaulted False (placed last after the other defaulted fields) so
|
|
148
|
+
# existing constructors stay valid and a loopback bind is byte-identical
|
|
149
|
+
# whether or not expose is set.
|
|
150
|
+
expose_transcripts: bool = False
|
|
143
151
|
|
|
144
152
|
|
|
145
153
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -556,6 +564,45 @@ def _check_db_migrations_pending(s: DoctorState) -> CheckResult:
|
|
|
556
564
|
)
|
|
557
565
|
|
|
558
566
|
|
|
567
|
+
def _check_db_version_ahead(s: DoctorState) -> CheckResult:
|
|
568
|
+
"""FAIL/WARN when a DB's user_version exceeds the running binary's
|
|
569
|
+
registry head (issue #145). stats.db ahead bricks commands (FAIL);
|
|
570
|
+
cache.db ahead auto-heals on the next open (WARN). doctor reads raw
|
|
571
|
+
user_version (no dispatcher), so it can report without healing/bricking.
|
|
572
|
+
"""
|
|
573
|
+
def _eval(status):
|
|
574
|
+
if not status:
|
|
575
|
+
return None
|
|
576
|
+
uv = status.get("user_version", 0) or 0
|
|
577
|
+
rs = status.get("registry_size", 0) or 0
|
|
578
|
+
return {"user_version": uv, "registry_size": rs, "ahead": uv > rs}
|
|
579
|
+
|
|
580
|
+
stats = _eval(s.stats_db_status)
|
|
581
|
+
cache = _eval(s.cache_db_status)
|
|
582
|
+
details = {"stats.db": stats, "cache.db": cache}
|
|
583
|
+
stats_ahead = bool(stats and stats["ahead"])
|
|
584
|
+
cache_ahead = bool(cache and cache["ahead"])
|
|
585
|
+
|
|
586
|
+
if stats_ahead:
|
|
587
|
+
return CheckResult(
|
|
588
|
+
id="db.version_ahead", title="Version ahead", severity="fail",
|
|
589
|
+
summary=f"stats.db ahead (v{stats['user_version']} > known v{stats['registry_size']})",
|
|
590
|
+
remediation="Run `cctally db recover --db stats --yes` (or restore from backup)",
|
|
591
|
+
details=details,
|
|
592
|
+
)
|
|
593
|
+
if cache_ahead:
|
|
594
|
+
return CheckResult(
|
|
595
|
+
id="db.version_ahead", title="Version ahead", severity="warn",
|
|
596
|
+
summary=f"cache.db ahead (v{cache['user_version']} > known v{cache['registry_size']}) — auto-heals",
|
|
597
|
+
remediation="Auto-heals on next command, or run `cctally db recover --db cache`",
|
|
598
|
+
details=details,
|
|
599
|
+
)
|
|
600
|
+
return CheckResult(
|
|
601
|
+
id="db.version_ahead", title="Version ahead", severity="ok",
|
|
602
|
+
summary="none ahead", remediation=None, details=details,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
|
|
559
606
|
def _check_data_latest_snapshot_age(s: DoctorState) -> CheckResult:
|
|
560
607
|
if s.latest_snapshot_at is None:
|
|
561
608
|
return CheckResult(
|
|
@@ -859,13 +906,24 @@ def _check_safety_dashboard_bind(s: DoctorState) -> CheckResult:
|
|
|
859
906
|
rem += "."
|
|
860
907
|
note = ("A separate running dashboard process may have overridden via --host; "
|
|
861
908
|
"the CLI sees config only.") if s.runtime_bind is None else None
|
|
909
|
+
# Conversation viewer (Plan 2, spec §5): a LAN bind WITH the
|
|
910
|
+
# `dashboard.expose_transcripts` opt-in serves raw conversation prose to
|
|
911
|
+
# the LAN. Surface that ONLY here (the bind already WARNs and is
|
|
912
|
+
# non-loopback by construction), additively — a loopback bind never
|
|
913
|
+
# reaches this branch, so the loopback report stays byte-identical
|
|
914
|
+
# regardless of the expose flag.
|
|
915
|
+
extra = {}
|
|
916
|
+
if s.expose_transcripts:
|
|
917
|
+
notes.append("transcripts exposed on LAN")
|
|
918
|
+
extra["transcripts_exposed_on_lan"] = True
|
|
862
919
|
return CheckResult(
|
|
863
920
|
id="safety.dashboard_bind", title="Dashboard bind",
|
|
864
921
|
severity="warn", summary="; ".join(notes),
|
|
865
922
|
remediation=rem,
|
|
866
923
|
details={"config": s.dashboard_bind_stored,
|
|
867
924
|
"runtime_bind": s.runtime_bind,
|
|
868
|
-
**({"note": note} if note else {})
|
|
925
|
+
**({"note": note} if note else {}),
|
|
926
|
+
**extra},
|
|
869
927
|
)
|
|
870
928
|
|
|
871
929
|
|
|
@@ -1052,6 +1110,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
|
|
|
1052
1110
|
("db", "Database", (
|
|
1053
1111
|
("db.stats.file", "_check_db_stats_file"),
|
|
1054
1112
|
("db.cache.file", "_check_db_cache_file"),
|
|
1113
|
+
("db.version_ahead", "_check_db_version_ahead"),
|
|
1055
1114
|
("db.migrations.applied", "_check_db_migrations_applied"),
|
|
1056
1115
|
("db.migrations.pending", "_check_db_migrations_pending"),
|
|
1057
1116
|
)),
|
package/bin/_lib_jsonl.py
CHANGED
|
@@ -201,6 +201,68 @@ def _parse_usage_entries(
|
|
|
201
201
|
return no_key_entries
|
|
202
202
|
|
|
203
203
|
|
|
204
|
+
def parse_cost_entry(obj, path_str: str):
|
|
205
|
+
"""Pure per-line cost parser: given a parsed JSONL object, return
|
|
206
|
+
``(UsageEntry, msg_id, req_id)`` when it is a billable assistant entry, or
|
|
207
|
+
``None`` otherwise (non-assistant, missing/invalid usage, model, or
|
|
208
|
+
timestamp, or a ``<synthetic>`` placeholder). No I/O, no byte offset — the
|
|
209
|
+
caller owns the readline()+tell() loop.
|
|
210
|
+
|
|
211
|
+
Extracted (#138) so the streaming ``_iter_jsonl_entries_with_offsets`` reader
|
|
212
|
+
and the fused single-pass sync walker (``_cctally_cache._iter_sync_entries``)
|
|
213
|
+
share ONE gating implementation — each JSONL line is ``json.loads``-parsed
|
|
214
|
+
once and classified once, never re-parsed for a separate second walk.
|
|
215
|
+
"""
|
|
216
|
+
if obj.get("type") != "assistant":
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
ts_raw = obj.get("timestamp")
|
|
220
|
+
if not isinstance(ts_raw, str) or not ts_raw.strip():
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
msg = obj.get("message")
|
|
224
|
+
if not isinstance(msg, dict):
|
|
225
|
+
msg = obj
|
|
226
|
+
|
|
227
|
+
usage = msg.get("usage")
|
|
228
|
+
if not isinstance(usage, dict):
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
model = msg.get("model") or obj.get("model")
|
|
232
|
+
if not isinstance(model, str) or not model.strip():
|
|
233
|
+
return None
|
|
234
|
+
model = model.strip()
|
|
235
|
+
if model == "<synthetic>":
|
|
236
|
+
# Matches ccusage's claude_loader.rs:454. Filtered here so the cache
|
|
237
|
+
# ingest path can't accidentally store these rows even if a downstream
|
|
238
|
+
# loop forgets to double-check (see `sync_cache` in _cctally_cache.py).
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
ts = dt.datetime.fromisoformat(ts_raw.strip().replace("Z", "+00:00"))
|
|
243
|
+
if ts.tzinfo is None:
|
|
244
|
+
ts = ts.replace(tzinfo=dt.timezone.utc)
|
|
245
|
+
except ValueError:
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
msg_id = msg.get("id")
|
|
249
|
+
req_id = obj.get("requestId")
|
|
250
|
+
cost_usd_raw = obj.get("costUSD")
|
|
251
|
+
cost_usd = float(cost_usd_raw) if cost_usd_raw is not None else None
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
UsageEntry(
|
|
255
|
+
timestamp=ts,
|
|
256
|
+
model=model,
|
|
257
|
+
usage=usage,
|
|
258
|
+
cost_usd=cost_usd,
|
|
259
|
+
source_path=path_str,
|
|
260
|
+
),
|
|
261
|
+
msg_id,
|
|
262
|
+
req_id,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
204
266
|
def _iter_jsonl_entries_with_offsets(fh, path_str: str):
|
|
205
267
|
"""Yield (byte_offset, UsageEntry, msg_id, req_id) for each assistant
|
|
206
268
|
entry starting from fh's current position.
|
|
@@ -209,7 +271,9 @@ def _iter_jsonl_entries_with_offsets(fh, path_str: str):
|
|
|
209
271
|
accurate for resume-from-offset after partial ingests. Malformed JSON
|
|
210
272
|
and non-assistant lines are skipped, but the offset still advances past
|
|
211
273
|
them so they are never re-read. Range filtering is intentionally NOT
|
|
212
|
-
done here — filters are applied at query time by iter_entries().
|
|
274
|
+
done here — filters are applied at query time by iter_entries(). The
|
|
275
|
+
per-line gating lives in ``parse_cost_entry`` (shared with the fused
|
|
276
|
+
single-pass sync walker, #138).
|
|
213
277
|
"""
|
|
214
278
|
while True:
|
|
215
279
|
offset = fh.tell()
|
|
@@ -230,56 +294,11 @@ def _iter_jsonl_entries_with_offsets(fh, path_str: str):
|
|
|
230
294
|
obj = json.loads(stripped)
|
|
231
295
|
except json.JSONDecodeError:
|
|
232
296
|
continue
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
ts_raw = obj.get("timestamp")
|
|
237
|
-
if not isinstance(ts_raw, str) or not ts_raw.strip():
|
|
238
|
-
continue
|
|
239
|
-
|
|
240
|
-
msg = obj.get("message")
|
|
241
|
-
if not isinstance(msg, dict):
|
|
242
|
-
msg = obj
|
|
243
|
-
|
|
244
|
-
usage = msg.get("usage")
|
|
245
|
-
if not isinstance(usage, dict):
|
|
246
|
-
continue
|
|
247
|
-
|
|
248
|
-
model = msg.get("model") or obj.get("model")
|
|
249
|
-
if not isinstance(model, str) or not model.strip():
|
|
297
|
+
parsed = parse_cost_entry(obj, path_str)
|
|
298
|
+
if parsed is None:
|
|
250
299
|
continue
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
# Matches ccusage's claude_loader.rs:454. Filtered at the
|
|
254
|
-
# iterator level so the cache ingest path can't accidentally
|
|
255
|
-
# store these rows even if a downstream loop forgets to
|
|
256
|
-
# double-check (see `sync_cache` in _cctally_cache.py).
|
|
257
|
-
continue
|
|
258
|
-
|
|
259
|
-
try:
|
|
260
|
-
ts = dt.datetime.fromisoformat(ts_raw.strip().replace("Z", "+00:00"))
|
|
261
|
-
if ts.tzinfo is None:
|
|
262
|
-
ts = ts.replace(tzinfo=dt.timezone.utc)
|
|
263
|
-
except ValueError:
|
|
264
|
-
continue
|
|
265
|
-
|
|
266
|
-
msg_id = msg.get("id")
|
|
267
|
-
req_id = obj.get("requestId")
|
|
268
|
-
cost_usd_raw = obj.get("costUSD")
|
|
269
|
-
cost_usd = float(cost_usd_raw) if cost_usd_raw is not None else None
|
|
270
|
-
|
|
271
|
-
yield (
|
|
272
|
-
offset,
|
|
273
|
-
UsageEntry(
|
|
274
|
-
timestamp=ts,
|
|
275
|
-
model=model,
|
|
276
|
-
usage=usage,
|
|
277
|
-
cost_usd=cost_usd,
|
|
278
|
-
source_path=path_str,
|
|
279
|
-
),
|
|
280
|
-
msg_id,
|
|
281
|
-
req_id,
|
|
282
|
-
)
|
|
300
|
+
entry, msg_id, req_id = parsed
|
|
301
|
+
yield (offset, entry, msg_id, req_id)
|
|
283
302
|
|
|
284
303
|
|
|
285
304
|
_CODEX_FILENAME_UUID_RE = re.compile(
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Pure loopback / Host gate predicates for the conversation-viewer transcript
|
|
2
|
+
endpoints (Plan 2, spec §5).
|
|
3
|
+
|
|
4
|
+
No I/O, no globals — directly unit-testable. The dashboard's
|
|
5
|
+
``_require_transcripts_allowed`` composes ``transcripts_allowed`` (is this bind
|
|
6
|
+
served at all?) with ``host_allowed_for_transcripts`` (anti-DNS-rebinding on the
|
|
7
|
+
request's Host header). Loopback-by-default kills rebinding (it needs a
|
|
8
|
+
hostname that resolves to 127.0.0.1); under the explicit ``expose`` opt-in the
|
|
9
|
+
LAN device reaches the dashboard at its IP literal (allowed), while an
|
|
10
|
+
attacker's rebinding *domain* (a hostname) is rejected.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
import ipaddress
|
|
14
|
+
|
|
15
|
+
# Public surface (Plan 2): shipped in the npm tarball + brew formula + public
|
|
16
|
+
# mirror — imported by the dashboard's transcript gate at runtime.
|
|
17
|
+
|
|
18
|
+
_LOOPBACK_NAMES = {"localhost", "::1"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def authority_host(host_header) -> str:
|
|
22
|
+
"""Extract the lowercased host from an HTTP authority (``Host`` header or a
|
|
23
|
+
bind string). Strips the port, the ``[...]`` IPv6 brackets, and any
|
|
24
|
+
``%zone`` id. IPv6-safe (does NOT ``split(':')``). Empty string on missing
|
|
25
|
+
input."""
|
|
26
|
+
if not host_header:
|
|
27
|
+
return ""
|
|
28
|
+
h = str(host_header).strip()
|
|
29
|
+
if h.startswith("["):
|
|
30
|
+
# [IPv6]:port -> IPv6
|
|
31
|
+
end = h.find("]")
|
|
32
|
+
if end != -1:
|
|
33
|
+
h = h[1:end]
|
|
34
|
+
else:
|
|
35
|
+
h = h[1:]
|
|
36
|
+
elif h.count(":") == 1:
|
|
37
|
+
# host:port (a single colon can't be a bare IPv6 literal)
|
|
38
|
+
h = h.split(":", 1)[0]
|
|
39
|
+
# else: bare IPv6 literal (multiple colons) or bare host — leave as-is
|
|
40
|
+
h = h.split("%", 1)[0] # drop IPv6 zone id
|
|
41
|
+
return h.lower()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_loopback(host: str) -> bool:
|
|
45
|
+
"""True for the loopback names plus any IPv4/IPv6 loopback literal
|
|
46
|
+
(127.0.0.0/8, ::1)."""
|
|
47
|
+
if not host:
|
|
48
|
+
return False
|
|
49
|
+
h = host.lower()
|
|
50
|
+
if h in _LOOPBACK_NAMES:
|
|
51
|
+
return True
|
|
52
|
+
try:
|
|
53
|
+
return ipaddress.ip_address(h).is_loopback
|
|
54
|
+
except ValueError:
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def transcripts_allowed(bind_host, expose: bool) -> bool:
|
|
59
|
+
"""Are transcripts served AT ALL on this bind? Loopback bind always; a
|
|
60
|
+
non-loopback (LAN) bind only under the explicit ``expose`` opt-in."""
|
|
61
|
+
return is_loopback(authority_host(bind_host)) or bool(expose)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def host_allowed_for_transcripts(host_header, expose: bool) -> bool:
|
|
65
|
+
"""Anti-DNS-rebinding Host allowlist. Loopback Host always OK. Otherwise
|
|
66
|
+
only an IP-literal Host under ``expose`` (the LAN IP the dashboard is
|
|
67
|
+
reached at) — a rebinding *domain* (any hostname) is rejected. Fail closed
|
|
68
|
+
on missing/empty Host."""
|
|
69
|
+
h = authority_host(host_header)
|
|
70
|
+
if not h:
|
|
71
|
+
return False
|
|
72
|
+
if is_loopback(h):
|
|
73
|
+
return True
|
|
74
|
+
if not expose:
|
|
75
|
+
return False
|
|
76
|
+
try:
|
|
77
|
+
ipaddress.ip_address(h) # IP literal can't be DNS-rebound
|
|
78
|
+
return True
|
|
79
|
+
except ValueError:
|
|
80
|
+
return False # hostname (rebinding vector) — reject
|
package/bin/cctally
CHANGED
|
@@ -306,6 +306,8 @@ BudgetInputs = _lib_budget.BudgetInputs
|
|
|
306
306
|
BudgetStatus = _lib_budget.BudgetStatus
|
|
307
307
|
compute_budget_status = _lib_budget.compute_budget_status
|
|
308
308
|
project_linear = _lib_budget.project_linear
|
|
309
|
+
calendar_month_window = _lib_budget.calendar_month_window
|
|
310
|
+
calendar_week_window = _lib_budget.calendar_week_window
|
|
309
311
|
|
|
310
312
|
# CLAUDE_MODEL_CONTEXT_WINDOWS / …_DEFAULT_FAMILY moved to _cctally_statusline.py (re-exported below).
|
|
311
313
|
|
|
@@ -377,12 +379,14 @@ _alert_text_weekly = _lib_alerts_payload._alert_text_weekly
|
|
|
377
379
|
_alert_text_five_hour = _lib_alerts_payload._alert_text_five_hour
|
|
378
380
|
_alert_text_budget = _lib_alerts_payload._alert_text_budget
|
|
379
381
|
_alert_text_project_budget = _lib_alerts_payload._alert_text_project_budget
|
|
382
|
+
_alert_text_codex_budget = _lib_alerts_payload._alert_text_codex_budget
|
|
380
383
|
_alert_text_projected = _lib_alerts_payload._alert_text_projected
|
|
381
384
|
_escape_applescript_string = _lib_alerts_payload._escape_applescript_string
|
|
382
385
|
_build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
|
|
383
386
|
_build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
|
|
384
387
|
_build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
|
|
385
388
|
_build_alert_payload_project_budget = _lib_alerts_payload._build_alert_payload_project_budget
|
|
389
|
+
_build_alert_payload_codex_budget = _lib_alerts_payload._build_alert_payload_codex_budget
|
|
386
390
|
_build_alert_payload_projected = _lib_alerts_payload._build_alert_payload_projected
|
|
387
391
|
|
|
388
392
|
_lib_alert_dispatch = _load_sibling("_lib_alert_dispatch")
|
|
@@ -736,6 +740,7 @@ add_column_if_missing = _cctally_db.add_column_if_missing
|
|
|
736
740
|
_MIGRATION_NAME_RE = _cctally_db._MIGRATION_NAME_RE
|
|
737
741
|
Migration = _cctally_db.Migration
|
|
738
742
|
DowngradeDetected = _cctally_db.DowngradeDetected
|
|
743
|
+
ProdMigrationRefused = _cctally_db.ProdMigrationRefused
|
|
739
744
|
_STATS_MIGRATIONS = _cctally_db._STATS_MIGRATIONS
|
|
740
745
|
_CACHE_MIGRATIONS = _cctally_db._CACHE_MIGRATIONS
|
|
741
746
|
_make_migration_decorator = _cctally_db._make_migration_decorator
|
|
@@ -743,7 +748,9 @@ stats_migration = _cctally_db.stats_migration
|
|
|
743
748
|
cache_migration = _cctally_db.cache_migration
|
|
744
749
|
_LEGACY_MARKER_ALIASES_BY_DB = _cctally_db._LEGACY_MARKER_ALIASES_BY_DB
|
|
745
750
|
_bootstrap_rename_legacy_markers = _cctally_db._bootstrap_rename_legacy_markers
|
|
751
|
+
_stamp_applied = _cctally_db._stamp_applied
|
|
746
752
|
_run_pending_migrations = _cctally_db._run_pending_migrations
|
|
753
|
+
_recover_version_ahead = _cctally_db._recover_version_ahead
|
|
747
754
|
_log_migration_error = _cctally_db._log_migration_error
|
|
748
755
|
_clear_migration_error_log_entries = _cctally_db._clear_migration_error_log_entries
|
|
749
756
|
_render_migration_error_banner = _cctally_db._render_migration_error_banner
|
|
@@ -757,6 +764,7 @@ _db_resolve_migration_name = _cctally_db._db_resolve_migration_name
|
|
|
757
764
|
_db_path_for_label = _cctally_db._db_path_for_label
|
|
758
765
|
cmd_db_skip = _cctally_db.cmd_db_skip
|
|
759
766
|
cmd_db_unskip = _cctally_db.cmd_db_unskip
|
|
767
|
+
cmd_db_recover = _cctally_db.cmd_db_recover
|
|
760
768
|
|
|
761
769
|
|
|
762
770
|
# Session-entry cache subsystem (Claude + Codex) — read-through delta
|
|
@@ -782,6 +790,7 @@ IngestStats = _cctally_cache.IngestStats
|
|
|
782
790
|
_progress_stderr = _cctally_cache._progress_stderr
|
|
783
791
|
_ensure_session_files_row = _cctally_cache._ensure_session_files_row
|
|
784
792
|
sync_cache = _cctally_cache.sync_cache
|
|
793
|
+
backfill_conversation_messages = _cctally_cache.backfill_conversation_messages
|
|
785
794
|
iter_entries = _cctally_cache.iter_entries
|
|
786
795
|
_collect_entries_direct = _cctally_cache._collect_entries_direct
|
|
787
796
|
_JoinedClaudeEntry = _cctally_cache._JoinedClaudeEntry
|
|
@@ -793,6 +802,7 @@ sync_codex_cache = _cctally_cache.sync_codex_cache
|
|
|
793
802
|
iter_codex_entries = _cctally_cache.iter_codex_entries
|
|
794
803
|
_collect_codex_entries_direct = _cctally_cache._collect_codex_entries_direct
|
|
795
804
|
get_codex_entries = _cctally_cache.get_codex_entries
|
|
805
|
+
_sum_codex_cost_for_range = _cctally_cache._sum_codex_cost_for_range
|
|
796
806
|
get_entries = _cctally_cache.get_entries
|
|
797
807
|
open_cache_db = _cctally_cache.open_cache_db
|
|
798
808
|
cmd_cache_sync = _cctally_cache.cmd_cache_sync
|
|
@@ -822,6 +832,7 @@ _normalize_percent = _cctally_record._normalize_percent
|
|
|
822
832
|
maybe_record_milestone = _cctally_record.maybe_record_milestone
|
|
823
833
|
maybe_record_budget_milestone = _cctally_record.maybe_record_budget_milestone
|
|
824
834
|
maybe_record_project_budget_milestone = _cctally_record.maybe_record_project_budget_milestone
|
|
835
|
+
maybe_record_codex_budget_milestone = _cctally_record.maybe_record_codex_budget_milestone
|
|
825
836
|
maybe_record_projected_alert = _cctally_record.maybe_record_projected_alert
|
|
826
837
|
_weekly_pct_week_avg_projection = _cctally_record._weekly_pct_week_avg_projection
|
|
827
838
|
_compute_block_totals = _cctally_record._compute_block_totals
|
|
@@ -1101,6 +1112,7 @@ _fetch_current_week_snapshots = _cctally_forecast._fetch_current_week_snapshots
|
|
|
1101
1112
|
_apply_midweek_reset_override = _cctally_forecast._apply_midweek_reset_override
|
|
1102
1113
|
_resolve_current_budget_window = _cctally_forecast._resolve_current_budget_window
|
|
1103
1114
|
_build_budget_status_inputs = _cctally_forecast._build_budget_status_inputs
|
|
1115
|
+
_build_vendor_budget_inputs = _cctally_forecast._build_vendor_budget_inputs
|
|
1104
1116
|
_select_dollars_per_percent = _cctally_forecast._select_dollars_per_percent
|
|
1105
1117
|
_assess_forecast_confidence = _cctally_forecast._assess_forecast_confidence
|
|
1106
1118
|
_pick_p_24h_ago = _cctally_forecast._pick_p_24h_ago
|
|
@@ -1115,6 +1127,12 @@ _forecast_color_enabled = _cctally_forecast._forecast_color_enabled
|
|
|
1115
1127
|
_render_forecast_progress_bar = _cctally_forecast._render_forecast_progress_bar
|
|
1116
1128
|
_render_forecast_terminal = _cctally_forecast._render_forecast_terminal
|
|
1117
1129
|
_BUDGET_JSON_SCHEMA_VERSION = _cctally_forecast._BUDGET_JSON_SCHEMA_VERSION
|
|
1130
|
+
# Single-sourced `--period` spellings (code-review #5): the parser's `choices=`
|
|
1131
|
+
# derives from the same map the handler normalizer uses, so they can't drift.
|
|
1132
|
+
_BUDGET_PERIOD_ALIASES = _cctally_forecast._BUDGET_PERIOD_ALIASES
|
|
1133
|
+
_BUDGET_PERIOD_CHOICES = _cctally_forecast._BUDGET_PERIOD_CHOICES
|
|
1134
|
+
_normalize_budget_period = _cctally_forecast._normalize_budget_period
|
|
1135
|
+
_resolve_calendar_window = _cctally_forecast._resolve_calendar_window
|
|
1118
1136
|
_cmd_budget_set = _cctally_forecast._cmd_budget_set
|
|
1119
1137
|
_cmd_budget_unset = _cctally_forecast._cmd_budget_unset
|
|
1120
1138
|
# Per-project budget set/unset (#19/#121, spec §4.3 / §7).
|
|
@@ -2082,12 +2100,18 @@ get_max_milestone_for_week = _cctally_milestones.get_max_milestone_for_
|
|
|
2082
2100
|
get_milestone_cost_for_week = _cctally_milestones.get_milestone_cost_for_week # record shim
|
|
2083
2101
|
get_milestones_for_week = _cctally_milestones.get_milestones_for_week # forecast c.; tui shim; percent-breakdown c.
|
|
2084
2102
|
insert_percent_milestone = _cctally_milestones.insert_percent_milestone # record shim; idempotency-test mod.
|
|
2085
|
-
insert_budget_milestone = _cctally_milestones.insert_budget_milestone # record shim
|
|
2103
|
+
insert_budget_milestone = _cctally_milestones.insert_budget_milestone # record shim; test_budget_alerts / test_project_budget_dashboard ns[] (+ test_codex_budget_alerts / test_projected_alerts post-#143 vendor-param unification)
|
|
2086
2104
|
insert_project_budget_milestone = _cctally_milestones.insert_project_budget_milestone # record shim; project-budget-config-test ns[]
|
|
2105
|
+
_budget_crossings = _cctally_milestones._budget_crossings # record shim (shared INSERT-and-arm core for the budget axis, both vendors, #143)
|
|
2106
|
+
_resolve_codex_budget_period_window = _cctally_milestones._resolve_codex_budget_period_window # record shim; milestones c. (codex period window)
|
|
2107
|
+
_resolve_budget_window = _cctally_milestones._resolve_budget_window # record shim; milestones c. (per-vendor cheap budget window dispatcher, #143)
|
|
2108
|
+
_budget_spend_for_vendor = _cctally_milestones._budget_spend_for_vendor # record shim; milestones c. (per-vendor budget spend dispatcher, #143)
|
|
2109
|
+
_reconcile_codex_budget_on_config_write = _cctally_milestones._reconcile_codex_budget_on_config_write # forecast/config c. (forward-only codex-budget reconcile)
|
|
2110
|
+
_resolve_claude_budget_window = _cctally_milestones._resolve_claude_budget_window # record shim; milestones c. (period-aware Claude budget window)
|
|
2087
2111
|
_project_crossings = _cctally_milestones._project_crossings # record shim; milestones c. (#130 firing/reconcile shared crossing arithmetic)
|
|
2088
2112
|
insert_projected_milestone = _cctally_milestones.insert_projected_milestone # record shim
|
|
2089
2113
|
_projected_levels_already_latched = _cctally_milestones._projected_levels_already_latched # record shim
|
|
2090
|
-
_reconcile_budget_milestones_on_set = _cctally_milestones._reconcile_budget_milestones_on_set # test_budget_alerts ns[]
|
|
2114
|
+
_reconcile_budget_milestones_on_set = _cctally_milestones._reconcile_budget_milestones_on_set # test_budget_alerts / test_codex_budget_alerts ns[] (vendor-param, #143)
|
|
2091
2115
|
_reconcile_budget_on_config_write = _cctally_milestones._reconcile_budget_on_config_write # forecast/config/dashboard c.; test_forecast_ns_patch mod. patch
|
|
2092
2116
|
_reconcile_project_budget_milestones_on_write = _cctally_milestones._reconcile_project_budget_milestones_on_write # forecast/config/dashboard c. (forward-only project-budget reconcile)
|
|
2093
2117
|
|
|
@@ -2672,6 +2696,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
2672
2696
|
except DowngradeDetected as exc:
|
|
2673
2697
|
eprint(f"cctally: {exc}")
|
|
2674
2698
|
return 2
|
|
2699
|
+
except ProdMigrationRefused as exc:
|
|
2700
|
+
eprint(f"{exc}") # message already carries the 'cctally:' prefix
|
|
2701
|
+
return 2
|
|
2675
2702
|
except Exception as exc: # pragma: no cover
|
|
2676
2703
|
eprint(f"Error: {exc}")
|
|
2677
2704
|
rc = 1
|