Skip to content

Local embeddings: on-device model as the default — complete epic (all platforms, field-tested) - #2

Open
NestorCanales wants to merge 47 commits into
mainfrom
feat/local-embeddings
Open

Local embeddings: on-device model as the default — complete epic (all platforms, field-tested)#2
NestorCanales wants to merge 47 commits into
mainfrom
feat/local-embeddings

Conversation

@NestorCanales

@NestorCanales NestorCanales commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

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)

  1. 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).
  2. 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.
  3. 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

  • Interface + pipeline: EmbedDocuments/EmbedQuery split, chunker tokenizer seam, local ONNX embedder package (internal/embeddings/local, CGo behind the localembed tag — plain go test ./... stays native-lib-free)
  • Provider system: local is default, OpenAI opt-in, provider switching with re-index, fingerprint safety, provider-aware thresholds; keyless onboarding UI
  • All five targets: macOS arm64 + x86_64, Linux arm64 + x64, Windows x64 — per-platform go:embed of the ONNX runtime, make assets manifest (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)
  • Fixes found during verification: token-budget overflow (critical, cross-platform); watcher create-events swallowed (verified on 3 OSes); index failures invisible in the Log page; Windows close-window zombie instances; arch-namespaced asset extraction; darwin-guarded tray; per-OS Claude Desktop config path

Verification

  • Automated suites green on all platforms (untagged suite stays native-lib-free; tagged integration includes a large-document regression)
  • Field tests on real hardware: Windows — real 109-file vault indexed (105 files / 932 chunks / 0 errors after the fix), real semantic searches through Claude Desktop; Linux x64 (Pop!_OS 24.04) — GUI onboarding, indexing, search verified on-device
  • Offline proof: --network none container runs index+search correctly; cross-platform vector compatibility confirmed (mac-indexed DB searched on Linux/Windows)
  • Consolidated branch: full suite + vet + tagged integration + make build re-run green after the merges

🤖 Generated with Claude Code

NestorCanales and others added 13 commits July 6, 2026 15:28
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>
@theBoEffect

Copy link
Copy Markdown
Contributor

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.

@theBoEffect

theBoEffect commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

The isolation to /local makes sense and organizes things nicely.
Would you like me to start running this locally and do an architectural pass or wait till you finish the epic and test it all together? It may be easier to review once the interfaces are in place.

NestorCanales and others added 3 commits July 10, 2026 12:53
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>
@NestorCanales NestorCanales changed the title Local embeddings (Phases 0–3): on-device model as default — draft Local embeddings (Phases 0-4): on-device model as default [draft] Jul 11, 2026
@NestorCanales

Copy link
Copy Markdown
Collaborator Author

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>
NestorCanales and others added 3 commits July 17, 2026 01:40
…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>
NestorCanales and others added 2 commits July 17, 2026 14:54
… 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>
NestorCanales and others added 6 commits July 18, 2026 01:47
…/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>
@NestorCanales NestorCanales changed the title Local embeddings (Phases 0-4): on-device model as default [draft] Local embeddings: on-device model as the default — complete epic (all platforms, field-tested) Jul 27, 2026
@NestorCanales
NestorCanales marked this pull request as ready for review July 27, 2026 19:17

@theBoEffect theBoEffect left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread main.go Outdated
// 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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. app.shutdown calls engine.Close() but never engine.Stop(), so stopCh is never closed and the scan goroutine keeps indexing while the store shuts down under it.
  2. IndexFile steps 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.
  3. 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).

Comment thread internal/engine/engine.go Outdated
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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread internal/engine/engine.go Outdated
}
if indexErr := eng.IndexFile(p); indexErr != nil {
log.Printf("engine: index %s: %v", p, indexErr)
eng.logActivity(p, "error", fmt.Sprintf("index: %v", indexErr))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread tray_stub.go

// 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() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@theBoEffect

Copy link
Copy Markdown
Contributor

Btw, try the /code-review skill next time. Thats what I used :)

@theBoEffect

Copy link
Copy Markdown
Contributor

Second epic to define (again: definition only, not built in this PR) — metadata filters for MCP search

Alongside background-presence, please add one more epic definition doc to this PR and queue it behind that work. Suggested name: mcp-search-filters (Documentation/Epics/mcp-search-filters.md).

What it does: the MCP search tool (stdio and HTTP) gains optional filter parameters that constrain results by file metadata, so a caller can ask things like "search only my markdown notes," "only files under this directory," or "only files modified in the last month" instead of filtering client-side after the fact.

User-set constraints to record in the epic (settled, don't 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 — worth a parking-lot note, nothing more.
  • MCP surface only: both transports' search tool schemas. No GUI search work.
  • Definition merged in this PR; implementation comes later, sequenced after background-presence unless Bo reorders.

Current-state notes for your research section (verified against this branch):

  • The files table today 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, which implies a migration and a decision about backfill (existing rows lack the data until re-index — cheap here since os.Stat at next scan can backfill without re-embedding).
  • The key design question is how filtering composes with the KNN query in store.Search: sqlite-vec vec0 KNN doesn't trivially JOIN-filter. Research the options — over-fetching then filtering in SQL/Go vs. sqlite-vec's newer metadata-column/partition-key support in vec0 tables — and record the measured trade-off in the epic the way local-embeddings recorded its Phase 0 numbers. Getting limit/offset/threshold semantics right post-filter matters (a filter that eats 90% of neighbors shouldn't return 1 result because the over-fetch was too small).
  • Filter params should flow through domain.SearchParams so engine/readonly/store stay interface-clean; tool schemas in internal/mcp/server.go are the only delivery-layer touch.

Follow the same epic conventions (Execution Notes, phase gates with Verify, record deviations in the doc).

🤖 Drafted with Claude Code

@theBoEffect

Copy link
Copy Markdown
Contributor

Field confirmation of your PDF passthrough bug — please schedule the fix

Bo hit Documentation/Bugs/pdf-extraction-passthrough.md in the wild on his real vault: for every indexed PDF (five checked, spanning PDF versions 1.3–1.6 and multiple producers, including Skia-generated ones), chunk 0's stored content is the raw PDF byte stream — %PDF-1.x, object dictionaries, /Filter /FlateDecode, compressed stream noise — exactly the passthrough your report diagnosed.

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. IndexFile skips any file whose content hash is unchanged (engine.go:227), and fixing the extractor doesn't change file hashes — so a naive fix silently never applies to already-indexed PDFs; the garbage chunks stay forever. The fix needs a mechanism to invalidate previously-extracted content when extraction logic changes — e.g., an extractor-version column on files checked alongside the hash, or a one-time purge of .pdf file rows on upgrade. Please include that mechanism (and a regression test for it) in the fix — it's the difference between fixing new indexes and fixing everyone's existing index.

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

@theBoEffect

Copy link
Copy Markdown
Contributor

Two more field findings from Bo's live usage (via Claude Desktop / stdio MCP)

1. index_status progress reporting is structurally dead over stdio — and the tool schema promises it works.

Observed: while the GUI was actively mid-index (file and chunk totals visibly climbing poll over poll), repeated index_status calls through Claude Desktop returned IsIndexing: false and IndexedFiles/TotalToIndex: 0/0 every single time. So an MCP client cannot distinguish "index is settled" from "index is mid-run," and can't show progress. This had a real cost during debugging: it made "PDFs are broken" indistinguishable from "PDFs just haven't been indexed yet."

Root cause (pre-existing, not introduced by this PR): the progress fields live only in the GUI process's memory — Engine.Stats() overlays eng.indexing / eng.indexedFiles / eng.totalToIndex (engine.go:508–517), and nothing ever persists them. The stdio process's ReadOnlyEngine.Stats() (readonly.go:64) can only read the store, and hard-codes IsIndexing = false (readonly.go:72) — the comment there shows this was a known limitation, but the index_status tool description still advertises a "currently indexing flag," so the contract overpromises what stdio can deliver.

Suggested fix shape (your design call): persist progress the same way watcher_running already is — the engine writes an indexing-state record to the config table (running flag + indexed/total counters + a heartbeat timestamp, updated every N files, cleared on completion), and ReadOnlyEngine.Stats() reads it, treating a stale heartbeat (e.g., > 30s old) as not-indexing so a crashed GUI can't leave the flag stuck on. The per-file config write is noise next to the chunk inserts already happening. Please also make the shutdown path clear the state — which ties into the Stop()-before-Close() fix from the review.

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: search returns null for an empty result set.

Bo hit it in practice: a tight threshold legitimately excluded all results (filtering itself was correct), and the tool returned literal null instead of []. A null where clients expect an array throws on .length / for...of — please make empty results serialize as [] in this PR's revision round. Likely one-liner: the nil slice from the store/engine marshals as null; return a non-nil empty slice (or normalize at the MCP dispatch layer for both transports).

🤖 Drafted with Claude Code

@theBoEffect

Copy link
Copy Markdown
Contributor

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: multi-representation-indexing (Documentation/Epics/multi-representation-indexing.md).

Problem (verified against this branch): a chunk's one 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 (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:

  • 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 near 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) → one raw vector + 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 (<title>, headings, alt, aria-label, link/button labels) via golang.org/x/net/html. No LLM, no per-file cost.

Binding constraints: 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 in the pipeline; the extra embed/vector cost lands on local compute and is accepted (~10–15% vector growth in a mostly-prose corpus).

Things the epic must work out (the real design 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/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; consider designing 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 verify landmark quality mid-document), vs. 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.

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 /Filter /FlateDecode). Slot the rest with Bo.

🤖 Drafted with Claude Code

@theBoEffect

Copy link
Copy Markdown
Contributor

Review wrap-up — one index of everything on this PR

Since 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 round

Blocking

  1. Shutdown-during-scan data loss — engine.Stop() before Close() + one transaction around IndexFile's remove/upsert/insert (inline comment on main.go:201).

Requested
2. Activity log retention (TTL/cap) + dedupe of repeat failures per path (engine.go:626).
3. Consolidate error logging into IndexFile; kill the double-log at the four call sites (engine.go:480).
4. Cover initialScan in the error-logging regression test (engine_test.go:626).
5. Deterministic cross-platform test for the watcher Create+Write merge (fswatcher.go:135).
6. Full embedding_fingerprint instead of dimension-only guard; record the deviation in the epic (readonly.go:44).
7. search empty results: return [], not null (upgraded from nit — Bo hit it in the field; likely a one-line nil-slice fix at the source or dispatch layer).

Polish (optional)
8. const hasTray in the tray build-tag pair instead of runtime.GOOS == "darwin" in main.go (tray_stub.go:7).

Docs to add to this PR (definition only — no implementation)
9. Documentation/Epics/background-presence.md — Windows/Linux tray + start-on-login + single-instance guard (scope in the review body).
10. Documentation/Epics/mcp-search-filters.md — filesystem-tier metadata filters on the MCP search tool (scope + constraints in comment).
11. Documentation/Epics/multi-representation-indexing.md — dual-vector chunks for markup-heavy formats (scope + design questions in comment).

Scheduled after merge (separate PRs, slot priorities with Bo)

  • PDF extraction fix — real text extraction + the re-index invalidation mechanism (extractor-version or purge) + regression test. Likely first: it's small, it's a correctness hole in the headline feature, and it's a hard prerequisite for epic #11.
  • Persisted index progress — progress state written to config with a heartbeat so index_status over stdio stops reporting a dead false/0/0; also fixes the tool schema's overpromise.
  • Epic queue — suggested order: background-presence → mcp-search-filters → multi-representation-indexing. Note fix(embeddings): token budget overflow — every multi-chunk file failed to embed [stacks on #7] #10 and #11 share the post-KNN over-fetch/dedupe machinery in store.Search; whichever you build first, design that piece for both.

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

NestorCanales and others added 8 commits August 12, 2026 10:46
… 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>
@NestorCanales

Copy link
Copy Markdown
Collaborator Author

Revision round complete — checklist → commits

Everything from the review is addressed; full suites + tagged integration + make build + -race (engine/watcher) green on the branch.

Review item Commit
1. Blocker: shutdown-during-scanStop() before Close() and the remove/upsert/insert sequence is now ONE transaction (store.UpsertFileWithChunks); Stop() also waits for the in-flight file. Regression tests: mid-transaction failure leaves the previous entry fully searchable; Stop() blocks until the file boundary df97e90
2. Activity log retention + dedupe — 30-day TTL + 5000-row cap at store open; error rows are per-path upserts a8b4db9, hardened in 36df4be
3. Error logging consolidated inside IndexFile (4 caller copies + line-234 special case removed) a8b4db9
4. initialScan covered in the visibility regression test (+ asserts exactly-once) a8b4db9
5. Deterministic watcher tests driving debounce() directly (create+write merge, write-alone, delete-wins) 5962d9f
6. Full embedding_fingerprint guard; deviation recorded in the epic 05936dc, hardened in 36df4be
7. Empty search results → [] (wire-contract test incl. offset-past-results) a8b4db9
8. hasTray owned by the tray build-tag pair 388b930
9–11. Epic definitions: background-presence, mcp-search-filters, multi-representation-indexing 3252e03

Took your /code-review advice — it caught real things

Ran the skill over the revision round before pinging you. It found defects in my own new code, all fixed in 36df4be/5b34c15: the read-only fingerprint/threshold used a provider resolution that dropped the key-implies-openai fallback (rule now hoisted to embeddings.ResolveProvider + embeddings.Fingerprint, used by writer and checker); the log upsert didn't collapse pre-existing duplicate rows; the fingerprint wasn't recorded after onboarding's first index; a WaitGroup Add/Wait race in the watcher handlers; and the debounce merge mis-handled vim-style atomic saves (RENAME+CREATE in one window → OnDelete for a file that still exists — dispatch now stats the path first). Plus: N+1 delete inside the new transaction → single IN statement; UpsertFile/InsertChunks (now production-dead) trimmed from the interface.

Honest residuals the review surfaced (pre-existing — flagging, not fixed here)

  1. Distance-metric semantics (worth a decision): chunk_embeddings is declared without distance_metric, so sqlite-vec returns L2, not cosine as comments claimed. Unit-norm vectors make ranking identical (L2 = √(2·cos_dist)), but the thresholds are therefore L2-scale: mE5 "related" ≈0.51–0.60, cross-lingual ≈0.58–0.66, unrelated ≈0.76. The local 0.6 cutoff truncates part of the cross-lingual band the epic targets. Options: declare distance_metric=cosine (table rebuild via existing Reset path) and keep 0.6, or retune the L2 value (~0.66–0.70). Comment now documents the real semantics either way.
  2. Upgraded-DB startup is unguarded in the GUI/RW path: a 1536-wide table + a DB that resolves to local (no key stored) opens without any width/fingerprint check → every insert fails per-file with no rebuild path. Wants one startup reconciliation (actual vec-table width + fingerprint vs active embedder → existing Reset). Natural companion to the fingerprint work; happy to do as a follow-up PR.
  3. Settings provider-switch flow: switching persists the provider before the embedder/chunker build or Reset can fail (a failed build can leave the app unable to start); and selecting OpenAI commits with an empty key (the key field renders after selection) → wipes the local index and re-embeds through 401s; saving the key afterwards doesn't rescan.
  4. Smaller: chunk_overlap isn't clamped when the local provider clamps chunk size (a stored overlap ≥ 480 drives the chunker step to 1); Stats() returns empty provider/model for legacy DBs over --mcp; warm starts read all ~150MB embedded assets before the idempotent fast path can short-circuit and the tokenizer.json is parsed/held twice; ortInitErr under sync.Once makes a transient ORT init failure permanent.

Suggested slotting: #2 + #3 alongside the already-queued PDF-extraction and index-progress fixes; #1 is your call on metric vs threshold; #4 as opportunistic cleanups.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants