cctally 1.88.2 → 1.89.1

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 +32 -0
  2. package/bin/_cctally_cache.py +827 -37
  3. package/bin/_cctally_config.py +125 -0
  4. package/bin/_cctally_core.py +86 -2
  5. package/bin/_cctally_dashboard_cache_report.py +31 -0
  6. package/bin/_cctally_dashboard_conversation.py +26 -7
  7. package/bin/_cctally_dashboard_sources.py +51 -0
  8. package/bin/_cctally_db.py +626 -0
  9. package/bin/_cctally_doctor.py +84 -1
  10. package/bin/_cctally_journal.py +2 -1
  11. package/bin/_cctally_parser.py +42 -0
  12. package/bin/_cctally_quota.py +1358 -112
  13. package/bin/_cctally_record.py +249 -9
  14. package/bin/_cctally_setup.py +14 -5
  15. package/bin/_cctally_store.py +16 -1
  16. package/bin/_cctally_tui.py +16 -2
  17. package/bin/_cctally_update.py +9 -2
  18. package/bin/_lib_background_mcp.py +168 -0
  19. package/bin/_lib_cache_report.py +19 -2
  20. package/bin/_lib_codex_conversation.py +8 -0
  21. package/bin/_lib_codex_conversation_query.py +8 -7
  22. package/bin/_lib_conversation.py +105 -5
  23. package/bin/_lib_conversation_dispatch.py +15 -4
  24. package/bin/_lib_conversation_query.py +294 -2
  25. package/bin/_lib_dashboard_sources.py +5 -1
  26. package/bin/_lib_doctor.py +202 -1
  27. package/bin/_lib_jsonl.py +12 -0
  28. package/bin/_lib_quota_alert_axes.py +188 -0
  29. package/bin/_lib_quota_ledger.py +274 -0
  30. package/bin/_lib_snapshot_cache.py +36 -0
  31. package/bin/cctally +6 -3
  32. package/dashboard/static/assets/index-BgoYXdus.js +92 -0
  33. package/dashboard/static/assets/index-Ub8vwz1M.css +1 -0
  34. package/dashboard/static/dashboard.html +2 -2
  35. package/package.json +4 -1
  36. package/dashboard/static/assets/index-B0ZCsoxI.css +0 -1
  37. package/dashboard/static/assets/index-Bvp8mxtz.js +0 -92
@@ -331,9 +331,23 @@ ALLOWED_CONFIG_KEYS = (
331
331
  "budget.codex.accounts",
332
332
  "telemetry.enabled",
333
333
  "conversation.retention_days",
334
+ "codex.hook.ingest_budget_seconds",
334
335
  )
335
336
 
336
337
 
338
+ #: Wall-clock budget the native Codex hook gives its ingest leg (public #5
339
+ #: spec §4). It applies to the HOOK path only — an explicit `cctally cache-sync`
340
+ #: still runs to completion.
341
+ CODEX_HOOK_INGEST_BUDGET_DEFAULT_SECONDS = 5.0
342
+
343
+ #: Codex kills a hook at 30 seconds. The budget is capped strictly BELOW that,
344
+ #: not merely validated positive: a configured 60 would guarantee the timeout
345
+ #: this whole change exists to remove, and the cap leaves room for the rest of
346
+ #: the tick (the reconcile, the budget evaluation, interpreter startup).
347
+ CODEX_HOOK_TIMEOUT_SECONDS = 30.0
348
+ CODEX_HOOK_INGEST_BUDGET_MAX_SECONDS = 20.0
349
+
350
+
337
351
  _CODEX_BUDGET_LEAF_PREFIX = "budget.codex."
338
352
 
339
353
  _DEFAULT_CONVERSATION_RETENTION_DAYS = 90
@@ -374,6 +388,62 @@ def _validate_retention_days_value(raw: object) -> int:
374
388
  return value
375
389
 
376
390
 
391
+ def _validate_codex_hook_ingest_budget_value(raw: object) -> float:
392
+ """Validate a ``config set`` value for ``codex.hook.ingest_budget_seconds``.
393
+
394
+ Positive is not enough. Codex kills a hook at
395
+ ``CODEX_HOOK_TIMEOUT_SECONDS``, so a value at or above
396
+ ``CODEX_HOOK_INGEST_BUDGET_MAX_SECONDS`` is rejected outright rather than
397
+ silently clamped: an operator who typed 60 asked for exactly the timeout
398
+ this budget exists to prevent, and a clamp would hide that.
399
+ """
400
+ if isinstance(raw, bool):
401
+ raise ValueError(
402
+ "codex.hook.ingest_budget_seconds must be a positive number")
403
+ if isinstance(raw, str):
404
+ try:
405
+ value = float(raw.strip())
406
+ except (TypeError, ValueError):
407
+ raise ValueError(
408
+ "codex.hook.ingest_budget_seconds must be a positive number, "
409
+ f"got {raw!r}")
410
+ elif isinstance(raw, (int, float)):
411
+ value = float(raw)
412
+ else:
413
+ raise ValueError(
414
+ "codex.hook.ingest_budget_seconds must be a positive number")
415
+ if not (value > 0) or value != value or value in (float("inf"),):
416
+ raise ValueError(
417
+ "codex.hook.ingest_budget_seconds must be > 0")
418
+ if value >= CODEX_HOOK_INGEST_BUDGET_MAX_SECONDS:
419
+ raise ValueError(
420
+ "codex.hook.ingest_budget_seconds must be < "
421
+ f"{CODEX_HOOK_INGEST_BUDGET_MAX_SECONDS:g} (Codex kills a hook at "
422
+ f"{CODEX_HOOK_TIMEOUT_SECONDS:g}s)")
423
+ return value
424
+
425
+
426
+ def resolve_codex_hook_ingest_budget(config: dict) -> float:
427
+ """The effective hook ingest budget in seconds.
428
+
429
+ Any malformed or out-of-range persisted value degrades to the default
430
+ rather than raising — a hand-edited config must never be able to make the
431
+ hook itself fail, and the default is the safe direction here.
432
+ """
433
+ default = CODEX_HOOK_INGEST_BUDGET_DEFAULT_SECONDS
434
+ block = config.get("codex") if isinstance(config, dict) else None
435
+ if not isinstance(block, dict):
436
+ return default
437
+ hook = block.get("hook")
438
+ if not isinstance(hook, dict):
439
+ return default
440
+ try:
441
+ return _validate_codex_hook_ingest_budget_value(
442
+ hook.get("ingest_budget_seconds"))
443
+ except ValueError:
444
+ return default
445
+
446
+
377
447
  def resolve_retention_days(config: dict) -> int:
378
448
  """Effective conversation transcript retention in days (F8).
379
449
 
@@ -981,6 +1051,10 @@ def _config_known_value(config: dict, key: str) -> "object":
981
1051
  # Effective transcript-retention window in days (default 180; 0 = keep
982
1052
  # forever). Malformed persisted data surfaces the safe default (F8).
983
1053
  return resolve_retention_days(config)
1054
+ if key == "codex.hook.ingest_budget_seconds":
1055
+ # Wall-clock budget for the native Codex hook's ingest leg (public #5).
1056
+ # Malformed or out-of-range persisted data surfaces the default.
1057
+ return resolve_codex_hook_ingest_budget(config)
984
1058
  if key in ("update.check.enabled", "update.check.ttl_hours"):
985
1059
  # Defaults mirror `_is_update_check_due` (True / 24 hours).
986
1060
  # Hand-edited junk surfaces as the default — matches dashboard.bind.
@@ -1623,6 +1697,41 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
1623
1697
  rendered = str(normalized)
1624
1698
  print(f"{key}={rendered}")
1625
1699
  return 0
1700
+ if key == "codex.hook.ingest_budget_seconds":
1701
+ # Validate first; rejection short-circuits before lock acquisition.
1702
+ try:
1703
+ budget = _validate_codex_hook_ingest_budget_value(raw)
1704
+ except ValueError as exc:
1705
+ print(f"cctally: {exc}", file=sys.stderr)
1706
+ return 2
1707
+ with config_writer_lock():
1708
+ config = _load_config_unlocked()
1709
+ existing = config.get("codex")
1710
+ if existing is not None and not isinstance(existing, dict):
1711
+ print(
1712
+ "cctally: codex config error: codex must be an object",
1713
+ file=sys.stderr,
1714
+ )
1715
+ return 2
1716
+ block = dict(existing or {})
1717
+ hook = block.get("hook")
1718
+ if hook is not None and not isinstance(hook, dict):
1719
+ print(
1720
+ "cctally: codex config error: codex.hook must be an object",
1721
+ file=sys.stderr,
1722
+ )
1723
+ return 2
1724
+ hook = dict(hook or {})
1725
+ hook["ingest_budget_seconds"] = budget
1726
+ block["hook"] = hook
1727
+ config["codex"] = block
1728
+ save_config(config)
1729
+ if getattr(args, "emit_json", False):
1730
+ print(json.dumps(
1731
+ {"codex": {"hook": {"ingest_budget_seconds": budget}}}, indent=2))
1732
+ else:
1733
+ print(f"{key}={budget:g}")
1734
+ return 0
1626
1735
  if key == "conversation.retention_days":
1627
1736
  # Validate first; rejection short-circuits before lock acquisition (F8).
1628
1737
  try:
@@ -2159,6 +2268,22 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
2159
2268
  save_config(config)
2160
2269
  # idempotent: silent on missing key
2161
2270
  return 0
2271
+ if key == "codex.hook.ingest_budget_seconds":
2272
+ # Mirror the conversation.retention_days branch: drop the leaf, then
2273
+ # prune an empty `hook` and an empty `codex` so config.json stays tidy.
2274
+ with config_writer_lock():
2275
+ config = _load_config_unlocked()
2276
+ block = config.get("codex")
2277
+ hook = block.get("hook") if isinstance(block, dict) else None
2278
+ if isinstance(hook, dict) and "ingest_budget_seconds" in hook:
2279
+ del hook["ingest_budget_seconds"]
2280
+ if not hook:
2281
+ block.pop("hook", None)
2282
+ if not block:
2283
+ config.pop("codex", None)
2284
+ save_config(config)
2285
+ # idempotent: silent on missing key
2286
+ return 0
2162
2287
  if key == "conversation.retention_days":
2163
2288
  # Mirror the display.tz branch: drop the leaf; if the `conversation`
2164
2289
  # block ends up empty, drop it too. Next get resolves the 180 default.
@@ -349,7 +349,22 @@ STATS_WAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024 # 16777216
349
349
  # 1003 -> 1004 (#410 Task B): pair the public journal cursor with the exact
350
350
  # prefix atomically applied to the materialized index. A cursor-only hand edit
351
351
  # can no longer skip an already-durable event and make its natural key look new.
352
- STATS_INDEX_EPOCH = 1004
352
+ # 1004 -> 1005 (public #5): the incremental Codex quota projection. Adds the
353
+ # reverse map + composable per-group digest on `quota_window_blocks` and the
354
+ # `quota_projection_ledger_state` row (change-ledger watermark, interpretation
355
+ # version, and the two alert axes that are not functions of window dirtiness).
356
+ # It is an epoch bump and NOT a stats migration because the registry is frozen
357
+ # AND because an epoch-current open returns before any schema work — an
358
+ # `add_column_if_missing` would never run on an upgraded install, so the column
359
+ # would simply never appear. Every upgrading install therefore rebuilds stats.db
360
+ # from the journal on first open; that is the documented resolution for an epoch
361
+ # mismatch and a real one-time cost, not a free change.
362
+ # 1005 -> 1006 (public #5, I2 review): the periodic verification. Adds
363
+ # `quota_projection_ledger_state.last_full_pass_at`, the deadline a time-based
364
+ # full pass is measured against. Same mechanical reason as 1005 — an
365
+ # epoch-current open returns before any schema work — so it is a second bump
366
+ # rather than an amendment to the first.
367
+ STATS_INDEX_EPOCH = 1006
353
368
  LEGACY_STATS_HEAD = 13
354
369
 
355
370
 
@@ -1248,6 +1263,21 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
1248
1263
  generation TEXT NOT NULL,
1249
1264
  orphaned_at TEXT,
1250
1265
  account_key TEXT NOT NULL DEFAULT 'unattributed',
1266
+ -- public #5: the REVERSE MAP. `physical_group_key` records the
1267
+ -- physical window this block was materialized from, so the
1268
+ -- generation sweep can be scoped to the groups a bounded pass
1269
+ -- actually re-materialized instead of to whole roots.
1270
+ -- `physical_group_digest` is that group's contribution to its
1271
+ -- root's physical signature: the root value is a digest over the
1272
+ -- root's sorted (group key, group digest) pairs, which makes it
1273
+ -- ASSOCIATIVE. A bounded pass recomputes only the dirty groups'
1274
+ -- digests and re-derives the root value from the stored set —
1275
+ -- O(groups), 608 on the real store, against O(observations) at
1276
+ -- 211K. Hanging it off the blocks is what makes it self-maintaining:
1277
+ -- a group swept to nothing loses its blocks and drops out of the
1278
+ -- composition with them.
1279
+ physical_group_key TEXT,
1280
+ physical_group_digest TEXT,
1251
1281
  UNIQUE(source, source_root_key, account_key, logical_limit_key,
1252
1282
  observed_slot, window_minutes, resets_at_utc)
1253
1283
  );
@@ -1319,6 +1349,46 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
1319
1349
  PRIMARY KEY(source_root_key, account_key)
1320
1350
  );
1321
1351
 
1352
+ -- public #5: everything the incremental projector needs to know about
1353
+ -- its own last pass, keyed by provider source. One row, read once per
1354
+ -- reconcile.
1355
+ --
1356
+ -- `watermark_seq` is the highest `quota_window_change_log.seq` this
1357
+ -- index has consumed. It is written INSIDE the same stats transaction
1358
+ -- as the projection it describes, so the two commit or roll back
1359
+ -- together; a crash therefore replays a ledger range rather than
1360
+ -- skipping one, which is safe because re-materializing a group is
1361
+ -- idempotent. (Writing it after the commit would need a second stats
1362
+ -- transaction, and `run_stats_ingest` is the sole stats writer.)
1363
+ --
1364
+ -- `interpretation_version` invalidates the mechanism itself: a
1365
+ -- classification change alters interpreted keys with no row mutation
1366
+ -- for the ledger to observe, so a bump queues one complete pass.
1367
+ --
1368
+ -- `alerts_enabled` / `next_evaluation_at_utc` are the two alert axes
1369
+ -- that are not functions of window dirtiness — a delivery-gate
1370
+ -- transition, and a future-clocked observation that becomes eligible
1371
+ -- when wall time passes it with no row mutation at all.
1372
+ --
1373
+ -- `last_full_pass_at` is the periodic verification's deadline. Two
1374
+ -- cases a scoped sweep structurally cannot see — a block whose physical
1375
+ -- group is absent from the cache entirely, and a milestone on a historic
1376
+ -- root no longer active — are otherwise repairable only by an
1377
+ -- interpretation bump, a rebuild or a burst overflow, none of which
1378
+ -- happen on a normal install. Every full pass stamps it, whatever
1379
+ -- triggered it, so the deadline is satisfied by whichever caller reaches
1380
+ -- it first and the bound on staleness is one interval rather than
1381
+ -- forever. NULL means "never verified", which reads as due.
1382
+ CREATE TABLE IF NOT EXISTS quota_projection_ledger_state (
1383
+ source TEXT NOT NULL,
1384
+ watermark_seq INTEGER NOT NULL DEFAULT 0,
1385
+ interpretation_version INTEGER NOT NULL DEFAULT 0,
1386
+ alerts_enabled INTEGER,
1387
+ next_evaluation_at_utc TEXT,
1388
+ last_full_pass_at TEXT,
1389
+ PRIMARY KEY(source)
1390
+ );
1391
+
1322
1392
  CREATE TABLE IF NOT EXISTS quota_alert_arming (
1323
1393
  id INTEGER PRIMARY KEY AUTOINCREMENT,
1324
1394
  source TEXT NOT NULL,
@@ -1361,6 +1431,20 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
1361
1431
  "quota_threshold_events", "quota_alert_arming"):
1362
1432
  add_column_if_missing(
1363
1433
  conn, _tbl, "account_key", "TEXT NOT NULL DEFAULT 'unattributed'")
1434
+ # public #5 backstop, and NOT redundant with the CREATE above. An
1435
+ # epoch-MISMATCHED index resolves by rebuild and gets the fresh CREATE;
1436
+ # a LEGACY index (`user_version <= LEGACY_STATS_HEAD`) takes the
1437
+ # in-place cutover, where `CREATE TABLE IF NOT EXISTS` is a no-op over
1438
+ # the table it already has — so the reverse map would never appear and
1439
+ # every reconcile after the cutover would fail on `no such column`.
1440
+ for _col in ("physical_group_key", "physical_group_digest"):
1441
+ add_column_if_missing(conn, "quota_window_blocks", _col, "TEXT")
1442
+ # Same seam, epoch 1006: a LEGACY index that already took the epoch-1005
1443
+ # cutover carries `quota_projection_ledger_state` WITHOUT the periodic
1444
+ # verification's deadline, and `CREATE TABLE IF NOT EXISTS` above is a
1445
+ # no-op over the table it already has.
1446
+ add_column_if_missing(
1447
+ conn, "quota_projection_ledger_state", "last_full_pass_at", "TEXT")
1364
1448
 
1365
1449
 
1366
1450
  def open_db(*, _target_path=None) -> sqlite3.Connection:
@@ -2299,7 +2383,7 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
2299
2383
  # Schema-apply compatibility for legacy/test-mode paths that reach this
2300
2384
  # DDL with a pre-pair cursor row. A released epoch-1003 index does NOT
2301
2385
  # use this as an upgrade shortcut: the epoch mismatch rebuilds it into
2302
- # the complete epoch-1004 schema first.
2386
+ # the complete current-epoch schema first.
2303
2387
  conn.execute(
2304
2388
  "UPDATE journal_cursor "
2305
2389
  "SET applied_segment = segment, applied_offset = offset "
@@ -135,6 +135,12 @@ class CacheReportDailyRow:
135
135
  net_usd: float
136
136
  anomaly_triggered: bool
137
137
  anomaly_reasons: tuple[str, ...]
138
+ # #443 S1. `anomaly_unevaluated` = the predicates the classifier declined
139
+ # to run for this row; `observed` is False only for the builder's
140
+ # synthetic today row. Both default so their absence reproduces pre-S1
141
+ # rendering exactly.
142
+ anomaly_unevaluated: tuple[str, ...] = ()
143
+ observed: bool = True
138
144
 
139
145
 
140
146
  @dataclass(frozen=True)
@@ -161,6 +167,9 @@ class CacheReportTodaySpotlight:
161
167
  anomaly_triggered: bool
162
168
  anomaly_reasons: tuple[str, ...]
163
169
  baseline_daily_row_count: int
170
+ # #443 S1 — see CacheReportDailyRow.
171
+ anomaly_unevaluated: tuple[str, ...] = ()
172
+ observed: bool = True
164
173
 
165
174
 
166
175
  def _cache_report_snapshot_to_dict(cr: "CacheReportSnapshot | None") -> "dict | None":
@@ -189,6 +198,8 @@ def _cache_report_snapshot_to_dict(cr: "CacheReportSnapshot | None") -> "dict |
189
198
  "anomaly_triggered": cr.today.anomaly_triggered,
190
199
  "anomaly_reasons": list(cr.today.anomaly_reasons),
191
200
  "baseline_daily_row_count": cr.today.baseline_daily_row_count,
201
+ "anomaly_unevaluated": list(cr.today.anomaly_unevaluated),
202
+ "observed": cr.today.observed,
192
203
  },
193
204
  "days": [
194
205
  {
@@ -203,6 +214,8 @@ def _cache_report_snapshot_to_dict(cr: "CacheReportSnapshot | None") -> "dict |
203
214
  "net_usd": d.net_usd,
204
215
  "anomaly_triggered": d.anomaly_triggered,
205
216
  "anomaly_reasons": list(d.anomaly_reasons),
217
+ "anomaly_unevaluated": list(d.anomaly_unevaluated),
218
+ "observed": d.observed,
206
219
  }
207
220
  for d in cr.days
208
221
  ],
@@ -319,6 +332,12 @@ def _cache_report_empty(
319
332
  ):
320
333
  """The empty (no in-window entries) ``CacheReportSnapshot`` — factored so the
321
334
  warm and cold builder paths share one ``is_empty`` return (#272 §6)."""
335
+ crk = _cache_report_load_kernel()
336
+ # #443 S1 — the dataclass defaults would claim ``observed=True`` with an
337
+ # empty ``anomaly_unevaluated`` for a day that was definitionally never
338
+ # measured or classified. That is the fabricating default this session
339
+ # removes everywhere else, so it is stated explicitly here too even though
340
+ # the empty branch short-circuits before any of it renders.
322
341
  empty_today = CacheReportTodaySpotlight(
323
342
  date=today_iso,
324
343
  cache_hit_percent=0.0,
@@ -328,6 +347,8 @@ def _cache_report_empty(
328
347
  anomaly_triggered=False,
329
348
  anomaly_reasons=(),
330
349
  baseline_daily_row_count=0,
350
+ anomaly_unevaluated=tuple(crk.CACHE_ANOMALY_PREDICATES),
351
+ observed=False,
331
352
  )
332
353
  return CacheReportSnapshot(
333
354
  window_days=window_days,
@@ -555,6 +576,10 @@ def build_cache_report_snapshot(
555
576
  anomaly_triggered=False,
556
577
  anomaly_reasons=(),
557
578
  baseline_daily_row_count=baseline_daily_row_count,
579
+ # This row never reaches the classifier, so nothing was evaluated
580
+ # for it and nothing was measured behind it (#443 S1 F1/F2).
581
+ anomaly_unevaluated=tuple(crk.CACHE_ANOMALY_PREDICATES),
582
+ observed=False,
558
583
  )
559
584
  else:
560
585
  today_spotlight = CacheReportTodaySpotlight(
@@ -568,6 +593,8 @@ def build_cache_report_snapshot(
568
593
  anomaly_triggered=today_row.anomaly_triggered,
569
594
  anomaly_reasons=tuple(today_row.anomaly_reasons),
570
595
  baseline_daily_row_count=baseline_daily_row_count,
596
+ anomaly_unevaluated=tuple(today_row.anomaly_unevaluated),
597
+ observed=True,
571
598
  )
572
599
 
573
600
  # Daily rows — newest first, capped at ``window_days``.
@@ -615,6 +642,8 @@ def build_cache_report_snapshot(
615
642
  net_usd=0.0,
616
643
  anomaly_triggered=False,
617
644
  anomaly_reasons=(),
645
+ anomaly_unevaluated=tuple(crk.CACHE_ANOMALY_PREDICATES),
646
+ observed=False,
618
647
  )
619
648
  )
620
649
  days_newest_first.extend(
@@ -630,6 +659,8 @@ def build_cache_report_snapshot(
630
659
  net_usd=r.net_usd,
631
660
  anomaly_triggered=r.anomaly_triggered,
632
661
  anomaly_reasons=tuple(r.anomaly_reasons),
662
+ anomaly_unevaluated=tuple(r.anomaly_unevaluated),
663
+ observed=True,
633
664
  )
634
665
  for r in raw_days_newest_first
635
666
  )
@@ -1090,16 +1090,35 @@ def _handle_get_conversation_payload_impl(handler, path: str) -> None:
1090
1090
  handler._respond_json(400, {"error": "bad request"})
1091
1091
  return
1092
1092
  cq = handler._conversation_query()
1093
- ok, loc = handler._run_conversation_query(
1094
- lambda conn: cq.locate_tool_payload(
1095
- conn, session_id, tool_use_id, which),
1096
- "/api/conversation/payload")
1097
- if not ok:
1098
- return
1093
+ mode = which
1094
+ if which == "result":
1095
+ # A backgrounded-MCP result lives in an attachment record, not in the
1096
+ # placeholder tool_result a public which='result' lookup finds first.
1097
+ # Resolve the internal carrier here; the response the client receives
1098
+ # still carries which:"result", so the client contract is unchanged and
1099
+ # `background_result` is never an accepted input value.
1100
+ ok, resolved = handler._run_conversation_query(
1101
+ lambda conn: cq.locate_result_payload(conn, session_id, tool_use_id),
1102
+ "/api/conversation/payload")
1103
+ if not ok:
1104
+ return
1105
+ mode, loc = resolved
1106
+ if mode == "background_gone":
1107
+ # A KNOWN background placeholder whose notification row is gone or
1108
+ # unresolvable — 410, distinct from the 404 an unknown id returns.
1109
+ handler._respond_json(410, {"error": "source no longer available"})
1110
+ return
1111
+ else:
1112
+ ok, loc = handler._run_conversation_query(
1113
+ lambda conn: cq.locate_tool_payload(
1114
+ conn, session_id, tool_use_id, which),
1115
+ "/api/conversation/payload")
1116
+ if not ok:
1117
+ return
1099
1118
  if loc is None:
1100
1119
  handler._respond_json(404, {"error": "not found"})
1101
1120
  return
1102
- payload = cq.read_full_payload(loc[0], loc[1], tool_use_id, which)
1121
+ payload = cq.read_located_payload(loc, tool_use_id, mode)
1103
1122
  if payload is None:
1104
1123
  handler._respond_json(410, {"error": "source no longer available"})
1105
1124
  return
@@ -3188,6 +3188,50 @@ def _claude_accounts_wire(
3188
3188
  return cards
3189
3189
 
3190
3190
 
3191
+ def _codex_ingest_backlog_wire(
3192
+ cache_conn: sqlite3.Connection,
3193
+ ) -> "dict[str, object] | None":
3194
+ """The hook's budgeted-ingest backlog, or ``None`` when there is none.
3195
+
3196
+ Public #5 spec §5. Additive and OMITTED ENTIRELY at zero, so the normal
3197
+ payload stays byte-identical for every install whose Codex history is
3198
+ already ingested — which is nearly all of them, nearly all the time.
3199
+
3200
+ It deliberately does NOT touch ``availability`` or ``freshness``. Those are
3201
+ read by a long and explicitly non-exhaustive list of gates, and degrading a
3202
+ shared signal for a domain-local condition is a failure this project has
3203
+ already hit; the client renders "history still loading" from this field
3204
+ without any shared axis moving.
3205
+
3206
+ Any read or shape failure degrades to ``None``. The record is a health
3207
+ signal, not evidence, and a hand-edited one must never be able to fail the
3208
+ envelope build.
3209
+ """
3210
+ try:
3211
+ row = cache_conn.execute(
3212
+ "SELECT value FROM cache_meta WHERE key = ? LIMIT 1",
3213
+ ("codex_ingest_backlog",),
3214
+ ).fetchone()
3215
+ except sqlite3.DatabaseError:
3216
+ return None
3217
+ if not row or not row[0]:
3218
+ return None
3219
+ try:
3220
+ record = json.loads(str(row[0]))
3221
+ files = int(record["files"])
3222
+ pending_bytes = int(record["bytes"])
3223
+ except (ValueError, TypeError, KeyError):
3224
+ return None
3225
+ if files <= 0:
3226
+ return None
3227
+ since = record.get("since")
3228
+ return {
3229
+ "files": files,
3230
+ "bytes": pending_bytes,
3231
+ "since": None if since is None else str(since),
3232
+ }
3233
+
3234
+
3191
3235
  def build_codex_source_state(
3192
3236
  context: DashboardReadContext,
3193
3237
  *,
@@ -3217,6 +3261,7 @@ def build_codex_source_state(
3217
3261
  context,
3218
3262
  )
3219
3263
  projection_incoherent = not coherence.coherent
3264
+ ingest_backlog = _codex_ingest_backlog_wire(context.cache_conn)
3220
3265
  # The cache reader's established report surface treats the ``now`` instant
3221
3266
  # as inclusive. The qualified adapter is half-open, so extend only its
3222
3267
  # query/result boundary by one microsecond and keep all live budget sums
@@ -3681,6 +3726,12 @@ def build_codex_source_state(
3681
3726
  "projected_thresholds": context.codex_quota_projected_thresholds,
3682
3727
  },
3683
3728
  "cache_report": cache_report,
3729
+ # public #5 §5: additive, omitted at zero (the `cycle_freshness`
3730
+ # precedent). Never emitted as an empty object — the absence IS the
3731
+ # "nothing owed" state, which is what keeps the normal payload
3732
+ # byte-identical.
3733
+ **({"ingest_backlog": ingest_backlog}
3734
+ if ingest_backlog is not None else {}),
3684
3735
  },
3685
3736
  domain_freshness={
3686
3737
  "hero": (