Global Search (Ctrl+K across everything) (part 2)
Everything Ctrl+K finds comes from a full-text index built in one place, and the rule is that what you can read in a conversation is what search can find — an agent row contributes only its prose, never its tool calls. The index stores no text of its own, so each result excerpt is rebuilt from the message when the row is shown, and a quoted phrase narrows a search to a true word-for-word match. A one-time job rebuilds the cold-storage archive’s index in the background, and tells you in your inbox if it keeps failing.
What it is
The page you are reading is the second half of Global Search, which describes the Ctrl+K palette itself: the tabs, the filters, how to use them and why a search stays fast. This part is the machinery underneath — what goes into the index search reads from, and how a phrase in quotes is matched word for word.
Where to find it
There is nothing to open: the index is built as messages arrive, and the archive rebuild runs by itself in the background after the app starts. The one thing you may see is an inbox notice when that rebuild keeps failing — search keeps working meanwhile.
How it behaves
What reaches the index, and how a phrase is matched word for word
What reaches the FTS index is decided in one place: indexableSearchText(source, content, opts) in /src/shared/search-index-text.ts, re-exported by /src/main/db/fts-index-text.ts and shared so the archive's database-worker rebuild applies the identical rule. Operator rows return their content verbatim; agent rows return only the trailing prose after the last tool marker (▸ / ←) via extractProse(); still-streaming partials and system rows return null and contribute nothing.
An agent row's text also depends on the four display flags that decide what the chat actually shows (the final-marker toggles and the dev-pipeline switch). Search therefore indexes exactly the text you can read: nothing visible stops being findable, and a hit always has a visible match. Those flags are a required argument, so a caller that forgets them fails to compile rather than quietly indexing under a different rule — every producer (addMessage(), the table rebuild, the cold-storage rebuild, the archive's realignment, the backup merge, the interrupted-turn heal) now takes them from the one reader in /src/main/db/index-render-flags.ts, and changing a display setting re-derives the index at the next launch rather than leaving it on the old rendering's words for good. That re-derive covers the main store; the cold-storage archive is indexed by its own one-time realignment, which a settings flip does not currently re-arm, so an archived agent row keeps the previous rendering's words until that is added.
The index stores no text of its own. conversation_messages_fts is contentless — it holds the search structure and nothing else, which is what makes it roughly half a gigabyte smaller than the layout it replaced. A result's excerpt is therefore rebuilt from the message when it is displayed, by /src/main/db/queries-messages/snippet-rebuild.ts, through the same rule and the same flags the index was written with. A per-row try/catch keeps a single pathological row from blocking a rebuild.
On the read side, extractExactPhrases(input) (also in queries-messages.ts) pulls every positive quoted phrase out of the user's query — it skips negated phrases (those stay on the stemmed NOT path) and returns [] for any query containing an OR (each side of an OR only ever appears in one of the candidate rows, so AND-ing word-exact narrowing would wrongly reject real hits). Each extracted phrase becomes an extra AND phrase_exact(<the row's indexed text>, ?) = 1 condition on the FTS query. That operand is recomputed per row by the indexed_text(source, content, metadata, flags) scalar, because the index now stores no text of its own and the raw column is not a substitute: an agent row's raw content also carries its tool calls and results, so narrowing against it would admit rows whose phrase sits in text the index never holds and the excerpt — derived from prose — cannot show. Operator rows return their content unchanged, so the derivation costs only agent rows, and only on a quoted query. phrase_exact and indexed_text are deterministic SQLite scalars registered in /src/main/db/custom-db-functions.ts — it compiles a per-needle regex ((?<!WORD)needle(?!WORD) with WORD = [\p{L}\p{N}_], iu flags, regex-metacharacters escaped, and every gap of whitespace or hyphens BETWEEN two words replaced with [\s-]+ by the shared phraseRegexSource in /src/main/db/db-text-functions.ts), memoized in a 100-entry LRU. The result excerpt (snippet-rebuild.ts) builds its pattern with the same helper and anchors on the quoted phrase (excerptTerms), so it highlights exactly what the narrowing accepted; bare words are found only at a word start. searchSessionTitles adds the same phrase_exact(s.name, ?) condition per quoted phrase, and isSemanticEligible in /src/main/ipc/session/search.ts refuses any query containing a quote. The result: FTS5's porter-stemmer recall narrows down to a true word-for-word match without rejecting the index's overall stemmed superset for unquoted searches.
The archive’s index: rebuilt once, in the background
The archive's index is rebuilt once, in the background. Messages moved to cold storage before
2026-09-17 were indexed at row ids that did not match their messages, so an archive search had to
look every candidate up by its message id, and restoring an old session could quietly remove OTHER
conversations' entries from Ctrl+K. A one-time job rebuilds the archive's index from the archived
messages themselves — every entry at its own message's row id, curated by the same rule as the live
index — and swaps it in with a rename, so search keeps using the old index until the new one is
complete. It starts after the app does, runs off the main thread in short time-boxed steps beside
the cold-storage sweep (slower when the computer is busy), picks up where it left off after a
restart, and finally clears the old index a slice at a time. Once it has swapped in, archived results
that had gone missing come back and the archive is searched by row id like the live store. The job
is archive-fts-realign.ts, its database
steps named-tx-archive-fts-realign.ts; the rule
is archive-rowid-alignment-is-restored-once in the same contract.
If that rebuild keeps failing, the inbox says so. After three failed runs in a row the user gets
one notice, "Archive search rebuild keeps failing": search still works as before, no message is
affected, the rebuild keeps retrying on its own, and the notice clears itself once it finishes. A run
cut short because the app was closing does not count, and neither does the developer kill switch;
a background worker that could not start does. The count is kept in
<data folder>/logs/archive-search-rebuild-failures.json, so failures across restarts add up. The
notice is held during a brand-new user's first day (a fresh archive has nothing to rebuild), and it
can be switched off like any other notice type. The code is
archive-fts-realign-alert.ts; the
rule is a-rebuild-that-keeps-failing-says-so in the same contract.
Related
Global Search (Ctrl+K) is the page this continues — the palette, its tabs and its filters. What reaches the index is governed by the FTS index contract, and semantic-search.md covers the meaning-based matches that blend into the same result list.
Last verified 2026-10-02