
On-device by design
Embeddings and reranking run through@huggingface/transformers on the WASM
backend — zero native dependencies, matching the node:sqlite philosophy. The
one deliberate exception is the assistant LLM, which uses node-llama-cpp
(prebuilt platform binaries, Metal on Apple Silicon) because WASM generation is
unusably slow.
Models run in dedicated utility processes so load and inference never block
the main process. Each worker is a separate main entry in the electron-vite
config, memoizes one loaded model, and caches weights under userData/models
(the main process hands the worker the path via an env var, since a utility
process can’t call app.getPath).
The model catalog
src/shared/aiModels.ts is the shared embedding catalog — several 384-d
feature-extraction models (MiniLM, BGE, GTE, multilingual E5) plus a 512-d
CLIP model that embeds text and images into one space. Adding a
feature-extraction model is a single catalog entry.
The chat catalog lives in src/shared/llmModels.ts (0.6B–35B, grouped by
family), and users can add any GGUF from Hugging Face at runtime.
Indexing
The pipeline that turns files into searchable vectors:indexer.ts orchestrates it — crawling roots, comparing fs.stat against
per-file index_state so unchanged files are no-ops, and draining a persistent
index_jobs queue as a two-stage prepare → commit pipeline that yields
between files. Notable properties:
- Cost-classed queue — removals and text first, images next, PDF/DOCX last — so a fresh index becomes useful in minutes.
- Duty-cycled via
powerMonitor: near full speed when you’re away, ~half while you’re active, gentler on battery. The embed worker runs multi-threaded WASM at low OS priority. - Byte-range PDF transport — book-size PDFs (up to 512 MB) extract with bounded memory instead of buffering the whole file.
- Filename fallback — files whose content can’t be extracted still get a humanized-basename chunk, so name queries find them through both lanes.
- Codebase-aware — a directory with a
.git/package.json/Cargo.tomlmarker is indexed docs-only; build output is never descended.
The vector store
Vectors are Float32 BLOBs in SQLite, searched with brute-force cosine over an in-memory cache (vectors only, no texts) warmed on first search and kept coherent by upsert/remove/remap/clear. There’s no ANN index or vector DB — dot products over a personal-scale index take tens of milliseconds, and the cache is what makes that hold without re-reading every BLOB.Search fusion
search.ts fuses two retrieval lanes with Reciprocal Rank Fusion (vector +
BM25), then reranks only the top ~16 candidates with an on-device cross-encoder
(more would take seconds on WASM). Filename evidence anchors the rank; the CLIP
image lane is gated on that model being downloaded. See
Search by Meaning for the product view.
The assistant (LLM)
The “Ask AI” panel is powered bysrc/main/ai/llm/:
llmWorker.tsrunsnode-llama-cppin its own utility process — downloads GGUF weights, keeps one model resident, runs one chat at a time, and streams tokens back aschunkmessages.context.tsbuilds the prompt from@filementions,#folderlistings, and/commands (/findruns the index search first).- Chat tools —
@shared/chatTools.tsdefines create/copy/move/rename/ delete/list/read tools with GBNF JSON schemas; the worker function-calls them, RPCs each call to main, and executes againstfs/servicewith injected deps. Every action is recoverable (deletes to Trash, no overwrites). - Persistence — every exchange, including mentions and
/findsources, is saved tochat_sessions+chat_messagesso conversations resume later.
The knowledge graph (Canvas)
src/main/ai/graph/ fuses three signals — embedding similarity (per-file
centroids → partitioned kNN), entities (opt-in on-device NER), and
temporal sessions (from mtimes) — into a GraphSnapshot with Louvain
communities. GraphBuilder is lazy and incremental, running under the same
duty-cycle as the indexer, and renders through cosmos.gl (GPU force layout +
WebGL). See Canvas.
Testing the AI layer
Dependencies are injected intoIndexer, IndexWatcher, and GraphBuilder,
so tests drive the whole pipeline with a fake provider and a temp directory — no
Electron required. See Testing.