---
title: Global Search (Ctrl+K across everything)
---

# Global Search (Ctrl+K across everything)

## What it is

Global Search is a single keyboard-first search palette that hits **every message channel in Omniscio at once** — Claude conversations, SMS, Slack, Telegram, RSS feeds, and incoming webhooks, plus session titles and project metadata. It uses SQLite FTS5 for full-text ranking, supports phrase quoting, `-word` exclusion, and `OR` syntax, and renders matches with `<<`/`>>` highlighting. Every result has a deep-link you can click — including `omniscio://session/<id>` URLs that render as native links and jump you straight to a specific session without reloading anything.

**What the index covers.** For Claude conversations the index is curated, not raw:

- **Operator (your) messages** — indexed in full, every word.
- **Agent messages** — only the **final reply prose** is indexed. Mid-turn narration, tool-call lines (`▸ ...`), tool-result lines (`← ...`), and still-streaming partial rows are deliberately excluded from the index.
- **System rows** — never indexed.

So searches always land on something a human said or the agent's actual answer — never on `Read(file.ts)` plumbing or transient mid-turn chatter. The exclusion is a property of the index itself, so no per-search toggle is needed (and none exists). Other channels (SMS, Slack, Telegram, RSS, webhook) carry only their natural message content, so they're indexed in full.

In late April 2026 the filter UI was redesigned. The previous "pill row + active-chips" toolbar is gone. **Desktop** now uses Omniscio's standard `DialogShell` chrome — a centered card on a `bg-surface-200` body, `rounded-xl`, `max-w-3xl`, locked to `85vh` tall regardless of result count so the dialog doesn't grow as matches stream in — with a search input row at the top of the body (the **Sort** dropdown lives here, beside the search box) and a **left-rail sidebar** (220 px) below it carrying the filters, in order: **Where → When → Source → Scope → Tag → Clear filters** (Clear filters is the last item, and only appears when something is filtered). All filters are always visible — there is no "Advanced" expander. **Mobile** kept its full-screen sheet with the **hero glow header** (a big search input on a soft accent-color radial halo) and a single **Filters** pill in place of the sidebar, with the Sort dropdown sitting beside that pill. The semantic-vs-keyword toggle is gone too: AI ranking now runs silently for every search per the existing eligibility rules. The user just types and gets the most-likely results.

## Where to find it

### Search surfaces (tabs)

Since 2026-07 the palette carries a **tab bar** that scopes the query to a surface — or searches all of them at once. It **opens on the Conversations tab** (the primary Ctrl+K use — finding a session — keeps its full filter sidebar and muscle memory); the **All** tab is first in the strip, one click away:

- **All** — runs every enabled surface and shows results **grouped by surface** (Conversations → Notes → Meetings → Voice → Recordings → Helpdesk → Settings, in that fixed order), each section capped with a **See all** link that switches to that surface's own tab.
- **Conversations** — the default; the message search the rest of this page describes (every channel + all the filters, Sort, dedup, and Ask AI). Everything below about filters / scope / status / dedup applies to **this** tab.
- **Notes** — full-text search over your KMS notes; opening a result switches to the Notes view and opens the note. This tab appears **only when KMS (Notes) is enabled** (`nothariEnabled`).
- **Meetings** — full-text search over your meeting notes; opening a result switches to the Meetings view and selects the meeting. Always available.
- **Voice** — full-text search over your voice-history conversations; opening a result switches to the Voice History view and opens the conversation. Shown **only when Voice History is enabled** (its unreleased-feature flag + `voiceHistorySidebarEnabled`).
- **Recordings** — full-text search over screen-recording titles + transcripts; opening a result switches to the Recordings library. Shown **only when the Screen Recorder is enabled** (`screenRecorderEnabled`).
- **Helpdesk** — full-text search over **your own** help requests (Get Help question text); opening a result opens your Helpdesk panel to that request. Shown **only when Get Help (the base `helpdesk` flag) is enabled**; it searches only this install's own questions, never other users' threads.
- **Settings** — finds a setting by label / description / keywords and jumps the Settings screen straight to that entry (scrolls + highlights it), reusing the same "jump to setting" path as the in-Settings search.

The rich conversation filter sidebar (Where / When / Source / Scope / Status / Tag) and the Sort control show **only on the Conversations tab** — they don't apply to the other surfaces. Keyboard nav (↑/↓, Enter, Esc) works the same across every tab, including across the grouped sections on All (one flat selection index spans the whole list). Meetings, Voice, Recordings, and Helpdesk are now wired here (2026-07); the remaining unindexed surfaces (emails, Granola, full session-transcript semantic search) are the next follow-up and plug into this same tab bar once their text is indexed. Component + test-locked invariants: [SearchModal surface-tabs contract](/.claude/memory/contracts/search-modal-surface-tabs-contract.md).

### How to use it

1. **Open it.** Press **Ctrl+K** (Windows/Linux) or **⌘K** (Mac) from anywhere in the app — it works even while you're typing in a text field. You can also press **/** (GitHub-style) when you're **not** typing in a text field; inside a text box `/` types a literal slash, so click out or press Esc first. Alternative entry points: the magnifying-glass icons in the header and the sessions sidebar.
2. **Type a query.** Results appear after 500 ms of idle typing (`SEARCH_QUERY_DEBOUNCE_MS` in [SearchModal.tsx](../../src/renderer/src/components/ui/SearchModal.tsx); a unit test locks the floor at 400 ms so a future "perf tweak" can't tighten it below the type-then-pause threshold). Use quotes for exact phrases (`"dead letter queue"`), `-word` to exclude (`refund -test`), or `OR` to combine (`staging OR production`). Single words match any token. **Search-as-you-type prefix matching**: the trailing bare-word token of every query is auto-prefixed (`Pr` matches `Project alpha`, `hello wo` matches `hello world`), so partial words find hits before you finish typing. Trailing phrases (`"hello"`) and trailing negations (`-bug`) suppress the auto-prefix — only an unquoted final token is expanded.

   **Quoted phrases are word-exact.** Wrapping text in `"..."` narrows the result to a true word-for-word match:

   - `"meeting"` does **not** match `meetings` or `premeeting` (the boundary is `[\p{L}\p{N}_]` lookarounds — a real word edge).
   - Matching is case-insensitive: `"Meeting"` and `"meeting"` are the same query.
   - Whitespace inside a phrase is flexible — `"project plan"` matches `project  plan` (multiple spaces) and `project\nplan` (newline) in stored text.
   - Regex metacharacters are literal — `"c++ template"` matches the literal three-character `c++` sequence, not "any character".
   - Unquoted searches still use FTS5's porter-stemmer recall superset (`meeting` finds `meetings`, `deploy` finds `deployed`), so quote only when you genuinely want to forbid near-matches.
   - Negated phrases (`-"foo"`) stay on the stemmed path — excluding a stemmed near-match is the safe direction.
   - `OR` queries skip exact-narrowing entirely (each branch's row contains only one side of the OR; word-exact AND-ing every quoted phrase would wrongly reject real hits).

3. **Apply filters.**
   - **Desktop**: a 220 px-wide **SearchSidebar** sits to the left of the results, in the order Where → When → Source → Scope → Status → Tag. **Where**, **When**, and **Tag** are buttons that open a popover anchored under each. Where is a combined popover with two sections: "Project" (each row has the project's icon — folder image for user projects, lucide icon for virtual ones like Recipes, Skills, Cron) and "Channel" (each row has the channel's lucide icon — chat for conversations, phone for SMS, hash for Slack, etc.). When opens a date-range popover with From/To inputs and Today / 7 days / 30 days preset chips. Click an option to set it; click the same row again or press Escape to close the popover without closing the modal. Each button shows the current value inline (muted "Anywhere"/"Anytime"/"Any tag" when default, full text when active). All section labels use the same small gray uppercase style.
   - **Sort lives by the search box, not in the filters.** It is a compact `<Select>` dropdown (Newest first / Oldest first / Most relevant) in the search-input row — sort orders results, it doesn't filter them. On mobile it sits in the **Filters** row beside the pill (the hero search input has no room beside it).
   - **Clear filters** appears as a small text link at the very **bottom** of the sidebar (after every filter) **only** when at least one filter is non-default; clicking it resets every sidebar control in one go (Source, Scope, and Status included).
   - **Source, Search scope & Status**: between When and Tag, the sidebar shows three always-visible control groups — Source (Any / My messages / Agent) and Scope (Titles + initial / All / Titles only / Initial only) as single-select radio rows, and **Status** (Live / Needs you / Archived / Done) as multi-select checkbox rows. They render inline, available from the start (no "Advanced" expander). On desktop their headings match the gray uppercase style of the other filter labels; on mobile they use the sheet's sentence-case style. Both are disabled on non-conversation channels (SMS/Slack/etc. don't carry the same metadata) — the disabled controls show a tooltip explaining why. The old **Hide tool actions** switch was removed once the index itself stopped covering mid-turn agent narration / tool calls / tool results — there is no longer anything to hide at search time.
   - **Mobile**: a hero-glow header (a big search input with a soft accent radial halo, full-screen sheet preserved from the previous design) sits above the results, and a single **Filters** pill (with the Sort dropdown beside it) replaces the sidebar. Tapping the pill opens a bottom sheet whose main view mirrors the desktop column — **Where** / **When** at the top, the **Source** / **Search scope** control groups below (always visible — no expander), then a conditional **Clear filters** link at the bottom. Where opens a drill-down sub-screen with a back button (Channel section + Project list, each row showing its icon). When uses From/To inputs plus Today / 7 days / 30 days preset chips inline. The Source and Scope controls are disabled on non-conversation channels just like desktop.
4. **Result rows.** Each row shows the channel icon, the match title (FTS5-bolded), a relative-time timestamp, and a snippet with `<mark>` highlights. The snippet window is **sized to the row's two visible lines** (`SEARCH_SNIPPET_TOKEN_BUDGET`), so the highlighted term stays anchored near the start and visible — a wider window would let a long lead-in push the match past the two-line clamp, making a real hit look like it lacked your word. **Session results also carry a small status dot trailing the title** — the same colored dot (running = green, needs-you = amber, error = red, archived = grey, …) and hover tooltip the sidebar uses, so you can read a session's state at a glance without opening it. Non-session results (SMS/Slack/Telegram/RSS/webhook) have no dot. The project name appears as a small subtitle line **only when no specific project is filtered** — once you scope a search to a single project, the project name disappears from each row (it'd be redundant). Sessions you opened from a project sidebar already start with that project pre-applied, so their result rows skip the subtitle by default. **One row per session, not per match** — when several messages in the same session match your query, the modal collapses them into a single row with a small `×N` badge after the title (e.g. `Refunds for May  ×4`). The row navigates you to the **best match** in that session, where "best" prefers a title hit, then the initial message, then most-recent operator/agent content. So if you searched `refund` and the title is "Refunds for May", clicking lands you on the session header — but if only the body matched, clicking lands you on the matched message itself. Single-match sessions skip the badge entirely. The same dedup applies to every channel by its natural group key (Conversation by sessionId, SMS by phone number, Slack by channel id, Telegram by conversation id, RSS by entry id, Webhook by source id).
5. **Result count.** Above the results: `N session` / `N sessions`, announced via `aria-live="polite"`. This is the **unique-session count after dedup**, not the total number of matches across all messages. The previous "Top N matches" wording from the semantic-AI banner is gone too.
6. **Navigate results.** Arrow Up/Down selects. Enter opens. **Escape** — or **Ctrl+W / ⌘W** (the browser "close" gesture) — closes, layered: if a sidebar popover is open it closes the popover first; a second press closes the modal. On mobile, the same layering applies between the FiltersSheet and the modal. The browser/mobile hardware **back** button mirrors the same layering via popstate, so swiping back on Android closes the sheet before the modal.
7. **Jump to older results.** Each page shows 30 matches — use the **Load more** button (cursor-based pagination) to scroll deeper.
8. **No results?** When a non-empty query yields zero matches and the **Scope** filter is narrowed (anything other than "All content"), the empty state shows a **"Search all message content for "<query>""** button. Clicking it widens the scope to `all` in place, re-running the search across full message bodies — the same query that just failed will usually find hits in agent replies or middle-of-thread operator messages that titles+initial misses.
9. **Ask AI (scoped background search).** If keyword results aren't what you want, click the **Ask AI** button (sparkle icon — desktop in the modal header, mobile in the Filters row). It opens a small **popover** (pre-filled with your query) where you tweak what to search; submitting spawns a [Session Search](session-search.md) chat **in the background**, scoped to the tab you're on (Conversations folds in your filters · Notes → your KMS vault · Settings → your settings catalog). You stay where you are — a toast with an **Open** button, plus the chat's normal sidebar status, lets you switch in when it's ready. Submit is disabled until the box is non-empty; a launch failure keeps the popover open to retry. The button hides itself if the Session Search project hasn't been seeded yet (very first launch) or outside the desktop app. Full flow: [session-search.md](session-search.md).

### Filters

Sidebar column, in order (Sort is NOT in the column — see the last row):

| Sidebar control                                                | Options                                                                                                                                                      |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Where**                                                      | "All projects" + per-project list (icons) — and a Channel section: Any / Conversation / SMS / Slack / Telegram / RSS / webhook                               |
| **When**                                                       | From / To dates + Today / 7 days / 30 days preset chips                                                                                                      |
| **Source**                                                     | Any / my messages / agent messages _(disabled on non-conversation channels)_                                                                                 |
| **Scope**                                                      | All content / titles only / initial messages only / titles + initial _(disabled on non-conversation)_                                                        |
| **Status** _(multi-select checkboxes)_                         | Live / Needs you / Archived / Done — narrows results to sessions in the selected state(s); none selected = no status filter _(disabled on non-conversation)_ |
| **Tag** _(only when session tags are enabled)_                 | "Any tag" + per-tag list                                                                                                                                     |
| **Clear filters**                                              | reset link, last item, shown only when a filter is non-default                                                                                               |
| **Sort** _(a `<Select>` by the search box, NOT in the column)_ | Newest first / Oldest first / Most relevant                                                                                                                  |

The **Status** buckets fold the internal session statuses so the user picks from a short, friendly set: **Live** = running / starting / ready / terminating / waiting; **Needs you** = needs-input / error / stalled; **Archived**; **Done** = ended / paused. Selecting any bucket makes the search conversation-only (non-conversation channels carry no session status, so they drop out); the mapping lives in [search-status-buckets.ts](../../src/shared/search-status-buckets.ts) and the filter is applied inside the query so paging stays correct.

Non-conversation channels (SMS/Slack/Telegram/RSS/webhook) don't carry the same metadata as Claude conversations, so the Source, Scope, and Status controls are disabled when one is selected. Where, When, and Sort stay enabled for every channel.

## How it behaves

### How it works

The **Ctrl+K** and **/** shortcuts are the `globalSearch` action, declared in [/src/shared/keybindings/core.keybindings.ts](/src/shared/keybindings/core.keybindings.ts) and collected via [/src/shared/keybindings.ts](/src/shared/keybindings.ts). It is `passthroughInputs: true`, but `buildBindingLookup` resolves that per-binding: **Ctrl+K** has a modifier so it keeps firing while a text field/editor is focused (unchanged), while bare **/** is forced non-passthrough (no modifier) so it types a literal slash in inputs and opens search only when you're not typing. The top-level component is [/src/renderer/src/components/ui/SearchModal.tsx](/src/renderer/src/components/ui/SearchModal.tsx); it branches on `useIsMobile()` to render the desktop sidebar layout or the mobile FiltersSheet. **The modal is a lazy chunk**, so both the desktop header search button (hover/focus) and the mobile breadcrumb search button (`pointerdown`) **preload it on intent**, and both open behind a shared [/src/renderer/src/components/ui/search/SearchModalOpeningFallback.tsx](/src/renderer/src/components/ui/search/SearchModalOpeningFallback.tsx) — an instant "Opening search…" loading affordance that mirrors the real modal's geometry per platform (desktop: a centered, dialog-sized card over the same translucent scrim as [`DialogShell`](/src/renderer/src/components/ui/DialogShell.tsx) at `size="xl" fixedHeight`; mobile: the full-screen sheet, tap-to-dismiss) — so a cold first open reads as the search box opening, never a blank frame or a full-screen "whole screen goes black" flash while the chunk fetches. Closing the modal also aborts any in-flight search. See the [mobile-search-startup-perf postmortem](/.claude/memory/postmortems/archived/mobile-search-startup-perf-postmortem.md).

The desktop modal renders inside [`DialogShell`](/src/renderer/src/components/ui/DialogShell.tsx) at `size="xl"` (`max-w-3xl`, centered, `bg-surface-200`, `rounded-xl`) and passes `fixedHeight` so the shell uses `h-[85vh]` instead of the default `max-h-[85vh]` — keeps the dialog at a stable height instead of expanding from short-to-tall as result rows stream in — DialogShell owns the portal, focus trap, animation, header X, and footer; SearchModal supplies the body. The body is a standard search input row (using `INPUT_CLASS` from [styles.ts](/src/renderer/src/lib/styles.ts), no halo) above a CSS Grid `220px 1fr` two-column section: sidebar on the left, results pane on the right. The sidebar component is [/src/renderer/src/components/ui/search/SearchSidebar.tsx](/src/renderer/src/components/ui/search/SearchSidebar.tsx); it composes the Where / When / Tag popovers using the same custom POPOVER_CONTAINER pattern as the rest of the app (no Radix), with the Source / Scope groups inline between them and the Clear-filters link last. **Sort is no longer a sidebar row** — it renders as the standardized [`<Select>`](/src/renderer/src/components/ui/Select.tsx) (`variant="compact"`, fed `SORT_OPTIONS`) in SearchModal's search-input row (desktop) and the mobile filter-pill row, wired to the same `sortOrder` state. Where is its own component at [/src/renderer/src/components/ui/search/WhereFilterPopover.tsx](/src/renderer/src/components/ui/search/WhereFilterPopover.tsx) so the project-rows-with-icons logic stays isolated; it imports `<ProjectIcon>` from [/src/renderer/src/components/ui/ProjectIcon.tsx](/src/renderer/src/components/ui/ProjectIcon.tsx) — the single source of truth for project visuals across the app, including virtual-project lucide icons. The **Source** and **Search scope** control groups live in [/src/renderer/src/components/ui/search/AdvancedFilters.tsx](/src/renderer/src/components/ui/search/AdvancedFilters.tsx), rendered inline with no toggle (always visible) plus the disabled-state gating for non-conversation channels. The component takes `labelClassName` + `className` props so each host styles its own surface — desktop passes the uppercase `MODAL_SECTION_LABEL` + a flush wrapper so Source/Scope match the sidebar's other labels; mobile keeps the sentence-case defaults. See the [search filter layout contract](/.claude/memory/contracts/search-filters-always-visible-contract.md) (order, Sort-by-the-box, label parity, always-visible). The mobile branch keeps the previous hand-rolled full-screen sheet — a single `<input>` wrapped in a div with a radial-gradient `background-image` style using the `--accent-rgb` CSS variable, so the glow follows the theme accent. A drift-prevention test at [tests/unit/components/no-new-bare-modal.test.ts](/tests/unit/components/no-new-bare-modal.test.ts) blocks any new hand-rolled portal+backdrop modal from being added — new dialogs must use `DialogShell` (inbox approval panes use `ApprovalPaneShell`, which is inline and doesn't trip the rule); the existing hand-rolled dialogs (mobile SearchModal among them) sit on a frozen allowlist that can only grow with a deliberate edit.

The mobile path renders [/src/renderer/src/components/ui/search/FiltersSheet.tsx](/src/renderer/src/components/ui/search/FiltersSheet.tsx) (drill-down bottom sheet with a focus trap, in a Portal with `AnimatePresence`) behind a single [/src/renderer/src/components/ui/search/FilterPill.tsx](/src/renderer/src/components/ui/search/FilterPill.tsx) pill. Shared label maps — `SORT_LABELS`, the `SORT_OPTIONS` array the Sort `<Select>` consumes, `SOURCE_LABELS`, `SCOPE_LABELS`, `STATUS_LABELS` + the `STATUS_OPTIONS` order for the Status group, `CHANNEL_LABELS`, `CHANNEL_OPTIONS`, and the `CHANNEL_ICONS` map — live in [/src/renderer/src/components/ui/search/filter-labels.ts](/src/renderer/src/components/ui/search/filter-labels.ts).

SearchModal is lazy-loaded by every caller — Dashboard, SessionsSidebar, and the header — because Rollup will hoist any statically-imported module into the entry chunk if _any_ file in the entry graph imports it, even a sidebar sibling. A build test at [tests/unit/build-output/non-registry-lazy-load.test.ts](/tests/unit/build-output/non-registry-lazy-load.test.ts) guards this invariant — it scans every renderer source file for static `SearchModal` imports and (when a production build is present) checks that SearchSidebar / WhereFilterPopover / AdvancedFilters / FiltersSheet identifiers don't leak into the entry chunk. See the CLAUDE.md frontend rules and the [mobile-search-startup-perf postmortem](/.claude/memory/postmortems/archived/mobile-search-startup-perf-postmortem.md).

**Resume-typing responsiveness.** Once results are on screen, continuing to type stays smooth: each result row is a `React.memo`'d `SearchResultRow` (in SearchModal.tsx) fed only props that stay stable across a query-only change, so a keystroke re-renders the input but skips the unchanged rows — without it, every keystroke rebuilt the whole list once results existed (the "search is laggy when I restart typing" report). The query→search effect also aborts the in-flight `AbortController` the instant `query` changes, so a slow reply for a _previous_ query can never overwrite the screen mid-typing — the `ac.signal.aborted` guards in `doSearch` drop the stale reply. Both are locked by tests; see the [search-modal resume-typing contract](/.claude/memory/contracts/search-modal-resume-typing-contract.md).

On the backend, the `IPC.SEARCH_ALL` handler in [/src/main/ipc/session-handlers.ts](/src/main/ipc/session-handlers.ts) parses the query (safely escaping FTS5 operators so user input like `"` can't inject) via `parseFts5Query` in [/src/main/db/queries-messages/search.ts](/src/main/db/queries-messages/search.ts) — a typed-token parser (`bare` / `phrase` / `not` / `or`) that quote-wraps every bare term to prevent operator injection, and **auto-suffixes the final bare token with `*`** so search-as-you-type prefixes (`Pr`, `proj`) hit FTS5's prefix-match path. Phrase, negation, and OR-operator trailers are recognized and left un-suffixed. The parsed query hits the `conversation_messages_fts` virtual table with `snippet()` producing the `<<`/`>>` highlighted excerpt — a `SEARCH_SNIPPET_TOKEN_BUDGET`-token window (10), right-sized to the result row's two-line `line-clamp-2` so a long lead-in can't push the match past the clamp (see the FTS index contract, I12) — and fans out to each channel's `searchMessages()` helper in parallel so a single round-trip returns results from all six channel types. After the channel results merge (and after the semantic ranker runs), a `dedupBySessionGroup` pass collapses rows that share a group key into one — keeping the highest-priority `matchScope` (title > initial > operator > agent > system) as the surviving row and stamping `matchCount` so the renderer can draw the `×N` badge. The pass runs **before** the final sort and pagination, so `total`, `hasMore`, and the cursor all reflect the post-dedup count. Sessions that match only once still pass through with `matchCount: 1` (the renderer hides the badge in that case). After the final slice, a single chokepoint tags each **visible** session result with its current `status` via one batched `getSessionStatusesByIds` lookup — covering all conversation producers at once rather than threading `s.status` through each SELECT — which the renderer draws as the shared `StatusDot`; non-session channels stay undotted. See the [search-result status dot contract](/.claude/memory/contracts/search-result-status-dot-contract.md). The **Status filter** is a separate concern from that dot: when the user selects one or more Status buckets, the handler expands them to raw statuses via `expandStatusBuckets` ([/src/shared/search-status-buckets.ts](/src/shared/search-status-buckets.ts)) and threads that allow-list into every conversation producer as an `s.status IN (…)` clause — applied **inside the query, before paging** (via the shared query builders, so the off-thread worker path honors it too), which keeps `total`/`hasMore`/cursor correct. A non-empty status filter also makes the search conversation-only: the non-conversation channels + the semantic ranker are skipped, since neither carries a session status. See the [search status filter contract](/.claude/memory/contracts/search-status-filter-contract.md). Semantic AI ranking is unshifted into the same response when eligibility criteria are met (see [semantic-search.md](semantic-search.md)) — the renderer no longer marks those rows specially, so semantic and keyword matches blend invisibly in the result list. `SearchChannelType` is defined in [/src/shared/types.ts](/src/shared/types.ts). Result snippets that contain markdown links like `[label](omniscio://session/<id>)` are rendered as real clickable links by the markdown renderer (via [/src/renderer/src/components/ui/agent-markdown-path-utils.ts](/src/renderer/src/components/ui/agent-markdown-path-utils.ts)) so clicking them routes through `useSessionStore.setActiveSession` instead of opening an external browser.

**Why a search stays fast on a big history.** A newest- or oldest-sorted conversation search (the
Ctrl+K default) reads only the rows it will show. Matches are found by the index's row id without
opening any message; each store — the live database and the cold-storage archive — contributes at
most one page, either by looking its few candidates up directly or by walking its timestamp index
until the page is full; the archive is skipped entirely when the live store's page already sorts
ahead of every archived message; and snippets plus session and project names are read for the
displayed rows alone. Before this, every matching message was read and snippeted before the top 30
were kept, so a common word or a long history took 15-30 seconds and a rare word could take
minutes. Relevance sort, quoted phrases, counts and offset paging still use the single full query.
The code is [search-page-first.ts](/src/main/db/queries-messages/search-page-first.ts); the rules are
in the [search FTS index contract](/.claude/memory/contracts/search-fts-index-contract.md)
(`time-sorted-search-reads-only-the-page`).

**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](/src/main/services/cold-storage/archive-fts-realign.ts), its database
steps [named-tx-archive-fts-realign.ts](/src/main/db/worker/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](/src/main/services/cold-storage/archive-fts-realign-alert.ts); the
rule is `a-rebuild-that-keeps-failing-says-so` in the same contract.

### Index curation and word-exact phrases (the two halves of the contract)

What reaches the FTS index is decided in one place: `indexableSearchText(source, content, {partial})` in [/src/shared/search-index-text.ts](/src/shared/search-index-text.ts), re-exported by [/src/main/db/fts-index-text.ts](/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. Every write path runs through this function — `addMessage()`, the data-transfer restore, the migration v244 reindex, the backup merge's archive rows and the archive's one-time rebuild — so the rule can never drift between cold-import and live writes. The same migration ships a transactional `rebuildMessagesFts(db)` that wipes and refills `conversation_messages_fts` so historical history matches the new rule on first launch after the upgrade. Per-row try/catch keeps a single pathological row from blocking startup.

On the read side, `extractExactPhrases(input)` (also in [queries-messages.ts](/src/main/db/queries-messages/search.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(conversation_messages_fts.content, ?) = 1` condition on the FTS query. `phrase_exact` is a deterministic SQLite scalar registered in [/src/main/db/custom-db-functions.ts](/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, runs of whitespace replaced with `\s+`), memoized in a 100-entry LRU. 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.

## Related

- [SearchModal surface-tabs contract](/.claude/memory/contracts/search-modal-surface-tabs-contract.md) — the tab model (All · Conversations · Notes · Meetings · Voice · Recordings · Helpdesk · Settings), per-surface search, grouped All, and the one-flat-index keyboard nav
- [semantic-search.md](semantic-search.md) — AI-powered meaning-based matches now blend silently into these results, no UI toggle to flip
- [deep-links.md](deep-links.md) — the `omniscio://` URLs that search results render as clickable
- [mempalace-memory.md](mempalace-memory.md) — MemPalace drawer search is a separate surface for "recall things agents saw"
- [lazy-content-load.md](lazy-content-load.md) — search hits that land on tool-output lines inside a not-yet-expanded Actions pill trigger an auto-expand before scroll, so the highlighted match is visible when you arrive; the `conversation_messages_fts` index still covers the full `content` column
