cctally 1.52.1 → 1.54.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,53 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.54.0] - 2026-06-22
9
+
10
+ ### Added
11
+ - **Dashboard conversation viewer — side-by-side session comparison.** A new **⟷ Compare with…** control in the reader head puts the conversation list into a pick-mode (a banner with Cancel/Esc; the anchor session's row greyed out) to choose a second session, then opens a side-by-side **prompt-sequence diff** of the two runs — built for comparing variations of the same or a similar task to see where they diverged and which run was cheaper / faster / cleaner. A metrics-delta strip heads the view with A→B Cost, Tokens, Prompts, Errors, Duration, and Files (an improvement arrow only on the lower-is-better cost / errors / duration), the two prompt spines are aligned by their normalized first lines with a ⚡ divergence marker on replaced regions and a hatched gap where one run has an extra prompt, and clicking any row lazily expands both runs' full prompt text inline (new `GET /api/conversation/<id>/prompts` route behind the same fail-closed transcript gate) with an "open in reader →" jump per side. The view is two-column on a wide viewport and a unified single column below ~1100px, divergence is conveyed by the ⚡ bar + ◆ markers + del/add styling (never color alone), and the comparison is shareable and cold-loadable via its `#/conversations/compare/<A>/<B>` URL with ⇄ swap / ✕ close. (#217)
12
+ - **Dashboard conversation viewer — transcript export & per-card extraction.** The reader header gains an `Export ▾` menu with four Markdown scopes — Whole transcript, Prompts only, Chat only, and Replay recipe (a numbered list of your prompts) — each offering Copy (clipboard) and Download (`.md`); the export is computed server-side over the whole assembled session (new `GET /api/conversation/<id>/export` route behind the same fail-closed transcript gate), so it's complete even for a long transcript you've only partially paged in, and truncated tool data is marked `… [truncated]` rather than shown as complete. A diff card additionally offers a `.patch` download (a real `diff --git`/`@@` unified diff — Write yields an applyable add-patch, Edit/MultiEdit are snippet-relative best-effort), and a Bash card offers a `copy full` action (`$ cmd` + stdout + stderr, with a truncation marker when clipped). (#217)
13
+ - **Dashboard conversation viewer — files-touched tab + tool-type/subagent focus filters.** The outline sidebar gains an `[Outline] [Files]` tab listing every file the session modified via an Edit/MultiEdit/Write call (in first-touch order) with a summed `+N −M` badge per path, expandable to per-touch jump rows that scroll the transcript to the change; a truncated edit with no computable stat is still listed (its badge side omitted), and `NotebookEdit`/read-only tools never appear. The focus control gains a `▾ More` menu adding three filters on the same single-select axis — Edits (Edit/MultiEdit/Write turns), Bash, and a Subagent submenu that isolates one top-level subagent thread (labelled by its agent kind, key fallback when no metadata) — that filter the reading column like the four primary modes; `v` keeps cycling only the four primary modes and returns to All from a More filter. (#217)
14
+ - **Dashboard conversation viewer — git-context diff rendering + a task-completion summary.** An injected `Injected context` block that carries an unfenced git diff (e.g. `diff --git a/CLAUDE.md b/CLAUDE.md … @@ … @@`) now renders as a real red/green unified diff — per-file path header, a `+N −M` stat, and the same syntax-highlighted hunk rows as an Edit diff — with the surrounding prose left as Markdown; detection is conservative (a real `diff --git` marker, never a bare `-`/`+` bullet), so a Markdown list is never mistaken for a diff. And when a session's main-thread to-do checklist ends fully completed, the reader header shows a green `✓ Complete · N` chip (always visible regardless of scroll) that jumps to the final checklist, plus a `✓ Session complete (N tasks)` outline landmark — both gated on the final *main-thread* snapshot being entirely done, so a subagent's own checklist never triggers them. (#217)
15
+ - **Dashboard conversation viewer — in-reader cost/token analytics.** Each assistant turn's cost footer gains a thin micro-bar whose width and intensity encode that turn's cost relative to the most-expensive turn loaded so far (the exact `$`/token text is unchanged; the bar is a relative cue with the precise cost in its tooltip); the reader header carries a cumulative-cost chip showing `$so-far / $total` plus a progress bar that tracks your scroll position, prefixed `~` when earlier pages aren't loaded yet (a lower bound) and hidden both on a costless session and until you've scrolled a turn into view (so it never flashes a transient `$0.00`); and the outline's cache-rebuild stat expands from a single count into a per-rebuild jump list — each row labelled with its turn, wasted tokens, and `~$` cost, worst-first, capped at three with a "+N more" expander, honoring the `dashboard.cache_failure_markers` opt-out. (#217)
16
+ - **Dashboard conversation viewer — per-turn bookmarks & notes.** Each turn's action row gains a ★ bookmark toggle with an inline note editor (saved on Enter/blur, cancelled on Esc); bookmarks persist in your browser's local storage only — nothing leaves the machine — and surface as ★ landmarks in the outline (labelled with your note or the turn's heading, including bookmarks on subagent turns), with a ★ chip in the outline's "Jump to" cluster and reader keys `i` / `I` to step to the next / previous bookmark and `t` to toggle a bookmark on the current turn. (#217)
17
+ - **Dashboard conversation viewer — inline PDF rendering.** A PDF attachment now offers a `view inline ▾` toggle that renders the PDF in the reading column at a capped, scrollable height (with a `collapse ▴` control); the viewer mounts — and the bytes fetch — only when expanded, is served behind the same privacy gate as every other transcript media request, and falls back to the `open ↗` link automatically in a browser without a built-in PDF viewer. (#217)
18
+
19
+ ### Changed
20
+ - **Dashboard conversation search — the Files facet now matches a file-path substring instead of a path prefix.** Searching the Files facet for a bare basename (`package.json`) or a mid-path fragment (`cctally`) now returns the sessions that touched a matching path, instead of requiring a leading path prefix; the match is a case-insensitive substring over the (modest) touched-file table. (#223)
21
+ - **Dashboard conversation find bar — regex matches are now underlined in the transcript.** In regex find mode the in-prose highlight is no longer suppressed: matches get a best-effort inline underline (per rendered text segment, case-toggle-aware), alongside the existing match count and jump-to-match. Invalid or pathological patterns degrade to no underline (the match count still drives navigation). (#223)
22
+
23
+ ### Fixed
24
+ - **Dashboard conversation viewer — the reader-header `Export ▾` and focus `▾ More` menus are now fully keyboard-navigable.** Both popovers previously exposed a `role="menu"` with no arrow-key navigation; they now implement the full menu keyboard pattern — opening moves focus to the first item, `↑`/`↓` cycle through items (wrapping), `Home`/`End` jump to the ends, and `Esc` closes and restores focus to the trigger, with a single roving tab stop so `Tab` moves past the menu rather than through every item. In the focus `▾ More` menu, `→` opens the Subagent submenu (focusing its first entry) and `←` collapses back to the parent. (#224)
25
+ - **Dashboard conversation viewer — git-context diff path parsing no longer over-captures a trailing word into the file path.** The `diff --git a/… b/…` path capture now stops at whitespace instead of greedily running to end-of-line, so a pathological marker line carrying trailing prose after the `b/` path can't bleed that text into the displayed new-path header; real injected diffs (whose paths are whitespace-free) are unaffected. (#224)
26
+ - Conversation viewer: subagents dispatched without an explicit type (the default general-purpose case, e.g. SDD implementors/reviewers) now show their kind, description, and token/duration/tool usage in the card chip and outline — previously only explicitly-typed subagents (e.g. Explore) did (#225).
27
+ - **Dashboard conversation viewer — jumping to a deeply-nested subagent no longer occasionally leaves that subagent's card collapsed.** When you jump to a target inside a subagent that was itself spawned by another subagent (a grandchild thread), the reader force-opens the whole ancestor chain; that expansion now latches each card's open-state in the same render that opens it instead of a beat later, so the brief reset that runs once the jump has scrolled into place can no longer race the parent's expansion and silently re-collapse the nested target. (#222)
28
+
29
+ ## [1.53.0] - 2026-06-21
30
+
31
+ ### Added
32
+ - **Dashboard conversation outline — per-subagent cost, a resizable column, a nested-subagent tree, and a compaction landmark.** Each subagent thread in the outline now shows its cost beside its label (a display-only, summed-cost-once figure that covers every thread, including older ones with no captured spawn metadata). A thin resize divider between the reading column and the outline lets you drag the outline wider or narrower — keyboard-resizable too (`←`/`→` step, `Home`/`End` to the extremes, with a labeled `separator` accessibility surface) — and the width persists across reloads. When a subagent spawns its own sub-subagents, the outline nests those children indented beneath their parent thread (a session with no such nesting looks exactly as before). And a conversation compaction (the auto-summarize point) is now a navigable outline landmark with its own "Jump to" chip and `m`/`M` reader keys. The outline's "Jump to" chips also change: a primary click now jumps to the most-recent occurrence of that landmark family, while shift-click steps to the previous one.
33
+ - **Dashboard conversation reader — opens at the bottom with two-way scrolling, remembers where you were reading, and jumps straight to the last prompt or error.** A long conversation now opens on its newest turns (single-request tail load, with live-tail already following) instead of at the top; a short conversation that fits one page still opens at the top so it reads from the start. The reader window pages in both directions — scrolling down loads newer turns and scrolling up prepends older ones, anchored so the turn you're reading stays put rather than jumping — and a backward (scroll-up) page never breaks the "follow the live session" state. Switching away from a conversation and returning later restores you to the turn you were last reading (anchored to the turn, so it survives a resized window or a grown transcript; a deep-link or jump target still wins, and a vanished turn falls back to the bottom). Two new reader keys jump directly to the most-recent occurrence: `a` to the last prompt and `L` to the last error. `Latest ↓` / `End` now reset to the newest page in a single request (instant even on a very long session) rather than paging all the way forward.
34
+ - **`cctally doctor` — new conversation-rollup consistency check.** `doctor` (and the dashboard's doctor panel) now run a `data.conversation_sessions_rollup` check that compares the materialized `conversation_sessions` browse-rail rollup against the distinct session count in the conversation cache, warning only when they disagree in a quiescent cache — an in-flight sync, reingest, or backfill is detected (via a non-blocking cache-lock probe and the pending-flag set) and never warns — so a stale or half-built rollup that would skew the conversation list's filters and counts is surfaced instead of passing silently.
35
+ - **Dashboard conversation reader — open-at-bottom and backward pagination on the reader API.** `GET /api/conversation/<id>` now pages with `before=<id>` (the page of turns immediately *before* a cursor) and `tail=1` (the last page in a single request, so a client can land on the newest turns without paging forward through the whole session) alongside the existing forward `after=<id>`; the three cursors are mutually exclusive (more than one is a `400`) and `limit` is clamped `1`–`1000` (default `500`). The `page` object gains additive `prev_before`/`has_prev` (older turns remain to page back to) beside `next_after`/`has_more`, each computed from the page's actual position so a short backward page at the head still reports more forward; a stale cursor yields an empty page rather than re-serving the head or tail. Existing no-cursor and `after` responses are byte-stable except for the two additive `page` keys (backend contract only — the frontend lights this up in a later session).
36
+ - **Dashboard conversation search — `kind=title` title search and server-side filtered search.** `GET /api/conversation/search` adds a `kind=title` facet that searches the AI-generated session titles and returns one session-level hit per matching session (anchored to the session's first turn, with `match_kinds:["title"]` and the matched title as the snippet); its `total` counts only sessions with surviving turns, so the count never lies, and it falls back to a `LIKE` scan when the host lacks FTS5. The search route also now accepts the same browse filters as the conversation list (`date_from`/`date_to`/`projects`/`cost_min`/`cost_max`/`rebuild_min`), applied as a session-scope restriction across every kind, with the same brief project/cost/rebuild degradation (a `filter_degraded` flag, date-axis only) while the per-session rollup is still indexing; a no-filter request is byte-stable with the prior output. The cross-session-only `title` facet is rejected with a `400` (never a `500`) on the in-conversation `find` route. Backend contract only — surfaced in a later frontend session.
37
+ - **Dashboard conversation search — `kind=files` file-path search.** `GET /api/conversation/search` adds a `kind=files` facet that searches the write-class file paths each session edited or created (files touched by an `Edit`/`MultiEdit`/`Write`/`NotebookEdit` tool call — read-only `Read` and shell `Bash` are excluded so the axis answers "which sessions modified this file"), returning one hit per distinct `(session, file path)` anchored to that path's most-recent touch (`match_kinds:["file"]`, snippet = the file path) and a `total` that is the distinct `(session, file path)` count; a query with no leading separator is treated as a case-insensitive path prefix that is genuinely index-assisted — it rides the `COLLATE NOCASE` path index as a key-range search (the index is `NOCASE` precisely because the default `LIKE` is case-insensitive and a binary-collated index can't serve it) — while a leading-separator query is a full substring scan over the modest touch table (a leading wildcard can never use an index), and the facet composes with the same browse filters as the other kinds and is rejected with a `400` on the in-conversation `find` route. The axis is backed by a re-derivable `conversation_file_touches` table (cache migration `019`) backfilled once on the first sync after upgrade. Backend contract only — surfaced in a later frontend session.
38
+ - **Dashboard conversation reader — long Bash output collapses by default, and a key to search the conversation list from inside a reader.** A Bash tool card whose output runs long (more than 20 rendered lines) now opens collapsed with a `show N lines` hint so it no longer buries the next turn — short output stays open, and the `[`/`]` collapse-all and a per-card click still override either way. The new `f` key focuses the conversation-list search input even while a conversation is open, so you can look up another session without pressing `Esc` to leave the reader first (`/` stays reader-aware and opens the in-conversation find bar).
39
+ - **Dashboard conversation find bar — `⌘F` / `Ctrl+F`, regex + case-sensitive search, a focus trap, and live results.** The in-conversation find bar now opens with `⌘F` / `Ctrl+F` as well as `/` (the browser's native find bar is suppressed only inside the Conversations workspace, and only when no modal or text input is up — it still works on the dashboard panels), and two toggles beside the input — `.*` (regular expression) and `Aa` (case-sensitive), both remembered across reloads — drive a real server-side match over the full transcript corpus (prose, tool commands/results/stderr, and thinking, the same content the search facets cover); an invalid regex shows an announced "invalid regex" hint rather than failing silently. The prose underline now honors the case toggle (and is suppressed in regex mode, where the match count and jump still work fully). The bar traps keyboard focus while open (`Tab`/`Shift+Tab` cycle its controls, `Esc` exits to the transcript), and its results now live-refresh as the open conversation grows — keeping your place by jumping back to the same match across the refresh, and resetting to the first match only when the one you were on disappeared.
40
+ - **Dashboard conversation rail — sort the browse list, persist your filters + sort, and apply filters to search.** A new **Sort** control in the rail header orders the conversation list by Recent (default), Oldest, Cost (high→low), Messages (high→low), or Project (A→Z, with un-labeled sessions last); the chosen sort and the browse filters now persist across reloads instead of resetting every session. The Browse filters also apply to full-text **search** now — the same filter set is shared, so the Filters button stays enabled with a search needle active, the active-filter chips stay visible, and the chosen date/project/cost/rebuild axes narrow the search results (file-path and AI-title results included) rather than only the browse list. While a brand-new or `--no-sync` dashboard is still building its per-session rollup, the Cost/Project sorts briefly fall back to Recent order (and the rollup-only filter axes to date-only), each with a muted "while indexing" note, self-correcting after the first full sync. The `GET /api/conversations` route gains a `sort` param (`recent`/`oldest`/`cost`/`messages`/`project`, lenient on unknown values) and an additive `page.sort_degraded` flag.
41
+ - **Dashboard conversation search — search across sessions by AI-title or file path, surfaced in the rail.** The cross-session search chip row gains two structural facets after a subtle separator — **Title** (searches the AI-generated session titles) and **Files** (searches the write-class file paths each session edited or created) — joining the existing All / Prompts / Assistant / Tools / Thinking content kinds; both stay available even while the search index is still backfilling (neither rides the split index). A Title hit shows the matched title and opens that session at its first turn; a Files hit leads with the file path (with the session title secondary) and opens the session at that path's most-recent touch, so you can jump from "which session modified `bin/cctally`" straight into the conversation. The new hits carry `title` / `file` match badges, and both facets narrow to your active browse filters like every other kind. (This lights up the `kind=title` / `kind=files` backend facets shipped earlier in the conversation viewer's search route.)
42
+
43
+ ### Changed
44
+ - **Dashboard conversation viewer — backend performance and tech-debt hardening (no visible change).** The per-sync browse-rail rollup no longer runs a full transcript assembly for every touched session — the cache-rebuild count now comes from a dedicated lightweight query that shares the exact cache-failure rule with the reader — so the rollup maintenance that runs under the cache lock on every sync no longer does work proportional to sessions × full-assembly; the in-conversation find skips assembling a transcript entirely when a query matches nothing; cross-session search batches its badge and snippet lookups into a couple of bounded queries instead of issuing up to ~200 per-result round-trips; and a dead, always-empty `search_aux` column is removed via cache migration `016`. The rendered conversation output is byte-for-byte unchanged.
45
+
46
+ ### Fixed
47
+ - **Dashboard conversation reader — a cluster of small reading and accessibility refinements.** The header `Errors` focus-mode badge now counts error *turns* (matching the "Jump to" error chip and exactly what clicking the filter steps between) rather than the raw total of error events, so it can no longer read higher than the number of places the filter lands. A WebSearch results-count chip no longer shows green when the search errored (it takes a neutral/error style, with the `· error` marker alongside). The browse list's "Load more" button disables and shows a loading label while a page is fetching, matching the search results list. The Find control uses an inline icon (matching the other reader controls), the `· N hidden ·` focus-mode marker carries a screen-reader label naming the hidden-turn count, the load-full affordance disables when there's no addressable session, the diff result disclosure names the snippet's line count instead of a fixed string, and the ExitPlanMode outcome badge no longer mis-reads a free-text user response that merely mentions "approve"/"reject" as an approval or rejection.
48
+ - **Dashboard conversation viewer — a subagent whose result exceeds 16 KB no longer drops its nested subagent into a detached, unlinked card.** When a subagent returned more than ~16 KB of text, the `agentId:` trailer that links a nested (grandchild) subagent back to the thread that spawned it could fall past the point where the result is clipped for storage, so the grandchild rendered as a flat, top-level card disconnected from its parent. The link is now captured at ingest time from the full, pre-clip result (with the read-time parse kept as a fallback), so deeply-nested subagents render correctly regardless of result size; existing transcripts are re-derived once by a flag-only cache migration (`017_arm_nested_agent_reingest`), and because the cache is fully re-derivable there is nothing to do on upgrade.
49
+ - **Dashboard conversation viewer — full-payload reads now bound and flag truncation honestly.** A tool input large enough to exceed the payload cap is now actually clipped to that cap rather than served over-cap with only a truncation flag set (the bound is measured in the same serialization the response uses, so it holds for non-ASCII text too), and a tool result whose `stderr` stream is itself truncated now carries an explicit `stderr_truncated` flag — so a consumer of the read-full-payload route can tell a clipped stream from a complete one. No change to the rendered transcript.
50
+ - **Dashboard — the Recent Sessions table no longer spills past its panel card at narrow two-column widths (≈1100–1600px).** Its columns (Started · Dur · Model · Project · Cost) sum wider than the card at those widths, so the Cost column used to bleed past the card and the viewport edge; the table now scrolls horizontally inside the card instead, keeping every column full-width (no truncation) with the scrollbar shown only when the table can't fit. Wider layouts (≥~1300px, including the centered ≥1600px cap) are unchanged — the table fits with no scrollbar (#208).
51
+ - **Dashboard — the Daily heatmap no longer clips the digits of larger costs at narrow widths.** A full three-digit cost such as `$212.83` overflowed the day cell's clip box and lost its leading/trailing glyphs (rendering as "212.8"); each cell now sizes its cost text to the cell's width given the value's length, so the whole amount (cents included) always fits — wide cells keep the original 16px size and only cells that would otherwise overflow shrink, fluidly, at any width (#208).
52
+ - **`record-credit` — a week with an already-applied credit is now refused before the confirm prompt, not after it.** Re-running `record-credit` on a week that already has a fully-recorded credit is refused by default (exit 2, naming the recorded pre-credit value and pointing at `--force`). Previously, on an interactive terminal, the command printed the full preview and asked `Proceed? [y/N]` first, then refused only after you answered `y` — so you were prompted to confirm an action that was never going to apply. The refusal now fires before the prompt, so the "already recorded — pass `--force`" message appears immediately on both interactive and non-interactive reruns (#212).
53
+ - **Dashboard conversation find bar — `Esc` from a find-bar button no longer closes the whole reader, and a Files search result no longer prints its path twice.** Pressing `Esc` while focus was on one of the find bar's buttons (Close, the `.*`/`Aa` toggles, or the prev/next arrows) closed the find bar *and* tore the entire reader down back to the conversation list — only `Esc` from the text input behaved correctly. `Esc` is now handled at the bar level, so it closes only the find bar from any of its controls and restores keyboard focus to the transcript (instead of dropping it to the page body). Separately, a `Files` search hit in the conversation rail showed the file path both as its prominent primary line and again, redundantly, in the snippet row beneath; the duplicate snippet row is now suppressed for file hits (every other result kind keeps its snippet).
54
+
8
55
  ## [1.52.1] - 2026-06-20
9
56
 
10
57
  ### Fixed
@@ -185,8 +185,8 @@ _CONV_INSERT_SQL = (
185
185
  " timestamp_utc,entry_type,text,blocks_json,model,msg_id,"
186
186
  " req_id,cwd,git_branch,is_sidechain,source_tool_use_id,"
187
187
  " stop_reason,attribution_skill,attribution_plugin,"
188
- " search_tool,search_thinking,search_aux)"
189
- " VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
188
+ " search_tool,search_thinking)"
189
+ " VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
190
190
  )
191
191
 
192
192
  # #193: last non-null write wins (ai-title carries no timestamp; see spec S1). NO
@@ -206,19 +206,19 @@ def _conv_row_tuple(m, path_str):
206
206
  """Flatten a ``MessageRow`` into the ``_CONV_INSERT_SQL`` column order.
207
207
 
208
208
  The #177 enrichment fields (stop_reason / attribution_skill /
209
- attribution_plugin / search_tool / search_thinking / search_aux) are
210
- TAIL-APPENDED after source_tool_use_id — same order as the SQL column list —
211
- so both ingest paths (fused per-file walk + backfill_conversation_messages)
212
- carry them through this one tuple. #177 S6: ``search_aux`` is always written
213
- as ``''`` (documented-dead); the split ``search_tool``/``search_thinking``
214
- columns carry the non-prose index."""
209
+ attribution_plugin / search_tool / search_thinking) are TAIL-APPENDED after
210
+ source_tool_use_id — same order as the SQL column list — so both ingest paths
211
+ (fused per-file walk + backfill_conversation_messages) carry them through this
212
+ one tuple. #217 S1 / U7a: the documented-dead ``search_aux`` column is gone
213
+ from the live schema (dropped by migration 016); the split
214
+ ``search_tool``/``search_thinking`` columns carry the non-prose index."""
215
215
  return (
216
216
  m.session_id, m.uuid, m.parent_uuid, path_str, m.byte_offset,
217
217
  m.timestamp_utc, m.entry_type, m.text, m.blocks_json, m.model,
218
218
  m.msg_id, m.req_id, m.cwd, m.git_branch, m.is_sidechain,
219
219
  m.source_tool_use_id,
220
220
  m.stop_reason, m.attribution_skill, m.attribution_plugin,
221
- m.search_tool, m.search_thinking, m.search_aux,
221
+ m.search_tool, m.search_thinking,
222
222
  )
223
223
 
224
224
 
@@ -595,6 +595,9 @@ _TARGETED_DECLINE_FLAGS = (
595
595
  "conversation_promote_command_args_pending",
596
596
  "conversation_sessions_backfill_pending",
597
597
  "conversation_queued_prompt_reingest_pending", # migration 014
598
+ "conversation_reingest_nested_agent_pending", # migration 017
599
+ "conversation_title_fts_backfill_pending", # migration 018 (P1-2: HERE ONLY)
600
+ "conversation_reingest_file_touches_pending", # migration 019 (P1-2: HERE ONLY)
598
601
  )
599
602
 
600
603
 
@@ -731,7 +734,20 @@ def sync_cache(
731
734
  # while-busy user prompts persisted as queued_command attachments); the
732
735
  # same offset-0 walk re-derives them through the current parser, so drop
733
736
  # that flag here too — MISSING this site re-arms the flag on every
734
- # cache-sync --rebuild.
737
+ # cache-sync --rebuild. #217 S1 migration 017 sets the DISTINCT
738
+ # conversation_reingest_nested_agent_pending flag (to land the
739
+ # ingest-time structured agent_id stamp on >16 KB nested-subagent
740
+ # grandchildren); the same offset-0 walk re-derives it through the
741
+ # current parser, so drop that flag here as well. #217 S2 migrations
742
+ # 018/019 set conversation_title_fts_backfill_pending (title FTS) and
743
+ # conversation_reingest_file_touches_pending (+ its
744
+ # conversation_file_touches_cursor); the offset-0 walk re-derives both
745
+ # the title FTS and the file-touch axis, so drop them too (#219 S2.3).
746
+ # NOTE: unlike the flags above, dropping the 018/019 keys here is
747
+ # COSMETIC — their consumers run on the just-wiped (empty) tables and
748
+ # self-clear before the offset-0 walk repopulates, so leaving them
749
+ # caused no redundant expensive pass and no re-arming. We add them for
750
+ # consistency with the documented convention only.
735
751
  conn.execute(
736
752
  "DELETE FROM cache_meta WHERE key IN "
737
753
  "('conversation_reingest_pending',"
@@ -739,6 +755,10 @@ def sync_cache(
739
755
  " 'conversation_reingest_enrichment_pending',"
740
756
  " 'conversation_media_reingest_pending',"
741
757
  " 'conversation_queued_prompt_reingest_pending',"
758
+ " 'conversation_reingest_nested_agent_pending',"
759
+ " 'conversation_title_fts_backfill_pending',"
760
+ " 'conversation_reingest_file_touches_pending',"
761
+ " 'conversation_file_touches_cursor',"
742
762
  " 'conversation_reingest_cursor',"
743
763
  " 'conversation_reingest_cursor_gen')")
744
764
  # #177 S6: a rebuild repopulates search_tool/search_thinking via the
@@ -885,9 +905,14 @@ def sync_cache(
885
905
  # for a distinct flag. Migration 014 uses ANOTHER distinct flag
886
906
  # ``conversation_queued_prompt_reingest_pending`` to land queued-while-
887
907
  # busy user prompts (queued_command attachments the parser now promotes
888
- # to HUMAN); same offset-0 re-parse, same distinct-flag reason. We
889
- # trigger the SAME clear + offset-0 backfill on ANY of these flags and
890
- # clear them ALL atomically here under the held flock.
908
+ # to HUMAN); same offset-0 re-parse, same distinct-flag reason. #217 S1
909
+ # migration 017 uses ANOTHER distinct flag
910
+ # ``conversation_reingest_nested_agent_pending`` to land the ingest-time
911
+ # structured agent_id stamp on >16 KB nested-subagent grandchildren
912
+ # (whose agentId: trailer was clipped past the 16 KB cap); same offset-0
913
+ # re-parse, same distinct-flag reason. We trigger the SAME clear +
914
+ # offset-0 backfill on ANY of these flags and clear them ALL atomically
915
+ # here under the held flock.
891
916
  try:
892
917
  _reingest = conn.execute(
893
918
  "SELECT 1 FROM cache_meta WHERE key IN "
@@ -895,7 +920,8 @@ def sync_cache(
895
920
  " 'conversation_source_tool_use_reingest_pending',"
896
921
  " 'conversation_reingest_enrichment_pending',"
897
922
  " 'conversation_media_reingest_pending',"
898
- " 'conversation_queued_prompt_reingest_pending')"
923
+ " 'conversation_queued_prompt_reingest_pending',"
924
+ " 'conversation_reingest_nested_agent_pending')"
899
925
  ).fetchone() is not None
900
926
  except sqlite3.OperationalError:
901
927
  _reingest = False
@@ -931,6 +957,21 @@ def sync_cache(
931
957
  # HUMAN(text=args); the split-FTS UPDATE triggers re-index the args.
932
958
  _consume_promote_command_args(conn)
933
959
 
960
+ # #217 S2 / E7: consume the migration-018 title-FTS backfill under the
961
+ # SAME held flock. An FTS5 'rebuild' re-derives the external-content
962
+ # title index from conversation_ai_titles (P1-7) — idempotent under
963
+ # the 012-then-018 both-pending ordering, and a cheap clear-only
964
+ # no-op on a no-FTS5 build (P1-6). Touches ONLY the title index, never
965
+ # conversation_messages (P1-2).
966
+ _consume_title_fts(conn)
967
+
968
+ # #217 S2 / I-3: consume the migration-019 file-touches backfill under
969
+ # the SAME held flock. Derives conversation_file_touches from existing
970
+ # blocks_json history (cursor-resumable; idempotent via INSERT OR
971
+ # IGNORE). Touches ONLY conversation_file_touches, never
972
+ # conversation_messages (P1-2).
973
+ _consume_file_touches(conn)
974
+
934
975
  if targeted:
935
976
  paths = [pathlib.Path(p) for p in only_paths if pathlib.Path(p).is_file()]
936
977
  else:
@@ -1293,6 +1334,16 @@ def sync_cache(
1293
1334
  # (parallel to the cost path's lifecycle).
1294
1335
  if conv_rows:
1295
1336
  conn.executemany(_CONV_INSERT_SQL, conv_rows)
1337
+ # #217 S2 / I-3: derive this tick's file touches, scoped to the
1338
+ # just-ingested rows' PHYSICAL keys (cr[3]=source_path,
1339
+ # cr[4]=byte_offset per _conv_row_tuple). Cheap (proportional to
1340
+ # new bytes); decoupled from the INSERT OR IGNORE rowcount —
1341
+ # _fill_file_touches reads conversation_messages by physical key,
1342
+ # so an already-present (rowcount-0) row still gets its touches.
1343
+ # Lands in the SAME per-file write transaction as the message
1344
+ # rows, so they commit atomically.
1345
+ _fill_file_touches(
1346
+ conn, scope=[(cr[3], cr[4]) for cr in conv_rows])
1296
1347
  # #193: ai-title upserts for this file, in file order (last wins).
1297
1348
  # Committed atomically with the session_files cursor below.
1298
1349
  if ai_rows:
@@ -1486,6 +1537,7 @@ _REINGEST_FLAG_KEYS = (
1486
1537
  "conversation_reingest_enrichment_pending",
1487
1538
  "conversation_media_reingest_pending", # #177 S4 (migration 009)
1488
1539
  "conversation_queued_prompt_reingest_pending", # migration 014
1540
+ "conversation_reingest_nested_agent_pending", # #217 S1 (migration 017)
1489
1541
  )
1490
1542
 
1491
1543
 
@@ -1550,10 +1602,25 @@ def _resumable_reingest_conversation_messages(conn):
1550
1602
  conn.commit()
1551
1603
  continue
1552
1604
  try:
1605
+ # #217 S2 / I-3 (P1-4): conversation_file_touches is derived state
1606
+ # keyed by conversation_messages.id, and this per-source reingest
1607
+ # DELETEs + re-inserts the file's message rows (bumping autoincrement
1608
+ # ids). Delete the file's touches BEFORE the message delete (resolving
1609
+ # the ids while they still exist), then refill from the reinserted rows
1610
+ # AFTER — all in this one atomic transaction, so a crash leaves no stale
1611
+ # or duplicate anchors.
1612
+ conn.execute(
1613
+ "DELETE FROM conversation_file_touches WHERE message_id IN "
1614
+ "(SELECT id FROM conversation_messages WHERE source_path=?)",
1615
+ (path_str,))
1553
1616
  conn.execute("DELETE FROM conversation_messages WHERE source_path=?",
1554
1617
  (path_str,))
1555
1618
  if rows:
1556
1619
  conn.executemany(_CONV_INSERT_SQL, rows)
1620
+ # Refill scoped to this file's just-reinserted physical keys
1621
+ # (col 3=source_path, col 4=byte_offset per _conv_row_tuple).
1622
+ _fill_file_touches(
1623
+ conn, scope=[(r[3], r[4]) for r in rows])
1557
1624
  _set_cache_meta(conn, "conversation_reingest_cursor", path_str)
1558
1625
  conn.commit()
1559
1626
  except BaseException:
@@ -1568,6 +1635,7 @@ def _resumable_reingest_conversation_messages(conn):
1568
1635
  " 'conversation_reingest_enrichment_pending',"
1569
1636
  " 'conversation_media_reingest_pending',"
1570
1637
  " 'conversation_queued_prompt_reingest_pending',"
1638
+ " 'conversation_reingest_nested_agent_pending',"
1571
1639
  " 'conversation_reingest_cursor',"
1572
1640
  " 'conversation_reingest_cursor_gen')")
1573
1641
  conn.commit()
@@ -1669,9 +1737,10 @@ def _fill_conversation_sessions_filter_columns(conn, session_ids):
1669
1737
  _project_label / _session_cost_map the rail's per-page Python path used), so
1670
1738
  a filtered/displayed value equals what the live rail produced. cost is
1671
1739
  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 propertyrecompute, never
1674
- increment).
1740
+ cache_rebuild_count is a per-session lightweight rebuild-count via the
1741
+ query kernel's single-source-of-truth helper (no full assembly U1; the
1742
+ rule is shared with the reader path) — a whole-session property
1743
+ (recompute, never increment).
1675
1744
 
1676
1745
  No-op when the columns are absent (a pre-migration-015 cache.db being
1677
1746
  re-derived before its 015 ALTER lands), so an early/partial sync never
@@ -1758,6 +1827,153 @@ def _consume_search_split(conn) -> None:
1758
1827
  conn.commit()
1759
1828
 
1760
1829
 
1830
+ def _consume_title_fts(conn) -> None:
1831
+ """#217 S2 / E7: flock-held consumer for ``conversation_title_fts_backfill_pending``
1832
+ (set by cache migration 018). Populates the external-content title FTS over
1833
+ ``conversation_ai_titles`` from existing history.
1834
+
1835
+ Uses the FTS5 ``'rebuild'`` command (the established consumer idiom —
1836
+ ``_apply_cache_schema``'s recovery rebuild + ``_consume_search_split`` —
1837
+ NOT blind row inserts, P1-7): ``'rebuild'`` re-derives the whole index from
1838
+ the content table and is IDEMPOTENT even if migration 012's
1839
+ ``ai_titles_backfill_pending`` ran first and already populated the index via
1840
+ the conv_title_fts_ai trigger (the 012-then-018 both-pending upgrade
1841
+ ordering) — re-running yields the same rows, no duplicates or conflict.
1842
+
1843
+ FTS5-unavailable (``fts5_unavailable`` set, P1-6): there is no usable vtable
1844
+ to rebuild and a ``'rebuild'`` would error on the absent fts5 module, so just
1845
+ clear the flag — ``kind=title`` degrades to a LIKE scan over
1846
+ conversation_ai_titles. Touches ONLY the title index (never
1847
+ conversation_messages — P1-2: this is NOT a message reingest); the flag is
1848
+ dropped LAST so a crash mid-rebuild re-runs cleanly on the next sync. A fresh
1849
+ install never sets the flag, so this is a cheap no-op there."""
1850
+ if conn.execute(
1851
+ "SELECT 1 FROM cache_meta "
1852
+ "WHERE key='conversation_title_fts_backfill_pending'"
1853
+ ).fetchone() is None:
1854
+ return
1855
+ fts_off = conn.execute(
1856
+ "SELECT 1 FROM cache_meta WHERE key='fts5_unavailable'"
1857
+ ).fetchone() is not None
1858
+ if not fts_off:
1859
+ # The title FTS may not exist YET even on an FTS5-capable build: a pre-S6
1860
+ # install whose conversation_fts is still the legacy (text) shape makes
1861
+ # _apply_cache_schema early-return at its legacy_present guard BEFORE it
1862
+ # creates conversation_title_fts. When migrations 010 + 018 are both
1863
+ # pending in that open, _consume_search_split swaps only the MESSAGE FTS
1864
+ # (never the title FTS), so a blind 'rebuild' here would raise
1865
+ # "no such table: conversation_title_fts" — fts5_unavailable is NOT set
1866
+ # (FTS5 IS available), so the fts_off guard above does not protect it.
1867
+ # Swallow that OperationalError and RETURN before the flag-clear so the
1868
+ # flag survives: the NEXT open (message FTS now split → legacy_present
1869
+ # False → _apply_cache_schema creates the title FTS) re-runs this consumer
1870
+ # and completes the backfill. Match the message-FTS path's resilience.
1871
+ try:
1872
+ conn.execute(
1873
+ "INSERT INTO conversation_title_fts(conversation_title_fts) "
1874
+ "VALUES('rebuild')")
1875
+ except sqlite3.OperationalError:
1876
+ return # title FTS not yet created (legacy-shape pre-swap); leave
1877
+ # the flag set, retry on the next open
1878
+ conn.execute(
1879
+ "DELETE FROM cache_meta WHERE key='conversation_title_fts_backfill_pending'")
1880
+ conn.commit()
1881
+
1882
+
1883
+ _FILE_TOUCH_INSERT_SQL = (
1884
+ "INSERT OR IGNORE INTO conversation_file_touches"
1885
+ "(message_id, session_id, uuid, file_path, tool) VALUES(?,?,?,?,?)")
1886
+
1887
+
1888
+ def _fill_file_touches(conn, scope=None) -> None:
1889
+ """#217 S2 / I-3: derive ``conversation_file_touches`` rows from
1890
+ ``conversation_messages.blocks_json`` for the in-scope message rows.
1891
+
1892
+ ``scope`` is an iterable of ``(source_path, byte_offset)`` physical keys, or
1893
+ ``None`` for ALL rows (the backfill). We read FROM ``conversation_messages``
1894
+ (the source of truth) and resolve ``message_id`` from the row's own ``id``.
1895
+
1896
+ P1-3 (load-bearing): scope by the PHYSICAL key ``(source_path, byte_offset)``,
1897
+ NEVER by ``uuid``. ``conversation_messages.uuid`` is NOT unique (only
1898
+ ``(source_path, byte_offset)`` is; the uuid index is ``(session_id, uuid)``),
1899
+ and resume/replay rows legitimately share a ``(session_id, uuid)`` — a
1900
+ ``WHERE uuid=?`` fill would touch unrelated physical rows.
1901
+
1902
+ Decoupled from the message-insert rowcount ("dedup must not gate side
1903
+ effects"): a no-op INSERT OR IGNORE of an already-present message row (rowcount
1904
+ 0) still has its touches derived here, because we read the row by physical key
1905
+ rather than from the insert's lastrowid/rowcount.
1906
+
1907
+ Cheap at steady state: scoped to the rows ingested this tick (proportional to
1908
+ new bytes), never re-parsing the whole session per tick. ``INSERT OR IGNORE``
1909
+ on ``UNIQUE(message_id, file_path, tool)`` makes it idempotent, and a row's
1910
+ ``blocks_json`` is immutable, so accumulate-via-IGNORE needs no per-tick DELETE.
1911
+ The caller owns the commit (this helper never commits)."""
1912
+ def _emit(rows):
1913
+ for mid, sid, uuid_, bj in rows:
1914
+ if not sid:
1915
+ continue # a touch row's session_id is NOT NULL; skip null-session rows
1916
+ try:
1917
+ blocks = json.loads(bj) if bj else []
1918
+ except (TypeError, ValueError):
1919
+ blocks = []
1920
+ for fp, tool in _lib_conversation._derive_file_touches(blocks):
1921
+ conn.execute(_FILE_TOUCH_INSERT_SQL, (mid, sid, uuid_, fp, tool))
1922
+
1923
+ if scope is None:
1924
+ # Backfill: cursor-resumable 500-row batches keyed on the message rowid.
1925
+ # Resume from the stored cursor so an interrupt skips already-derived
1926
+ # batches (the fill is also idempotent via INSERT OR IGNORE, so a restart
1927
+ # from 0 would be correct but redundant).
1928
+ row = conn.execute(
1929
+ "SELECT value FROM cache_meta "
1930
+ "WHERE key='conversation_file_touches_cursor'").fetchone()
1931
+ last_id = int(row[0]) if row and row[0] is not None else 0
1932
+ while True:
1933
+ batch = conn.execute(
1934
+ "SELECT id, session_id, uuid, blocks_json FROM conversation_messages "
1935
+ "WHERE id > ? ORDER BY id LIMIT 500",
1936
+ (last_id,)).fetchall()
1937
+ if not batch:
1938
+ break
1939
+ _emit(batch)
1940
+ last_id = batch[-1][0]
1941
+ _cctally_db_sib._set_cache_meta(
1942
+ conn, "conversation_file_touches_cursor", str(last_id))
1943
+ conn.commit()
1944
+ return
1945
+ for sp, off in scope:
1946
+ rows = conn.execute(
1947
+ "SELECT id, session_id, uuid, blocks_json FROM conversation_messages "
1948
+ "WHERE source_path=? AND byte_offset=?", (sp, off)).fetchall()
1949
+ _emit(rows)
1950
+
1951
+
1952
+ def _consume_file_touches(conn) -> None:
1953
+ """#217 S2 / I-3: flock-held consumer for
1954
+ ``conversation_reingest_file_touches_pending`` (set by cache migration 019).
1955
+ Derives ``conversation_file_touches`` from ALL existing ``blocks_json`` history
1956
+ via ``_fill_file_touches(conn, scope=None)`` (cursor-resumable, 500-row
1957
+ batches), then clears the flag + cursor.
1958
+
1959
+ Touches ONLY ``conversation_file_touches`` (never ``conversation_messages`` —
1960
+ P1-2: this is NOT a message reingest). The fill is idempotent (INSERT OR
1961
+ IGNORE on the UNIQUE key), so an interrupted backfill resumes cleanly on the
1962
+ next locked sync. A fresh install never sets the flag, so this is a cheap
1963
+ no-op there."""
1964
+ if conn.execute(
1965
+ "SELECT 1 FROM cache_meta "
1966
+ "WHERE key='conversation_reingest_file_touches_pending'"
1967
+ ).fetchone() is None:
1968
+ return
1969
+ _fill_file_touches(conn, scope=None)
1970
+ conn.execute(
1971
+ "DELETE FROM cache_meta WHERE key IN "
1972
+ "('conversation_reingest_file_touches_pending',"
1973
+ " 'conversation_file_touches_cursor')")
1974
+ conn.commit()
1975
+
1976
+
1761
1977
  def _consume_promote_command_args(conn) -> None:
1762
1978
  """#188 bug 4: flock-held consumer for ``conversation_promote_command_args_pending``
1763
1979
  (set by cache migration 011). Cursor-resumable walk of