cctally 1.73.0 → 1.75.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.
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import datetime as dt
5
+ import dataclasses
5
6
  import hashlib
6
7
  import json
7
8
  import pathlib
@@ -20,6 +21,7 @@ from _cctally_core import (
20
21
  )
21
22
  from _cctally_cache import _codex_provider_roots
22
23
  from _lib_quota import build_blocks
24
+ from _lib_pricing import _calculate_codex_entry_cost
23
25
  from _cctally_quota import load_codex_quota_observations
24
26
  from _lib_source_analytics import (
25
27
  AnalyticsWindow,
@@ -50,6 +52,37 @@ class SourceUsageError(ValueError):
50
52
  """A provider-aware request is syntactically or semantically invalid."""
51
53
 
52
54
 
55
+ @dataclasses.dataclass(frozen=True)
56
+ class CodexProjectMetadataHealth:
57
+ """A root-qualified partition of retained Codex accounting metadata."""
58
+
59
+ total_rows: int
60
+ qualified_rows: int
61
+ missing_conversation_key_rows: int
62
+ missing_thread_join_rows: int
63
+
64
+ @property
65
+ def incomplete_rows(self) -> int:
66
+ return self.missing_conversation_key_rows + self.missing_thread_join_rows
67
+
68
+
69
+ @dataclasses.dataclass(frozen=True)
70
+ class RootedCodexAccountingEntry:
71
+ """One cache-only Codex accounting row with authoritative root identity."""
72
+
73
+ timestamp: dt.datetime
74
+ session_id: str
75
+ source_path: str
76
+ source_root_key: str
77
+ model: str
78
+ input_tokens: int
79
+ cached_input_tokens: int
80
+ output_tokens: int
81
+ reasoning_output_tokens: int
82
+ total_tokens: int
83
+ cost_usd: float
84
+
85
+
53
86
  _OPAQUE_PROJECT_KEY_RE = re.compile(r"^project:[0-9a-f]{24}$")
54
87
 
55
88
 
@@ -74,6 +107,18 @@ _QUALIFIED_CODEX_ENTRIES_SQL = """
74
107
  """
75
108
 
76
109
 
110
+ _INHERITED_CODEX_PROJECT_METADATA_SQL = """
111
+ SELECT files.source_root_key, files.path, inherited.cwd, inherited.git_json
112
+ FROM codex_session_files AS files
113
+ JOIN codex_conversation_threads AS inherited
114
+ ON inherited.source_root_key = files.source_root_key
115
+ AND inherited.native_thread_id = files.last_native_thread_id
116
+ WHERE files.last_native_thread_id IS NOT NULL
117
+ AND files.last_native_thread_id != ''
118
+ ORDER BY inherited.last_seen_utc DESC, inherited.conversation_key DESC
119
+ """
120
+
121
+
77
122
  _CODEX_ACCOUNTING_ENTRIES_SQL = """
78
123
  SELECT timestamp_utc, source_root_key, conversation_key, model,
79
124
  input_tokens, cached_input_tokens, output_tokens,
@@ -86,6 +131,75 @@ _CODEX_ACCOUNTING_ENTRIES_SQL = """
86
131
  """
87
132
 
88
133
 
134
+ _ROOTED_CODEX_ACCOUNTING_ENTRIES_SQL = """
135
+ SELECT timestamp_utc, session_id, source_path, source_root_key, model,
136
+ input_tokens, cached_input_tokens, output_tokens,
137
+ reasoning_output_tokens, total_tokens
138
+ FROM codex_session_entries INDEXED BY idx_codex_entries_ts_root_conversation
139
+ WHERE timestamp_utc >= ?
140
+ AND timestamp_utc < ?
141
+ {root_predicate}
142
+ ORDER BY timestamp_utc ASC, source_root_key ASC, conversation_key ASC, id ASC
143
+ """
144
+
145
+
146
+ _CODEX_PROJECT_METADATA_HEALTH_SQL = """
147
+ SELECT
148
+ COUNT(*) AS total_rows,
149
+ COALESCE(SUM(CASE
150
+ WHEN entries.conversation_key IS NULL OR entries.conversation_key = ''
151
+ THEN 1 ELSE 0 END), 0) AS missing_conversation_key_rows,
152
+ COALESCE(SUM(CASE
153
+ WHEN entries.conversation_key IS NOT NULL
154
+ AND entries.conversation_key != ''
155
+ AND threads.conversation_key IS NULL
156
+ AND NOT EXISTS (
157
+ SELECT 1 FROM codex_session_files AS files
158
+ JOIN codex_conversation_threads AS inherited
159
+ ON inherited.source_root_key = entries.source_root_key
160
+ AND inherited.native_thread_id = files.last_native_thread_id
161
+ WHERE files.path = entries.source_path
162
+ AND files.source_root_key = entries.source_root_key
163
+ )
164
+ THEN 1 ELSE 0 END), 0) AS missing_thread_join_rows
165
+ FROM codex_session_entries AS entries
166
+ LEFT JOIN codex_conversation_threads AS threads
167
+ ON threads.conversation_key = entries.conversation_key
168
+ AND threads.source_root_key = entries.source_root_key
169
+ WHERE (? IS NULL OR entries.timestamp_utc >= ?)
170
+ AND (? IS NULL OR entries.timestamp_utc < ?)
171
+ """
172
+
173
+
174
+ _CODEX_PROJECT_METADATA_HEALTH_LEGACY_SQL = """
175
+ SELECT
176
+ COUNT(*) AS total_rows,
177
+ COALESCE(SUM(CASE
178
+ WHEN entries.conversation_key IS NULL OR entries.conversation_key = ''
179
+ THEN 1 ELSE 0 END), 0) AS missing_conversation_key_rows,
180
+ COALESCE(SUM(CASE
181
+ WHEN entries.conversation_key IS NOT NULL
182
+ AND entries.conversation_key != ''
183
+ AND threads.conversation_key IS NULL
184
+ THEN 1 ELSE 0 END), 0) AS missing_thread_join_rows
185
+ FROM codex_session_entries AS entries
186
+ LEFT JOIN codex_conversation_threads AS threads
187
+ ON threads.conversation_key = entries.conversation_key
188
+ AND threads.source_root_key = entries.source_root_key
189
+ WHERE (? IS NULL OR entries.timestamp_utc >= ?)
190
+ AND (? IS NULL OR entries.timestamp_utc < ?)
191
+ """
192
+
193
+
194
+ def _supports_native_file_aliases(cache_conn: sqlite3.Connection) -> bool:
195
+ """Return whether this cache generation can link child files to a root task."""
196
+ try:
197
+ columns = cache_conn.execute("PRAGMA table_info(codex_session_files)").fetchall()
198
+ except sqlite3.Error:
199
+ return False
200
+ return any(len(row) > 1 and row[1] == "last_native_thread_id" for row in columns)
201
+
202
+
89
203
  def _cctally():
90
204
  return sys.modules["cctally"]
91
205
 
@@ -100,6 +214,160 @@ def _parse_timestamp(value: object) -> dt.datetime:
100
214
  return timestamp.astimezone(UTC)
101
215
 
102
216
 
217
+ def _metadata_health_bound(value: dt.datetime | None, *, name: str) -> str | None:
218
+ if value is None:
219
+ return None
220
+ if value.tzinfo is None or value.utcoffset() is None:
221
+ raise ValueError(f"{name} must be timezone-aware")
222
+ # Retain the canonical ``+00:00`` representation written by Codex ingest
223
+ # and consumed by the accounting readers: SQLite TEXT bounds must sort in
224
+ # the same encoding for the [start, end) window to remain exact.
225
+ return value.astimezone(UTC).isoformat()
226
+
227
+
228
+ def load_codex_project_metadata_health(
229
+ *,
230
+ cache_conn: sqlite3.Connection,
231
+ start: dt.datetime | None = None,
232
+ end: dt.datetime | None = None,
233
+ ) -> CodexProjectMetadataHealth:
234
+ """Classify retained Codex accounting in one root-qualified SQL read.
235
+
236
+ Bounds are both timezone-aware or both omitted; the omitted form serves the
237
+ all-history doctor check. The output is an exhaustive, mutually-exclusive
238
+ count partition and contains no source or identity data.
239
+ """
240
+ if (start is None) != (end is None):
241
+ raise ValueError("start and end must be supplied together or both omitted")
242
+ bound_start = _metadata_health_bound(start, name="start")
243
+ bound_end = _metadata_health_bound(end, name="end")
244
+ if start is not None and end is not None and start > end:
245
+ raise ValueError("start must not be after end")
246
+
247
+ row = cache_conn.execute(
248
+ (_CODEX_PROJECT_METADATA_HEALTH_SQL if _supports_native_file_aliases(cache_conn)
249
+ else _CODEX_PROJECT_METADATA_HEALTH_LEGACY_SQL),
250
+ (bound_start, bound_start, bound_end, bound_end),
251
+ ).fetchone()
252
+ if row is None or len(row) != 3:
253
+ raise RuntimeError("Codex project metadata health query returned no partition")
254
+ try:
255
+ total_rows, missing_key_rows, missing_join_rows = (int(value) for value in row)
256
+ except (TypeError, ValueError) as exc:
257
+ raise RuntimeError("Codex project metadata health query returned invalid counts") from exc
258
+ qualified_rows = total_rows - missing_key_rows - missing_join_rows
259
+ values = (total_rows, qualified_rows, missing_key_rows, missing_join_rows)
260
+ if any(value < 0 for value in values) or total_rows != sum(values[1:]):
261
+ raise RuntimeError("Codex project metadata health partition is invalid")
262
+ return CodexProjectMetadataHealth(
263
+ total_rows=total_rows,
264
+ qualified_rows=qualified_rows,
265
+ missing_conversation_key_rows=missing_key_rows,
266
+ missing_thread_join_rows=missing_join_rows,
267
+ )
268
+
269
+
270
+ def load_cached_rooted_codex_accounting_entries(
271
+ start: dt.datetime,
272
+ end: dt.datetime,
273
+ *,
274
+ speed: str,
275
+ cache_conn: sqlite3.Connection,
276
+ source_root_keys: Iterable[str] | None = None,
277
+ ) -> tuple[RootedCodexAccountingEntry, ...]:
278
+ """Read bounded accounting from one caller-owned cache snapshot only.
279
+
280
+ This reader intentionally never syncs, opens or closes a database, reads
281
+ rollouts, resolves projects, or discovers configured Codex roots. The
282
+ cached root key plus source path are the dashboard's file identity. A
283
+ caller can additionally constrain the read to roots that established one
284
+ native cycle; root identities remain internal to this adapter.
285
+ """
286
+ if start.tzinfo is None or start.utcoffset() is None:
287
+ raise ValueError("start must be timezone-aware")
288
+ if end.tzinfo is None or end.utcoffset() is None:
289
+ raise ValueError("end must be timezone-aware")
290
+ if end <= start:
291
+ raise ValueError("end must be after start")
292
+ root_keys = (
293
+ tuple(sorted({key for key in source_root_keys if isinstance(key, str) and key}))
294
+ if source_root_keys is not None else ()
295
+ )
296
+ if source_root_keys is not None and not root_keys:
297
+ return ()
298
+ root_predicate = (
299
+ "AND source_root_key IN (" + ", ".join("?" for _ in root_keys) + ")"
300
+ if source_root_keys is not None else ""
301
+ )
302
+ try:
303
+ rows = tuple(cache_conn.execute(
304
+ _ROOTED_CODEX_ACCOUNTING_ENTRIES_SQL.format(root_predicate=root_predicate),
305
+ (
306
+ start.astimezone(UTC).isoformat(), end.astimezone(UTC).isoformat(), *root_keys,
307
+ ),
308
+ ))
309
+ except sqlite3.Error as exc:
310
+ raise QualifiedMetadataUnavailable("Codex accounting metadata is unavailable") from exc
311
+
312
+ result: list[RootedCodexAccountingEntry] = []
313
+ for row in rows:
314
+ try:
315
+ (
316
+ timestamp_raw, session_id_raw, source_path_raw, source_root_key_raw,
317
+ model_raw, input_raw, cached_input_raw, output_raw, reasoning_raw,
318
+ total_raw,
319
+ ) = row
320
+ source_path = source_path_raw if isinstance(source_path_raw, str) else ""
321
+ source_root_key = source_root_key_raw if isinstance(source_root_key_raw, str) else ""
322
+ if not source_path or not source_root_key:
323
+ raise ValueError("rooted accounting identity is absent")
324
+ model = str(model_raw)
325
+ input_tokens = int(input_raw)
326
+ cached_input_tokens = int(cached_input_raw)
327
+ output_tokens = int(output_raw)
328
+ reasoning_output_tokens = int(reasoning_raw)
329
+ total_tokens = int(total_raw)
330
+ cost_usd = _calculate_codex_entry_cost(
331
+ model,
332
+ input_tokens,
333
+ cached_input_tokens,
334
+ output_tokens,
335
+ reasoning_output_tokens,
336
+ speed=speed,
337
+ )
338
+ except (TypeError, ValueError, OverflowError) as exc:
339
+ raise QualifiedMetadataUnavailable("Codex accounting metadata is unavailable") from exc
340
+ result.append(RootedCodexAccountingEntry(
341
+ timestamp=_parse_timestamp(timestamp_raw),
342
+ session_id=str(session_id_raw or ""),
343
+ source_path=source_path,
344
+ source_root_key=source_root_key,
345
+ model=model,
346
+ input_tokens=input_tokens,
347
+ cached_input_tokens=cached_input_tokens,
348
+ output_tokens=output_tokens,
349
+ reasoning_output_tokens=reasoning_output_tokens,
350
+ total_tokens=total_tokens,
351
+ cost_usd=cost_usd,
352
+ ))
353
+ return tuple(result)
354
+
355
+
356
+ def has_cached_codex_accounting_entries(*, cache_conn: sqlite3.Connection) -> bool:
357
+ """Return whether any retained Codex accounting exists without range filtering.
358
+
359
+ This intentionally tiny caller-owned cache read lets a missing native cycle
360
+ fail closed even when the dashboard's visible or budget range is empty.
361
+ It neither resolves roots nor reads rollout files.
362
+ """
363
+ try:
364
+ return cache_conn.execute(
365
+ "SELECT 1 FROM codex_session_entries LIMIT 1"
366
+ ).fetchone() is not None
367
+ except sqlite3.Error as exc:
368
+ raise QualifiedMetadataUnavailable("Codex accounting metadata is unavailable") from exc
369
+
370
+
103
371
  def _project_label(value: object) -> str:
104
372
  """Render only a basename-like label; absolute paths remain internal."""
105
373
  raw = str(value).strip()
@@ -191,6 +459,12 @@ def load_qualified_codex_entries(
191
459
  _QUALIFIED_CODEX_ENTRIES_SQL,
192
460
  (start.astimezone(UTC).isoformat(), end.astimezone(UTC).isoformat()),
193
461
  ))
462
+ inherited_metadata: dict[tuple[str, str], sqlite3.Row] = {}
463
+ if _supports_native_file_aliases(conn):
464
+ for inherited in conn.execute(_INHERITED_CODEX_PROJECT_METADATA_SQL):
465
+ identity = (str(inherited["source_root_key"] or ""), str(inherited["path"] or ""))
466
+ if all(identity):
467
+ inherited_metadata.setdefault(identity, inherited)
194
468
  except sqlite3.Error as exc:
195
469
  raise QualifiedMetadataUnavailable("Codex qualified project metadata is unavailable") from exc
196
470
  finally:
@@ -204,8 +478,19 @@ def load_qualified_codex_entries(
204
478
  resolved_by_git_json: dict[str, str | None] = {}
205
479
  result: list[QualifiedCodexEntry] = []
206
480
  for row in rows:
207
- root_key, conversation_key = _require_joined_metadata(row)
208
- cwd = row["cwd"]
481
+ root_key = row["source_root_key"]
482
+ conversation_key = row["conversation_key"]
483
+ inherited = inherited_metadata.get((str(root_key or ""), str(row["source_path"] or "")))
484
+ try:
485
+ root_key, conversation_key = _require_joined_metadata(row)
486
+ except QualifiedMetadataUnavailable:
487
+ if (
488
+ inherited is None
489
+ or not isinstance(root_key, str) or not root_key
490
+ or not isinstance(conversation_key, str) or not conversation_key
491
+ ):
492
+ raise
493
+ cwd = row["cwd"] or (inherited["cwd"] if inherited is not None else None)
209
494
  if isinstance(cwd, str) and cwd:
210
495
  project = resolved_by_cwd.get(cwd)
211
496
  if project is None:
@@ -218,7 +503,7 @@ def load_qualified_codex_entries(
218
503
  else _project_label(project.display_key)
219
504
  )
220
505
  else:
221
- git_json = row["git_json"]
506
+ git_json = row["git_json"] or (inherited["git_json"] if inherited is not None else None)
222
507
  if isinstance(git_json, str) and git_json not in resolved_by_git_json:
223
508
  resolved_by_git_json[git_json] = _git_resolved_key(git_json)
224
509
  git_key = resolved_by_git_json.get(git_json) if isinstance(git_json, str) else None
@@ -940,9 +940,9 @@ def _authoritative_record_usage(
940
940
  ) -> _AuthoritativeRecordResult:
941
941
  """Record OAuth authority under write-ahead tombstones and reconcile it.
942
942
 
943
- ``lock_held`` is reserved for automatic polling, which holds the selected
944
- lock across its post-lock freshness/backoff recheck and network request.
945
- All other callers acquire the same blocking lock here.
943
+ ``lock_held`` is for OAuth refresh callers that already hold the selected
944
+ lock across their fetch, authoritative publication, and matching backoff
945
+ transition. All other callers acquire the same blocking lock here.
946
946
  """
947
947
  if not lock_held:
948
948
  try:
@@ -227,6 +227,7 @@ from _lib_fmt import stable_sum
227
227
  # shared no-op singleton), so the _tui_build_snapshot seam wraps below cost
228
228
  # nothing on the default path.
229
229
  import _lib_perf as _perf
230
+ import _lib_log
230
231
 
231
232
  import importlib.util as _ilu
232
233
 
@@ -261,7 +262,6 @@ from _lib_forecast import _compute_forecast, ForecastInputs, ForecastOutput, Bud
261
262
  _ensure_sibling_loaded("_lib_dashboard_sources")
262
263
  _ensure_sibling_loaded("_cctally_dashboard_sources")
263
264
  from _cctally_dashboard_sources import (
264
- CodexProjectionIncoherent,
265
265
  DashboardReadContext,
266
266
  build_codex_source_state,
267
267
  refresh_codex_source_clock,
@@ -2520,8 +2520,18 @@ def _tui_build_source_bundle(
2520
2520
  else unavailable_source_state("codex", warning)
2521
2521
  )
2522
2522
  else:
2523
- codex = reuse_coherent_source_state(
2524
- prior_codex, data_version=codex_version,
2523
+ # Projection coherence can recover when S2 writes its certificate
2524
+ # without changing physical accounting. A current, hero-scoped
2525
+ # incoherence generation is intentionally not retained through
2526
+ # that repair opportunity; all other fresh partial states remain
2527
+ # eligible for exact reuse.
2528
+ codex = (
2529
+ None if prior_codex is not None and any(
2530
+ warning.code == "codex_projection_incoherent"
2531
+ for warning in prior_codex.warnings
2532
+ ) else reuse_coherent_source_state(
2533
+ prior_codex, data_version=codex_version,
2534
+ )
2525
2535
  )
2526
2536
  if codex is None:
2527
2537
  try:
@@ -2536,21 +2546,17 @@ def _tui_build_source_bundle(
2536
2546
  week_start_name=semantics.week_start_name,
2537
2547
  speed=semantics.speed,
2538
2548
  codex_budget=semantics.codex_budget,
2549
+ codex_quota_actual_thresholds=semantics.codex_quota_actual_thresholds,
2550
+ codex_quota_projected_thresholds=semantics.codex_quota_projected_thresholds,
2551
+ cache_report_anomaly_threshold_pp=semantics.cache_report_anomaly_threshold_pp,
2539
2552
  ),
2540
2553
  data_version=codex_version,
2541
2554
  )
2542
- except CodexProjectionIncoherent:
2543
- warning = SourceDashboardWarning(
2544
- "codex_projection_incoherent",
2545
- "Codex quota projection is unavailable.",
2546
- "quota",
2547
- )
2548
- codex = (
2549
- degrade_source_state(prior_codex, warning)
2550
- if prior_codex is not None
2551
- else unavailable_source_state("codex", warning)
2552
- )
2553
2555
  except Exception:
2556
+ _lib_log.get_logger("dashboard").error(
2557
+ "codex_read_model source build failed",
2558
+ exc_info=True,
2559
+ )
2554
2560
  warning = SourceDashboardWarning(
2555
2561
  "source_build_failed", "Source data could not be built.", "read_model",
2556
2562
  )
@@ -42,7 +42,7 @@ import os
42
42
  import pathlib
43
43
  import sys
44
44
  from dataclasses import dataclass
45
- from typing import Any, Callable
45
+ from typing import Any, Callable, Iterable
46
46
 
47
47
 
48
48
  def _cctally():
@@ -378,6 +378,13 @@ class CodexBucketUsage:
378
378
  cost_usd: float
379
379
  models: list[str] # Distinct full model names (first-seen order)
380
380
  model_breakdowns: list[dict[str, Any]] # Sorted by cost desc
381
+ # Native quota-cycle context. Calendar daily/monthly callers leave these
382
+ # null; the dashboard's subscription-week adapter fills them from the
383
+ # retained OpenAI rate-limit observations.
384
+ period_start_at: dt.datetime | None = None
385
+ period_end_at: dt.datetime | None = None
386
+ used_pct: float | None = None
387
+ dollar_per_pct: float | None = None
381
388
 
382
389
 
383
390
  @dataclass
@@ -414,6 +421,17 @@ class CodexSessionUsage:
414
421
  codex_root: str = ""
415
422
 
416
423
 
424
+ @dataclass(frozen=True)
425
+ class CodexSessionIdentity:
426
+ """Caller-supplied identity for the pure Codex session accumulator."""
427
+
428
+ group_key: tuple[str, str]
429
+ session_id_path: str
430
+ session_file: str
431
+ directory: str
432
+ codex_root: str
433
+
434
+
417
435
  @dataclass
418
436
  class ClaudeSessionUsage:
419
437
  """Aggregated Claude usage for one sessionId (may span multiple JSONL files)."""
@@ -619,41 +637,19 @@ def _codex_home_root_from_prefix(root_prefix: str) -> str:
619
637
  return s
620
638
 
621
639
 
622
- def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard") -> list[CodexSessionUsage]:
623
- """Group by session file path (upstream-compatible).
624
-
625
- Sessions are keyed by the full relative-path-without-.jsonl rather than
626
- the inner UUID. Result is sorted by last_activity descending (most
627
- recent first), matching upstream's default view.
628
-
629
- Per-model breakdowns include `isFallback: bool` — true when the model is
630
- absent from CODEX_MODEL_PRICING.
631
- """
640
+ def _aggregate_codex_sessions_keyed(
641
+ entries: Iterable[tuple[CodexEntry, CodexSessionIdentity]],
642
+ speed: str = "standard",
643
+ ) -> list[CodexSessionUsage]:
644
+ """Pure Codex session arithmetic over caller-owned file identities."""
632
645
  by_session: dict[tuple[str, str], dict[str, Any]] = {}
633
- for entry in entries:
634
- id_path, file_name, directory = _session_path_parts(entry.source_path)
635
- # Disambiguate identical relative paths under DIFFERENT $CODEX_HOME
636
- # roots (issue #108). _session_path_parts strips the matched root, so
637
- # <rootA>/sessions/2026/04/17/rollout-x.jsonl and the same relative
638
- # path under <rootB> both yield id_path "2026/04/17/rollout-x";
639
- # grouping on id_path alone would silently merge two distinct sessions
640
- # (summed tokens, one UUID). Key on (root_prefix, id_path), where
641
- # root_prefix is source_path with the id_path tail removed. Single-root
642
- # data — and the bare-relative fixture form — has a constant prefix, so
643
- # the grouping, insertion order, and every golden stay byte-identical;
644
- # only a genuine cross-root collision splits into separate rows.
645
- suffix = id_path + ".jsonl"
646
- sp = entry.source_path
647
- root_prefix = sp[: -len(suffix)] if sp.endswith(suffix) else sp
648
- sess = by_session.setdefault((root_prefix, id_path), {
646
+ for entry, identity in entries:
647
+ sess = by_session.setdefault(identity.group_key, {
649
648
  "session_id_uuid": entry.session_id,
650
- "session_id_path": id_path,
651
- "session_file": file_name,
652
- "directory": directory,
653
- # Matched $CODEX_HOME root (home-root form) — issue #110 display
654
- # disambiguator. Derived from the same root_prefix that keys the
655
- # group, so it's constant per group.
656
- "codex_root": _codex_home_root_from_prefix(root_prefix),
649
+ "session_id_path": identity.session_id_path,
650
+ "session_file": identity.session_file,
651
+ "directory": identity.directory,
652
+ "codex_root": identity.codex_root,
657
653
  "input": 0, "cached_input": 0, "output": 0, "reasoning": 0,
658
654
  "cost": 0.0, "models": {}, "models_order": [],
659
655
  "last": entry.timestamp,
@@ -724,6 +720,31 @@ def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard"
724
720
  return result
725
721
 
726
722
 
723
+ def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard") -> list[CodexSessionUsage]:
724
+ """Group CLI Codex entries by their existing filesystem-derived identity.
725
+
726
+ The wrapper preserves the historic CLI path parsing and byte-stable output;
727
+ only the accumulation itself lives in the keyed pure seam above.
728
+ """
729
+ keyed: list[tuple[CodexEntry, CodexSessionIdentity]] = []
730
+ for entry in entries:
731
+ id_path, file_name, directory = _session_path_parts(entry.source_path)
732
+ # Disambiguate equal relative paths under separate configured roots.
733
+ suffix = id_path + ".jsonl"
734
+ source_path = entry.source_path
735
+ root_prefix = (
736
+ source_path[: -len(suffix)] if source_path.endswith(suffix) else source_path
737
+ )
738
+ keyed.append((entry, CodexSessionIdentity(
739
+ group_key=(root_prefix, id_path),
740
+ session_id_path=id_path,
741
+ session_file=file_name,
742
+ directory=directory,
743
+ codex_root=_codex_home_root_from_prefix(root_prefix),
744
+ )))
745
+ return _aggregate_codex_sessions_keyed(keyed, speed=speed)
746
+
747
+
727
748
  def _aggregate_claude_sessions(
728
749
  entries: list["_JoinedClaudeEntry"],
729
750
  mode: str = "auto",
@@ -359,9 +359,17 @@ def _aggregate_cache_by_day(
359
359
  cost = cost_calculator(entry.model, entry.usage, "auto", entry.cost_usd)
360
360
  create_tok = entry.usage.get("cache_creation_input_tokens", 0)
361
361
  read_tok = entry.usage.get("cache_read_input_tokens", 0)
362
- saved, wasted, net = _compute_entry_cache_dollars(
363
- entry.model, create_tok, read_tok, pricing=pricing,
364
- )
362
+ explicit_saved = getattr(entry, "cache_saved_usd", None)
363
+ explicit_wasted = getattr(entry, "cache_wasted_usd", None)
364
+ explicit_net = getattr(entry, "cache_net_usd", None)
365
+ if all(value is not None for value in (explicit_saved, explicit_wasted, explicit_net)):
366
+ saved, wasted, net = (
367
+ float(explicit_saved), float(explicit_wasted), float(explicit_net),
368
+ )
369
+ else:
370
+ saved, wasted, net = _compute_entry_cache_dollars(
371
+ entry.model, create_tok, read_tok, pricing=pricing,
372
+ )
365
373
  models = day_model_buckets.setdefault(day_key, {})
366
374
  b = models.setdefault(entry.model, _Bucket())
367
375
  b.input_tokens += entry.usage.get("input_tokens", 0)
@@ -792,12 +800,20 @@ def _aggregate_cache_breakdown(
792
800
  b.input_tokens += getattr(e, "input_tokens", 0)
793
801
  b.cache_creation_tokens += getattr(e, "cache_creation_tokens", 0)
794
802
  b.cache_read_tokens += getattr(e, "cache_read_tokens", 0)
795
- saved, wasted, net = _compute_entry_cache_dollars(
796
- getattr(e, "model", ""),
797
- getattr(e, "cache_creation_tokens", 0),
798
- getattr(e, "cache_read_tokens", 0),
799
- pricing=pricing,
800
- )
803
+ explicit_saved = getattr(e, "cache_saved_usd", None)
804
+ explicit_wasted = getattr(e, "cache_wasted_usd", None)
805
+ explicit_net = getattr(e, "cache_net_usd", None)
806
+ if all(value is not None for value in (explicit_saved, explicit_wasted, explicit_net)):
807
+ saved, wasted, net = (
808
+ float(explicit_saved), float(explicit_wasted), float(explicit_net),
809
+ )
810
+ else:
811
+ saved, wasted, net = _compute_entry_cache_dollars(
812
+ getattr(e, "model", ""),
813
+ getattr(e, "cache_creation_tokens", 0),
814
+ getattr(e, "cache_read_tokens", 0),
815
+ pricing=pricing,
816
+ )
801
817
  b.saved_usd += saved
802
818
  b.wasted_usd += wasted
803
819
  b.net_usd += net
@@ -1,4 +1,4 @@
1
- """#313 P3: 180-day conversation-transcript retention prune kernel.
1
+ """#313 P3: conversation-transcript retention prune kernel.
2
2
 
3
3
  Prunes ONLY the re-derivable transcript rows:
4
4
  * Claude: ``conversation_messages`` + their ``conversation_file_touches`` /
@@ -63,7 +63,7 @@ def _cutoff_iso(cutoff_utc: dt.datetime) -> str:
63
63
  ``...Z`` timestamps.
64
64
 
65
65
  Second-granular: the sub-second mixed-precision edge at the exact cutoff
66
- second is immaterial for a 180-day window — any message wrongly classified
66
+ second is immaterial for a multi-day retention window — any message wrongly classified
67
67
  there is re-derivable from JSONL and the boundary self-corrects on the next
68
68
  (daily) prune.
69
69
  """
@@ -259,6 +259,26 @@ def _maybe_prune_conversation_retention(
259
259
  except Exception:
260
260
  conn.rollback()
261
261
  raise
262
+ # Return the freed pages to the OS. On an INCREMENTAL auto-vacuum
263
+ # cache.db (the default for freshly-created DBs, #313 P3) this
264
+ # shrinks the file on disk instead of leaving a growing freelist,
265
+ # so the transcript prune reclaims space automatically without a
266
+ # manual `cctally db vacuum`; on a legacy auto_vacuum=NONE cache it
267
+ # is a harmless no-op (those still reclaim via `db vacuum` or a
268
+ # `cache-sync --rebuild`). Runs OUTSIDE the committed transaction,
269
+ # still under the maintenance + provider flocks (no concurrent
270
+ # writer), and best-effort — a reclaim error must never fail the
271
+ # already-durable prune.
272
+ if stats.total_rows > 0:
273
+ try:
274
+ # `.fetchall()` drives the pragma to completion — a bare
275
+ # execute() steps it once and reclaims a single page. This
276
+ # clears the freelist and drops page_count; the physical
277
+ # file shrinks on the next `wal_checkpoint(TRUNCATE)` the
278
+ # sync loop already forces (#297).
279
+ conn.execute("PRAGMA incremental_vacuum").fetchall()
280
+ except sqlite3.Error:
281
+ pass
262
282
  return stats
263
283
  finally:
264
284
  try: