Local embeddings: on-device model as the default — complete epic (all platforms, field-tested) - #2
Local embeddings: on-device model as the default — complete epic (all platforms, field-tested)#2NestorCanales wants to merge 47 commits into
Conversation
Phase 0 gate passed on macOS arm64: local stack (ORT 1.26.0 + daulet/tokenizers v1.27.0 + Xenova mE5-small int8) produces valid 384-dim unit-norm embeddings. mE5-small chosen as committed default; Granite-97m logged as future upgrade. Records measurements + 3 findings that adjust the plan (token_type_ids, threshold miscalibration, RSS budget). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Captures review + Phase 0 concerns, each tagged to the phase that fixes it (chunker swap, read-only dim mismatch, onboarding migration, centralize defaults/threshold, token_type_ids, RSS, go:embed build tags). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
extractPDFPassthrough embeds raw PDF bytes instead of extracted text; PDF semantic search is effectively broken. Separate track from the epic. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ker tokenizer seam Mechanical, behavior-preserving interface change (no local model yet): - Embedder: Embed -> EmbedDocuments + EmbedQuery; add MaxInputTokens (OpenAI=0) - OpenAIEmbedder implements the new interface (EmbedQuery delegates; behavior identical) - chunker: add Tokenizer seam; tiktoken becomes the default adapter; WithTokenizer / WithMaxInputTokens options; ChunkText clamps to maxTokens (no-op at default) - engine/readonly Search use EmbedQuery; indexing uses EmbedDocuments - MockEmbedder updated with EmbedQuery fallback to minimize test churn OpenAI index/search path unchanged. go build + go vet clean; go test ./... green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update New-files table + note the 3 Phase 2 decisions (CGo build-tag isolation, HF adapter relocated to local/, dev-path asset sourcing) per the 'update the tables, don't silently diverge' rule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…olated) New internal/embeddings/local package implementing embeddings.Embedder with in-process CPU inference (ONNX Runtime + multilingual-e5-small): - Pure-Go core (untagged): prefixes, sub-batching, buildInputs (3 int64 inputs incl. zero token_type_ids), mean-pool, L2-normalize, lazy sync.Once init, onnxSession/tokenizerBackend interface seams; assets resolve + checksummed atomic extract; unit-tested with injected fakes (no native libs). - Real ONNX/tokenizer behind //go:build localembed (session_ort.go, tokenizer_hf.go incl. chunker.Tokenizer adapter); !localembed stubs keep the default build green. - go.mod: + yalue/onnxruntime_go v1.31.0, + daulet/tokenizers v1.27.0 (indirect until Phase 3 wires main.go; not tidied). Default go build/vet/test ./... green and native-lib-free. Tagged integration test reproduces Phase 0 ordering (related 0.134 < cross-lingual 0.170 < unrelated 0.283 cosine distance). No changes to main.go/app/store/engine/chunker/frontend. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Method-level plan for Phase 3 wiring/config/switching, split into 3 sub-PRs (3a build/bundling, 3b config/switching, 3c safety), with the 3 architecture decisions flagged for Bo's sign-off. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build & distribution for the local provider (no running-app behavior change; main.go untouched, so the app still uses OpenAI until 3b): - Makefile: 'assets' target downloads + SHA-256-verifies model/tokenizer/ORT-lib into gitignored embedded/ + lib/; 'build' depends on it and uses -tags localembed + CGO_LDFLAGS. dev/test/clean unchanged (test stays no-tag/lib-free). - assets/manifest.json (in git): pinned URLs + checksums (darwin-arm64). - .gitignore: embedded/ + lib/ (weights/libs never committed). - local: assets_embed.go (//go:build localembed) go:embeds the 3 runtime assets and extracts via extractAndVerify; assets_embed_stub.go keeps the default build green with no files present. resolveAssets priority: dev override -> embedded. Deviation (documented in epic): embedded assets live under the local package dir (internal/embeddings/local/embedded/), not repo-root assets/, because go:embed can't reach parent dirs. Default go build/vet/test ./... green + lib-free. make assets + make build work; tagged test runs real inference off the embedded-then-extracted assets. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…chable Wire the local provider in as the default and make provider switching work: - embeddings/defaults.go: central DefaultProvider (local) / DefaultModel / DefaultDimension — removes scattered model/1536 hardcodes (Open Concern #4). - main.go: composition-root reorder — peek config (read-only) -> resolve provider (SAFE DEFAULT: configured > existing key=openai > local) -> resolve model+dimension -> open store at that dimension -> build embedder + matched chunker. Generalized EmbedderFactory (provider,apiKey,model). stdio stays lazy. - store: NewSQLiteStore(dbPath, dim); migrate creates the vec table at dim for fresh DBs; existing DBs preserved via IF NOT EXISTS (existing OpenAI users keep their 1536 table — SAFE DEFAULT for upgrades). - engine.SetChunker; app.SetConfig gains embedding_provider (swap embedder + chunker + Reset) and provider-aware model/key handling (Open Concern #1). - local.NewChunkerTokenizer (tagged) + stub so main can wire the model tokenizer into the chunker. Makefile dev now uses -tags localembed too. - go.mod: onnxruntime_go + daulet/tokenizers promoted to direct (main now imports local, which uses them under the localembed tag). Default go build/vet/test ./... green + lib-free. make build works; binary ~205MB (model baked in). New tests cover provider resolution + switch (swap+Reset, -race). Deferred to 3c: stdio dimension guard (#2), Stats layering, onboarding migration (#3). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…threshold Finishes Phase 3 (the safety net): - Stats reports the active provider + model from config (IndexStats.Provider); removed the hardcoded text-embedding-3-small fallback (store stays free of an embeddings import). MCP index_status advertises the provider; threshold param descriptions say the default is provider-dependent. - engine persists embedding_dimension on Reset + after initialScan; readonly Search guards against a dimension mismatch and returns an actionable 'rebuild the index' error instead of hitting sqlite-vec with a bad vector (Open Concern #2). - embeddings.DefaultThreshold(provider): openai 1.5 (unchanged), local 0.6 from the Phase 0 cosine-distance ranges (tunable); engine.Search + readonly.Search use it instead of a hardcoded 1.5, provider read from config (Open Concern #5). Default go build/vet/test ./... green + lib-free; make build works (205MB). New/updated tests: DefaultThreshold, Stats provider reporting, readonly dimension-mismatch + provider-aware threshold. Deferred to Phase 4: onboarding migration (#3) + GUI fingerprint-mismatch surface. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Regarding this question: "One thing I'd love your input on: for existing OpenAI users upgrading, I chose to keep them on OpenAI (preserving their index) rather than switch them to local and prompt a re-index. I took the safer route, but I may have read the fingerprint section differently than you intended — very happy to change it if you'd prefer." I would say, this isn't a widely used product and its not a SaaS solution, its a local desktop app. So we don't need to think about migration. I don't know what preserving the index from OpenAI and then adding the new one does. If it works fine, great! But if not, we may just want to reindex and thats ok. The main goal is to get away form OpenAI as the default though. |
|
The isolation to /local makes sense and organizes things nicely. |
Make the app usable on the local model with no API key, and expose the
provider choice in the UI:
- Onboarding: no key required; welcome -> add folders -> done (local default);
optional 'use OpenAI instead' affordance; sets onboarding_complete on finish.
- App.jsx: gate onboarding on onboarding_complete, not the OpenAI key.
- Settings: Embedding Provider section (Local default vs OpenAI; key/model shown
only for OpenAI); re-index confirm on switch; Outbound is provider-aware
('none (fully offline)' for local).
- Dashboard: show provider + model (e.g. 'local · multilingual-e5-small').
- main.go: backfill onboarding_complete for upgraded installs (dirs or key
present) so existing users aren't re-onboarded.
Default go build/vet/test ./... green; make build succeeds; frontend builds clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reframe the docs for the new default across README/FEATURES/ARCHITECTURE/ CLAUDE.md/ROADMAP: - Works out of the box with the bundled local model — no API key, no network by default; onboarding is welcome -> add folders -> done. - Lead the privacy story with 'no outbound network calls by default'. - OpenAI reframed as an opt-in provider (Settings), not the default/required one. - Document make assets (~150MB first build), the localembed build tag, ~180MB binary, and the new internal/embeddings/local package + asset pipeline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks Bo! Two quick things. On the migration question: got it, I'll keep it simple. Local stays the default, and if an index ever mismatches we'll just re-index. No heavy migration logic. On timing: since your comment I finished Phase 4 (keyless onboarding, a Local/OpenAI toggle in Settings, and updated docs), so this PR is now the full macOS version, Phases 0 through 4. It's working and tested end to end through Claude Desktop, so the interfaces and UI are all in place. Probably a good point for your architectural pass whenever you get a chance. I'll do the cross-platform work (Phase 5) as a separate PR. Also bumped the PR title to reflect 0 through 4. |
Implements epic Open Concern #8: one binary can embed only one platform's ONNX Runtime library, so the ORT go:embed moves out of the shared assets_embed.go into per-platform assets_embed_<GOOS>_<GOARCH>.go files (darwin-arm64 first; each defines embeddedORTLib + ortLibFile). Model and tokenizer stay in the shared embed — identical bytes on every platform. Each later Phase 5 platform lands as one sibling file + manifest entries. Also, prerequisites for building on Linux at all: - Makefile: portable SHA256 var (sha256sum on Linux, shasum -a 256 on mac) - dylibCandidates: add the versioned libonnxruntime.so.1.26.0 the official Linux tarball actually ships - tripwire test: ortLibFile must be a name findDylib recognizes No behavior change on darwin-arm64: untagged tests green, tagged integration test passes (Phase 0 distance ordering reproduced), packaged app re-extracts embedded assets and answers an MCP search. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n-guard tray Adds linux-arm64 and linux-amd64 to assets/manifest.json (official ORT 1.26.0 + libtokenizers 1.27.0 artifacts, archive- and member-pinned by SHA-256) and one sibling embed file — both Linux arches ship the same versioned .so name, so a single localembed && linux file covers them. tray.go's Cocoa CGo preamble had no build constraint, which made every non-macOS build impossible; it is now //go:build darwin with a no-op tray_stub.go elsewhere (CLAUDE.md already declared tray macOS-only — epic's Explicitly-unchanged table updated per the deviation rule). First-ever Linux test run also surfaced a pre-existing watcher bug (inotify CREATE swallowed by the replace-not-merge debouncer); it is documented in Documentation/Bugs/fswatcher-create-event-swallowed-linux.md and deliberately NOT fixed here (out of epic scope, no user impact today). Verified on linux-arm64 in Docker (golang:1.26-bookworm): make assets checksums pass, test suite green (watcher known-fail excepted), real int8 inference matches Phase 0 ordering (0.140 < 0.171 < 0.286), full Wails build (webkit2_41), and an offline --network none MCP search returned correct semantic matches from a macOS-indexed DB. linux-amd64 artifacts are pinned + checksum-verified; runtime smoke pending an x64 environment. macOS regression-checked: build + tests green with the tray guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…wallowed On Linux, writing a new file emits CREATE then WRITE as separate inotify events within milliseconds. The per-path debouncer replaced the pending callback on each event, so the WRITE cancelled the CREATE and the handler reported a modification — OnCreate never fired for any newly created file (TestOnCreate failed deterministically on Linux; macOS's event coalescing masked the bug). No user impact today because engine.OnCreate and OnModify both index the file, but any future divergence would break Linux silently. The debouncer now accumulates fsnotify ops per path (OR) and classifies the merged set when the timer fires, with precedence Remove/Rename > Create > Write/Chmod. Single-event behavior is unchanged. Verified: watcher suite passes 3/3 on macOS and 3/3 on Linux arm64 (golang:1.26-bookworm container); previously 3/3 FAIL on Linux. Found during local-embeddings Phase 5 (first test run on Linux); report in Documentation/Bugs/fswatcher-create-event-swallowed-linux.md (PR #4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…extraction Adds darwin-amd64 to assets/manifest.json. The ONNX Runtime dylib is our own source build of the official v1.26.0 tag (Microsoft's mac-Intel prebuilts stopped at 1.23), cross-compiled from arm64 and published as this repo's ort-1.26.0-darwin-x64 release with a reproducible recipe in its notes; tokenizers ships an official darwin-x86_64 prebuilt. Both are archive- and member-pinned by SHA-256 like every artifact. assets_embed_darwin_arm64.go becomes assets_embed_darwin.go (localembed && darwin) — both mac arches share the dylib name, mirroring the Linux single-file pattern. New make build-darwin-amd64 target cross-builds the Intel app from an arm64 Mac. Fixes an arch-collision bug this work exposed: the runtime extraction dir was keyed only by provider/model fingerprint, so an Intel build (or a home dir migrated from an Intel Mac) left an x86_64 dylib that poisoned the arm64 build's dlopen. Extraction dirs are now namespaced <GOOS>-<GOARCH>-<fingerprint>; no shipped users affected (the scheme exists only in this unmerged PR stack). Verified on the arm64 dev Mac via Rosetta 2: integration test passes as an x86_64 binary (cosine ordering 0.140 < 0.171 < 0.288, matching arm64 and Linux); GOARCH=amd64 make assets downloads + verifies from the repo release; the full x86_64 Wails app builds, extracts to its own arch dir (coexisting with the arm64 dir), and answers an MCP search. Native arm64 build re-verified after restoring assets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… assets Adds windows-amd64 to assets/manifest.json: the official Microsoft ONNX Runtime DLL (onnxruntime-win-x64-1.26.0.zip, member-pinned) and our source-built libtokenizers.a. daulet/tokenizers ships no Windows prebuilt, so it is built from the v1.27.0 tag with the GNU Rust toolchain (must match MinGW gcc that CGo links with) and hosted in this repo's releases (tokenizers-1.27.0-windows-x64), SHA-256 pinned like every artifact. - assets_embed_windows.go: per-platform go:embed of onnxruntime.dll. - Makefile: extract .zip archives (unzip, else Windows System32 tar) so make assets works on Windows — tars stay the path for the others. - Documentation/windows-build.md: full build + artifact-provenance recipe (toolchain versions, the GNU-must-match-MinGW constraint, the libtokenizers_ffi.a -> libtokenizers.a rename in the v1.27.0 layout). Tokenizer lib compiled on Windows x64 (rustc 1.97.1 GNU, Go 1.26.5, MinGW gcc 16.1.0). App build + on-device verification run next on the PC. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ilt tokenizer The Windows libtokenizers.a (Rust std, GNU toolchain) references Nt*/Rtl*, Winsock and crypto syscalls that MinGW does not link by default, so the Windows `make build` failed with "undefined reference to NtCreateFile" etc. Makefile LINK_LIBS now appends -lntdll -lws2_32 -lbcrypt -luserenv -ladvapi32 -lkernel32 -lncrypt when GOOS=windows; empty on macOS/Linux (verified LINK_LIBS = -ltokenizers there), so those builds are unchanged. Observed and resolved during the first on-device Windows build; the integration test then linked and passed (cosine ordering 0.140 < 0.171 < 0.286, matching every other platform). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dropped) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…/10), observations recorded Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ent recorded Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…passes, status Complete-pending-review Orphan grep: no old Embed() callers; all model/dimension literals confined to the OpenAI provider path; store's documented dim<=0 fallback kept. Architecture: inward-only imports verified package-by-package, wiring in main.go, mocks for all domain interfaces, thin delivery layer. Formal pass: vet + untagged suite + tagged integration (incl. large-doc regression) + make build + stdio-mode semantic search, all green. One stale comment fixed (SearchOptions.Threshold → provider-aware DefaultThreshold). Final measured numbers recorded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Conflict resolution: epic main.go imports gained "runtime" only (strconv moved to app.go in the epic); the merged visibility-fix regression test updated to the epic's renamed mock field (EmbedFn -> EmbedDocumentsFn). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
theBoEffect
left a comment
There was a problem hiding this comment.
Excellent work on this epic — the review found the architecture discipline, build-tag isolation, asset pipeline, and inference math all solid, and everything verified locally on this end: full untagged suite green, tagged integration suite (real model, incl. the large-document regression) green, go vet clean, make build clean, GUI launches with the keyless local default, and a real offline semantic search through stdio MCP works end-to-end. The field-testing rigor shows.
The inline comments below are what I'd like addressed before merge. The first (shutdown during scan) is the one true blocker; the rest are small. One additional nit that has no diff line to anchor to: an empty search result set serializes as null — [] would be kinder to strict JSON clients.
One more thing — a new epic to define (not build) in this PR
The close=quit fix you made for Windows/Linux is the right triage for the zombie-instance bug, but it leaves those platforms with a real product gap: the app only watches and indexes while the window is open. On macOS the tray keeps us resident in the background; on Windows/Linux, closing the window now stops watching entirely (stdio search keeps working, but the index goes stale until the next launch). Windows has a first-class notification-area/tray pattern for exactly this — the limitation is only that our tray.go is darwin-only Objective-C.
I'd like you to add an epic definition to this PR — just the document, not the implementation — and take it on as your next piece of work after this merges. Suggested name: background-presence (Documentation/Epics/background-presence.md). Rough scope to capture in it:
- Cross-platform tray/notification-area icon (likely
fyne-io/systray; evaluate its message-loop integration with Wails v2) with Show/Quit, restoring hide-on-close on platforms that have it - Start-on-login option (per-OS: Login Items / registry Run key / XDG autostart)
- Linux reality check: tray support varies by desktop environment (appindicator vs legacy); document what we target and what degrades to close=quit
- Single-instance guard so the zombie-stacking class of bug is structurally impossible regardless of tray state
- Keep the current close=quit as the documented fallback wherever a tray isn't available
Follow the same epic conventions as local-embeddings (Execution Notes, phase gates, record deviations in the doc).
🤖 Review drafted with Claude Code
| // quit it — relaunches then stack zombie instances that contend for | ||
| // the single-writer SQLite DB (observed on Windows: six concurrent | ||
| // instances). Close = quit everywhere except macOS. | ||
| HideWindowOnClose: runtime.GOOS == "darwin", |
There was a problem hiding this comment.
Blocking: shutdown during the initial scan can silently orphan files.
This change is correct (hide-with-no-tray left unreachable zombie instances — good catch in the field test), but it makes "app quits while initialScan is running" a routine event on Windows/Linux, and the shutdown path isn't safe against it:
app.shutdowncallsengine.Close()but neverengine.Stop(), sostopChis never closed and the scan goroutine keeps indexing while the store shuts down under it.IndexFilesteps 9–11 (remove old chunks → upsert file row with the new hash → insert chunks) are three separate implicit transactions. If the process dies between steps 10 and 11, the DB permanently records the file as indexed at the current hash with zero chunks.- The hash short-circuit (
engine.go:227) then skips that file on every future scan — silently unsearchable forever, no error, no log row. For a re-indexed file it's worse: step 9 already deleted the old chunks, so previously-searchable content vanishes.
Two-part fix, please: call engine.Stop() before engine.Close() in app.shutdown (orderly cancel at a file boundary), and wrap steps 9–11 in a single transaction so even a mid-file process death leaves the file either fully indexed or untouched-and-retryable. The transaction is the part that actually guarantees correctness (this race pre-dates your PR via macOS tray-Quit; your change just promotes it from rare to routine, so now's the time).
| if indexErr := eng.IndexFile(p); indexErr != nil { | ||
| errored++ | ||
| log.Printf("engine: initial scan index %s: %v", p, indexErr) | ||
| eng.logActivity(p, "error", fmt.Sprintf("index: %v", indexErr)) |
There was a problem hiding this comment.
Surfacing index failures in the activity log is a real improvement — but as written the log grows without bound and this line self-amplifies.
A failed file never persists a hash (that only happens on success at step 10), so every launch re-runs the pipeline and appends the identical error row again — your own field test's 79 failing files would mean ~79 new rows per launch, forever. Nothing prunes activity_log except Reset(), and the Log page polls ListLogEntries every 3s, which opens with SELECT COUNT(*) over the whole table on the MaxOpenConns=1 connection — so the growing table is paid for continuously, in contention with indexing writes.
Requested: (a) retention on the table — a TTL prune and/or row cap, applied at startup or on write; (b) dedupe the self-feeding source — log a failure only on first occurrence or state change per path (or update a last-seen timestamp instead of appending).
| } | ||
| if indexErr := eng.IndexFile(p); indexErr != nil { | ||
| log.Printf("engine: index %s: %v", p, indexErr) | ||
| eng.logActivity(p, "error", fmt.Sprintf("index: %v", indexErr)) |
There was a problem hiding this comment.
Extract failures double-log, and this logging is copy-pasted at four call sites.
IndexFile already logs extraction failures internally (engine.go:234) and then returns the error — so this line (and its three siblings at 626, 731, 748) logs the same failure a second time: two Log rows per corrupt PDF/docx, the most common real-world failure class, while every other failure class gets one. And because the logging lives in the callers, any future IndexFile caller silently regresses to stderr-only.
Suggest logging the error exactly once inside IndexFile (or an indexAndLog wrapper), deleting the four caller-side copies and the line-234 special case.
| // event, the failure must land in the activity log (the Log page), not just | ||
| // the invisible process stderr. Found when an embedding bug silently dropped | ||
| // 79 of 105 real-vault files with zero user-visible signal. | ||
| func TestIndexErrorsAreLoggedToActivityLog(t *testing.T) { |
There was a problem hiding this comment.
This regression test never exercises initialScan — the exact path of the motivating incident.
The Windows field-test failure this fixes ("79 of 105 files dropped with zero signal") happened during the startup scan, but the test only covers other callers — deleting the logActivity line from initialScan leaves the suite green. Coverage is cheap from here: stub ListDirectoriesFn to return a temp dir (as done elsewhere in this file) and call eng.initialScan() directly — it's synchronous when called directly.
| // dispatch classifies a merged op set. Precedence: a removal ends the story | ||
| // regardless of what preceded it; a creation outranks the writes that filled | ||
| // the new file with content. | ||
| func (fw *FSWatcher) dispatch(path string, op fsnotify.Op) { |
There was a problem hiding this comment.
The merge fix is correct and well-reasoned — but its test coverage is platform-dependent.
TestOnCreate catches a regression only on OSes that emit the CREATE-then-WRITE double event; on macOS it passes with or without this fix, so the primary dev environment can't detect a regression. Please add a small deterministic test that drives debounce directly with a Create followed by a Write for the same path and asserts OnCreate (not OnModify) fires — that pins the behavior on every platform. Worth covering the delete-wins precedence the same way.
| // embedding model than the one this read-only process is configured with. | ||
| if dimStr, _ := ro.store.GetConfig("embedding_dimension"); dimStr != "" { | ||
| if indexDim, convErr := strconv.Atoi(dimStr); convErr == nil && indexDim != ro.embedder.Dimensions() { | ||
| return nil, fmt.Errorf("index was built with a different embedding model (dim %d) than the active provider (dim %d) — reopen the GUI app to rebuild the index", indexDim, ro.embedder.Dimensions()) |
There was a problem hiding this comment.
Unrecorded deviation from the epic: dimension-only guard instead of the specified fingerprint.
The epic (Phase 3c table) specifies an embedding_fingerprint (provider:model:dimensions) written per index run and checked at GUI startup; what shipped is embedding_dimension, checked only here in the read-only path. The simplification is defensible today — the two providers happen to differ in dimension, and provider switches always go through Reset() — but dimension can't detect a same-width model swap, and our own named upgrade candidate (Granite 97m) is also 384-dim. The day the default local model changes, this guard is blind and mixed vectors return silently garbage-ranked results — exactly what the fingerprint was designed to catch.
Requested (cheap): store the full fingerprint string in this same config slot — identical plumbing, strictly more information — and record the decision in the epic doc either way, per its own "update the tables, don't silently diverge" rule (Phases 2 and 5 both did this well).
|
|
||
| // setupTray is a no-op on platforms without the macOS status-bar integration | ||
| // (tray.go is darwin-only Objective-C via CGo). Returns a no-op cleanup func. | ||
| func (a *App) setupTray() func() { |
There was a problem hiding this comment.
Polish (non-blocking): main.go's HideWindowOnClose: runtime.GOOS == "darwin" re-derives the "has tray" fact that the build tags on tray.go/tray_stub.go already own — two places that must agree with nothing enforcing it. A const hasTray = true/false declared in each tagged file and used as HideWindowOnClose: hasTray makes drift impossible (and drops main.go's only use of the runtime import). This also sets up the background-presence epic cleanly — the tray implementation becomes the single owner of that fact per platform.
|
Btw, try the /code-review skill next time. Thats what I used :) |
Second epic to define (again: definition only, not built in this PR) — metadata filters for MCP searchAlongside What it does: the MCP User-set constraints to record in the epic (settled, don't relitigate):
Current-state notes for your research section (verified against this branch):
Follow the same epic conventions (Execution Notes, phase gates with Verify, record deviations in the doc). 🤖 Drafted with Claude Code |
Field confirmation of your PDF passthrough bug — please schedule the fixBo hit Good find in Phase 0 — and now that the local model makes indexing free, every one of these burns CPU to embed noise and pollutes search results. Please pick this up as a proper fix task (separate PR is fine, per your report's own scoping): real PDF text extraction via a pure-Go library, plus a unit test with a small sample PDF. One trap your report doesn't cover — the fix must force re-extraction. Priority-wise: slot it with Bo, but it's likely ahead of the epic queue — it's small, and it's a correctness hole in the product's headline feature. 🤖 Drafted with Claude Code |
Two more field findings from Bo's live usage (via Claude Desktop / stdio MCP)1. Observed: while the GUI was actively mid-index (file and chunk totals visibly climbing poll over poll), repeated Root cause (pre-existing, not introduced by this PR): the progress fields live only in the GUI process's memory — Suggested fix shape (your design call): persist progress the same way Schedule with Bo alongside the PDF fix — same class: pre-existing, field-found, hurts the MCP consumer experience directly. 2. Upgrading the review-body nit to a requested fix: Bo hit it in practice: a tight threshold legitimately excluded all results (filtering itself was correct), and the tool returned literal 🤖 Drafted with Claude Code |
Third epic to define (definition only, same drill) — multi-representation indexing (dual-vector chunks)One more epic definition to add to this PR's docs and queue. Suggested name: Problem (verified against this branch): a chunk's one Proposed shape (design input for you to validate in the epic, not settled): decouple embedded text from stored text — allow up to two vectors per chunk, both pointing at the same stored raw payload:
Binding constraints: files on disk are never modified; Things the epic must work out (the real design content):
Sequencing: the PDF extraction fix (previous comment) is a hard prerequisite — same mechanism, and PDFs become "embed extracted, store extracted" with no raw vector (nobody queries for 🤖 Drafted with Claude Code |
Review wrap-up — one index of everything on this PRSince the feedback landed across a review and several comments, here's the consolidated list. Details live in the linked items; this is the checklist. For this PR's revision roundBlocking
Requested Polish (optional) Docs to add to this PR (definition only — no implementation) Scheduled after merge (separate PRs, slot priorities with Bo)
Nothing else outstanding from this review. Once the revision round lands, ping and we'll re-verify the same way (full suites, tagged integration, build, live run). Thanks again — the field-test discipline on this PR set a high bar. 🤖 Drafted with Claude Code |
… waits Review blocker (PR #2): quitting mid-scan could permanently orphan files. The remove-chunks/upsert-file/insert-chunks sequence was three separate transactions; dying between the file upsert and the chunk insert recorded the file as indexed-at-hash with zero chunks, and the hash short-circuit then skipped it forever (worse on re-index: old chunks were already deleted). And app.shutdown closed the store without stopping the engine, so the scan goroutine kept writing during shutdown — routine now that close=quit on Windows/Linux. - store: new UpsertFileWithChunks does the whole replace in ONE transaction (interface + sqlite + mock); IndexFile steps 9-11 collapse into the single atomic call (also drops the re-fetch-ID round-trip). - engine: indexWG tracks in-flight indexing (scan loops + watcher handlers); Stop() closes stopCh, stops the watcher, then WAITS for the in-flight file to finish — shutdown lands on a file boundary. - app.shutdown: engine.Stop() before engine.Close(). Tests: TestUpsertFileWithChunksAtomic (wrong-dim vec insert fails mid-transaction → v1 entry fully intact and searchable; good v2 replaces with no stale chunks) and TestStopWaitsForInflightIndexing (blocking embedder: Stop() must not return mid-file, must return after the write completes; 5x stable). Full suite + vet + tagged integration green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… dedupe, retention Review items 2-4 (PR #2): index failures were logged at four call sites (double-logging extract errors, and any new IndexFile caller silently regressed to stderr-only); failed files re-appended an identical error row every launch (a failed file never persists a hash, so every launch retries — the field test's 79 failing files meant ~79 new rows per launch); and nothing ever pruned activity_log while the Log page pays COUNT(*) over it every 3s poll. - IndexFile now logs its own failures exactly once (wrapper around the pipeline); the four caller-side copies and the extract special case are gone. - Error rows are UPSERTED per (path, action): a persistently-failing file keeps one living row whose timestamp/detail update in place (store.UpsertLogEntry, interface + mock). - Retention at store open: 30-day TTL + 5000-row cap (pruneActivityLog; hygiene never blocks opening). Tests: the visibility regression test now covers initialScan (the exact path of the motivating field incident) and asserts errors are logged exactly ONCE per failure; store tests pin the upsert-dedupe contract and the TTL prune across reopen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review item 5 (PR #2): TestOnCreate only catches a merge regression on OSes that emit the CREATE-then-WRITE double event — on macOS (the primary dev environment) it passes with or without the merge fix. New test drives debounce() directly: Create+Write must fire OnCreate (not OnModify), Write alone fires OnModify, and Remove wins over Create+Write — pinned on every platform, no real filesystem events involved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review item 6 (PR #2): the shipped guard compared only the dimension — an unrecorded simplification of the epic's specified fingerprint, and blind to a same-width model swap (the epic's own upgrade candidate granite-97m is also 384-dim; mixed vectors would silently return garbage-ranked results). The engine now records the full provider:model:dimensions fingerprint on every index run/reset (bare dimension kept for pre-fingerprint DBs), and the read-only guard checks the fingerprint first, falling back to the dimension for older DBs. Deviation + resolution recorded in the epic doc per its own rule. Tests: same-dimension different-model mismatch errors without touching sqlite-vec; matching fingerprint searches cleanly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review item 8 (PR #2, optional): HideWindowOnClose re-derived the "has tray" fact via runtime.GOOS — two places that had to agree with nothing enforcing it. Each tray file now declares hasTray (true in tray.go, false in tray_stub.go) and main.go uses it directly; drift is structurally impossible, main.go loses its runtime import, and the background-presence epic gets a clean seam (the tray implementation owns the fact per platform). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…presentation-indexing Review items 9-11 (PR #2): the three epic definitions Bo requested — documents only, no implementation. Scopes and settled constraints transcribed from the review; each follows the local-embeddings conventions (Execution Notes, phase gates with Verify, record-deviations rule) and records its open design questions, including the shared post-KNN over-fetch/dedupe machinery flagged between filters and multi-representation. Queue order after merge: background-presence → mcp-search-filters → multi-representation-indexing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Findings from running the /code-review skill over the revision round (per the review's own suggestion), each verified before fixing: - Provider resolution existed in 4 divergent copies; the read-only path dropped the key-implies-openai fallback, so a legacy DB (key set, provider never persisted) got a 'local' threshold and fingerprint applied to an OpenAI index over --mcp. Hoisted to embeddings.ResolveProvider + embeddings.Fingerprint; writer, checker, engine threshold, and composition root all resolve identically now. - UpsertLogEntry updated every matching row (bulk-rewriting legacy duplicates instead of collapsing them). Now transactional delete+insert: one living row per (path, action), legacy duplicates collapse on first re-failure. OnDelete errors join the same policy. - recordIndexIdentity was only written by initialScan/Reset — a fresh onboarding session's DB carried no fingerprint until the next launch (guard silently inactive). AddDirectory records it after its index run; SetConfig errors are logged instead of dropped. - OnCreate/OnModify (and the scan loops) could WaitGroup-Add concurrently with Stop()'s Wait via a debounce timer that fired before watcher.Stop cancels timers — torn shutdown or WaitGroup panic. Adds now go through tryBeginIndexWork(), checked-and-added under the same mutex Stop uses to close stopCh. - Watcher: atomic-save editors (vim backupcopy=no) emit RENAME then CREATE in one debounce window; the merged Remove/Rename verdict deleted a file that still exists from the index. dispatch() now confirms Remove/Rename against the filesystem before firing OnDelete. Deterministic tests added for both outcomes. Full suite + tagged integration + -race on engine/watcher green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… metric Also from the /code-review pass: UpsertFile/InsertChunks lost their last production caller to the atomic UpsertFileWithChunks — removed from the Store interface and mocks (concrete SQLiteStore methods remain for tests). DefaultThreshold's comment claimed vec0 defaults to cosine; it defaults to EUCLIDEAN (L2). Ranking is unaffected (unit vectors: L2 and cosine are monotonically equivalent) but the thresholds are L2-scale values — comment now states the real semantics with the conversion math; whether to declare distance_metric=cosine or retune is flagged for the owner in the PR notes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Revision round complete — checklist → commitsEverything from the review is addressed; full suites + tagged integration +
Took your
|
What this is
The complete local-embeddings epic, consolidated into one branch per your review-workflow preference: the app now works fully offline with no API key — a bundled on-device model (
multilingual-e5-small, int8 ONNX) is the default embedding provider on every shipping platform. OpenAI remains an explicit opt-in.Everything is in this one branch: Phases 0–6 plus all fixes discovered during verification (previously drafts #3, #4, #5, #6, #7, #8, #9, #10 — all closed pointing here).
Guided tour (suggested reading order)
Documentation/Epics/local-embeddings.md— the whole story: research, phase-by-phase execution notes, platform matrix, field-test findings, measured numbers, and two parked product questions (Phase 6 notes).Documentation/Bugs/local-embed-token-budget-overflow.md— the critical find: every multi-chunk file (>~1.7 KB) silently failed to embed on all platforms; caught by a real-vault field test, fixed with a permanent regression test.Documentation/Bugs/fswatcher-create-event-swallowed-linux.md+Documentation/windows-build.md— the other bug report and the Windows build/artifact provenance.What's included
EmbedDocuments/EmbedQuerysplit, chunker tokenizer seam, local ONNX embedder package (internal/embeddings/local, CGo behind thelocalembedtag — plaingo test ./...stays native-lib-free)go:embedof the ONNX runtime,make assetsmanifest (SHA-256-pinned), two self-hosted native artifacts in this repo's releases (mac-Intel ORT source build; Windows tokenizer lib source build — recipes in release notes)Verification
--network nonecontainer runs index+search correctly; cross-platform vector compatibility confirmed (mac-indexed DB searched on Linux/Windows)make buildre-run green after the merges🤖 Generated with Claude Code