cctally 1.48.0 → 1.49.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 CHANGED
@@ -5,6 +5,12 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.49.0] - 2026-06-17
9
+
10
+ ### Added
11
+ - **Dashboard conversation viewer — Browse-list filters.** The Browse rail gains a compact `Filters ▾` popover that narrows the conversation list by four axes: date (This month / Last month / Last 7d presets, or an arbitrary from→to range), project (a multi-select matching any of the chosen projects, with per-project conversation counts), cost (an optional minimum and/or maximum USD with quick `≥$1` / `≥$5` / `≥$10` presets), and number of cache rebuilds (a `≥1` / `≥3` / `≥5` or custom threshold). Axes combine with AND, active filters show as removable chips under the search box, and filtering is server-side so pagination and counts stay correct. The date filter matches each session's last activity — the same instant the rail now sorts and groups by — so the date sections, the recent order, and the filter all agree. When a filter set matches nothing the rail shows a distinct "No conversations match these filters." message with a one-click "Clear filters" button (separate from the generic "No conversations." copy for an empty install). Filters apply to Browse only (disabled while a full-text search is active, which also dismisses the popover so the reader's keyboard navigation stays live) and are session-only (reset on reload). Each row's displayed cost is the live, pricing-immediate recompute (consistent with the detail and report views), while the cost filter compares the stored per-session rollup — the two agree except in the brief window after a pricing-table edit, before the next sync re-derives the stored value.
12
+ - **Dashboard conversation viewer — jump to the latest turn.** A `Latest ↓` action in the reader header (and the `End` key) pages the open conversation forward to its end and lands on the most recent turn with the usual flash, reusing the existing jump pipeline with an uncapped pager so it reaches the last turn even in very long conversations — and now correctly waits out a page-load that happens to be in flight when you trigger it, rather than stopping short. Landing at the bottom parks the reader in its stick-to-bottom position, so a live session keeps following new turns automatically.
13
+
8
14
  ## [1.48.0] - 2026-06-16
9
15
 
10
16
  ### Fixed
@@ -1637,6 +1637,7 @@ def _recompute_conversation_sessions(conn, session_ids=None) -> None:
1637
1637
  "(session_id, msg_count, started_utc, last_activity_utc) "
1638
1638
  + _CONV_SESSIONS_SELECT + " GROUP BY session_id"
1639
1639
  )
1640
+ _fill_conversation_sessions_filter_columns(conn, None)
1640
1641
  return
1641
1642
  ids = [s for s in session_ids if s is not None]
1642
1643
  for i in range(0, len(ids), 400):
@@ -1653,6 +1654,49 @@ def _recompute_conversation_sessions(conn, session_ids=None) -> None:
1653
1654
  + f" AND session_id IN ({placeholders}) GROUP BY session_id",
1654
1655
  chunk,
1655
1656
  )
1657
+ _fill_conversation_sessions_filter_columns(conn, ids)
1658
+
1659
+
1660
+ def _fill_conversation_sessions_filter_columns(conn, session_ids):
1661
+ """Fill the rollup's browse-FILTER columns (project_label / cost_usd /
1662
+ cache_rebuild_count, migration 015) for the given sessions, or ALL when
1663
+ ``session_ids is None``. The structural COUNT/MIN/MAX columns are filled by
1664
+ the INSERT in _recompute_conversation_sessions; this is the second pass that
1665
+ materializes the three filter axes so the rail's date/project/cost/rebuild
1666
+ filters are pure-SQL predicates.
1667
+
1668
+ project_label + cost reuse the query kernel's batch maps (the SAME
1669
+ _project_label / _session_cost_map the rail's per-page Python path used), so
1670
+ a filtered/displayed value equals what the live rail produced. cost is
1671
+ rounded to 6dp to match list_conversations' per-row rounding.
1672
+ cache_rebuild_count is a per-session assemble via the query kernel's
1673
+ single-source-of-truth helper (whole-session property — recompute, never
1674
+ increment).
1675
+
1676
+ No-op when the columns are absent (a pre-migration-015 cache.db being
1677
+ re-derived before its 015 ALTER lands), so an early/partial sync never
1678
+ raises ``no such column``. The CALLER owns the commit (this never commits)."""
1679
+ cols = {r[1] for r in conn.execute("PRAGMA table_info(conversation_sessions)")}
1680
+ if "cache_rebuild_count" not in cols:
1681
+ return
1682
+ lq = _load_lib("_lib_conversation_query")
1683
+ if session_ids is None:
1684
+ ids = [r[0] for r in conn.execute(
1685
+ "SELECT session_id FROM conversation_sessions")]
1686
+ else:
1687
+ ids = [s for s in session_ids if s is not None]
1688
+ if not ids:
1689
+ return
1690
+ cost = lq._session_cost_map(conn, ids)
1691
+ meta = lq._session_latest_meta_map(conn, ids)
1692
+ for sid in ids:
1693
+ proj = lq._project_label(meta.get(sid, (None, None))[0])
1694
+ rebuilds = lq.session_cache_rebuild_count(conn, sid)
1695
+ conn.execute(
1696
+ "UPDATE conversation_sessions SET project_label=?, cost_usd=?, "
1697
+ "cache_rebuild_count=? WHERE session_id=?",
1698
+ (proj, round(cost.get(sid, 0.0), 6), rebuilds, sid),
1699
+ )
1656
1700
 
1657
1701
 
1658
1702
  def _consume_search_split(conn) -> None:
@@ -5276,6 +5276,13 @@ def _qs_str(q: dict, key: str, default: str | None) -> str | None:
5276
5276
  _CONV_SEARCH_KINDS = ("all", "prompts", "assistant", "tools", "thinking")
5277
5277
 
5278
5278
 
5279
+ class _BadConversationFilter(Exception):
5280
+ """Internal sentinel: a browse-filter query param failed validation. The
5281
+ parse helper has ALREADY sent the 400 response when this is raised, so the
5282
+ caller just unwinds and returns (the conversation routes all 400 on bad
5283
+ input, consistent with the search ``kind`` facet). Module-private."""
5284
+
5285
+
5279
5286
  def _cached_file_sigs(conn, paths):
5280
5287
  """{path: size_bytes} from session_files for the given paths — the cache's
5281
5288
  own view of how far each file is ingested. Size-only by design, matching the
@@ -5419,6 +5426,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5419
5426
  self._handle_share_history_get()
5420
5427
  elif path == "/api/doctor":
5421
5428
  self._handle_get_doctor()
5429
+ elif path == "/api/conversations/facets":
5430
+ self._handle_get_conversations_facets()
5422
5431
  elif path == "/api/conversations":
5423
5432
  self._handle_get_conversations()
5424
5433
  elif path == "/api/conversation/search":
@@ -7462,12 +7471,90 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7462
7471
  conn.close()
7463
7472
  return True, body
7464
7473
 
7474
+ def _parse_conversation_filters(self, q):
7475
+ """Parse the browse-list filter params (spec §2) from a ``parse_qs``
7476
+ mapping. On any malformed value this sends a **400** and returns
7477
+ ``None`` — the caller just ``return``s (the conversation routes all 400
7478
+ on bad input). On success returns a dict of ``list_conversations``
7479
+ kwargs: ``date_from``/``date_to`` (UTC-ISO bounds), ``projects``
7480
+ (list[str] | None), ``cost_min``/``cost_max`` (float | None),
7481
+ ``rebuild_min`` (int | None). Empty/blank params drop to ``None``.
7482
+
7483
+ Numeric axes validate strictly (a non-numeric cost / non-integer
7484
+ rebuild threshold is a hard 400). Date bounds route through the pure
7485
+ ``_lib_dashboard_dates.parse_filter_date_range`` helper, which resolves
7486
+ naive date-only bounds in ``display.tz`` and raises ``ValueError`` (→
7487
+ 400) on a malformed date. Projects accept BOTH repeated
7488
+ ``?projects=a&projects=b`` and a single comma-joined ``?projects=a,b``.
7489
+ """
7490
+ def _float(name):
7491
+ v = _qs_str(q, name, "")
7492
+ if v is None or v == "":
7493
+ return None
7494
+ try:
7495
+ return float(v)
7496
+ except ValueError:
7497
+ self._respond_json(400, {"error": f"bad {name}: {v}"})
7498
+ raise _BadConversationFilter
7499
+
7500
+ def _int(name):
7501
+ v = _qs_str(q, name, "")
7502
+ if v is None or v == "":
7503
+ return None
7504
+ try:
7505
+ return int(v)
7506
+ except ValueError:
7507
+ self._respond_json(400, {"error": f"bad {name}: {v}"})
7508
+ raise _BadConversationFilter
7509
+
7510
+ try:
7511
+ cost_min = _float("cost_min")
7512
+ cost_max = _float("cost_max")
7513
+ rebuild_min = _int("rebuild_min")
7514
+ except _BadConversationFilter:
7515
+ return None # 400 already sent
7516
+
7517
+ projects = [p for p in q.get("projects", []) if p] or None
7518
+ # Single comma-joined value -> split (the client may send either form).
7519
+ if projects and len(projects) == 1 and "," in projects[0]:
7520
+ projects = [s for s in projects[0].split(",") if s] or None
7521
+
7522
+ date_from = _qs_str(q, "date_from", "") or None
7523
+ date_to = _qs_str(q, "date_to", "") or None
7524
+ if date_from or date_to:
7525
+ from importlib import import_module
7526
+ tz = _resolve_display_tz_obj(
7527
+ _apply_display_tz_override(
7528
+ load_config(), type(self).display_tz_pref_override
7529
+ )
7530
+ ).key
7531
+ try:
7532
+ df, dtt = import_module(
7533
+ "_lib_dashboard_dates"
7534
+ ).parse_filter_date_range(date_from, date_to, tz_name=tz)
7535
+ except ValueError as exc:
7536
+ self._respond_json(400, {"error": str(exc)})
7537
+ return None
7538
+ else:
7539
+ df = dtt = None
7540
+
7541
+ return {
7542
+ "date_from": df,
7543
+ "date_to": dtt,
7544
+ "projects": projects,
7545
+ "cost_min": cost_min,
7546
+ "cost_max": cost_max,
7547
+ "rebuild_min": rebuild_min,
7548
+ }
7549
+
7465
7550
  def _handle_get_conversations(self) -> None:
7466
7551
  """``GET /api/conversations`` — the browse rail (spec §3.1).
7467
7552
 
7468
7553
  Gated first (loopback / Host allowlist). ``sort``/``limit``/``offset``
7469
- are read from the query string; the kernel clamps bounds. Cache-open
7470
- failures are 500s, never 5xx-with-stacktrace.
7554
+ are read from the query string; the kernel clamps bounds. The browse
7555
+ filters (date/project/cost/rebuild — spec §2) are parsed/validated here
7556
+ (malformed → 400) and threaded into the kernel. Cache-open failures are
7557
+ 500s, never 5xx-with-stacktrace.
7471
7558
  """
7472
7559
  if not self._require_transcripts_allowed():
7473
7560
  return
@@ -7476,14 +7563,33 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7476
7563
  sort = _qs_str(q, "sort", "recent")
7477
7564
  limit = _qs_int(q, "limit", 50)
7478
7565
  offset = _qs_int(q, "offset", 0)
7566
+ filters = self._parse_conversation_filters(q)
7567
+ if filters is None:
7568
+ return # a 400 has already been sent
7479
7569
  ok, body = self._run_conversation_query(
7480
7570
  lambda conn: self._conversation_query().list_conversations(
7481
- conn, sort=sort, limit=limit, offset=offset),
7571
+ conn, sort=sort, limit=limit, offset=offset, **filters),
7482
7572
  "/api/conversations")
7483
7573
  if not ok:
7484
7574
  return
7485
7575
  self._respond_json(200, body)
7486
7576
 
7577
+ def _handle_get_conversations_facets(self) -> None:
7578
+ """``GET /api/conversations/facets`` — distinct project labels + their
7579
+ conversation counts, for the browse filter's project multi-select (spec
7580
+ §2). Behind the SAME loopback/Host privacy gate as the list route; a
7581
+ cheap indexed GROUP BY over the rollup. The popover loads its options
7582
+ once from here (deriving from a paginated page would be incomplete).
7583
+ """
7584
+ if not self._require_transcripts_allowed():
7585
+ return
7586
+ ok, body = self._run_conversation_query(
7587
+ lambda conn: self._conversation_query().list_conversation_facets(conn),
7588
+ "/api/conversations/facets")
7589
+ if not ok:
7590
+ return
7591
+ self._respond_json(200, body)
7592
+
7487
7593
  def _handle_get_conversation_detail(self, path: str) -> None:
7488
7594
  """``GET /api/conversation/<session-id>`` — the reader (spec §3.2).
7489
7595
 
@@ -2360,11 +2360,18 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2360
2360
  byte_offset INTEGER NOT NULL
2361
2361
  );
2362
2362
 
2363
- -- Browse-rail rollup (conversation_sessions). Materializes exactly the
2364
- -- four structural aggregates the rail's old live GROUP BY produced
2363
+ -- Browse-rail rollup (conversation_sessions). Materializes the four
2364
+ -- structural aggregates the rail's old live GROUP BY produced
2365
2365
  -- (COUNT/MIN/MAX over conversation_messages per session_id) so
2366
2366
  -- GET /api/conversations no longer scans the whole message table to
2367
- -- render a 50-row page. The explicit NOT NULL on the non-INTEGER PK
2367
+ -- render a 50-row page, PLUS three filter columns
2368
+ -- (project_label/cost_usd/cache_rebuild_count, migration 015) so the
2369
+ -- Browse list's date/project/cost/cache-rebuild filters are pure-SQL
2370
+ -- predicates. The structural columns are recomputed by a COUNT/MIN/MAX
2371
+ -- GROUP BY; the filter columns are filled per-session by
2372
+ -- _fill_conversation_sessions_filter_columns in the same flock-held
2373
+ -- recompute (cost via the query kernel's batch maps, cache_rebuild_count
2374
+ -- via a per-session assemble). The explicit NOT NULL on the non-INTEGER PK
2368
2375
  -- matters (SQLite's legacy NULL-in-PK bug); the rail keys on a concrete
2369
2376
  -- session_id and the recompute's GROUP BY already filters nulls. The
2370
2377
  -- index lets the only paginated ordering (recent) early-terminate at
@@ -2373,10 +2380,13 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2373
2380
  -- flag-gated full recompute) — migration 013 arms the one-time
2374
2381
  -- history backfill.
2375
2382
  CREATE TABLE IF NOT EXISTS conversation_sessions (
2376
- session_id TEXT NOT NULL PRIMARY KEY,
2377
- msg_count INTEGER NOT NULL DEFAULT 0,
2378
- started_utc TEXT,
2379
- last_activity_utc TEXT
2383
+ session_id TEXT NOT NULL PRIMARY KEY,
2384
+ msg_count INTEGER NOT NULL DEFAULT 0,
2385
+ started_utc TEXT,
2386
+ last_activity_utc TEXT,
2387
+ project_label TEXT,
2388
+ cost_usd REAL NOT NULL DEFAULT 0,
2389
+ cache_rebuild_count INTEGER NOT NULL DEFAULT 0
2380
2390
  );
2381
2391
  CREATE INDEX IF NOT EXISTS idx_conv_sessions_recent
2382
2392
  ON conversation_sessions(last_activity_utc DESC, session_id DESC);
@@ -3463,6 +3473,35 @@ def _014_conversation_queued_prompt_reingest(conn: sqlite3.Connection) -> None:
3463
3473
  conn.commit()
3464
3474
 
3465
3475
 
3476
+ @cache_migration("015_conversation_sessions_filter_columns")
3477
+ def _015_conversation_sessions_filter_columns(conn: sqlite3.Connection) -> None:
3478
+ """Add the browse-filter columns to the conversation_sessions rollup
3479
+ (project_label, cost_usd, cache_rebuild_count) so the rail's date/project/
3480
+ cost/cache-rebuild filters are pure-SQL predicates (spec §1). ALTERs are
3481
+ idempotent (duplicate-column tolerated). Arms the SHARED
3482
+ conversation_sessions_backfill_pending flag so the next sync_cache full
3483
+ recompute fills the new columns via the augmented
3484
+ _recompute_conversation_sessions — keeping the heavy per-session assemble
3485
+ (cache_rebuild_count) off the migration's critical path, mirroring 013.
3486
+ Central stamp via the dispatcher (#140); handler does NOT self-stamp.
3487
+
3488
+ A fresh install gets the three columns from _apply_cache_schema's CREATE
3489
+ TABLE and stamps 015 WITHOUT running this handler — the ALTERs no-op there
3490
+ (already present), and the empty rollup needs no backfill (the incremental
3491
+ DELETE+INSERT re-derive fills all columns in lockstep). Mirrors 013."""
3492
+ for ddl in (
3493
+ "ALTER TABLE conversation_sessions ADD COLUMN project_label TEXT",
3494
+ "ALTER TABLE conversation_sessions ADD COLUMN cost_usd REAL NOT NULL DEFAULT 0",
3495
+ "ALTER TABLE conversation_sessions ADD COLUMN cache_rebuild_count INTEGER NOT NULL DEFAULT 0",
3496
+ ):
3497
+ try:
3498
+ conn.execute(ddl)
3499
+ except sqlite3.OperationalError:
3500
+ pass # idempotent: column already present
3501
+ _set_cache_meta(conn, "conversation_sessions_backfill_pending", "1")
3502
+ conn.commit()
3503
+
3504
+
3466
3505
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
3467
3506
 
3468
3507
  @stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
@@ -445,6 +445,30 @@ def _stamp_cache_failures(items):
445
445
  running_max[key] = max(rm, cr)
446
446
 
447
447
 
448
+ def session_cache_rebuild_count(conn, session_id) -> int:
449
+ """Per-session count of cache-rebuild (cache-failure) turns.
450
+
451
+ Single source of truth: assembles the session via the SAME pipeline the
452
+ reader/outline use and runs the SAME _stamp_cache_failures kernel, then
453
+ counts flagged items. /outline omits stats.cache_failures when the count is
454
+ 0, so "no flagged items" -> 0 (byte-identical to outline). Stored on the
455
+ conversation_sessions rollup by _recompute_conversation_sessions; filtered
456
+ by list_conversations(rebuild_min=...).
457
+
458
+ _assemble_session already runs _stamp_cache_failures internally before
459
+ returning, so the items carry the flag on entry; re-running the kernel here
460
+ is an idempotent recompute (it only ADDS the key on a flagged turn, never
461
+ clears one, and the predicate is deterministic over the same items) that
462
+ keeps the helper correct even if assembly ever stops stamping.
463
+ """
464
+ asm = _assemble_session(conn, session_id)
465
+ if asm is None:
466
+ return 0
467
+ items = asm["items"]
468
+ _stamp_cache_failures(items)
469
+ return sum(1 for it in items if "cache_failure" in it)
470
+
471
+
448
472
  def _session_cost_map(conn, session_ids):
449
473
  """{session_id: total_cost_usd} for the given sessions. Joins
450
474
  conversation_messages turn keys to the single deduped session_entries row
@@ -570,40 +594,135 @@ def _rollup_authoritative(conn) -> bool:
570
594
  return not pending
571
595
 
572
596
 
573
- def _list_session_rows_rollup(conn, order, limit, offset):
597
+ # --- Browse-list filter predicates (spec §2) ------------------------------
598
+ # The rail filters on four axes (date / project / cost / cache-rebuilds). All
599
+ # four are STORED columns on the conversation_sessions rollup, so the rollup
600
+ # fast path expresses every axis as a parameterized WHERE pushed BEFORE
601
+ # LIMIT/OFFSET (pagination stays correct over the filtered set). The live
602
+ # GROUP BY fallback has no cost/project/rebuild columns, so it expresses ONLY
603
+ # the date axis (via HAVING over MAX(timestamp_utc)) — the rollup-only axes
604
+ # degrade in that brief, self-correcting non-authoritative window (spec §1
605
+ # dual-branch parity). The date predicate compares against the stored
606
+ # last_activity_utc string, so the caller MUST pass UTC-ISO bounds in the same
607
+ # format (...Z) — see bin/_lib_dashboard_dates.parse_filter_date_range.
608
+ _FILTER_KEYS = ("date_from", "date_to", "projects",
609
+ "cost_min", "cost_max", "rebuild_min")
610
+ # The rollup-only axes — the ones the live fallback cannot express. Used to set
611
+ # the page's filter_degraded flag when one is requested under the live branch.
612
+ _ROLLUP_ONLY_FILTER_KEYS = ("projects", "cost_min", "cost_max", "rebuild_min")
613
+
614
+
615
+ def _empty_filters() -> dict:
616
+ """A no-op filter dict (every axis None) — the unfiltered default and the
617
+ shape the row-source helpers expect."""
618
+ return {k: None for k in _FILTER_KEYS}
619
+
620
+
621
+ def _rollup_where(filters):
622
+ """(sql_fragment, params) for the ROLLUP branch — all four axes are stored
623
+ columns. Returns (" WHERE ...", [params]) or ("", []) when no axis is set.
624
+ Project IN-list naturally excludes empty/NULL project_label (neither the ''
625
+ no-cwd sentinel nor a NULL not-yet-filled row matches a real label).
626
+
627
+ The date axis is HALF-OPEN: ``date_from`` is an inclusive start-of-day lower
628
+ bound (``>=``) and ``date_to`` is the EXCLUSIVE start-of-next-day upper bound
629
+ (``<``) emitted by ``_lib_dashboard_dates.parse_filter_date_range``. The
630
+ strict ``<`` is load-bearing: it makes the lex compare against the stored
631
+ mixed-precision ``last_activity_utc`` (whole-second AND millisecond ``...Z``)
632
+ chronologically correct at the day edge (review Finding 1)."""
633
+ clauses, params = [], []
634
+ if filters["date_from"] is not None:
635
+ clauses.append("last_activity_utc >= ?"); params.append(filters["date_from"])
636
+ if filters["date_to"] is not None:
637
+ clauses.append("last_activity_utc < ?"); params.append(filters["date_to"])
638
+ if filters["projects"]:
639
+ ph = ",".join("?" for _ in filters["projects"])
640
+ clauses.append("project_label IN (%s)" % ph); params.extend(filters["projects"])
641
+ if filters["cost_min"] is not None:
642
+ clauses.append("cost_usd >= ?"); params.append(filters["cost_min"])
643
+ if filters["cost_max"] is not None:
644
+ clauses.append("cost_usd <= ?"); params.append(filters["cost_max"])
645
+ if filters["rebuild_min"] is not None:
646
+ clauses.append("cache_rebuild_count >= ?"); params.append(filters["rebuild_min"])
647
+ return (" WHERE " + " AND ".join(clauses)) if clauses else "", params
648
+
649
+
650
+ def _live_having(filters):
651
+ """(sql_fragment, params) for the LIVE fallback — only the DATE axis is
652
+ expressible (no cost/project/rebuild columns exist on the raw messages).
653
+ The rollup's last_activity_utc IS MAX(timestamp_utc), so the date predicate
654
+ is the same HALF-OPEN bound applied via HAVING over the aggregate: inclusive
655
+ ``>=`` lower, EXCLUSIVE strict-``<`` upper — kept byte-for-byte in lockstep
656
+ with ``_rollup_where``'s date axis so both branches agree at the day edge
657
+ over mixed-precision timestamps (review Finding 1)."""
658
+ having, params = [], []
659
+ if filters["date_from"] is not None:
660
+ having.append("MAX(timestamp_utc) >= ?"); params.append(filters["date_from"])
661
+ if filters["date_to"] is not None:
662
+ having.append("MAX(timestamp_utc) < ?"); params.append(filters["date_to"])
663
+ return (" HAVING " + " AND ".join(having)) if having else "", params
664
+
665
+
666
+ def _list_session_rows_rollup(conn, order, limit, offset, filters=None):
574
667
  """FAST path: read the pre-aggregated rail rows straight from the
575
668
  conversation_sessions rollup (spec §3). No GROUP BY, no temp B-tree for the
576
669
  ``recent`` sort. Returns (session_id, msg_count, started, last_activity)
577
670
  tuples — the same shape the live aggregate yields, so the downstream
578
- assembly is identical. No WHERE is needed: the rollup is non-null by
579
- construction (PK NOT NULL; the recompute's GROUP BY already drops NULLs)."""
671
+ assembly is identical. The optional ``filters`` dict pushes the four-axis
672
+ browse predicate (date/project/cost/rebuild all stored columns) into the
673
+ WHERE BEFORE LIMIT/OFFSET, so ``has_more``/``next_offset`` stay correct over
674
+ the filtered set."""
675
+ where, params = _rollup_where(filters or _empty_filters())
580
676
  return conn.execute(
581
677
  "SELECT session_id, msg_count, "
582
678
  " started_utc AS started, last_activity_utc AS last_activity "
583
- "FROM conversation_sessions "
584
- "ORDER BY " + order + " LIMIT ? OFFSET ?",
585
- (limit + 1, offset),
679
+ "FROM conversation_sessions"
680
+ + where +
681
+ " ORDER BY " + order + " LIMIT ? OFFSET ?",
682
+ (*params, limit + 1, offset),
586
683
  ).fetchall()
587
684
 
588
685
 
589
- def _list_session_rows_live(conn, order, limit, offset):
686
+ def _list_session_rows_live(conn, order, limit, offset, filters=None):
590
687
  """RETAINED fallback (Codex gate BLOCKER 2): the original live GROUP BY over
591
688
  conversation_messages, used while the rollup is not authoritative (the flag
592
689
  is set — e.g. an existing install before its first sync, or permanently
593
690
  under ``--no-sync``). Byte-identical output to the rollup branch by
594
- construction; ``order`` here is the _SORTS_LIVE aggregate expression."""
691
+ construction; ``order`` here is the _SORTS_LIVE aggregate expression. The
692
+ optional ``filters`` dict applies ONLY the date axis (via HAVING over
693
+ MAX(timestamp_utc)); the rollup-only axes (project/cost/rebuild) cannot be
694
+ expressed here and are dropped — the caller flags filter_degraded."""
695
+ having, hparams = _live_having(filters or _empty_filters())
595
696
  return conn.execute(
596
697
  "SELECT session_id, COUNT(*) AS msg_count, "
597
698
  " MIN(timestamp_utc) AS started, MAX(timestamp_utc) AS last_activity "
598
699
  "FROM conversation_messages "
599
700
  "WHERE session_id IS NOT NULL "
600
- "GROUP BY session_id "
601
- "ORDER BY " + order + " LIMIT ? OFFSET ?",
602
- (limit + 1, offset),
701
+ "GROUP BY session_id"
702
+ + having +
703
+ " ORDER BY " + order + " LIMIT ? OFFSET ?",
704
+ (*hparams, limit + 1, offset),
603
705
  ).fetchall()
604
706
 
605
707
 
606
- def list_conversations(conn, *, sort="recent", limit=50, offset=0) -> dict:
708
+ def list_conversation_facets(conn) -> dict:
709
+ """Distinct projects (+ conversation counts) for the browse filter
710
+ multi-select (spec §2). Reads the rollup; cheap GROUP BY. Empty/NULL
711
+ project labels are dropped (a no-cwd session stores '' and a not-yet-filled
712
+ row stores NULL — neither is a real selectable project). Sorted ascending
713
+ by label so the popover renders a stable list. Returns
714
+ ``{"projects": [{"project_label": str, "count": int}, ...]}``."""
715
+ rows = conn.execute(
716
+ "SELECT project_label, COUNT(*) FROM conversation_sessions "
717
+ "WHERE project_label IS NOT NULL AND project_label != '' "
718
+ "GROUP BY project_label ORDER BY project_label"
719
+ ).fetchall()
720
+ return {"projects": [{"project_label": p, "count": n} for p, n in rows]}
721
+
722
+
723
+ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
724
+ date_from=None, date_to=None, projects=None,
725
+ cost_min=None, cost_max=None, rebuild_min=None) -> dict:
607
726
  """All-history per-session browse rows (spec §3.1). NOT 365-day bounded.
608
727
 
609
728
  Reads the conversation_sessions rollup (Task A) when it is authoritative —
@@ -622,12 +741,27 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0) -> dict:
622
741
  revalidates every visible tick anyway."""
623
742
  limit = max(1, min(int(limit), 200))
624
743
  offset = max(0, int(offset))
744
+ # Browse-list filters (spec §2). The rollup branch expresses all four axes
745
+ # as column predicates; the live fallback only the date axis, so when a
746
+ # rollup-only axis is requested under the live branch we flag the page
747
+ # filter_degraded (a brief, self-correcting non-authoritative window).
748
+ filters = {
749
+ "date_from": date_from,
750
+ "date_to": date_to,
751
+ "projects": list(projects) if projects else None,
752
+ "cost_min": cost_min,
753
+ "cost_max": cost_max,
754
+ "rebuild_min": rebuild_min,
755
+ }
756
+ degraded = False
625
757
  if _rollup_authoritative(conn):
626
758
  order = _SORTS.get(sort, _SORTS["recent"])
627
- rows = _list_session_rows_rollup(conn, order, limit, offset)
759
+ rows = _list_session_rows_rollup(conn, order, limit, offset, filters)
628
760
  else:
629
761
  order = _SORTS_LIVE.get(sort, _SORTS_LIVE["recent"])
630
- rows = _list_session_rows_live(conn, order, limit, offset)
762
+ degraded = any(
763
+ filters[k] is not None for k in _ROLLUP_ONLY_FILTER_KEYS)
764
+ rows = _list_session_rows_live(conn, order, limit, offset, filters)
631
765
  has_more = len(rows) > limit
632
766
  rows = rows[:limit]
633
767
  session_ids = [r[0] for r in rows]
@@ -650,12 +784,17 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0) -> dict:
650
784
  }
651
785
  for (sid, msg_count, started, last_activity) in rows
652
786
  ]
787
+ page = {
788
+ "next_offset": offset + len(conversations) if has_more else None,
789
+ "has_more": has_more,
790
+ }
791
+ if degraded:
792
+ # The rail surfaces this: project/cost/rebuild filters apply once the
793
+ # rollup finishes indexing; only the date axis held this page.
794
+ page["filter_degraded"] = True
653
795
  return {
654
796
  "conversations": conversations,
655
- "page": {
656
- "next_offset": offset + len(conversations) if has_more else None,
657
- "has_more": has_more,
658
- },
797
+ "page": page,
659
798
  }
660
799
 
661
800
 
@@ -1169,6 +1308,20 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
1169
1308
  _pl = _project_label(_latest(logical, 10))
1170
1309
  _title = _session_titles_map(conn, [session_id]).get(session_id) or _pl or session_id
1171
1310
 
1311
+ # Jump-to-latest target (spec §3): the {session_id, uuid, id} of the
1312
+ # conversation's FINAL rendered turn, sourced from the tail of the
1313
+ # whole-session assembled item list (so it lands on a real grouped item, not
1314
+ # a folded fragment). Codex P2 #4: assembled anchors carry session_id=None
1315
+ # (only the returned PAGE's anchors are patched), so build it EXPLICITLY with
1316
+ # the request session_id rather than copying items[-1]["anchor"] verbatim.
1317
+ # None only for a genuinely empty conversation (an existing session always
1318
+ # has >=1 item, so this stays non-None for any non-None return). Computed on
1319
+ # the unsliced list so it is the SAME regardless of which page was requested.
1320
+ last_anchor = None
1321
+ if items:
1322
+ _la = items[-1]["anchor"]
1323
+ last_anchor = {"session_id": session_id, "uuid": _la["uuid"], "id": _la["id"]}
1324
+
1172
1325
  # Cursor pagination over the item list (anchored to each item's canonical id).
1173
1326
  # A non-None `after` that matches no item's anchor (stale/deleted cursor)
1174
1327
  # yields an EMPTY page — never silently re-serves the head (M1).
@@ -1189,6 +1342,7 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
1189
1342
  "last_activity_utc": logical[-1][2],
1190
1343
  "cost_usd": header_cost,
1191
1344
  "models": sorted({r[6] for r in logical if r[6]}),
1345
+ "last_anchor": last_anchor,
1192
1346
  "items": [],
1193
1347
  "subagent_meta": subagent_meta,
1194
1348
  "page": {"next_after": None, "has_more": False},
@@ -1224,6 +1378,7 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
1224
1378
  "last_activity_utc": last[2],
1225
1379
  "cost_usd": header_cost,
1226
1380
  "models": models,
1381
+ "last_anchor": last_anchor,
1227
1382
  "items": page,
1228
1383
  "subagent_meta": subagent_meta,
1229
1384
  "page": {"next_after": next_after, "has_more": has_more},