cctally 1.113.0 → 1.115.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 (97) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +32 -8
  4. package/bin/_cctally_cache.py +6035 -722
  5. package/bin/_cctally_completion.py +1287 -0
  6. package/bin/_cctally_config.py +37 -0
  7. package/bin/_cctally_core.py +400 -2
  8. package/bin/_cctally_dashboard.py +98 -20
  9. package/bin/_cctally_dashboard_conversation.py +292 -23
  10. package/bin/_cctally_dashboard_envelope.py +16 -3
  11. package/bin/_cctally_dashboard_perf.py +128 -9
  12. package/bin/_cctally_dashboard_sources.py +285 -213
  13. package/bin/_cctally_db.py +1177 -23
  14. package/bin/_cctally_diagnosis_sources.py +22 -4
  15. package/bin/_cctally_doctor.py +572 -45
  16. package/bin/_cctally_five_hour.py +84 -33
  17. package/bin/_cctally_forecast.py +108 -15
  18. package/bin/_cctally_journal.py +3950 -116
  19. package/bin/_cctally_journal_repair.py +101 -12
  20. package/bin/_cctally_milestone_history.py +82 -9
  21. package/bin/_cctally_parser.py +42 -0
  22. package/bin/_cctally_percent_breakdown.py +3 -2
  23. package/bin/_cctally_project.py +8 -2
  24. package/bin/_cctally_quarantine.py +652 -0
  25. package/bin/_cctally_quota.py +227 -37
  26. package/bin/_cctally_quota_model.py +360 -83
  27. package/bin/_cctally_record.py +691 -149
  28. package/bin/_cctally_rederive.py +320 -41
  29. package/bin/_cctally_refresh.py +84 -13
  30. package/bin/_cctally_source_analytics.py +83 -17
  31. package/bin/_cctally_statusline.py +6154 -517
  32. package/bin/_cctally_store.py +367 -53
  33. package/bin/_cctally_transcript.py +8 -7
  34. package/bin/_cctally_tui.py +61 -56
  35. package/bin/_cctally_weekrefs.py +128 -21
  36. package/bin/_lib_account_binding.py +152 -0
  37. package/bin/_lib_account_protocol.py +386 -0
  38. package/bin/_lib_aggregators.py +4 -0
  39. package/bin/_lib_cache_coverage.py +13 -1
  40. package/bin/_lib_cache_report.py +35 -6
  41. package/bin/_lib_cache_writer_lock.py +19 -3
  42. package/bin/_lib_clone_copy.py +142 -0
  43. package/bin/_lib_codex_conversation.py +32 -0
  44. package/bin/_lib_codex_conversation_query.py +297 -49
  45. package/bin/_lib_codex_conversation_watch.py +59 -14
  46. package/bin/_lib_codex_drain_admission.py +214 -0
  47. package/bin/_lib_codex_metadata.py +18 -1
  48. package/bin/_lib_codex_publication_barrier.py +277 -0
  49. package/bin/_lib_codex_rollout_container.py +502 -0
  50. package/bin/_lib_conversation_dispatch.py +3 -0
  51. package/bin/_lib_conversation_query.py +106 -59
  52. package/bin/_lib_conversation_retention.py +1605 -640
  53. package/bin/_lib_cost_provenance.py +53 -2
  54. package/bin/_lib_detector_access_registry.py +3817 -0
  55. package/bin/_lib_doctor.py +714 -69
  56. package/bin/_lib_five_hour.py +126 -0
  57. package/bin/_lib_fmt.py +11 -0
  58. package/bin/_lib_ingest_frontier.py +414 -20
  59. package/bin/_lib_journal.py +141 -10
  60. package/bin/_lib_jsonl.py +5 -4
  61. package/bin/_lib_legacy_spool.py +126 -0
  62. package/bin/_lib_legacy_writer_evidence.py +136 -0
  63. package/bin/_lib_merge_gate.py +231 -86
  64. package/bin/_lib_meter_rate_change.py +10 -0
  65. package/bin/_lib_outcome_quarantine.py +690 -0
  66. package/bin/_lib_perf.py +27 -1
  67. package/bin/_lib_pricing.py +199 -9
  68. package/bin/_lib_pricing_check.py +20 -4
  69. package/bin/_lib_quarantine.py +154 -0
  70. package/bin/_lib_quota_calibration.py +53 -8
  71. package/bin/_lib_quota_copy.py +2 -1
  72. package/bin/_lib_quota_model.py +288 -46
  73. package/bin/_lib_reclaim_planner.py +792 -0
  74. package/bin/_lib_rederive.py +319 -82
  75. package/bin/_lib_segment_summary.py +35 -2
  76. package/bin/_lib_snapshot_cache.py +21 -0
  77. package/bin/_lib_sqlite_close.py +81 -0
  78. package/bin/_lib_statusline_candidates.py +292 -23
  79. package/bin/_lib_subscription_weeks.py +100 -17
  80. package/bin/_lib_tick_stats.py +217 -17
  81. package/bin/_lib_wal_checkpoint.py +1077 -0
  82. package/bin/_lib_write_budget.py +483 -0
  83. package/bin/_lib_write_io.py +703 -0
  84. package/bin/cctally +31 -1
  85. package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
  86. package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
  87. package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
  88. package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
  89. package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
  90. package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
  91. package/dashboard/static/assets/index-BKITk1CT.css +1 -0
  92. package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
  93. package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
  94. package/dashboard/static/dashboard.html +2 -2
  95. package/package.json +19 -1
  96. package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
  97. package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
@@ -282,29 +282,32 @@ def _session_first_prompt_titles_map(conn, session_ids):
282
282
  to this helper's result dict): once a sid resolves a first-prompt title, later
283
283
  rows in its 12-row window do not overwrite it (Codex P1-3).
284
284
 
285
- NOTE (Codex P1.2): the window ranks the full per-session human partition before
286
- rn<=12 — confirmed index-ordered + bounded by the page (≤200 sessions);
287
- per-session human counts are modest. Honors ``_reingest_pending(conn)``'s
288
- skip_skill_titles gate: while 005's reingest is pending a stale ``human`` row
289
- may actually be an injected skill body, so skip those as title candidates
290
- (gated on the flag so a genuine post-reingest human prompt starting with the
291
- preamble stays a normal title — Codex code-review P2)."""
285
+ #901 Amendment 19 T1 (spec I1): one index-served ``LIMIT 12`` lookup per
286
+ session, walking ``idx_conv_session_ts`` in ``(timestamp_utc, id)`` order —
287
+ a true top-12 with no sorter. The ``ROW_NUMBER()`` window it replaces
288
+ ranked the session's WHOLE human partition through a temp b-tree before
289
+ keeping twelve, and one session's history is not a bound. Same rows in the
290
+ same order: sessions in ascending id order (the window's
291
+ ``ORDER BY session_id``), each one's earliest twelve candidates by
292
+ ``(timestamp_utc, id)`` with NULL timestamps first. Honors
293
+ ``_reingest_pending(conn)``'s skip_skill_titles gate: while 005's reingest
294
+ is pending a stale ``human`` row may actually be an injected skill body, so
295
+ skip those as title candidates (gated on the flag so a genuine
296
+ post-reingest human prompt starting with the preamble stays a normal title
297
+ — Codex code-review P2)."""
292
298
  if not session_ids:
293
299
  return {}
294
300
  titles = {}
295
301
  skip_skill_titles = _reingest_pending(conn)
296
- ph = ",".join("?" for _ in session_ids)
297
- rows = conn.execute(
298
- "SELECT session_id, text FROM ("
299
- " SELECT session_id, text, "
300
- " ROW_NUMBER() OVER (PARTITION BY session_id "
301
- " ORDER BY timestamp_utc, id) AS rn "
302
- f" FROM conversation_messages "
303
- f" WHERE session_id IN ({ph}) AND entry_type='human' "
304
- " AND is_sidechain=0 AND COALESCE(text,'') <> ''"
305
- ") WHERE rn <= 12 ORDER BY session_id, rn",
306
- tuple(session_ids),
307
- ).fetchall()
302
+ rows = []
303
+ for sid in sorted({s for s in session_ids if s is not None}, key=str):
304
+ rows.extend(conn.execute(
305
+ "SELECT session_id, text FROM conversation_messages "
306
+ "WHERE session_id = ? AND entry_type='human' "
307
+ " AND is_sidechain=0 AND COALESCE(text,'') <> '' "
308
+ "ORDER BY timestamp_utc, id LIMIT 12",
309
+ (sid,),
310
+ ).fetchall())
308
311
  for sid, text in rows:
309
312
  if sid in titles:
310
313
  continue # first-wins: already resolved a title
@@ -578,7 +581,7 @@ _CACHE_FAILURE_CACHE_FLOOR = 20_000 # prior cache must be meaningful to "lo
578
581
  _CACHE_FAILURE_CREATE_FLOOR = 20_000 # the re-creation must be substantial / real cost
579
582
 
580
583
 
581
- def _cache_failure_wasted_usd(model, lost, *, speed):
584
+ def _cache_failure_wasted_usd(model, lost, *, speed, prompt_tokens):
582
585
  """Marginal extra paid by re-creating `lost` previously-cached tokens at the
583
586
  cache-WRITE rate instead of reading them at the cache-READ rate. Reuses the
584
587
  pricing chokepoint `_calculate_entry_cost` (zero on unknown models — the
@@ -586,40 +589,54 @@ def _cache_failure_wasted_usd(model, lost, *, speed):
586
589
  any cost-snapshot / budget / reconciled figure — a display-only estimate.
587
590
  The lost-prefix subset has no authoritative mapping to the source row's
588
591
  5m/1h write buckets, so this preserves the existing 5m estimate while
589
- applying the retained effective speed tier."""
592
+ applying the retained effective speed tier.
593
+
594
+ `prompt_tokens` (REQUIRED, #929) is the SOURCE request's real prompt total
595
+ (input + cache creation + cache read). Both synthetic calls pass it as
596
+ `prompt_tokens_for_tier`, so a prompt-length-priced card (Claude Haiku 5.5)
597
+ bills the subset at the card the real request selected, never at the card
598
+ the smaller subset would select on its own."""
590
599
  write = _calculate_entry_cost(
591
600
  model or "",
592
601
  claude_usage_dict(
593
602
  cache_1h_tokens=None, speed=speed, cache_creation_tokens=lost
594
603
  ),
604
+ prompt_tokens_for_tier=prompt_tokens,
595
605
  )
596
606
  read = _calculate_entry_cost(
597
607
  model or "",
598
608
  claude_usage_dict(
599
609
  cache_1h_tokens=None, speed=speed, cache_read_tokens=lost
600
610
  ),
611
+ prompt_tokens_for_tier=prompt_tokens,
601
612
  )
602
613
  return write - read
603
614
 
604
615
 
605
- def _cache_read_saved_usd(model, cache_read, *, speed):
616
+ def _cache_read_saved_usd(model, cache_read, *, speed, prompt_tokens):
606
617
  """Marginal USD the cache SAVED this turn: the `cache_read` prefix priced at
607
618
  the full input rate minus its actual cache-READ rate. Display-only (same
608
619
  caveat as `_cache_failure_wasted_usd`): NEVER summed into a cost-snapshot /
609
620
  budget / reconciled figure. `input_tokens` and `cache_read_input_tokens` are
610
621
  independent keys in the Claude `_calculate_entry_cost` (no subset
611
- subtraction), so passing each alone yields the two rates cleanly."""
622
+ subtraction), so passing each alone yields the two rates cleanly.
623
+
624
+ `prompt_tokens` (REQUIRED, #929) is the source request's real prompt total,
625
+ passed to both calls as `prompt_tokens_for_tier` exactly as in
626
+ `_cache_failure_wasted_usd`."""
612
627
  full = _calculate_entry_cost(
613
628
  model or "",
614
629
  claude_usage_dict(
615
630
  cache_1h_tokens=None, speed=speed, input_tokens=cache_read
616
631
  ),
632
+ prompt_tokens_for_tier=prompt_tokens,
617
633
  )
618
634
  read = _calculate_entry_cost(
619
635
  model or "",
620
636
  claude_usage_dict(
621
637
  cache_1h_tokens=None, speed=speed, cache_read_tokens=cache_read
622
638
  ),
639
+ prompt_tokens_for_tier=prompt_tokens,
623
640
  )
624
641
  return full - read
625
642
 
@@ -749,11 +766,17 @@ def _stamp_cache_failures(items):
749
766
  """
750
767
  events, sources = _cache_failure_events_from_items(items)
751
768
  for idx, prev_cached, lost, model, speed in _iter_cache_failures(events):
769
+ # #929: the flagged request's REAL prompt selects the pricing card, not
770
+ # the lost-prefix subset being priced.
771
+ tok = sources[idx].get("tokens") or {}
772
+ prompt_tokens = ((tok.get("input", 0) or 0)
773
+ + (tok.get("cache_creation", 0) or 0)
774
+ + (tok.get("cache_read", 0) or 0))
752
775
  sources[idx]["cache_failure"] = {
753
776
  "tokens_recreated": lost,
754
777
  "prev_cached": prev_cached,
755
778
  "est_wasted_usd": _cache_failure_wasted_usd(
756
- model, lost, speed=speed
779
+ model, lost, speed=speed, prompt_tokens=prompt_tokens
757
780
  ),
758
781
  }
759
782
 
@@ -1037,18 +1060,24 @@ def _session_cost_map(conn, session_ids):
1037
1060
  shared ``_turn_costs_for_keys`` helper (cost-once per deduped session_entries
1038
1061
  row), summing into the owning session. (msg_id, req_id) is globally unique in
1039
1062
  session_entries and maps to exactly one session_id, so per-session sums are
1040
- clean and a turn replayed across files contributes once."""
1063
+ clean and a turn replayed across files contributes once.
1064
+
1065
+ #901 Amendment 19 T1 (spec I1): the de-duplication runs in Python over the
1066
+ streamed rows, keeping each key's FIRST occurrence in scan order — exactly
1067
+ the rows, and the order, ``SELECT DISTINCT`` returned. The DISTINCT built a
1068
+ temp b-tree of every turn of every touched session, and one session's
1069
+ history is not a bound."""
1041
1070
  costs = {sid: 0.0 for sid in session_ids}
1042
1071
  if not session_ids:
1043
1072
  return costs
1044
1073
  placeholders = ",".join("?" for _ in session_ids)
1045
- pairs = conn.execute(
1046
- "SELECT DISTINCT session_id, msg_id, req_id "
1074
+ pairs = list(dict.fromkeys(conn.execute(
1075
+ "SELECT session_id, msg_id, req_id "
1047
1076
  "FROM conversation_messages "
1048
1077
  "WHERE session_id IN (%s) AND msg_id IS NOT NULL AND req_id IS NOT NULL"
1049
1078
  % placeholders,
1050
1079
  list(session_ids),
1051
- ).fetchall()
1080
+ )))
1052
1081
  if not pairs:
1053
1082
  return costs
1054
1083
  key_cost = _turn_costs_for_keys(conn, [(m, r) for _, m, r in pairs])
@@ -1084,20 +1113,25 @@ def _models_main_first(rows):
1084
1113
 
1085
1114
  def _session_models_map(conn, session_ids):
1086
1115
  """{session_id: models ordered MAIN-session-first (subagent/sidechain-only
1087
- models last), alphabetical within each group — see _models_main_first}."""
1116
+ models last), alphabetical within each group — see _models_main_first}.
1117
+
1118
+ #901 Amendment 19 T1 (spec I1): the de-duplication runs in Python over the
1119
+ streamed rows (one entry per distinct tuple), where a ``SELECT DISTINCT``
1120
+ built a temp b-tree over every touched session's whole history.
1121
+ ``_models_main_first`` reads its input as sets, so the result is the same."""
1088
1122
  out = {sid: [] for sid in session_ids}
1089
1123
  if not session_ids:
1090
1124
  return out
1091
1125
  placeholders = ",".join("?" for _ in session_ids)
1092
1126
  sql = (
1093
- "SELECT DISTINCT session_id, model, source_path, is_sidechain "
1127
+ "SELECT session_id, model, source_path, is_sidechain "
1094
1128
  "FROM conversation_messages "
1095
1129
  "WHERE session_id IN (%s) AND model IS NOT NULL AND model != ''"
1096
1130
  % placeholders
1097
1131
  )
1098
1132
  per_session = {}
1099
- for sid, model, source_path, is_sidechain in conn.execute(
1100
- sql, list(session_ids)):
1133
+ for sid, model, source_path, is_sidechain in dict.fromkeys(
1134
+ conn.execute(sql, list(session_ids))):
1101
1135
  per_session.setdefault(sid, []).append(
1102
1136
  (model, source_path, is_sidechain))
1103
1137
  for sid, rows in per_session.items():
@@ -1113,30 +1147,31 @@ def _session_latest_meta_map(conn, session_ids):
1113
1147
  latest non-null git_branch may land on DIFFERENT rows (the newest row can
1114
1148
  carry one but not the other), so each is resolved independently.
1115
1149
 
1116
- #217 S1 / U7b: consolidated from the prior TWIN correlated subqueries into a
1117
- SINGLE windowed scan — two ``FIRST_VALUE`` window functions over one
1118
- partition pass per column, each ordered ``(<col> IS NULL), timestamp_utc DESC,
1119
- id DESC`` so the most-recent non-null value sorts first (and an all-null
1120
- session yields NULL, exactly as the LIMIT-1 subquery did). Guarded by
1121
- ``test_session_latest_meta_map_parity_*`` against the prior semantics; if the
1122
- window form could not match it, the old correlated SQL would be kept (it
1123
- matched cleanly, so the consolidation lands)."""
1150
+ #901 Q11 (spec §5.3a, 901-PA-001 b): two ``LIMIT 1`` lookups per session,
1151
+ one per column, each ``WHERE <col> IS NOT NULL ORDER BY timestamp_utc DESC,
1152
+ id DESC`` and served by its partial index on ``(session_id, timestamp_utc,
1153
+ id)`` (conversations migration 011): one seek, however long the session.
1154
+ The #217 S1 window query this replaces (two ``FIRST_VALUE`` windows over
1155
+ each touched session) sorted the session's whole history through temp
1156
+ b-trees on every conversation-sync pass, and one session is not a bound.
1157
+ The semantics are unchanged: an empty string is a value, NULL timestamps
1158
+ order below every real one, ties fall to the larger ``id``, an all-NULL
1159
+ column or a missing session gives ``None``. Guarded by
1160
+ ``test_session_latest_meta_map_parity_*`` and
1161
+ ``tests/test_901_session_latest_meta.py`` (a Python reference and the
1162
+ frozen window query, over sparse metadata in long histories)."""
1124
1163
  meta = {sid: (None, None) for sid in session_ids}
1125
- if not session_ids:
1126
- return meta
1127
- placeholders = ",".join("?" for _ in session_ids)
1128
- sql = (
1129
- "SELECT DISTINCT session_id, "
1130
- " FIRST_VALUE(cwd) OVER ("
1131
- " PARTITION BY session_id "
1132
- " ORDER BY (cwd IS NULL), timestamp_utc DESC, id DESC), "
1133
- " FIRST_VALUE(git_branch) OVER ("
1134
- " PARTITION BY session_id "
1135
- " ORDER BY (git_branch IS NULL), timestamp_utc DESC, id DESC) "
1136
- "FROM conversation_messages WHERE session_id IN (%s)" % placeholders
1137
- )
1138
- for sid, cwd, branch in conn.execute(sql, list(session_ids)):
1139
- meta[sid] = (cwd, branch)
1164
+ for sid in meta:
1165
+ cwd = conn.execute(
1166
+ "SELECT cwd FROM conversation_messages "
1167
+ "WHERE session_id = ? AND cwd IS NOT NULL "
1168
+ "ORDER BY timestamp_utc DESC, id DESC LIMIT 1", (sid,)).fetchone()
1169
+ branch = conn.execute(
1170
+ "SELECT git_branch FROM conversation_messages "
1171
+ "WHERE session_id = ? AND git_branch IS NOT NULL "
1172
+ "ORDER BY timestamp_utc DESC, id DESC LIMIT 1", (sid,)).fetchone()
1173
+ meta[sid] = (cwd[0] if cwd is not None else None,
1174
+ branch[0] if branch is not None else None)
1140
1175
  return meta
1141
1176
 
1142
1177
 
@@ -2982,8 +3017,12 @@ def get_conversation_outline(conn, session_id):
2982
3017
  tokens[k] += tok.get(k, 0)
2983
3018
  cr_tokens = tok.get("cache_read", 0) or 0
2984
3019
  if cr_tokens > 0:
3020
+ # #929: the turn's real prompt selects the card.
2985
3021
  cache_saved += _cache_read_saved_usd(
2986
- it.get("model"), cr_tokens, speed=it.get("_speed")
3022
+ it.get("model"), cr_tokens, speed=it.get("_speed"),
3023
+ prompt_tokens=((tok.get("input", 0) or 0)
3024
+ + (tok.get("cache_creation", 0) or 0)
3025
+ + cr_tokens),
2987
3026
  )
2988
3027
  # Copy the cache-failure marker onto the OutlineTurn exactly where
2989
3028
  # tokens is copied (assistant-only, rides the same source row) and
@@ -3256,11 +3295,19 @@ def _scoped_codex_anon_paths(conn, account_key: str):
3256
3295
  raise RuntimeError("Codex raw threads table missing for scoped anonymization")
3257
3296
  return set(), 0, 0
3258
3297
  ambiguous = 0
3259
- thread_schema = threads.split(".", 1)[0]
3298
+ # Threads can live in the last-good TEMP view, whose schema says nothing
3299
+ # about the physical accounting roots. Resolve roots independently and
3300
+ # bypass account-scope TEMP shadows; the provenance checks below still
3301
+ # decide which of those paths this account may use. A missing/unreadable
3302
+ # physical roots table propagates as a refusal, never a partial vocabulary.
3303
+ roots_table = (
3304
+ "main.codex_source_roots" if conn.execute(
3305
+ "SELECT 1 FROM main.sqlite_master WHERE type='table' "
3306
+ "AND name='codex_source_roots'").fetchone() is not None
3307
+ else "cache_db.codex_source_roots")
3260
3308
  root_paths: dict[str, str] = {}
3261
3309
  for root_key, root_path in conn.execute(
3262
- "SELECT source_root_key, canonical_root_path FROM "
3263
- + thread_schema + ".codex_source_roots"
3310
+ "SELECT source_root_key, canonical_root_path FROM " + roots_table
3264
3311
  ):
3265
3312
  if isinstance(root_key, str) and isinstance(root_path, str):
3266
3313
  if root_path and os.path.isabs(root_path):