cctally 1.101.0 → 1.103.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 +1222 -1364
- package/README.md +8 -2
- package/bin/_cctally_cache.py +31 -0
- package/bin/_cctally_dashboard.py +433 -122
- package/bin/_cctally_dashboard_conversation.py +12 -0
- package/bin/_cctally_dashboard_envelope.py +25 -23
- package/bin/_cctally_diagnosis.py +1172 -0
- package/bin/_cctally_diagnosis_sources.py +4054 -0
- package/bin/_cctally_forecast.py +25 -15
- package/bin/_cctally_parser.py +84 -0
- package/bin/_cctally_quota.py +14 -0
- package/bin/_cctally_record.py +144 -70
- package/bin/_cctally_refresh.py +105 -93
- package/bin/_cctally_statusline.py +8 -1
- package/bin/_lib_blocks.py +12 -0
- package/bin/_lib_codex_conversation.py +14 -0
- package/bin/_lib_codex_conversation_query.py +22 -8
- package/bin/_lib_conversation.py +6 -3
- package/bin/_lib_conversation_query.py +256 -69
- package/bin/_lib_diagnosis.py +1261 -0
- package/bin/_lib_forecast.py +50 -0
- package/bin/_lib_jsonl.py +102 -5
- package/bin/_lib_perf.py +12 -0
- package/bin/_lib_pricing.py +97 -21
- package/bin/_lib_readme_refresh.py +26 -5
- package/bin/cctally +93 -16
- package/bin/cctally-explain +5 -0
- package/dashboard/static/assets/index-Di2hljvB.css +1 -0
- package/dashboard/static/assets/index-XYCIWjVG.js +97 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-D6Eb9KDn.js +0 -97
- package/dashboard/static/assets/index-i3g7g8zo.css +0 -1
package/bin/_cctally_refresh.py
CHANGED
|
@@ -918,6 +918,58 @@ def cmd_refresh_usage(args: argparse.Namespace) -> int:
|
|
|
918
918
|
# Hook-tick OAuth refresh path
|
|
919
919
|
# =========================================================================
|
|
920
920
|
|
|
921
|
+
def _hook_tick_oauth_skip_status(c, *, throttle_seconds: float) -> str | None:
|
|
922
|
+
"""Return the automatic-refresh suppression reason under selected lock."""
|
|
923
|
+
now_epoch = int(time.time())
|
|
924
|
+
needs_repair = c._authoritative_repair_required(now_epoch=now_epoch)
|
|
925
|
+
obs_age = c._statusline_observe_age_seconds()
|
|
926
|
+
if (not needs_repair
|
|
927
|
+
and obs_age < float(_cctally_core.OAUTH_BACKFILL_STALE_SECONDS)):
|
|
928
|
+
return f"skipped(statusline-fresh:{int(obs_age)}s)"
|
|
929
|
+
backoff_remaining = c._oauth_backoff_remaining_seconds()
|
|
930
|
+
if backoff_remaining > 0:
|
|
931
|
+
return f"skipped(backoff:{int(backoff_remaining)}s)"
|
|
932
|
+
age_s = c._newest_snapshot_age_seconds()
|
|
933
|
+
if age_s is not None and age_s < throttle_seconds:
|
|
934
|
+
return f"skipped(fresh:{int(age_s)}s)"
|
|
935
|
+
return None
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _hook_tick_parse_oauth_payload(c, api):
|
|
939
|
+
"""Validate one OAuth response outside the selected-state lock."""
|
|
940
|
+
try:
|
|
941
|
+
seven = api["seven_day"]
|
|
942
|
+
seven_pct = c._normalize_percent(float(seven["utilization"]))
|
|
943
|
+
seven_resets_epoch = _iso_to_epoch(seven["resets_at"])
|
|
944
|
+
except (TypeError, ValueError, KeyError):
|
|
945
|
+
return None
|
|
946
|
+
five = api.get("five_hour") if isinstance(api.get("five_hour"), dict) else None
|
|
947
|
+
five_pct: float | None = None
|
|
948
|
+
five_resets_epoch: int | None = None
|
|
949
|
+
if (five is not None and "utilization" in five
|
|
950
|
+
and isinstance(five.get("resets_at"), str)):
|
|
951
|
+
try:
|
|
952
|
+
five_pct = c._normalize_percent(float(five["utilization"]))
|
|
953
|
+
five_resets_epoch = _iso_to_epoch(five["resets_at"])
|
|
954
|
+
except (TypeError, ValueError):
|
|
955
|
+
five_pct = None
|
|
956
|
+
five_resets_epoch = None
|
|
957
|
+
record_args = argparse.Namespace(
|
|
958
|
+
percent=seven_pct,
|
|
959
|
+
resets_at=str(seven_resets_epoch),
|
|
960
|
+
five_hour_percent=five_pct,
|
|
961
|
+
five_hour_resets_at=(
|
|
962
|
+
str(five_resets_epoch) if five_resets_epoch is not None else None
|
|
963
|
+
),
|
|
964
|
+
source="api",
|
|
965
|
+
)
|
|
966
|
+
axes = {"sevenDay", *({"fiveHour"} if five_pct is not None else set())}
|
|
967
|
+
parts = [f"7d={int(round(seven_pct))}"]
|
|
968
|
+
if five_pct is not None:
|
|
969
|
+
parts.append(f"5h={int(round(five_pct))}")
|
|
970
|
+
return record_args, axes, parts
|
|
971
|
+
|
|
972
|
+
|
|
921
973
|
def _hook_tick_oauth_refresh(
|
|
922
974
|
timeout_seconds: float = 5.0,
|
|
923
975
|
throttle_seconds: float | None = None,
|
|
@@ -950,115 +1002,75 @@ def _hook_tick_oauth_refresh(
|
|
|
950
1002
|
throttle_seconds = float(_get_oauth_usage_config(c.load_config())["throttle_seconds"])
|
|
951
1003
|
except OauthUsageConfigError:
|
|
952
1004
|
throttle_seconds = float(c.HOOK_TICK_DEFAULT_THROTTLE_SECONDS)
|
|
953
|
-
#
|
|
954
|
-
#
|
|
955
|
-
#
|
|
956
|
-
#
|
|
957
|
-
#
|
|
958
|
-
# owns the lock across the OAuth fetch AND the authoritative record, so
|
|
959
|
-
# `_authoritative_record_usage` cannot release it — the deferral has to
|
|
960
|
-
# reach the frame that acquired it. The nudge is a loopback POST with a
|
|
961
|
-
# multi-second timeout and this lock is an `fcntl.flock` every cctally
|
|
962
|
-
# process contends on, so one unresponsive listener would otherwise stall
|
|
963
|
-
# every other process's selected-state writes at status-line cadence.
|
|
964
|
-
deferred = []
|
|
1005
|
+
# Check freshness under the selected-state lock, release it for the
|
|
1006
|
+
# potentially five-second network request, then recheck after reacquiring
|
|
1007
|
+
# before publication. A concurrent winner can therefore suppress this
|
|
1008
|
+
# response without the high-cadence hook holding the cross-process flock
|
|
1009
|
+
# during network I/O.
|
|
965
1010
|
try:
|
|
966
1011
|
with c._selected_state_lock():
|
|
967
|
-
|
|
968
|
-
c,
|
|
969
|
-
token=token,
|
|
970
|
-
timeout_seconds=timeout_seconds,
|
|
971
|
-
throttle_seconds=throttle_seconds,
|
|
972
|
-
nudge_sink=lambda: deferred.append(1),
|
|
1012
|
+
skipped = _hook_tick_oauth_skip_status(
|
|
1013
|
+
c, throttle_seconds=throttle_seconds,
|
|
973
1014
|
)
|
|
974
1015
|
except OSError:
|
|
975
1016
|
return "err(record-usage=exc)", None
|
|
976
|
-
if
|
|
977
|
-
|
|
978
|
-
return out
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
def _hook_tick_oauth_refresh_locked(
|
|
982
|
-
c,
|
|
983
|
-
*,
|
|
984
|
-
token: str,
|
|
985
|
-
timeout_seconds: float,
|
|
986
|
-
throttle_seconds: float,
|
|
987
|
-
nudge_sink=None,
|
|
988
|
-
) -> tuple[str, dict | None]:
|
|
989
|
-
"""Automatic OAuth path with the selected-state lock already held.
|
|
1017
|
+
if skipped is not None:
|
|
1018
|
+
return skipped, None
|
|
990
1019
|
|
|
991
|
-
``nudge_sink`` records that a dashboard nudge is warranted; the caller
|
|
992
|
-
fires it after releasing the lock (#583 S2).
|
|
993
|
-
"""
|
|
994
|
-
# Backfill gate: an inflight/invalid tombstone bypasses only selected-age
|
|
995
|
-
# suppression so a later authoritative result can repair it. The normal
|
|
996
|
-
# throttle and 429 deadline continue to bound OAuth traffic.
|
|
997
|
-
now_epoch = int(time.time())
|
|
998
|
-
needs_repair = c._authoritative_repair_required(now_epoch=now_epoch)
|
|
999
|
-
obs_age = c._statusline_observe_age_seconds()
|
|
1000
|
-
if (not needs_repair
|
|
1001
|
-
and obs_age < float(_cctally_core.OAUTH_BACKFILL_STALE_SECONDS)):
|
|
1002
|
-
return f"skipped(statusline-fresh:{int(obs_age)}s)", None
|
|
1003
|
-
backoff_remaining = c._oauth_backoff_remaining_seconds()
|
|
1004
|
-
if backoff_remaining > 0:
|
|
1005
|
-
return f"skipped(backoff:{int(backoff_remaining)}s)", None
|
|
1006
|
-
age_s = c._newest_snapshot_age_seconds()
|
|
1007
|
-
if age_s is not None and age_s < throttle_seconds:
|
|
1008
|
-
return f"skipped(fresh:{int(age_s)}s)", None
|
|
1009
1020
|
try:
|
|
1010
1021
|
api = c._fetch_oauth_usage(token=token, timeout_seconds=timeout_seconds)
|
|
1011
1022
|
except RefreshUsageRateLimitError as exc:
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1023
|
+
try:
|
|
1024
|
+
with c._selected_state_lock():
|
|
1025
|
+
c._oauth_backoff_register_429(
|
|
1026
|
+
retry_after_deadline=getattr(
|
|
1027
|
+
exc, "retry_after_deadline", None,
|
|
1028
|
+
),
|
|
1029
|
+
now=time.time(),
|
|
1030
|
+
)
|
|
1031
|
+
except OSError:
|
|
1032
|
+
return "err(record-usage=exc)", None
|
|
1016
1033
|
return "err(rate-limit)", None
|
|
1017
1034
|
except RefreshUsageNetworkError:
|
|
1018
1035
|
return "err(network)", None
|
|
1019
1036
|
except RefreshUsageMalformedError:
|
|
1020
1037
|
return "err(parse)", None
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
seven_resets_epoch = _iso_to_epoch(seven["resets_at"])
|
|
1025
|
-
except (TypeError, ValueError, KeyError):
|
|
1038
|
+
|
|
1039
|
+
parsed = _hook_tick_parse_oauth_payload(c, api)
|
|
1040
|
+
if parsed is None:
|
|
1026
1041
|
return "err(parse)", None
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1042
|
+
record_args, axes, parts = parsed
|
|
1043
|
+
|
|
1044
|
+
# #583 S2: the nudge remains deferred until after the publication lock is
|
|
1045
|
+
# released. The sink contract is now enforced by the authority kernel, so
|
|
1046
|
+
# every lock-owning caller has to make the deferral explicit.
|
|
1047
|
+
deferred = []
|
|
1048
|
+
try:
|
|
1049
|
+
with c._selected_state_lock():
|
|
1050
|
+
skipped = _hook_tick_oauth_skip_status(
|
|
1051
|
+
c,
|
|
1052
|
+
throttle_seconds=throttle_seconds,
|
|
1053
|
+
)
|
|
1054
|
+
if skipped is not None:
|
|
1055
|
+
return skipped, None
|
|
1056
|
+
authoritative = c._authoritative_record_usage(
|
|
1057
|
+
record_args,
|
|
1058
|
+
axes,
|
|
1059
|
+
lock_held=True,
|
|
1060
|
+
nudge_sink=lambda: deferred.append(1),
|
|
1061
|
+
)
|
|
1062
|
+
if authoritative.status != "ok":
|
|
1063
|
+
reason = authoritative.reason or ""
|
|
1064
|
+
if reason.startswith("exit "):
|
|
1065
|
+
return f"err(record-usage={reason[5:]})", None
|
|
1066
|
+
return "err(record-usage=exc)", None
|
|
1067
|
+
# A success reaches shared backoff only after tombstones, control,
|
|
1068
|
+
# and freshness are authoritative under this same lock.
|
|
1069
|
+
c._oauth_backoff_reset()
|
|
1070
|
+
except OSError:
|
|
1055
1071
|
return "err(record-usage=exc)", None
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
c._oauth_backoff_reset()
|
|
1059
|
-
parts = [f"7d={int(round(seven_pct))}"]
|
|
1060
|
-
if five_pct is not None:
|
|
1061
|
-
parts.append(f"5h={int(round(five_pct))}")
|
|
1072
|
+
if deferred:
|
|
1073
|
+
c._nudge_dashboard_repaint()
|
|
1062
1074
|
return f"ok({','.join(parts)})", api
|
|
1063
1075
|
|
|
1064
1076
|
|
|
@@ -1008,8 +1008,15 @@ def _authoritative_record_usage(
|
|
|
1008
1008
|
selected-state writes. This frame owns the lock only on the
|
|
1009
1009
|
``lock_held=False`` path, so that is the only path that can fire the
|
|
1010
1010
|
deferred nudge itself; a caller passing ``lock_held=True`` owns the lock
|
|
1011
|
-
and must supply its own sink
|
|
1011
|
+
and must supply its own sink. The fail-closed guard below enforces that
|
|
1012
|
+
contract before any tombstone or journal write begins.
|
|
1012
1013
|
"""
|
|
1014
|
+
if lock_held and nudge_dashboard and nudge_sink is None:
|
|
1015
|
+
return _AuthoritativeRecordResult(
|
|
1016
|
+
"record_failed",
|
|
1017
|
+
"lock_held authoritative record requires nudge_sink",
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1013
1020
|
if not lock_held:
|
|
1014
1021
|
deferred = []
|
|
1015
1022
|
try:
|
package/bin/_lib_blocks.py
CHANGED
|
@@ -105,6 +105,7 @@ def _group_entries_into_blocks(
|
|
|
105
105
|
dt.datetime, tuple[dt.datetime, dt.datetime]
|
|
106
106
|
] | None = None,
|
|
107
107
|
now: dt.datetime | None = None,
|
|
108
|
+
_entry_membership: dict[int, list[UsageEntry]] | None = None,
|
|
108
109
|
) -> list[Block]:
|
|
109
110
|
"""Group sorted UsageEntry objects into 5-hour blocks with gap detection.
|
|
110
111
|
|
|
@@ -138,6 +139,13 @@ def _group_entries_into_blocks(
|
|
|
138
139
|
|
|
139
140
|
`now` pins the current instant (typically via `_command_as_of()`). When
|
|
140
141
|
omitted, falls back to wall clock so existing callers are unaffected.
|
|
142
|
+
|
|
143
|
+
``_entry_membership`` is an internal detail-route seam. When supplied,
|
|
144
|
+
each non-gap result object's identity maps to the exact entries assigned
|
|
145
|
+
by this grouping pass. That ownership is stricter than a later
|
|
146
|
+
``start <= timestamp < end`` filter when adjacent recorded windows overlap
|
|
147
|
+
after a reset shift; consumers that render per-entry samples must use the
|
|
148
|
+
grouping decision rather than independently reconstructing it.
|
|
141
149
|
"""
|
|
142
150
|
if not entries:
|
|
143
151
|
return []
|
|
@@ -264,6 +272,8 @@ def _group_entries_into_blocks(
|
|
|
264
272
|
anchor="recorded",
|
|
265
273
|
)
|
|
266
274
|
first_entry_ts_by_block[id(blk)] = bucket_sorted[0].timestamp
|
|
275
|
+
if _entry_membership is not None:
|
|
276
|
+
_entry_membership[id(blk)] = bucket_sorted
|
|
267
277
|
recorded_block_objs.append(blk)
|
|
268
278
|
|
|
269
279
|
# Phase 2: Build heuristic Block objects from raw_blocks using _aggregate_block
|
|
@@ -278,6 +288,8 @@ def _group_entries_into_blocks(
|
|
|
278
288
|
)
|
|
279
289
|
if block_entries:
|
|
280
290
|
first_entry_ts_by_block[id(blk)] = block_entries[0].timestamp
|
|
291
|
+
if _entry_membership is not None:
|
|
292
|
+
_entry_membership[id(blk)] = block_entries
|
|
281
293
|
heuristic_block_objs.append(blk)
|
|
282
294
|
|
|
283
295
|
# Merge + sort by start_time
|
|
@@ -2142,6 +2142,20 @@ def infer_codex_event_turns(
|
|
|
2142
2142
|
return turns, current
|
|
2143
2143
|
|
|
2144
2144
|
|
|
2145
|
+
def fold_codex_event_turns(events) -> dict:
|
|
2146
|
+
"""Map each prefetched physical event's ``line_offset`` to its turn.
|
|
2147
|
+
|
|
2148
|
+
The pure half of ``_file_turn_map`` (#620 S3 X3): the caller supplies the
|
|
2149
|
+
ordered ``codex_conversation_events`` batch and this returns
|
|
2150
|
+
``{line_offset: turn_id | None}`` over the same lifecycle inference cost
|
|
2151
|
+
attribution and normalized prose already share. Opens no database.
|
|
2152
|
+
"""
|
|
2153
|
+
materialized = list(events)
|
|
2154
|
+
turns, _terminal = infer_codex_event_turns(materialized)
|
|
2155
|
+
return {event.line_offset: turn
|
|
2156
|
+
for event, turn in zip(materialized, turns)}
|
|
2157
|
+
|
|
2158
|
+
|
|
2145
2159
|
def _is_late_turn_anchor(
|
|
2146
2160
|
record_type: str | None, payload_type: str | None, explicit_turn: str | None,
|
|
2147
2161
|
) -> bool:
|
|
@@ -1272,15 +1272,18 @@ def _tokens_union(tokens: dict) -> dict:
|
|
|
1272
1272
|
# ── cost attribution (§5.4) ───────────────────────────────────────────────────
|
|
1273
1273
|
|
|
1274
1274
|
|
|
1275
|
-
def
|
|
1276
|
-
"""
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1275
|
+
def _codex_file_events(conn: sqlite3.Connection, source_path: str) -> list:
|
|
1276
|
+
"""One retained file's physical events, in offset order.
|
|
1277
|
+
|
|
1278
|
+
Split out of ``_file_turn_map`` (#620 S3 X3) so the turn attribution can
|
|
1279
|
+
run as a pure fold over a prefetched batch. This walk is UNBOUNDED by
|
|
1280
|
+
construction: it reads every event the file retains, however far the
|
|
1281
|
+
in-window entry sits behind that history, which is why the diagnosis
|
|
1282
|
+
budgets its own use of it rather than calling this directly.
|
|
1280
1283
|
"""
|
|
1281
1284
|
from _lib_jsonl import CodexPhysicalEvent
|
|
1282
1285
|
|
|
1283
|
-
|
|
1286
|
+
return [
|
|
1284
1287
|
CodexPhysicalEvent(*row)
|
|
1285
1288
|
for row in conn.execute(
|
|
1286
1289
|
"SELECT source_path, line_offset, source_root_key, conversation_key, "
|
|
@@ -1291,8 +1294,19 @@ def _file_turn_map(conn: sqlite3.Connection, source_path: str) -> dict[int, str
|
|
|
1291
1294
|
(source_path,),
|
|
1292
1295
|
)
|
|
1293
1296
|
]
|
|
1294
|
-
|
|
1295
|
-
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
def _file_turn_map(conn: sqlite3.Connection, source_path: str) -> dict[int, str | None]:
|
|
1300
|
+
"""Exact physical-offset → canonical logical turn for one retained file.
|
|
1301
|
+
|
|
1302
|
+
Cost attribution and normalized prose use the same pure lifecycle inference,
|
|
1303
|
+
including resumed segments whose native proof arrives on task completion.
|
|
1304
|
+
|
|
1305
|
+
Since #620 S3 this is a thin wrapper: it fetches and delegates to
|
|
1306
|
+
``fold_codex_event_turns``. Its signature, return shape and every caller
|
|
1307
|
+
are unchanged.
|
|
1308
|
+
"""
|
|
1309
|
+
return kern.fold_codex_event_turns(_codex_file_events(conn, source_path))
|
|
1296
1310
|
|
|
1297
1311
|
|
|
1298
1312
|
def _attribute_costs(conn: sqlite3.Connection, conversation_key: str, effective_speed: str):
|
package/bin/_lib_conversation.py
CHANGED
|
@@ -55,10 +55,13 @@ def _join_text_blocks(blocks):
|
|
|
55
55
|
migration-011 consumer rebuilds the marker text from blocks_json to feed
|
|
56
56
|
_extract_command_invocation, so this lives in the parser kernel (the one
|
|
57
57
|
_cctally_cache already imports) to keep the two derivations identical."""
|
|
58
|
-
if not blocks:
|
|
58
|
+
if not isinstance(blocks, list) or not blocks:
|
|
59
59
|
return ""
|
|
60
|
-
return "\n".join(
|
|
61
|
-
|
|
60
|
+
return "\n".join(
|
|
61
|
+
text for b in blocks
|
|
62
|
+
if isinstance(b, dict) and b.get("kind") == "text"
|
|
63
|
+
and isinstance((text := b.get("text")), str)
|
|
64
|
+
)
|
|
62
65
|
|
|
63
66
|
|
|
64
67
|
def _extract_command_invocation(blocks, text):
|