cctally 1.112.0 → 1.114.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 (58) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +4 -5
  3. package/bin/_cctally_cache.py +1683 -485
  4. package/bin/_cctally_core.py +223 -1
  5. package/bin/_cctally_dashboard.py +96 -18
  6. package/bin/_cctally_dashboard_perf.py +128 -9
  7. package/bin/_cctally_dashboard_sources.py +267 -204
  8. package/bin/_cctally_db.py +861 -18
  9. package/bin/_cctally_diagnosis_sources.py +22 -4
  10. package/bin/_cctally_doctor.py +136 -18
  11. package/bin/_cctally_five_hour.py +2 -2
  12. package/bin/_cctally_forecast.py +27 -6
  13. package/bin/_cctally_journal.py +67 -5
  14. package/bin/_cctally_milestone_history.py +82 -9
  15. package/bin/_cctally_percent_breakdown.py +3 -2
  16. package/bin/_cctally_quota.py +201 -33
  17. package/bin/_cctally_record.py +14 -0
  18. package/bin/_cctally_rederive.py +23 -9
  19. package/bin/_cctally_source_analytics.py +60 -15
  20. package/bin/_cctally_statusline.py +17 -0
  21. package/bin/_cctally_store.py +80 -5
  22. package/bin/_cctally_tui.py +4 -3
  23. package/bin/_cctally_weekrefs.py +128 -21
  24. package/bin/_lib_cache_report.py +35 -6
  25. package/bin/_lib_codex_conversation.py +32 -0
  26. package/bin/_lib_codex_conversation_query.py +164 -30
  27. package/bin/_lib_conversation_query.py +95 -56
  28. package/bin/_lib_conversation_retention.py +1605 -640
  29. package/bin/_lib_cost_provenance.py +53 -2
  30. package/bin/_lib_doctor.py +263 -69
  31. package/bin/_lib_fmt.py +11 -0
  32. package/bin/_lib_merge_gate.py +469 -51
  33. package/bin/_lib_perf.py +27 -1
  34. package/bin/_lib_pricing.py +209 -8
  35. package/bin/_lib_pricing_check.py +20 -4
  36. package/bin/_lib_reclaim_planner.py +792 -0
  37. package/bin/_lib_rederive.py +225 -77
  38. package/bin/_lib_snapshot_cache.py +21 -0
  39. package/bin/_lib_sqlite_close.py +81 -0
  40. package/bin/_lib_subscription_weeks.py +100 -17
  41. package/bin/_lib_tick_stats.py +217 -17
  42. package/bin/_lib_wal_checkpoint.py +1077 -0
  43. package/bin/_lib_write_budget.py +483 -0
  44. package/bin/_lib_write_io.py +703 -0
  45. package/bin/cctally +8 -0
  46. package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
  47. package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
  48. package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
  49. package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
  50. package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
  51. package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
  52. package/dashboard/static/assets/index-BKITk1CT.css +1 -0
  53. package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
  54. package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
  55. package/dashboard/static/dashboard.html +2 -2
  56. package/package.json +6 -1
  57. package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
  58. package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
@@ -353,13 +353,19 @@ def _load_conversation_index_rows(
353
353
  """
354
354
  rows: list = []
355
355
  detail_bytes: dict[tuple[str, int], int] = {}
356
+ # #901 Amendment 19 T1: index order, each timestamp tie run then put in
357
+ # physical order in Python (``kern.in_physical_order``), not a sorter.
356
358
  for (source_path, line_offset, timestamp_utc, turn_id, call_id, kind,
357
359
  event_type, record_family, model, content_digest, content_len,
358
- detail_json, detail_len) in conn.execute(
359
- "SELECT " + _NARROW_ROW_COLS + " FROM codex_conversation_messages "
360
- "WHERE conversation_key = ? "
361
- "ORDER BY timestamp_utc, source_path, line_offset",
362
- (conversation_key,),
360
+ detail_json, detail_len) in kern.in_physical_order(
361
+ conn.execute(
362
+ "SELECT " + _NARROW_ROW_COLS + " FROM codex_conversation_messages "
363
+ "WHERE conversation_key = ? "
364
+ "ORDER BY timestamp_utc, id",
365
+ (conversation_key,),
366
+ ),
367
+ timestamp=lambda row: row[2],
368
+ position=lambda row: (row[0], row[1]),
363
369
  ):
364
370
  rows.append(kern.CodexNormalizedRow(
365
371
  conversation_key=conversation_key, source_root_key="",
@@ -3872,34 +3878,165 @@ def _project_find_row(row, *, payload: dict | None = None, block: dict | None =
3872
3878
  return project_plain((RenderLeaf("t0", text),))
3873
3879
 
3874
3880
 
3881
+ #: Every ``codex_find_projection`` column in one fixed order: the builder's row
3882
+ #: tuples, the stored-row read and the write statements below all use it.
3883
+ #: ``(message_id, surface)`` (positions 0 and 5) is the table's primary key;
3884
+ #: everything else is an attribute the difference compares (#901 §5.3b).
3885
+ _FIND_PROJECTION_COLUMNS = (
3886
+ "message_id,conversation_key,item_key,block_key,container_block_key,"
3887
+ "surface,render_order,projected_text,leaves_json,disclosure_json,"
3888
+ "projection_version"
3889
+ )
3890
+ _FIND_PROJECTION_KEY = (0, 5)
3891
+ _FIND_PROJECTION_ORDER = 6
3892
+
3893
+ _FIND_PROJECTION_INSERT_SQL = (
3894
+ "INSERT INTO codex_find_projection "
3895
+ "(" + _FIND_PROJECTION_COLUMNS + ") "
3896
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?)"
3897
+ )
3898
+ #: An attribute change that keeps the row's place: the ordering index
3899
+ #: ``(conversation_key, render_order, message_id, surface)`` is untouched.
3900
+ _FIND_PROJECTION_UPDATE_IN_PLACE_SQL = (
3901
+ "UPDATE codex_find_projection SET item_key=?,block_key=?,"
3902
+ "container_block_key=?,projected_text=?,leaves_json=?,disclosure_json=?,"
3903
+ "projection_version=? WHERE message_id=? AND surface=?"
3904
+ )
3905
+ #: A moved row (its dense ``render_order`` shifted): the index entry moves too.
3906
+ _FIND_PROJECTION_UPDATE_MOVED_SQL = (
3907
+ "UPDATE codex_find_projection SET conversation_key=?,item_key=?,"
3908
+ "block_key=?,container_block_key=?,render_order=?,projected_text=?,"
3909
+ "leaves_json=?,disclosure_json=?,projection_version=? "
3910
+ "WHERE message_id=? AND surface=?"
3911
+ )
3912
+ _FIND_PROJECTION_DELETE_SQL = (
3913
+ "DELETE FROM codex_find_projection WHERE message_id=? AND surface=?"
3914
+ )
3915
+ _FIND_PROJECTION_GENERATION_SQL = (
3916
+ "INSERT INTO cache_meta(key,value) VALUES"
3917
+ "('codex_find_projection_generation','1') "
3918
+ "ON CONFLICT(key) DO UPDATE SET value=CAST(value AS INTEGER)+1"
3919
+ )
3920
+
3921
+
3922
+ def _write_codex_find_projection_difference(
3923
+ conn: sqlite3.Connection,
3924
+ conversation_key: str,
3925
+ built: list[tuple],
3926
+ ) -> int:
3927
+ """Make the stored projection of one conversation equal ``built``.
3928
+
3929
+ ``built`` is the complete canonical projection in the builder's order. Both
3930
+ sides are keyed by the primary key ``(message_id, surface)`` and compared
3931
+ on every other column; only the difference is written. Returns the number
3932
+ of rows deleted, inserted or updated (0 for an unchanged conversation).
3933
+ """
3934
+ built_by_key = {
3935
+ (row[_FIND_PROJECTION_KEY[0]], row[_FIND_PROJECTION_KEY[1]]): row
3936
+ for row in built
3937
+ }
3938
+ if len(built_by_key) != len(built):
3939
+ # The whole-rewrite writer raised on a duplicate key at INSERT time;
3940
+ # a difference must not silently keep only one of the two rows.
3941
+ raise sqlite3.IntegrityError(
3942
+ "UNIQUE constraint failed: codex_find_projection.message_id, "
3943
+ "codex_find_projection.surface"
3944
+ )
3945
+ stored = {
3946
+ (row[_FIND_PROJECTION_KEY[0]], row[_FIND_PROJECTION_KEY[1]]): row
3947
+ for row in conn.execute(
3948
+ "SELECT " + _FIND_PROJECTION_COLUMNS + " FROM codex_find_projection "
3949
+ "WHERE conversation_key=?",
3950
+ (conversation_key,),
3951
+ )
3952
+ }
3953
+ deletes = sorted(key for key in stored if key not in built_by_key)
3954
+ inserts: list[tuple] = []
3955
+ in_place: list[tuple] = []
3956
+ moved: list[tuple] = []
3957
+ for key, row in built_by_key.items():
3958
+ old = stored.get(key)
3959
+ if old is None:
3960
+ inserts.append(row)
3961
+ continue
3962
+ if tuple(old) == row:
3963
+ continue
3964
+ message_id, conversation, item, block, container, surface, order, *rest = row
3965
+ if old[1] == conversation and old[_FIND_PROJECTION_ORDER] == order:
3966
+ in_place.append((item, block, container, *rest, message_id, surface))
3967
+ else:
3968
+ moved.append(
3969
+ (conversation, item, block, container, order, *rest,
3970
+ message_id, surface)
3971
+ )
3972
+ if deletes:
3973
+ conn.executemany(_FIND_PROJECTION_DELETE_SQL, deletes)
3974
+ if in_place:
3975
+ conn.executemany(_FIND_PROJECTION_UPDATE_IN_PLACE_SQL, in_place)
3976
+ if moved:
3977
+ conn.executemany(_FIND_PROJECTION_UPDATE_MOVED_SQL, moved)
3978
+ if inserts:
3979
+ conn.executemany(_FIND_PROJECTION_INSERT_SQL, inserts)
3980
+ return len(deletes) + len(inserts) + len(in_place) + len(moved)
3981
+
3982
+
3983
+ def _load_conversation_rows(
3984
+ conn: sqlite3.Connection, conversation_key: str,
3985
+ ) -> list:
3986
+ """A conversation's WIDE normalized rows in physical order:
3987
+ ``(timestamp_utc, source_path, line_offset)``.
3988
+
3989
+ #901 Amendment 19 T1: read in ``(timestamp_utc, id)`` index order and each
3990
+ timestamp tie run put in physical order by ``kern.in_physical_order``; the
3991
+ old ``ORDER BY`` left a partial sorter over every tie run, and one
3992
+ conversation is not a bound."""
3993
+ return kern.in_physical_order(
3994
+ (
3995
+ kern.CodexNormalizedRow(*row)
3996
+ for row in conn.execute(
3997
+ "SELECT " + _ROW_COLS + " FROM codex_conversation_messages "
3998
+ "WHERE conversation_key=? "
3999
+ "ORDER BY timestamp_utc,id",
4000
+ (conversation_key,),
4001
+ )
4002
+ ),
4003
+ timestamp=lambda row: row.timestamp_utc,
4004
+ position=lambda row: (row.source_path, row.line_offset),
4005
+ )
4006
+
4007
+
3875
4008
  def materialize_codex_find_projection(
3876
4009
  conn: sqlite3.Connection,
3877
4010
  conversation_keys,
4011
+ *,
4012
+ cascaded: bool = False,
3878
4013
  ) -> None:
3879
- """Replace #482 projection rows for the affected conversations.
4014
+ """Bring #482 projection rows of the affected conversations up to date.
3880
4015
 
3881
4016
  The existing item/block builder is the only authority for native folds.
3882
4017
  Every searchable physical row keeps its own block key; a folded output or
3883
4018
  completion separately records the visual call block that owns it.
4019
+
4020
+ #901 §5.3b (W6): each conversation's complete canonical projection is still
4021
+ built in memory, exactly as before, but only its difference against the
4022
+ stored rows is written, so an unchanged conversation writes no row.
4023
+ ``codex_find_projection_generation`` advances exactly once per call that
4024
+ changed a row, or when the caller says ``cascaded=True``: it deleted
4025
+ messages of these conversations in the same transaction, so the message
4026
+ trigger already cascaded their projection rows away and the builder may
4027
+ find nothing left to change. A true no-op leaves the generation alone.
4028
+ Callers make one call per transaction, so this is once per transaction.
3884
4029
  """
3885
4030
  keys = sorted({key for key in conversation_keys if key})
3886
4031
  if not keys:
3887
4032
  return
4033
+ changed = 0
3888
4034
  for conversation_key in keys:
3889
- conn.execute(
3890
- "DELETE FROM codex_find_projection WHERE conversation_key=?",
3891
- (conversation_key,),
3892
- )
3893
- rows = [
3894
- kern.CodexNormalizedRow(*row)
3895
- for row in conn.execute(
3896
- "SELECT " + _ROW_COLS + " FROM codex_conversation_messages "
3897
- "WHERE conversation_key=? "
3898
- "ORDER BY timestamp_utc,source_path,line_offset",
3899
- (conversation_key,),
3900
- )
3901
- ]
4035
+ built: list[tuple] = []
4036
+ rows = _load_conversation_rows(conn, conversation_key)
3902
4037
  if not rows:
4038
+ changed += _write_codex_find_projection_difference(
4039
+ conn, conversation_key, built)
3903
4040
  continue
3904
4041
  kept, _suppressed = kern.pair_mirrors(rows)
3905
4042
  items = kern.canonical_items(kept)
@@ -3941,12 +4078,9 @@ def materialize_codex_find_projection(
3941
4078
  if row.kind in {"reasoning", "meta"} or surface != "body"
3942
4079
  else []
3943
4080
  )
3944
- conn.execute(
3945
- "INSERT INTO codex_find_projection "
3946
- "(message_id,conversation_key,item_key,block_key,"
3947
- "container_block_key,surface,render_order,projected_text,"
3948
- "leaves_json,disclosure_json,projection_version) "
3949
- "VALUES (?,?,?,?,?,?,?,?,?,?,?)",
4081
+ # The builder collects complete rows; the difference below writes
4082
+ # them (column order: `_FIND_PROJECTION_COLUMNS`).
4083
+ built.append(
3950
4084
  (
3951
4085
  message_id,
3952
4086
  conversation_key,
@@ -4032,11 +4166,11 @@ def materialize_codex_find_projection(
4032
4166
  container = completion_owner.get(physical_key, physical_key)
4033
4167
  store(row, container_block_key=container)
4034
4168
 
4035
- conn.execute(
4036
- "INSERT INTO cache_meta(key,value) VALUES"
4037
- "('codex_find_projection_generation','1') "
4038
- "ON CONFLICT(key) DO UPDATE SET value=CAST(value AS INTEGER)+1"
4039
- )
4169
+ changed += _write_codex_find_projection_difference(
4170
+ conn, conversation_key, built)
4171
+
4172
+ if changed or cascaded:
4173
+ conn.execute(_FIND_PROJECTION_GENERATION_SQL)
4040
4174
 
4041
4175
 
4042
4176
  def _fts_query(query: str, column: str | None) -> str:
@@ -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