cctally 1.72.0 → 1.74.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/_cctally_cache.py +271 -55
  3. package/bin/_cctally_core.py +20 -40
  4. package/bin/_cctally_dashboard.py +35 -1
  5. package/bin/_cctally_dashboard_conversation.py +731 -94
  6. package/bin/_cctally_dashboard_share.py +38 -3
  7. package/bin/_cctally_dashboard_sources.py +812 -74
  8. package/bin/_cctally_db.py +104 -1
  9. package/bin/_cctally_doctor.py +86 -4
  10. package/bin/_cctally_parser.py +17 -4
  11. package/bin/_cctally_record.py +162 -33
  12. package/bin/_cctally_refresh.py +58 -36
  13. package/bin/_cctally_source_analytics.py +288 -3
  14. package/bin/_cctally_statusline.py +811 -82
  15. package/bin/_cctally_transcript.py +223 -7
  16. package/bin/_cctally_tui.py +17 -14
  17. package/bin/_lib_aggregators.py +48 -34
  18. package/bin/_lib_codex_conversation_export.py +124 -0
  19. package/bin/_lib_codex_conversation_query.py +500 -28
  20. package/bin/_lib_codex_conversation_watch.py +323 -0
  21. package/bin/_lib_conversation_dispatch.py +269 -7
  22. package/bin/_lib_conversation_query.py +88 -0
  23. package/bin/_lib_dashboard_sources.py +20 -7
  24. package/bin/_lib_doctor.py +106 -0
  25. package/bin/_lib_jsonl.py +11 -0
  26. package/bin/_lib_pricing.py +7 -4
  27. package/bin/_lib_pricing_check.py +7 -1
  28. package/bin/_lib_quota.py +12 -11
  29. package/bin/_lib_statusline_candidates.py +734 -0
  30. package/bin/_lib_view_models.py +50 -14
  31. package/bin/cctally +31 -2
  32. package/dashboard/static/assets/index-DeQjEMO6.js +80 -0
  33. package/dashboard/static/assets/index-ZiPO8veo.css +1 -0
  34. package/dashboard/static/dashboard.html +2 -2
  35. package/package.json +4 -1
  36. package/dashboard/static/assets/index-BWadAHxC.js +0 -80
  37. package/dashboard/static/assets/index-D2nwo_ln.css +0 -1
@@ -624,10 +624,6 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
624
624
  status="fetch_failed",
625
625
  reason=f"invalid oauth_usage config: {exc}",
626
626
  )
627
- # Successful API response — clear any pending 429 backoff + counter, so a
628
- # user-confirmed recovery lets automatic polling resume (spec §4).
629
- c._oauth_backoff_reset()
630
-
631
627
  seven = api.get("seven_day") or {}
632
628
  try:
633
629
  # Normalize at the OAuth ingress so the payload JSON published
@@ -681,12 +677,21 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
681
677
  # this, cmd_record_usage would fall back to its "statusline" default.
682
678
  source="api",
683
679
  )
684
- try:
685
- rc = c.cmd_record_usage(record_args)
686
- except Exception as exc:
687
- return _RefreshUsageResult(status="record_failed", reason=str(exc))
688
- if rc != 0:
689
- return _RefreshUsageResult(status="record_failed", reason=f"exit {rc}")
680
+ authoritative = c._authoritative_record_usage(
681
+ record_args,
682
+ {
683
+ "sevenDay",
684
+ *({"fiveHour"} if five_pct is not None else set()),
685
+ },
686
+ )
687
+ if authoritative.status != "ok":
688
+ return _RefreshUsageResult(
689
+ status="record_failed", reason=authoritative.reason
690
+ )
691
+ # A fetched response becomes evidence of recovery only after its
692
+ # write-ahead tombstones, database reconciliation, selected control, and
693
+ # selected-freshness marker have all committed.
694
+ c._oauth_backoff_reset()
690
695
 
691
696
  # §5.6 Option C: route through cctally's namespace so
692
697
  # `monkeypatch.setitem(ns, "_bust_statusline_cache", …)` propagates
@@ -877,18 +882,38 @@ def _hook_tick_oauth_refresh(
877
882
  throttle_seconds = float(_get_oauth_usage_config(c.load_config())["throttle_seconds"])
878
883
  except OauthUsageConfigError:
879
884
  throttle_seconds = float(c.HOOK_TICK_DEFAULT_THROTTLE_SECONDS)
880
- # Backfill gate (spec §4). The automatic OAuth poll is now a BACKFILL
881
- # behind the statusline (the primary writer). It fetches only when the
882
- # statusline has NOT fed recently AND no 429 cooldown is pending — so a
883
- # live statusline keeps usage fresh with zero API calls, and a 429 streak
884
- # is suppressed by the shared deadline instead of re-polled every tick.
885
- # Gating on the observation marker (not snapshot age) is what makes the
886
- # demotion correct: cmd_record_usage dedups unchanged percentages without
887
- # refreshing captured_at, so snapshot age keeps growing while the
888
- # statusline is alive. Force-refresh (_refresh_usage_inproc) never routes
889
- # through here, so it is exempt (user-initiated).
885
+ # The automatic writer takes the selected-state lock *before* rechecking
886
+ # its suppression conditions. A concurrent publisher can otherwise make
887
+ # this tick issue an unnecessary OAuth request between the initial gate
888
+ # and the request itself.
889
+ try:
890
+ with c._selected_state_lock():
891
+ return _hook_tick_oauth_refresh_locked(
892
+ c,
893
+ token=token,
894
+ timeout_seconds=timeout_seconds,
895
+ throttle_seconds=throttle_seconds,
896
+ )
897
+ except OSError:
898
+ return "err(record-usage=exc)", None
899
+
900
+
901
+ def _hook_tick_oauth_refresh_locked(
902
+ c,
903
+ *,
904
+ token: str,
905
+ timeout_seconds: float,
906
+ throttle_seconds: float,
907
+ ) -> tuple[str, dict | None]:
908
+ """Automatic OAuth path with the selected-state lock already held."""
909
+ # Backfill gate: an inflight/invalid tombstone bypasses only selected-age
910
+ # suppression so a later authoritative result can repair it. The normal
911
+ # throttle and 429 deadline continue to bound OAuth traffic.
912
+ now_epoch = int(time.time())
913
+ needs_repair = c._authoritative_repair_required(now_epoch=now_epoch)
890
914
  obs_age = c._statusline_observe_age_seconds()
891
- if obs_age < float(_cctally_core.OAUTH_BACKFILL_STALE_SECONDS):
915
+ if (not needs_repair
916
+ and obs_age < float(_cctally_core.OAUTH_BACKFILL_STALE_SECONDS)):
892
917
  return f"skipped(statusline-fresh:{int(obs_age)}s)", None
893
918
  backoff_remaining = c._oauth_backoff_remaining_seconds()
894
919
  if backoff_remaining > 0:
@@ -899,9 +924,6 @@ def _hook_tick_oauth_refresh(
899
924
  try:
900
925
  api = c._fetch_oauth_usage(token=token, timeout_seconds=timeout_seconds)
901
926
  except RefreshUsageRateLimitError as exc:
902
- # Arm the shared backoff deadline so the next tick is suppressed
903
- # rather than immediately re-polling (spec §4). Honors Retry-After
904
- # when present, else conservative exponential backoff.
905
927
  c._oauth_backoff_register_429(
906
928
  retry_after_deadline=getattr(exc, "retry_after_deadline", None),
907
929
  now=time.time(),
@@ -911,8 +933,6 @@ def _hook_tick_oauth_refresh(
911
933
  return "err(network)", None
912
934
  except RefreshUsageMalformedError:
913
935
  return "err(parse)", None
914
- # Successful API response — clear any pending 429 backoff + counter.
915
- c._oauth_backoff_reset()
916
936
  seven = api["seven_day"]
917
937
  try:
918
938
  seven_pct = c._normalize_percent(float(seven["utilization"]))
@@ -922,10 +942,6 @@ def _hook_tick_oauth_refresh(
922
942
  five = api.get("five_hour") if isinstance(api.get("five_hour"), dict) else None
923
943
  five_pct: float | None = None
924
944
  five_resets_epoch: int | None = None
925
- # An inactive 5h window arrives as `resets_at: null` (key present, value
926
- # null). Require a string here — mirrors the seven_day guard in
927
- # _fetch_oauth_usage — so _iso_to_epoch(None) can't raise AttributeError;
928
- # a malformed (non-null) string still degrades via the except below.
929
945
  if (five is not None and "utilization" in five
930
946
  and isinstance(five.get("resets_at"), str)):
931
947
  try:
@@ -939,15 +955,21 @@ def _hook_tick_oauth_refresh(
939
955
  resets_at=str(seven_resets_epoch),
940
956
  five_hour_percent=five_pct,
941
957
  five_hour_resets_at=str(five_resets_epoch) if five_resets_epoch is not None else None,
942
- # OAuth feeder — label the row "api" (spec §5).
943
958
  source="api",
944
959
  )
945
- try:
946
- rc = c.cmd_record_usage(record_args)
947
- except Exception:
960
+ authoritative = c._authoritative_record_usage(
961
+ record_args,
962
+ {"sevenDay", *({"fiveHour"} if five_pct is not None else set())},
963
+ lock_held=True,
964
+ )
965
+ if authoritative.status != "ok":
966
+ reason = authoritative.reason or ""
967
+ if reason.startswith("exit "):
968
+ return f"err(record-usage={reason[5:]})", None
948
969
  return "err(record-usage=exc)", None
949
- if rc != 0:
950
- return f"err(record-usage={rc})", None
970
+ # Success is acknowledged to the shared OAuth backoff only after the
971
+ # authoritative writer has published tombstones, control, and freshness.
972
+ c._oauth_backoff_reset()
951
973
  parts = [f"7d={int(round(seven_pct))}"]
952
974
  if five_pct is not None:
953
975
  parts.append(f"5h={int(round(five_pct))}")
@@ -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