cctally 1.27.1 → 1.28.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.
@@ -286,7 +286,7 @@ from _lib_subscription_weeks import _compute_subscription_weeks
286
286
  from _lib_blocks import _group_entries_into_blocks
287
287
  from _cctally_config import save_config, _load_config_unlocked
288
288
  from _cctally_db import _render_migration_error_banner
289
- from _cctally_cache import get_entries
289
+ from _cctally_cache import get_entries, open_cache_db
290
290
 
291
291
 
292
292
  # === Module-level back-ref shims for helpers that STAY in bin/cctally ======
@@ -347,6 +347,12 @@ def _build_alert_payload_project_budget(*args, **kwargs):
347
347
  )
348
348
 
349
349
 
350
+ def _build_alert_payload_codex_budget(*args, **kwargs):
351
+ return sys.modules["cctally"]._build_alert_payload_codex_budget(
352
+ *args, **kwargs
353
+ )
354
+
355
+
350
356
  def _dispatch_alert_notification(*args, **kwargs):
351
357
  return sys.modules["cctally"]._dispatch_alert_notification(*args, **kwargs)
352
358
 
@@ -4181,14 +4187,29 @@ def _envelope_rows_five_hour(conn, descriptor, limit, severity_for) -> list[dict
4181
4187
  def _envelope_rows_budget(conn, descriptor, limit, severity_for) -> list[dict]:
4182
4188
  # Third axis (issue #19): equiv-$ budget threshold crossings. Budget
4183
4189
  # alerts are keyed by the effective (post-reset) week_start_at + the
4184
- # integer threshold; the envelope id mirrors the dispatch payload's
4185
- # ``budget:<week_start_at>:<threshold>`` shape
4190
+ # write-once ``period`` discriminator + the integer threshold; the envelope
4191
+ # id mirrors the dispatch payload's
4192
+ # ``budget:<week_start_at>:<period>:<threshold>`` shape
4186
4193
  # (``_build_alert_payload_budget``). No ``reset_event_id`` segment —
4187
4194
  # a mid-week reset re-anchors ``week_start_at`` so the new window
4188
- # naturally gets fresh rows under ``UNIQUE(week_start_at, threshold)``.
4195
+ # naturally gets fresh rows under
4196
+ # ``UNIQUE(week_start_at, period, threshold)``.
4197
+ #
4198
+ # Period generalization (calendar-period-codex-budgets, spec §5/§6): the
4199
+ # ``week_start_at`` key column carries either a subscription-week instant
4200
+ # OR a calendar period-start instant (back-compat misnomer, like
4201
+ # ``weekly_usd``); the ``period`` discriminator (#137) tells them apart.
4202
+ # ``period`` is read FROM THE ROW (snapshotted at crossing), never live
4203
+ # config that may have changed since (the Codex P0-4 lesson) — so a user who
4204
+ # fires subscription-week alerts then switches ``budget.period`` keeps the
4205
+ # historical row's noun (Symptom 1 fix). ``COALESCE(period,
4206
+ # 'subscription-week')`` renders a pre-011 NULL-sentinel row with the
4207
+ # vendor-default noun and keeps the ``id`` non-``None``.
4189
4208
  rows = conn.execute(
4190
4209
  f"""
4191
- SELECT week_start_at, threshold, crossed_at_utc, alerted_at,
4210
+ SELECT week_start_at,
4211
+ COALESCE(period, 'subscription-week') AS period,
4212
+ threshold, crossed_at_utc, alerted_at,
4192
4213
  budget_usd, spent_usd, consumption_pct
4193
4214
  FROM {descriptor.milestone_table}
4194
4215
  WHERE alerted_at IS NOT NULL
@@ -4201,7 +4222,7 @@ def _envelope_rows_budget(conn, descriptor, limit, severity_for) -> list[dict]:
4201
4222
  for r in rows:
4202
4223
  threshold = int(r["threshold"])
4203
4224
  out.append({
4204
- "id": f"budget:{r['week_start_at']}:{threshold}",
4225
+ "id": f"budget:{r['week_start_at']}:{r['period']}:{threshold}",
4205
4226
  "axis": descriptor.id,
4206
4227
  "threshold": threshold,
4207
4228
  "severity": severity_for(threshold),
@@ -4209,6 +4230,8 @@ def _envelope_rows_budget(conn, descriptor, limit, severity_for) -> list[dict]:
4209
4230
  "alerted_at": r["alerted_at"],
4210
4231
  "context": {
4211
4232
  "week_start_at": r["week_start_at"],
4233
+ "period": r["period"],
4234
+ "period_start_at": r["week_start_at"],
4212
4235
  "budget_usd": float(r["budget_usd"]),
4213
4236
  "spent_usd": float(r["spent_usd"]),
4214
4237
  "consumption_pct": float(r["consumption_pct"]),
@@ -4221,16 +4244,23 @@ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict
4221
4244
  # Fourth axis (issue #121): projected-pace threshold crossings. Like
4222
4245
  # budget, projected alerts re-anchor ``week_start_at`` on a mid-week
4223
4246
  # reset, so there is NO ``reset_event_id`` segment — the new window gets
4224
- # fresh rows under ``UNIQUE(week_start_at, metric, threshold)``. The
4225
- # ``metric`` discriminator (``weekly_pct`` | ``budget_usd``) drives the
4226
- # frontend's metric-aware context renderer; ``denominator`` +
4227
- # ``projected_value`` are rendered FROM THE ROW (the values snapshotted at
4228
- # crossing), never live config that may have changed since (Codex P0-4).
4229
- # The envelope id mirrors the dispatch payload's
4230
- # ``projected:<week_start_at>:<metric>:<threshold>`` shape.
4247
+ # fresh rows under ``UNIQUE(week_start_at, period, metric, threshold)``. The
4248
+ # ``metric`` discriminator (``weekly_pct`` | ``budget_usd`` |
4249
+ # ``codex_budget_usd``) drives the frontend's metric-aware context renderer;
4250
+ # ``denominator`` + ``projected_value`` are rendered FROM THE ROW (the values
4251
+ # snapshotted at crossing), never live config that may have changed since
4252
+ # (Codex P0-4). The envelope id mirrors the dispatch payload's
4253
+ # ``projected:<week_start_at>:<period>:<metric>:<threshold>`` shape. The
4254
+ # write-once ``period`` discriminator (#137) carries no symptom-1 label here
4255
+ # — projected's ``context`` is metric-driven, never a live-config period
4256
+ # noun — so ``COALESCE(period, 'subscription-week')`` is purely for a stable
4257
+ # non-``None`` id segment on a pre-011 NULL-sentinel row (the calendar-week ↔
4258
+ # calendar-month within-metric collision otherwise shares a React key).
4231
4259
  rows = conn.execute(
4232
4260
  f"""
4233
- SELECT week_start_at, metric, threshold, projected_value,
4261
+ SELECT week_start_at,
4262
+ COALESCE(period, 'subscription-week') AS period,
4263
+ metric, threshold, projected_value,
4234
4264
  denominator, crossed_at_utc, alerted_at
4235
4265
  FROM {descriptor.milestone_table}
4236
4266
  WHERE alerted_at IS NOT NULL
@@ -4244,7 +4274,10 @@ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict
4244
4274
  threshold = int(r["threshold"])
4245
4275
  metric = str(r["metric"])
4246
4276
  out.append({
4247
- "id": f"projected:{r['week_start_at']}:{metric}:{threshold}",
4277
+ "id": (
4278
+ f"projected:{r['week_start_at']}:{r['period']}"
4279
+ f":{metric}:{threshold}"
4280
+ ),
4248
4281
  "axis": descriptor.id,
4249
4282
  "metric": metric,
4250
4283
  "threshold": threshold,
@@ -4321,6 +4354,66 @@ def _envelope_rows_project_budget(conn, descriptor, limit, severity_for) -> list
4321
4354
  return out
4322
4355
 
4323
4356
 
4357
+ def _envelope_rows_codex_budget(conn, descriptor, limit, severity_for) -> list[dict]:
4358
+ # Sixth axis (calendar-period-codex-budgets, spec §6): per-vendor Codex
4359
+ # equiv-actual-$ budget threshold crossings over a CALENDAR period
4360
+ # (calendar-week / calendar-month — Codex has no Anthropic subscription
4361
+ # week). Keyed on ``period_start_at`` (the resolved period-window start
4362
+ # instant, stored as the ``isoformat(timespec="seconds")`` ``+00:00`` offset
4363
+ # form, NOT a ``Z`` suffix) + the integer threshold; the envelope id mirrors
4364
+ # the dispatch payload's ``codex_budget:<period_start_at>:<threshold>`` shape
4365
+ # (``_build_alert_payload_codex_budget``). NO ``reset_event_id`` segment —
4366
+ # rolling to the next period yields a fresh ``period_start_at`` so the new
4367
+ # window naturally gets fresh rows under ``UNIQUE(period_start_at,
4368
+ # threshold)``. ``budget_usd`` / ``spent_usd`` / ``consumption_pct`` are
4369
+ # rendered FROM THE ROW (snapshotted at crossing), never live config that may
4370
+ # have changed since (the Codex P0-4 lesson). The ``period`` discriminator is
4371
+ # the write-once ``period`` discriminator (#137) is read FROM THE ROW
4372
+ # (snapshotted at crossing) — mirroring how the dispatched payload sourced it
4373
+ # from config-at-fire-time — never live ``budget.codex.period`` that may have
4374
+ # changed since (the Codex P0-4 lesson; Symptom 1 fix). ``COALESCE(period,
4375
+ # 'calendar-month')`` renders a pre-011 NULL-sentinel row with the
4376
+ # vendor-default civil-period noun (Codex has no subscription week) and keeps
4377
+ # the ``id`` non-``None``. The id gains the ``period`` segment so a
4378
+ # calendar-week ↔ calendar-month coinciding-instant collision (now distinct
4379
+ # coexisting rows under ``UNIQUE(period_start_at, period, threshold)``) gets
4380
+ # distinct React keys.
4381
+ rows = conn.execute(
4382
+ f"""
4383
+ SELECT period_start_at,
4384
+ COALESCE(period, 'calendar-month') AS period,
4385
+ threshold, crossed_at_utc, alerted_at,
4386
+ budget_usd, spent_usd, consumption_pct
4387
+ FROM {descriptor.milestone_table}
4388
+ WHERE alerted_at IS NOT NULL
4389
+ ORDER BY alerted_at DESC
4390
+ LIMIT ?
4391
+ """,
4392
+ (limit,),
4393
+ ).fetchall()
4394
+ out: list[dict] = []
4395
+ for r in rows:
4396
+ threshold = int(r["threshold"])
4397
+ out.append({
4398
+ "id": (
4399
+ f"codex_budget:{r['period_start_at']}:{r['period']}:{threshold}"
4400
+ ),
4401
+ "axis": descriptor.id,
4402
+ "threshold": threshold,
4403
+ "severity": severity_for(threshold),
4404
+ "crossed_at": r["crossed_at_utc"],
4405
+ "alerted_at": r["alerted_at"],
4406
+ "context": {
4407
+ "period": r["period"],
4408
+ "period_start_at": r["period_start_at"],
4409
+ "budget_usd": float(r["budget_usd"]),
4410
+ "spent_usd": float(r["spent_usd"]),
4411
+ "consumption_pct": float(r["consumption_pct"]),
4412
+ },
4413
+ })
4414
+ return out
4415
+
4416
+
4324
4417
  # Keyed by ``AlertAxisDescriptor.id`` — the registry decides which axes run,
4325
4418
  # in what order; this table supplies the bespoke heterogeneous row-mapper.
4326
4419
  _ENVELOPE_AXIS_MAPPERS = {
@@ -4329,6 +4422,7 @@ _ENVELOPE_AXIS_MAPPERS = {
4329
4422
  "budget": _envelope_rows_budget,
4330
4423
  "projected": _envelope_rows_projected,
4331
4424
  "project_budget": _envelope_rows_project_budget,
4425
+ "codex_budget": _envelope_rows_codex_budget,
4332
4426
  }
4333
4427
 
4334
4428
 
@@ -4339,20 +4433,24 @@ def _build_alerts_envelope_array(
4339
4433
  """Return the ``alerts`` array for the SSE snapshot envelope.
4340
4434
 
4341
4435
  Union of ``percent_milestones``, ``five_hour_milestones``,
4342
- ``budget_milestones``, ``projected_milestones``, and
4343
- ``project_budget_milestones`` rows with ``alerted_at IS NOT NULL``,
4344
- ordered newest-first by ``alerted_at``, capped at ``limit`` (default 100).
4345
- Single source of truth for both the dashboard panel (slices to 10
4346
- client-side) and the modal (renders all 100). Forward-only semantics: only
4347
- rows the alert-dispatch path stamped get included; pre-deploy crossings
4348
- stay NULL and are intentionally invisible (spec §4.3).
4349
-
4350
- All five axes share the same envelope schema; the ``axis`` field
4436
+ ``budget_milestones``, ``projected_milestones``,
4437
+ ``project_budget_milestones``, and ``codex_budget_milestones`` rows with
4438
+ ``alerted_at IS NOT NULL``, ordered newest-first by ``alerted_at``, capped at
4439
+ ``limit`` (default 100). Single source of truth for both the dashboard panel
4440
+ (slices to 10 client-side) and the modal (renders all 100). Forward-only
4441
+ semantics: only rows the alert-dispatch path stamped get included; pre-deploy
4442
+ crossings stay NULL and are intentionally invisible (spec §4.3).
4443
+
4444
+ All six axes share the same envelope schema; the ``axis`` field
4351
4445
  (``weekly`` / ``five_hour`` / ``budget`` / ``projected`` /
4352
- ``project_budget``) discriminates. The ``projected`` axis additionally
4353
- carries a top-level ``metric`` (``weekly_pct`` | ``budget_usd``) so the
4354
- frontend can pick its metric-aware context renderer; ``project_budget``
4355
- carries the project basename + ``$spent of $budget`` in its context.
4446
+ ``project_budget`` / ``codex_budget``) discriminates. The ``projected`` axis
4447
+ additionally carries a top-level ``metric`` (``weekly_pct`` | ``budget_usd``
4448
+ | ``codex_budget_usd``) so the frontend can pick its metric-aware context
4449
+ renderer; ``project_budget``
4450
+ carries the project basename + ``$spent of $budget`` in its context;
4451
+ ``budget`` + ``codex_budget`` carry a ``period`` discriminator
4452
+ (subscription-week / calendar-week / calendar-month) so the frontend renders
4453
+ a period-aware "Month" / "Calendar week" / "Week" label.
4356
4454
 
4357
4455
  Per-axis ``LIMIT`` is applied at the SQL level (each query may yield
4358
4456
  up to ``limit``) and the union is re-sorted + sliced — important for
@@ -4792,6 +4890,15 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
4792
4890
  # getter's ``project_alerts_enabled`` (default False) — the frontend
4793
4891
  # SettingsOverlay seeds a single on/off toggle from it.
4794
4892
  "project_alerts_enabled": bool(_budget_cfg.get("project_alerts_enabled")),
4893
+ # Codex budget toggle mirrors (#134). The frontend SettingsOverlay
4894
+ # seeds two toggles (alerts + projected) + a disabled-with-hint empty
4895
+ # state from these three flags. ``_budget_cfg["codex"]`` is ``None`` by
4896
+ # default (no Codex budget) → all three default false/absent safely;
4897
+ # the ``_BudgetConfigError`` fallback dict above lacks a ``codex`` key,
4898
+ # so ``.get("codex")`` → ``None`` is likewise safe.
4899
+ "codex_budget_configured": _budget_cfg.get("codex") is not None,
4900
+ "codex_budget_alerts_enabled": bool((_budget_cfg.get("codex") or {}).get("alerts_enabled")),
4901
+ "codex_projected_enabled": bool((_budget_cfg.get("codex") or {}).get("projected_enabled")),
4795
4902
  # Alert-dispatch notifier mirror (Phase B). `notifier` is the
4796
4903
  # validated backend selector ("auto"/"command"/etc.). The raw
4797
4904
  # `command_template` is NEVER mirrored — it routinely holds secrets
@@ -5106,6 +5213,21 @@ _DASHBOARD_SYNC_LOCK_TIMEOUT_SECONDS = 2.0
5106
5213
  # Pre-extract location: bin/cctally L17694.
5107
5214
 
5108
5215
 
5216
+ def _qs_int(q: dict, key: str, default: int) -> int:
5217
+ """Parse a single query-string int with a fallback.
5218
+
5219
+ ``q`` is a ``urllib.parse.parse_qs`` mapping (list-valued). A missing key,
5220
+ an empty value, or a non-integer spelling all fall back to ``default`` —
5221
+ the kernels clamp bounds, so this only needs to be permissive, not strict.
5222
+ """
5223
+ vals = q.get(key, [None])
5224
+ raw = vals[0] if vals else None
5225
+ try:
5226
+ return int(raw) if raw is not None else default
5227
+ except (TypeError, ValueError):
5228
+ return default
5229
+
5230
+
5109
5231
  class DashboardHTTPHandler(BaseHTTPRequestHandler):
5110
5232
  """Routes:
5111
5233
  GET / → dashboard.html
@@ -5146,6 +5268,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5146
5268
  # path leaves this None and only sees the `config.json` view, which
5147
5269
  # is the Codex H4 finding. Set by cmd_dashboard before serve_forever.
5148
5270
  cctally_host: "str | None" = None
5271
+ # Conversation viewer (Plan 2, spec §5): the resolved
5272
+ # `dashboard.expose_transcripts` opt-in. False = transcript endpoints
5273
+ # are served only over loopback; True = LAN devices reach them at the
5274
+ # bind's IP literal (anti-DNS-rebinding still rejects hostnames). Set by
5275
+ # cmd_dashboard before serve_forever; the per-request gate
5276
+ # (`_require_transcripts_allowed`) ANDs this with the request Host.
5277
+ cctally_expose_transcripts: bool = False
5149
5278
 
5150
5279
  # Silence the default per-request access log — noisy in the parent
5151
5280
  # terminal, and we pipe it through our own logger in cmd_dashboard.
@@ -5218,6 +5347,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5218
5347
  self._handle_share_history_get()
5219
5348
  elif path == "/api/doctor":
5220
5349
  self._handle_get_doctor()
5350
+ elif path == "/api/conversations":
5351
+ self._handle_get_conversations()
5352
+ elif path == "/api/conversation/search":
5353
+ self._handle_get_conversation_search()
5354
+ elif path.startswith("/api/conversation/"):
5355
+ self._handle_get_conversation_detail(path)
5221
5356
  else:
5222
5357
  self.send_error(404, "not found")
5223
5358
 
@@ -5377,6 +5512,37 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5377
5512
  else:
5378
5513
  self._respond_json(403, {"error": reason})
5379
5514
 
5515
+ @staticmethod
5516
+ def _transcript_gate():
5517
+ """Lazy-load the pure transcript-access gate kernel (Plan 2, §5)."""
5518
+ return sys.modules["cctally"]._load_sibling("_lib_transcript_access")
5519
+
5520
+ def _transcripts_visible_to_request(self) -> bool:
5521
+ """Single source of truth for "may transcripts be served to THIS
5522
+ request?" Composes the bind gate (`transcripts_allowed`) with the
5523
+ per-request Host allowlist (`host_allowed_for_transcripts`,
5524
+ anti-DNS-rebinding). Spec §5.
5525
+
5526
+ Both `_require_transcripts_allowed` (the GET-route 403 gate) and the
5527
+ `transcriptsEnabled` client signal in `_serve_api_data` route through
5528
+ this predicate so the two are contractually identical — a future
5529
+ one-line drift can never re-introduce the enabled-then-403 desync.
5530
+ """
5531
+ ta = self._transcript_gate()
5532
+ expose = bool(type(self).cctally_expose_transcripts)
5533
+ return (ta.transcripts_allowed(type(self).cctally_host, expose)
5534
+ and ta.host_allowed_for_transcripts(
5535
+ self.headers.get("Host", ""), expose))
5536
+
5537
+ def _require_transcripts_allowed(self) -> bool:
5538
+ """True if transcripts may be served to THIS request; else emit 403 and
5539
+ return False. Spec §5.
5540
+ """
5541
+ if not self._transcripts_visible_to_request():
5542
+ self._respond_403("transcripts not exposed")
5543
+ return False
5544
+ return True
5545
+
5380
5546
  def _handle_post_settings(self) -> None:
5381
5547
  """Persist a settings update and trigger an immediate SSE broadcast.
5382
5548
 
@@ -5384,8 +5550,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5384
5550
  "update"?: {"check"?: {"enabled"?: bool, "ttl_hours"?: int}},
5385
5551
  "cache_report"?: {"anomaly_threshold_pp"?: int},
5386
5552
  "budget"?: {"weekly_usd"?: number|null, "alerts_enabled"?: bool,
5387
- "alert_thresholds"?: int[], "projected_enabled"?: bool}}`` — every
5388
- top-level key is optional; any subset may be sent together
5553
+ "alert_thresholds"?: int[], "projected_enabled"?: bool,
5554
+ "codex"?: {"alerts_enabled"?: bool, "projected_enabled"?: bool}}}``
5555
+ — every top-level key is optional; any subset may be sent together
5389
5556
  (combined save). Unknown top-level keys are rejected with 400.
5390
5557
 
5391
5558
  Per-block validation:
@@ -5412,6 +5579,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5412
5579
  block and validated via ``_get_budget_config(merged)`` (issue
5413
5580
  #19, projected toggle #121); ``_BudgetConfigError`` → 400.
5414
5581
  Budget is its OWN config block, distinct from ``alerts``.
5582
+ ``budget.codex`` (#134) is a nested partial-merge: only the two
5583
+ toggles (``alerts_enabled`` / ``projected_enabled``) are
5584
+ dashboard-writable — ``amount_usd`` / ``period`` /
5585
+ ``alert_thresholds`` stay CLI-only and are preserved from the
5586
+ persisted block. A non-dict ``budget.codex`` → 400; toggling
5587
+ ``budget.codex.*`` when no Codex budget is configured → 400
5588
+ (fail-closed; amounts are never invented from the dashboard).
5415
5589
 
5416
5590
  Atomic merged write: if all touched blocks validate, the merged
5417
5591
  config is persisted in a single ``save_config`` call inside the
@@ -5755,6 +5929,36 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5755
5929
  ):
5756
5930
  if leaf in budget_in:
5757
5931
  merged_budget[leaf] = budget_in[leaf]
5932
+ # Nested partial-merge for the Codex sub-block (#134). Mirrors
5933
+ # the ``update.check`` nested-dict merge below: only the two
5934
+ # alert toggles (``alerts_enabled`` / ``projected_enabled``) are
5935
+ # dashboard-writable — ``amount_usd`` / ``period`` /
5936
+ # ``alert_thresholds`` stay CLI-only (ignored if sent), and the
5937
+ # merge preserves them from the persisted block rather than
5938
+ # replacing the whole ``codex`` dict (the clobber regression).
5939
+ if "codex" in budget_in:
5940
+ incoming_codex = budget_in["codex"]
5941
+ if not isinstance(incoming_codex, dict):
5942
+ self._respond_json(
5943
+ 400, {"error": "budget.codex must be an object"}
5944
+ )
5945
+ return
5946
+ existing_codex = merged_budget.get("codex")
5947
+ if existing_codex is None:
5948
+ # Fail closed: the dashboard only TOGGLES an existing
5949
+ # Codex budget — amounts are CLI-only — so it must never
5950
+ # invent one. The frontend disables the toggle, this
5951
+ # backstops a direct POST.
5952
+ self._respond_json(400, {"error": (
5953
+ "no Codex budget configured — set one via the CLI "
5954
+ "first (cctally budget set <amount> --vendor codex)"
5955
+ )})
5956
+ return
5957
+ merged_codex = dict(existing_codex)
5958
+ for sub in ("alerts_enabled", "projected_enabled"):
5959
+ if sub in incoming_codex:
5960
+ merged_codex[sub] = bool(incoming_codex[sub])
5961
+ merged_budget["codex"] = merged_codex
5758
5962
  merged["budget"] = merged_budget
5759
5963
  # Final validation against the merged block.
5760
5964
  # _BudgetConfigError → 400 (no partial write — save_config
@@ -5861,6 +6065,21 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5861
6065
  _cctally()._reconcile_project_budget_milestones_on_write(
5862
6066
  validated_budget
5863
6067
  )
6068
+ # Codex actual-spend reconcile (#134). Key it on the ``alerts_enabled``
6069
+ # SUB-leaf specifically — NOT ``"codex" in touched``. The helper
6070
+ # (`_reconcile_codex_budget_on_config_write`) is itself gated on
6071
+ # alerts_enabled && amount && thresholds, so a coarse "codex touched"
6072
+ # check would run it whenever alerts is already True — meaning a
6073
+ # ``projected_enabled``-only toggle would latch (silently suppress) a
6074
+ # still-unfired actual-spend crossing. Keying on the sub-leaf means
6075
+ # flipping alerts ON latches already-crossed thresholds (intended),
6076
+ # while toggling projected reconciles nothing (projected stays
6077
+ # live-pace, Q4).
6078
+ codex_in = budget_in.get("codex")
6079
+ if isinstance(codex_in, dict) and "alerts_enabled" in codex_in:
6080
+ _cctally()._reconcile_codex_budget_on_config_write(
6081
+ validated_budget
6082
+ )
5864
6083
  if update_check_validated is not None:
5865
6084
  # Echo the full merged check block (cooked defaults included)
5866
6085
  # so the SettingsOverlay can repaint without a follow-up GET.
@@ -5959,25 +6178,30 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5959
6178
  axis = body.get("axis", "weekly")
5960
6179
  if axis not in (
5961
6180
  "weekly", "five_hour", "budget", "projected", "project_budget",
6181
+ "codex_budget",
5962
6182
  ):
5963
6183
  self._respond_json(
5964
6184
  400,
5965
6185
  {"error": (
5966
6186
  "axis must be 'weekly', 'five_hour', 'budget', "
5967
- f"'projected' or 'project_budget', got {axis!r}"
6187
+ "'projected', 'project_budget' or 'codex_budget', "
6188
+ f"got {axis!r}"
5968
6189
  )},
5969
6190
  )
5970
6191
  return
5971
6192
  # ``metric`` discriminates the projected axis (weekly_pct vs
5972
- # budget_usd); the other axes ignore it. Validate only when it
5973
- # matters so a stray metric on a weekly/budget test isn't a 400.
6193
+ # budget_usd vs codex_budget_usd); the other axes ignore it. Validate
6194
+ # only when it matters so a stray metric on a weekly/budget test isn't
6195
+ # a 400.
5974
6196
  metric = body.get("metric", "weekly_pct")
5975
- if axis == "projected" and metric not in ("weekly_pct", "budget_usd"):
6197
+ if axis == "projected" and metric not in (
6198
+ "weekly_pct", "budget_usd", "codex_budget_usd",
6199
+ ):
5976
6200
  self._respond_json(
5977
6201
  400,
5978
6202
  {"error": (
5979
- "metric must be 'weekly_pct' or 'budget_usd', "
5980
- f"got {metric!r}"
6203
+ "metric must be 'weekly_pct', 'budget_usd' or "
6204
+ f"'codex_budget_usd', got {metric!r}"
5981
6205
  )},
5982
6206
  )
5983
6207
  return
@@ -6031,6 +6255,22 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6031
6255
  spent_usd=26.0,
6032
6256
  consumption_pct=104.0,
6033
6257
  )
6258
+ elif axis == "codex_budget":
6259
+ # Synthetic Codex budget payload — mirrors the CLI
6260
+ # ``alerts test --axis codex-budget`` branch (NO DB writes,
6261
+ # test/real divergence contract, NO real budget.codex entry
6262
+ # required). A $200 calendar-month budget reads plausibly; spent
6263
+ # scaled to the threshold so the body line reads as the
6264
+ # at-crossing snapshot the dashboard would render (R4).
6265
+ payload = _build_alert_payload_codex_budget(
6266
+ threshold=threshold,
6267
+ crossed_at_utc=now_utc_iso(),
6268
+ period_start_at=dt.date.today().replace(day=1).isoformat(),
6269
+ period="calendar-month",
6270
+ budget_usd=200.0,
6271
+ spent_usd=200.0 * threshold / 100.0,
6272
+ consumption_pct=float(threshold),
6273
+ )
6034
6274
  elif axis == "projected":
6035
6275
  # Synthetic projected-pace payload — mirrors the CLI
6036
6276
  # cmd_alerts_test projected branch (NO DB writes, test/real
@@ -6039,10 +6279,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6039
6279
  # value (so the body reads plausibly, e.g. weekly 100% → "~100% of
6040
6280
  # cap", budget 100% → "$300 of $300"). denominator is the
6041
6281
  # at-crossing target the row would carry (Codex P0-4): 100.0 for
6042
- # weekly_pct, $300 for budget_usd.
6282
+ # weekly_pct, $300 for budget_usd, $200 for codex_budget_usd
6283
+ # (matching the codex_budget axis test-alert budget).
6043
6284
  if metric == "budget_usd":
6044
6285
  denominator = 300.0
6045
6286
  projected_value = 300.0 * threshold / 100.0
6287
+ elif metric == "codex_budget_usd":
6288
+ denominator = 200.0
6289
+ projected_value = 200.0 * threshold / 100.0
6046
6290
  else: # weekly_pct
6047
6291
  denominator = 100.0
6048
6292
  projected_value = float(threshold)
@@ -6901,6 +7145,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6901
7145
  display_tz_pref_override=type(self).display_tz_pref_override,
6902
7146
  runtime_bind=type(self).cctally_host,
6903
7147
  )
7148
+ # Conversation viewer (Plan 2, spec §5): inject the client signal
7149
+ # PER-REQUEST and Host-aware — NOT inside snapshot_to_envelope (the
7150
+ # request-independent SSE snapshot has no Host header). Routes through
7151
+ # the SAME predicate as the transcript GET-route gate so a LAN-hostname
7152
+ # request that the transcript GETs would 403 shows
7153
+ # transcriptsEnabled=false, never enabled-then-403 (the pass-2 P2
7154
+ # finding) — one predicate, two consumers, desync impossible.
7155
+ env["transcriptsEnabled"] = self._transcripts_visible_to_request()
6904
7156
  body = json.dumps(env, ensure_ascii=False).encode("utf-8")
6905
7157
  self.send_response(200)
6906
7158
  self.send_header("Content-Type", "application/json; charset=utf-8")
@@ -6990,6 +7242,108 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6990
7242
  self.end_headers()
6991
7243
  self.wfile.write(body)
6992
7244
 
7245
+ @staticmethod
7246
+ def _conversation_query():
7247
+ """Lazy-load the pure conversation query kernel (Plan 2, §3)."""
7248
+ return sys.modules["cctally"]._load_sibling("_lib_conversation_query")
7249
+
7250
+ def _handle_get_conversations(self) -> None:
7251
+ """``GET /api/conversations`` — the browse rail (spec §3.1).
7252
+
7253
+ Gated first (loopback / Host allowlist). ``sort``/``limit``/``offset``
7254
+ are read from the query string; the kernel clamps bounds. Cache-open
7255
+ failures are 500s, never 5xx-with-stacktrace.
7256
+ """
7257
+ if not self._require_transcripts_allowed():
7258
+ return
7259
+ import urllib.parse as _u
7260
+ q = _u.parse_qs(self.path.partition("?")[2])
7261
+ sort = (q.get("sort", ["recent"]) or ["recent"])[0]
7262
+ limit = _qs_int(q, "limit", 50)
7263
+ offset = _qs_int(q, "offset", 0)
7264
+ try:
7265
+ conn = open_cache_db()
7266
+ except (sqlite3.DatabaseError, OSError) as exc:
7267
+ self._respond_json(500, {"error": f"cache unavailable: {exc}"})
7268
+ return
7269
+ try:
7270
+ body = self._conversation_query().list_conversations(
7271
+ conn, sort=sort, limit=limit, offset=offset)
7272
+ except Exception as exc: # noqa: BLE001
7273
+ self.log_error("/api/conversations failed: %r", exc)
7274
+ self._respond_json(500, {"error": f"{type(exc).__name__}: {exc}"})
7275
+ return
7276
+ finally:
7277
+ conn.close()
7278
+ self._respond_json(200, body)
7279
+
7280
+ def _handle_get_conversation_detail(self, path: str) -> None:
7281
+ """``GET /api/conversation/<session-id>`` — the reader (spec §3.2).
7282
+
7283
+ The id is percent-decoded so clients that encode reserved chars
7284
+ round-trip. Unknown id → 404. ``after``/``limit`` page the items.
7285
+ """
7286
+ if not self._require_transcripts_allowed():
7287
+ return
7288
+ import urllib.parse as _u
7289
+ # ``path`` is already query-stripped by ``do_GET`` (``self.path.split("?")``),
7290
+ # so the cursor params (?after=/?limit=) live ONLY on the raw ``self.path``.
7291
+ # Sibling handlers read ``self.path`` directly — the detail route must too,
7292
+ # or every request re-serves the head and pagination is dead.
7293
+ query_str = self.path.partition("?")[2]
7294
+ session_id = _u.unquote(path[len("/api/conversation/"):])
7295
+ if not session_id:
7296
+ self.send_error(404, "conversation not found")
7297
+ return
7298
+ q = _u.parse_qs(query_str)
7299
+ after = (q.get("after", [None]) or [None])[0]
7300
+ limit = _qs_int(q, "limit", 500)
7301
+ try:
7302
+ conn = open_cache_db()
7303
+ except (sqlite3.DatabaseError, OSError) as exc:
7304
+ self._respond_json(500, {"error": f"cache unavailable: {exc}"})
7305
+ return
7306
+ try:
7307
+ body = self._conversation_query().get_conversation(
7308
+ conn, session_id, after=after, limit=limit)
7309
+ except Exception as exc: # noqa: BLE001
7310
+ self.log_error("/api/conversation failed: %r", exc)
7311
+ self._respond_json(500, {"error": f"{type(exc).__name__}: {exc}"})
7312
+ return
7313
+ finally:
7314
+ conn.close()
7315
+ if body is None:
7316
+ self.send_error(404, "conversation not found")
7317
+ return
7318
+ self._respond_json(200, body)
7319
+
7320
+ def _handle_get_conversation_search(self) -> None:
7321
+ """``GET /api/conversation/search?q=...`` — cross-session FTS/LIKE
7322
+ search (spec §3.3). Matched BEFORE the ``<id>`` reader in ``do_GET``.
7323
+ """
7324
+ if not self._require_transcripts_allowed():
7325
+ return
7326
+ import urllib.parse as _u
7327
+ q = _u.parse_qs(self.path.partition("?")[2])
7328
+ query = (q.get("q", [""]) or [""])[0]
7329
+ limit = _qs_int(q, "limit", 50)
7330
+ offset = _qs_int(q, "offset", 0)
7331
+ try:
7332
+ conn = open_cache_db()
7333
+ except (sqlite3.DatabaseError, OSError) as exc:
7334
+ self._respond_json(500, {"error": f"cache unavailable: {exc}"})
7335
+ return
7336
+ try:
7337
+ body = self._conversation_query().search_conversations(
7338
+ conn, query, limit=limit, offset=offset)
7339
+ except Exception as exc: # noqa: BLE001
7340
+ self.log_error("/api/conversation/search failed: %r", exc)
7341
+ self._respond_json(500, {"error": f"{type(exc).__name__}: {exc}"})
7342
+ return
7343
+ finally:
7344
+ conn.close()
7345
+ self._respond_json(200, body)
7346
+
6993
7347
  def _handle_get_project_detail(self) -> None:
6994
7348
  """Return ProjectDetail JSON for ``GET /api/project/<key>?weeks=N``
6995
7349
  (spec §5.3 / §6.5).
@@ -7515,6 +7869,14 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
7515
7869
  # in the doctor SSE block + /api/doctor reflects the actual --host
7516
7870
  # the process is serving, not just the config-only view the CLI sees.
7517
7871
  DashboardHTTPHandler.cctally_host = args.host
7872
+ # Conversation viewer (Plan 2, spec §5): the resolved
7873
+ # `dashboard.expose_transcripts` opt-in. Read off the already-loaded
7874
+ # `config` the same way `dashboard.bind` is resolved above (the
7875
+ # `_config_known_value` shim surfaces the boolean default of False for
7876
+ # an absent or hand-edited-junk value).
7877
+ DashboardHTTPHandler.cctally_expose_transcripts = bool(
7878
+ _config_known_value(config, "dashboard.expose_transcripts")
7879
+ )
7518
7880
  DashboardHTTPHandler.run_sync_now = staticmethod(
7519
7881
  lambda: _run_sync_now(skip_sync=args.no_sync)
7520
7882
  )