diff --git a/.gitignore b/.gitignore
index ec75915..df56f48 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,3 +43,9 @@ Desktop.ini
# Scratchpad
scratchpad/
+
+# Local-embedding assets (downloaded by `make assets`, never committed).
+# Runtime assets that get go:embed-ed into the localembed build:
+internal/embeddings/local/embedded/
+# Link-time static tokenizer lib (pulled in via CGO_LDFLAGS):
+internal/embeddings/local/lib/
diff --git a/CLAUDE.md b/CLAUDE.md
index 5edf196..2038451 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,16 +1,24 @@
# Agent Memory
-Local-first desktop app + MCP server for semantic file search. Watches directories, embeds file contents via OpenAI, stores vectors in SQLite (sqlite-vec), and provides KNN semantic search.
+Local-first desktop app + MCP server for semantic file search. Watches directories, embeds file contents (a bundled local model by default; OpenAI opt-in), stores vectors in SQLite (sqlite-vec), and provides KNN semantic search.
## Build & Run
```bash
-make build # wails build -skipbindings (DO NOT use plain `wails build` — hangs on binding generation due to CGo)
+make assets # download pinned local model/tokenizer/ORT libs into assets/embedded/ (~150 MB, checksummed via assets/manifest.json)
+make build # wails build -skipbindings, builds with -tags localembed (DO NOT use plain `wails build` — hangs on binding generation due to CGo)
+make build-darwin-amd64 # cross-build the Intel-mac app from an arm64 Mac (swaps in darwin-amd64 assets)
make dev # hot-reload dev mode
-make test # go test ./...
+make test # go test ./... (default build, no localembed tag — stays native-lib-free)
make clean # rm -rf build/bin
```
+> `make build` depends on `make assets`, which downloads ~150 MB of native artifacts (model,
+> tokenizer, ONNX Runtime lib) the first time — needs network access. The build sets
+> `-tags localembed` plus `CGO_LDFLAGS` to link the tokenizer library; the resulting binary is
+> ~180 MB (it bundles the model). The native code is build-tag-isolated, so plain
+> `go test ./...` and CI need no assets.
+
> **macOS 26+ gotcha:** Go ≤ 1.24 produces CGo binaries the kernel kills instantly on launch
> (exit 137, `dyld: missing LC_UUID`) — this breaks `make build`, the `wails` CLI, and the built
> app. Fix: `go env -w GOTOOLCHAIN=go1.26.4`, then `go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0`.
@@ -44,7 +52,8 @@ Delivery (main.go, mcp/, tray.go, frontend/)
|---------|---------|
| `internal/engine` | Core orchestrator: scan → extract → chunk → embed → store |
| `internal/store` | SQLite + sqlite-vec persistence (config, directories, files, chunks, vectors) |
-| `internal/embeddings` | OpenAI embedding client with batching (2048/req) and retry |
+| `internal/embeddings` | Embedder interface + OpenAI client (opt-in) with batching (2048/req) and retry |
+| `internal/embeddings/local` | Bundled in-process local embedder (default): ONNX Runtime + HF tokenizer, `multilingual-e5-small` (384-dim), offline |
| `internal/chunker` | Token-based text splitting (tiktoken, cl100k_base) |
| `internal/watcher` | fsnotify recursive directory watcher with 500ms debounce |
| `internal/extractor` | Multi-format content extraction (text, docx, xlsx, pptx, pdf, images) |
diff --git a/Documentation/ARCHITECTURE.md b/Documentation/ARCHITECTURE.md
index ca4c7d9..a6f97ad 100644
--- a/Documentation/ARCHITECTURE.md
+++ b/Documentation/ARCHITECTURE.md
@@ -17,7 +17,7 @@ Delivery -> Service (Engine) -> Domain (interfaces) -> Infrastructure (imp
**Domain** — each package defines its own interface in `iface.go`. No cross-domain imports.
-**Infrastructure** — concrete implementations that satisfy domain interfaces: SQLite, OpenAI API, fsnotify, file extractor.
+**Infrastructure** — concrete implementations that satisfy domain interfaces: SQLite, the embedding providers (local ONNX model and OpenAI API), fsnotify, file extractor.
## Composition Root
@@ -30,7 +30,7 @@ Six interfaces, each in its own package:
| Interface | Package | Defined in | Implemented by |
|-----------|---------|------------|----------------|
| `Store` | `internal/store` | `iface.go` | `sqlite.go` (SQLite + sqlite-vec) |
-| `Embedder` | `internal/embeddings` | `iface.go` | `openai.go` (OpenAI API) |
+| `Embedder` | `internal/embeddings` | `iface.go` | `local/` (bundled ONNX model, **default**) and `openai.go` (OpenAI API, opt-in) |
| `Chunker` | `internal/chunker` | `iface.go` | `chunker.go` (tiktoken, cl100k_base) |
| `Watcher` | `internal/watcher` | `iface.go` | `fswatcher.go` (fsnotify) |
| `FileEventHandler` | `internal/watcher` | `iface.go` | `engine.go` (Engine implements OnCreate/OnModify/OnDelete) |
@@ -63,7 +63,8 @@ tray.go System tray (macOS CGo)
internal/
domain/types.go Shared types: Directory, File, Chunk, SearchResult, IndexStats, ActivityLogEntry
store/ Store interface + SQLite implementation
- embeddings/ Embedder interface + OpenAI implementation
+ embeddings/ Embedder interface + OpenAI implementation (opt-in)
+ local/ Bundled ONNX local embedder (default provider)
chunker/ Chunker interface + token-based splitter
watcher/ Watcher + FileEventHandler interfaces + fsnotify implementation
extractor/ Extractor interface + multi-format file extraction
@@ -96,17 +97,36 @@ One binary, two modes controlled by the `--mcp` flag:
Both share the same SQLite database. WAL mode supports concurrent readers with one writer. The GUI writes, the stdio process reads.
-## Embedding Model Change Flow
+## Embedding Providers
-When the user changes the embedding model in Settings:
+Two infrastructure implementations satisfy the same `Embedder` interface, so the engine, store, watcher, and MCP layers stay provider-agnostic:
-1. `app.SetConfig("embedding_model", newModel)` detects the change
-2. Persists the new model to the config table
-3. Creates a new embedder via the injected `EmbedderFactory` (so `app.go` never imports concrete embeddings)
-4. Calls `engine.SetEmbedder()` to swap it
+- **Local (default)** — `internal/embeddings/local/`. A bundled `multilingual-e5-small` ONNX model (384 dimensions) runs in-process on the CPU via ONNX Runtime (`yalue/onnxruntime_go`), with a Hugging Face tokenizer (`daulet/tokenizers`). The session is lazy-initialized on first use; inference tokenizes, runs the model, mean-pools over the attention mask, and L2-normalizes. In stdio (`--mcp`) mode the model loads lazily on the first search so the MCP handshake stays instant. Works fully offline.
+- **OpenAI (opt-in)** — `openai.go`. Used only when the user supplies an API key and selects the OpenAI provider in Settings.
+
+`main.go` picks the implementation from the `embedding_provider` config key; the local branch also wires the model-matched tokenizer into the chunker (via `chunker.WithTokenizer`) so chunk boundaries are measured in the model's own tokens.
+
+### Asset Bundling
+
+The native artifacts (ONNX model weights, tokenizer, ONNX Runtime shared library) are **not** committed to git. Instead:
+
+- `assets/manifest.json` (in git) pins each artifact's URL and SHA-256 checksum.
+- `make assets` downloads and checksum-verifies them into `assets/embedded/` (gitignored).
+- The `localembed`-tagged build embeds them into the binary via `go:embed`; on first use they are extracted (atomically, checksum-verified) to `~/.agent-memory/runtime/` because the ORT library must be `dlopen`-ed from a real file path.
+
+The native-lib-dependent code lives behind the `//go:build localembed` tag, so the default `go build`/`go test ./...` (and CI) stays lib-free and green; the pure-Go embedding pipeline is unit-testable without the tag. `make build` sets `-tags localembed` plus the `CGO_LDFLAGS` to link the tokenizer library.
+
+## Embedding Provider / Model Change Flow
+
+When the user changes the embedding provider or model in Settings:
+
+1. `app.SetConfig` detects the change (`embedding_provider` or `embedding_model`)
+2. Persists the new value to the config table
+3. Creates a new embedder via the injected `EmbedderFactory` (so `app.go` never imports concrete embeddings) — the factory maps the provider to `local.New(...)` or `NewOpenAIEmbedder(...)`
+4. Calls `engine.SetEmbedder()` (and, for a provider switch, swaps the matching chunker/tokenizer) to swap it
5. Calls `engine.Reset()` which stops the watcher, drops and recreates the vector table with the new dimension, and restarts
-The same factory pattern applies when the API key changes — the embedder is swapped so new requests use the updated key immediately.
+The same factory pattern applies when the OpenAI API key changes — the embedder is swapped so new requests use the updated key immediately.
## Testing
diff --git a/Documentation/Bugs/fswatcher-create-event-swallowed-linux.md b/Documentation/Bugs/fswatcher-create-event-swallowed-linux.md
new file mode 100644
index 0000000..9e48921
--- /dev/null
+++ b/Documentation/Bugs/fswatcher-create-event-swallowed-linux.md
@@ -0,0 +1,54 @@
+# Bug: watcher swallows Create events on Linux (debounce replaces instead of merges)
+
+**Date found:** 2026-07-16
+**Found during:** local-embeddings epic, Phase 5 Linux verification (first-ever test run on Linux)
+**Status:** Fix submitted — PR #5 (`fix/watcher-create-swallowed-linux`, based on `main`, independent of the epic PR stack)
+**Severity:** Low today (no user-visible breakage), latent correctness risk
+
+## Symptom
+
+`go test ./internal/watcher/` fails deterministically on Linux (3/3 runs, Docker
+`golang:1.26-bookworm`, arm64):
+
+```
+--- FAIL: TestOnCreate (2.01s)
+ fswatcher_test.go:121: expected OnCreate to be called
+```
+
+`TestOnModify` and `TestOnDelete` pass. The full suite is green on macOS.
+
+## Root cause
+
+Writing a new file on Linux (inotify) emits **two events** for the same path within
+milliseconds: `CREATE`, then `WRITE`. In `internal/watcher/fswatcher.go`,
+`handleEvent` → `debounce(path, fn)` stores **one pending closure per path and
+replaces it** on each new event (`fswatcher.go:114-128`): the `WRITE` event cancels
+the `CREATE` timer and substitutes a closure that only sees `WRITE`. When the
+debounce fires, the handler classifies the event as a modification —
+`handler.OnCreate` is never called for newly written files.
+
+macOS (fsnotify kqueue/FSEvents backend) delivers/coalesces these events differently,
+so the swallow never manifests there.
+
+## Impact
+
+- **Today: effectively none for users.** `engine.OnCreate` and `engine.OnModify`
+ are identical (`engine.go:717-747` — both call `IndexFile`), so new files on
+ Linux are still indexed, merely misclassified as modifications.
+- **Latent risk:** any future divergence between create and modify handling
+ (e.g. create-only bookkeeping, activity-log semantics, per-event UX) silently
+ breaks on Linux only.
+- Blocks a fully green `go test ./...` on Linux (Phase 5 verify gate) until fixed.
+
+## Fix (applied in PR #5)
+
+In the debouncer, **accumulate the fsnotify op bits per path** instead of replacing
+the closure — e.g. keep `pendingOps map[string]fsnotify.Op`, OR-ing each event's op;
+when the timer fires, classify with precedence Remove/Rename > Create > Write/Chmod
+from the merged bits, then clear the entry. Semantics on macOS are unchanged
+(single-op case degenerates to today's behavior); Linux create+write merges to
+Create. `TestOnCreate` then passes on both platforms.
+
+Fix delivered as its own small PR per working rules (the watcher is outside the
+local-embeddings epic's scope): **PR #5**. Verified there: watcher suite 3/3 pass
+on Linux arm64 (was 3/3 fail) and 3/3 pass on macOS.
diff --git a/Documentation/Bugs/local-embed-token-budget-overflow.md b/Documentation/Bugs/local-embed-token-budget-overflow.md
new file mode 100644
index 0000000..18fe244
--- /dev/null
+++ b/Documentation/Bugs/local-embed-token-budget-overflow.md
@@ -0,0 +1,54 @@
+# Bug: local embedder overflows the model context — every multi-chunk file silently dropped
+
+**Date found:** 2026-07-17
+**Found during:** Phase 5 Windows GUI field test (first real-world vault ever indexed)
+**Status:** Fixed — branch `fix/local-embed-token-budget` (stacks on the epic PR chain)
+**Severity:** Critical for the local provider — affects ALL platforms, latent since Phase 3
+
+## Symptom
+
+Indexing a real vault (109 files, D:\Brain) silently indexed only 26 files. No errors
+anywhere user-visible (see the companion visibility bug, PR #8). The 26 survivors were
+exactly the files ≤ 1.6 KB; every file ≥ 1.7 KB — i.e., anything needing more than one
+chunk — was dropped. All previous platform verifications used tiny single-chunk test
+files, so five platforms' smokes all passed while the pipeline was broken for real
+workloads everywhere.
+
+## Root cause (the "512 by 516" error)
+
+ONNX Runtime fails hard when a sequence exceeds mE5-small's 512-token context:
+
+```
+BroadcastIterator::Append ... Attempting to broadcast an axis by a dimension other than 1. 512 by 516
+```
+
+`buildChunker` passed `embedder.MaxInputTokens()` (512) straight through as the chunk
+budget. But the embedder prepends the E5 instruction prefix ("passage: ") and the
+tokenizer adds special tokens **after** chunking, so a chunk cut at exactly 512 tokens
+reached the model at 516. The epic specified the defense precisely ("effective chunk
+size = min(chunk_size, MaxInputTokens − prefix − special) → default 480"); the
+implementation skipped the reservation.
+
+The unchunked **query path** was also exposed: `EmbedQuery` has no chunker, so a search
+query longer than ~500 tokens crashed inference on every platform.
+
+## Fix (two layers)
+
+1. `app.go buildChunker`: chunk budget = `MaxInputTokens() − local.EmbedTokenReserve`
+ (32 → effective 480, matching the epic).
+2. `LocalEmbedder.embedBatch`: defensively truncate any tokenized input to the model
+ limit, preserving the trailing EOS token — protects queries and any future caller
+ regardless of chunking correctness.
+
+## Verification
+
+- Unit: truncation table test + fake-session proof the model never receives >512 tokens.
+- Integration (permanent): `TestIntegrationLargeDocument` — real pipeline, multi-chunk
+ document + over-long query. Failed before the fix on macOS AND Windows; passes on both.
+- Field: clean re-index of the same vault on Windows → **105 files / 932 chunks, all 5
+ dossier files present, 79 multi-chunk files, zero errors** (was 26/26/0).
+
+## Lesson recorded
+
+Platform verifications used single-chunk corpora only; a large-document case is now a
+permanent integration test, and the Phase 6 checklist gained a real-vault field test.
diff --git a/Documentation/Bugs/pdf-extraction-passthrough.md b/Documentation/Bugs/pdf-extraction-passthrough.md
new file mode 100644
index 0000000..65facfa
--- /dev/null
+++ b/Documentation/Bugs/pdf-extraction-passthrough.md
@@ -0,0 +1,43 @@
+# Bug: PDF "extraction" embeds raw bytes, not text
+
+**Status:** Open · **Severity:** Medium (silent quality loss) · **Reported:** 2026-07-06
+**Component:** `internal/extractor` · **Track:** separate from the local-embeddings epic (pre-existing)
+
+## Summary
+
+PDF files are treated as "supported," but their text is never actually extracted.
+`extractPDFPassthrough` (`internal/extractor/extractor.go:177`) does `os.ReadFile(path)` and returns
+the **raw PDF bytes** as the `Text` to chunk and embed. PDFs are binary (compressed content streams,
+xref tables, object dictionaries), so what gets embedded is mostly non-text noise, not the
+document's readable content.
+
+## Impact
+
+- Semantic search over PDFs is effectively broken — matches are against binary noise, not content.
+- The README claims "PDF — text content extraction," which the code does not do.
+- Under the local-embeddings epic, the same garbage would burn **local CPU** instead of API dollars.
+
+## Evidence
+
+`internal/extractor/extractor.go`:
+
+```go
+func extractPDFPassthrough(path string) (Result, error) {
+ data, err := os.ReadFile(path)
+ if err != nil { return Result{}, err }
+ return Result{Text: string(data)}, nil // raw PDF bytes, not extracted text
+}
+```
+
+`.pdf` is registered in `binaryExtractors`, so the file is reported as supported and indexed.
+
+## Proposed fix (separate task)
+
+Replace the passthrough with real PDF text extraction (a pure-Go PDF text library returning
+concatenated page text), and add a unit test with a small sample PDF. Keep this **out of the
+local-embeddings epic scope** — it is an independent defect.
+
+## Notes
+
+Found during the Phase-0 "get familiar / try to break it" pass. Logged per the bug-report workflow;
+not a blocker for the epic.
diff --git a/Documentation/Epics/background-presence.md b/Documentation/Epics/background-presence.md
new file mode 100644
index 0000000..84ee1e8
--- /dev/null
+++ b/Documentation/Epics/background-presence.md
@@ -0,0 +1,82 @@
+# Epic: Background Presence (cross-platform tray, start-on-login, single instance)
+
+**Date:** 2026-08-12
+**Status:** Proposed — definition requested in PR #2 review; first in the post-merge epic queue
+**Owner:** Bo Motlagh (definition scoped by Bo; drafted by Nestor per review)
+
+## Goal
+
+Close the product gap the close=quit triage left on Windows/Linux: **the app only watches
+and indexes while its window is open.** On macOS the status-bar tray keeps the app resident
+in the background; on Windows/Linux, closing the window now stops watching entirely (stdio
+search keeps working against the existing index, but the index goes stale until the next
+launch). Windows has a first-class notification-area pattern for exactly this — the current
+limitation is only that `tray.go` is darwin-only Objective-C.
+
+After this epic: on every platform that supports it, closing the window hides the app into
+a tray with a Show/Quit menu and watching continues; the app can optionally start on login;
+and running two instances against one DB is structurally impossible.
+
+## User-set constraints (settled in the PR #2 review — do not relitigate)
+
+- Cross-platform tray/notification-area icon with **Show / Quit**, restoring hide-on-close
+ on platforms that have a tray.
+- **Start-on-login option**, per-OS mechanism (macOS Login Items / Windows registry Run key /
+ Linux XDG autostart).
+- **Linux reality check is part of the epic**: tray support varies by desktop environment
+ (appindicator vs legacy tray protocols). Document what we target and what degrades to
+ close=quit — degradation is acceptable, silence about it is not.
+- **Single-instance guard** so the zombie-stacking class of bug is structurally impossible
+ regardless of tray state.
+- Current **close=quit stays as the documented fallback** wherever a tray isn't available.
+
+## Execution Notes (read first if you are the implementing session)
+
+- Follow the conventions proven by `local-embeddings.md`: phase gates with explicit
+ **Verify** steps, record deviations in this file, never start a phase before the prior
+ gate passes.
+- The `hasTray` seam already exists: `tray.go` (darwin) / `tray_stub.go` (!darwin) each own
+ the constant, and `main.go` keys `HideWindowOnClose` off it. This epic's tray
+ implementations replace the stub per platform and flip `hasTray` there — `main.go` should
+ need no changes for the hide-on-close behavior.
+- Shutdown correctness is already handled (engine.Stop-before-Close + atomic index writes,
+ PR #2 revision round). Tray-Quit must go through the same shutdown path.
+
+## Research the epic must settle (Phase 0)
+
+1. **Library evaluation — likely `fyne-io/systray`**: the known hard question is message-loop
+ integration with Wails v2 (both want the main thread on some platforms). Spike: tray icon +
+ Show/Quit alongside a running Wails window on Windows first, then Linux. Record findings
+ here the way local-embeddings recorded its Phase 0 numbers. Fallback candidates if it
+ fails: platform-native minimal implementations (Win32 Shell_NotifyIcon via CGo; keep the
+ existing darwin ObjC).
+2. **Linux tray matrix**: GNOME (needs appindicator extension), KDE, XFCE, Pop!_OS — what
+ works out of the box, what needs a package, what doesn't work at all. Output: a support
+ table in this doc + the degrade-to-close=quit list.
+3. **Single-instance mechanism**: evaluate a lock file beside the DB (flock/LockFileEx) vs a
+ local socket. Must handle stale locks after crashes; second launch should surface the
+ existing instance's window when possible rather than just erroring.
+4. **Start-on-login mechanics** per OS, including uninstall/cleanup behavior (removing the
+ app must not leave a broken login entry).
+
+## Phases (outline — implementation plan written after Phase 0, per workflow)
+
+- **Phase 0 — Spike (throwaway):** systray×Wails coexistence on Windows + one Linux DE.
+ **Gate:** tray icon + working Show/Quit alongside a live Wails window, findings recorded here.
+- **Phase 1 — Single-instance guard** (independent of tray; kills the zombie class on its
+ own). **Verify:** second launch on each OS surfaces/exits cleanly; crash + relaunch does
+ not deadlock on a stale lock.
+- **Phase 2 — Windows tray** (flip `hasTray` on windows, hide-on-close restored, Quit goes
+ through full shutdown). **Verify:** close → still watching (index a file while hidden);
+ Quit → process exits, no zombies.
+- **Phase 3 — Linux tray** per the support matrix, with documented degradation.
+- **Phase 4 — Start-on-login** (all platforms, off by default, Settings toggle).
+- **Phase 5 — Docs + epic close-out** (README/FEATURES/ARCHITECTURE; record measured
+ behavior per platform).
+
+## Out of scope
+
+- Any change to indexing/search behavior (this epic is presence/lifecycle only).
+- macOS tray rework (existing ObjC tray stays; only refactor if the Phase 0 library choice
+ makes unification free).
+- Auto-update, menubar richness beyond Show/Quit (parking lot).
diff --git a/Documentation/Epics/local-embeddings.md b/Documentation/Epics/local-embeddings.md
index 51ce4b3..7b83fe0 100644
--- a/Documentation/Epics/local-embeddings.md
+++ b/Documentation/Epics/local-embeddings.md
@@ -1,7 +1,13 @@
# Epic: Local Embeddings as the Primary Provider
**Date:** 2026-07-02
-**Status:** Proposed
+**Status:** Complete pending review — all phases (0–6) executed and verified. Every shipping
+target (macOS arm64/x86_64, Linux arm64/x64, Windows x64) built, tested, and — for Windows and
+Linux — GUI field-tested on real hardware with real data. The Windows field test surfaced and
+fixed a critical latent cross-platform bug (multi-chunk embed overflow, PR #10 + bug report).
+Delivered as a stacked PR chain #2→#3→#4→#6→#7→#10 plus independent fixes #5/#8/#9, all draft,
+awaiting Bo's review. Flips to Complete when the stack merges (one post-merge chore: re-run
+the Windows suite after #5 lands).
**Owner:** Bo Motlagh
## Goal
@@ -122,6 +128,49 @@ newer-headers/older-runtime mismatch). The artifact is hosted in this repo's Git
pinned by SHA-256 in `assets/manifest.json` like every other artifact — `make assets` treats it
identically to officially-published binaries.
+## Phase 0 Results
+
+**Date:** 2026-07-06 · **Outcome: GATE PASSED** — local embedding stack proven end-to-end on
+macOS arm64 (throwaway spike, scratchpad only; no repo code touched).
+
+**Verified stack (all pinned versions worked):** ONNX Runtime 1.26.0 CPU via `onnxruntime_go`
+v1.31.0; tokenizer via `daulet/tokenizers` v1.27.0 (prebuilt `libtokenizers.darwin-arm64`);
+models `Xenova/multilingual-e5-small` int8 (118 MB) and
+`ibm-granite/granite-embedding-97m-multilingual-r2` int8 (98 MB). Pipeline
+tokenizer → ORT → mean-pool → L2-normalize produced a valid **384-dim, L2-norm = 1.0** vector;
+multilingual tokenization (EN/ES/ZH/AR) sane.
+
+| Metric | mE5-small | Granite-97m |
+|---|---|---|
+| Cold load | 129 ms | 145 ms |
+| Per-chunk @ batch 16 (~300 tok) | ~34 ms (~30/s) | ~32 ms |
+| Peak RSS (upper bound) | ~1.3 GB | ~1.5 GB |
+| Disk | 118 MB | 98 MB |
+
+**Quality (cosine; distance = 1 − cos):** ordering correct — related 0.13–0.18 <
+cross-lingual 0.17–0.22 < unrelated ~0.29 (mE5). Cross-lingual (EN↔ES) retrieval works well.
+
+**Model decision:** **`multilingual-e5-small` is the committed default.** **Granite-97m logged as
+a future upgrade candidate** — it loaded and ran at full speed on arm64 (the AVX2-int8 concern did
+not materialize), smaller file, crisper related/unrelated separation, but marginally weaker
+cross-lingual; needs a broader recall eval + prefix-convention decision before promotion. Upside,
+not a dependency (model = data file).
+
+**Findings that adjust the plan:**
+
+1. **mE5 requires `token_type_ids`.** The Xenova export takes **three** INT64 inputs
+ (`input_ids`, `attention_mask`, `token_type_ids`), not two — pass a zero tensor for
+ `token_type_ids` or ORT errors. Affects the Phase 2 `LocalEmbedder` inference code. (Granite
+ takes the two-input form.)
+2. **Default search threshold `1.5` is miscalibrated for local vectors** (real related-vs-unrelated
+ separation is ~0.25 cosine distance; 1.5 admits nearly everything). Make the default
+ **provider-aware**, and confirm which distance metric sqlite-vec's `vec0` table is configured for
+ before tuning.
+3. **Peak RSS ~1 GB** (ORT memory arena + batch activations) — constrain ORT arena / batch size in
+ `LocalEmbedder` for the desktop memory budget.
+4. **Build flags:** minimal `CGO_LDFLAGS="-L
-ltokenizers"` suffices on darwin arm64; no
+ `-framework` flags needed (the lib embeds `-ldl -lm`).
+
## Architecture
### Design principles (unchanged)
@@ -297,17 +346,35 @@ and the indexing-progress UX all already exist and are the extension points.
| File | Purpose |
|---|---|
-| `internal/embeddings/local/local.go` | `LocalEmbedder` (lazy ONNX session, tokenize→infer→pool→normalize, prefixes) |
-| `internal/embeddings/local/assets.go` | `go:embed` + extract-to-`~/.agent-memory/runtime/` with checksums |
-| `internal/embeddings/local/local_test.go` | Unit tests + build-tagged integration test |
-| `internal/chunker/hf_tokenizer.go` (or similar) | HF tokenizer adapter satisfying `chunker.Tokenizer` |
-| `assets/manifest.json` | Pinned artifact URLs + SHA-256 (in git) |
+| `internal/embeddings/local/local.go` | `LocalEmbedder` (implements `Embedder`): prefixes, sub-batching, tensor assembly (incl. zero `token_type_ids`), mean-pool, L2-normalize, lazy-session orchestration. **Pure Go, no build tag** — unit-tested via fake session/tokenizer seams. |
+| `internal/embeddings/local/session_ort.go` | **(`//go:build localembed`)** real ONNX Runtime session via `onnxruntime_go` (thread cap + arena limit). |
+| `internal/embeddings/local/tokenizer_hf.go` | **(`//go:build localembed`)** HF tokenizer via `daulet/tokenizers` + the `chunker.Tokenizer` adapter. **Relocated here from `internal/chunker/` (Phase 2 decision — see below)** to keep the pure-Go `chunker` package CGo-free. |
+| `internal/embeddings/local/assets.go` | asset resolution + checksummed atomic extraction to `~/.agent-memory/runtime//`. Source = dev env path (`AGENT_MEMORY_LOCAL_ASSETS`) in Phase 2; swapped to `go:embed` in Phase 3. |
+| `internal/embeddings/local/local_test.go` | unit tests (fake session+tokenizer, no tag, always run) + `//go:build localembed` integration test. |
+| `assets/manifest.json` | Pinned artifact URLs + SHA-256 (in git) — populated in Phase 3. |
+
+**Phase 2 implementation decisions (recorded 2026-07-07, per the "update the tables, don't silently diverge" rule):**
+
+1. **CGo isolation via a `//go:build localembed` tag + interface seams.** The ONNX/tokenizer
+ infrastructure (which requires native libs) lives in tagged files behind `onnxSession` /
+ `tokenizer` interfaces; the pure pipeline logic and unit tests carry no tag. This keeps the
+ default `go build ./...` / `go test ./...` (and CI) green and native-lib-free through Phase 2;
+ `make build` gains the tag + `CGO_LDFLAGS` in Phase 3. Reinforces the architecture rules
+ (interfaces at boundaries, mocks for all seams).
+2. **HF tokenizer adapter moved from `internal/chunker/` to `internal/embeddings/local/`.** Placing
+ the CGo-dependent adapter in the `chunker` package would force that pure-Go domain package (and
+ its always-run tests) to require the native tokenizer lib. `main.go` injects it via the
+ `chunker.WithTokenizer` seam. Better honors separation of concerns than the original placement.
+3. **Phase 2 sources assets from a dev path** (env var), not `go:embed`; weights/libs are never
+ committed. `go:embed` + `make assets` + manifest land in Phase 3.
### Explicitly unchanged
`internal/watcher/`, `internal/extractor/`, `internal/mcp/dispatch.go`, `internal/mcp/stdio.go`
-(interface types unchanged — `ReadOnlyEngineService` signature is stable), `tray.go`,
+(interface types unchanged — `ReadOnlyEngineService` signature is stable),
`internal/store/sqlite_readonly.go`, all search/KNN logic in `store.Search`.
+(~~`tray.go`~~ — removed from this list in Phase 5: it required a `//go:build darwin` guard
++ non-darwin stub to compile anywhere but macOS; see Phase 5 Linux findings.)
### Known dead-code risks to check at the end
@@ -367,6 +434,94 @@ and the indexing-progress UX all already exist and are the extension points.
### Phase 5 — Cross-platform builds
+**Execution decisions (recorded 2026-07-16, per the "update the tables, don't silently diverge" rule):**
+
+- **Delivered as small stacked PRs** (Bo's small-PRs rule; the epic doesn't mandate one PR):
+ foundation → Linux → macOS x86_64 → Windows. Windows moved last purely for convenience
+ (only target needing a second machine for the Rust tokenizer build); no dependency reason.
+- **Foundation PR (`feat/xplat-foundation`)** implements Open Concern #8: the ORT shared
+ library's `go:embed` moves from `assets_embed.go` into per-platform
+ `assets_embed__.go` files (each defines `embeddedORTLib` + `ortLibFile`;
+ darwin-arm64 first). Model + tokenizer stay in the shared embed (platform-independent
+ bytes). Each later platform PR adds one sibling file + manifest entries only.
+- **Makefile checksum portability**: `shasum -a 256` (macOS-only) replaced by a `SHA256`
+ variable that picks `sha256sum` on Linux — prerequisite for `make assets` inside Docker/CI.
+- `dylibCandidates` gains the versioned Linux name (`libonnxruntime.so.1.26.0`) that the
+ official Linux tarball actually ships.
+
+**Linux findings (recorded 2026-07-16, branch `feat/xplat-linux`):**
+
+- **`tray.go` was never darwin-guarded** — its Cocoa CGo preamble compiled on every platform,
+ making *any* non-macOS build impossible. Fixed here (`//go:build darwin` + no-op
+ `tray_stub.go`); this removes tray.go from the "Explicitly unchanged" list (deviation
+ recorded per the rules — CLAUDE.md already declared it macOS-only in intent).
+- **Pre-existing watcher bug surfaced by first-ever Linux test run:** inotify emits
+ CREATE+WRITE for a new file; the per-path debouncer replaces rather than merges events, so
+ OnCreate is swallowed (OnModify fires instead — no user impact today since both index the
+ file). Documented in `Documentation/Bugs/fswatcher-create-event-swallowed-linux.md`;
+ fix deliberately kept out of this epic's PRs (watcher is out of scope).
+- **linux-arm64 verified end-to-end** in Docker (golang:1.26-bookworm): `make assets`
+ checksums, test suite (watcher known-fail excepted), real int8 inference (cosine ordering
+ 0.140 < 0.171 < 0.286, matching Phase 0), full Wails build (webkit2gtk-4.1 via the
+ `webkit2_41` tag), and an offline (`--network none`) MCP search that retrieved correct
+ semantic matches from a macOS-indexed DB — cross-platform vector compatibility confirmed.
+- **linux-amd64**: artifacts pinned and checksum-verified; runtime smoke still pending
+ (needs an x64 Linux env — Docker on the arm64 dev Mac would run it under slow emulation).
+
+**macOS x86_64 findings (recorded 2026-07-17, branch `feat/xplat-macos-intel`):**
+
+- **ORT 1.26.0 source-built for mac-Intel** exactly as planned (official prebuilts stopped at
+ 1.23): cross-compiled from the arm64 dev Mac (`./build.sh --config Release --osx_arch x86_64
+ --build_shared_lib --skip_tests`; needs CMake ≥4 ok, Python ≥3.10 — system 3.9 fails on
+ `match` syntax). 27.5 MB dylib, published as repo release **`ort-1.26.0-darwin-x64`**
+ (prerelease, reproducible recipe in its notes), pinned in the manifest like every artifact.
+- **Darwin embed file consolidated**: `assets_embed_darwin_arm64.go` →
+ `assets_embed_darwin.go` (`localembed && darwin`) — both mac arches share the dylib file
+ name, mirroring the Linux single-file pattern; the arch difference is which artifact
+ `make assets` fetches.
+- **New `make build-darwin-amd64`** target cross-builds the Intel app from an arm64 Mac
+ (GOARCH=amd64 assets fetch + `wails build -platform darwin/amd64`).
+- **Arch-collision bug found and fixed before ship:** the runtime extraction dir
+ (`~/.agent-memory/runtime//`) was not arch-namespaced, so an Intel build (or a
+ home dir migrated from an Intel Mac) poisoned the arm64 build's dlopen with an
+ incompatible-architecture dylib. Extraction dirs are now `--`.
+ No user impact (the extraction scheme never shipped — it exists only in this PR stack).
+- **Verified under Rosetta 2 on the arm64 dev Mac:** integration test passes as an x86_64
+ binary (cosine ordering 0.140 < 0.171 < 0.288, matching all other platforms);
+ `GOARCH=amd64 make assets` downloads + checksum-verifies from the repo release; full
+ x86_64 Wails app builds, extracts its assets to its own arch dir, and answers an MCP
+ search correctly with both arch dirs coexisting. Native arm64 re-verified after.
+
+**Windows x64 findings (recorded 2026-07-17, branch `feat/xplat-windows`):**
+
+- **Built on-device over SSH** (Tailscale) to the Windows PC, driven from the dev Mac.
+ Toolchain installed via scoop: Go 1.26.5, MinGW gcc 16.1.0, rustup-gnu + cargo 1.97.1,
+ make. Recipe + provenance in `Documentation/windows-build.md`.
+- **libtokenizers.a source-built** (no upstream Windows prebuilt): `daulet/tokenizers` v1.27.0
+ tag, **GNU** Rust toolchain (must match MinGW gcc; MSVC would produce an unlinkable `.lib`).
+ The v1.27.0 layout emits `libtokenizers_ffi.a` — renamed to `libtokenizers.a` on packaging.
+ Published as repo release **`tokenizers-1.27.0-windows-x64`**, SHA-256 pinned. ONNX Runtime
+ DLL is the **official** Microsoft `onnxruntime-win-x64-1.26.0.zip` (member-pinned).
+- **`assets_embed_windows.go`** embeds `onnxruntime.dll`; **Makefile** learned to extract
+ `.zip` archives (unzip → Windows `tar.exe` fallback) so `make assets` runs on Windows.
+- **Two Windows-only build gaps found and fixed in the Makefile** (both invisible on
+ macOS/Linux, guarded by `GOOS=windows`):
+ 1. The Rust static lib needs NT/Winsock/crypto syscall libs MinGW doesn't link by default
+ (`undefined reference to Nt*/Rtl*`) → `LINK_LIBS` appends `-lntdll -lws2_32 -lbcrypt
+ -luserenv -ladvapi32 -lkernel32 -lncrypt`.
+ 2. sqlite-vec's cgo `#include "sqlite3.h"` has no system header on Windows (macOS SDK /
+ Linux libsqlite3 supplied it) → new `winhdr` step stages the headers mattn/go-sqlite3
+ bundles (its `sqlite3-binding.h` IS the amalgamation `sqlite3.h`) into `build/winhdr`,
+ version-matched to the SQLite mattn compiles in.
+- **Verified natively on Windows x64:** `make assets` downloads + checksum-verifies all four
+ artifacts (incl. `.zip` extraction and our published tokenizer release); integration test
+ links and passes (cosine ordering 0.140 < 0.171 < 0.286, matching every platform); full
+ `make build` produces `agent-memory.exe` (193 MB PE32+ GUI); the exe extracts its embedded
+ assets to `windows-amd64-/` and answers an MCP search on a Mac-indexed DB
+ ("how do I cook italian pasta" → carbonara-recipe.md) — cross-platform vector compatibility
+ confirmed. (Network not forcibly disabled — SSH session — but the local provider makes no
+ outbound calls by design; the `--network none` Linux/Intel runs already proved that property.)
+
1. Linux x64/arm64: assets manifest entries, CI build, smoke test.
2. Windows x64: CI job builds `libtokenizers.a` with Rust toolchain (no published binary);
ORT DLL from official release; smoke test.
@@ -383,7 +538,135 @@ and the indexing-progress UX all already exist and are the extension points.
2. Architecture rule check per `Documentation/ARCHITECTURE.md` (inward deps, wiring in main.go,
mocks for all interfaces, thin delivery layer).
3. `go vet ./...`, `go test ./...`, `make build`, full manual test both modes.
-4. Update this epic's Status to Complete; record the chosen default model and measured numbers.
+4. **Verification gaps carried from Phase 5** (recorded 2026-07-17 — coverage gaps, not known
+ defects; each Phase 5 platform's core inference + search path IS verified):
+ - [x] **Windows GUI smoke** — DONE 2026-07-17/18, and it earned its keep: the field test
+ (keyless onboarding ✓, real 109-file vault indexed, dossier search via Claude Desktop
+ returning correct contextual results ✓) surfaced **three real bugs**, all fixed:
+ 1. **Token-budget overflow** — every multi-chunk file (>~1.7 KB) silently failed to
+ embed on EVERY platform, latent since Phase 3 (all prior verifications used tiny
+ single-chunk corpora). Fixed + permanent large-doc integration test: **PR #10**;
+ report `Documentation/Bugs/local-embed-token-budget-overflow.md`. Field-verified:
+ 105 files / 932 chunks / 79 multi-chunk / 0 errors (was 26/26/0).
+ 2. **Index failures invisible** (stderr only, nothing in the Log page): **PR #8**.
+ 3. **Close-window zombies on Windows** (HideWindowOnClose without the mac-only tray;
+ six concurrent instances accumulated): **PR #9**.
+ Observations for Bo (not fixed): onboarding registers the folder but indexing starts
+ only via the Dashboard button (intentional per app.go comment — confirm UX intent);
+ Microsoft-Store-installed Claude Desktop reads its config from the MSIX sandbox
+ (`%LOCALAPPDATA%\Packages\Claude_*\LocalCache\Roaming\Claude\`), so the Install button
+ can't reach it — documented limitation, needs a decision on Store-install detection.
+ - [x] **Linux GUI field test** — DONE 2026-07-20 (Surface, Pop!_OS 24.04, fix #10 build):
+ first-ever Linux GUI run — keyless onboarding ✓, indexed 4 large docs (76 chunks, all
+ multi-chunk, 0 errors) ✓, MCP semantic search correct ✓. Throughput ~4 chunks/s on the
+ older dual-core i7 (vs ~30/s on the M-series dev Mac) — expected CPU scaling, no
+ pathology. **Packaging lesson:** mid-test the machine upgraded 22.04→24.04, which
+ REMOVES webkit2gtk-4.0 — the 4.0-linked binary stopped loading. Linux release builds
+ must target **webkit2gtk-4.1** (`-tags webkit2_41`; present on 22.04 AND 24.04; the
+ linux-arm64 build already does — only the ad-hoc x64 test build used 4.0).
+ - [x] **Full untagged `go test ./...` on Windows** — DONE 2026-07-17: every package green
+ except `TestOnCreate`, which fails exactly as on Linux (assumption CONFIRMED — Windows
+ delivers the same create+write double-event). PR #5's fix branch verified on Windows:
+ watcher suite 3/3 pass. So the sole Windows failure is the known bug with a proven fix;
+ re-run once #5 merges into the stack for the final green checkmark.
+ - [x] **linux-amd64 runtime smoke** — DONE 2026-07-17 on real x64 hardware (Surface Book
+ i7, Pop!_OS 22.04): binary built in an Ubuntu 22.04 amd64 container (glibc-matched;
+ `go build` with wails production tags — the full wails-CLI build path was already proven
+ on linux-arm64), integration test passed in-container (0.140 < 0.171 < 0.286), then the
+ binary ran natively on the Surface with **zero library installs** (`ldd` clean against
+ stock webkit2gtk-4.0/gtk-3), extracted assets to `linux-amd64-/`, and
+ answered the MCP search correctly against a macOS-indexed DB.
+5. Update this epic's Status to Complete; record the chosen default model and measured numbers.
+
+**Review revision round (2026-08-12, addressing Bo's PR #2 review):** the shipped
+dimension-only read-only guard was an unrecorded simplification of this epic's specified
+`embedding_fingerprint` — now resolved per the review: the engine records the full
+`provider:model:dimensions` fingerprint on every index run/reset (bare dimension kept for
+pre-fingerprint DBs), and the read-only guard compares fingerprints first, so a
+same-dimension model swap (e.g. granite-97m, also 384-dim) is caught instead of silently
+returning mixed-vector garbage. Also landed in the round: crash-safe shutdown (atomic
+per-file index transaction + Stop-waits-for-file-boundary), activity-log retention
+(30-day TTL / 5000-row cap) with per-path error upsert (no more identical rows per
+launch), error logging consolidated inside IndexFile, deterministic cross-platform
+debounce-merge tests, and `[]` (not `null`) for empty search results.
+
+**Phase 6 executed 2026-07-20 — results:**
+
+- **Orphan hunt: clean.** Zero callers of the old `Embed()` name anywhere. Every
+ `"text-embedding-3-small"` / `1536` occurrence lives in a legitimate OpenAI-path site
+ (defaults.go, openai.go, OpenAI UI pickers, tests asserting OpenAI resolution). One
+ deliberate fallback kept: `store.defaultVecDimension = 1536` (dim ≤ 0 fallback, documented
+ as historical-schema preservation; the single production caller always passes the resolved
+ dimension). Fixed one stale comment (domain.SearchOptions.Threshold now points at the
+ provider-aware `embeddings.DefaultThreshold`: openai 1.5 / local 0.6).
+- **Architecture audit: passes.** Inward-only imports verified package-by-package
+ (store→domain only — never imports embeddings; embeddings/chunker/watcher/extractor
+ import no siblings; engine depends on interfaces of all domains; mcp defines its own
+ service interfaces and imports only domain). All wiring in main.go. All six domain
+ interfaces have mocks (`chunker.Tokenizer`, a two-method seam, uses package-local fakes
+ in its consumers — acceptable). Delivery layer thin (app.go pass-through + the sanctioned
+ provider-swap flow).
+- **Formal pass: green.** `go vet` clean; untagged suite 8/8 packages; tagged integration
+ suite (incl. the large-document regression) passes; `make build` (arm64, fix included);
+ stdio mode answers a semantic search correctly with the fixed binary; GUI mode
+ Phase-4-verified and field-verified on Windows + Linux with the fix.
+- **Post-review items only:** re-run the Windows suite once PR #5 merges (expect all-green);
+ flip Status from "Complete pending review" to "Complete" when the PR stack lands.
+
+**Final measured numbers (recorded per this checklist):** default model
+`multilingual-e5-small` int8 (384-dim, 512-token ctx, effective chunk budget 480);
+cosine-distance ordering related ≈0.13–0.14 < cross-lingual ≈0.17 < unrelated ≈0.28–0.29,
+reproduced identically on macOS arm64, macOS x86_64 (Rosetta), Linux arm64/x64, Windows x64;
+throughput ~30 chunks/s (M-series) to ~4 chunks/s (2016 dual-core i7); field scale:
+105 files / 932 chunks real vault on Windows, offline, zero errors.
+
+## Phase 3 — Detailed Plan (DRAFT, pending Bo review)
+
+Drafted 2026-07-07 as the method-level plan for the Phase 3 outline above. **Not yet built** —
+Phase 3 is the biggest, behavior-changing phase and its architecture decisions want Bo's sign-off
+first. **Recommend delivering as 3 smaller PRs** (3a build/bundling → 3b config/switching → 3c
+safety) to fit the "small PRs, never break anything" model.
+
+### 3a — Build & distribution (no running-app behavior change yet)
+
+| File | Change |
+|---|---|
+| `Makefile` | New `assets` target: download pinned model/tokenizer/ORT-lib into `assets/embedded/` (gitignored), verify SHA-256 vs `assets/manifest.json`. `build` depends on `assets`; passes `-tags localembed` + `CGO_LDFLAGS` for `libtokenizers.a`. |
+| `assets/manifest.json` (new) | Pinned URLs + SHA-256 (in git). |
+| `.gitignore` | `assets/embedded/`. |
+| `internal/embeddings/local/assets_embed.go` (new, `//go:build localembed`) | `go:embed` model+tokenizer+dylib; extract via existing `extractAndVerify` to `~/.agent-memory/runtime//`; `resolveAssets` falls back to this when no dev dir/env is set. |
+| `internal/embeddings/local/assets_embed_stub.go` (new, `//go:build !localembed`) | "no embedded assets" → keeps default build small/green. |
+
+**Verify:** `make assets && make build` runs the local model offline; plain `go test ./...` stays green/lib-free.
+
+### 3b — Config, factory & provider switching (local becomes default)
+
+| File | Change (method-level) |
+|---|---|
+| `app.go` | `EmbedderFactory` → `func(provider, apiKey, model) (embeddings.Embedder, error)`. `SetConfig` gains `embedding_provider` case: swap embedder **and chunker** → `engine.Reset()`. Provider-aware `embedding_model`/`openai_api_key` handling. |
+| `internal/engine/engine.go` | **Add `SetChunker(c chunker.Chunker)`** (Open Concern #1) — provider switch swaps tokenizer/chunker atomically with the embedder. |
+| `main.go` | Both branches read `embedding_provider`, build via factory (`local`→`local.New`, `openai`→`NewOpenAIEmbedder`); GUI wires embedder-matched tokenizer into chunker (`WithTokenizer` + `WithMaxInputTokens`); **stdio stays lazy**. |
+| `internal/embeddings/defaults.go` (new) | Centralize `defaultModel(provider)` / `defaultDimension(provider, model)` (Open Concern #4) — remove scattered `"text-embedding-3-small"`/`1536` hardcodes. |
+
+**Verify:** fresh DB indexes a test folder fully offline; provider switch local↔openai triggers reset + re-index.
+
+### 3c — Safety, store & Stats
+
+| File | Change |
+|---|---|
+| `internal/store/sqlite.go` | `migrate()`/`Reset()` create `chunk_embeddings` at the active provider's dimension (passed in — see Decision 2), not hardcoded. `Stats()` reports `embedding_provider`+`embedding_model` from config (layering fix). |
+| `app.go` | Write `embedding_fingerprint` (`provider:model:dim`) on each index run; check at GUI startup → mismatch surfaces "re-index required", not garbage. |
+| `internal/engine/readonly.go` | Read-only dimension guard (Open Concern #2): compare embedder `Dimensions()`/fingerprint to stored table; mismatch → actionable error, not raw sqlite-vec failure. |
+| `internal/mcp/server.go` | `index_status` gains a `provider` field (from `Stats`). |
+| threshold | Confirm sqlite-vec metric; provider-aware default threshold from Phase 0 numbers (~0.25 local), centralized (Open Concern #5). |
+
+**Verify:** existing OpenAI-vectored DB hits the fingerprint-mismatch path (not garbage); `--mcp` returns a clear error on dimension mismatch.
+
+### Decisions needing Bo's sign-off
+
+1. **`make build` now requires native libs + `-tags localembed`** (via `make assets`, ~150 MB); binary ~26 MB → ~180 MB. Epic accepts this — confirm.
+2. **Store dimension: reorder the composition root** so `main.go` resolves provider/model/dimension up front and **passes the dimension into the store**, rather than `migrate()` hardcoding it — avoids a new hardcode *and* keeps `store` from importing `embeddings`. Touches wiring order.
+3. **Existing-user handling:** when `embedding_provider` is unset on an upgraded DB with OpenAI vectors + a key → default to **openai** (preserve their setup), not local. Full onboarding migration is Phase 4; the resolution rule starts here.
## Risks
@@ -399,6 +682,23 @@ and the indexing-progress UX all already exist and are the extension points.
| Binary size ~180 MB | Accepted | Decision made 2026-07-02; the trade for zero API cost |
| int8 quantization quality drop vs fp32 | Low | Phase 0 sanity comparison; fp32 fallback possible at 449 MB if unacceptable |
+## Open Concerns & Plan Adjustments (from review + Phase 0)
+
+Captured 2026-07-06 during pre-implementation review and the Phase 0 spike. Each item is tagged to
+the phase that must address it, so nothing is lost mid-epic.
+
+| # | Concern | Fix in | Note |
+|---|---|---|---|
+| 1 | **Chunker not swapped on runtime provider switch.** `engine` has `SetEmbedder` but no `SetChunker`; `app.SetConfig` swaps only the embedder — switching OpenAI↔local at runtime would leave the wrong tokenizer/clamp. | **Phase 3** | Add `engine.SetChunker` (or a combined provider swap); `SetConfig` provider case swaps embedder + chunker atomically. |
+| 2 | **Read-only stdio dimension mismatch unhandled.** `readonly.Search` embeds the query with a config-derived embedder; if its dim disagrees with the stored vectors, sqlite-vec errors and the read-only process cannot re-index. | **Phase 3** | stdio compares embedder `Dimensions()`/fingerprint to the stored table; return an actionable error ("rebuild in the GUI"), not a raw failure. |
+| 3 | **Existing-user onboarding regression.** Switching the gate to `onboarding_complete` re-onboards current users and defaults them to local, mismatching their OpenAI vectors. | **Phase 4** | Migration: if the DB has watched dirs or a saved key, back-fill `onboarding_complete=true` and preserve `provider=openai` for existing OpenAI DBs. |
+| 4 | **Scattered default-model/dimension hardcodes** (`"text-embedding-3-small"` ×5, `1536` ×2). | **Phase 3** (seed in Phase 1) | Centralize `defaultModel(provider)` / `defaultDimension(provider,model)`; stop trading a `1536`→`384` hardcode. |
+| 5 | **Threshold `1.5` duplicated (~4 sites) and miscalibrated** for local (Phase 0: real separation ~0.25 cosine distance). | **Phase 3** | Centralize the default; make it provider-aware; confirm sqlite-vec's configured distance metric. |
+| 6 | **mE5 needs `token_type_ids`** (3 INT64 inputs, not 2). | **Phase 2** | Pass a zero tensor in `LocalEmbedder`. |
+| 7 | **Peak RSS ~1 GB** (ORT arena + batch activations). | **Phase 2** | Constrain ORT arena / batch size. |
+| 8 | **`go:embed` must be build-tagged per platform** (one binary can embed only one platform's ORT lib). | **Phase 5** | Build constraints on the embed directives. |
+| 9 | **`OpenAIEmbedder.MaxInputTokens()==0` ("no limit")** — footgun if `chunk_size` ever exceeds OpenAI's 8191. | Low priority | Documented trade-off; add a comment. |
+
## Out of Scope (this epic)
- Ollama / external-runtime provider option
diff --git a/Documentation/Epics/mcp-search-filters.md b/Documentation/Epics/mcp-search-filters.md
new file mode 100644
index 0000000..38573db
--- /dev/null
+++ b/Documentation/Epics/mcp-search-filters.md
@@ -0,0 +1,87 @@
+# Epic: MCP Search Filters (filesystem-tier metadata)
+
+**Date:** 2026-08-12
+**Status:** Proposed — definition requested in PR #2 review; queued after `background-presence`
+**Owner:** Bo Motlagh (definition scoped by Bo; drafted by Nestor per review)
+
+## Goal
+
+The MCP `search` tool (stdio and HTTP) gains **optional filter parameters** that constrain
+results by file metadata, so a caller can ask "search only my markdown notes," "only files
+under this directory," or "only files modified in the last month" — instead of over-fetching
+and filtering client-side after the fact.
+
+## User-set constraints (settled in the PR #2 review — do not relitigate)
+
+- **Filesystem-tier metadata only**: path/glob, file type/extension, watched directory,
+ file modified time, file size, indexed-at time. **Document-embedded properties**
+ (title/author/created from docx/pdf/etc.) are explicitly **out of scope** for this epic —
+ parking-lot note only.
+- **MCP surface only**: both transports' `search` tool schemas. **No GUI search work.**
+- Definition merged in PR #2; implementation sequenced after `background-presence` unless
+ Bo reorders.
+
+## Current state (verified against the PR #2 branch)
+
+- The `files` table carries only `path`, `hash`, `indexed_at`, `directory_id` — **no mtime,
+ size, or type column**. Filters on modified-time/size need columns captured at index time:
+ a schema migration plus a backfill decision. Backfill is cheap — an `os.Stat` per file at
+ the next scan populates the columns **without re-embedding** (the content hash is
+ unchanged, so the pipeline is skipped; only the file row updates).
+- Filter params must flow through `domain.SearchParams` so engine / readonly / store stay
+ interface-clean; the tool schemas in `internal/mcp/server.go` are the only delivery-layer
+ touch (both transports dispatch through the same search path).
+- `store.Search` today over-fetches (`limit+offset`, ×2 under a threshold) and filters
+ distance in Go — there is already a post-KNN filtering stage to build on.
+
+## The key design question (the epic's real research content)
+
+**How filtering composes with the KNN query** in `store.Search`: sqlite-vec `vec0` KNN does
+not trivially JOIN-filter. Research and **measure** both options, recording numbers in this
+doc the way local-embeddings recorded its Phase 0 results:
+
+1. **Over-fetch then filter** (in SQL over the joined rows, or in Go): simple, no schema
+ coupling to sqlite-vec — but `limit`/`offset`/`threshold` semantics must hold
+ **post-filter**. A filter that excludes 90% of neighbors must not return 1 result because
+ the over-fetch was too small: the over-fetch factor needs to adapt (e.g. iterative
+ re-query with growing K until `limit` post-filter results or the index is exhausted).
+2. **sqlite-vec metadata columns / partition keys** in the `vec0` table (newer sqlite-vec
+ feature): filters pushed into the KNN itself. Verify version availability in our pinned
+ sqlite-vec, migration cost for existing tables, and which of our filter types it can
+ express (globs almost certainly not — likely a hybrid: partition/metadata for cheap
+ equality filters, post-filter for globs/ranges).
+
+**Design-once note (from the review):** result post-processing (over-fetch → dedupe/filter →
+limit/offset/threshold) is the **same machinery `multi-representation-indexing` needs** for
+its per-chunk best-score dedupe. Whichever epic lands first should design `store.Search`'s
+post-KNN stage to serve both.
+
+## Execution Notes (read first if you are the implementing session)
+
+- Epic conventions per `local-embeddings.md`: phase gates with Verify, record deviations here.
+- Schema migration must be additive (`ALTER TABLE ADD COLUMN`, nullable) — existing DBs keep
+ working un-backfilled; NULL metadata simply doesn't match metadata filters until the next
+ scan backfills (document this in the tool description).
+- Tool schema: filters land as optional properties (e.g. `path_glob`, `extensions[]`,
+ `directory`, `modified_after/before`, `min/max_size`, `indexed_after/before`) — absent
+ filters must behave byte-for-byte like today's search (regression tests on that).
+
+## Phases (outline)
+
+- **Phase 0 — Measure the composition options** on a realistic index (thousands of chunks):
+ over-fetch-adaptive vs metadata-column KNN, latency + correctness under selective filters.
+ **Gate:** numbers recorded here; approach chosen.
+- **Phase 1 — Schema + backfill:** mtime/size (+type derived from path) columns, migration,
+ os.Stat backfill on scan without re-embed. **Verify:** upgraded DB backfills on next scan;
+ no re-embedding occurs (chunk counts and vectors unchanged).
+- **Phase 2 — `domain.SearchParams` + store filtering** per the chosen approach, with the
+ post-filter limit/offset/threshold semantics pinned by tests (including the
+ filter-eats-90%-of-neighbors case).
+- **Phase 3 — MCP tool schemas** (both transports) + docs. **Verify:** filtered searches via
+ a live MCP client; unfiltered search unchanged.
+
+## Out of scope / parking lot
+
+- Document-embedded properties (docx/pdf titles, authors, created dates) — future epic.
+- GUI search/filter UI.
+- Saved filters, filter persistence, per-directory defaults.
diff --git a/Documentation/Epics/multi-representation-indexing.md b/Documentation/Epics/multi-representation-indexing.md
new file mode 100644
index 0000000..4686a6a
--- /dev/null
+++ b/Documentation/Epics/multi-representation-indexing.md
@@ -0,0 +1,98 @@
+# Epic: Multi-Representation Indexing (dual-vector chunks)
+
+**Date:** 2026-08-12
+**Status:** Proposed — definition requested in PR #2 review; queued after `mcp-search-filters`
+**Owner:** Bo Motlagh (definition + design input scoped by Bo; drafted by Nestor per review)
+
+## Problem (verified against the PR #2 branch)
+
+A chunk's single `content` currently serves two conflicting jobs: **the text we embed** and
+**the text we return**. `.html`/`.htm` are in the extractor's `textExtensions` (raw
+passthrough), so an HTML chunk's vector substantially describes class names and tags
+(`text-slate-800`, `border-collapse`) rather than what the page says — findable only by
+markup-shaped queries. Stripping tags before embedding is wrong the other way: for source
+files the markup *is* the artifact, and a code-shaped query should match raw and get raw
+back. Any per-file "document or code?" classification has no right answer.
+
+## Proposed shape (Bo's design input — to validate in this epic, not settled)
+
+Decouple embedded text from stored text: **up to two vectors per chunk**, both pointing at
+the same stored raw payload.
+
+- **Vector A** = embed(raw chunk) — matches code/markup-shaped queries.
+- **Vector B** = embed(extracted text of that chunk) — matches prose queries about the
+ rendered content.
+- **Emission is measurable, not taxonomic** — at index time compare extracted length to raw
+ length:
+ - Ratio ≈ 1 (prose: md/txt/docx): one vector from raw — exactly today's behavior, no
+ regression.
+ - Extracted ≪ raw (html/svg/ipynb): both vectors.
+ - Extraction ≈ nothing (minified bundles, base64 blobs): one raw vector **plus a
+ file-level summary chunk** (path, type, title/headings outline) — the same shape as the
+ ZIP metadata stub the extractor already emits.
+- Extraction stays **deterministic and free**: for HTML, walk text nodes + structural
+ landmarks (``, headings, `alt`, `aria-label`, link/button labels) via
+ `golang.org/x/net/html`. No LLM, no per-file cost.
+
+## Binding constraints (settled — do not relitigate)
+
+- Files on disk are **never modified**.
+- `search` returns **raw `content` by default** — existing callers unchanged; an optional
+ `include_extracted` response field is fine.
+- **No LLM** anywhere in the pipeline.
+- The extra embed/vector cost lands on local compute and is accepted (~10–15% vector growth
+ in a mostly-prose corpus).
+- **Hard prerequisite: the PDF extraction fix** (see
+ `Documentation/Bugs/pdf-extraction-passthrough.md` + its scheduled fix) — same mechanism,
+ and PDFs become "embed extracted, store extracted" with **no raw vector** (nobody queries
+ for `/Filter /FlateDecode`).
+
+## Design questions this epic must work out (the real content)
+
+1. **Schema.** `chunk_embeddings` is `vec0(chunk_id INTEGER PRIMARY KEY, embedding
+ FLOAT[dim])` — strictly one vector per chunk today. Two vectors per chunk means a
+ synthetic embedding id with a (chunk_id, representation) mapping — via an aux table or
+ sqlite-vec's metadata/auxiliary columns — plus migration and `Reset()` changes.
+2. **Search semantics.** KNN now returns *embedding* rows, not chunks — results must dedupe
+ to one row per chunk keeping the best score, which forces over-fetch before
+ `limit`/`offset`/`threshold` apply. This is the **same post-KNN machinery
+ `mcp-search-filters` needs**; whichever epic lands second inherits the first's design —
+ design `store.Search`'s post-processing once for both.
+3. **Pairing granularity — the subtlest question.** Chunk boundaries are computed on raw
+ text, so "the extracted text of chunk 42" means extracting from a raw HTML *fragment*
+ (`x/net/html` tolerates fragments, but landmark quality mid-document needs verification)
+ — versus the alternative of two parallel chunkings of the file with looser file-level
+ pairing. **Spike this first; it decides the data model.**
+4. **Threshold interplay.** Raw-vector and extracted-vector distances for the same chunk
+ will distribute differently; confirm the provider-aware default threshold behaves
+ sensibly when both representations compete in one result set (measure, per the
+ local-embeddings Phase 0 pattern).
+
+## Execution Notes (read first if you are the implementing session)
+
+- Epic conventions per `local-embeddings.md`: phase gates with Verify, record deviations and
+ measured numbers in this doc.
+- Sequenced **after** `mcp-search-filters` (unless Bo reorders) — and note design question 2
+ is shared with it either way.
+- The emission ratio thresholds (what counts as "≈1", "≪") must be recorded here once
+ measured against a real corpus — they are product behavior, not implementation detail.
+
+## Phases (outline)
+
+- **Phase 0 — Pairing spike (throwaway):** fragment-extraction quality on real HTML
+ mid-document vs parallel-chunking alternative. **Gate:** data model decided, recorded here.
+- **Phase 1 — Schema migration** ((chunk_id, representation) mapping, Reset, fingerprint
+ interaction). **Verify:** existing single-vector DBs migrate or trigger the re-index path
+ cleanly — never silently mixed.
+- **Phase 2 — Extraction + emission rules** (ratio-based; html first, svg/ipynb after).
+ **Verify:** prose corpus produces byte-identical behavior to today (no regression class).
+- **Phase 3 — Search dedupe + threshold calibration** (shared machinery with
+ mcp-search-filters). **Verify:** prose-shaped query finds the HTML page's content; code-
+ shaped query still finds raw markup; measured distances recorded.
+- **Phase 4 — MCP `include_extracted` + docs.**
+
+## Out of scope / parking lot
+
+- LLM-generated summaries or descriptions of any kind.
+- Per-file-type user configuration of representations.
+- OCR / image content extraction.
diff --git a/Documentation/FEATURES.md b/Documentation/FEATURES.md
index a326a76..f782134 100644
--- a/Documentation/FEATURES.md
+++ b/Documentation/FEATURES.md
@@ -2,7 +2,7 @@
## Core Pipeline
-**Directory watching and indexing.** Add directories via the GUI or MCP. The engine walks each directory, filters by ignore patterns and supported file types, then runs the pipeline: extract text -> chunk -> embed via OpenAI -> store vectors in SQLite. File content is hashed (SHA-256) so unchanged files are skipped on subsequent scans.
+**Directory watching and indexing.** Add directories via the GUI or MCP. The engine walks each directory, filters by ignore patterns and supported file types, then runs the pipeline: extract text -> chunk -> embed (via the active provider — local by default) -> store vectors in SQLite. File content is hashed (SHA-256) so unchanged files are skipped on subsequent scans.
**Semantic search.** Query text is embedded, then matched against stored vectors using KNN (sqlite-vec cosine distance). Returns ranked results with file path, chunk text, and similarity score. Available via MCP `search` tool.
@@ -24,11 +24,15 @@ Token-based splitting using tiktoken (cl100k_base encoding). Configurable chunk
## Embedding
-OpenAI API client supporting `text-embedding-3-small` (1536 dimensions) and `text-embedding-3-large` (3072 dimensions). Batches up to 2048 inputs per API call. Retries with exponential backoff on rate limits (max 3 retries). The engine further batches by token count (250K tokens per batch) to stay within API limits.
+Two providers, selectable in Settings. Both satisfy the same `Embedder` interface, so the rest of the pipeline is provider-agnostic.
-**Model switching.** Changing the embedding model via Settings drops the vector table, recreates it with the new dimension, and re-indexes everything. The embedder is hot-swapped so no restart is needed.
+**Local (default).** A bundled `multilingual-e5-small` model (384 dimensions, ~100 languages) runs in-process on the CPU via ONNX Runtime. Works fully offline — no API key, no network calls. The model, tokenizer, and runtime library are bundled into the app; onboarding requires no key.
-**API key changes.** Updating the OpenAI API key immediately swaps the embedder so new requests use the updated key.
+**OpenAI (opt-in).** OpenAI API client supporting `text-embedding-3-small` (1536 dimensions) and `text-embedding-3-large` (3072 dimensions). Enabled by adding an API key in Settings. Batches up to 2048 inputs per API call. Retries with exponential backoff on rate limits (max 3 retries). The engine further batches by token count (250K tokens per batch) to stay within API limits.
+
+**Provider & model switching.** Changing the provider or model via Settings drops the vector table, recreates it with the new dimension, and re-indexes everything (vectors from different models are incompatible). The embedder is hot-swapped so no restart is needed.
+
+**API key changes.** Updating the OpenAI API key (while the OpenAI provider is active) immediately swaps the embedder so new requests use the updated key.
## Ignore Patterns
@@ -68,7 +72,7 @@ Both implement the MCP protocol: `initialize` handshake, `tools/list`, and `tool
Wails v2 with React frontend. Native webview, no browser required.
-**Onboarding** — first-launch flow: API key entry, directory picker, then dashboard.
+**Onboarding** — first-launch flow: welcome, directory picker, then dashboard. No API key required — the local provider works out of the box. Opting into OpenAI is done later in Settings.
**Dashboard** — total files, chunks, last indexed time, indexing progress bar, embedding model display.
@@ -76,7 +80,7 @@ Wails v2 with React frontend. Native webview, no browser required.
**Controls** — Start/Stop/Restart/Reset buttons. Reset clears all vectors and re-indexes (with confirmation dialog).
-**Settings** — API key (masked), model picker, chunk size/overlap, MCP port, auth token display with rotate button.
+**Settings** — embedding provider selector (local default vs OpenAI); when OpenAI is selected, an API key field (masked) and model picker appear. Plus chunk size/overlap, MCP port, and auth token display with rotate button. Switching provider re-indexes everything.
**Activity Log** — paginated log of indexing events (indexed, ignored, deleted, errors).
@@ -97,7 +101,7 @@ The config path is customizable for non-standard installs. Claude Desktop launch
- HTTP server binds to `127.0.0.1` only — no network exposure.
- Bearer token auth on all MCP HTTP requests. Token auto-generated, stored in SQLite, rotatable via GUI.
-- Only outbound traffic is HTTPS to `api.openai.com`.
+- No outbound traffic by default — the local embedding provider runs in-process. Outbound HTTPS to `api.openai.com` occurs only if the OpenAI provider is opted into.
- GUI uses native webview IPC — no localhost web server for the UI.
- SQLite file uses standard filesystem permissions.
@@ -111,7 +115,7 @@ All settings are stored in the SQLite `config` table and manageable from the GUI
**MCP server port:** Default `9847`. Binds to `127.0.0.1` only.
-**Embedding model:** `text-embedding-3-small` (1536 dimensions) by default. Switch to `text-embedding-3-large` (3072 dimensions) in Settings. Changing the model re-indexes everything.
+**Embedding provider & model:** local `multilingual-e5-small` (384 dimensions) by default. Switch to the OpenAI provider in Settings to use `text-embedding-3-small` (1536 dimensions) or `text-embedding-3-large` (3072 dimensions), which require an API key. Changing the provider or model re-indexes everything.
**Chunk size:** 512 tokens default, configurable. Overlap: 50 tokens default, configurable.
diff --git a/Documentation/ROADMAP.md b/Documentation/ROADMAP.md
index 2c9cc9c..e846008 100644
--- a/Documentation/ROADMAP.md
+++ b/Documentation/ROADMAP.md
@@ -2,9 +2,10 @@
## Local Embeddings (Epic)
-Replace OpenAI as the default embedding provider with a bundled in-process local model
-(ONNX Runtime + multilingual-e5-small); OpenAI becomes an explicit opt-in backup. Full design
-and implementation plan: [Epics/local-embeddings.md](Epics/local-embeddings.md).
+**Implemented** — a bundled in-process local model (ONNX Runtime + `multilingual-e5-small`, 384-dim)
+is now the default embedding provider, working fully offline with no API key; OpenAI is an explicit
+opt-in in Settings. Working on macOS arm64; cross-platform builds (Linux, Windows, macOS x86_64)
+are the remaining phase. Full design and status: [Epics/local-embeddings.md](Epics/local-embeddings.md).
## Search Configuration UI
diff --git a/Documentation/windows-build.md b/Documentation/windows-build.md
new file mode 100644
index 0000000..1df3cca
--- /dev/null
+++ b/Documentation/windows-build.md
@@ -0,0 +1,90 @@
+# Windows x64 Build & Artifact Provenance (local-embeddings, Phase 5)
+
+How the Windows local-embedding build is produced and how to reproduce its one
+source-built artifact. Records what was actually run (versions below are what
+shipped), so a future maintainer can rebuild after a dependency bump.
+
+## Why Windows needs a build step at all
+
+Every other platform gets both native libraries as official prebuilts. Windows
+is the exception on one of them:
+
+| Artifact | Windows source |
+|---|---|
+| ONNX Runtime DLL | **Official** Microsoft release (`onnxruntime-win-x64-1.26.0.zip`) — pinned directly in `assets/manifest.json`. |
+| `libtokenizers.a` (static) | **No upstream Windows prebuilt** — `daulet/tokenizers` publishes macOS/Linux only. We build it from source and host it in this repo's releases (`tokenizers-1.27.0-windows-x64`). |
+
+## Toolchain (installed via scoop)
+
+```
+scoop install go mingw rustup-gnu make
+rustup default stable-x86_64-pc-windows-gnu
+```
+
+Verified versions: Go 1.26.5, MinGW gcc 16.1.0, rustc/cargo 1.97.1 (GNU),
+GNU make 4.4.1.
+
+**The GNU pairing is load-bearing.** CGo links the Go binary with MinGW `gcc`,
+so the Rust static lib must come from the `*-windows-gnu` toolchain. Building it
+with the default MSVC toolchain yields a `.lib` in a format MinGW cannot link.
+
+## Reproducing `libtokenizers.a`
+
+```
+git clone --depth 1 --branch v1.27.0 https://github.com/daulet/tokenizers
+cd tokenizers
+cargo build --release
+# Output: target/release/libtokenizers_ffi.a (~40 MB)
+```
+
+Note the **name change**: this repo layout (v1.27.0) emits `libtokenizers_ffi.a`
+from the `tokenizers-ffi` crate. The macOS/Linux prebuilts — and therefore the
+`-ltokenizers` link flag and the manifest's `member: libtokenizers.a` — expect
+`libtokenizers.a`. Same archive, so we simply rename on packaging:
+
+```
+cp target/release/libtokenizers_ffi.a libtokenizers.a
+tar czf libtokenizers.windows-x86_64.tar.gz libtokenizers.a
+# publish as a repo release, pin archive + member SHA-256 in the manifest
+```
+
+## Building the app
+
+```
+git clone https://github.com/unitedeffectslabs/agent-memory
+cd agent-memory && git checkout feat/xplat-windows
+cd frontend && npm install && cd ..
+make assets # downloads DLL + model + tokenizer + our published static lib; verifies all checksums
+make build # wails build -skipbindings -tags localembed → build/bin/agent-memory.exe
+```
+
+`make assets` extracts the `.zip` ORT archive via `unzip` if present, else falls
+back to Windows' bundled `C:\Windows\System32\tar.exe` (Git Bash's GNU tar
+cannot read zips). Checksums use `sha256sum` if present, else `shasum -a 256`
+(both provided by Git for Windows).
+
+## Verification (the Phase 5 gate)
+
+1. Integration test — real tokenizer + ORT + model, as a native Windows binary:
+ ```
+ CGO_LDFLAGS="-L$PWD/internal/embeddings/local/lib -ltokenizers" \
+ go test -tags localembed -count=1 -run TestIntegrationEmbed -v ./internal/embeddings/local/
+ ```
+ Expect PASS, cosine ordering ≈ related 0.14 < cross-lingual 0.17 < unrelated
+ 0.29 (matches macOS arm64/x86_64 and Linux).
+2. Offline app run: disconnect network, `agent-memory.exe --db test.db`, onboard
+ with **no API key**, index a small folder, confirm search returns matches.
+
+## Troubleshooting (observed / anticipated)
+
+- **Missing Windows system symbols at link** (`undefined reference to Nt*` /
+ `Rtl*`, `ws2_32`, `bcrypt`, `userenv`): the Rust static lib pulls in NT/Winsock/
+ crypto syscalls MinGW doesn't link by default. **Handled** — the Makefile's
+ `LINK_LIBS` appends `-lntdll -lws2_32 -lbcrypt -luserenv -ladvapi32 -lkernel32
+ -lncrypt` when `GOOS=windows` (empty elsewhere). Observed and fixed during the
+ first Windows build; listed here in case a tokenizers/Rust bump adds more.
+- **`onnxruntime_providers_shared.dll` load error**: the official zip ships this
+ second DLL; CPU-only use normally doesn't need it, but if ORT fails to load,
+ pin it as an extra `embedded/` artifact beside the main DLL.
+- **Defender quarantines the fresh unsigned exe**: add a temp exclusion for the
+ repo folder while testing.
diff --git a/Makefile b/Makefile
index 524818f..1dc8e30 100644
--- a/Makefile
+++ b/Makefile
@@ -1,13 +1,114 @@
-.PHONY: build dev test clean
+.PHONY: build build-darwin-amd64 dev test clean assets winhdr
-build:
- wails build -skipbindings
+# --- Local-embedding asset bundling -----------------------------------------
+# Artifacts (model, tokenizer, ONNX Runtime dylib, static tokenizer lib) are
+# pinned by URL + SHA-256 in assets/manifest.json and downloaded by `make
+# assets` into the local embedding package (gitignored). They are never
+# committed. `make build` links the tokenizer static lib via CGO_LDFLAGS and
+# compiles with the `localembed` tag (which go:embed's the runtime assets).
+MANIFEST := assets/manifest.json
+LOCAL_DIR := internal/embeddings/local
+EMBED_DIR := $(LOCAL_DIR)/embedded
+LIB_DIR := $(LOCAL_DIR)/lib
+PLATFORM := $(shell go env GOOS)-$(shell go env GOARCH)
+# Portable SHA-256: macOS ships shasum, Linux ships sha256sum. Both print
+# "", so the awk '{print $1}' callers work with either.
+SHA256 := $(shell command -v sha256sum >/dev/null 2>&1 && echo sha256sum || echo shasum -a 256)
-dev:
- wails dev
+# Windows-only: the source-built libtokenizers.a (Rust std, GNU toolchain) pulls
+# in low-level NT/Winsock/crypto syscalls that MinGW does not link by default.
+# Naming them here resolves "undefined reference to Nt*/Rtl*" at link time.
+# Empty on macOS/Linux, so those builds are unaffected.
+LINK_LIBS := -ltokenizers
+CGO_EXTRA_CFLAGS :=
+WIN_PREREQ :=
+ifeq ($(shell go env GOOS),windows)
+LINK_LIBS += -lntdll -lws2_32 -lbcrypt -luserenv -ladvapi32 -lkernel32 -lncrypt
+# sqlite-vec's cgo build #includes sqlite3.h / sqlite3ext.h, which macOS and
+# Linux supply from the system but Windows does not. Stage the exact headers
+# mattn/go-sqlite3 bundles (its sqlite3-binding.h IS the amalgamation sqlite3.h),
+# so they match the SQLite that mattn compiles in — see the winhdr target.
+CGO_EXTRA_CFLAGS := -I$(PWD)/build/winhdr
+WIN_PREREQ := winhdr
+endif
+
+# Stage sqlite headers for the Windows build from the mattn/go-sqlite3 module
+# (Windows has no system sqlite3.h). No-op / unused on macOS and Linux.
+winhdr:
+ @mkdir -p "$(PWD)/build/winhdr"
+ @d=$$(go list -m -f '{{.Dir}}' github.com/mattn/go-sqlite3); \
+ d=$$(cygpath -u "$$d" 2>/dev/null || echo "$$d"); \
+ cp "$$d/sqlite3-binding.h" "$(PWD)/build/winhdr/sqlite3.h"; \
+ cp "$$d/sqlite3ext.h" "$(PWD)/build/winhdr/sqlite3ext.h"; \
+ echo ">> staged Windows sqlite headers from $$d"
+
+build: assets $(WIN_PREREQ)
+ CGO_CFLAGS="$(CGO_EXTRA_CFLAGS)" CGO_LDFLAGS="-L$(PWD)/$(LIB_DIR) $(LINK_LIBS)" wails build -skipbindings -tags localembed
+
+# Cross-build the Intel-mac app from an arm64 Mac. GOARCH=amd64 makes the
+# assets target fetch the darwin-amd64 artifacts (the embedded/ and lib/ dirs
+# hold ONE platform at a time — the checksum check refetches on arch switch,
+# so alternating with `make build` is safe, just re-downloads).
+build-darwin-amd64:
+ GOARCH=amd64 $(MAKE) assets
+ CGO_LDFLAGS="-L$(PWD)/$(LIB_DIR) -ltokenizers" wails build -skipbindings -tags localembed -platform darwin/amd64
+
+dev: assets $(WIN_PREREQ)
+ CGO_CFLAGS="$(CGO_EXTRA_CFLAGS)" CGO_LDFLAGS="-L$(PWD)/$(LIB_DIR) $(LINK_LIBS)" wails dev -tags localembed
test:
go test ./...
clean:
rm -rf build/bin
+
+# Download + checksum-verify each manifest artifact for the current platform.
+# Idempotent: files already present with a matching checksum are skipped.
+assets:
+ @echo ">> fetching local-embedding assets for $(PLATFORM)"
+ @command -v jq >/dev/null || { echo "ERROR: jq is required"; exit 1; }
+ @test -f $(MANIFEST) || { echo "ERROR: $(MANIFEST) not found"; exit 1; }
+ @jq -e '.platforms["$(PLATFORM)"]' $(MANIFEST) >/dev/null 2>&1 || \
+ { echo "ERROR: no manifest entry for platform $(PLATFORM)"; exit 1; }
+ @mkdir -p $(EMBED_DIR) $(LIB_DIR)
+ @set -e; \
+ for key in $$(jq -r '.platforms["$(PLATFORM)"] | keys[]' $(MANIFEST)); do \
+ sel() { jq -r ".platforms[\"$(PLATFORM)\"].$$key.$$1 // empty" $(MANIFEST); }; \
+ url=$$(sel url); sha=$$(sel sha256); dest=$$(sel dest); \
+ member=$$(sel member); membersha=$$(sel member_sha256); \
+ destpath=$(LOCAL_DIR)/$$dest; \
+ want=$$sha; [ -n "$$membersha" ] && want=$$membersha; \
+ if [ -f "$$destpath" ]; then \
+ have=$$($(SHA256) "$$destpath" | awk '{print $$1}'); \
+ if [ "$$have" = "$$want" ]; then echo " ok (cached) $$dest"; continue; fi; \
+ echo " stale, refetching $$dest"; \
+ fi; \
+ echo " downloading $$key -> $$dest"; \
+ tmp=$$(mktemp); \
+ curl -fsSL -o "$$tmp" "$$url"; \
+ got=$$($(SHA256) "$$tmp" | awk '{print $$1}'); \
+ if [ "$$got" != "$$sha" ]; then \
+ echo "ERROR: archive checksum mismatch for $$key: got $$got want $$sha"; rm -f "$$tmp"; exit 1; \
+ fi; \
+ mkdir -p "$$(dirname "$$destpath")"; \
+ if [ -n "$$member" ]; then \
+ xd=$$(mktemp -d); \
+ case "$$url" in \
+ *.zip) if command -v unzip >/dev/null 2>&1; then unzip -q "$$tmp" "$$member" -d "$$xd"; \
+ elif [ -x /c/Windows/System32/tar.exe ]; then /c/Windows/System32/tar.exe xf "$$tmp" -C "$$xd" "$$member"; \
+ else tar xf "$$tmp" -C "$$xd" "$$member"; fi ;; \
+ *) tar xzf "$$tmp" -C "$$xd" "$$member" ;; \
+ esac; \
+ cp "$$xd/$$member" "$$destpath"; \
+ rm -rf "$$xd"; \
+ got2=$$($(SHA256) "$$destpath" | awk '{print $$1}'); \
+ if [ "$$got2" != "$$membersha" ]; then \
+ echo "ERROR: member checksum mismatch for $$key: got $$got2 want $$membersha"; rm -f "$$tmp"; exit 1; \
+ fi; \
+ else \
+ cp "$$tmp" "$$destpath"; \
+ fi; \
+ rm -f "$$tmp"; \
+ echo " verified $$dest"; \
+ done
+ @echo ">> assets ready in $(EMBED_DIR) and $(LIB_DIR)"
diff --git a/README.md b/README.md
index cbe2b52..8773da5 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ Works as a standalone file tracker/indexer and as an MCP server that gives Claud
## How It Works
-1. **Launch the app** and enter your OpenAI API key in the onboarding screen (used for generating embeddings — your files never leave your machine except as embedding API calls).
+1. **Launch the app.** It works out of the box with a bundled local embedding model — no API key, no account, no network calls. Onboarding is just: welcome → add folders → done. (You *can* opt into OpenAI embeddings later in Settings if you prefer; see below.)
2. **Add directories** you want indexed. The app watches them in real time — new files, edits, and deletions are picked up automatically.
3. **Search your files**, connect Claude Desktop so it can search your files during conversations.
@@ -23,19 +23,26 @@ Under the hood, Claude Desktop launches the app as a subprocess (`--mcp` flag) t
- **PDFs** — text content extraction
- **Media & archives** — metadata only (file name, size, dimensions, contents list)
-Files are chunked into ~512-token segments (configurable), embedded via OpenAI, and stored as vectors. Unchanged files (matched by SHA-256 hash) are skipped on re-scan.
+Files are chunked into ~512-token segments (configurable), embedded by the active provider (local by default), and stored as vectors. Unchanged files (matched by SHA-256 hash) are skipped on re-scan.
### Supported Embedding Models
-- `text-embedding-3-small` (1536 dimensions) — default, faster, cheaper
+**Local (default, opt-out):**
+
+- `multilingual-e5-small` (384 dimensions) — bundled into the app, runs in-process on the CPU, ~100 languages. Works fully offline: no API key, no account, no network calls.
+
+**OpenAI (opt-in):** enable in Settings by adding an API key. Higher retrieval quality, at a per-request cost; sends text to `api.openai.com`.
+
+- `text-embedding-3-small` (1536 dimensions) — faster, cheaper
- `text-embedding-3-large` (3072 dimensions) — higher quality, more expensive
-Switching models in Settings re-indexes everything (vectors from different models are incompatible).
+Switching provider or model in Settings re-indexes everything (vectors from different models are incompatible).
### Privacy & Security
+- **No outbound network calls by default** — the local embedding model runs entirely on your machine, so with the default provider nothing ever leaves your computer.
- All data stays local in `~/.agent-memory/agent-memory.db`
-- The only outbound network call is to `api.openai.com` for embeddings
+- The only outbound network call is to `api.openai.com` for embeddings — and only if you opt into the OpenAI provider in Settings.
- The MCP HTTP server binds to `127.0.0.1` only (never exposed to the network)
- The stdio transport (Claude Desktop) uses process-level isolation — no network at all
@@ -48,9 +55,10 @@ Switching models in Settings re-indexes everything (vectors from different model
- Go 1.24+
- Wails v2 CLI (`go install github.com/wailsapp/wails/v2/cmd/wails@latest`)
- Node.js 18+ and npm (for the frontend build)
-- OpenAI API key
+- Network access on the first build — `make build` runs `make assets`, which downloads the pinned local model, tokenizer, and ONNX Runtime library (~150 MB, checksummed against `assets/manifest.json`) into the gitignored `assets/embedded/`. Cached after the first run.
+- OpenAI API key — optional, only needed if you opt into the OpenAI provider at runtime
- macOS, Linux, or Windows
-- CGo enabled (required for SQLite + sqlite-vec; default on macOS/Linux, may need `CC=gcc` on Windows)
+- CGo enabled (required for SQLite + sqlite-vec, and for the local embedder's native libraries; default on macOS/Linux, may need `CC=gcc` on Windows)
### Local Development
@@ -74,6 +82,8 @@ make test # run all Go tests
make clean # remove build artifacts
```
+`make build` depends on `make assets` (downloads the local model/tokenizer/ORT libraries, ~150 MB, on the first run) and builds with the `localembed` build tag plus the `CGO_LDFLAGS` needed to link the native libraries. The resulting binary is ~180 MB (it bundles the embedding model). `make test` runs the default, native-lib-free build — the `localembed`-tagged code is isolated so plain `go test ./...` needs no assets.
+
Or manually:
```bash
diff --git a/app.go b/app.go
index fd0d8b9..80d2163 100644
--- a/app.go
+++ b/app.go
@@ -10,9 +10,13 @@ import (
"log"
"os"
"path/filepath"
+ goruntime "runtime"
+ "strconv"
+ "github.com/borzou/vecstore/internal/chunker"
"github.com/borzou/vecstore/internal/domain"
"github.com/borzou/vecstore/internal/embeddings"
+ "github.com/borzou/vecstore/internal/embeddings/local"
"github.com/borzou/vecstore/internal/engine"
"github.com/borzou/vecstore/internal/mcp"
"github.com/borzou/vecstore/internal/store"
@@ -23,9 +27,59 @@ import (
// appInstance is a package-level reference so CGo tray callbacks can reach the app.
var appInstance *App
-// EmbedderFactory creates an Embedder for the given API key and model.
-// Injected by main.go so app.go doesn't depend on concrete embedder packages.
-type EmbedderFactory func(apiKey, model string) embeddings.Embedder
+// EmbedderFactory creates an Embedder for the given provider, API key and model.
+// Injected by main.go so app.go doesn't own provider-to-implementation wiring.
+type EmbedderFactory func(provider, apiKey, model string) (embeddings.Embedder, error)
+
+// resolveProvider delegates to the single shared policy in the embeddings
+// package (an explicit provider wins; else an OpenAI key implies openai; else
+// the default) — the engine and read-only search resolve through the same
+// function, so the rule cannot drift between processes.
+func resolveProvider(configuredProvider, apiKey string) string {
+ return embeddings.ResolveProvider(configuredProvider, apiKey)
+}
+
+// resolveModel returns the stored model if set, else the provider's default.
+func resolveModel(provider, storedModel string) string {
+ if storedModel != "" {
+ return storedModel
+ }
+ return embeddings.DefaultModel(provider)
+}
+
+// buildChunker constructs a chunker matched to the embedding provider, reusing
+// the stored chunk_size/chunk_overlap config. For the local provider it injects
+// the model's own tokenizer and clamps chunk size to the model's context window
+// so chunks are measured in the embedding model's tokens.
+func buildChunker(cfg *store.SQLiteStore, provider string, embedder embeddings.Embedder) (chunker.Chunker, error) {
+ var opts []chunker.Option
+ if sizeStr, _ := cfg.GetConfig("chunk_size"); sizeStr != "" {
+ if size, err := strconv.Atoi(sizeStr); err == nil {
+ opts = append(opts, chunker.WithChunkSize(size))
+ }
+ }
+ if overlapStr, _ := cfg.GetConfig("chunk_overlap"); overlapStr != "" {
+ if overlap, err := strconv.Atoi(overlapStr); err == nil {
+ opts = append(opts, chunker.WithOverlap(overlap))
+ }
+ }
+ if provider == embeddings.ProviderLocal {
+ tok, err := local.NewChunkerTokenizer(local.Config{})
+ if err != nil {
+ return nil, fmt.Errorf("local chunker tokenizer: %w", err)
+ }
+ // Budget below the model's hard limit: the embedder prepends the E5
+ // prefix and the tokenizer adds special tokens AFTER chunking, so a
+ // chunk cut exactly at MaxInputTokens overflows the model (512+prefix
+ // → the "512 by 516" ORT failure that silently dropped every
+ // multi-chunk file). Epic: effective chunk size ≈ 480.
+ opts = append(opts,
+ chunker.WithTokenizer(tok),
+ chunker.WithMaxInputTokens(embedder.MaxInputTokens()-local.EmbedTokenReserve),
+ )
+ }
+ return chunker.New(opts...)
+}
// App exposes methods to the Wails frontend.
type App struct {
@@ -63,6 +117,12 @@ func (a *App) shutdown(ctx context.Context) {
if err := a.mcpServer.Stop(); err != nil {
log.Printf("mcp server stop: %v", err)
}
+ // Stop before Close: cancel in-flight indexing and wait for it to reach a
+ // file boundary so the store never shuts down under an active write. With
+ // close=quit on Windows/Linux, quitting mid-scan is a routine event.
+ if err := a.engine.Stop(); err != nil {
+ log.Printf("engine stop: %v", err)
+ }
if err := a.engine.Close(); err != nil {
log.Printf("engine close: %v", err)
}
@@ -153,36 +213,84 @@ func (a *App) GetConfig(key string) string {
return val
}
+// currentProvider resolves the active embedding provider from config, applying
+// the same policy as the composition root.
+func (a *App) currentProvider() string {
+ provider, _ := a.store.GetConfig("embedding_provider")
+ apiKey, _ := a.store.GetConfig("openai_api_key")
+ return resolveProvider(provider, apiKey)
+}
+
// SetConfig writes a configuration value to the store.
-// If the embedding model changes, the embedder is swapped and the index is
-// reset — the virtual table must be recreated with the new dimension.
-// If the API key changes, the embedder is swapped so new requests use it.
+// - embedding_provider: persist, rebuild the embedder AND the matched chunker,
+// then reset the index (the vector table is recreated at the new dimension).
+// - embedding_model: only meaningful for the active provider; rebuild the
+// embedder and reset (dimension may change, e.g. OpenAI small↔large).
+// - openai_api_key: only re-swap the embedder when openai is the active provider.
func (a *App) SetConfig(key, value string) error {
switch key {
+ case "embedding_provider":
+ oldProvider := a.currentProvider()
+ if oldProvider == value {
+ return a.store.SetConfig(key, value)
+ }
+ log.Printf("[SetConfig] embedding provider changed %s → %s — swapping embedder+chunker and resetting index", oldProvider, value)
+ if err := a.store.SetConfig(key, value); err != nil {
+ return err
+ }
+ // Provider switch uses the provider's default model; the local model is
+ // fixed, and OpenAI users can adjust embedding_model afterward.
+ model := embeddings.DefaultModel(value)
+ apiKey, _ := a.store.GetConfig("openai_api_key")
+ embedder, err := a.newEmbedder(value, apiKey, model)
+ if err != nil {
+ return fmt.Errorf("build embedder for provider %s: %w", value, err)
+ }
+ newChunker, err := buildChunker(a.store, value, embedder)
+ if err != nil {
+ return fmt.Errorf("build chunker for provider %s: %w", value, err)
+ }
+ if err := a.store.SetConfig("embedding_model", model); err != nil {
+ return err
+ }
+ a.engine.SetEmbedder(embedder)
+ a.engine.SetChunker(newChunker)
+ return a.engine.Reset()
+
case "embedding_model":
+ provider := a.currentProvider()
oldModel, _ := a.store.GetConfig("embedding_model")
if oldModel == "" {
- oldModel = "text-embedding-3-small"
+ oldModel = embeddings.DefaultModel(provider)
}
- if oldModel != value {
- log.Printf("[SetConfig] embedding model changed %s → %s — resetting index", oldModel, value)
- if err := a.store.SetConfig(key, value); err != nil {
- return err
- }
- apiKey, _ := a.store.GetConfig("openai_api_key")
- a.engine.SetEmbedder(a.newEmbedder(apiKey, value))
- return a.engine.Reset()
+ if oldModel == value {
+ return nil
}
+ log.Printf("[SetConfig] embedding model changed %s → %s — resetting index", oldModel, value)
+ if err := a.store.SetConfig(key, value); err != nil {
+ return err
+ }
+ apiKey, _ := a.store.GetConfig("openai_api_key")
+ embedder, err := a.newEmbedder(provider, apiKey, value)
+ if err != nil {
+ return fmt.Errorf("build embedder: %w", err)
+ }
+ a.engine.SetEmbedder(embedder)
+ return a.engine.Reset()
case "openai_api_key":
if err := a.store.SetConfig(key, value); err != nil {
return err
}
- model, _ := a.store.GetConfig("embedding_model")
- if model == "" {
- model = "text-embedding-3-small"
+ // A key change only affects requests when openai is the active provider.
+ if a.currentProvider() == embeddings.ProviderOpenAI {
+ model := resolveModel(embeddings.ProviderOpenAI, a.GetConfig("embedding_model"))
+ embedder, err := a.newEmbedder(embeddings.ProviderOpenAI, value, model)
+ if err != nil {
+ return fmt.Errorf("build embedder: %w", err)
+ }
+ a.engine.SetEmbedder(embedder)
}
- a.engine.SetEmbedder(a.newEmbedder(value, model))
return nil
}
@@ -282,10 +390,28 @@ func generateToken() (string, error) {
return hex.EncodeToString(b), nil
}
-// defaultClaudeDesktopConfigPath returns the standard Claude Desktop config location.
+// defaultClaudeDesktopConfigPath returns the standard Claude Desktop config
+// location for the current OS. Claude Desktop reads a different path per
+// platform; writing the macOS path on Windows produces a config it never sees
+// (while still reporting success — the Phase 5 Windows smoke caught exactly that).
func defaultClaudeDesktopConfigPath() string {
- home, _ := os.UserHomeDir()
- return filepath.Join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json")
+ switch goruntime.GOOS {
+ case "windows":
+ if appData := os.Getenv("APPDATA"); appData != "" {
+ return filepath.Join(appData, "Claude", "claude_desktop_config.json")
+ }
+ home, _ := os.UserHomeDir()
+ return filepath.Join(home, "AppData", "Roaming", "Claude", "claude_desktop_config.json")
+ case "darwin":
+ home, _ := os.UserHomeDir()
+ return filepath.Join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json")
+ default: // linux
+ if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
+ return filepath.Join(xdg, "Claude", "claude_desktop_config.json")
+ }
+ home, _ := os.UserHomeDir()
+ return filepath.Join(home, ".config", "Claude", "claude_desktop_config.json")
+ }
}
// GetClaudeDesktopConfigPath returns the current config path (custom or default).
diff --git a/assets/manifest.json b/assets/manifest.json
new file mode 100644
index 0000000..50532cf
--- /dev/null
+++ b/assets/manifest.json
@@ -0,0 +1,136 @@
+{
+ "schema": 1,
+ "comment": "Pinned local-embedding artifacts (URLs + SHA-256). Weights/libs are NEVER committed; `make assets` downloads and checksum-verifies these into internal/embeddings/local/embedded/ (go:embed runtime assets) and internal/embeddings/local/lib/ (link-time static lib). For tarball artifacts, `sha256` is the checksum of the downloaded archive and `member_sha256` is the checksum of the extracted member. `dest` is relative to internal/embeddings/local/. Model + tokenizer entries are identical bytes on every platform (per-platform keyed for a uniform `make assets`). darwin-amd64's onnxruntime is our own source build (official mac-Intel prebuilts stopped at ORT 1.23) hosted in this repo's GitHub releases — see the ort-1.26.0-darwin-x64 release notes for the reproducible recipe. windows-amd64's tokenizers_static has no upstream prebuilt: it is built from Rust source per Documentation/windows-build.md and hosted in this repo's releases (tokenizers-1.27.0-windows-x64).",
+ "platforms": {
+ "darwin-arm64": {
+ "model": {
+ "url": "https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/onnx/model_quantized.onnx",
+ "sha256": "f80102d3f2a1229f387d3c81909990d8945513e347b0eab049f7de3c6f98c193",
+ "dest": "embedded/model_quantized.onnx"
+ },
+ "tokenizer": {
+ "url": "https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/tokenizer.json",
+ "sha256": "0b44a9d7b51c3c62626640cda0e2c2f70fdacdc25bbbd68038369d14ebdf4c39",
+ "dest": "embedded/tokenizer.json"
+ },
+ "onnxruntime": {
+ "url": "https://github.com/microsoft/onnxruntime/releases/download/v1.26.0/onnxruntime-osx-arm64-1.26.0.tgz",
+ "sha256": "7a1280bbb1701ea514f71828765237e7896e0f2e1cd332f1f70dbd5c3e33aca3",
+ "member": "onnxruntime-osx-arm64-1.26.0/lib/libonnxruntime.1.26.0.dylib",
+ "member_sha256": "30afadcfc3c704f7671f8430d6252956651c1972373901d2be629da2e6a4d8ee",
+ "dest": "embedded/libonnxruntime.1.26.0.dylib"
+ },
+ "tokenizers_static": {
+ "url": "https://github.com/daulet/tokenizers/releases/download/v1.27.0/libtokenizers.darwin-arm64.tar.gz",
+ "sha256": "fb84b8b2e349a5952767ffe80ccd862fc44084de47f3b0cc3f0b7c9d4e649cf7",
+ "member": "libtokenizers.a",
+ "member_sha256": "c91ae814afb8fe4f000099972208f60c3aa2d13899a7cd5a31fee2e9e2efbac7",
+ "dest": "lib/libtokenizers.a"
+ }
+ },
+ "windows-amd64": {
+ "model": {
+ "url": "https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/onnx/model_quantized.onnx",
+ "sha256": "f80102d3f2a1229f387d3c81909990d8945513e347b0eab049f7de3c6f98c193",
+ "dest": "embedded/model_quantized.onnx"
+ },
+ "tokenizer": {
+ "url": "https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/tokenizer.json",
+ "sha256": "0b44a9d7b51c3c62626640cda0e2c2f70fdacdc25bbbd68038369d14ebdf4c39",
+ "dest": "embedded/tokenizer.json"
+ },
+ "onnxruntime": {
+ "url": "https://github.com/microsoft/onnxruntime/releases/download/v1.26.0/onnxruntime-win-x64-1.26.0.zip",
+ "sha256": "6ebe99b5564bf4d029b6e93eac9ff423682b6212eade769e9ca3f685eaf500b4",
+ "member": "onnxruntime-win-x64-1.26.0/lib/onnxruntime.dll",
+ "member_sha256": "b2ba7ca16e0e4fe71ad5148744ab885a2f5809e52a0c3de4d9ba3853a03977f9",
+ "dest": "embedded/onnxruntime.dll"
+ },
+ "tokenizers_static": {
+ "url": "https://github.com/unitedeffectslabs/agent-memory/releases/download/tokenizers-1.27.0-windows-x64/libtokenizers.windows-x86_64.tar.gz",
+ "sha256": "882f520174a6cb14dcf4dca559375d65915a69a075bd85c22e40463bc0b466a8",
+ "member": "libtokenizers.a",
+ "member_sha256": "5e0815434a9d9eea40638ae9303f239c85059868673dc7e2a5a1eee72a9ad692",
+ "dest": "lib/libtokenizers.a"
+ }
+ },
+ "darwin-amd64": {
+ "model": {
+ "url": "https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/onnx/model_quantized.onnx",
+ "sha256": "f80102d3f2a1229f387d3c81909990d8945513e347b0eab049f7de3c6f98c193",
+ "dest": "embedded/model_quantized.onnx"
+ },
+ "tokenizer": {
+ "url": "https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/tokenizer.json",
+ "sha256": "0b44a9d7b51c3c62626640cda0e2c2f70fdacdc25bbbd68038369d14ebdf4c39",
+ "dest": "embedded/tokenizer.json"
+ },
+ "onnxruntime": {
+ "url": "https://github.com/unitedeffectslabs/agent-memory/releases/download/ort-1.26.0-darwin-x64/onnxruntime-osx-x86_64-1.26.0-uelabs.tgz",
+ "sha256": "a78e1d8c0183833bf21f23440b0b36ae3cd4f99dbfac470249ec41768493f2b8",
+ "member": "libonnxruntime.1.26.0.dylib",
+ "member_sha256": "e29a80a09a41def826f3b31519978944a6ab5c3dc0613124a1863ff49eefce9b",
+ "dest": "embedded/libonnxruntime.1.26.0.dylib"
+ },
+ "tokenizers_static": {
+ "url": "https://github.com/daulet/tokenizers/releases/download/v1.27.0/libtokenizers.darwin-x86_64.tar.gz",
+ "sha256": "6239efe5a81fde8089ef2df8ae710366542b4e5deab6d8ecb74d7d1862db2ddb",
+ "member": "libtokenizers.a",
+ "member_sha256": "c8b1f9ac68193f185a0d09ffc2e8d8892ebcfc71aad6318ffdbd6dce11d05460",
+ "dest": "lib/libtokenizers.a"
+ }
+ },
+ "linux-arm64": {
+ "model": {
+ "url": "https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/onnx/model_quantized.onnx",
+ "sha256": "f80102d3f2a1229f387d3c81909990d8945513e347b0eab049f7de3c6f98c193",
+ "dest": "embedded/model_quantized.onnx"
+ },
+ "tokenizer": {
+ "url": "https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/tokenizer.json",
+ "sha256": "0b44a9d7b51c3c62626640cda0e2c2f70fdacdc25bbbd68038369d14ebdf4c39",
+ "dest": "embedded/tokenizer.json"
+ },
+ "onnxruntime": {
+ "url": "https://github.com/microsoft/onnxruntime/releases/download/v1.26.0/onnxruntime-linux-aarch64-1.26.0.tgz",
+ "sha256": "34ff1c2d0f12e2cf3d33a0c5f82e39792e1d581fbd6968fd7c30d173654be01a",
+ "member": "onnxruntime-linux-aarch64-1.26.0/lib/libonnxruntime.so.1.26.0",
+ "member_sha256": "115ecb838e703d390262b8b4d07d5248e6693c67658d4c98c48f94905ab27af4",
+ "dest": "embedded/libonnxruntime.so.1.26.0"
+ },
+ "tokenizers_static": {
+ "url": "https://github.com/daulet/tokenizers/releases/download/v1.27.0/libtokenizers.linux-arm64.tar.gz",
+ "sha256": "e96545ad05930c26f51f63d932ee6d3bbd32bbed149e102c5290d587a2293067",
+ "member": "libtokenizers.a",
+ "member_sha256": "aa4d3a7d28023e439fa7a645fb606195acafb5c33c8cd01928ed1aacfc4c074e",
+ "dest": "lib/libtokenizers.a"
+ }
+ },
+ "linux-amd64": {
+ "model": {
+ "url": "https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/onnx/model_quantized.onnx",
+ "sha256": "f80102d3f2a1229f387d3c81909990d8945513e347b0eab049f7de3c6f98c193",
+ "dest": "embedded/model_quantized.onnx"
+ },
+ "tokenizer": {
+ "url": "https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/tokenizer.json",
+ "sha256": "0b44a9d7b51c3c62626640cda0e2c2f70fdacdc25bbbd68038369d14ebdf4c39",
+ "dest": "embedded/tokenizer.json"
+ },
+ "onnxruntime": {
+ "url": "https://github.com/microsoft/onnxruntime/releases/download/v1.26.0/onnxruntime-linux-x64-1.26.0.tgz",
+ "sha256": "1254da24fb389cf39dc0ff3451ab48301740ffbfcbaf646849df92f80ee92c57",
+ "member": "onnxruntime-linux-x64-1.26.0/lib/libonnxruntime.so.1.26.0",
+ "member_sha256": "5bd5bedf736fc501692435d0ec4f6e8b2bdf48cd30af8e6d00d61b3ddc9a7ab8",
+ "dest": "embedded/libonnxruntime.so.1.26.0"
+ },
+ "tokenizers_static": {
+ "url": "https://github.com/daulet/tokenizers/releases/download/v1.27.0/libtokenizers.linux-amd64.tar.gz",
+ "sha256": "72556cdca798dd4ea7cdaba308e5f0d68a8cb93b67c96edf485b7a0edd7b07f4",
+ "member": "libtokenizers.a",
+ "member_sha256": "e6862b31745bb7d07980fcee70e49cd3b4318097609180f5d2d3fb394f305d50",
+ "dest": "lib/libtokenizers.a"
+ }
+ }
+ }
+}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index c39b3fa..cd9433e 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -22,8 +22,8 @@ export default function App() {
async function checkOnboarding() {
try {
- const apiKey = await window.go.main.App.GetConfig('openai_api_key')
- setNeedsOnboarding(!apiKey || apiKey.trim() === '')
+ const complete = await window.go.main.App.GetConfig('onboarding_complete')
+ setNeedsOnboarding(complete !== 'true')
} catch {
setNeedsOnboarding(true)
}
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
index 5a6a829..1c0405f 100644
--- a/frontend/src/pages/Dashboard.jsx
+++ b/frontend/src/pages/Dashboard.jsx
@@ -84,7 +84,11 @@ export default function Dashboard() {
-
+
diff --git a/frontend/src/pages/Onboarding.jsx b/frontend/src/pages/Onboarding.jsx
index e0aa40d..ca24233 100644
--- a/frontend/src/pages/Onboarding.jsx
+++ b/frontend/src/pages/Onboarding.jsx
@@ -1,21 +1,35 @@
import React, { useState } from 'react'
+const OPENAI_MODELS = ['text-embedding-3-small', 'text-embedding-3-large']
+
export default function Onboarding({ onComplete }) {
const [step, setStep] = useState(0)
+ const [useOpenAI, setUseOpenAI] = useState(false)
const [apiKey, setApiKey] = useState('')
+ const [openaiModel, setOpenaiModel] = useState('text-embedding-3-small')
const [dirs, setDirs] = useState([])
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
- async function handleSaveApiKey() {
- if (!apiKey.trim()) { setError('API key is required.'); return }
- setSaving(true); setError('')
- try {
- await window.go.main.App.SetConfig('openai_api_key', apiKey.trim())
- setStep(2)
- } catch (e) {
- setError('Failed to save: ' + (e?.message || String(e)))
- } finally { setSaving(false) }
+ // Welcome → directories. If the user opted into OpenAI, persist that provider
+ // choice here; otherwise the backend keeps the default (local) provider.
+ async function handleStartFromWelcome() {
+ setError('')
+ if (useOpenAI) {
+ if (!apiKey.trim()) { setError('API key is required to use OpenAI.'); return }
+ setSaving(true)
+ try {
+ await window.go.main.App.SetConfig('embedding_provider', 'openai')
+ await window.go.main.App.SetConfig('openai_api_key', apiKey.trim())
+ await window.go.main.App.SetConfig('embedding_model', openaiModel)
+ } catch (e) {
+ setError('Failed to save OpenAI settings: ' + (e?.message || String(e)))
+ setSaving(false)
+ return
+ }
+ setSaving(false)
+ }
+ setStep(1)
}
async function handleChooseFolder() {
@@ -41,17 +55,24 @@ export default function Onboarding({ onComplete }) {
for (const path of dirs) {
await window.go.main.App.RegisterDirectory(path)
}
- setStep(3)
+ setStep(2)
} catch (e) {
setError('Failed to save directories: ' + (e?.message || String(e)))
}
}
- function handleFinish() {
+ async function handleFinish() {
+ setError('')
+ try {
+ await window.go.main.App.SetConfig('onboarding_complete', 'true')
+ } catch (e) {
+ setError('Failed to finish setup: ' + (e?.message || String(e)))
+ return
+ }
onComplete()
}
- const totalSteps = 4
+ const totalSteps = 3
return (
- Agent Memory watches your directories, creates embeddings with OpenAI,
- and stores vectors locally. It provides semantic search via MCP for
- Claude and other AI assistants.
+ Agent Memory watches your directories, creates embeddings, and stores
+ vectors locally. It provides semantic search via MCP for Claude and
+ other AI assistants.
-
-
-
- >
- )}
-
- {step === 1 && (
- <>
-
OpenAI API Key
- Enter your OpenAI API key to enable embeddings.
- It's stored locally in the database — never sent anywhere except OpenAI.
+ By default it runs a local, on-device embedding model — private, no
+ API key, and fully offline. You can switch to OpenAI anytime in Settings.