cctally 1.64.0 → 1.66.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.
- package/CHANGELOG.md +50 -0
- package/bin/_cctally_alerts.py +1 -1
- package/bin/_cctally_cache.py +262 -40
- package/bin/_cctally_cache_report.py +7 -5
- package/bin/_cctally_core.py +49 -14
- package/bin/_cctally_dashboard.py +827 -4911
- package/bin/_cctally_dashboard_cache_report.py +714 -0
- package/bin/_cctally_dashboard_conversation.py +771 -0
- package/bin/_cctally_dashboard_envelope.py +1520 -0
- package/bin/_cctally_dashboard_share.py +2012 -0
- package/bin/_cctally_db.py +127 -5
- package/bin/_cctally_doctor.py +104 -3
- package/bin/_cctally_five_hour.py +2 -1
- package/bin/_cctally_forecast.py +78 -135
- package/bin/_cctally_parser.py +919 -784
- package/bin/_cctally_percent_breakdown.py +1 -1
- package/bin/_cctally_pricing_check.py +39 -0
- package/bin/_cctally_project.py +8 -5
- package/bin/_cctally_record.py +279 -274
- package/bin/_cctally_reporting.py +1 -1
- package/bin/_cctally_sync_week.py +1 -1
- package/bin/_cctally_telemetry.py +3 -5
- package/bin/_cctally_tui.py +487 -254
- package/bin/_cctally_update.py +5 -0
- package/bin/_lib_alert_dispatch.py +1 -1
- package/bin/_lib_blocks.py +6 -1
- package/bin/_lib_conversation_query.py +349 -36
- package/bin/_lib_credit.py +133 -0
- package/bin/_lib_doctor.py +150 -1
- package/bin/_lib_five_hour.py +34 -0
- package/bin/_lib_forecast.py +144 -0
- package/bin/_lib_json_envelope.py +38 -0
- package/bin/_lib_jsonl.py +115 -73
- package/bin/_lib_log.py +96 -0
- package/bin/_lib_perf.py +180 -0
- package/bin/_lib_pricing.py +47 -1
- package/bin/_lib_pricing_check.py +25 -3
- package/bin/_lib_record.py +179 -0
- package/bin/_lib_render.py +18 -10
- package/bin/_lib_snapshot_cache.py +61 -0
- package/bin/_lib_transcript_access.py +23 -0
- package/bin/cctally +51 -7
- package/bin/cctally-dashboard +2 -2
- package/bin/cctally-statusline +1 -0
- package/bin/cctally-tui +2 -2
- package/dashboard/static/assets/index-CJTUCpKt.js +80 -0
- package/dashboard/static/assets/index-DQWNrIqu.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +11 -1
- package/dashboard/static/assets/index-0jzYm75p.css +0 -1
- package/dashboard/static/assets/index-D_Ylyqsf.js +0 -80
|
@@ -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
|
|
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
|
|
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
|
|
1072
|
-
|
|
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
|
-
|
|
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)
|
|
1119
|
-
multi-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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)
|