cctally 1.108.0 → 1.109.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 (78) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/bin/_cctally_account.py +4 -0
  3. package/bin/_cctally_alerts.py +6 -2
  4. package/bin/_cctally_cache.py +1590 -153
  5. package/bin/_cctally_config.py +3 -2
  6. package/bin/_cctally_core.py +589 -32
  7. package/bin/_cctally_dashboard.py +1223 -160
  8. package/bin/_cctally_dashboard_conversation.py +263 -8
  9. package/bin/_cctally_dashboard_envelope.py +144 -4
  10. package/bin/_cctally_dashboard_share.py +154 -36
  11. package/bin/_cctally_dashboard_sources.py +2146 -347
  12. package/bin/_cctally_db.py +634 -5
  13. package/bin/_cctally_diagnosis_sources.py +57 -11
  14. package/bin/_cctally_doctor.py +84 -15
  15. package/bin/_cctally_five_hour.py +1295 -40
  16. package/bin/_cctally_forecast.py +109 -26
  17. package/bin/_cctally_journal.py +380 -37
  18. package/bin/_cctally_milestone_history.py +86 -6
  19. package/bin/_cctally_percent_breakdown.py +6 -1
  20. package/bin/_cctally_project.py +48 -76
  21. package/bin/_cctally_quota.py +39 -7
  22. package/bin/_cctally_quota_model.py +17 -2
  23. package/bin/_cctally_record.py +1603 -437
  24. package/bin/_cctally_refresh.py +2 -1
  25. package/bin/_cctally_share.py +24 -5
  26. package/bin/_cctally_source_analytics.py +553 -30
  27. package/bin/_cctally_statusline.py +83 -19
  28. package/bin/_cctally_store.py +61 -4
  29. package/bin/_cctally_tui.py +229 -74
  30. package/bin/_cctally_weekrefs.py +28 -5
  31. package/bin/_lib_blocks.py +127 -20
  32. package/bin/_lib_cache_report.py +20 -5
  33. package/bin/_lib_codex_conversation_query.py +11 -1
  34. package/bin/_lib_codex_hooks.py +102 -0
  35. package/bin/_lib_conversation.py +119 -0
  36. package/bin/_lib_conversation_dispatch.py +31 -1
  37. package/bin/_lib_conversation_query.py +61 -14
  38. package/bin/_lib_conversation_retention.py +413 -25
  39. package/bin/_lib_dashboard_sources.py +267 -7
  40. package/bin/_lib_diagnosis.py +10 -0
  41. package/bin/_lib_diff_kernel.py +12 -1
  42. package/bin/_lib_doctor.py +214 -6
  43. package/bin/_lib_ingest_frontier.py +1366 -49
  44. package/bin/_lib_journal.py +19 -2
  45. package/bin/_lib_merge_gate.py +307 -0
  46. package/bin/_lib_pricing.py +522 -18
  47. package/bin/_lib_record.py +130 -12
  48. package/bin/_lib_render.py +9 -3
  49. package/bin/_lib_retained_size.py +27 -2
  50. package/bin/_lib_segment_summary.py +66 -8
  51. package/bin/_lib_share_templates.py +28 -18
  52. package/bin/_lib_snapshot_cache.py +115 -1
  53. package/bin/_lib_source_retry.py +320 -0
  54. package/bin/_lib_statusline_candidates.py +400 -36
  55. package/bin/_lib_tick_stats.py +119 -0
  56. package/bin/_lib_view_models.py +251 -76
  57. package/bin/cctally +69 -0
  58. package/dashboard/static/assets/ConversationsView-TO5bpIyc.js +72 -0
  59. package/dashboard/static/assets/{DoctorModal-CWn3U4Wl.js → DoctorModal-BZGXOlc2.js} +1 -1
  60. package/dashboard/static/assets/ModalRoot-C_fvugTl.js +1 -0
  61. package/dashboard/static/assets/{ProjectsDrillPanel-QLI9i5mZ.js → ProjectsDrillPanel-KbO_KWa7.js} +1 -1
  62. package/dashboard/static/assets/SourceDetailModal-BrOPLVRL.js +1 -0
  63. package/dashboard/static/assets/{UpdateModal-D3m8GG6V.js → UpdateModal-CYgEFAi4.js} +2 -2
  64. package/dashboard/static/assets/dashboardStream.shared-worker-DbG6Ef0T.js +1 -0
  65. package/dashboard/static/assets/index-Cj_l7T7u.css +1 -0
  66. package/dashboard/static/assets/index-DLVwACUM.js +13 -0
  67. package/dashboard/static/assets/outlineNavigation-DXX-RjKl.js +9 -0
  68. package/dashboard/static/assets/useKeymap-DQx_A1MB.js +1 -0
  69. package/dashboard/static/dashboard.html +3 -3
  70. package/package.json +3 -1
  71. package/dashboard/static/assets/ConversationsView-BOSaBRtu.js +0 -72
  72. package/dashboard/static/assets/ModalRoot-SR070V6c.js +0 -1
  73. package/dashboard/static/assets/SourceDetailModal-pv0wlFxl.js +0 -1
  74. package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +0 -1
  75. package/dashboard/static/assets/index-BHu4mxd8.css +0 -1
  76. package/dashboard/static/assets/index-CIWsbux3.js +0 -13
  77. package/dashboard/static/assets/outlineNavigation-CJvKqmLV.js +0 -9
  78. package/dashboard/static/assets/useKeymap-ffqsS5G0.js +0 -1
@@ -49,7 +49,6 @@ from _cctally_core import open_db, parse_iso_datetime
49
49
  from _cctally_config import save_config, _load_config_unlocked
50
50
  from _lib_fmt import stable_sum
51
51
  from _lib_pricing import _calculate_entry_cost, claude_usage_dict
52
- from _lib_five_hour import _canonical_5h_window_key
53
52
  from _lib_display_tz import _resolve_tz, resolve_display_tz_name
54
53
 
55
54
 
@@ -525,48 +524,154 @@ def _share_per_day_per_project_for_range(
525
524
  return out
526
525
 
527
526
 
527
+ def _share_block_ownership_context(range_start, range_end):
528
+ """The canonical five-hour windows competing over ``[range_start,
529
+ range_end]``, as ``(windows, owner_key_by_block_start_epoch)``.
530
+
531
+ #751a: a block cannot decide what it owns without knowing what its
532
+ neighbours own, so the caller widens this range past what it displays
533
+ purely in order to exclude a predecessor's entries.
534
+ """
535
+ import _lib_blocks
536
+
537
+ _selected, _overrides, canonical_intervals = (
538
+ sys.modules["cctally"]._load_recorded_five_hour_windows(
539
+ range_start, range_end,
540
+ )
541
+ )
542
+ windows = []
543
+ owner_key_by_start: dict[int, object] = {}
544
+ for anchor, (block_start, reset) in canonical_intervals.items():
545
+ windows.append(_lib_blocks.OwnedWindow(
546
+ key=anchor, start=block_start, reset=reset,
547
+ ))
548
+ owner_key_by_start[int(block_start.timestamp())] = anchor
549
+ return windows, owner_key_by_start
550
+
551
+
552
+ def _share_entry_cost(entry) -> float:
553
+ usage = claude_usage_dict( # #195 chokepoint
554
+ input_tokens=entry.input_tokens,
555
+ output_tokens=entry.output_tokens,
556
+ cache_creation_tokens=entry.cache_creation_tokens,
557
+ cache_read_tokens=entry.cache_read_tokens,
558
+ cache_1h_tokens=getattr(entry, "cache_1h_tokens", None),
559
+ speed=getattr(entry, "speed", None),
560
+ )
561
+ return _calculate_entry_cost(
562
+ entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
563
+ )
564
+
565
+
566
+ def _share_per_block_per_project_by_ownership(starts, iso_by_start_epoch):
567
+ """Per-block per-project costs over the entries each block OWNS.
568
+
569
+ The rollup-table path above is the canonical source; this runs only
570
+ when that table is empty or unreadable. It used to sweep each block's
571
+ raw ``[start, start + 5h]`` interval through
572
+ ``_share_all_projects_for_range``, which is an independent
573
+ reselection: two windows overlapping after a reset shift each claimed
574
+ the overlap entries, and the artifact's per-block per-project
575
+ breakdown double-counted them (#751a review P2-1).
576
+
577
+ Ownership is resolved once, by the one rule in
578
+ ``_lib_blocks.partition_entries_by_owner``. A block with no canonical
579
+ row is heuristic, so it reads the leftovers — the entries no exact
580
+ window contains — inside its own interval, which is the grouping
581
+ pass's own treatment of them.
582
+ """
583
+ import _lib_blocks
584
+
585
+ five_hours = dt.timedelta(hours=5)
586
+ earliest, latest = min(starts), max(starts)
587
+ out: dict[str, dict[str, float]] = {
588
+ iso: {} for iso in iso_by_start_epoch.values()
589
+ }
590
+ try:
591
+ windows, owner_key_by_start = _share_block_ownership_context(
592
+ earliest - five_hours, latest + 2 * five_hours,
593
+ )
594
+ # late-binding: reach the dashboard module object so both the ns-patch
595
+ # (share tests) and a dashboard-object patch (rebuild-parity) are
596
+ # honored (#279 S5 F1 / spec §3).
597
+ entries = list(
598
+ sys.modules["_cctally_dashboard"].get_claude_session_entries(
599
+ earliest, latest + 2 * five_hours, skip_sync=True,
600
+ )
601
+ )
602
+ except Exception:
603
+ return out
604
+ owned, leftovers = _lib_blocks.partition_entries_by_owner(entries, windows)
605
+ for start in starts:
606
+ block_iso = iso_by_start_epoch[int(start.timestamp())]
607
+ owner_key = owner_key_by_start.get(int(start.timestamp()))
608
+ if owner_key is None:
609
+ mine = [
610
+ e for e in leftovers
611
+ if start <= e.timestamp < start + five_hours
612
+ ]
613
+ else:
614
+ mine = owned.get(owner_key, [])
615
+ bucket = out[block_iso]
616
+ for entry in mine:
617
+ key = entry.project_path or "(unknown)"
618
+ bucket[key] = bucket.get(key, 0.0) + _share_entry_cost(entry)
619
+ return out
620
+
621
+
528
622
  def _share_per_block_per_project(
529
623
  recent_blocks: list[dict],
530
624
  ) -> dict[str, dict[str, float]]:
531
625
  """Aggregate per-block per-project costs from `five_hour_block_projects`.
532
626
 
533
627
  Returns {block_start_at_iso: {project_path_or_'(unknown)': cost_usd}}.
534
- Block.start_at → five_hour_window_key via `_canonical_5h_window_key`
535
- (10-min floor; same chokepoint as `maybe_update_five_hour_block`,
536
- per CLAUDE.md "5-hour windows" gotcha — never derive a third key shape).
537
-
538
- Fallback (rollup empty/unreadable): per-block sweep over
539
- `_share_all_projects_for_range` — uncapped, accuracy parity with the
540
- canonical path. Fires only during the first tick after fresh install
541
- or before stats-migration `002_five_hour_block_projects_backfill_v1`
542
- completes. Issue #33.
628
+
629
+ The rollup rows are reached through their parent's `block_start_at`
630
+ rather than through a window key derived here. `five_hour_block_projects.
631
+ five_hour_window_key` is canonicalized from the window's RESET, so a key
632
+ derived from the block's START missed every row by the block duration and
633
+ the lookup never resolved — which made the fallback the effective path
634
+ (#751a review P2-1). Resolving the parent by its own recorded start is a
635
+ lookup rather than a second derivation, so no third key shape is created
636
+ (CLAUDE.md "5-hour windows").
637
+
638
+ Fallback (rollup empty/unreadable): `_share_per_block_per_project_by_
639
+ ownership`, which prices the entries each block owns. Fires only during
640
+ the first tick after fresh install or before stats-migration
641
+ `002_five_hour_block_projects_backfill_v1` completes. Issue #33.
543
642
  """
544
643
  if not recent_blocks:
545
644
  return {}
546
645
  out: dict[str, dict[str, float]] = {}
547
- keys: list[int] = []
548
- iso_by_key: dict[int, str] = {}
646
+ starts: list[dt.datetime] = []
647
+ iso_by_start_epoch: dict[int, str] = {}
549
648
  for b in recent_blocks:
550
649
  try:
551
650
  ts = parse_iso_datetime(b["start_at"], "share.block.start_at")
552
651
  except (ValueError, KeyError):
553
652
  continue
554
- wk = _canonical_5h_window_key(int(ts.timestamp()))
555
- keys.append(wk)
556
- iso_by_key[wk] = b["start_at"]
557
- if not keys:
653
+ ts = ts.astimezone(dt.timezone.utc)
654
+ starts.append(ts)
655
+ iso_by_start_epoch[int(ts.timestamp())] = b["start_at"]
656
+ if not starts:
558
657
  return out
658
+ epochs = list(iso_by_start_epoch)
559
659
  try:
560
660
  conn = open_db()
561
- placeholders = ",".join("?" for _ in keys)
562
- rows = conn.execute(
563
- f"SELECT five_hour_window_key, project_path, cost_usd "
564
- f"FROM five_hour_block_projects "
565
- f"WHERE five_hour_window_key IN ({placeholders})",
566
- keys,
567
- ).fetchall()
568
- for wk, project_path, cost in rows:
569
- block_iso = iso_by_key.get(wk)
661
+ try:
662
+ placeholders = ",".join("?" for _ in epochs)
663
+ rows = conn.execute(
664
+ f"SELECT unixepoch(b.block_start_at) AS start_epoch, "
665
+ f" p.project_path, p.cost_usd "
666
+ f" FROM five_hour_block_projects p "
667
+ f" JOIN five_hour_blocks b ON b.id = p.block_id "
668
+ f" WHERE unixepoch(b.block_start_at) IN ({placeholders})",
669
+ epochs,
670
+ ).fetchall()
671
+ finally:
672
+ conn.close()
673
+ for start_epoch, project_path, cost in rows:
674
+ block_iso = iso_by_start_epoch.get(int(start_epoch))
570
675
  if block_iso is None:
571
676
  continue
572
677
  proj = project_path or "(unknown)"
@@ -576,15 +681,7 @@ def _share_per_block_per_project(
576
681
  return out
577
682
  except (sqlite3.DatabaseError, OSError):
578
683
  pass
579
- # Fallback: per-block uncapped session_entries sweep.
580
- for b in recent_blocks:
581
- try:
582
- ts = parse_iso_datetime(b["start_at"], "share.block.start_at")
583
- except (ValueError, KeyError):
584
- continue
585
- end = ts + dt.timedelta(hours=5)
586
- out[b["start_at"]] = sys.modules["cctally"]._share_all_projects_for_range(ts, end)
587
- return out
684
+ return _share_per_block_per_project_by_ownership(starts, iso_by_start_epoch)
588
685
 
589
686
 
590
687
  def _build_share_panel_data(panel: str, options: dict,
@@ -1327,7 +1424,28 @@ def _build_projects_share_panel_data(options: dict,
1327
1424
  week_end = cw_start + dt.timedelta(days=7)
1328
1425
  now = _share_now_utc()
1329
1426
  period_end = week_end if week_end <= now else now
1330
- period_start = cw_start - dt.timedelta(days=7 * (effective_weeks - 1))
1427
+ # #750 S4 §4.7: the period start comes from the SELECTED INTERVAL
1428
+ # IDENTITIES, not from `cw_start - 7 * (effective_weeks - 1)`. A credited
1429
+ # week adds a billing cycle without adding seven calendar days, so the
1430
+ # seven-day arithmetic advertises a period WIDER than the data the
1431
+ # artifact aggregated the moment one is inside the window. The instants
1432
+ # come from `projects.trend.weeks[].week_start_at` (§2.2). The old
1433
+ # arithmetic remains the fallback for an envelope that predates the
1434
+ # field, where it is still the best available answer.
1435
+ _selected_starts = [
1436
+ w.get("week_start_at") for w in (trend.get("weeks") or [])
1437
+ if isinstance(w, dict) and w.get("week_start_at")
1438
+ ][-effective_weeks:]
1439
+ period_start = None
1440
+ if _selected_starts:
1441
+ try:
1442
+ period_start = parse_iso_datetime(
1443
+ _selected_starts[0], "projects.trend.week_start_at",
1444
+ )
1445
+ except (TypeError, ValueError):
1446
+ period_start = None
1447
+ if period_start is None:
1448
+ period_start = cw_start - dt.timedelta(days=7 * (effective_weeks - 1))
1331
1449
 
1332
1450
  return {
1333
1451
  "rows": rows,
@@ -1533,7 +1651,7 @@ def _share_codex_state_for_period(data_snap, *, panel: str, options: dict):
1533
1651
  ),
1534
1652
  data_version=(
1535
1653
  f"share:codex:{panel}:{range_start.isoformat()}:"
1536
- f"{now_override.isoformat()}:{semantics.identity}"
1654
+ f"{now_override.isoformat()}:{semantics.codex_identity}"
1537
1655
  ),
1538
1656
  )
1539
1657
  finally: