cctally 1.63.0 → 1.65.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.
@@ -21,7 +21,7 @@ from datetime import datetime as _datetime, timezone as _timezone
21
21
  # Public surface (Plan 2): shipped in the npm tarball + brew formula + public
22
22
  # mirror — imported by the dashboard's conversation endpoints at runtime.
23
23
 
24
- from _lib_pricing import _calculate_entry_cost
24
+ from _lib_pricing import _calculate_entry_cost, _chip_for_model
25
25
  # #178: the on-demand load-full re-read helper re-stringifies a raw tool_result
26
26
  # content block the same way the parser does at ingest — reuse the parser's
27
27
  # _stringify so the full (un-capped) result text matches the cached/capped one.
@@ -55,6 +55,65 @@ from _lib_conversation import (
55
55
  _strip_remote_control_prefix,
56
56
  )
57
57
 
58
+ # ── Opt-in phase instrumentation (issue #276, Session C / M5) ────────────────
59
+ # This kernel is imported DIRECTLY by several tests (tests/test_conversation_*),
60
+ # NOT always through cctally._load_sibling, so it must NOT reach through
61
+ # sys.modules["cctally"]._load_sibling("_lib_perf") (Codex F1 — that becomes
62
+ # import-order dependent / KeyErrors when the kernel is loaded standalone). It
63
+ # instead resolves the SHARED _lib_perf instance so the assemble.* sub-tree
64
+ # nests under an endpoint / bench --trace root on the SAME thread-local stack:
65
+ # an `import _lib_perf` first (a hit in sys.modules — set by the dashboard's
66
+ # _perf_gate / bench pin / conftest — or resolvable via bin/ on sys.path), then
67
+ # a __file__-relative path-load registered under "_lib_perf" so later importers
68
+ # reuse it, then a no-op shim so a perf-import failure never breaks assembly.
69
+ import importlib.util as _importlib_util
70
+ import pathlib as _pathlib
71
+ import sys as _sys
72
+
73
+
74
+ class _PerfShim:
75
+ """No-op _lib_perf stand-in: phase() returns a context-manager no-op so the
76
+ kernel never hard-fails when the sibling is somehow unloadable."""
77
+
78
+ class _NullPhase:
79
+ def __enter__(self):
80
+ return self
81
+
82
+ def __exit__(self, *_exc):
83
+ return False
84
+
85
+ def set_count(self, *_a):
86
+ pass
87
+
88
+ def set_meta(self, **_kw):
89
+ pass
90
+
91
+ def phase(self, *_a, **_k):
92
+ return self._NullPhase()
93
+
94
+
95
+ _PERF = None
96
+
97
+
98
+ def _perf():
99
+ """Return the shared _lib_perf collector (near-noop when tracing is off)."""
100
+ global _PERF
101
+ if _PERF is None:
102
+ try:
103
+ import _lib_perf as _mod
104
+ _PERF = _mod
105
+ except Exception:
106
+ try:
107
+ _p = _pathlib.Path(__file__).with_name("_lib_perf.py")
108
+ _spec = _importlib_util.spec_from_file_location("_lib_perf", _p)
109
+ _mod = _importlib_util.module_from_spec(_spec)
110
+ _sys.modules.setdefault("_lib_perf", _mod)
111
+ _spec.loader.exec_module(_mod)
112
+ _PERF = _mod
113
+ except Exception:
114
+ _PERF = _PerfShim()
115
+ return _PERF
116
+
58
117
 
59
118
  _TITLE_MAX = 120
60
119
 
@@ -974,9 +1033,13 @@ def _rollup_authoritative(conn) -> bool:
974
1033
  # last_activity_utc string, so the caller MUST pass UTC-ISO bounds in the same
975
1034
  # format (...Z) — see bin/_lib_dashboard_dates.parse_filter_date_range.
976
1035
  _FILTER_KEYS = ("date_from", "date_to", "projects",
977
- "cost_min", "cost_max", "rebuild_min")
1036
+ "cost_min", "cost_max", "rebuild_min", "models")
978
1037
  # The rollup-only axes — the ones the live fallback cannot express. Used to set
979
1038
  # the page's filter_degraded flag when one is requested under the live branch.
1039
+ # NOTE (#278 Theme C): "models" is deliberately NOT here. The model axis is a
1040
+ # live session_id IN (…conversation_messages.model…) restriction that applies on
1041
+ # BOTH branches, so a model-only filter under a pending rollup still applies and
1042
+ # must NEVER set filter_degraded.
980
1043
  _ROLLUP_ONLY_FILTER_KEYS = ("projects", "cost_min", "cost_max", "rebuild_min")
981
1044
 
982
1045
 
@@ -986,7 +1049,50 @@ def _empty_filters() -> dict:
986
1049
  return {k: None for k in _FILTER_KEYS}
987
1050
 
988
1051
 
989
- def _rollup_where(filters):
1052
+ def _model_clause(conn, families):
1053
+ """Resolve a model-family filter to a ``session_id`` predicate fragment
1054
+ (#278 Theme C). Returns ``(clause_sql, params)``:
1055
+
1056
+ * ``families`` falsy (axis ABSENT) -> ``("", [])`` (no restriction —
1057
+ the byte-stable unfiltered path).
1058
+ * ``families`` present, resolves to ids -> a ``session_id IN (subquery)``
1059
+ predicate over ``conversation_messages.model``.
1060
+ * ``families`` present, resolves to NO ids -> a match-NOTHING predicate.
1061
+
1062
+ The middle/last split is load-bearing: an axis that is PRESENT but matches no
1063
+ models (a family with no sessions, an 'other'/synthetic-only selection, a
1064
+ typo'd family in a hand-crafted URL) must restrict to ZERO rows, never widen
1065
+ to everything. Family classification goes through ``_chip_for_model`` — the
1066
+ SAME classifier ``list_conversation_facets`` uses — so a family's facet count
1067
+ equals the rows its chip yields, and 'any appearance' semantics + correct
1068
+ 'other'-exclusion come for free. 'other' is unreachable as a selectable
1069
+ family (never emitted by the client) and is simply never collected here.
1070
+
1071
+ The fragment ANDs into any WHERE that has a ``session_id`` column, so the
1072
+ IDENTICAL string composes into both the ``conversation_sessions`` rollup and
1073
+ the ``conversation_messages`` live branch verbatim (spec §1)."""
1074
+ if not families:
1075
+ return "", []
1076
+ wanted = set(families)
1077
+ ids = [
1078
+ m for (m,) in conn.execute(
1079
+ "SELECT DISTINCT model FROM conversation_messages "
1080
+ "WHERE model IS NOT NULL AND model != ''"
1081
+ )
1082
+ if _chip_for_model(m) in wanted
1083
+ ]
1084
+ if not ids:
1085
+ # Present-but-empty: match nothing (NOT ("", []) which would be unfiltered).
1086
+ return "session_id IN (SELECT session_id FROM conversation_messages WHERE 0)", []
1087
+ ph = ",".join("?" for _ in ids)
1088
+ return (
1089
+ "session_id IN (SELECT DISTINCT session_id FROM conversation_messages "
1090
+ "WHERE session_id IS NOT NULL AND model IN (%s))" % ph,
1091
+ ids,
1092
+ )
1093
+
1094
+
1095
+ def _rollup_where(filters, model_clause=("", [])):
990
1096
  """(sql_fragment, params) for the ROLLUP branch — all four axes are stored
991
1097
  columns. Returns (" WHERE ...", [params]) or ("", []) when no axis is set.
992
1098
  Project IN-list naturally excludes empty/NULL project_label (neither the ''
@@ -997,7 +1103,12 @@ def _rollup_where(filters):
997
1103
  (``<``) emitted by ``_lib_dashboard_dates.parse_filter_date_range``. The
998
1104
  strict ``<`` is load-bearing: it makes the lex compare against the stored
999
1105
  mixed-precision ``last_activity_utc`` (whole-second AND millisecond ``...Z``)
1000
- chronologically correct at the day edge (review Finding 1)."""
1106
+ chronologically correct at the day edge (review Finding 1).
1107
+
1108
+ ``model_clause`` (#278 Theme C) is the precomputed ``(clause_sql, params)``
1109
+ from ``_model_clause`` — resolved ONCE per request by the caller (which has
1110
+ ``conn``), so ``_rollup_where`` stays connection-free. The model predicate is
1111
+ a ``session_id IN (…)`` fragment that ANDs cleanly into the rollup WHERE."""
1001
1112
  clauses, params = [], []
1002
1113
  if filters["date_from"] is not None:
1003
1114
  clauses.append("last_activity_utc >= ?"); params.append(filters["date_from"])
@@ -1012,6 +1123,9 @@ def _rollup_where(filters):
1012
1123
  clauses.append("cost_usd <= ?"); params.append(filters["cost_max"])
1013
1124
  if filters["rebuild_min"] is not None:
1014
1125
  clauses.append("cache_rebuild_count >= ?"); params.append(filters["rebuild_min"])
1126
+ mc_sql, mc_params = model_clause
1127
+ if mc_sql:
1128
+ clauses.append(mc_sql); params.extend(mc_params)
1015
1129
  return (" WHERE " + " AND ".join(clauses)) if clauses else "", params
1016
1130
 
1017
1131
 
@@ -1060,19 +1174,29 @@ def _resolve_search_session_filter(conn, filters):
1060
1174
  any_axis = any(filters[k] is not None for k in _FILTER_KEYS)
1061
1175
  if not any_axis:
1062
1176
  return "", [], False
1177
+ # #278 Theme C: resolve the model-family clause once and AND it onto whatever
1178
+ # session-scope restriction this returns — including the model-only case
1179
+ # (where the model clause BECOMES the restriction). It applies on BOTH
1180
+ # branches and never degrades (model is not a rollup-only axis).
1181
+ model_clause = _model_clause(conn, filters["models"])
1182
+ mc_sql, mc_params = model_clause
1063
1183
  if _rollup_authoritative(conn):
1064
- where, params = _rollup_where(filters)
1184
+ where, params = _rollup_where(filters, model_clause) # model folded in
1065
1185
  return "SELECT session_id FROM conversation_sessions" + where, params, False
1066
- # Live fallback: only the date axis is expressible over raw messages.
1186
+ # Live fallback: only the date axis + the (live-expressible) model axis apply
1187
+ # over raw messages; the rollup-only axes drop and degrade.
1067
1188
  degraded = any(filters[k] is not None for k in _ROLLUP_ONLY_FILTER_KEYS)
1068
1189
  having, hparams = _live_having(filters)
1190
+ where_extra = (" AND " + mc_sql) if mc_sql else ""
1069
1191
  sub = (
1070
1192
  "SELECT session_id FROM conversation_messages "
1071
- "WHERE session_id IS NOT NULL GROUP BY session_id" + having)
1072
- return sub, hparams, degraded
1193
+ "WHERE session_id IS NOT NULL" + where_extra +
1194
+ " GROUP BY session_id" + having)
1195
+ return sub, [*mc_params, *hparams], degraded
1073
1196
 
1074
1197
 
1075
- def _list_session_rows_rollup(conn, order, limit, offset, filters=None):
1198
+ def _list_session_rows_rollup(conn, order, limit, offset, filters=None,
1199
+ model_clause=("", [])):
1076
1200
  """FAST path: read the pre-aggregated rail rows straight from the
1077
1201
  conversation_sessions rollup (spec §3). No GROUP BY, no temp B-tree for the
1078
1202
  ``recent`` sort. Returns (session_id, msg_count, started, last_activity)
@@ -1080,8 +1204,9 @@ def _list_session_rows_rollup(conn, order, limit, offset, filters=None):
1080
1204
  assembly is identical. The optional ``filters`` dict pushes the four-axis
1081
1205
  browse predicate (date/project/cost/rebuild — all stored columns) into the
1082
1206
  WHERE BEFORE LIMIT/OFFSET, so ``has_more``/``next_offset`` stay correct over
1083
- the filtered set."""
1084
- where, params = _rollup_where(filters or _empty_filters())
1207
+ the filtered set. ``model_clause`` (#278 Theme C) is the precomputed model
1208
+ predicate folded into the same WHERE."""
1209
+ where, params = _rollup_where(filters or _empty_filters(), model_clause)
1085
1210
  return conn.execute(
1086
1211
  "SELECT session_id, msg_count, "
1087
1212
  " started_utc AS started, last_activity_utc AS last_activity "
@@ -1092,7 +1217,8 @@ def _list_session_rows_rollup(conn, order, limit, offset, filters=None):
1092
1217
  ).fetchall()
1093
1218
 
1094
1219
 
1095
- def _list_session_rows_live(conn, order, limit, offset, filters=None):
1220
+ def _list_session_rows_live(conn, order, limit, offset, filters=None,
1221
+ model_clause=("", [])):
1096
1222
  """RETAINED fallback (Codex gate BLOCKER 2): the original live GROUP BY over
1097
1223
  conversation_messages, used while the rollup is not authoritative (the flag
1098
1224
  is set — e.g. an existing install before its first sync, or permanently
@@ -1100,38 +1226,81 @@ def _list_session_rows_live(conn, order, limit, offset, filters=None):
1100
1226
  construction; ``order`` here is the _SORTS_LIVE aggregate expression. The
1101
1227
  optional ``filters`` dict applies ONLY the date axis (via HAVING over
1102
1228
  MAX(timestamp_utc)); the rollup-only axes (project/cost/rebuild) cannot be
1103
- expressed here and are dropped — the caller flags filter_degraded."""
1229
+ expressed here and are dropped — the caller flags filter_degraded.
1230
+
1231
+ ``model_clause`` (#278 Theme C) IS expressible here (the model lives on
1232
+ ``conversation_messages``), so it folds into the WHERE (before GROUP BY) and
1233
+ the axis never degrades. The existing ``session_id IS NOT NULL`` guard ANDs
1234
+ cleanly with the model predicate; ``mc_params`` precede ``hparams`` because
1235
+ the model clause precedes the HAVING in the SQL text."""
1104
1236
  having, hparams = _live_having(filters or _empty_filters())
1237
+ mc_sql, mc_params = model_clause
1238
+ where_extra = (" AND " + mc_sql) if mc_sql else ""
1105
1239
  return conn.execute(
1106
1240
  "SELECT session_id, COUNT(*) AS msg_count, "
1107
1241
  " MIN(timestamp_utc) AS started, MAX(timestamp_utc) AS last_activity "
1108
1242
  "FROM conversation_messages "
1109
- "WHERE session_id IS NOT NULL "
1243
+ "WHERE session_id IS NOT NULL" + where_extra + " "
1110
1244
  "GROUP BY session_id"
1111
1245
  + having +
1112
1246
  " ORDER BY " + order + " LIMIT ? OFFSET ?",
1113
- (*hparams, limit + 1, offset),
1247
+ (*mc_params, *hparams, limit + 1, offset),
1114
1248
  ).fetchall()
1115
1249
 
1116
1250
 
1251
+ _MODEL_FAMILY_ORDER = ("opus", "sonnet", "haiku", "fable") # display order; 'other' excluded
1252
+
1253
+
1117
1254
  def list_conversation_facets(conn) -> dict:
1118
- """Distinct projects (+ conversation counts) for the browse filter
1119
- multi-select (spec §2). Reads the rollup; cheap GROUP BY. Empty/NULL
1120
- project labels are dropped (a no-cwd session stores '' and a not-yet-filled
1121
- row stores NULL neither is a real selectable project). Sorted ascending
1122
- by label so the popover renders a stable list. Returns
1123
- ``{"projects": [{"project_label": str, "count": int}, ...]}``."""
1255
+ """Distinct projects (+ conversation counts) AND per-model-family session
1256
+ counts for the browse filter multi-selects (spec §2 + #278 Theme C).
1257
+
1258
+ ``projects`` reads the rollup; cheap GROUP BY. Empty/NULL project labels are
1259
+ dropped (a no-cwd session stores '' and a not-yet-filled row stores NULL —
1260
+ neither is a real selectable project). Sorted ascending by label so the
1261
+ popover renders a stable list.
1262
+
1263
+ ``models`` folds distinct ``(session_id, model)`` pairs from
1264
+ ``conversation_messages`` (where ``session_id IS NOT NULL`` — matching the
1265
+ browse/live row sources, so a null-session row can't inflate a count above
1266
+ the filterable sessions) to distinct ``(session_id, family)`` via
1267
+ ``_chip_for_model``, then counts sessions per family. Fold-then-count, NOT
1268
+ sum-of-per-model-distinct: a session that used two Opus point-releases counts
1269
+ ONCE under opus, and an Opus+Haiku session counts under BOTH. Families are
1270
+ emitted present-only, excluding ``other``, in a fixed display order so the
1271
+ popover list is stable — so a family's facet count equals the rows selecting
1272
+ its chip yields (self-consistent with the §1 filter semantics).
1273
+
1274
+ Returns ``{"projects": [{"project_label", "count"}, ...],
1275
+ "models": [{"family", "count"}, ...]}``."""
1124
1276
  rows = conn.execute(
1125
1277
  "SELECT project_label, COUNT(*) FROM conversation_sessions "
1126
1278
  "WHERE project_label IS NOT NULL AND project_label != '' "
1127
1279
  "GROUP BY project_label ORDER BY project_label"
1128
1280
  ).fetchall()
1129
- return {"projects": [{"project_label": p, "count": n} for p, n in rows]}
1281
+ projects = [{"project_label": p, "count": n} for p, n in rows]
1282
+
1283
+ fam_sessions: dict = {}
1284
+ for sid, model in conn.execute(
1285
+ "SELECT DISTINCT session_id, model FROM conversation_messages "
1286
+ "WHERE session_id IS NOT NULL AND model IS NOT NULL AND model != ''"
1287
+ ):
1288
+ fam = _chip_for_model(model)
1289
+ if fam == "other":
1290
+ continue
1291
+ fam_sessions.setdefault(fam, set()).add(sid)
1292
+ models = [
1293
+ {"family": fam, "count": len(fam_sessions[fam])}
1294
+ for fam in _MODEL_FAMILY_ORDER
1295
+ if fam in fam_sessions
1296
+ ]
1297
+ return {"projects": projects, "models": models}
1130
1298
 
1131
1299
 
1132
1300
  def list_conversations(conn, *, sort="recent", limit=50, offset=0,
1133
1301
  date_from=None, date_to=None, projects=None,
1134
- cost_min=None, cost_max=None, rebuild_min=None) -> dict:
1302
+ cost_min=None, cost_max=None, rebuild_min=None,
1303
+ models=None) -> dict:
1135
1304
  """All-history per-session browse rows (spec §3.1). NOT 365-day bounded.
1136
1305
 
1137
1306
  Reads the conversation_sessions rollup (Task A) when it is authoritative —
@@ -1161,12 +1330,18 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
1161
1330
  "cost_min": cost_min,
1162
1331
  "cost_max": cost_max,
1163
1332
  "rebuild_min": rebuild_min,
1333
+ "models": list(models) if models else None,
1164
1334
  }
1335
+ # Resolve the model-family clause ONCE (needs conn; the WHERE builders stay
1336
+ # connection-free). It applies on BOTH branches and never degrades — model is
1337
+ # NOT a rollup-only axis (#278 Theme C).
1338
+ model_clause = _model_clause(conn, filters["models"])
1165
1339
  degraded = False
1166
1340
  sort_degraded = False
1167
1341
  if _rollup_authoritative(conn):
1168
1342
  order = _SORTS.get(sort, _SORTS["recent"])
1169
- rows = _list_session_rows_rollup(conn, order, limit, offset, filters)
1343
+ rows = _list_session_rows_rollup(conn, order, limit, offset, filters,
1344
+ model_clause)
1170
1345
  else:
1171
1346
  order = _SORTS_LIVE.get(sort, _SORTS_LIVE["recent"])
1172
1347
  degraded = any(
@@ -1175,7 +1350,8 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
1175
1350
  # an unknown sort ALSO falls back to recent here (via _SORTS_LIVE.get),
1176
1351
  # but must stay byte-stable, so only the known rollup-only sorts flag it.
1177
1352
  sort_degraded = sort in _DEGRADABLE_SORTS
1178
- rows = _list_session_rows_live(conn, order, limit, offset, filters)
1353
+ rows = _list_session_rows_live(conn, order, limit, offset, filters,
1354
+ model_clause)
1179
1355
  has_more = len(rows) > limit
1180
1356
  rows = rows[:limit]
1181
1357
  session_ids = [r[0] for r in rows]
@@ -1271,10 +1447,21 @@ def _assemble_session(conn, session_id):
1271
1447
  BY CONSTRUCTION (Codex F8 — one grouping pass, never two implementations).
1272
1448
  Returns None for an unknown session.
1273
1449
  """
1450
+ # Opt-in phase instrumentation (issue #276 M5): an outer `assemble` root
1451
+ # wrapping eight structural child seams. Explicit __enter__/__exit__ (not a
1452
+ # `with` re-indent) so the existing body stays byte-for-byte unchanged; each
1453
+ # stage is closed before the next opens, and the identity-aware Phase.__exit__
1454
+ # makes the outer close survive the unknown-session early return. Near-noop
1455
+ # when CCTALLY_PERF_TRACE is off (sp.phase() -> the shared _NULL_PHASE).
1456
+ sp = _perf()
1457
+ _root = sp.phase("assemble"); _root.__enter__()
1458
+ _ph = sp.phase("assemble.read"); _ph.__enter__()
1274
1459
  exists = conn.execute(
1275
1460
  "SELECT 1 FROM conversation_messages WHERE session_id=? LIMIT 1",
1276
1461
  (session_id,)).fetchone()
1277
1462
  if exists is None:
1463
+ _ph.__exit__(None, None, None)
1464
+ _root.__exit__(None, None, None)
1278
1465
  return None
1279
1466
 
1280
1467
  # Pull the session ordered; dedup logical messages by (session_id, uuid),
@@ -1290,7 +1477,9 @@ def _assemble_session(conn, session_id):
1290
1477
  " source_tool_use_id, stop_reason, attribution_skill, attribution_plugin "
1291
1478
  "FROM conversation_messages WHERE session_id=? "
1292
1479
  "ORDER BY timestamp_utc, id", (session_id,)).fetchall()
1480
+ _ph.set_count(len(raw)); _ph.__exit__(None, None, None)
1293
1481
 
1482
+ _ph = sp.phase("assemble.dedup"); _ph.__enter__()
1294
1483
  seen_uuid = set()
1295
1484
  logical = [] # canonical physical rows, in order
1296
1485
  for row in raw:
@@ -1299,7 +1488,9 @@ def _assemble_session(conn, session_id):
1299
1488
  continue
1300
1489
  seen_uuid.add(u)
1301
1490
  logical.append(row)
1491
+ _ph.set_count(len(logical)); _ph.__exit__(None, None, None)
1302
1492
 
1493
+ _ph = sp.phase("assemble.build"); _ph.__enter__()
1303
1494
  # Group assistant fragments sharing (msg_id, req_id) into one turn item over
1304
1495
  # the WHOLE logical list — NOT by adjacency. Real tool-using transcripts
1305
1496
  # interleave a tool_result (a `user`/tool_result item) between fragments of
@@ -1353,7 +1544,9 @@ def _assemble_session(conn, session_id):
1353
1544
  items.append(it)
1354
1545
  if etype == "assistant": # null-msg_id assistant: index its uses too
1355
1546
  _index_tool_uses(it)
1547
+ _ph.set_count(len(items)); _ph.__exit__(None, None, None)
1356
1548
 
1549
+ _ph = sp.phase("assemble.correlate"); _ph.__enter__()
1357
1550
  # ---- Subagent-kind correlation (#166); MUST run before Phase 2 fold ----
1358
1551
  # Reads AND strips (pop) the parser-only keys in one pass, so the returned
1359
1552
  # tool_call/tool_result block shapes are unchanged — the only new output is
@@ -1473,7 +1666,9 @@ def _assemble_session(conn, session_id):
1473
1666
  _entry["parent_subagent_key"] = _owner["subagent_key"]
1474
1667
  _entry["spawn_uuid"] = _owner["anchor"]["uuid"]
1475
1668
  _entry["spawn_tool_use_id"] = _tuid
1669
+ _ph.set_meta(subagents=len(subagent_meta)); _ph.__exit__(None, None, None)
1476
1670
 
1671
+ _ph = sp.phase("assemble.fold"); _ph.__enter__()
1477
1672
  # ---- Phase 2: fold each tool_result item into its owning assistant item ----
1478
1673
  drop = set() # id() of folded placeholder items
1479
1674
  for tr in tool_result_items:
@@ -1545,7 +1740,9 @@ def _assemble_session(conn, session_id):
1545
1740
 
1546
1741
  # ---- Phase 3b: fold the Task* op stream into per-run checklist snapshots ----
1547
1742
  _fold_task_runs(items, task_link)
1743
+ _ph.__exit__(None, None, None)
1548
1744
 
1745
+ _ph = sp.phase("assemble.classify"); _ph.__enter__()
1549
1746
  # ---- Phase 4: classify injected meta items (skill / command / context) ----
1550
1747
  # `meta` rows (the parser's isMeta classification) AND — only while the 005
1551
1748
  # reingest is still pending — not-yet-reingested `human` rows whose body is a
@@ -1620,7 +1817,16 @@ def _assemble_session(conn, session_id):
1620
1817
  _skill_drop.add(id(it))
1621
1818
  if _skill_drop:
1622
1819
  items = [it for it in items if id(it) not in _skill_drop]
1623
-
1820
+ _ph.__exit__(None, None, None)
1821
+
1822
+ _ph = sp.phase("assemble.cost"); _ph.__enter__()
1823
+ # DB-read shape (Codex F4): _turn_cost_map / _turn_usage_map each chunk their
1824
+ # session_entries reads at 400 keys, so a big session is many SELECTs in the
1825
+ # cost stage, not two. Record the key count + both chunk counts.
1826
+ _turn_keys = len(turn_index)
1827
+ _cost_chunks = (_turn_keys + 399) // 400 if _turn_keys else 0
1828
+ _ph.set_meta(turn_keys=_turn_keys, cost_chunks=_cost_chunks,
1829
+ usage_chunks=_cost_chunks)
1624
1830
  costs = _turn_cost_map(conn, list(turn_index))
1625
1831
  # #177: per-turn token usage from the SAME deduped session_entries row cost
1626
1832
  # uses (a separate map; _turn_cost_map is unchanged for the search path).
@@ -1646,7 +1852,9 @@ def _assemble_session(conn, session_id):
1646
1852
  del it["_req_id"]
1647
1853
  it.pop("_has_prose", None)
1648
1854
  header_cost = round(header_cost, 6)
1855
+ _ph.__exit__(None, None, None)
1649
1856
 
1857
+ _ph = sp.phase("assemble.finalize"); _ph.__enter__()
1650
1858
  # §4 1c — async completion. A background subagent's launch result carries
1651
1859
  # status:"async_launched" and NO totals; completion arrives as a separate
1652
1860
  # <task-notification> meta row (text populated by Phase 4). Join it back, and
@@ -1712,6 +1920,7 @@ def _assemble_session(conn, session_id):
1712
1920
  # order-dependent). Healthy turns get no key (absent, not zero). Shared
1713
1921
  # assembly -> the flag reaches BOTH the reader detail and the outline.
1714
1922
  _stamp_cache_failures(items)
1923
+ _ph.set_count(len(items)); _ph.__exit__(None, None, None)
1715
1924
 
1716
1925
  # Strip the internal Phase-4b threading key from EVERY item (meta/human items
1717
1926
  # carry it too, not just assistant turns) so it never surfaces in the public
@@ -1719,10 +1928,81 @@ def _assemble_session(conn, session_id):
1719
1928
  for it in items:
1720
1929
  it.pop("_source_tool_use_id", None)
1721
1930
 
1931
+ _root.__exit__(None, None, None)
1722
1932
  return {"items": items, "logical": logical,
1723
1933
  "subagent_meta": subagent_meta, "header_cost": header_cost}
1724
1934
 
1725
1935
 
1936
+ # --- Assembly memo (issue #278 Theme B) ------------------------------------
1937
+ # A tiny thread-safe LRU in FRONT of _assemble_session so the always-open reader
1938
+ # stops re-running the full pipeline on every idle SSE tick. Keyed by the
1939
+ # conversation_sessions watermark PLUS the #270 session_entries_mutation_seq
1940
+ # (the latter catches id-stable cost/token finalization the watermark misses —
1941
+ # Codex F1). Bypassed (and cleared) when the rollup is non-authoritative so a
1942
+ # destructive reingest can never serve a stale entry under an unchanged
1943
+ # watermark (Codex F2). Module-level + threading.Lock, following the
1944
+ # _DOCTOR_MEMO precedent (the dashboard is a threading HTTP server).
1945
+ import threading as _threading
1946
+ from collections import OrderedDict as _OrderedDict
1947
+
1948
+ _ASM_MEMO_LOCK = _threading.Lock()
1949
+ _ASM_MEMO: "_OrderedDict" = _OrderedDict() # key -> asm dict
1950
+ _ASM_MEMO_CAP = 4 # main reader + comparison pair + 1 switch
1951
+
1952
+
1953
+ def _assemble_memo_clear() -> None:
1954
+ """Drop every memo entry (tests; and the non-authoritative bypass)."""
1955
+ with _ASM_MEMO_LOCK:
1956
+ _ASM_MEMO.clear()
1957
+
1958
+
1959
+ def _assemble_memo_key(conn, session_id):
1960
+ """Return (session_id, msg_count, last_activity_utc, entry_mutation_seq), or
1961
+ None to signal 'bypass the memo' — a missing rollup row or an unreadable
1962
+ cache_meta (bare/path-less conn). The caller has already confirmed the
1963
+ rollup is authoritative."""
1964
+ try:
1965
+ row = conn.execute(
1966
+ "SELECT msg_count, last_activity_utc FROM conversation_sessions "
1967
+ "WHERE session_id=?", (session_id,)).fetchone()
1968
+ except sqlite3.OperationalError:
1969
+ return None
1970
+ if row is None:
1971
+ return None
1972
+ try:
1973
+ seq_row = conn.execute(
1974
+ "SELECT value FROM cache_meta "
1975
+ "WHERE key='session_entries_mutation_seq'").fetchone()
1976
+ except sqlite3.OperationalError:
1977
+ return None
1978
+ seq = int(seq_row[0]) if seq_row and seq_row[0] is not None else 0
1979
+ return (session_id, row[0], row[1], seq)
1980
+
1981
+
1982
+ def _assemble_session_memoized(conn, session_id):
1983
+ """Memoized _assemble_session. Same return contract (dict or None)."""
1984
+ if not _rollup_authoritative(conn):
1985
+ _assemble_memo_clear() # Codex F2: no pre-reingest survivors
1986
+ return _assemble_session(conn, session_id)
1987
+ key = _assemble_memo_key(conn, session_id)
1988
+ if key is None:
1989
+ return _assemble_session(conn, session_id)
1990
+ with _ASM_MEMO_LOCK:
1991
+ hit = _ASM_MEMO.get(key)
1992
+ if hit is not None:
1993
+ _ASM_MEMO.move_to_end(key)
1994
+ return hit
1995
+ asm = _assemble_session(conn, session_id)
1996
+ if asm is None:
1997
+ return None # don't cache unknown-session negatives
1998
+ with _ASM_MEMO_LOCK:
1999
+ _ASM_MEMO[key] = asm
2000
+ _ASM_MEMO.move_to_end(key)
2001
+ while len(_ASM_MEMO) > _ASM_MEMO_CAP:
2002
+ _ASM_MEMO.popitem(last=False)
2003
+ return asm
2004
+
2005
+
1726
2006
  def get_conversation(conn, session_id, *, after=None, before=None, tail=False,
1727
2007
  limit=500):
1728
2008
  """Reader payload for one session (spec §3.2). Returns None for an unknown
@@ -1745,8 +2025,18 @@ def get_conversation(conn, session_id, *, after=None, before=None, tail=False,
1745
2025
  if sum(1 for x in (after is not None, before is not None, bool(tail)) if x) > 1:
1746
2026
  raise ValueError("after/before/tail are mutually exclusive")
1747
2027
  limit = max(1, min(int(limit), 1000))
1748
- asm = _assemble_session(conn, session_id)
2028
+ # Reader-path phases (#276 M5): an outer `conversation.detail` (cursor meta)
2029
+ # nesting the `assemble` sub-tree, plus a `conversation.paginate` child over
2030
+ # the page slice. Explicit __enter__/__exit__ so the multiple early returns
2031
+ # each close cleanly. Near-noop when tracing is off.
2032
+ _rp = _perf()
2033
+ _det = _rp.phase("conversation.detail"); _det.__enter__()
2034
+ _det.set_meta(cursor=("after" if after is not None else
2035
+ "before" if before is not None else
2036
+ "tail" if tail else "none"))
2037
+ asm = _assemble_session_memoized(conn, session_id)
1749
2038
  if asm is None:
2039
+ _det.__exit__(None, None, None)
1750
2040
  return None
1751
2041
  items = asm["items"]
1752
2042
  logical = asm["logical"]
@@ -1812,12 +2102,14 @@ def get_conversation(conn, session_id, *, after=None, before=None, tail=False,
1812
2102
  elif before is not None:
1813
2103
  b = _idx(before)
1814
2104
  if b is None: # stale cursor → empty page (M1)
2105
+ _det.__exit__(None, None, None)
1815
2106
  return _stale_empty_page()
1816
2107
  end = b
1817
2108
  start = max(0, end - limit)
1818
2109
  elif after is not None:
1819
2110
  a = _idx(after)
1820
2111
  if a is None: # stale cursor → empty page (M1)
2112
+ _det.__exit__(None, None, None)
1821
2113
  return _stale_empty_page()
1822
2114
  start = a + 1
1823
2115
  end = min(start + limit, N)
@@ -1825,6 +2117,7 @@ def get_conversation(conn, session_id, *, after=None, before=None, tail=False,
1825
2117
  start = 0
1826
2118
  end = min(limit, N)
1827
2119
 
2120
+ _pg = _rp.phase("conversation.paginate"); _pg.__enter__()
1828
2121
  page = items[start:end]
1829
2122
  has_more = end < N
1830
2123
  has_prev = start > 0
@@ -1838,19 +2131,34 @@ def get_conversation(conn, session_id, *, after=None, before=None, tail=False,
1838
2131
  # item before emit, so a pre-fix row already indexed with raw SGR renders
1839
2132
  # clean (the read-time half of the no-forced-reingest contract). tool_result
1840
2133
  # blocks are EXCLUDED — Bash AnsiText (#177 S3) renders their SGR colors.
2134
+ # #278: page items are shared out of the assembly memo, so build COPIES here
2135
+ # rather than mutating in place. Bounded to <=1000 page items; cheap next to a
2136
+ # full assemble, and it makes the memo provably safe.
2137
+ patched = []
1841
2138
  for it in page:
1842
- it["anchor"]["session_id"] = session_id
1843
- if it.get("text"):
1844
- it["text"] = _strip_ansi(it["text"])
2139
+ nit = dict(it)
2140
+ nit["anchor"] = {**it["anchor"], "session_id": session_id}
2141
+ if nit.get("text"):
2142
+ nit["text"] = _strip_ansi(nit["text"])
2143
+ new_blocks = []
1845
2144
  for b in it["blocks"]:
1846
2145
  if b.get("kind") in ("text", "thinking") and b.get("text"):
1847
- b["text"] = _strip_ansi(b["text"])
2146
+ nb = dict(b)
2147
+ nb["text"] = _strip_ansi(nb["text"])
2148
+ new_blocks.append(nb)
2149
+ else:
2150
+ new_blocks.append(b)
2151
+ nit["blocks"] = new_blocks
2152
+ patched.append(nit)
2153
+ page = patched
2154
+ _pg.set_count(len(page)); _pg.__exit__(None, None, None)
1848
2155
 
1849
2156
  first = logical[0]
1850
2157
  last = logical[-1]
1851
2158
  # #243: main-session models first (r[6]=model, r[12]=source_path,
1852
2159
  # r[9]=is_sidechain) so opus outranks a haiku subagent, not sorted().
1853
2160
  models = _models_main_first((r[6], r[12], r[9]) for r in logical)
2161
+ _det.__exit__(None, None, None)
1854
2162
  return {
1855
2163
  "session_id": session_id,
1856
2164
  "title": _title,
@@ -1898,7 +2206,7 @@ def get_conversation_outline(conn, session_id):
1898
2206
  every consumer tolerate it. Stats derive from the SAME assembled items the
1899
2207
  reader pages (Codex F8). Returns None for an unknown session.
1900
2208
  """
1901
- asm = _assemble_session(conn, session_id)
2209
+ asm = _assemble_session_memoized(conn, session_id)
1902
2210
  if asm is None:
1903
2211
  return None
1904
2212
  items, logical = asm["items"], asm["logical"]
@@ -2044,7 +2352,7 @@ def get_conversation_prompts(conn, session_id):
2044
2352
  (the handler's 404 sentinel) — the same contract ``get_conversation_outline``
2045
2353
  uses (``asm is None``).
2046
2354
  """
2047
- asm = _assemble_session(conn, session_id)
2355
+ asm = _assemble_session_memoized(conn, session_id)
2048
2356
  if asm is None:
2049
2357
  return None
2050
2358
  from _lib_conversation_export import extract_prompt_entries
@@ -2062,7 +2370,7 @@ def get_conversation_export(conn, session_id, scope):
2062
2370
  or ``None`` for an unknown session (the handler's 404 sentinel). ``scope`` is
2063
2371
  pre-validated by the handler (an unknown scope never reaches here)."""
2064
2372
  from _lib_conversation_export import export_session_markdown
2065
- asm = _assemble_session(conn, session_id)
2373
+ asm = _assemble_session_memoized(conn, session_id)
2066
2374
  if asm is None:
2067
2375
  return None
2068
2376
  items = asm["items"]
@@ -2314,7 +2622,8 @@ def _search_depth(conn) -> str:
2314
2622
  def search_conversations(conn, query, *, limit=50, offset=0,
2315
2623
  kind="all", fts_available=None,
2316
2624
  date_from=None, date_to=None, projects=None,
2317
- cost_min=None, cost_max=None, rebuild_min=None) -> dict:
2625
+ cost_min=None, cost_max=None, rebuild_min=None,
2626
+ models=None) -> dict:
2318
2627
  """Cross-session search (spec §3.3). Uses FTS5 when available (bm25 rank +
2319
2628
  snippet); else a LIKE scan with a manual snippet. Hits deduped by
2320
2629
  (session_id, uuid); each carries the turn's cost. `fts_available` overrides
@@ -2362,6 +2671,10 @@ def search_conversations(conn, query, *, limit=50, offset=0,
2362
2671
  "date_from": date_from, "date_to": date_to,
2363
2672
  "projects": list(projects) if projects else None,
2364
2673
  "cost_min": cost_min, "cost_max": cost_max, "rebuild_min": rebuild_min,
2674
+ # #278 Theme C: the model-family axis, folded in verbatim like projects.
2675
+ # Applies to EVERY search kind via _resolve_search_session_filter, on
2676
+ # both the rollup and live branches, and never degrades.
2677
+ "models": list(models) if models else None,
2365
2678
  }
2366
2679
  filt_sql, filt_params, degraded = _resolve_search_session_filter(conn, filters)
2367
2680
  if degraded:
@@ -3110,7 +3423,7 @@ def find_in_conversation(conn, session_id, query, *, kind="all",
3110
3423
  conn, session_id, q, kind, depth, fts_available)
3111
3424
  if not matched:
3112
3425
  return {**base, "mode": mode}
3113
- asm = _assemble_session(conn, session_id)
3426
+ asm = _assemble_session_memoized(conn, session_id)
3114
3427
  if asm is None:
3115
3428
  return None
3116
3429
  anchors = _find_anchors_from_matched(asm, matched)