File Search and Similarity
This page covers two related search subsystems:
File Search — macOS-only Spotlight integration via
mdfind. Streams file-system hits as events.Similarity (Similar Docs) — BM25 index over the archives directory. Returns ranked similar documents for the current draft.
File Search (Spotlight)
Source: tauri-
Spotlight search is macOS-only. On non-macOS targets the commands compile as stubs that return errors. Use file_search_is_supported to feature-detect at runtime. See also: Spotlight Picker.
Data Structures
FileSearchOpts
Options passed to file_search_start:
interface FileSearchOpts {
mode: 'Any' | 'DirectoryOnly' | 'FileOnly';
scope?: string; // Absolute path to limit the search scope; null = global
maxResults?: number; // Default: 200; hard cap: 1000
}FileSearchHit
Payload of each file-search:{id}:hit event:
interface FileSearchHit {
path: string; // Absolute path to the matched file or directory
kind: 'file' | 'directory';
}Commands
file_search_is_supported
Check whether Spotlight search is available on the current platform.
const supported = await invoke<boolean>('file_search_is_supported');Parameters: none
Returns: true on macOS, false on all other platforms.
Behavior: Compile-time platform check — no filesystem access. Safe to call unconditionally.
file_search_start
Start a Spotlight search and receive results via events.
const searchId = await invoke<string>('file_search_start', {
query: 'meeting notes',
opts: { mode: 'FileOnly', scope: '/Users/me/Documents', maxResults: 100 },
});Parameters:
| Name | Type | Description |
|---|---|---|
query | string | Spotlight query string |
opts | FileSearchOpts | Search options |
Returns: A UUID searchId string on success. Throws if Spotlight is unsupported or if the subprocess cannot be started.
Behavior:
Spawns
mdfindas a subprocess with the given query and scope.Streams each result line as a
file-search:{searchId}:hitevent.Emits
file-search:{searchId}:completewhenmdfindexits normally.Emits
file-search:{searchId}:errorwith an error message ifmdfindfails.
At most maxResults hits are emitted; additional results are silently dropped.
file_search_cancel
Cancel an in-progress Spotlight search.
await invoke('file_search_cancel', { searchId });Parameters:
| Name | Type | Description |
|---|---|---|
search_id | string | The UUID returned by file_search_start |
Returns: null on success. Throws if the search ID is not found.
Behavior: Kills the mdfind subprocess. No further events are emitted for the cancelled search.
Events
file-search:{searchId}:hit
Emitted for each Spotlight match. Subscribe using the searchId returned by file_search_start.
import { listen } from '@tauri-apps/api/event';
const unlisten = await listen<FileSearchHit>(`file-search:${searchId}:hit`, (event) => {
console.log(event.payload.path, event.payload.kind);
});file-search:{searchId}:complete
Emitted when the search finishes without error.
const unlisten = await listen(`file-search:${searchId}:complete`, () => {
console.log('Search done');
});file-search:{searchId}:error
Emitted when mdfind exits with an error.
const unlisten = await listen<{ message: string }>(`file-search:${searchId}:error`, (event) => {
console.error('Search error:', event.payload.message);
});Similarity Search (Similar Docs)
Source: packages/ (engine) + packages/ (workspace corpus)
Similarity search has no Tauri commands and no REST routes. It is a TypeScript BM25 index over the in-memory workspace model, so the same engine serves desktop, web, and iOS. The Rust index and its four similar_docs_* commands were deleted in S21 (#4225): they scanned the workspace directory, which cloud-primary storage no longer fills (epic #4204 D9).
Corpus — the notes
workspaceNotesListsurfaces for the requested directories (numbered notes first, then named.md; internal files excluded). Frontmatter is stripped before tokenization; the caller still receives the complete file payload.Scoring — BM25 with
k1 = 1.5,b = 0.75, smoothed Robertson–Sparck Jones IDF,score > 0only, ranked score descending with the canonical path as a deterministic tie break.Tokenization —
Intl.Segmenterword segmentation (Japanese + English), with a character-bigram fallback whereIntl.Segmenteris unavailable.Invalidation — one cached index per canonical directory SET, dropped when
subscribeWorkspaceFilesreports a change under one of its directories, and dropped wholesale when the workspace model is replaced (disarm / switch / seed).
Data Structures
SimilarDocResult
interface SimilarDocResult {
path: string; // Canonical Note Tray identity (e.g. "archives/20250101-hello.md")
score: number; // BM25 relevance score (higher = more similar)
title: string; // Frontmatter `title` → first H1 → filename stem
snippet: string; // ~150-character window around the best match
matchedTerms: string[]; // Query terms that contributed to the match
content: string; // Complete note payload, frontmatter included
}SimilarDocsQueryResponse
interface SimilarDocsQueryResponse {
results: SimilarDocResult[];
unfilteredResultCount: number; // positive-score matches before keyword + limit
filteredResultCount: number; // after keyword eligibility, before limit
}Bridge Facade
bridge.fileSearch
// Check platform support
const ok = bridge.fileSearch.isSupported(); // boolean (sync)
// Start a search — returns searchId + event streams
const { searchId, onHit, onComplete, onError } = await bridge.fileSearch.start(query, opts);
// Cancel
await bridge.fileSearch.cancel(searchId);bridge.similarDocs
isSupported() reports workspace readiness: an unarmed workspace has no corpus, and the Finder gates on it so that state reads as "feature off" rather than "no matches". getContent accepts any canonical Note Tray identity; writeContent stays archive-only.
// Query for similar documents
if (bridge.similarDocs.isSupported()) {
const response = await bridge.similarDocs.query({
content: draftContent,
directories: ["inbox", "archives", "projects/client"],
keyword: optionalKeyword,
excludePaths: ["archives/current-note.md"],
limit: 10,
});
// response.results contain canonical paths and complete note bodies.
// response.unfilteredResultCount / filteredResultCount are pre-limit counts.
}
// Read an archive file
const content = await bridge.similarDocs.getContent(path); // string | null
// Write an archive file
await bridge.similarDocs.writeContent(path, content);
// Force index rebuild
await bridge.similarDocs.rebuildIndex();