cctally 1.68.0 → 1.69.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 (40) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/bin/_cctally_alerts.py +54 -2
  3. package/bin/_cctally_cache.py +644 -107
  4. package/bin/_cctally_cache_report.py +41 -17
  5. package/bin/_cctally_codex.py +109 -4
  6. package/bin/_cctally_config.py +368 -2
  7. package/bin/_cctally_core.py +168 -5
  8. package/bin/_cctally_dashboard.py +679 -5
  9. package/bin/_cctally_dashboard_conversation.py +9 -4
  10. package/bin/_cctally_dashboard_envelope.py +110 -1
  11. package/bin/_cctally_dashboard_share.py +557 -79
  12. package/bin/_cctally_db.py +303 -17
  13. package/bin/_cctally_diff.py +49 -16
  14. package/bin/_cctally_doctor.py +118 -0
  15. package/bin/_cctally_forecast.py +12 -4
  16. package/bin/_cctally_parser.py +298 -92
  17. package/bin/_cctally_project.py +24 -8
  18. package/bin/_cctally_quota.py +1381 -0
  19. package/bin/_cctally_record.py +319 -14
  20. package/bin/_cctally_refresh.py +105 -3
  21. package/bin/_cctally_reporting.py +7 -1
  22. package/bin/_cctally_setup.py +343 -113
  23. package/bin/_cctally_statusline.py +228 -0
  24. package/bin/_cctally_tui.py +787 -7
  25. package/bin/_lib_alert_axes.py +11 -0
  26. package/bin/_lib_codex_hooks.py +367 -0
  27. package/bin/_lib_conversation_query.py +156 -67
  28. package/bin/_lib_doctor.py +202 -0
  29. package/bin/_lib_jsonl.py +529 -162
  30. package/bin/_lib_quota.py +566 -0
  31. package/bin/_lib_share.py +152 -34
  32. package/bin/_lib_snapshot_cache.py +26 -0
  33. package/bin/_lib_source_identity.py +74 -0
  34. package/bin/cctally +324 -10
  35. package/dashboard/static/assets/index-CBbErI-P.js +80 -0
  36. package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
  37. package/dashboard/static/dashboard.html +2 -2
  38. package/package.json +5 -1
  39. package/dashboard/static/assets/index-BybNp_Di.js +0 -80
  40. package/dashboard/static/assets/index-DgAmLK65.css +0 -1
@@ -38,7 +38,9 @@ import json
38
38
  import pathlib
39
39
  import sqlite3
40
40
  import sys
41
+ from collections.abc import Mapping
41
42
  from dataclasses import replace
43
+ from types import SimpleNamespace
42
44
  from zoneinfo import ZoneInfo
43
45
 
44
46
  from _cctally_core import open_db, parse_iso_datetime
@@ -1254,6 +1256,405 @@ def _share_load_templates_module_impl(handler):
1254
1256
  raise
1255
1257
  return mod
1256
1258
 
1259
+
1260
+ def _share_source_selection(req: dict) -> tuple[str, bool]:
1261
+ """Resolve S4's optional source field without changing legacy requests."""
1262
+ explicit = "source" in req
1263
+ source = req.get("source", "claude")
1264
+ if source not in ("claude", "codex", "all"):
1265
+ raise ValueError("source capability unavailable")
1266
+ return source, explicit
1267
+
1268
+
1269
+ def _source_state_for_share(data_snap, source: str):
1270
+ try:
1271
+ bundle = data_snap.source_bundle
1272
+ return bundle.sources[source]
1273
+ except (AttributeError, KeyError, TypeError) as exc:
1274
+ raise ValueError("source capability unavailable") from exc
1275
+
1276
+
1277
+ def _share_codex_range_start(panel: str, now_utc: "dt.datetime",
1278
+ custom_start: "dt.datetime | None") -> "dt.datetime":
1279
+ """Return the bounded cache range used for a non-current Codex share.
1280
+
1281
+ The native source projection has no hidden live-current fallback: every
1282
+ range is derived from the requested panel period and the source builder is
1283
+ called with ``sync=False`` by construction. Keep these spans aligned with
1284
+ the legacy dashboard builders' visible windows.
1285
+ """
1286
+ if custom_start is not None:
1287
+ return custom_start
1288
+ if panel == "daily":
1289
+ return now_utc - dt.timedelta(days=31) # 30 rows plus boundary slack
1290
+ if panel == "monthly":
1291
+ year, month = now_utc.year, now_utc.month
1292
+ for _ in range(11):
1293
+ month -= 1
1294
+ if month == 0:
1295
+ year, month = year - 1, 12
1296
+ return dt.datetime(year, month, 1, tzinfo=dt.timezone.utc)
1297
+ if panel == "weekly":
1298
+ return now_utc - dt.timedelta(days=7 * 13)
1299
+ if panel == "blocks":
1300
+ return now_utc - dt.timedelta(days=7)
1301
+ return now_utc - dt.timedelta(days=30)
1302
+
1303
+
1304
+ def _share_codex_state_for_period(data_snap, *, panel: str, options: dict):
1305
+ """Return the selected Codex state, rebuilding non-current requests safely.
1306
+
1307
+ Dashboard snapshots intentionally contain the live/current source bundle.
1308
+ Share period overrides rebuild their legacy Claude panel fields, but using
1309
+ that unchanged bundle for Codex would mislabel current provider data as a
1310
+ past/custom export. Rebuild only the selected Codex read model over the
1311
+ requested bounded range; its source adapters are cache/stats readers and
1312
+ use ``sync=False`` internally. The resulting state is request-local and
1313
+ never replaces the published snapshot.
1314
+ """
1315
+ now_override, start_override, err = _share_resolve_period(panel, options)
1316
+ if err is not None:
1317
+ raise ValueError("source capability unavailable")
1318
+ if now_override is None:
1319
+ return _source_state_for_share(data_snap, "codex")
1320
+
1321
+ from _cctally_cache import open_cache_db
1322
+ from _cctally_dashboard_sources import (
1323
+ DashboardReadContext,
1324
+ build_codex_source_state,
1325
+ resolve_dashboard_source_semantics,
1326
+ )
1327
+
1328
+ range_start = _share_codex_range_start(panel, now_override, start_override)
1329
+ config = sys.modules["cctally"].load_config()
1330
+ display_tz_name = options.get("display_tz")
1331
+ if display_tz_name == "utc":
1332
+ display_tz_name = "UTC"
1333
+ elif display_tz_name == "local" or not isinstance(display_tz_name, str):
1334
+ display_tz_name = None
1335
+ semantics = resolve_dashboard_source_semantics(
1336
+ config, display_tz_name=display_tz_name,
1337
+ )
1338
+ stats_conn = open_db()
1339
+ cache_conn = open_cache_db()
1340
+ try:
1341
+ return build_codex_source_state(
1342
+ DashboardReadContext(
1343
+ cache_conn=cache_conn,
1344
+ stats_conn=stats_conn,
1345
+ range_start=range_start,
1346
+ now_utc=now_override,
1347
+ display_tz_name=semantics.display_tz_name,
1348
+ week_start_idx=semantics.week_start_idx,
1349
+ week_start_name=semantics.week_start_name,
1350
+ speed=semantics.speed,
1351
+ codex_budget=semantics.codex_budget,
1352
+ ),
1353
+ data_version=(
1354
+ f"share:codex:{panel}:{range_start.isoformat()}:"
1355
+ f"{now_override.isoformat()}:{semantics.identity}"
1356
+ ),
1357
+ )
1358
+ finally:
1359
+ cache_conn.close()
1360
+ stats_conn.close()
1361
+
1362
+
1363
+ def _share_parse_bucket_start(panel: str, label: object) -> "dt.datetime | None":
1364
+ try:
1365
+ if panel == "monthly":
1366
+ return dt.datetime.strptime(str(label), "%Y-%m").replace(tzinfo=dt.timezone.utc)
1367
+ return dt.datetime.fromisoformat(str(label)).replace(tzinfo=dt.timezone.utc)
1368
+ except ValueError:
1369
+ return None
1370
+
1371
+
1372
+ def _share_codex_period_bounds(*, state, panel: str, options: dict, rows) -> tuple:
1373
+ now_override, start_override, err = _share_resolve_period(panel, options)
1374
+ if err is not None:
1375
+ raise ValueError("source capability unavailable")
1376
+ end = now_override or state.last_success_at or dt.datetime.now(dt.timezone.utc)
1377
+ if end.tzinfo is None or end.utcoffset() is None:
1378
+ end = end.replace(tzinfo=dt.timezone.utc)
1379
+ end = end.astimezone(dt.timezone.utc)
1380
+ if start_override is not None:
1381
+ return start_override.astimezone(dt.timezone.utc), end
1382
+ starts = []
1383
+ for row in rows:
1384
+ if not isinstance(row, Mapping):
1385
+ continue
1386
+ raw = row.get("first_seen") or row.get("last_activity") or row.get("label")
1387
+ parsed = _share_parse_bucket_start("monthly" if panel == "monthly" else "daily", raw)
1388
+ if parsed is not None:
1389
+ starts.append(parsed)
1390
+ if panel == "current-week":
1391
+ starts = [
1392
+ parsed for row in rows if isinstance(row, Mapping)
1393
+ if (parsed := _share_parse_bucket_start("weekly", row.get("label"))) is not None
1394
+ ]
1395
+ return (max(starts) if starts else end - dt.timedelta(days=7)), end
1396
+ return (min(starts) if starts else end), end
1397
+
1398
+
1399
+ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
1400
+ template_id: str, options: dict):
1401
+ """Adapt S4 normalized data through canonical Codex share kernels."""
1402
+ data = state.data
1403
+ if not isinstance(data, Mapping) or state.availability == "unavailable":
1404
+ raise ValueError("source capability unavailable")
1405
+ required_domain = {
1406
+ "current-week": "hero",
1407
+ "daily": "periods",
1408
+ "monthly": "periods",
1409
+ "weekly": "periods",
1410
+ "blocks": "quota",
1411
+ "sessions": "sessions",
1412
+ "projects": "projects",
1413
+ }.get(panel)
1414
+ if required_domain is None or required_domain not in data:
1415
+ raise ValueError("source capability unavailable")
1416
+ availability = state.availability if state.availability in ("ok", "empty") else "unavailable"
1417
+ reason = "source data unavailable" if availability == "unavailable" else None
1418
+ hero = data.get("hero") if isinstance(data.get("hero"), Mapping) else {}
1419
+ if panel == "current-week":
1420
+ periods = data.get("periods")
1421
+ weekly = periods.get("weekly") if isinstance(periods, Mapping) else None
1422
+ if not isinstance(weekly, Mapping):
1423
+ raise ValueError("source capability unavailable")
1424
+ all_rows = tuple(weekly.get("rows", ()))
1425
+ source_rows = all_rows[-1:] if all_rows else ()
1426
+ command = "codex-weekly"
1427
+ display_tz = str(weekly.get("display_tz") or "UTC")
1428
+ elif panel in ("daily", "monthly", "weekly"):
1429
+ periods = data.get("periods")
1430
+ panel_data = periods.get(panel) if isinstance(periods, Mapping) else {}
1431
+ if not isinstance(panel_data, Mapping):
1432
+ raise ValueError("source capability unavailable")
1433
+ source_rows = tuple(panel_data.get("rows", ()))
1434
+ command = f"codex-{panel}"
1435
+ display_tz = str(panel_data.get("display_tz") or "UTC")
1436
+ elif panel == "sessions":
1437
+ panel_data = data.get(panel) if isinstance(data.get(panel), Mapping) else {}
1438
+ source_rows = tuple(panel_data.get("rows", ())) if isinstance(panel_data, Mapping) else ()
1439
+ command = "codex-session"
1440
+ display_tz = "UTC"
1441
+ elif panel == "projects":
1442
+ panel_data = data.get("projects") if isinstance(data.get("projects"), Mapping) else {}
1443
+ source_rows = tuple(panel_data.get("rows", ())) if isinstance(panel_data, Mapping) else ()
1444
+ start, end = _share_codex_period_bounds(
1445
+ state=state, panel=panel, options=options, rows=source_rows,
1446
+ )
1447
+ rows = tuple(ls.Row(cells={
1448
+ "project": ls.ProjectCell(
1449
+ str(row.get("label", "Project")),
1450
+ float(row.get("cost_usd", 0.0) or 0.0),
1451
+ identity=str(row.get("key")),
1452
+ ),
1453
+ "tokens": ls.TextCell(f"{int(row.get('total_tokens', 0) or 0):,}"),
1454
+ "cost": ls.MoneyCell(float(row.get("cost_usd", 0.0) or 0.0)),
1455
+ }) for row in source_rows if isinstance(row, Mapping))
1456
+ return ls.ShareSnapshot(
1457
+ cmd="project", title="Codex Project Usage", subtitle=None,
1458
+ period=ls.PeriodSpec(start=start, end=end, display_tz="UTC", label=None),
1459
+ columns=(
1460
+ ls.ColumnSpec(key="project", label="Project"),
1461
+ ls.ColumnSpec(key="tokens", label="Tokens", align="right"),
1462
+ ls.ColumnSpec(key="cost", label="$ Cost", align="right"),
1463
+ ),
1464
+ rows=rows, chart=None,
1465
+ totals=(ls.Totalled(label="Total", value=f"${float(panel_data.get('total_cost_usd', 0.0) or 0.0):,.2f}"),),
1466
+ notes=(), generated_at=end, version=sys.modules["cctally"]._share_resolve_version(),
1467
+ template_id=template_id, source="codex", source_label="Codex",
1468
+ availability=availability, availability_reason=reason,
1469
+ )
1470
+ else: # blocks
1471
+ quota = data.get("quota")
1472
+ panel_data = quota if isinstance(quota, Mapping) else {}
1473
+ source_rows = tuple(panel_data.get("blocks", ())) if isinstance(panel_data, Mapping) else ()
1474
+ start, end = _share_codex_period_bounds(
1475
+ state=state, panel=panel, options=options, rows=source_rows,
1476
+ )
1477
+ columns = (
1478
+ ls.ColumnSpec(key="label", label="Quota", align="left"),
1479
+ ls.ColumnSpec(key="usage", label="Usage", align="right"),
1480
+ ls.ColumnSpec(key="resets", label="Resets", align="right"),
1481
+ )
1482
+ def cells(row):
1483
+ percent = row.get("current_percent", 0.0)
1484
+ return {
1485
+ "label": ls.TextCell(str(row.get("label", "Codex quota"))),
1486
+ "usage": ls.TextCell(f"{float(percent or 0.0):.1f}%"),
1487
+ "resets": ls.TextCell(str(row.get("resets_at", "—"))),
1488
+ }
1489
+ rows = tuple(ls.Row(cells=cells(row)) for row in source_rows if isinstance(row, Mapping))
1490
+ return ls.ShareSnapshot(
1491
+ cmd="codex-quota", title="Codex Quota Windows", subtitle=None,
1492
+ period=ls.PeriodSpec(start=start, end=end, display_tz="UTC", label=None),
1493
+ columns=columns, rows=rows, chart=None,
1494
+ totals=(), notes=(), generated_at=end,
1495
+ version=sys.modules["cctally"]._share_resolve_version(),
1496
+ template_id=template_id, source="codex", source_label="Codex",
1497
+ availability=availability, availability_reason=reason,
1498
+ )
1499
+
1500
+ start, end = _share_codex_period_bounds(
1501
+ state=state, panel=panel, options=options, rows=source_rows,
1502
+ )
1503
+ normalized_rows = tuple(
1504
+ SimpleNamespace(
1505
+ bucket=str(row.get("label", "—")),
1506
+ total_tokens=int(row.get("total_tokens", 0) or 0),
1507
+ cost_usd=float(row.get("cost_usd", 0.0) or 0.0),
1508
+ last_activity=parse_iso_datetime(str(row.get("last_activity")), "codex.session.last_activity")
1509
+ if command == "codex-session" else None,
1510
+ )
1511
+ for row in source_rows if isinstance(row, Mapping)
1512
+ )
1513
+ view = SimpleNamespace(
1514
+ rows=normalized_rows,
1515
+ total_cost_usd=stable_sum(row.cost_usd for row in normalized_rows),
1516
+ total_tokens=sum(row.total_tokens for row in normalized_rows),
1517
+ period_start=start,
1518
+ period_end=end,
1519
+ display_tz_label=display_tz,
1520
+ )
1521
+ codex_module = sys.modules["cctally"]._load_sibling("_cctally_codex")
1522
+ return replace(
1523
+ codex_module._build_codex_share_snapshot(command, view, normalized_rows),
1524
+ template_id=template_id,
1525
+ )
1526
+
1527
+
1528
+ def _share_plain_value(value):
1529
+ if isinstance(value, Mapping):
1530
+ return {str(key): _share_plain_value(item) for key, item in value.items()}
1531
+ if isinstance(value, (tuple, list)):
1532
+ return [_share_plain_value(item) for item in value]
1533
+ if isinstance(value, dt.datetime):
1534
+ return value.astimezone(dt.timezone.utc).isoformat()
1535
+ return value
1536
+
1537
+
1538
+ def _share_state_domain(state, panel: str):
1539
+ data = state.data if isinstance(state.data, Mapping) else {}
1540
+ if panel in ("daily", "monthly", "weekly", "current-week"):
1541
+ periods = data.get("periods") if isinstance(data.get("periods"), Mapping) else {}
1542
+ key = "weekly" if panel == "current-week" else panel
1543
+ return periods.get(key)
1544
+ if panel == "blocks":
1545
+ return data.get("quota")
1546
+ return data.get(panel)
1547
+
1548
+
1549
+ def _share_digest_input(*, panel: str, template_id: str, source: str,
1550
+ source_explicit: bool, states, snapshots,
1551
+ panel_data):
1552
+ if not source_explicit:
1553
+ return {
1554
+ "panel": panel,
1555
+ "template_id": template_id,
1556
+ "panel_data": panel_data,
1557
+ }
1558
+ providers = []
1559
+ for state, snapshot in zip(states, snapshots):
1560
+ providers.append({
1561
+ "source": state.source,
1562
+ "data_version": state.data_version,
1563
+ "availability": state.availability,
1564
+ "period": {
1565
+ "start": snapshot.period.start,
1566
+ "end": snapshot.period.end,
1567
+ "display_tz": snapshot.period.display_tz,
1568
+ },
1569
+ "data": _share_plain_value(_share_state_domain(state, panel)),
1570
+ })
1571
+ return {
1572
+ "panel": panel,
1573
+ "template_id": template_id,
1574
+ "source": source,
1575
+ "providers": providers,
1576
+ **(
1577
+ {"claude_panel_data": panel_data}
1578
+ if source in ("claude", "all") else {}
1579
+ ),
1580
+ }
1581
+
1582
+
1583
+ def _share_build_source_snapshots(*, ls, template, template_id: str,
1584
+ panel: str, options: dict, source: str,
1585
+ source_explicit: bool, data_snap):
1586
+ """Branch by provider before invoking any provider-specific builder."""
1587
+ claude_snapshot = None
1588
+ claude_state = None
1589
+ panel_data = None
1590
+ if source in ("claude", "all"):
1591
+ claude_data_snap, period_err = _share_apply_period_override(
1592
+ panel, options, data_snap,
1593
+ )
1594
+ if period_err is not None:
1595
+ raise _SharePeriodError(period_err)
1596
+ panel_data = _build_share_panel_data(panel, options, claude_data_snap)
1597
+ claude_snapshot = replace(
1598
+ template.builder(panel_data=panel_data, options=options),
1599
+ template_id=template_id,
1600
+ )
1601
+ if source_explicit or source == "all":
1602
+ claude_snapshot = replace(
1603
+ claude_snapshot, source="claude", source_label="Claude",
1604
+ )
1605
+ # A source-less request is the shipped legacy Claude contract. It
1606
+ # must remain usable by callers whose synthetic/older DataSnapshot
1607
+ # does not carry the additive S4 source bundle.
1608
+ if source_explicit or source == "all":
1609
+ claude_state = _source_state_for_share(data_snap, "claude")
1610
+
1611
+ codex_snapshot = None
1612
+ codex_state = None
1613
+ if source in ("codex", "all"):
1614
+ codex_state = _share_codex_state_for_period(
1615
+ data_snap, panel=panel, options=options,
1616
+ )
1617
+ codex_snapshot = _build_codex_source_share_snapshot(
1618
+ ls,
1619
+ state=codex_state,
1620
+ panel=panel,
1621
+ template_id=template_id,
1622
+ options=options,
1623
+ )
1624
+
1625
+ if source == "claude":
1626
+ return (claude_snapshot,), (claude_state,), panel_data
1627
+ if source == "codex":
1628
+ return (codex_snapshot,), (codex_state,), None
1629
+ return (
1630
+ (claude_snapshot, codex_snapshot),
1631
+ (claude_state, codex_state),
1632
+ panel_data,
1633
+ )
1634
+
1635
+
1636
+ class _SharePeriodError(ValueError):
1637
+ """Carry the established period-validation envelope across dispatch."""
1638
+
1639
+ def __init__(self, payload: Mapping):
1640
+ super().__init__(str(payload.get("error", "invalid period")))
1641
+ self.payload = dict(payload)
1642
+
1643
+
1644
+ def _share_public_failure(handler, exc: Exception, *, phase: str,
1645
+ capability: bool = False) -> None:
1646
+ handler.log_error("/api/share/%s failed: %r", phase, exc)
1647
+ if capability:
1648
+ handler._respond_json(400, {
1649
+ "code": "source_capability_unavailable",
1650
+ "error": "source capability unavailable",
1651
+ })
1652
+ else:
1653
+ handler._respond_json(500, {
1654
+ "code": "source_render_failed",
1655
+ "error": "source render failed",
1656
+ })
1657
+
1257
1658
  def _handle_share_templates_get_impl(handler) -> None:
1258
1659
  """List share templates registered for the requested panel.
1259
1660
 
@@ -1325,6 +1726,14 @@ def _handle_share_render_post_impl(handler) -> None:
1325
1726
  if not isinstance(req, dict):
1326
1727
  handler._respond_json(400, {"error": "expected JSON object"})
1327
1728
  return
1729
+ try:
1730
+ source, source_explicit = _share_source_selection(req)
1731
+ except ValueError:
1732
+ handler._respond_json(400, {
1733
+ "code": "source_capability_unavailable",
1734
+ "error": "source capability unavailable",
1735
+ })
1736
+ return
1328
1737
  panel = req.get("panel")
1329
1738
  template_id = req.get("template_id")
1330
1739
  options = req.get("options") or {}
@@ -1405,53 +1814,60 @@ def _handle_share_render_post_impl(handler) -> None:
1405
1814
  })
1406
1815
  return
1407
1816
 
1408
- # Build panel_data from the live dashboard snapshot — reuses the
1409
- # already-built `DataSnapshot` so we don't re-query the DB on the
1410
- # share hot path. `_build_share_panel_data` dispatches per panel.
1411
1817
  snap_ref = type(handler).snapshot_ref
1412
1818
  data_snap = snap_ref.get() if snap_ref is not None else None
1413
- # Period override (current / previous / custom). For
1414
- # `kind='current'` (the default) this is a no-op; otherwise we
1415
- # re-build the relevant panel's DataSnapshot field from DB with
1416
- # a shifted `now_utc` before slicing.
1417
- data_snap, period_err = _share_apply_period_override(panel, options,
1418
- data_snap)
1419
- if period_err is not None:
1420
- handler._respond_json(400, period_err)
1421
- return
1422
- try:
1423
- panel_data = _build_share_panel_data(panel, options, data_snap)
1424
- except Exception as exc:
1425
- handler._respond_json(500, {"error": f"panel_data build failed: {exc}"})
1426
- return
1427
-
1428
- # Run template builder → kernel render. Builder produces a
1429
- # ShareSnapshot; `_scrub` anonymizes project labels when the
1430
- # client opted in to anon-on-export (`reveal_projects=False`).
1431
1819
  ls = _share_load_lib()
1432
1820
  try:
1433
- snap_built = template.builder(panel_data=panel_data, options=options)
1821
+ source_snaps, source_states, panel_data = _share_build_source_snapshots(
1822
+ ls=ls,
1823
+ template=template,
1824
+ template_id=template_id,
1825
+ panel=panel,
1826
+ options=options,
1827
+ source=source,
1828
+ source_explicit=source_explicit,
1829
+ data_snap=data_snap,
1830
+ )
1831
+ except _SharePeriodError as exc:
1832
+ handler._respond_json(400, exc.payload)
1833
+ return
1834
+ except ValueError as exc:
1835
+ _share_public_failure(handler, exc, phase="render provider", capability=True)
1836
+ return
1434
1837
  except Exception as exc:
1435
- handler._respond_json(500, {"error": f"builder failed: {exc}"})
1838
+ _share_public_failure(handler, exc, phase="render provider")
1436
1839
  return
1437
- snap_built = replace(snap_built, template_id=template_id)
1438
- # Content toggles (spec §Q4). Defaults match the existing
1439
- # behavior (chart on, table on); explicit False strips the
1440
- # corresponding section from the ShareSnapshot. ShareSnapshot
1441
- # is frozen so we use dataclasses.replace.
1442
- snap_built = _share_apply_content_toggles(snap_built, options)
1840
+ source_snaps = tuple(
1841
+ _share_apply_content_toggles(item, options) for item in source_snaps
1842
+ )
1443
1843
  reveal = bool(options.get("reveal_projects", True))
1444
1844
  if not reveal:
1445
- snap_built = ls._scrub(snap_built, reveal_projects=False)
1446
- try:
1447
- body = ls.render(
1448
- snap_built,
1449
- format=fmt,
1450
- theme=options.get("theme", "light"),
1451
- branding=not options.get("no_branding", False),
1845
+ source_snaps = tuple(
1846
+ ls._scrub(item, reveal_projects=False) for item in source_snaps
1452
1847
  )
1848
+ try:
1849
+ if source == "all":
1850
+ body = ls.compose(
1851
+ tuple(
1852
+ ls.ComposedSection(snap=item, drift_detected=False)
1853
+ for item in source_snaps
1854
+ ),
1855
+ opts=ls.ComposeOptions(
1856
+ title=f"Claude + Codex {panel.replace('-', ' ').title()}",
1857
+ theme=options.get("theme", "light"), format=fmt,
1858
+ no_branding=bool(options.get("no_branding", False)),
1859
+ reveal_projects=reveal,
1860
+ ),
1861
+ )
1862
+ else:
1863
+ body = ls.render(
1864
+ source_snaps[0],
1865
+ format=fmt,
1866
+ theme=options.get("theme", "light"),
1867
+ branding=not options.get("no_branding", False),
1868
+ )
1453
1869
  except Exception as exc:
1454
- handler._respond_json(500, {"error": f"render failed: {exc}"})
1870
+ _share_public_failure(handler, exc, phase="render kernel")
1455
1871
  return
1456
1872
  content_type = {
1457
1873
  "md": "text/markdown",
@@ -1465,11 +1881,15 @@ def _handle_share_render_post_impl(handler) -> None:
1465
1881
  # detect "section data has drifted since add-time" (spec §5.2 /
1466
1882
  # §7.1) — flipping anon-on-export must not register as drift, since
1467
1883
  # the underlying data is identical.
1468
- digest_input = {
1469
- "panel": panel,
1470
- "template_id": template_id,
1471
- "panel_data": panel_data,
1472
- }
1884
+ digest_input = _share_digest_input(
1885
+ panel=panel,
1886
+ template_id=template_id,
1887
+ source=source,
1888
+ source_explicit=source_explicit,
1889
+ states=source_states,
1890
+ snapshots=source_snaps,
1891
+ panel_data=panel_data,
1892
+ )
1473
1893
  try:
1474
1894
  data_digest = ls._data_digest(digest_input)
1475
1895
  except Exception:
@@ -1488,6 +1908,7 @@ def _handle_share_render_post_impl(handler) -> None:
1488
1908
  "options": options,
1489
1909
  "generated_at": _share_now_utc_iso(),
1490
1910
  "data_digest": data_digest,
1911
+ **({"source": source} if source_explicit else {}),
1491
1912
  },
1492
1913
  })
1493
1914
 
@@ -1578,6 +1999,17 @@ def _handle_share_compose_post_impl(handler) -> None:
1578
1999
  template_id = snap_recipe.get("template_id")
1579
2000
  sec_opts = snap_recipe.get("options") or {}
1580
2001
  digest_at_add = snap_recipe.get("data_digest_at_add") or ""
2002
+ try:
2003
+ source, source_explicit = _share_source_selection(
2004
+ {"source": snap_recipe["source"]}
2005
+ if "source" in snap_recipe else {}
2006
+ )
2007
+ except ValueError:
2008
+ handler._respond_json(400, {
2009
+ "code": "source_capability_unavailable",
2010
+ "error": "source capability unavailable",
2011
+ })
2012
+ return
1581
2013
  if (not isinstance(panel, str)
1582
2014
  or panel not in tpl_mod.SHARE_CAPABLE_PANELS):
1583
2015
  handler._respond_json(400, {
@@ -1613,59 +2045,71 @@ def _handle_share_compose_post_impl(handler) -> None:
1613
2045
  "theme": theme, "format": fmt,
1614
2046
  "no_branding": no_branding}
1615
2047
  composite_opts.setdefault("display_tz", composite_display_tz)
1616
- # Per-section period override — each basket item carries its
1617
- # own period recipe, independent of the composite anon flag.
1618
- sec_snap, period_err = _share_apply_period_override(
1619
- panel, composite_opts, data_snap,
1620
- )
1621
- if period_err is not None:
2048
+ try:
2049
+ source_snaps, source_states, panel_data = _share_build_source_snapshots(
2050
+ ls=ls,
2051
+ template=template,
2052
+ template_id=template_id,
2053
+ panel=panel,
2054
+ options=composite_opts,
2055
+ source=source,
2056
+ source_explicit=source_explicit,
2057
+ data_snap=data_snap,
2058
+ )
2059
+ except _SharePeriodError as exc:
1622
2060
  handler._respond_json(400, {
1623
- "error": f"sections[{idx}]: {period_err['error']}",
1624
- "field": f"sections[{idx}].snapshot.{period_err['field']}",
2061
+ "error": f"sections[{idx}]: {exc.payload['error']}",
2062
+ "field": f"sections[{idx}].snapshot.{exc.payload['field']}",
1625
2063
  })
1626
2064
  return
1627
- try:
1628
- panel_data = _build_share_panel_data(panel, composite_opts,
1629
- sec_snap)
1630
- except Exception as exc:
1631
- handler._respond_json(500, {
1632
- "error": f"sections[{idx}] panel_data build failed: {exc}",
1633
- })
2065
+ except ValueError as exc:
2066
+ _share_public_failure(
2067
+ handler, exc, phase=f"compose section {idx} provider", capability=True,
2068
+ )
1634
2069
  return
1635
- try:
1636
- snap_built = template.builder(panel_data=panel_data,
1637
- options=composite_opts)
1638
2070
  except Exception as exc:
1639
- handler._respond_json(500, {
1640
- "error": f"sections[{idx}] builder failed: {exc}",
1641
- })
2071
+ _share_public_failure(
2072
+ handler, exc, phase=f"compose section {idx} provider",
2073
+ )
1642
2074
  return
1643
- snap_built = replace(snap_built, template_id=template_id)
1644
2075
  # Same content toggles as the single-section render path.
1645
2076
  # Per-section `show_chart`/`show_table` from the basket
1646
2077
  # recipe are applied here; the composite anon flag is
1647
2078
  # already merged into composite_opts upstream.
1648
- snap_built = _share_apply_content_toggles(snap_built, composite_opts)
2079
+ source_snaps = tuple(
2080
+ _share_apply_content_toggles(item, composite_opts)
2081
+ for item in source_snaps
2082
+ )
1649
2083
  if not reveal_projects:
1650
- snap_built = ls._scrub(snap_built, reveal_projects=False)
2084
+ source_snaps = tuple(
2085
+ ls._scrub(item, reveal_projects=False) for item in source_snaps
2086
+ )
1651
2087
 
1652
2088
  # Defensive: digest is non-blocking metadata — fall back to
1653
2089
  # "" on failure rather than 500-ing the whole compose
1654
2090
  # (mirrors the render handler at bin/cctally:33402-33408).
1655
2091
  try:
1656
- digest_now = ls._data_digest({
1657
- "panel": panel,
1658
- "template_id": template_id,
1659
- "panel_data": panel_data,
1660
- })
2092
+ digest_now = ls._data_digest(_share_digest_input(
2093
+ panel=panel,
2094
+ template_id=template_id,
2095
+ source=source,
2096
+ source_explicit=source_explicit,
2097
+ states=source_states,
2098
+ snapshots=source_snaps,
2099
+ panel_data=panel_data,
2100
+ ))
1661
2101
  except Exception:
1662
2102
  digest_now = ""
1663
- composed_sections.append(ls.ComposedSection(
1664
- snap=snap_built,
1665
- drift_detected=(digest_now != digest_at_add),
1666
- ))
2103
+ composed_sections.extend(
2104
+ ls.ComposedSection(
2105
+ snap=item,
2106
+ drift_detected=(digest_now != digest_at_add),
2107
+ )
2108
+ for item in source_snaps
2109
+ )
1667
2110
  section_results.append({
1668
2111
  "snapshot_id": f"{idx:02d}",
2112
+ "source": source,
1669
2113
  "drift_detected": digest_now != digest_at_add,
1670
2114
  "data_digest_at_add": digest_at_add,
1671
2115
  "data_digest_now": digest_now,
@@ -1678,7 +2122,7 @@ def _handle_share_compose_post_impl(handler) -> None:
1678
2122
  try:
1679
2123
  body = ls.compose(tuple(composed_sections), opts=compose_opts)
1680
2124
  except Exception as exc:
1681
- handler._respond_json(500, {"error": f"compose failed: {exc}"})
2125
+ _share_public_failure(handler, exc, phase="compose kernel")
1682
2126
  return
1683
2127
 
1684
2128
  content_type = {
@@ -1720,7 +2164,17 @@ def _handle_share_presets_get_impl(handler) -> None:
1720
2164
  """
1721
2165
  cfg = sys.modules["cctally"].load_config()
1722
2166
  presets = (cfg.get("share") or {}).get("presets") or {}
1723
- handler._respond_json(200, {"presets": presets})
2167
+ # Old records predate S4. Resolve them as Claude on read without mutating
2168
+ # config (a GET must remain read-only).
2169
+ resolved = {
2170
+ panel: {
2171
+ name: ({**record, "source": record.get("source", "claude")}
2172
+ if isinstance(record, dict) else record)
2173
+ for name, record in bucket.items()
2174
+ }
2175
+ for panel, bucket in presets.items() if isinstance(bucket, dict)
2176
+ }
2177
+ handler._respond_json(200, {"presets": resolved})
1724
2178
 
1725
2179
  def _handle_share_presets_post_impl(handler) -> None:
1726
2180
  """Create or overwrite a preset (idempotent on `(panel, name)`).
@@ -1757,6 +2211,14 @@ def _handle_share_presets_post_impl(handler) -> None:
1757
2211
  name = req.get("name")
1758
2212
  template_id = req.get("template_id")
1759
2213
  options = req.get("options")
2214
+ try:
2215
+ source, _ = _share_source_selection(req)
2216
+ except ValueError:
2217
+ handler._respond_json(400, {
2218
+ "code": "source_capability_unavailable",
2219
+ "error": "source capability unavailable",
2220
+ })
2221
+ return
1760
2222
  if not isinstance(panel, str) or not panel:
1761
2223
  handler._respond_json(400, {
1762
2224
  "error": "missing or non-string panel",
@@ -1807,7 +2269,10 @@ def _handle_share_presets_post_impl(handler) -> None:
1807
2269
  return
1808
2270
 
1809
2271
  saved_at = _share_now_utc_iso()
1810
- record = {"template_id": template_id, "options": options, "saved_at": saved_at}
2272
+ record = {
2273
+ "template_id": template_id, "options": options,
2274
+ "source": source, "saved_at": saved_at,
2275
+ }
1811
2276
 
1812
2277
  with sys.modules["cctally"].config_writer_lock():
1813
2278
  cfg = _load_config_unlocked()
@@ -1878,7 +2343,11 @@ def _handle_share_history_get_impl(handler) -> None:
1878
2343
  """Return the recent-shares ring buffer (newest last, spec §11.4)."""
1879
2344
  cfg = sys.modules["cctally"].load_config()
1880
2345
  history = (cfg.get("share") or {}).get("history") or []
1881
- handler._respond_json(200, {"history": history})
2346
+ handler._respond_json(200, {"history": [
2347
+ ({**record, "source": record.get("source", "claude")}
2348
+ if isinstance(record, dict) else record)
2349
+ for record in history
2350
+ ]})
1882
2351
 
1883
2352
  def _handle_share_history_post_impl(handler) -> None:
1884
2353
  """Append a recipe to the ring buffer; FIFO trim to 20.
@@ -1914,6 +2383,14 @@ def _handle_share_history_post_impl(handler) -> None:
1914
2383
  options = req.get("options") or {}
1915
2384
  fmt = req.get("format")
1916
2385
  destination = req.get("destination")
2386
+ try:
2387
+ source, _ = _share_source_selection(req)
2388
+ except ValueError:
2389
+ handler._respond_json(400, {
2390
+ "code": "source_capability_unavailable",
2391
+ "error": "source capability unavailable",
2392
+ })
2393
+ return
1917
2394
  if not isinstance(panel, str) or not panel:
1918
2395
  handler._respond_json(400, {
1919
2396
  "error": "missing or non-string panel",
@@ -1978,6 +2455,7 @@ def _handle_share_history_post_impl(handler) -> None:
1978
2455
  "panel": panel,
1979
2456
  "template_id": template_id,
1980
2457
  "options": options,
2458
+ "source": source,
1981
2459
  "format": fmt,
1982
2460
  "destination": destination,
1983
2461
  "exported_at": _share_now_utc_iso(),