Omniscio documentation
Browse all documentation
  1. Getting Started13
  2. Sessions & Agents115
  3. Inbox & Notifications59
  4. Projects & Tasks95
  5. Automation & Scheduling75
  6. Knowledge & Memory26
  7. AI Features60
  8. Integrations100
  9. Plugins & Marketplace33
  10. Cloud & Teams56
  11. Settings & Customization58
  12. Account & Billing28
  13. Troubleshooting84
  14. CLI & API Reference22
  15. Legal & Policies4
  16. Uncategorised22

Global Search (Ctrl+K across everything)

One keyboard-first palette that searches every message channel in Omniscio at once — conversations, SMS, Slack, Telegram, feeds, webhooks, session titles and project metadata — with quoted phrases, exclusions and deep links into any result.

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.

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; 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 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.

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 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 and collected via /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; 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 — 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 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.

The desktop modal renders inside DialogShell 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, 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; 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> (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 so the project-rows-with-icons logic stays isolated; it imports <ProjectIcon> from /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, 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 (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 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 (drill-down bottom sheet with a focus trap, in a Portal with AnimatePresence) behind a single /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.

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 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.

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.

On the backend, the IPC.SEARCH_ALL handler in /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 — 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. 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) 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. Semantic AI ranking is unshifted into the same response when eligibility criteria are met (see 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. 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) 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; the rules are in the search FTS index contract (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, 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.

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, 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, 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 — 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 — AI-powered meaning-based matches now blend silently into these results, no UI toggle to flip
  • deep-links.md — the omniscio:// URLs that search results render as clickable
  • mempalace-memory.md — MemPalace drawer search is a separate surface for "recall things agents saw"
  • 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 index is contentless, and the excerpt is rebuilt from the message's own content when the row is displayed

Last verified 2026-10-02