cctally 1.82.0 → 1.83.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 +70 -0
- package/README.md +52 -74
- package/bin/_cctally_alerts.py +8 -1
- package/bin/_cctally_cache.py +963 -149
- package/bin/_cctally_config.py +43 -4
- package/bin/_cctally_core.py +933 -759
- package/bin/_cctally_dashboard.py +157 -47
- package/bin/_cctally_dashboard_cache_report.py +13 -6
- package/bin/_cctally_dashboard_conversation.py +1 -0
- package/bin/_cctally_dashboard_envelope.py +186 -8
- package/bin/_cctally_dashboard_share.py +60 -20
- package/bin/_cctally_dashboard_sources.py +427 -128
- package/bin/_cctally_db.py +605 -128
- package/bin/_cctally_doctor.py +413 -28
- package/bin/_cctally_five_hour.py +12 -5
- package/bin/_cctally_journal.py +2050 -156
- package/bin/_cctally_journal_repair.py +519 -0
- package/bin/_cctally_milestone_history.py +142 -56
- package/bin/_cctally_milestones.py +179 -111
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_project.py +24 -18
- package/bin/_cctally_quota.py +139 -25
- package/bin/_cctally_record.py +279 -108
- package/bin/_cctally_rederive.py +1052 -0
- package/bin/_cctally_reporting.py +58 -53
- package/bin/_cctally_setup.py +1 -0
- package/bin/_cctally_source_analytics.py +4 -1
- package/bin/_cctally_statusline.py +11 -11
- package/bin/_cctally_store.py +1039 -31
- package/bin/_cctally_sync_week.py +17 -8
- package/bin/_cctally_tui.py +421 -54
- package/bin/_cctally_update.py +133 -8
- package/bin/_cctally_weekrefs.py +14 -0
- package/bin/_lib_aggregators.py +10 -6
- package/bin/_lib_cache_report.py +101 -9
- package/bin/_lib_codex_pools.py +82 -0
- package/bin/_lib_conversation_query.py +126 -33
- package/bin/_lib_dashboard_sources.py +126 -1
- package/bin/_lib_diff_kernel.py +28 -15
- package/bin/_lib_doctor.py +342 -4
- package/bin/_lib_journal.py +924 -2
- package/bin/_lib_jsonl.py +43 -14
- package/bin/_lib_pricing.py +140 -21
- package/bin/_lib_readme_refresh.py +401 -0
- package/bin/_lib_rederive.py +395 -0
- package/bin/_lib_share.py +58 -2
- package/bin/cctally +56 -8
- package/dashboard/static/assets/{index-DJP4gEB7.js → index-3bgCMVHb.js} +52 -52
- package/dashboard/static/assets/index-D27EIHEI.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +6 -1
- package/dashboard/static/assets/index-Dk1nplOz.css +0 -1
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Pure correction-plan kernel for journal rederivation.
|
|
2
|
+
|
|
3
|
+
Task B of #372 deliberately separates two responsibilities:
|
|
4
|
+
|
|
5
|
+
* eager code replays retained truth through the current derivation hooks in a
|
|
6
|
+
scratch index and produces the desired effective events; and
|
|
7
|
+
* this module compares those events with Task A's ``EffectiveSelection`` and
|
|
8
|
+
renders a deterministic, auditable correction plan.
|
|
9
|
+
|
|
10
|
+
No filesystem, SQLite, provider, alert, or journal operation belongs here.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import dataclasses
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
from collections.abc import Iterable, Mapping
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
FAMILY = "claude-usage"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RederiveError(RuntimeError):
|
|
25
|
+
"""Base error for a plan that cannot be produced truthfully."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RederiveDataGap(RederiveError):
|
|
29
|
+
"""Required retained source data is absent or too old for current logic."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RederiveConflict(RederiveError):
|
|
33
|
+
"""The desired event set is internally inconsistent."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclasses.dataclass(frozen=True)
|
|
37
|
+
class KindClassification:
|
|
38
|
+
mode: str
|
|
39
|
+
reason: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_EVT_CLASSIFICATIONS = {
|
|
43
|
+
"snapshot_accept": KindClassification(
|
|
44
|
+
"rederived", "accepted Claude usage observation"),
|
|
45
|
+
"weekly_cost_snapshot": KindClassification(
|
|
46
|
+
"rederived", "as-of-bounded Claude cache cost"),
|
|
47
|
+
"weekly_credit_effects": KindClassification(
|
|
48
|
+
"rederived", "record-credit destructive effects"),
|
|
49
|
+
"week_reset": KindClassification(
|
|
50
|
+
"rederived", "Claude weekly reset or credit decision"),
|
|
51
|
+
"five_hour_credit": KindClassification(
|
|
52
|
+
"rederived", "Claude five-hour credit decision"),
|
|
53
|
+
"five_hour_block_close": KindClassification(
|
|
54
|
+
"rederived", "closed Claude five-hour projection"),
|
|
55
|
+
"percent_milestone": KindClassification(
|
|
56
|
+
"rederived", "dependent Claude weekly milestone"),
|
|
57
|
+
"five_hour_milestone": KindClassification(
|
|
58
|
+
"rederived", "dependent Claude five-hour milestone"),
|
|
59
|
+
"budget": KindClassification(
|
|
60
|
+
"re_materialized_projection",
|
|
61
|
+
"historical budget config is not journaled; stale Claude latches retire"),
|
|
62
|
+
"projected": KindClassification(
|
|
63
|
+
"re_materialized_projection",
|
|
64
|
+
"historical alert config is not journaled; stale Claude latches retire"),
|
|
65
|
+
"project_budget": KindClassification(
|
|
66
|
+
"re_materialized_projection",
|
|
67
|
+
"historical project budgets are not journaled; stale latches retire"),
|
|
68
|
+
"quota_alert_arming": KindClassification(
|
|
69
|
+
"retained", "Codex quota lifecycle state is outside claude-usage"),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_OP_CLASSIFICATIONS = {
|
|
73
|
+
"weekly_credit_floor": KindClassification(
|
|
74
|
+
"retained_input", "operator credit truth is replayed into scratch"),
|
|
75
|
+
"account_observe": KindClassification(
|
|
76
|
+
"retained_input", "account ownership truth is replayed into scratch"),
|
|
77
|
+
"account_label": KindClassification(
|
|
78
|
+
"retained_input", "operator account labels are replayed into scratch"),
|
|
79
|
+
"accounts_cutover": KindClassification(
|
|
80
|
+
"retained_input",
|
|
81
|
+
"legacy Claude ownership normalizes unstamped journal history"),
|
|
82
|
+
"sync_week": KindClassification(
|
|
83
|
+
"retained_input", "operator cost-sync request is re-executed in scratch"),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_owned_event(record: Mapping) -> bool:
|
|
88
|
+
payload = record.get("payload") or {}
|
|
89
|
+
kind = payload.get("kind")
|
|
90
|
+
classification = _EVT_CLASSIFICATIONS.get(kind)
|
|
91
|
+
if classification is None or classification.mode not in {
|
|
92
|
+
"rederived", "re_materialized_projection",
|
|
93
|
+
}:
|
|
94
|
+
return False
|
|
95
|
+
if kind == "budget":
|
|
96
|
+
return payload.get("vendor", "claude") == "claude"
|
|
97
|
+
if kind == "projected":
|
|
98
|
+
metric = str(payload.get("metric") or "")
|
|
99
|
+
return not metric.startswith("codex_")
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclasses.dataclass(frozen=True)
|
|
104
|
+
class FamilyRegistryReport:
|
|
105
|
+
family: str
|
|
106
|
+
evt: Mapping[str, KindClassification]
|
|
107
|
+
op: Mapping[str, KindClassification]
|
|
108
|
+
unclassified_evt_kinds: tuple[str, ...]
|
|
109
|
+
unclassified_op_kinds: tuple[str, ...]
|
|
110
|
+
|
|
111
|
+
def classification_for_evt(self, kind: str) -> KindClassification:
|
|
112
|
+
return self.evt[kind]
|
|
113
|
+
|
|
114
|
+
def classification_for_op(self, kind: str) -> KindClassification:
|
|
115
|
+
return self.op[kind]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def validate_family_registry(*, evt_kinds: set[str],
|
|
119
|
+
op_kinds: set[str]) -> FamilyRegistryReport:
|
|
120
|
+
"""Return the closure table and expose any newly-added unclassified kind."""
|
|
121
|
+
return FamilyRegistryReport(
|
|
122
|
+
family=FAMILY,
|
|
123
|
+
evt=dict(_EVT_CLASSIFICATIONS),
|
|
124
|
+
op=dict(_OP_CLASSIFICATIONS),
|
|
125
|
+
unclassified_evt_kinds=tuple(sorted(evt_kinds - _EVT_CLASSIFICATIONS.keys())),
|
|
126
|
+
unclassified_op_kinds=tuple(sorted(op_kinds - _OP_CLASSIFICATIONS.keys())),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
_SESSION_ENTRY_COLUMNS = frozenset({
|
|
131
|
+
"timestamp_utc",
|
|
132
|
+
"model",
|
|
133
|
+
"input_tokens",
|
|
134
|
+
"output_tokens",
|
|
135
|
+
"cache_create_tokens",
|
|
136
|
+
"cache_read_tokens",
|
|
137
|
+
"cache_create_1h_tokens",
|
|
138
|
+
"source_path",
|
|
139
|
+
"account_key",
|
|
140
|
+
})
|
|
141
|
+
_SESSION_FILE_COLUMNS = frozenset({"path", "session_id", "project_path"})
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def validate_claude_cache_contract(tables: Mapping[str, set[str]]) -> None:
|
|
145
|
+
"""Fail before planning if current Claude cost inputs are not retained."""
|
|
146
|
+
if "session_entries" not in tables:
|
|
147
|
+
raise RederiveDataGap("missing cache.db table session_entries")
|
|
148
|
+
missing_entries = sorted(
|
|
149
|
+
_SESSION_ENTRY_COLUMNS - set(tables["session_entries"]))
|
|
150
|
+
if missing_entries:
|
|
151
|
+
raise RederiveDataGap(
|
|
152
|
+
"missing cache.db session_entries column(s): "
|
|
153
|
+
+ ", ".join(missing_entries)
|
|
154
|
+
)
|
|
155
|
+
if "session_files" not in tables:
|
|
156
|
+
raise RederiveDataGap("missing cache.db table session_files")
|
|
157
|
+
missing_files = sorted(_SESSION_FILE_COLUMNS - set(tables["session_files"]))
|
|
158
|
+
if missing_files:
|
|
159
|
+
raise RederiveDataGap(
|
|
160
|
+
"missing cache.db session_files column(s): " + ", ".join(missing_files)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _canonical_bytes(value) -> bytes:
|
|
165
|
+
return json.dumps(
|
|
166
|
+
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
|
167
|
+
).encode("utf-8")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _sha256(value) -> str:
|
|
171
|
+
return "sha256:" + hashlib.sha256(_canonical_bytes(value)).hexdigest()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _semantic_event(record: Mapping) -> dict:
|
|
175
|
+
return {
|
|
176
|
+
"id": record.get("id"),
|
|
177
|
+
"at": record.get("at"),
|
|
178
|
+
"payload": record.get("payload") or {},
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _preserve_non_derivable_state(
|
|
183
|
+
current_record: Mapping, desired_record: Mapping
|
|
184
|
+
) -> Mapping:
|
|
185
|
+
"""Carry durable state that scratch replay cannot truthfully reconstruct.
|
|
186
|
+
|
|
187
|
+
Percent-milestone ``alerted_at`` is the latch recorded when the first
|
|
188
|
+
crossing actually dispatched. Historical alert configuration is not
|
|
189
|
+
retained, so scratch replay deliberately runs with alerts disabled and
|
|
190
|
+
produces ``None``. Preserve the selected event's exact latch both when the
|
|
191
|
+
rest of the milestone is retained and when another field needs an audited
|
|
192
|
+
higher-revision correction (#410 Task B).
|
|
193
|
+
"""
|
|
194
|
+
current_payload = current_record.get("payload") or {}
|
|
195
|
+
desired_payload = desired_record.get("payload") or {}
|
|
196
|
+
if (
|
|
197
|
+
current_payload.get("kind") != "percent_milestone"
|
|
198
|
+
or desired_payload.get("kind") != "percent_milestone"
|
|
199
|
+
or "alerted_at" not in current_payload
|
|
200
|
+
):
|
|
201
|
+
return desired_record
|
|
202
|
+
merged = dict(desired_record)
|
|
203
|
+
merged_payload = dict(desired_payload)
|
|
204
|
+
merged_payload["alerted_at"] = current_payload["alerted_at"]
|
|
205
|
+
merged["payload"] = merged_payload
|
|
206
|
+
return merged
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclasses.dataclass(frozen=True)
|
|
210
|
+
class PlanAction:
|
|
211
|
+
disposition: str
|
|
212
|
+
event_id: str
|
|
213
|
+
revision: int
|
|
214
|
+
at: str
|
|
215
|
+
payload: "dict | None"
|
|
216
|
+
payload_hash: str
|
|
217
|
+
|
|
218
|
+
def to_dict(self) -> dict:
|
|
219
|
+
out = {
|
|
220
|
+
"disposition": self.disposition,
|
|
221
|
+
"eventId": self.event_id,
|
|
222
|
+
"revision": self.revision,
|
|
223
|
+
"at": self.at,
|
|
224
|
+
"payloadHash": self.payload_hash,
|
|
225
|
+
}
|
|
226
|
+
if self.payload is not None:
|
|
227
|
+
out["payload"] = self.payload
|
|
228
|
+
return out
|
|
229
|
+
|
|
230
|
+
def to_correction_action(self) -> dict:
|
|
231
|
+
if self.disposition == "tombstone":
|
|
232
|
+
return {
|
|
233
|
+
"action": "tombstone",
|
|
234
|
+
"id": self.event_id,
|
|
235
|
+
"rev": self.revision,
|
|
236
|
+
"at": self.at,
|
|
237
|
+
"payload": None,
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
"action": "replace",
|
|
241
|
+
"id": self.event_id,
|
|
242
|
+
"rev": self.revision,
|
|
243
|
+
"at": self.at,
|
|
244
|
+
"payload": dict(self.payload or {}),
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@dataclasses.dataclass(frozen=True)
|
|
249
|
+
class RederivePlan:
|
|
250
|
+
family: str
|
|
251
|
+
journal_high_water: "tuple[str, int] | None"
|
|
252
|
+
cache_fingerprint: str
|
|
253
|
+
config_fingerprint: str
|
|
254
|
+
counts: Mapping[str, int]
|
|
255
|
+
actions: tuple[PlanAction, ...]
|
|
256
|
+
retained_event_count: int
|
|
257
|
+
|
|
258
|
+
def _body(self) -> dict:
|
|
259
|
+
return {
|
|
260
|
+
"schemaVersion": 1,
|
|
261
|
+
"family": self.family,
|
|
262
|
+
"journalHighWater": (
|
|
263
|
+
None if self.journal_high_water is None else {
|
|
264
|
+
"segment": self.journal_high_water[0],
|
|
265
|
+
"offset": self.journal_high_water[1],
|
|
266
|
+
}
|
|
267
|
+
),
|
|
268
|
+
"cacheFingerprint": self.cache_fingerprint,
|
|
269
|
+
"configFingerprint": self.config_fingerprint,
|
|
270
|
+
"counts": dict(self.counts),
|
|
271
|
+
"retainedEventCount": self.retained_event_count,
|
|
272
|
+
"payloadHashes": sorted(action.payload_hash for action in self.actions),
|
|
273
|
+
"actions": [action.to_dict() for action in self.actions],
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def plan_hash(self) -> str:
|
|
278
|
+
return _sha256(self._body())
|
|
279
|
+
|
|
280
|
+
def to_bytes(self) -> bytes:
|
|
281
|
+
body = self._body()
|
|
282
|
+
body["planHash"] = self.plan_hash
|
|
283
|
+
return _canonical_bytes(body)
|
|
284
|
+
|
|
285
|
+
def to_correction_actions(self) -> list[dict]:
|
|
286
|
+
return [action.to_correction_action() for action in self.actions]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _desired_by_id(events: Iterable[Mapping]) -> dict[str, Mapping]:
|
|
290
|
+
desired = {}
|
|
291
|
+
for event in events:
|
|
292
|
+
event_id = event.get("id")
|
|
293
|
+
if not isinstance(event_id, str) or not event_id:
|
|
294
|
+
raise RederiveConflict("desired event is missing a non-empty id")
|
|
295
|
+
if not _is_owned_event(event):
|
|
296
|
+
continue
|
|
297
|
+
prior = desired.get(event_id)
|
|
298
|
+
if prior is not None and _semantic_event(prior) != _semantic_event(event):
|
|
299
|
+
raise RederiveConflict(f"divergent desired event id {event_id}")
|
|
300
|
+
desired[event_id] = event
|
|
301
|
+
return desired
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def build_claude_usage_plan(*, selection, desired_events: Iterable[Mapping],
|
|
305
|
+
journal_high_water: "tuple[str, int] | None",
|
|
306
|
+
cache_fingerprint: str,
|
|
307
|
+
config_fingerprint: str,
|
|
308
|
+
conflicted_event_ids=frozenset()) -> RederivePlan:
|
|
309
|
+
"""Diff current effective events against one scratch-derived desired set.
|
|
310
|
+
|
|
311
|
+
``conflicted_event_ids`` (#374) names the event ids this family owns whose
|
|
312
|
+
same-revision group the selector QUARANTINED at the winning revision. They
|
|
313
|
+
force a ``supersede`` at ``selected.rev + 1`` **even when the provisional
|
|
314
|
+
winner already equals the desired state** — without that, the equality
|
|
315
|
+
branch below returns ``retain`` and the rev-0 conflict survives in the
|
|
316
|
+
append-only journal forever, which is precisely the case the first design
|
|
317
|
+
called already-correct. The resulting correction is a semantic no-op in
|
|
318
|
+
content and a revision advance in effect, and the revision advance is what
|
|
319
|
+
suppresses the rev-0 group under the selector's revision filter."""
|
|
320
|
+
conflicted_event_ids = frozenset(conflicted_event_ids or ())
|
|
321
|
+
desired = _desired_by_id(desired_events)
|
|
322
|
+
current = {
|
|
323
|
+
event_id: selected.record
|
|
324
|
+
for event_id, selected in selection.by_id.items()
|
|
325
|
+
if selected.status == "active"
|
|
326
|
+
and selected.record is not None
|
|
327
|
+
and _is_owned_event(selected.record)
|
|
328
|
+
}
|
|
329
|
+
retained_event_count = sum(
|
|
330
|
+
1 for selected in selection.by_id.values()
|
|
331
|
+
if selected.status == "active"
|
|
332
|
+
and selected.record is not None
|
|
333
|
+
and not _is_owned_event(selected.record)
|
|
334
|
+
)
|
|
335
|
+
counts = {"retain": 0, "supersede": 0, "tombstone": 0, "add": 0}
|
|
336
|
+
actions: list[PlanAction] = []
|
|
337
|
+
|
|
338
|
+
for event_id in sorted(set(current) | set(desired)):
|
|
339
|
+
current_record = current.get(event_id)
|
|
340
|
+
desired_record = desired.get(event_id)
|
|
341
|
+
selected = selection.by_id.get(event_id)
|
|
342
|
+
if current_record is not None and desired_record is not None:
|
|
343
|
+
desired_record = _preserve_non_derivable_state(
|
|
344
|
+
current_record, desired_record
|
|
345
|
+
)
|
|
346
|
+
if (
|
|
347
|
+
_semantic_event(current_record) == _semantic_event(desired_record)
|
|
348
|
+
and event_id not in conflicted_event_ids
|
|
349
|
+
):
|
|
350
|
+
counts["retain"] += 1
|
|
351
|
+
continue
|
|
352
|
+
revision = int(selected.rev) + 1
|
|
353
|
+
disposition = "supersede"
|
|
354
|
+
at = str(desired_record["at"])
|
|
355
|
+
payload = dict(desired_record.get("payload") or {})
|
|
356
|
+
elif current_record is not None:
|
|
357
|
+
revision = int(selected.rev) + 1
|
|
358
|
+
disposition = "tombstone"
|
|
359
|
+
at = str(current_record["at"])
|
|
360
|
+
payload = None
|
|
361
|
+
else:
|
|
362
|
+
if selected is not None:
|
|
363
|
+
# A formerly-tombstoned id must advance rather than attempt rev 0.
|
|
364
|
+
revision = int(selected.rev) + 1
|
|
365
|
+
else:
|
|
366
|
+
revision = 0
|
|
367
|
+
disposition = "add"
|
|
368
|
+
at = str(desired_record["at"])
|
|
369
|
+
payload = dict(desired_record.get("payload") or {})
|
|
370
|
+
counts[disposition] += 1
|
|
371
|
+
action_shape = {
|
|
372
|
+
"disposition": disposition,
|
|
373
|
+
"eventId": event_id,
|
|
374
|
+
"revision": revision,
|
|
375
|
+
"at": at,
|
|
376
|
+
"payload": payload,
|
|
377
|
+
}
|
|
378
|
+
actions.append(PlanAction(
|
|
379
|
+
disposition=disposition,
|
|
380
|
+
event_id=event_id,
|
|
381
|
+
revision=revision,
|
|
382
|
+
at=at,
|
|
383
|
+
payload=payload,
|
|
384
|
+
payload_hash=_sha256(action_shape),
|
|
385
|
+
))
|
|
386
|
+
|
|
387
|
+
return RederivePlan(
|
|
388
|
+
family=FAMILY,
|
|
389
|
+
journal_high_water=journal_high_water,
|
|
390
|
+
cache_fingerprint=cache_fingerprint,
|
|
391
|
+
config_fingerprint=config_fingerprint,
|
|
392
|
+
counts=counts,
|
|
393
|
+
actions=tuple(actions),
|
|
394
|
+
retained_event_count=retained_event_count,
|
|
395
|
+
)
|
package/bin/_lib_share.py
CHANGED
|
@@ -1160,6 +1160,9 @@ _SVG_TABLE_MIN_COL_W = 24
|
|
|
1160
1160
|
_SVG_AVG_GLYPH_WIDTH_FRACTION = 0.6
|
|
1161
1161
|
_SVG_WRAP_BREAK_CHARS = (" ", "/", "-", "_")
|
|
1162
1162
|
_SVG_ELLIPSIS = "…"
|
|
1163
|
+
_SVG_NOTE_FONT = 11
|
|
1164
|
+
_SVG_NOTE_LINE_H = 16
|
|
1165
|
+
_SVG_NOTE_GAP = 14
|
|
1163
1166
|
|
|
1164
1167
|
|
|
1165
1168
|
def _svg_text_width(text: str, font_size: float) -> float:
|
|
@@ -1236,6 +1239,25 @@ def _wrap_for_width(text: str, content_w: float, font_size: float) -> list[str]:
|
|
|
1236
1239
|
return lines or [_SVG_ELLIPSIS]
|
|
1237
1240
|
|
|
1238
1241
|
|
|
1242
|
+
def _render_svg_notes(
|
|
1243
|
+
snap: "ShareSnapshot", *, palette: dict, x: float, y: float, width: float,
|
|
1244
|
+
) -> tuple[str, float]:
|
|
1245
|
+
"""Render deterministic, wrapped artifact notes and return body + height."""
|
|
1246
|
+
lines: list[str] = []
|
|
1247
|
+
for note in snap.notes:
|
|
1248
|
+
lines.extend(_wrap_for_width(note, width, _SVG_NOTE_FONT))
|
|
1249
|
+
if not lines:
|
|
1250
|
+
return "", 0.0
|
|
1251
|
+
body = svg_group([
|
|
1252
|
+
svg_text(
|
|
1253
|
+
x, y + ((idx + 1) * _SVG_NOTE_LINE_H), line,
|
|
1254
|
+
font_size=_SVG_NOTE_FONT, fill=palette["ref_warn"],
|
|
1255
|
+
)
|
|
1256
|
+
for idx, line in enumerate(lines)
|
|
1257
|
+
])
|
|
1258
|
+
return body, float(len(lines) * _SVG_NOTE_LINE_H)
|
|
1259
|
+
|
|
1260
|
+
|
|
1239
1261
|
def _svg_table_anchor_and_x(align: str, col_x: float, col_w: float,
|
|
1240
1262
|
pad_x: float) -> tuple[str, float]:
|
|
1241
1263
|
"""Map `ColumnSpec.align` to SVG text-anchor + x-coordinate."""
|
|
@@ -1429,9 +1451,26 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
|
|
|
1429
1451
|
content_w = max(_SVG_WIDTH, table_w)
|
|
1430
1452
|
|
|
1431
1453
|
table_block_h = (_SVG_TABLE_GAP + table_h) if has_table else 0
|
|
1454
|
+
note_block_h = 0.0
|
|
1455
|
+
note_svg = ""
|
|
1456
|
+
if include_chrome and snap.notes:
|
|
1457
|
+
note_y = (
|
|
1458
|
+
_SVG_PADDING + header_h + chart_h + table_block_h + _SVG_NOTE_GAP
|
|
1459
|
+
)
|
|
1460
|
+
note_svg, note_h = _render_svg_notes(
|
|
1461
|
+
snap,
|
|
1462
|
+
palette=palette,
|
|
1463
|
+
x=_SVG_PADDING,
|
|
1464
|
+
y=note_y,
|
|
1465
|
+
width=_SVG_WIDTH,
|
|
1466
|
+
)
|
|
1467
|
+
note_block_h = _SVG_NOTE_GAP + note_h
|
|
1432
1468
|
|
|
1433
1469
|
if include_chrome:
|
|
1434
|
-
height =
|
|
1470
|
+
height = (
|
|
1471
|
+
header_h + chart_h + table_block_h + note_block_h
|
|
1472
|
+
+ _SVG_FOOTER_H + (_SVG_PADDING * 2)
|
|
1473
|
+
)
|
|
1435
1474
|
else:
|
|
1436
1475
|
height = chart_h + table_block_h + (_SVG_PADDING * 2)
|
|
1437
1476
|
|
|
@@ -1464,8 +1503,14 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
|
|
|
1464
1503
|
if has_table:
|
|
1465
1504
|
pieces.append(table_svg)
|
|
1466
1505
|
|
|
1506
|
+
if note_svg:
|
|
1507
|
+
pieces.append(note_svg)
|
|
1508
|
+
|
|
1467
1509
|
if include_chrome:
|
|
1468
|
-
footer_y =
|
|
1510
|
+
footer_y = (
|
|
1511
|
+
_SVG_PADDING + header_h + chart_h + table_block_h + note_block_h
|
|
1512
|
+
+ _SVG_FOOTER_BASELINE
|
|
1513
|
+
)
|
|
1469
1514
|
pieces.append(_render_svg_footer(
|
|
1470
1515
|
snap, palette=palette,
|
|
1471
1516
|
x=_SVG_PADDING, y=footer_y,
|
|
@@ -1581,6 +1626,16 @@ def _render_html_fragment(snap: ShareSnapshot, *, palette: dict, branding: bool)
|
|
|
1581
1626
|
f'{_format_generated_at_iso(snap.generated_at)}</div>'
|
|
1582
1627
|
)
|
|
1583
1628
|
table_html = _render_html_table(snap, palette) if snap.columns else ""
|
|
1629
|
+
notes_html = (
|
|
1630
|
+
f'<aside aria-label="Data notes" '
|
|
1631
|
+
f'style="margin-top:14px;padding:8px 10px;border-left:3px solid {palette["ref_warn"]};'
|
|
1632
|
+
f'color:{palette["fg"]};font-size:13px">'
|
|
1633
|
+
+ "".join(
|
|
1634
|
+
f'<div>{_xml_escape(note)}</div>' for note in snap.notes
|
|
1635
|
+
)
|
|
1636
|
+
+ "</aside>"
|
|
1637
|
+
if snap.notes else ""
|
|
1638
|
+
)
|
|
1584
1639
|
if branding:
|
|
1585
1640
|
# "Generated by cctally" stays as a single plain-text substring so HTML
|
|
1586
1641
|
# consumers can grep for the branding marker uniformly with the SVG
|
|
@@ -1598,6 +1653,7 @@ def _render_html_fragment(snap: ShareSnapshot, *, palette: dict, branding: bool)
|
|
|
1598
1653
|
f'<header>{title_html}{source_html}{availability_html}{subtitle_html}{timestamp_html}</header>'
|
|
1599
1654
|
f'{chart_html}'
|
|
1600
1655
|
f'{table_html}'
|
|
1656
|
+
f'{notes_html}'
|
|
1601
1657
|
f'{footer_html}'
|
|
1602
1658
|
)
|
|
1603
1659
|
|
package/bin/cctally
CHANGED
|
@@ -343,6 +343,7 @@ _SEMVER_RE = _lib_semver._SEMVER_RE
|
|
|
343
343
|
_lib_pricing = _load_sibling("_lib_pricing")
|
|
344
344
|
TIERED_THRESHOLD = _lib_pricing.TIERED_THRESHOLD
|
|
345
345
|
CLAUDE_MODEL_PRICING = _lib_pricing.CLAUDE_MODEL_PRICING
|
|
346
|
+
CLAUDE_FAST_MULTIPLIER_OVERRIDES = _lib_pricing.CLAUDE_FAST_MULTIPLIER_OVERRIDES
|
|
346
347
|
CODEX_TIERED_THRESHOLD = _lib_pricing.CODEX_TIERED_THRESHOLD
|
|
347
348
|
CODEX_MODEL_PRICING = _lib_pricing.CODEX_MODEL_PRICING
|
|
348
349
|
CODEX_LEGACY_FALLBACK_MODEL = _lib_pricing.CODEX_LEGACY_FALLBACK_MODEL
|
|
@@ -353,6 +354,11 @@ _resolve_codex_pricing = _lib_pricing._resolve_codex_pricing
|
|
|
353
354
|
_is_codex_fallback = _lib_pricing._is_codex_fallback
|
|
354
355
|
_resolve_model_pricing = _lib_pricing._resolve_model_pricing
|
|
355
356
|
_calculate_entry_cost = _lib_pricing._calculate_entry_cost
|
|
357
|
+
_claude_fast_multiplier = _lib_pricing._claude_fast_multiplier
|
|
358
|
+
# #195: the single construction point for every cost-feeding usage dict,
|
|
359
|
+
# plus the derived 1-hour cache-write multiplier.
|
|
360
|
+
claude_usage_dict = _lib_pricing.claude_usage_dict
|
|
361
|
+
CACHE_WRITE_1H_MULTIPLIER = _lib_pricing.CACHE_WRITE_1H_MULTIPLIER
|
|
356
362
|
_warn_unknown_codex_model = _lib_pricing._warn_unknown_codex_model
|
|
357
363
|
_calculate_codex_entry_cost = _lib_pricing._calculate_codex_entry_cost
|
|
358
364
|
_codex_fast_multiplier = _lib_pricing._codex_fast_multiplier
|
|
@@ -493,6 +499,14 @@ _apply_overlap_clamp_to_subweeks = _lib_subscription_weeks._apply_overlap_clamp_
|
|
|
493
499
|
_apply_reset_events_to_subweeks = _lib_subscription_weeks._apply_reset_events_to_subweeks
|
|
494
500
|
_compute_subscription_weeks = _lib_subscription_weeks._compute_subscription_weeks
|
|
495
501
|
|
|
502
|
+
# Codex quota-pool classification (#373). A pure leaf: stdlib only, no
|
|
503
|
+
# cctally imports, so `_lib_jsonl` can consume it without dragging in the
|
|
504
|
+
# quota kernel. Loaded BEFORE `_lib_jsonl`, which imports from it.
|
|
505
|
+
_lib_codex_pools = _load_sibling("_lib_codex_pools")
|
|
506
|
+
codex_model_scoped_quota_pool = _lib_codex_pools.codex_model_scoped_quota_pool
|
|
507
|
+
is_model_scoped_codex_quota = _lib_codex_pools.is_model_scoped_codex_quota
|
|
508
|
+
codex_history_is_model_scoped = _lib_codex_pools.codex_history_is_model_scoped
|
|
509
|
+
|
|
496
510
|
_lib_jsonl = _load_sibling("_lib_jsonl")
|
|
497
511
|
UsageEntry = _lib_jsonl.UsageEntry
|
|
498
512
|
CodexEntry = _lib_jsonl.CodexEntry
|
|
@@ -969,6 +983,9 @@ _run_pending_migrations = _cctally_db._run_pending_migrations
|
|
|
969
983
|
_recover_version_ahead = _cctally_db._recover_version_ahead
|
|
970
984
|
_log_migration_error = _cctally_db._log_migration_error
|
|
971
985
|
_clear_migration_error_log_entries = _cctally_db._clear_migration_error_log_entries
|
|
986
|
+
_reconcile_durable_applied_migration_errors = (
|
|
987
|
+
_cctally_db._reconcile_durable_applied_migration_errors
|
|
988
|
+
)
|
|
972
989
|
_render_migration_error_banner = _cctally_db._render_migration_error_banner
|
|
973
990
|
_BANNER_SUPPRESSED_COMMANDS = _cctally_db._BANNER_SUPPRESSED_COMMANDS
|
|
974
991
|
_print_migration_error_banner_if_needed = _cctally_db._print_migration_error_banner_if_needed
|
|
@@ -1115,6 +1132,20 @@ _derive_week_from_payload = _cctally_record._derive_week_from_payload
|
|
|
1115
1132
|
insert_usage_snapshot = _cctally_record.insert_usage_snapshot
|
|
1116
1133
|
_saved_dict_from_usage_row = _cctally_record._saved_dict_from_usage_row
|
|
1117
1134
|
|
|
1135
|
+
# #372 Task B: side-effect-free Claude usage rederive planner. Loaded after the
|
|
1136
|
+
# record sibling so its scratch adapter can reuse the registered ingest hooks.
|
|
1137
|
+
_lib_rederive = _load_sibling("_lib_rederive")
|
|
1138
|
+
_cctally_rederive = _load_sibling("_cctally_rederive")
|
|
1139
|
+
plan_claude_usage_rederive = _cctally_rederive.plan_claude_usage
|
|
1140
|
+
read_rederive_journal_prefix = _cctally_rederive.read_rederive_journal_prefix
|
|
1141
|
+
preview_db_rederive = _cctally_rederive.preview_db_rederive
|
|
1142
|
+
apply_db_rederive = _cctally_rederive.apply_db_rederive
|
|
1143
|
+
cmd_db_rederive = _cctally_rederive.cmd_db_rederive
|
|
1144
|
+
|
|
1145
|
+
# #402 Task B: preview-first append-only structural protocol acknowledgement.
|
|
1146
|
+
_cctally_journal_repair = _load_sibling("_cctally_journal_repair")
|
|
1147
|
+
cmd_db_journal_repair = _cctally_journal_repair.cmd_db_journal_repair
|
|
1148
|
+
|
|
1118
1149
|
# Phase F #21: ``bin/_cctally_update.py`` is loaded eagerly per spec §4.8
|
|
1119
1150
|
# carve-out — ``tests/test_update.py`` reaches into ~30 symbols via
|
|
1120
1151
|
# ``ns["X"]`` direct-dict reads and ``monkeypatch.setitem(ns, "X", …)``
|
|
@@ -2018,15 +2049,23 @@ def _usage_entry_from_joined(je) -> "UsageEntry":
|
|
|
2018
2049
|
drop the fast-tier flag and render ``<model>`` where the normal path
|
|
2019
2050
|
renders ``<model>-fast``. ``je.usage_extra`` is ``{"speed": …}`` or
|
|
2020
2051
|
``None``, so the merge never shadows the token integers.
|
|
2052
|
+
|
|
2053
|
+
#195: the dict is built by ``claude_usage_dict``, NOT by hand. This is a
|
|
2054
|
+
cost-feeding usage dict — ``_fold_entry`` prices from ``entry.usage`` —
|
|
2055
|
+
so a hand-rolled literal here silently drops the cache-write TTL split
|
|
2056
|
+
and under-prices the whole ``daily -i`` / ``daily -p`` branch (and makes
|
|
2057
|
+
``--debug`` report phantom pricing mismatches). ``getattr`` keeps the
|
|
2058
|
+
adapter tolerant of the duck-typed joined-entry stand-ins that a couple
|
|
2059
|
+
of call sites synthesize.
|
|
2021
2060
|
"""
|
|
2022
|
-
usage =
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2061
|
+
usage = claude_usage_dict( # #195 chokepoint
|
|
2062
|
+
input_tokens=je.input_tokens,
|
|
2063
|
+
output_tokens=je.output_tokens,
|
|
2064
|
+
cache_creation_tokens=je.cache_creation_tokens,
|
|
2065
|
+
cache_read_tokens=je.cache_read_tokens,
|
|
2066
|
+
cache_1h_tokens=getattr(je, "cache_1h_tokens", None),
|
|
2067
|
+
speed=getattr(je, "speed", None),
|
|
2068
|
+
)
|
|
2030
2069
|
return UsageEntry(
|
|
2031
2070
|
timestamp=je.timestamp,
|
|
2032
2071
|
model=je.model,
|
|
@@ -3401,6 +3440,15 @@ def _post_command_update_hooks(command: str | None, args) -> None:
|
|
|
3401
3440
|
# no-mutation contract (test_pricing_check_offline_does_not_mutate_
|
|
3402
3441
|
# fresh_home) requires skipping the whole hook.
|
|
3403
3442
|
return
|
|
3443
|
+
if (
|
|
3444
|
+
command == "db"
|
|
3445
|
+
and getattr(args, "db_action", None) == "journal-repair"
|
|
3446
|
+
):
|
|
3447
|
+
# `db journal-repair` preview has the same literal no-mutation contract
|
|
3448
|
+
# as doctor/pricing-check. In particular, do not create config/update/
|
|
3449
|
+
# telemetry state after the preview handler has proved the journal
|
|
3450
|
+
# prefix without writing.
|
|
3451
|
+
return
|
|
3404
3452
|
if command == "repair-symlinks":
|
|
3405
3453
|
return
|
|
3406
3454
|
if command == "__preview":
|