cctally 1.43.2 → 1.44.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.
@@ -196,6 +196,19 @@ def _session_titles_map(conn, session_ids):
196
196
  if not session_ids:
197
197
  return {}
198
198
  titles = {}
199
+ # #193: AI title wins when present. Query the dedicated table first; the
200
+ # existing first-prompt scan below fills only sessions WITHOUT one (its
201
+ # ``if sid in titles: continue`` guard skips ai-title sessions for free).
202
+ try:
203
+ ph0 = ",".join("?" for _ in session_ids)
204
+ for sid, at in conn.execute(
205
+ f"SELECT session_id, ai_title FROM conversation_ai_titles "
206
+ f"WHERE session_id IN ({ph0})", tuple(session_ids)
207
+ ).fetchall():
208
+ if at:
209
+ titles[sid] = at
210
+ except sqlite3.OperationalError:
211
+ pass # table absent (pre-migration / :memory:) -> fall through to first-prompt
199
212
  # While 005's reingest is pending, a stale `human` row may actually be an
200
213
  # injected skill body (a SessionStart skill can even lead the transcript) —
201
214
  # skip those as title candidates so the rail never shows "Base directory for
@@ -339,18 +352,73 @@ def _session_latest_meta_map(conn, session_ids):
339
352
  return meta
340
353
 
341
354
 
355
+ # Rail sort keys, in the rollup table's STRUCTURAL columns (Task A). ``recent``
356
+ # rides idx_conv_sessions_recent(last_activity_utc DESC, session_id DESC) and
357
+ # early-terminates at LIMIT with no temp B-tree; ``oldest`` scan-sorts the few
358
+ # thousand rollup rows (microseconds). The live fallback re-expresses the SAME
359
+ # orderings over conversation_messages aggregates via _SORTS_LIVE below.
360
+ # When adding a sort key, add it to BOTH _SORTS and _SORTS_LIVE and exercise it
361
+ # in test_conversation_rail_rollup_reconcile.py (its _dump asserts the test's
362
+ # sort list covers exactly _SORTS.keys(), so a one-sided add fails loud).
342
363
  _SORTS = {
364
+ "recent": "last_activity_utc DESC, session_id DESC",
365
+ "oldest": "started_utc ASC, session_id ASC",
366
+ }
367
+
368
+ # The matching ORDER BY for the live GROUP BY fallback: the rollup's
369
+ # last_activity_utc IS MAX(timestamp_utc) and started_utc IS MIN(timestamp_utc),
370
+ # so the two branches paginate in byte-identical order (the load-bearing
371
+ # invariant). Keep this table in lockstep with _SORTS.
372
+ _SORTS_LIVE = {
343
373
  "recent": "MAX(timestamp_utc) DESC, session_id DESC",
344
374
  "oldest": "MIN(timestamp_utc) ASC, session_id ASC",
345
375
  }
346
376
 
347
377
 
348
- def list_conversations(conn, *, sort="recent", limit=50, offset=0) -> dict:
349
- """All-history per-session browse rows (spec §3.1). NOT 365-day bounded."""
350
- order = _SORTS.get(sort, _SORTS["recent"])
351
- limit = max(1, min(int(limit), 200))
352
- offset = max(0, int(offset))
353
- rows = conn.execute(
378
+ def _rollup_authoritative(conn) -> bool:
379
+ """True when the conversation_sessions rollup is authoritative i.e. the
380
+ durable ``conversation_sessions_backfill_pending`` flag is NOT set, so the
381
+ rollup has been fully recomputed and the fast read is safe.
382
+
383
+ Returns True (authoritative) when the flag is absent, including the case
384
+ where cache_meta does not exist at all (a path-less / schema-not-applied or
385
+ in-memory connection). This mirrors Task A's
386
+ _conversation_sessions_backfill_pending OperationalError degrade-to-False —
387
+ here that means "no pending flag" -> authoritative -> read the rollup. A
388
+ populated cache.db always has cache_meta, so this only affects bare conns."""
389
+ try:
390
+ pending = conn.execute(
391
+ "SELECT 1 FROM cache_meta "
392
+ "WHERE key='conversation_sessions_backfill_pending'"
393
+ ).fetchone() is not None
394
+ except sqlite3.OperationalError:
395
+ return True
396
+ return not pending
397
+
398
+
399
+ def _list_session_rows_rollup(conn, order, limit, offset):
400
+ """FAST path: read the pre-aggregated rail rows straight from the
401
+ conversation_sessions rollup (spec §3). No GROUP BY, no temp B-tree for the
402
+ ``recent`` sort. Returns (session_id, msg_count, started, last_activity)
403
+ tuples — the same shape the live aggregate yields, so the downstream
404
+ assembly is identical. No WHERE is needed: the rollup is non-null by
405
+ construction (PK NOT NULL; the recompute's GROUP BY already drops NULLs)."""
406
+ return conn.execute(
407
+ "SELECT session_id, msg_count, "
408
+ " started_utc AS started, last_activity_utc AS last_activity "
409
+ "FROM conversation_sessions "
410
+ "ORDER BY " + order + " LIMIT ? OFFSET ?",
411
+ (limit + 1, offset),
412
+ ).fetchall()
413
+
414
+
415
+ def _list_session_rows_live(conn, order, limit, offset):
416
+ """RETAINED fallback (Codex gate BLOCKER 2): the original live GROUP BY over
417
+ conversation_messages, used while the rollup is not authoritative (the flag
418
+ is set — e.g. an existing install before its first sync, or permanently
419
+ under ``--no-sync``). Byte-identical output to the rollup branch by
420
+ construction; ``order`` here is the _SORTS_LIVE aggregate expression."""
421
+ return conn.execute(
354
422
  "SELECT session_id, COUNT(*) AS msg_count, "
355
423
  " MIN(timestamp_utc) AS started, MAX(timestamp_utc) AS last_activity "
356
424
  "FROM conversation_messages "
@@ -359,6 +427,33 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0) -> dict:
359
427
  "ORDER BY " + order + " LIMIT ? OFFSET ?",
360
428
  (limit + 1, offset),
361
429
  ).fetchall()
430
+
431
+
432
+ def list_conversations(conn, *, sort="recent", limit=50, offset=0) -> dict:
433
+ """All-history per-session browse rows (spec §3.1). NOT 365-day bounded.
434
+
435
+ Reads the conversation_sessions rollup (Task A) when it is authoritative —
436
+ i.e. the durable ``conversation_sessions_backfill_pending`` flag is clear —
437
+ which lets the ``recent`` sort early-terminate on idx_conv_sessions_recent
438
+ with no full-table aggregate. While the flag is set (an existing install
439
+ before its first sync consumes the backfill, or permanently under
440
+ ``--no-sync``), it falls back to the RETAINED live GROUP BY over
441
+ conversation_messages — correct, possibly slower, never an empty rail.
442
+
443
+ The two branches are byte-identical for every (sort, limit, offset): the
444
+ rollup recomputes the same COUNT/MIN/MAX over the same rows, and the
445
+ fallback IS the old aggregate (pinned by the reconcile test over both
446
+ branches). Staleness bound: at most one sync tick (~5s) if a session gains
447
+ messages on the exact tick the rail reads — cosmetic only, and the rail
448
+ revalidates every visible tick anyway."""
449
+ limit = max(1, min(int(limit), 200))
450
+ offset = max(0, int(offset))
451
+ if _rollup_authoritative(conn):
452
+ order = _SORTS.get(sort, _SORTS["recent"])
453
+ rows = _list_session_rows_rollup(conn, order, limit, offset)
454
+ else:
455
+ order = _SORTS_LIVE.get(sort, _SORTS_LIVE["recent"])
456
+ rows = _list_session_rows_live(conn, order, limit, offset)
362
457
  has_more = len(rows) > limit
363
458
  rows = rows[:limit]
364
459
  session_ids = [r[0] for r in rows]
@@ -536,6 +631,7 @@ def _assemble_session(conn, session_id):
536
631
  # the top-level subagent_meta map (no undocumented block keys leak). Join is
537
632
  # spawn tool_use id <-> tool_result tool_use_id; agent_id == subagent_key.
538
633
  spawn_kind = {} # tool_use id -> subagent_type
634
+ spawn_desc = {} # tool_use id -> spawning Task description (#193)
539
635
  agent_link = {} # tool_use id -> (agent_id, raw_meta)
540
636
  ask_link = {} # tool_use id -> (answers, annotations) (#177 S2)
541
637
  bash_link = {} # tool_use id -> (stderr, interrupted) (#177 S3)
@@ -549,6 +645,13 @@ def _assemble_session(conn, session_id):
549
645
  st = b.pop("subagent_type", None)
550
646
  if st and b.get("id") is not None:
551
647
  spawn_kind[b["id"]] = st
648
+ # #193: harvest the spawning Task description from the
649
+ # already-stored bounded input. Guarded on subagent_type, so
650
+ # a Bash `description` (no subagent_type) is NEVER picked up.
651
+ _inp = b.get("input")
652
+ _d = _inp.get("description") if isinstance(_inp, dict) else None
653
+ if isinstance(_d, str) and _d.strip():
654
+ spawn_desc[b["id"]] = _d
552
655
  elif k == "tool_result":
553
656
  aid = b.pop("agent_id", None)
554
657
  meta = b.pop("subagent_meta", None)
@@ -579,6 +682,8 @@ def _assemble_session(conn, session_id):
579
682
  continue # spawn with no (yet) result -> title-only
580
683
  _aid, _raw = _link
581
684
  _entry = {"kind": _kind}
685
+ if spawn_desc.get(_tuid): # #193: spawning Task description
686
+ _entry["description"] = spawn_desc[_tuid]
582
687
  for _f in ("total_tokens", "total_duration_ms", "total_tool_use_count", "status"):
583
688
  if _raw.get(_f) is not None:
584
689
  _entry[_f] = _raw[_f]
@@ -783,6 +888,13 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
783
888
  subagent_meta = asm["subagent_meta"]
784
889
  header_cost = asm["header_cost"]
785
890
 
891
+ # #193: compute the reader header title ONCE so BOTH return sites (the early
892
+ # empty/stale-cursor return and the normal return) carry it. Same fallback
893
+ # chain the rail/search rows use: ai-title -> first human prompt -> project
894
+ # label -> session_id (matching the list's `titles.get(sid) or pl or sid`).
895
+ _pl = _project_label(_latest(logical, 10))
896
+ _title = _session_titles_map(conn, [session_id]).get(session_id) or _pl or session_id
897
+
786
898
  # Cursor pagination over the item list (anchored to each item's canonical id).
787
899
  # A non-None `after` that matches no item's anchor (stale/deleted cursor)
788
900
  # yields an EMPTY page — never silently re-serves the head (M1).
@@ -796,7 +908,8 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
796
908
  if start is None:
797
909
  return {
798
910
  "session_id": session_id,
799
- "project_label": _project_label(_latest(logical, 10)),
911
+ "title": _title,
912
+ "project_label": _pl,
800
913
  "git_branch": _latest(logical, 11),
801
914
  "started_utc": logical[0][2],
802
915
  "last_activity_utc": logical[-1][2],
@@ -830,7 +943,8 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
830
943
  models = sorted({r[6] for r in logical if r[6]})
831
944
  return {
832
945
  "session_id": session_id,
833
- "project_label": _project_label(_latest(logical, 10)),
946
+ "title": _title,
947
+ "project_label": _pl,
834
948
  "git_branch": _latest(logical, 11),
835
949
  "started_utc": first[2],
836
950
  "last_activity_utc": last[2],
package/bin/cctally CHANGED
@@ -792,6 +792,7 @@ _progress_stderr = _cctally_cache._progress_stderr
792
792
  _ensure_session_files_row = _cctally_cache._ensure_session_files_row
793
793
  sync_cache = _cctally_cache.sync_cache
794
794
  backfill_conversation_messages = _cctally_cache.backfill_conversation_messages
795
+ backfill_ai_titles = _cctally_cache.backfill_ai_titles
795
796
  iter_entries = _cctally_cache.iter_entries
796
797
  _collect_entries_direct = _cctally_cache._collect_entries_direct
797
798
  _JoinedClaudeEntry = _cctally_cache._JoinedClaudeEntry