cctally 1.100.0 → 1.102.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 +73 -0
- package/README.md +8 -2
- package/bin/_cctally_alerts.py +13 -2
- package/bin/_cctally_cache.py +3 -1
- package/bin/_cctally_cache_report.py +103 -6
- package/bin/_cctally_dashboard.py +1140 -282
- package/bin/_cctally_dashboard_conversation.py +12 -0
- package/bin/_cctally_dashboard_envelope.py +53 -61
- package/bin/_cctally_dashboard_share.py +101 -29
- package/bin/_cctally_dashboard_sources.py +663 -192
- package/bin/_cctally_diagnosis.py +1172 -0
- package/bin/_cctally_diagnosis_sources.py +4054 -0
- package/bin/_cctally_diff.py +20 -0
- package/bin/_cctally_forecast.py +329 -111
- package/bin/_cctally_milestone_history.py +10 -2
- package/bin/_cctally_parser.py +84 -0
- package/bin/_cctally_project.py +155 -47
- package/bin/_cctally_quota.py +14 -0
- package/bin/_cctally_record.py +151 -71
- package/bin/_cctally_refresh.py +105 -93
- package/bin/_cctally_share.py +9 -2
- package/bin/_cctally_source_analytics.py +40 -4
- package/bin/_cctally_statusline.py +8 -1
- package/bin/_cctally_tui.py +425 -234
- package/bin/_lib_alert_scope.py +685 -0
- package/bin/_lib_alerts_payload.py +112 -7
- package/bin/_lib_blocks.py +12 -0
- package/bin/_lib_cache_report.py +110 -1
- package/bin/_lib_codex_conversation.py +14 -0
- package/bin/_lib_codex_conversation_query.py +22 -8
- package/bin/_lib_codex_pools.py +20 -8
- package/bin/_lib_conversation.py +6 -3
- package/bin/_lib_conversation_query.py +256 -69
- package/bin/_lib_dashboard_sources.py +212 -24
- package/bin/_lib_diagnosis.py +1261 -0
- package/bin/_lib_forecast.py +62 -4
- package/bin/_lib_perf.py +12 -0
- package/bin/_lib_pricing.py +8 -7
- package/bin/_lib_readme_refresh.py +26 -5
- package/bin/_lib_render.py +31 -3
- package/bin/_lib_share_templates.py +150 -55
- package/bin/_lib_snapshot_cache.py +71 -13
- package/bin/_lib_source_identity.py +50 -2
- package/bin/_lib_subscription_weeks.py +65 -0
- package/bin/cctally +103 -16
- package/bin/cctally-explain +5 -0
- package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -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 +6 -1
- package/dashboard/static/assets/index-B5YfQEtn.css +0 -1
- package/dashboard/static/assets/index-Bt59nMMO.js +0 -97
|
@@ -63,6 +63,100 @@ _lib_accounts = _load_lib("_lib_accounts")
|
|
|
63
63
|
_UNATTRIBUTED = _lib_accounts.UNATTRIBUTED
|
|
64
64
|
_VENDOR_WIDE = _lib_accounts.VENDOR_WIDE
|
|
65
65
|
|
|
66
|
+
# Next-step routing (#620 S1, D11): every axis offers the command that explains
|
|
67
|
+
# it, scoped to that alert's own provider and window. The scope is derived from
|
|
68
|
+
# the retained context by `_lib_alert_scope`; nothing here parses an alert id.
|
|
69
|
+
_lib_alert_scope = _load_lib("_lib_alert_scope")
|
|
70
|
+
derive_alert_scope = _lib_alert_scope.derive_alert_scope
|
|
71
|
+
format_next_step = _lib_alert_scope.format_next_step
|
|
72
|
+
alert_next_step_command = _lib_alert_scope.alert_next_step_command
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
next_step_line = _lib_alert_scope.next_step_line
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def alert_next_step_line(
|
|
79
|
+
payload: dict, tz: "ZoneInfo | None", now: "dt.datetime | None" = None,
|
|
80
|
+
) -> str:
|
|
81
|
+
"""The one affordance line an alert body ends with.
|
|
82
|
+
|
|
83
|
+
The command's own arguments stay in UTC, because that is what the
|
|
84
|
+
selectors they feed accept (``--block-start`` is documented naive-is-UTC);
|
|
85
|
+
the trailing scope statement is what renders in the display zone.
|
|
86
|
+
|
|
87
|
+
``now`` is the instant the liveness test compares against; it defaults to
|
|
88
|
+
the current UTC instant. The clock read lives here rather than in
|
|
89
|
+
``_lib_alert_scope`` so that kernel stays a pure leaf.
|
|
90
|
+
"""
|
|
91
|
+
axis = payload.get("axis")
|
|
92
|
+
ctx = payload.get("context") or {}
|
|
93
|
+
scope = derive_alert_scope(axis, ctx, payload.get("account_key"))
|
|
94
|
+
command = alert_next_step_command(axis, ctx, scope)
|
|
95
|
+
if command is None:
|
|
96
|
+
return format_next_step(
|
|
97
|
+
None,
|
|
98
|
+
unavailable_reason=_lib_alert_scope.alert_target_unavailable_reason(
|
|
99
|
+
axis, ctx, scope
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
# A command that reports only the live window cannot address one that has
|
|
103
|
+
# already closed. Offering it anyway would report the current window under
|
|
104
|
+
# a line naming the closed one, so the navigation is withheld with its
|
|
105
|
+
# cause — the same treatment a purged five-hour block already gets.
|
|
106
|
+
if (
|
|
107
|
+
_lib_alert_scope.command_reports_the_live_period_only(command)
|
|
108
|
+
and scope.window_end is not None
|
|
109
|
+
):
|
|
110
|
+
if now is None:
|
|
111
|
+
now = dt.datetime.now(dt.timezone.utc)
|
|
112
|
+
if scope.window_end <= now:
|
|
113
|
+
return format_next_step(
|
|
114
|
+
None,
|
|
115
|
+
unavailable_reason=_lib_alert_scope.closed_window_reason(command),
|
|
116
|
+
)
|
|
117
|
+
if scope.available:
|
|
118
|
+
return next_step_line(
|
|
119
|
+
command,
|
|
120
|
+
provider=scope.provider,
|
|
121
|
+
window_start=scope.window_start,
|
|
122
|
+
window_end=scope.window_end,
|
|
123
|
+
tz=tz,
|
|
124
|
+
granularity=scope.window_granularity,
|
|
125
|
+
)
|
|
126
|
+
# A live-window command with no derivable length: name the provider and
|
|
127
|
+
# say what is missing, never a window the alert did not describe.
|
|
128
|
+
detail = (
|
|
129
|
+
f"{scope.provider} · {scope.withheld_reason}"
|
|
130
|
+
if scope.provider else scope.withheld_reason
|
|
131
|
+
)
|
|
132
|
+
return format_next_step(command, detail=detail)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def synthetic_preview_week_start(
|
|
136
|
+
now: "dt.datetime | None" = None,
|
|
137
|
+
) -> dt.datetime:
|
|
138
|
+
"""The week start the two `alerts test --axis weekly` previews use.
|
|
139
|
+
|
|
140
|
+
A real crossing carries `percent_milestones.week_start_at`, the week's
|
|
141
|
+
reset instant, and a preview that omitted it rendered the day-granularity
|
|
142
|
+
fallback — two bare dates with no reset hour and no zone — which is not
|
|
143
|
+
what a real crossing produces. The instant is hour-aligned and one day
|
|
144
|
+
back so its reset hour is a real clock reading rather than the midnight
|
|
145
|
+
that would render identically to the fallback it replaces.
|
|
146
|
+
"""
|
|
147
|
+
if now is None:
|
|
148
|
+
now = dt.datetime.now(dt.timezone.utc)
|
|
149
|
+
return (now - dt.timedelta(days=1)).replace(
|
|
150
|
+
minute=0, second=0, microsecond=0
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _with_next_step(body: str, payload: dict, tz: "ZoneInfo | None") -> str:
|
|
155
|
+
"""Append the affordance as its own line. AppleScript collapses the
|
|
156
|
+
newline to a space (``_escape_applescript_string``) and ``notify-send``
|
|
157
|
+
keeps it, so both notifiers render the whole sentence."""
|
|
158
|
+
return f"{body}\n{alert_next_step_line(payload, tz)}"
|
|
159
|
+
|
|
66
160
|
|
|
67
161
|
def _alert_text_weekly(payload: dict, tz: "ZoneInfo | None") -> tuple[str, str, str]:
|
|
68
162
|
"""Build (title, subtitle, body) for a weekly threshold alert.
|
|
@@ -96,7 +190,7 @@ def _alert_text_weekly(payload: dict, tz: "ZoneInfo | None") -> tuple[str, str,
|
|
|
96
190
|
body = f"${cumulative:.2f} spent so far - ${float(dpp):.2f} per 1%"
|
|
97
191
|
else:
|
|
98
192
|
body = f"${cumulative:.2f} spent so far"
|
|
99
|
-
return title, subtitle, body
|
|
193
|
+
return title, subtitle, _with_next_step(body, payload, tz)
|
|
100
194
|
|
|
101
195
|
|
|
102
196
|
def _alert_text_five_hour(payload: dict, tz: "ZoneInfo | None") -> tuple[str, str, str]:
|
|
@@ -121,7 +215,7 @@ def _alert_text_five_hour(payload: dict, tz: "ZoneInfo | None") -> tuple[str, st
|
|
|
121
215
|
body = f"${cost:.2f} spent in this block - current model: {model}"
|
|
122
216
|
else:
|
|
123
217
|
body = f"${cost:.2f} spent in this block"
|
|
124
|
-
return title, subtitle, body
|
|
218
|
+
return title, subtitle, _with_next_step(body, payload, tz)
|
|
125
219
|
|
|
126
220
|
|
|
127
221
|
def _escape_applescript_string(s: str) -> str:
|
|
@@ -147,6 +241,7 @@ def _build_alert_payload_weekly(
|
|
|
147
241
|
cumulative_cost_usd: float,
|
|
148
242
|
dollars_per_percent: "float | None",
|
|
149
243
|
account_key: str = _UNATTRIBUTED,
|
|
244
|
+
week_start_at: "str | None" = None,
|
|
150
245
|
) -> dict:
|
|
151
246
|
"""Build the alert payload for a weekly threshold crossing.
|
|
152
247
|
|
|
@@ -155,7 +250,16 @@ def _build_alert_payload_weekly(
|
|
|
155
250
|
(set-then-dispatch invariant, spec §3.2). Consumers (envelope builders,
|
|
156
251
|
test inspectors) read the ``alerted_at`` field as the authoritative
|
|
157
252
|
"alert was attempted" timestamp. ``account_key`` (#341) is the real account
|
|
158
|
-
the crossing wrote under (per-account family).
|
|
253
|
+
the crossing wrote under (per-account family).
|
|
254
|
+
|
|
255
|
+
``week_start_at`` is the week's RESET INSTANT, retained on the milestone
|
|
256
|
+
row and already a declared ``AlertEntry.context`` key. Without it a reader
|
|
257
|
+
can only fall back to ``week_start_date``, which names the right week but
|
|
258
|
+
places both bounds at UTC midnight — wrong by the whole reset-hour offset
|
|
259
|
+
for every account whose week does not reset at midnight. Empty string when
|
|
260
|
+
the row predates the column, matching the five-hour axis's
|
|
261
|
+
``block_start_at``: the key stays on the wire and the reader degrades to
|
|
262
|
+
day granularity rather than inventing a clock reading."""
|
|
159
263
|
return {
|
|
160
264
|
"id": f"weekly:{week_start_date}:{threshold}",
|
|
161
265
|
"axis": "weekly",
|
|
@@ -165,6 +269,7 @@ def _build_alert_payload_weekly(
|
|
|
165
269
|
"account_key": str(account_key),
|
|
166
270
|
"context": {
|
|
167
271
|
"week_start_date": week_start_date,
|
|
272
|
+
"week_start_at": week_start_at or "",
|
|
168
273
|
"cumulative_cost_usd": float(cumulative_cost_usd),
|
|
169
274
|
"dollars_per_percent": (
|
|
170
275
|
float(dollars_per_percent) if dollars_per_percent is not None else None
|
|
@@ -224,7 +329,7 @@ def _alert_text_budget(payload: dict, tz: "ZoneInfo | None") -> tuple[str, str,
|
|
|
224
329
|
budget = float(ctx.get("budget_usd") or 0.0)
|
|
225
330
|
consumption = float(ctx.get("consumption_pct") or 0.0)
|
|
226
331
|
body = f"${spent:,.2f} of ${budget:,.2f} ({consumption:.0f}% of budget)"
|
|
227
|
-
return title, subtitle, body
|
|
332
|
+
return title, subtitle, _with_next_step(body, payload, tz)
|
|
228
333
|
|
|
229
334
|
|
|
230
335
|
def _build_alert_payload_budget(
|
|
@@ -298,7 +403,7 @@ def _alert_text_project_budget(
|
|
|
298
403
|
f"Project {project} - ${spent:,.2f} of ${budget:,.2f} "
|
|
299
404
|
f"({consumption:.0f}% of budget)"
|
|
300
405
|
)
|
|
301
|
-
return title, subtitle, body
|
|
406
|
+
return title, subtitle, _with_next_step(body, payload, tz)
|
|
302
407
|
|
|
303
408
|
|
|
304
409
|
def _build_alert_payload_project_budget(
|
|
@@ -374,7 +479,7 @@ def _alert_text_codex_budget(
|
|
|
374
479
|
f"Codex - ${spent:,.2f} of ${budget:,.2f} "
|
|
375
480
|
f"({consumption:.0f}% of budget)"
|
|
376
481
|
)
|
|
377
|
-
return title, subtitle, body
|
|
482
|
+
return title, subtitle, _with_next_step(body, payload, tz)
|
|
378
483
|
|
|
379
484
|
|
|
380
485
|
def _build_alert_payload_codex_budget(
|
|
@@ -451,7 +556,7 @@ def _alert_text_projected(payload: dict, tz: "ZoneInfo | None") -> tuple[str, st
|
|
|
451
556
|
f"Projected ${proj:,.2f} of ${denom:,.2f} budget "
|
|
452
557
|
f"(week-average pace)"
|
|
453
558
|
)
|
|
454
|
-
return title, subtitle, body
|
|
559
|
+
return title, subtitle, _with_next_step(body, payload, tz)
|
|
455
560
|
|
|
456
561
|
|
|
457
562
|
def _build_alert_payload_projected(
|
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
|
package/bin/_lib_cache_report.py
CHANGED
|
@@ -17,7 +17,9 @@ from __future__ import annotations
|
|
|
17
17
|
|
|
18
18
|
import datetime as dt
|
|
19
19
|
from dataclasses import dataclass, field
|
|
20
|
-
from typing import
|
|
20
|
+
from typing import (
|
|
21
|
+
Any, Callable, Iterable, Literal, NamedTuple, Optional, Sequence,
|
|
22
|
+
)
|
|
21
23
|
from zoneinfo import ZoneInfo
|
|
22
24
|
|
|
23
25
|
|
|
@@ -853,6 +855,113 @@ def _compute_baseline_median(
|
|
|
853
855
|
return statistics.median(values)
|
|
854
856
|
|
|
855
857
|
|
|
858
|
+
class CacheRowVerdict(NamedTuple):
|
|
859
|
+
"""One row's four-state Cache Report verdict (#620 S1 D6).
|
|
860
|
+
|
|
861
|
+
``observed`` travels beside ``state`` rather than inside it: a day with
|
|
862
|
+
no activity is still ``unevaluated``, and whether anything happened is a
|
|
863
|
+
separate fact from whether the predicates ran.
|
|
864
|
+
"""
|
|
865
|
+
state: str # "anomalous" | "clean" | "partial" | "unevaluated"
|
|
866
|
+
triggered: bool
|
|
867
|
+
unevaluated: "tuple[CacheAnomalyReason, ...]"
|
|
868
|
+
observed: bool
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
# The worded terminal labels. The dashboard renders glyph + CSS class from
|
|
872
|
+
# `cacheRowFlagGlyph` / `cacheRowFlagClass`; the terminal has no CSS and a
|
|
873
|
+
# bare glyph cannot say which of the three non-anomalous states a row is in,
|
|
874
|
+
# which is exactly the defect: an unmarked tick meant "evaluated and clean",
|
|
875
|
+
# "one predicate skipped" and "nothing evaluated at all" alike.
|
|
876
|
+
#
|
|
877
|
+
# WIDTH IS A HARD CONSTRAINT, and it is why these read `not eval` rather than
|
|
878
|
+
# the spec's `· not evaluated`. `_layout_cache_table` gives a left-aligned,
|
|
879
|
+
# non-expandable column a compact floor of 8 characters, and at the shipped
|
|
880
|
+
# 120-column default the daily table has EXACTLY 8 columns of slack for a
|
|
881
|
+
# twelfth column once the ultra-compact fallback has run:
|
|
882
|
+
# 120 - 34 border - 10 Date - 12 Models - 56 (8 numeric x 7) = 8.
|
|
883
|
+
# A 15-character `· not evaluated` cannot be rendered there; it truncates to
|
|
884
|
+
# `· not e…`, which is unreadable and is the overflow class the plan refuses
|
|
885
|
+
# to re-take a golden over. The glyph is dropped from the label rather than
|
|
886
|
+
# the word, because an anomalous row already carries the red anomaly glyph on
|
|
887
|
+
# its first cell — so no state loses its marker, and every state gains a word
|
|
888
|
+
# that fits.
|
|
889
|
+
_CACHE_ROW_VERDICT_LABELS: "dict[str, str]" = {
|
|
890
|
+
"anomalous": "anomaly",
|
|
891
|
+
"clean": "clear",
|
|
892
|
+
"partial": "partial",
|
|
893
|
+
"unevaluated": "not eval",
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def cache_row_verdict(
|
|
898
|
+
*,
|
|
899
|
+
triggered: bool,
|
|
900
|
+
reasons: "Sequence[CacheAnomalyReason] | None" = None,
|
|
901
|
+
unevaluated: "Sequence[CacheAnomalyReason] | None" = None,
|
|
902
|
+
predicates: "Sequence[CacheAnomalyReason] | None" = None,
|
|
903
|
+
observed: "bool | None" = None,
|
|
904
|
+
) -> CacheRowVerdict:
|
|
905
|
+
"""Classify one row into the four Cache Report states (#620 S1 D6).
|
|
906
|
+
|
|
907
|
+
Mirrors ``cacheRowVerdict`` in
|
|
908
|
+
``dashboard/web/src/lib/cacheReportVerdict.ts`` exactly, including
|
|
909
|
+
triggered precedence. The two are separate implementations — one literal
|
|
910
|
+
classifier is not possible across the language boundary without code
|
|
911
|
+
generation — and they are pinned to each other by a single shared
|
|
912
|
+
truth-table fixture, ``tests/fixtures/cache-report-verdict-vectors.json``,
|
|
913
|
+
which both ``tests/test_620_cache_report_verdict.py`` and
|
|
914
|
+
``dashboard/web/__tests__/cacheReportVerdictParity.test.ts`` read.
|
|
915
|
+
|
|
916
|
+
``predicates`` is the set the PROVIDER can evaluate at all, defaulting to
|
|
917
|
+
``CACHE_ANOMALY_PREDICATES`` (Claude's pair). Codex can only ever
|
|
918
|
+
evaluate ``cache_drop``, so a Codex row carrying it alone is FULLY
|
|
919
|
+
unevaluated; resolving it against the Claude pair would call it
|
|
920
|
+
``partial``, which claims some other predicate ran when none exists.
|
|
921
|
+
Not applicable is not unevaluated.
|
|
922
|
+
|
|
923
|
+
``observed=None`` means observed, so a row from a producer that does not
|
|
924
|
+
publish the field reads as it does today.
|
|
925
|
+
|
|
926
|
+
``reasons`` is accepted and deliberately unused in the classification:
|
|
927
|
+
the state is a function of ``triggered`` and the unevaluated set. It is
|
|
928
|
+
part of the signature because the truth table carries it and a caller
|
|
929
|
+
should not have to know which half the rule reads.
|
|
930
|
+
"""
|
|
931
|
+
unevaluated_t = tuple(unevaluated or ())
|
|
932
|
+
predicate_t = tuple(
|
|
933
|
+
predicates if predicates is not None else CACHE_ANOMALY_PREDICATES
|
|
934
|
+
)
|
|
935
|
+
# Triggered wins first. A thin-baseline cache_drop beside a real
|
|
936
|
+
# net_negative is still a real anomaly, and the unevaluated presentation
|
|
937
|
+
# must never swallow it.
|
|
938
|
+
if triggered:
|
|
939
|
+
state = "anomalous"
|
|
940
|
+
elif not unevaluated_t:
|
|
941
|
+
state = "clean"
|
|
942
|
+
elif len(unevaluated_t) >= len(predicate_t):
|
|
943
|
+
state = "unevaluated"
|
|
944
|
+
else:
|
|
945
|
+
state = "partial"
|
|
946
|
+
return CacheRowVerdict(
|
|
947
|
+
state=state,
|
|
948
|
+
triggered=bool(triggered),
|
|
949
|
+
unevaluated=unevaluated_t,
|
|
950
|
+
observed=observed is not False,
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def cache_row_verdict_label(state: str) -> str:
|
|
955
|
+
"""The worded terminal label for one verdict state.
|
|
956
|
+
|
|
957
|
+
There is no `unicode_ok` axis: every label above is pure ASCII, so an
|
|
958
|
+
ASCII-fallback table would be a verbatim copy and the parameter would
|
|
959
|
+
select between two identical results while every caller still had to
|
|
960
|
+
pass it. A future label that does need a glyph adds the axis then.
|
|
961
|
+
"""
|
|
962
|
+
return _CACHE_ROW_VERDICT_LABELS[state]
|
|
963
|
+
|
|
964
|
+
|
|
856
965
|
def _classify_anomalies(
|
|
857
966
|
rows: list[CacheRow],
|
|
858
967
|
*,
|
|
@@ -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_codex_pools.py
CHANGED
|
@@ -38,18 +38,30 @@ def is_model_scoped_codex_quota(logical_limit_key: object, limit_name: object) -
|
|
|
38
38
|
return codex_model_scoped_quota_pool(limit_name) is not None
|
|
39
39
|
|
|
40
40
|
|
|
41
|
-
def
|
|
41
|
+
def codex_key_model_pool(logical_limit_key: object) -> str | None:
|
|
42
|
+
"""Return the ``modelPool`` an interpreted quota key names, else ``None``.
|
|
43
|
+
|
|
44
|
+
The name axis of the same classification ``is_model_scoped_codex_quota``
|
|
45
|
+
answers as a boolean. Callers that need to PRESENT the pool (#620 S1's
|
|
46
|
+
alert scope) read it here rather than parsing the key themselves, so pool
|
|
47
|
+
classification keeps exactly one home.
|
|
48
|
+
"""
|
|
42
49
|
if not isinstance(logical_limit_key, str):
|
|
43
|
-
return
|
|
50
|
+
return None
|
|
44
51
|
try:
|
|
45
52
|
payload = json.loads(logical_limit_key)
|
|
46
53
|
except (json.JSONDecodeError, TypeError, ValueError):
|
|
47
|
-
return
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
54
|
+
return None
|
|
55
|
+
if not isinstance(payload, dict):
|
|
56
|
+
return None
|
|
57
|
+
pool = payload.get("modelPool")
|
|
58
|
+
if not isinstance(pool, str) or not pool.strip():
|
|
59
|
+
return None
|
|
60
|
+
return pool.strip().lower()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _key_has_model_pool(logical_limit_key: object) -> bool:
|
|
64
|
+
return codex_key_model_pool(logical_limit_key) is not None
|
|
53
65
|
|
|
54
66
|
|
|
55
67
|
def codex_history_is_model_scoped(history, *, baseline=None) -> bool:
|
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):
|