cctally 1.43.2 → 1.43.3

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,16 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.43.3] - 2026-06-14
9
+
10
+ ### Added
11
+ - Conversation viewer: each conversation is now titled by Claude Code's AI-generated session title — in the sessions rail and as the reader header — instead of the first prompt, falling back in order to the first prompt, the project label, then the session id; a title that Claude rewrites mid-session updates an already-open reader live (#193).
12
+ - Conversation viewer: a subagent thread is now titled by the description from the `Task` that spawned it, in both the thread-card header and the matching outline landmark (falling back to the subagent's first prompt), and a Bash tool call shows its own description on the dimmed chip line (falling back to the command, which always stays in the expanded `$ …` body); older transcripts and tool calls without a stored description keep their prior rendering (#193).
13
+
14
+ ### Fixed
15
+ - Conversation viewer: a short single-line "You" prompt no longer shows an empty gap below its text — the per-message copy/permalink actions (previously an always-reserved but hidden-until-hover row that padded the bottom of the bordered box) now float into the bubble's top-right corner, so the box hugs its prose; assistant turns are unchanged (#192).
16
+ - Conversation viewer: the outline no longer highlights two entries at once when you scroll onto a landmark inside a prompt's section — a subagent card, a section heading, or a plan/question (most visible when a subagent is the last outline entry) — it now marks only that exact landmark instead of also lighting the enclosing "You" prompt, while scrolling onto ordinary prose still highlights its section prompt as before (#192).
17
+
8
18
  ## [1.43.2] - 2026-06-13
9
19
 
10
20
  ### Added
@@ -189,6 +189,18 @@ _CONV_INSERT_SQL = (
189
189
  " VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
190
190
  )
191
191
 
192
+ # #193: last non-null write wins (ai-title carries no timestamp; see spec S1). NO
193
+ # byte_offset guard — it can't order a cross-file resumed session. Ordering is
194
+ # made deterministic by ingest order: backfill_ai_titles walks files
195
+ # mtime-ascending so the newest file's last title is written last; the
196
+ # incremental fused walk appends only new bytes in file order.
197
+ _AI_TITLE_UPSERT_SQL = (
198
+ "INSERT INTO conversation_ai_titles(session_id,ai_title,source_path,byte_offset) "
199
+ "VALUES(?,?,?,?) "
200
+ "ON CONFLICT(session_id) DO UPDATE SET "
201
+ "ai_title=excluded.ai_title, source_path=excluded.source_path, byte_offset=excluded.byte_offset"
202
+ )
203
+
192
204
 
193
205
  def _conv_row_tuple(m, path_str):
194
206
  """Flatten a ``MessageRow`` into the ``_CONV_INSERT_SQL`` column order.
@@ -212,19 +224,23 @@ def _conv_row_tuple(m, path_str):
212
224
 
213
225
  def _iter_sync_entries(fh, path_str):
214
226
  """Fused single-pass sync walker (#138). Yields
215
- ``(byte_offset, cost_or_None, msgrow_or_None)`` for each JSONL line from
216
- ``fh``'s current position that produces a cost entry and/or a conversation
217
- message row.
227
+ ``(byte_offset, cost_or_None, msgrow_or_None, aititle_or_None)`` for each
228
+ JSONL line from ``fh``'s current position that produces a cost entry, a
229
+ conversation message row, and/or an ai-title record.
218
230
 
219
231
  Each line is read once (readline()+tell()) and ``json.loads``-parsed ONCE,
220
- then classified by both pure per-line parsers:
232
+ then classified by the pure per-line parsers (#138 one-parse-per-line stays
233
+ intact — ``parse_ai_title`` runs on the SAME already-parsed ``obj``):
221
234
 
222
235
  * ``cost_or_None`` is ``(UsageEntry, msg_id, req_id)`` when the line is a
223
236
  billable assistant entry (``_lib_jsonl.parse_cost_entry``), else None.
224
237
  * ``msgrow_or_None`` is a ``MessageRow`` when the line is a user/assistant
225
238
  turn carrying a uuid (``_lib_conversation.parse_message_row``), else None.
239
+ * ``aititle_or_None`` is an ``AiTitleRow`` when the line is an ai-title
240
+ carrying a non-empty sessionId+aiTitle (#193), else None.
226
241
 
227
- The two are independent — a normal assistant line yields both. This replaces
242
+ The three are independent — a normal assistant line yields the first two;
243
+ an ai-title line (a non-user/assistant type) yields only the third. This replaces
228
244
  the former cost walk + re-seek-and-walk over the identical byte span: with a
229
245
  single walk the "identical span" invariant is structural (one stop point),
230
246
  not a prose-enforced ``mrow.byte_offset >= final_offset`` runtime break. A
@@ -252,8 +268,9 @@ def _iter_sync_entries(fh, path_str):
252
268
  continue
253
269
  cost = _lib_jsonl.parse_cost_entry(obj, path_str)
254
270
  mrow = _lib_conversation.parse_message_row(obj, offset)
255
- if cost is not None or mrow is not None:
256
- yield offset, cost, mrow
271
+ ai = _lib_conversation.parse_ai_title(obj, offset)
272
+ if cost is not None or mrow is not None or ai is not None:
273
+ yield offset, cost, mrow, ai
257
274
 
258
275
 
259
276
  def _iter_claude_jsonl_files():
@@ -616,6 +633,12 @@ def sync_cache(
616
633
  # under the lock (#138) — NOT a bare DELETE that fires conv_fts_ad
617
634
  # per row.
618
635
  clear_conversation_messages(conn)
636
+ # #193: ai-titles share the message lifecycle on a rebuild — wipe the
637
+ # table (so a title for a since-deleted session can't linger) and the
638
+ # pending-backfill flag in lockstep. The per-file fused walk below
639
+ # repopulates from offset 0, satisfying any deferred backfill.
640
+ conn.execute("DELETE FROM conversation_ai_titles")
641
+ conn.execute("DELETE FROM cache_meta WHERE key='ai_titles_backfill_pending'")
619
642
  # Clear the walk-complete sentinel atomically with the wipe
620
643
  # (cctally-dev#93, D5/D2): a stale "complete" marker must never
621
644
  # survive a destructive rebuild. The end-of-loop write below
@@ -726,6 +749,27 @@ def sync_cache(
726
749
  )
727
750
  conn.commit()
728
751
 
752
+ # #193: consume the deferred ai-title backfill. Cache migration 012 is
753
+ # flag-only (sets ``ai_titles_backfill_pending``); the offset-0 walk
754
+ # over all history via backfill_ai_titles (mtime-ascending,
755
+ # last-write-wins) runs HERE under the held flock — same #139
756
+ # contract as the message backfill above. Touches ONLY
757
+ # conversation_ai_titles; the flag is dropped LAST so a crash mid-walk
758
+ # re-runs cleanly. Never on the rebuild path (which already cleared
759
+ # the flag + repopulates via the normal walk).
760
+ try:
761
+ _ai_pending = conn.execute(
762
+ "SELECT 1 FROM cache_meta WHERE key='ai_titles_backfill_pending'"
763
+ ).fetchone() is not None
764
+ except sqlite3.OperationalError:
765
+ _ai_pending = False
766
+ if _ai_pending:
767
+ backfill_ai_titles(conn)
768
+ conn.execute(
769
+ "DELETE FROM cache_meta WHERE key='ai_titles_backfill_pending'"
770
+ )
771
+ conn.commit()
772
+
729
773
  # Issue #164: consume the deferred conversation_messages re-ingest.
730
774
  # Cache migration 003 is flag-only — it sets
731
775
  # ``conversation_reingest_pending`` rather than clearing inline
@@ -905,6 +949,11 @@ def sync_cache(
905
949
  # triggers → truncate → 'delete-all' → recreate, so conv_fts_ad
906
950
  # never fires O(rows) inside the held lock.
907
951
  clear_conversation_messages(conn)
952
+ # #193: truncation escalates to a full offset-0 re-ingest, so wipe
953
+ # conversation_ai_titles too (parallel to the session_entries +
954
+ # conversation_messages full-reset). The per-file fused walk below
955
+ # repopulates it from offset 0.
956
+ conn.execute("DELETE FROM conversation_ai_titles")
908
957
  # Clear the walk-complete sentinel atomically with the truncation
909
958
  # full-reset (cctally-dev#93, D5/D2): the cache is being wiped, so
910
959
  # any "complete" marker is now stale. The end-of-loop write below
@@ -976,6 +1025,7 @@ def sync_cache(
976
1025
  # so a slow JSONL doesn't hold a SQLite lock.
977
1026
  rows: list[tuple[Any, ...]] = []
978
1027
  conv_rows: list[tuple[Any, ...]] = []
1028
+ ai_rows: list[tuple[Any, ...]] = [] # #193: ai-title upserts
979
1029
  final_offset = start_offset
980
1030
  try:
981
1031
  with open(jp, "r", encoding="utf-8", errors="replace") as fh:
@@ -987,7 +1037,7 @@ def sync_cache(
987
1037
  # walk over the identical span — the "identical span"
988
1038
  # invariant is now structural (a single stop point) rather
989
1039
  # than a prose-enforced ``>= final_offset`` runtime break.
990
- for offset, cost, mrow in _iter_sync_entries(fh, path_str):
1040
+ for offset, cost, mrow, ai in _iter_sync_entries(fh, path_str):
991
1041
  if cost is not None:
992
1042
  entry, msg_id, req_id = cost
993
1043
  usage = entry.usage
@@ -1015,6 +1065,11 @@ def sync_cache(
1015
1065
  ))
1016
1066
  if mrow is not None:
1017
1067
  conv_rows.append(_conv_row_tuple(mrow, path_str))
1068
+ if ai is not None:
1069
+ # #193: accumulate ai-title upserts in file order; the
1070
+ # executemany below applies them after conv_rows.
1071
+ ai_rows.append((ai.session_id, ai.ai_title,
1072
+ path_str, ai.byte_offset))
1018
1073
  # ``final_offset`` is the single walk's stop — captured AFTER
1019
1074
  # the loop drains (or rewinds a partial mid-write tail line).
1020
1075
  # It is what session_files.last_byte_offset is written from,
@@ -1115,6 +1170,10 @@ def sync_cache(
1115
1170
  # (parallel to the cost path's lifecycle).
1116
1171
  if conv_rows:
1117
1172
  conn.executemany(_CONV_INSERT_SQL, conv_rows)
1173
+ # #193: ai-title upserts for this file, in file order (last wins).
1174
+ # Committed atomically with the session_files cursor below.
1175
+ if ai_rows:
1176
+ conn.executemany(_AI_TITLE_UPSERT_SQL, ai_rows)
1118
1177
  # UPSERT preserves session_id / project_path columns populated
1119
1178
  # by _ensure_session_files_row at the top of this loop. A plain
1120
1179
  # INSERT OR REPLACE would wipe them on every changed-file sync.
@@ -1226,6 +1285,46 @@ def backfill_conversation_messages(conn: sqlite3.Connection) -> int:
1226
1285
  return inserted
1227
1286
 
1228
1287
 
1288
+ def backfill_ai_titles(conn: sqlite3.Connection) -> int:
1289
+ """One-time backfill of ``conversation_ai_titles`` for existing installs
1290
+ (#193). Walks EVERY Claude JSONL from offset 0 via
1291
+ ``_lib_conversation.iter_ai_titles`` and upserts.
1292
+
1293
+ Files are walked MTIME-ASCENDING so that, for a session whose ai-title spans
1294
+ multiple files (a ``--resume``), the most-recently-modified file's last
1295
+ non-null title is written last (last-write-wins; see _AI_TITLE_UPSERT_SQL).
1296
+ Per-file commit; the caller (``sync_cache``, consuming the
1297
+ ``ai_titles_backfill_pending`` flag) holds the ``cache.db.lock`` flock for the
1298
+ duration. Touches ONLY ``conversation_ai_titles`` — the cost/message cursors
1299
+ are untouched. Idempotent: a re-run rewrites the same current title (the
1300
+ last-write-wins ordering is stable under the deterministic mtime walk).
1301
+ Returns rows upserted."""
1302
+ n = 0
1303
+
1304
+ def _mtime(p):
1305
+ try:
1306
+ return p.stat().st_mtime
1307
+ except OSError:
1308
+ return 0.0 # vanished mid-walk; sorts first, the open() below skips it
1309
+
1310
+ files = sorted(_iter_claude_jsonl_files(), key=_mtime)
1311
+ for jp in files:
1312
+ path_str = str(jp)
1313
+ rows: list[tuple[Any, ...]] = []
1314
+ try:
1315
+ with open(jp, "r", encoding="utf-8", errors="replace") as fh:
1316
+ for r in _lib_conversation.iter_ai_titles(fh, path_str):
1317
+ rows.append((r.session_id, r.ai_title, path_str, r.byte_offset))
1318
+ except OSError as exc:
1319
+ eprint(f"[ai-title-backfill] could not read {jp}: {exc}")
1320
+ continue
1321
+ if rows:
1322
+ conn.executemany(_AI_TITLE_UPSERT_SQL, rows)
1323
+ n += len(rows)
1324
+ conn.commit()
1325
+ return n
1326
+
1327
+
1229
1328
  _REINGEST_FLAG_KEYS = (
1230
1329
  "conversation_reingest_pending",
1231
1330
  "conversation_source_tool_use_reingest_pending",
@@ -2349,6 +2349,17 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2349
2349
  CREATE INDEX IF NOT EXISTS idx_conv_turnkey
2350
2350
  ON conversation_messages(msg_id, req_id);
2351
2351
 
2352
+ -- #193: per-session AI-generated title, isolated from the six places
2353
+ -- that iterate conversation_messages. The explicit NOT NULL on the
2354
+ -- non-INTEGER PRIMARY KEY matters (SQLite's legacy NULL-in-PK bug);
2355
+ -- _session_titles_map can only key on a concrete session_id.
2356
+ CREATE TABLE IF NOT EXISTS conversation_ai_titles (
2357
+ session_id TEXT NOT NULL PRIMARY KEY,
2358
+ ai_title TEXT NOT NULL,
2359
+ source_path TEXT,
2360
+ byte_offset INTEGER NOT NULL
2361
+ );
2362
+
2352
2363
  CREATE TABLE IF NOT EXISTS codex_session_files (
2353
2364
  path TEXT PRIMARY KEY,
2354
2365
  size_bytes INTEGER NOT NULL,
@@ -3383,6 +3394,19 @@ def _011_conversation_promote_command_args(conn: sqlite3.Connection) -> None:
3383
3394
  conn.commit()
3384
3395
 
3385
3396
 
3397
+ @cache_migration("012_create_conversation_ai_titles")
3398
+ def _012_create_conversation_ai_titles(conn: sqlite3.Connection) -> None:
3399
+ """Flag-only arm for #193. The conversation_ai_titles table itself is created
3400
+ by _apply_cache_schema (runs on every open, fresh + existing installs); this
3401
+ migration sets ``ai_titles_backfill_pending`` so sync_cache walks all history
3402
+ once via backfill_ai_titles under the cache.db.lock flock. No data work here
3403
+ -> the dispatcher's central stamp (#140) marks a complete handler; a fresh
3404
+ install stamps WITHOUT a populated history (its incremental walk fills the
3405
+ table as it ingests, and the consumed backfill no-ops). Mirrors 002/010/011."""
3406
+ _set_cache_meta(conn, "ai_titles_backfill_pending", "1")
3407
+ conn.commit()
3408
+
3409
+
3386
3410
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
3387
3411
 
3388
3412
  @stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
@@ -263,6 +263,55 @@ def parse_message_row(obj, offset):
263
263
  return _normalize(obj, t, offset)
264
264
 
265
265
 
266
+ @dataclass
267
+ class AiTitleRow:
268
+ """Pure per-line AI-title record (no I/O). Parallels MessageRow but for the
269
+ main-session ``{"type":"ai-title","aiTitle":...,"sessionId":...}`` lines that
270
+ parse_message_row drops (type not in user/assistant). #193."""
271
+ session_id: "str | None"
272
+ ai_title: str
273
+ byte_offset: int
274
+
275
+
276
+ def parse_ai_title(obj, offset):
277
+ """Return an AiTitleRow when ``obj`` is an ai-title line with BOTH a non-empty
278
+ string sessionId and a non-empty string aiTitle, else None. Skips the null /
279
+ blank rewrites CC emits as the title evolves, and any malformed line. #193."""
280
+ if obj.get("type") != "ai-title":
281
+ return None
282
+ sid = obj.get("sessionId")
283
+ title = obj.get("aiTitle")
284
+ if not (isinstance(sid, str) and sid.strip()):
285
+ return None
286
+ if not (isinstance(title, str) and title.strip()):
287
+ return None
288
+ return AiTitleRow(sid, title, offset)
289
+
290
+
291
+ def iter_ai_titles(fh, path_str):
292
+ """Yield AiTitleRow for each ai-title line from ``fh``'s current position.
293
+ Mirrors iter_message_rows: caller owns the open file handle; pure parse.
294
+ ``path_str`` is accepted for signature-parity (ai-title rows don't use it)."""
295
+ while True:
296
+ offset = fh.tell()
297
+ line = fh.readline()
298
+ if not line:
299
+ return
300
+ if not line.endswith("\n"):
301
+ fh.seek(offset)
302
+ return
303
+ s = line.strip()
304
+ if not s:
305
+ continue
306
+ try:
307
+ obj = json.loads(s)
308
+ except json.JSONDecodeError:
309
+ continue
310
+ row = parse_ai_title(obj, offset)
311
+ if row is not None:
312
+ yield row
313
+
314
+
266
315
  def _normalize(obj, t, offset):
267
316
  msg = obj.get("message")
268
317
  if not isinstance(msg, dict):
@@ -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
@@ -536,6 +549,7 @@ def _assemble_session(conn, session_id):
536
549
  # the top-level subagent_meta map (no undocumented block keys leak). Join is
537
550
  # spawn tool_use id <-> tool_result tool_use_id; agent_id == subagent_key.
538
551
  spawn_kind = {} # tool_use id -> subagent_type
552
+ spawn_desc = {} # tool_use id -> spawning Task description (#193)
539
553
  agent_link = {} # tool_use id -> (agent_id, raw_meta)
540
554
  ask_link = {} # tool_use id -> (answers, annotations) (#177 S2)
541
555
  bash_link = {} # tool_use id -> (stderr, interrupted) (#177 S3)
@@ -549,6 +563,13 @@ def _assemble_session(conn, session_id):
549
563
  st = b.pop("subagent_type", None)
550
564
  if st and b.get("id") is not None:
551
565
  spawn_kind[b["id"]] = st
566
+ # #193: harvest the spawning Task description from the
567
+ # already-stored bounded input. Guarded on subagent_type, so
568
+ # a Bash `description` (no subagent_type) is NEVER picked up.
569
+ _inp = b.get("input")
570
+ _d = _inp.get("description") if isinstance(_inp, dict) else None
571
+ if isinstance(_d, str) and _d.strip():
572
+ spawn_desc[b["id"]] = _d
552
573
  elif k == "tool_result":
553
574
  aid = b.pop("agent_id", None)
554
575
  meta = b.pop("subagent_meta", None)
@@ -579,6 +600,8 @@ def _assemble_session(conn, session_id):
579
600
  continue # spawn with no (yet) result -> title-only
580
601
  _aid, _raw = _link
581
602
  _entry = {"kind": _kind}
603
+ if spawn_desc.get(_tuid): # #193: spawning Task description
604
+ _entry["description"] = spawn_desc[_tuid]
582
605
  for _f in ("total_tokens", "total_duration_ms", "total_tool_use_count", "status"):
583
606
  if _raw.get(_f) is not None:
584
607
  _entry[_f] = _raw[_f]
@@ -783,6 +806,13 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
783
806
  subagent_meta = asm["subagent_meta"]
784
807
  header_cost = asm["header_cost"]
785
808
 
809
+ # #193: compute the reader header title ONCE so BOTH return sites (the early
810
+ # empty/stale-cursor return and the normal return) carry it. Same fallback
811
+ # chain the rail/search rows use: ai-title -> first human prompt -> project
812
+ # label -> session_id (matching the list's `titles.get(sid) or pl or sid`).
813
+ _pl = _project_label(_latest(logical, 10))
814
+ _title = _session_titles_map(conn, [session_id]).get(session_id) or _pl or session_id
815
+
786
816
  # Cursor pagination over the item list (anchored to each item's canonical id).
787
817
  # A non-None `after` that matches no item's anchor (stale/deleted cursor)
788
818
  # yields an EMPTY page — never silently re-serves the head (M1).
@@ -796,7 +826,8 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
796
826
  if start is None:
797
827
  return {
798
828
  "session_id": session_id,
799
- "project_label": _project_label(_latest(logical, 10)),
829
+ "title": _title,
830
+ "project_label": _pl,
800
831
  "git_branch": _latest(logical, 11),
801
832
  "started_utc": logical[0][2],
802
833
  "last_activity_utc": logical[-1][2],
@@ -830,7 +861,8 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
830
861
  models = sorted({r[6] for r in logical if r[6]})
831
862
  return {
832
863
  "session_id": session_id,
833
- "project_label": _project_label(_latest(logical, 10)),
864
+ "title": _title,
865
+ "project_label": _pl,
834
866
  "git_branch": _latest(logical, 11),
835
867
  "started_utc": first[2],
836
868
  "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