Skip to content

v2.1.1 — Local embeddings (semantic search) - #54

Merged
ddutchie merged 24 commits into
mainfrom
ddutchie/embeddings
Jun 19, 2026
Merged

ddutchie merged 24 commits into
mainfrom
ddutchie/embeddings

Conversation

@ddutchie

@ddutchie ddutchie commented Jun 18, 2026 •

Copy link
Copy Markdown
Owner

What does this PR do?

Adds local-first embeddings (nomic-ai/nomic-embed-text-v1.5 via @xenova/transformers + onnxruntime-node) powering semantic Knowledge Graph edges, a Semantic Hubs section in the BacklinksPanel, and incremental auto-reindex on note save. Also fixes an infinite-loop OOM crash in chunkLongText, an install-status bug that hid already-downloaded models, and an upsert-overwrite footgun where recomputeProjections silently wiped search_document rows.

Type of change

  • Bug fix
  • New feature
  • Refactor / code quality
  • Docs / changelog
  • Tests

Screenshots / recording

  • Settings → Embeddings: install progress + worker status + maintenance buttons with progress bar
  • Notes editor with Sparkles toggle on: Backlinks panel showing "3 similar + 2 backlinks" with score bars
  • Knowledge Graph with semantic slider dragged to reveal cosine edges

Checklist

  • npm run type-check:all passes
  • npm run lint passes (not run)
  • npm test passes (33 new tests in electron/embeddings/)
  • npm run test:e2e passes (not run — UI behaviour, please run before merging)
  • No hardcoded colours — CSS variables only (var(--accent), var(--text-primary), etc.)
  • No text-[Npx] pixel font classes — rem equivalents only (text-[0.714rem], text-xs, etc.)
  • New IPC handlers wrapped in handle() and return IpcResult<T>
  • New DB migrations appended (not edited) in schema.ts (v17 — note_embeddings table)
  • New SQL goes in electron/db/queries.ts — single source of truth (imported by both Electron main process and MCP server); never construct a Database instance outside db/client.ts (Electron) or mcp-server.ts (MCP runtime)

Notes for reviewer

Architecture: The embeddings worker runs as a separate HTTP process (cairn-embeddings binary via @yao-pkg/pkg, or via node in dev with --max-old-space-size=4096). Electron main spawns it on first embed request, monitors health, and disposes it on before-quit. reindexNotes is also called inline on note save for incremental reindexing (skipped if content_hash unchanged — so untouched notes never re-embed). See docs/plans/local-embeddings.md for the original 8-phase plan with all status checkboxes ticked.

Critical fixes worth a second look:

  1. electron/embeddings/service.ts:chunkLongText — make sure the regression test (chunking.test.ts > TERMINATES on very long input) stays green; this was an infinite loop that OOM'd the worker.
  2. electron/ipc/db-handlers.ts:130-148 — the incremental reindex-after-save is fire-and-forget (void reindexSingleNoteEmbedding(...).then(...)). If you'd prefer it awaited, happy to switch, but blocking note saves on the worker felt wrong.
  3. electron/db/queries.ts:upsertNoteEmbedding still uses note_id as primary key. The clustering task was removed (everything now uses search_document, so recompute is UMAP-only), but the schema is unchanged — if you'd rather switch to a compound key (note_id, task), that's a v18 migration. I chose not to so users keep their indexed vectors across the upgrade.

Test coverage: cosine.test.ts, projection.test.ts, chunking.test.ts (incl. OOM regression), query-cache.test.ts — 33 tests passing. The worker binary itself isn't unit-tested (no good way to mock ONNX inference deterministically); end-to-end behaviour was verified manually in dev mode.

Not yet wired up:

  • onnxruntime-node's binaries for Windows/Linux may need a rebuild step in scripts/rebuild-native.js (currently we ship the npm package defaults). Working on Mac arm64; please smoke-test on Windows before releasing cross-platform builds.
  • The SemanticHubsPanel.tsx file still exists in the tree but is unused (the panel was folded into BacklinksPanel.tsx). Happy to delete or leave for a follow-up.

Summary by CodeRabbit

Release Notes

  • New Features
    • Offline semantic search for similar notes using locally packaged embeddings.
    • Semantic backlinks and a Semantic Hubs panel in the note editor.
    • Knowledge graph now supports “Semantic” edges with an adjustable similarity threshold (including semantic tooltips/filtering).
    • New Embeddings settings page for enabling, managing models, and running reindex/recompute.
  • Bug Fixes
    • More stable long-document chunking and fewer embedding/projection update issues.
    • Safer embeddings worker lifecycle with protections against duplicate concurrent runs.
  • Other
    • Expanded semantic neighbor support for agents/tools.

Adds local-first embeddings (nomic-ai/nomic-embed-text-v1.5 via
@xenova/transformers + onnxruntime-node) powering semantic Knowledge
Graph edges, the Semantic Hubs section in the Backlinks panel, and
incremental auto-reindex on note save.

Features
- New electron/embeddings/ module: HTTP worker binary (cairn-embeddings),
  spawn/health/dispose lifecycle, model manifest, cosine + UMAP projection,
  chunked-embed-and-average for long notes, LRU query vector cache
- v17 DB migration (note_embeddings table, JSON-TEXT vector storage)
- Settings → Embeddings UI: model install/remove/setDefault, reindex
  + recompute buttons with progress that survives view switches
- Graph: 'semantic' edge type with 0.78 cosine threshold + opacity slider
- BacklinksPanel: 'Semantic' section showing top-5 similar notes
- Incremental reindex on note save (skipped if content_hash unchanged)

Fixes
- chunkLongText infinite loop → OOM crash (terminates on long input)
- Model-install status never detected (transformers.js nested dir layout)
- Recompute overwrote search_document embeddings (single-task consolidation)
- Worker killed during in-flight requests (inFlightEmbeds guard)
- Progress state lost on Settings remount (getStatus now exposes last done/total)
- Duplicate concurrent reindex calls (withLock mutex)
- UMAP crashed on single-vector input (nNeighbors <= nPoints - 1)
- Notes with zero backlinks hid the Semantic section (auto-show)

Tests
- electron/embeddings/cosine.test.ts (12 tests)
- electron/embeddings/projection.test.ts (8 tests)
- electron/embeddings/chunking.test.ts (8 tests, incl. OOM regression)
- electron/embeddings/query-cache.test.ts (5 tests)

33 tests passing; type-check:all + compile verified clean.
@coderabbitai

coderabbitai Bot commented Jun 18, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a complete local semantic search subsystem: a packaged HTTP embeddings worker (Node/binary) running Nomic text embeddings via @xenova/transformers, section-level SQLite storage with v17–v19 migrations, incremental per-save reindexing, cosine similarity graph edges, UMAP 2D projection, cursor-aware section tracking in the editor, IPC/preload plumbing, an Embeddings Settings UI, and semantic results integrated into the BacklinksPanel with auto-expand on activity.

Changes

Local Embeddings & Semantic Search

Layer / File(s) Summary
Core types, constants, and vector math
electron/embeddings/types.ts, electron/embeddings/nomic.ts, electron/embeddings/cosine.ts, electron/embeddings/cosine.test.ts
Defines Zod-validated types (NomicTask, request/response shapes), NOMIC_DIM, task-prefix helpers (withNomicPrefix), and vector math (cosine similarity with dimension validation, topK selection with threshold/exclusion, magnitude, dot product, Float32Array coercion) with comprehensive unit tests.
Projection, port utility, and note sections
electron/embeddings/projection.ts, electron/embeddings/projection.test.ts, electron/embeddings/port.ts, electron/embeddings/sections.ts, electron/embeddings/sections.test.ts
Implements seeded deterministic UMAP projection via projectTo2d, normaliseProjection rescaling, findFreePort for ephemeral binding; adds markdown section splitting (splitIntoSections) with header-based boundaries (#/##) and title extraction; includes tests verifying determinism, normalization, chunking, and section parsing.
Transformer pipeline and HTTP server
electron/embeddings/pipeline.ts, electron/embeddings/server.ts
Wraps @xenova/transformers feature-extraction with cached pipeline loading, ONNX session creation, task prefixing, and tensor construction. Standalone HTTP server exposes /health and POST /embed, emits newline-delimited JSON stdout events, parses CLI arguments (port/cache-dir/model), and handles graceful shutdown with uncaught-exception logging.
Electron embeddings client and lifecycle
electron/embeddings/client.ts
Resolves worker binary/script from dev/packaged paths; spawns on a free port; health-polls with restart logic; tracks in-flight request count and reindex/recompute progress; exposes ensureStarted (single-flight), embed (POST with timeout/dim validation), stopWorker (SIGTERM→SIGKILL), dispose, getStatus, and onProgress subscription.
Model manifest and disk detection
electron/embeddings/manifest.ts
Reads/writes per-model JSON manifests and default-model file; detects on-disk ONNX by checking config.json and .onnx files under org/name/onnx/; reconciles persisted download status with disk presence; provides setters for status/progress, model removal, and default-model selection.
Embeddings schema migrations (v17–v19)
electron/db/schema.ts
Introduces three migration versions: v17 creates note_embeddings table with metadata/vector/projection fields and indexes; v18 converts to section-level granularity with (note_id, section_idx) keys, adds section_title, migrates existing rows; v19 ensures relationship_cache section-title columns exist idempotently.
Embeddings query and CRUD layer
electron/db/queries.ts, electron/mcp/db.ts
Exports NoteEmbeddingRecord type and query helpers for upserting per-section embeddings, managing projection staleness, fetching embeddings by note/workspace/task, and pruning legacy clustering rows; ensureEmbeddingsTable creates the table in MCP standalone process.
Semantic edge computation and storage
electron/db/graph-queries.ts
Extends EdgeType to include "semantic"; adds computeSemanticRelationships computing pairwise cosine similarity, upserting top-K neighbors above threshold with section-title metadata; supports incremental recomputation for selected entities; exports getSemanticNeighbors for MCP tools.
Semantic relationship tests
electron/db/graph-queries.test.ts
Comprehensive unit tests validating semantic edge computation: empty embeddings, similarity floor enforcement, top-K capping, section-title storage, multi-section linking, cluster behavior, incremental recompute, and client-side threshold filtering.
Embeddings service: reindex, search, projection
electron/embeddings/service.ts
Implements reindexNotes with chunking, SHA-256 hashing for skip logic, batch embedding; searchAdjacent with LRU query caching, cosine scoring, best-section-per-note selection; recomputeProjections detecting stale notes, re-embedding, UMAP projection, and projection-fresh marking.
Service tests: chunking, caching, benchmarks
electron/embeddings/chunking.test.ts, electron/embeddings/query-cache.test.ts, electron/embeddings/service.bench.test.ts
Validates chunking termination/overlap behavior, LRU cache semantics, deterministic key derivation; benchmarks pipeline stages (chunking, averaging, cosine, topK, projection) with p95 timing assertions.
Embeddings IPC handlers with workflow locking
electron/ipc/embeddings-handlers.ts
Registers reindex/search/recompute/status/stop/model handlers; adds per-workflow lock slots preventing duplicate concurrent runs; broadcasts progress to renderer; model install handles manifest status, warm-up embed, and fallback for already-installed models.
Incremental reindex on note update
electron/ipc/db-handlers.ts
Extends db:note:update to trigger per-note embedding reindex and, on success, recompute semantic relationships for that note with guarded error handling.
IPC registration, settings, and config caching
electron/ipc/handlers.ts, electron/ipc/registry.ts, electron/ipc/settings-handlers.ts, electron/lib/config-cache.ts
Wires embeddings handler registration; classifies db:embeddings:search as read-only channel; adds embeddings settings IPC endpoints; extends CachedConfig with embeddings settings persistence.
Preload API surface and app lifecycle
electron/preload.ts, electron/main.ts, electron/mcp-server.ts
Exposes window.electron.embeddings namespace with status/control/models/settings/onProgress; disposes worker on app quit; ensures embeddings table in MCP startup.
Graph types and semantic edge type
src/types/index.ts, src/store/slices/graph.ts
Extends GraphEdgeType union with "semantic" and adds optional sourceSectionTitle/targetSectionTitle fields on GraphEdge; includes semantic in default edge filters and label mapping.
Force graph semantic edge filtering and rendering
src/components/graph/ForceGraphCanvas.tsx
Accepts semanticThreshold prop; filters edges by weight threshold; renders semantic edges with accent color and dashed style; introduces canvas-based node drawing with selection/dimming; adds semantic-edge-only hover tooltips.
Radial graph semantic edge filtering and rendering
src/components/graph/RadialTreeCanvas.tsx
Accepts semanticThreshold prop; filters cross-edges by weight threshold; renders semantic edges with distinct styling; adds selection-aware node dimming and semantic-edge-only tooltips.
Knowledge graph semantic threshold control
src/components/graph/KnowledgeGraphView.tsx
Adds semanticThreshold state; passes threshold into force and radial canvases; adds toolbar slider control (force/radial modes) with "off" label when threshold >= 1.
Debounced value hook
src/hooks/useDebouncedValue.ts
Provides generic useDebouncedValue hook for delayed state updates with timer cleanup on dependency changes and unmount.
Section title and text extraction
src/components/notes/toc-utils.ts
Adds findSectionTitleAtOffset and extractSectionTextAtOffset helpers for cursor-driven semantic context in the editor.
Markdown editor cursor tracking
src/components/notes/markdown-editor.tsx
Adds onCursorActivity callback; refactors CodeMirror selection handler to emit cursor offset independently of selection state.
Semantic search in backlinks panel
src/components/notes/BacklinksPanel.tsx
Extends with semantic enablement, debounced semantic queries (workspace-wide and section-scoped), loading/result states, auto-expand on semantic activity, "Semantic" subsection with hit buttons, and "Related to" section display.
Note editor semantic panel and section tracking
src/components/notes/note-editor.tsx
Adds showSemanticPanel toggle, semanticContent tracking, cursor-derived activeSectionTitle/activeSectionText; wires semantic controls into editor header; passes semantic props to BacklinksPanel.
Embeddings settings UI
src/components/settings/EmbeddingsSettings.tsx, src/components/settings/settings-view.tsx
Implements EmbeddingsSettings with worker status, per-model install/remove/default actions with progress, enable toggle, and maintenance reindex/recompute buttons; integrated as "Embeddings" section in SettingsView.
Build pipeline and dependencies
package.json, scripts/build-embeddings-binary.js
Extends compile to bundle embeddings server; adds pkg cross-compilation script; adds @xenova/transformers, onnxruntime-node, umap-js dependencies.
MCP semantic neighbor tool
electron/lib/tool-schemas.ts, electron/mcp/tools/graph.ts, electron/mcp/tools/index.ts
Adds get_semantic_neighbors MCP tool schema and implementation; integrates into tool dispatcher.
Implementation plan and release notes
docs/plans/local-embeddings.md, changelogs/v2.1.1.md
Adds complete local-embeddings implementation plan and v2.1.1 changelog describing features, fixes, behavioral changes, and affected areas.

Sequence Diagram

sequenceDiagram
  participant Renderer as Renderer
  participant Preload as preload.ts
  participant IPCHandlers as IPC handlers
  participant EmbeddingsService as embeddings/service
  participant EmbeddingsClient as embeddings/client
  participant Worker as embeddings-server
  participant DB as SQLite

  Renderer->>Preload: electron.embeddings.reindex(workspaceId)
  Preload->>IPCHandlers: invoke("db:embeddings:reindex")
  IPCHandlers->>EmbeddingsService: reindexNotes(workspaceId)
  EmbeddingsService->>EmbeddingsClient: embed(chunked texts, search_document)
  EmbeddingsClient->>Worker: POST /embed
  Worker-->>EmbeddingsClient: {vectors, dim, model}
  EmbeddingsClient-->>EmbeddingsService: number[][]
  EmbeddingsService->>DB: upsertNoteEmbedding
  EmbeddingsService-->>IPCHandlers: ReindexResult
  IPCHandlers->>DB: computeSemanticRelationships
  IPCHandlers-->>Preload: result
  Preload-->>Renderer: {indexed, skipped, total}
  
  Renderer->>Preload: electron.embeddings.search(workspaceId, queryText)
  Preload->>IPCHandlers: invoke("db:embeddings:search")
  IPCHandlers->>EmbeddingsService: searchAdjacent(queryText)
  EmbeddingsService->>EmbeddingsClient: embed([queryText], search_query)
  EmbeddingsClient->>Worker: POST /embed
  Worker-->>EmbeddingsClient: vectors
  EmbeddingsService->>DB: topK similarity
  DB-->>EmbeddingsService: AdjacentNote[]
  EmbeddingsService-->>IPCHandlers: results
  IPCHandlers-->>Preload: AdjacentNote[]
  Preload-->>Renderer: displayed in BacklinksPanel
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐇 Hop, hop — a little vector flies,
Through transformer weights to find what's wise.
Cosine angles, chunk by chunk we go,
SQLite stores the seeds we sow.
Semantic edges bloom in the graph tonight —
The rabbit's embeddings are finally right! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title "v2.1.1 — Local embeddings (semantic search)" clearly and concisely summarizes the main feature addition: local embeddings with semantic search capability, and appropriately references the version bump.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ddutchie/embeddings

Comment @coderabbitai help to get the list of available commands and usage tips.

…ct patterns

- electron/embeddings/client.ts: import execSync from 'child_process' instead
  of inline require()
- BacklinksPanel: refactor semantic search into a state-machine SearchState
  union {kind:'loading'} | {kind:'results', hits}; setState calls moved to
  async callbacks and effect cleanup
- BacklinksPanel: replace autoOpened useState with useRef for the lock flag
- RadialTreeCanvas: add semanticThreshold to useCallback deps array
- note-editor: remove unused SemanticHubsPanel import; delete the now-orphan
  component file
- note-editor: explicit eslint-disable for the canonical sync-on-prop-change
  pattern (wordCount/semanticContent reset on note.id switch)

npm run lint: 0 errors 0 warnings; exit 0
npm run type-check:all: clean
npm test: 463 tests passing

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

🟠 Major comments (21)
scripts/build-embeddings-binary.js-37-37 (1)

37-37: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Linux binary name mismatches runtime lookup contract.

target.out for Linux is cairn-embeddings-linux, but electron/embeddings/client.ts resolves packaged non-Windows binaries as dist-embeddings/cairn-embeddings. On Linux packages, worker discovery will fail and embeddings won’t start.

Suggested fix
-if (wantLinux || (!wantMac && !wantWin && !wantLinux && platform === "linux"))   targets.push({ id: "node22-linux-x64",   out: "cairn-embeddings-linux" });
+if (wantLinux || (!wantMac && !wantWin && !wantLinux && platform === "linux"))   targets.push({ id: "node22-linux-x64",   out: "cairn-embeddings" });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/build-embeddings-binary.js` at line 37, The Linux binary output name
`cairn-embeddings-linux` defined in the targets array does not match the binary
name that the runtime expects when looking up packaged non-Windows binaries in
electron/embeddings/client.ts, which resolves to `cairn-embeddings`. Change the
Linux target.out value from `cairn-embeddings-linux` to `cairn-embeddings` to
align with the runtime's binary discovery contract and ensure the embeddings
worker can be located and started on Linux packages.
src/components/notes/SemanticHubsPanel.tsx-40-69 (1)

40-69: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent stale semantic results from out-of-order async completions.

Overlapping api.search(...) requests can resolve in reverse order and replace newer results with older content matches. Gate state updates so only the latest request can commit state.

Proposed fix
-import React, { useEffect, useState, useCallback } from "react";
+import React, { useEffect, useState, useCallback, useRef } from "react";
@@
 export function SemanticHubsPanel({ workspaceId, noteId, content, className, onSelectNote }: Props) {
   const [results, setResults] = useState<AdjacentNote[]>([]);
   const [loading, setLoading] = useState(false);
   const [error, setError] = useState<string | null>(null);
   const [enabled, setEnabled] = useState(false);
+  const requestIdRef = useRef(0);
@@
   const fetchAdjacent = useCallback(async () => {
-    if (!workspaceId || !content || !enabled) {
+    const requestId = ++requestIdRef.current;
+    if (!workspaceId || !content || !enabled) {
       setResults([]);
+      setLoading(false);
       return;
     }
     setLoading(true);
     setError(null);
     try {
       const trimmed = content.trim();
       if (trimmed.length < 4) {
         setResults([]);
         return;
       }
       const api = window.electron?.embeddings;
       if (!api) {
         setResults([]);
         return;
       }
       const res = await api.search(workspaceId, trimmed, {
         queryNoteId: noteId,
         k: 5,
       });
-      setResults(res);
+      if (requestId === requestIdRef.current) setResults(res);
     } catch (e) {
-      setError(e instanceof Error ? e.message : String(e));
-      setResults([]);
+      if (requestId === requestIdRef.current) {
+        setError(e instanceof Error ? e.message : String(e));
+        setResults([]);
+      }
     } finally {
-      setLoading(false);
+      if (requestId === requestIdRef.current) setLoading(false);
     }
   }, [workspaceId, content, noteId, enabled]);

Also applies to: 71-76

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/notes/SemanticHubsPanel.tsx` around lines 40 - 69, The
fetchAdjacent function has a race condition where overlapping api.search calls
can resolve out of order, causing older results to overwrite newer ones. Fix
this by creating an AbortController to track the latest request and gate all
state updates (setResults, setError, setLoading) so only the most recent request
can commit state changes. Create the AbortController before calling api.search,
pass its signal to the search call if supported, and wrap the try/catch/finally
state updates with a check to ensure the request hasn't been aborted or
superseded by a newer request. Also update the useCallback dependency array to
include any tracking variables you introduce.
src/components/notes/note-editor.tsx-63-64 (1)

63-64: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid double-debouncing semantic content before search.

NoteEditor debounces before passing props, and BacklinksPanel debounces again (src/components/notes/BacklinksPanel.tsx, Line [42]). This adds ~2.4s latency and can briefly query previous-note text after note switches.

Proposed fix
 import { SemanticHubsPanel } from "./SemanticHubsPanel";
-import { useDebouncedValue } from "`@/hooks/useDebouncedValue`";
@@
   const [showSemanticPanel, setShowSemanticPanel] = useState(false);
   const [semanticContent, setSemanticContent] = useState(note.content ?? "");
-  const debouncedSemanticContent = useDebouncedValue(semanticContent, 1200);
@@
       <BacklinksPanel
         note={note}
         onOpenCard={() => setView("board")}
         semanticEnabled={showSemanticPanel}
-        semanticContent={debouncedSemanticContent}
+        semanticContent={semanticContent}
         workspaceId={activeWorkspaceId}
       />

Also applies to: 947-948

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/notes/note-editor.tsx` around lines 63 - 64, Remove the
debouncing from the NoteEditor component at the useDebouncedValue call for
semanticContent (line 63-64). Instead of debouncing semanticContent in
NoteEditor and passing the debounced value to BacklinksPanel, pass the raw
semanticContent directly so that only BacklinksPanel performs debouncing at its
own useDebouncedValue call. This eliminates the double-debouncing issue that
causes excessive latency and prevents stale data from previous notes being
queried after note switches.
src/components/settings/EmbeddingsSettings.tsx-147-153 (1)

147-153: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear failed installs out of the downloading state.

After install() throws, progressByModel[modelId] remains { status: "downloading" }, which keeps the card stuck on “Cancel” until remount. Clear or mark that progress entry in catch/finally.

Proposed fix
     try {
       await e.models.install(modelId);
-      await refreshQuiet();
     } catch (err) {
       console.error("[embeddings] install failed:", err);
+      setProgressByModel((prev) => {
+        const { [modelId]: _discard, ...rest } = prev;
+        return rest;
+      });
+    } finally {
+      await refreshQuiet();
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/settings/EmbeddingsSettings.tsx` around lines 147 - 153, The
progressByModel state entry for a failed model installation remains stuck in the
"downloading" status because it is not cleared or updated when the install()
call throws an error. In the catch block where console.error is called, add a
setProgressByModel call to either remove the modelId entry entirely or update
its status to indicate failure. This will prevent the UI from staying in a
"Cancel" state after the installation fails.
src/components/settings/EmbeddingsSettings.tsx-167-172 (1)

167-172: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep config.modelId in sync when changing the default model.

handleReindexAll() and handleRecomputeProjections() pass config.modelId, but setDefault() only updates the model manifest/status. After choosing a new default, maintenance actions can still run with the old model ID unless the settings config is updated and saved too.

Proposed fix
   const handleSetDefault = async (modelId: string) => {
     const e = window.electron?.embeddings;
     if (!e) return;
     try {
       await e.models.setDefault(modelId);
+      const next = { ...config, modelId };
+      setConfig(next);
+      await e.saveSettings(next);
       await refreshQuiet();
     } catch (err) {
       console.error("[embeddings] setDefault failed:", err);
     }
   };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/settings/EmbeddingsSettings.tsx` around lines 167 - 172, The
handleSetDefault function calls await e.models.setDefault(modelId) to update the
model manifest but does not synchronize this change with the settings config
object. This causes handleReindexAll and handleRecomputeProjections to still
reference the old config.modelId when executing later. Update config.modelId to
the new modelId after the setDefault call completes and before calling
refreshQuiet() to ensure the settings config stays in sync with the selected
default model.
src/components/settings/EmbeddingsSettings.tsx-114-115 (1)

114-115: ⚠️ Potential issue | 🟠 Major

Use the store's activeWorkspaceId instead of dereferencing workspace.list()[0].id.

The component currently maintains local state activeWorkspaceId initialized from the first workspace in the list. Since other components use useCairnStore((s) => s.activeWorkspaceId) to access the user's currently selected workspace, EmbeddingsSettings should do the same. Multi-workspace users can otherwise run reindex() and recomputeProjections() against the wrong workspace. Keep the actions disabled while activeWorkspaceId is null (initial state) to ensure the correct workspace is targeted before any operations execute.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/settings/EmbeddingsSettings.tsx` around lines 114 - 115,
Remove the local state assignment of activeWorkspaceId from
workspace.list()[0].id in the code block around lines 114-115. Instead, use the
store's activeWorkspaceId by calling useCairnStore((s) => s.activeWorkspaceId)
to access the user's currently selected workspace, which is consistent with how
other components retrieve this value. Additionally, add null checks or disabled
state conditions to the reindex() and recomputeProjections() action buttons to
ensure they remain disabled while activeWorkspaceId is null, preventing
operations from running against an incorrect workspace.
electron/embeddings/server.ts-43-49 (1)

43-49: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add a hard payload cap for request body reads.

readBody accumulates unbounded bytes in memory. A large or repeated POST to /embed can force OOM and kill the worker process.

Suggested fix
+const MAX_EMBED_BODY_BYTES = 1_000_000; // 1 MB
+
 function readBody(req: http.IncomingMessage): Promise<string> {
   return new Promise((resolve, reject) => {
     const chunks: Buffer[] = [];
-    req.on("data", (c: Buffer) => chunks.push(c));
+    let total = 0;
+    req.on("data", (c: Buffer) => {
+      total += c.length;
+      if (total > MAX_EMBED_BODY_BYTES) {
+        reject(Object.assign(new Error("payload too large"), { statusCode: 413 }));
+        req.destroy();
+        return;
+      }
+      chunks.push(c);
+    });
     req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
     req.on("error", reject);
   });
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/embeddings/server.ts` around lines 43 - 49, The readBody function
accumulates request body chunks into memory without any size constraints, making
it vulnerable to Out-Of-Memory attacks. Add a maximum payload size constant to
cap the total bytes that can be accumulated. In the data event handler where
chunks are pushed to the array, track the cumulative size of all chunks and
reject the promise if the total size exceeds the maximum payload limit. This
will prevent unbounded memory allocation in the readBody function.
electron/embeddings/pipeline.ts-34-57 (1)

34-57: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset cached pipeline state when model load fails.

loadPipeline caches _loadedModelId and _extractor before the load resolves. If the promise rejects once, later calls can keep reusing a rejected cached promise instead of retrying.

Suggested fix
 export async function loadPipeline(
   modelId: string = NOMIC_MODEL_ID,
   onProgress?: ProgressCallback,
 ): Promise<FeatureExtractionPipeline> {
   if (_extractor && _loadedModelId === modelId) return _extractor;
-  _extractor = pipeline("feature-extraction", modelId, {
+  _extractor = pipeline("feature-extraction", modelId, {
     quantized: true,
     progress_callback: onProgress
       ? (data: unknown) => {
           if (data && typeof data === "object") {
             const d = data as Record<string, unknown>;
@@
         }
       : undefined,
-  });
+  }).catch((err) => {
+    if (_loadedModelId === modelId) {
+      _extractor = null;
+      _loadedModelId = null;
+    }
+    throw err;
+  });
   _loadedModelId = modelId;
   return _extractor;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/embeddings/pipeline.ts` around lines 34 - 57, The loadPipeline
function caches _extractor and _loadedModelId without awaiting the pipeline
promise or handling errors. If the pipeline creation fails, the rejected promise
remains cached, causing subsequent calls to return that same rejected promise
instead of retrying. Add await to the pipeline call and wrap it in error
handling (try-catch or .catch()) to reset both _extractor and _loadedModelId to
their initial states when the operation fails, ensuring subsequent calls can
retry the load.
electron/embeddings/port.ts-6-12 (1)

6-12: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Avoid the free-port TOCTOU race in worker startup.

Line 6 binds a port and Line 9 immediately releases it before the worker process binds, so the port can be stolen and startup becomes flaky under contention. Prefer binding the worker to port 0 and reporting the actual bound port, or add spawn-on-bind-failure retry logic tied to EADDRINUSE.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/embeddings/port.ts` around lines 6 - 12, The code has a race
condition where the server listening on a randomly assigned port (line 6) is
immediately closed before the worker process can bind to that same port (line
9), allowing another process to claim the port in between. Instead of closing
the server and passing the port to the worker, either keep the server listening
while the worker connects to it, or modify the worker process to bind directly
to port 0 and have it report back the actual port it bound to. If using the
latter approach, add retry logic with exponential backoff that listens for
EADDRINUSE errors and retries the worker spawn when port conflicts occur.
electron/embeddings/manifest.ts-85-87 (1)

85-87: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve persisted download progress/speed when reading the manifest.

setEmbeddingModelStatus() stores progress metadata, but getEmbeddingModelsManifest() drops it by returning fixed 0/100 progress and undefined speed, so in-progress status is lost on refresh/remount.

Suggested fix
     const entry: EmbeddingModelManifestEntry = {
@@
-      downloadProgress: status === "installed" ? 100 : 0,
-      downloadSpeed: undefined,
+      downloadProgress:
+        status === "installed"
+          ? 100
+          : status === "downloading"
+            ? Math.max(0, Math.min(100, stored.downloadProgress ?? 0))
+            : 0,
+      downloadSpeed: status === "downloading" ? stored.downloadSpeed : undefined,
       error: stored.error,
     };

Also applies to: 108-110

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/embeddings/manifest.ts` around lines 85 - 87, The downloadProgress
and downloadSpeed properties in getEmbeddingModelsManifest() are being set to
hardcoded values (0/100 and undefined) instead of reading from the stored
metadata, causing in-progress download status to be lost on refresh. Replace the
hardcoded downloadProgress assignment (which currently returns 100 if status is
installed, otherwise 0) with the actual stored progress value, and replace the
undefined downloadSpeed with the stored speed value from the metadata object,
similar to how error is read from stored.error. Apply this same fix to both
occurrences around lines 85-87 and 108-110.
electron/embeddings/client.ts-269-283 (1)

269-283: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent orphan workers during unhealthy restart.

Line 353 increments inFlightEmbeds before Line 355 calls ensureStarted(). When health checks fail, Line 282 calls stopWorker(), but Line 307 suppresses stop due to the current in-flight count, then startup continues and can spawn a new worker without terminating the old one.

Suggested fix
 export async function ensureStarted(): Promise<number> {
@@
-    await stopWorker();
+    await stopWorker({ force: true });
   }
@@
 export async function embed(
   texts: string[],
   task: NomicTask,
   model = getDefaultModelId(),
 ): Promise<number[][]> {
   if (texts.length === 0) return [];
-  inFlightEmbeds++;
+  const port = await ensureStarted();
+  inFlightEmbeds++;
   try {
-    const port = await ensureStarted();
     const body = JSON.stringify({ texts, task, model });

Also applies to: 305-313, 347-356

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/embeddings/client.ts` around lines 269 - 283, The issue is that when
ensureStarted() detects an unhealthy worker and calls stopWorker() at line 282,
the stop operation is suppressed due to non-zero inFlightEmbeds count that was
already incremented before ensureStarted() was called from the caller code. This
allows a new worker to spawn while the old unhealthy one still exists, creating
orphans. Modify stopWorker() to accept a parameter that forces termination of
the old worker regardless of the inFlightEmbeds count, then pass this force flag
when calling stopWorker() from the unhealthy restart path in ensureStarted() to
ensure the old unhealthy worker is always properly terminated before any new
worker is spawned.
electron/db/queries.ts-1352-1360 (1)

1352-1360: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Filter workspace embeddings by model to prevent mixed-vector comparisons.

This query currently mixes rows from different embedding models under the same task. That breaks semantic ranking quality and can trigger dimension mismatches downstream after model switches or partial reindex runs.

Suggested contract change
 export function getAllEmbeddingsForWorkspace(
   db: Database.Database,
   workspaceId: string,
   task: string,
+  model?: string,
 ): NoteEmbeddingRecord[] {
-  const rows = db.prepare(
-    "SELECT * FROM note_embeddings WHERE workspace_id = ? AND task = ?"
-  ).all(workspaceId, task) as NoteEmbeddingRow[];
+  const hasModel = typeof model === "string" && model.length > 0;
+  const rows = hasModel
+    ? db.prepare(
+        "SELECT * FROM note_embeddings WHERE workspace_id = ? AND task = ? AND model = ?"
+      ).all(workspaceId, task, model)
+    : db.prepare(
+        "SELECT * FROM note_embeddings WHERE workspace_id = ? AND task = ?"
+      ).all(workspaceId, task);
+  const typed = rows as NoteEmbeddingRow[];
-  return rows.map(toNoteEmbedding);
+  return typed.map(toNoteEmbedding);
 }

Update call sites to pass the active model (search, projection recompute, semantic-relationship recompute) so comparisons stay in one embedding space.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/db/queries.ts` around lines 1352 - 1360, The
getAllEmbeddingsForWorkspace function currently filters only by workspace_id and
task, mixing embeddings from different embedding models which causes semantic
ranking issues and dimension mismatches. Add a model parameter to the function
signature, update the WHERE clause in the SQL query to also filter by the model
field using an additional placeholder parameter, and then update all call sites
of getAllEmbeddingsForWorkspace to pass the active embedding model when invoking
the function.
electron/db/graph-queries.ts-768-774 (1)

768-774: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Incremental semantic recompute drops valid pairs due to ordering check.

When entityIds is provided, only changed IDs are in activePool. With if (a.id >= b.id) continue, pairs where changed a.id sorts after unchanged b.id are skipped and never recomputed after deletion.

Safe pair canonicalization fix
   const tx = db.transaction(() => {
+    const seen = new Set<string>();
     if (activeIds) {
       for (const id of activeIds) deleteOld.run(id, id);
     } else {
       const allIds = vectors.map((v) => v.id);
       for (const id of allIds) deleteOld.run(id, id);
     }
     for (const a of activePool) {
       for (const b of fullPool) {
-        if (a.id >= b.id) continue;
+        if (a.id === b.id) continue;
+        const [src, tgt] = a.id < b.id ? [a.id, b.id] : [b.id, a.id];
+        const key = `${src}|${tgt}`;
+        if (seen.has(key)) continue;
+        seen.add(key);
         const sim = cosine(a.vec, b.vec);
         if (sim >= SEMANTIC_THRESHOLD) {
-          upsert.run(a.id, b.id, "semantic", Math.round(sim * 100) / 100, now);
+          upsert.run(src, tgt, "semantic", Math.round(sim * 100) / 100, now);
         }
       }
     }
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/db/graph-queries.ts` around lines 768 - 774, The ordering check `if
(a.id >= b.id) continue;` in the nested loop comparing `activePool` against
`fullPool` incorrectly skips valid pairs when `entityIds` is provided. Since
`activePool` only contains changed entities while `fullPool` contains all
entities, pairs where a changed entity ID sorts after an unchanged entity ID are
never recomputed. Replace the directional skip with safe pair canonicalization
by always comparing the pair in consistent sorted order using the minimum and
maximum of the two IDs, ensuring all relevant semantic similarity pairs are
evaluated regardless of which pool contains which entity.
src/components/graph/RadialTreeCanvas.tsx-137-140 (1)

137-140: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

semanticThreshold changes won’t reliably re-render radial cross-edges.

renderTree reads semanticThreshold (Line 139) but useCallback deps omit it (Line 348), so slider updates can be ignored until another dependency changes.

🔧 Suggested fix
-  }, [graph, selectedNodeId, hoveredNodeId, labelMode, spacing, onNodeClick, fs]);
+  }, [graph, selectedNodeId, hoveredNodeId, labelMode, spacing, semanticThreshold, onNodeClick, fs]);

Also applies to: 348-348

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/graph/RadialTreeCanvas.tsx` around lines 137 - 140, The
renderTree function reads semanticThreshold in the crossEdges filter but the
useCallback hook that wraps renderTree omits semanticThreshold from its
dependency array, causing stale closures when the threshold value changes. Add
semanticThreshold to the dependency array of the useCallback hook at line 348 so
that renderTree is recreated whenever the threshold updates and the radial
cross-edges re-render with the current threshold value.
electron/embeddings/query-cache.test.ts-13-35 (1)

13-35: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

LRU test double is out of sync with production cache logic.

get() here promotes keys to MRU, but searchAdjacent’s real cache does not. This test can pass while production remains FIFO.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/embeddings/query-cache.test.ts` around lines 13 - 35, The
LruQueryCache test double implementation is not aligned with the production
cache behavior. The get() method in LruQueryCache currently promotes accessed
keys to MRU (Most Recently Used) by deleting and re-adding them to the map, but
the real searchAdjacent cache does not perform this promotion and maintains FIFO
semantics instead. Remove the this.map.delete(key) and this.map.set(key, v)
lines from the get() method so it simply retrieves and returns the value without
reordering, making the test double match the actual production cache behavior.
electron/embeddings/service.ts-149-152 (1)

149-152: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Delete stored embeddings when a note becomes empty.

At Line 149, empty content is counted as skipped and exits early, but the existing embedding is left intact. Because note-save reindex calls this path, cleared notes can keep appearing in semantic/search results with stale vectors.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/embeddings/service.ts` around lines 149 - 152, When a note's
content_text is empty or only whitespace (checked in the condition at line 149),
the code currently skips processing by incrementing the skipped counter and
continuing to the next iteration, but this leaves any existing embeddings intact
in storage, causing stale vector data to persist in search results. Modify this
block to delete the stored embedding for the note from your embeddings storage
(using an appropriate delete method based on your embeddings data structure)
before or instead of just incrementing skipped and continuing, ensuring that
cleared notes are completely removed from semantic search results.
electron/embeddings/service.ts-205-214 (1)

205-214: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Query cache currently evicts FIFO, not LRU.

On cache hit (Line 205), recency is not refreshed, so eviction at Line 209 removes oldest insertion, not least-recently-used query.

♻️ Suggested fix
   let queryVec = queryCache.get(queryHash);
-  if (!queryVec) {
+  if (queryVec) {
+    // refresh recency for LRU behavior
+    queryCache.delete(queryHash);
+    queryCache.set(queryHash, queryVec);
+  } else {
     const { vector } = await embedChunkedDocument(embed, trimmed, "search_query", model);
     queryVec = vector;
     if (queryCache.size >= QUERY_CACHE_MAX) {
       const firstKey = queryCache.keys().next().value;
       if (firstKey) queryCache.delete(firstKey);
     }
     queryCache.set(queryHash, queryVec);
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/embeddings/service.ts` around lines 205 - 214, The queryCache uses
FIFO eviction instead of LRU because cache hits do not refresh the recency of
accessed entries. When queryVec is retrieved from the cache at the start of the
if statement checking queryCache.get(queryHash), the entry's position in the
cache is not updated to reflect that it was recently accessed. To implement true
LRU eviction, delete and re-insert the queryVec entry into queryCache
immediately after a successful cache hit to mark it as recently used, ensuring
that the FIFO deletion logic at line 209 actually removes the
least-recently-used entry instead of the oldest insertion.
electron/ipc/embeddings-handlers.ts-64-68 (1)

64-68: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

withLock broadcasts success even when the task fails.

The finally block always sends status: "done" and progress: 100 (Line 67), so listeners receive a false success state on exceptions.

🛠️ Suggested fix
-  try {
-    return await p as T;
+  let failed: unknown = null;
+  try {
+    return await p as T;
+  } catch (e) {
+    failed = e;
+    throw e;
   } finally {
     slot.setFlag(false);
-    broadcastProgress(win, { modelId: "", status: "done", progress: 100, loaded: 1, total: 1 });
+    broadcastProgress(win, {
+      modelId: "",
+      status: failed ? "error" : "done",
+      progress: failed ? undefined : 100,
+      loaded: failed ? undefined : 1,
+      total: failed ? undefined : 1,
+      error: failed instanceof Error ? failed.message : failed ? String(failed) : undefined,
+    });
     if (slot.current === p) slot.current = null;
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ipc/embeddings-handlers.ts` around lines 64 - 68, The `finally`
block in the `withLock` function always broadcasts status "done" with progress
100 regardless of whether the task succeeded or failed, causing listeners to
receive false success messages on exceptions. Move the broadcastProgress call
that sends the success status outside the finally block to only execute when the
task completes successfully, while keeping the cleanup operations
(slot.setFlag(false) and slot.current = null) in the finally block since cleanup
must always occur.
electron/ipc/db-handlers.ts-152-160 (1)

152-160: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Serialize per-note incremental reindex jobs to avoid stale overwrite races.

This fire-and-forget path can run concurrent reindexNotes(..., [id], ...) jobs for the same note on rapid saves. Since writes happen after async embedding, older runs can finish last and overwrite newer vectors/hashes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ipc/db-handlers.ts` around lines 152 - 160, The current
implementation of the reindexing logic fires off concurrent
reindexSingleNoteEmbedding operations for the same note without serialization,
which can cause older async jobs to finish after and overwrite newer embedding
data. Implement a per-note job queue or serialization mechanism (such as a Map
of promises keyed by note id) to ensure that reindexSingleNoteEmbedding calls
for the same note are executed sequentially rather than concurrently, preventing
stale writes from overwriting fresh embeddings and ensuring the most recent
reindex operation's results persist.
electron/ipc/db-handlers.ts-27-35 (1)

27-35: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Gate semantic recompute on a successful embedding refresh.

reindexSingleNoteEmbedding always resolves (it catches internally), so semantic recompute runs even when reindexing is skipped/failed. That can refresh semantic edges from stale vectors.

Suggested fix
-async function reindexSingleNoteEmbedding(ctx: DbContext, noteId: string, workspaceId: string): Promise<void> {
+async function reindexSingleNoteEmbedding(ctx: DbContext, noteId: string, workspaceId: string): Promise<boolean> {
   try {
     const settings = getEmbeddingsSettingsCached();
-    if (!settings?.enabled) return;
+    if (!settings?.enabled) return false;
     const model = settings.modelId || getEmbeddingModelId();
     await reindexNotes(ctx.db, workspaceId, [noteId], model);
+    return true;
   } catch (e) {
     console.warn("[embeddings] incremental reindex failed:", e instanceof Error ? e.message : e);
+    return false;
   }
 }
@@
-      void reindexSingleNoteEmbedding(ctx, id, note.workspaceId).then(() => {
+      void reindexSingleNoteEmbedding(ctx, id, note.workspaceId).then((didReindex) => {
+        if (!didReindex) return;
         try {
           computeSemanticRelationships(ctx.db, note.workspaceId, [id]);
         } catch (e) {

Also applies to: 154-160

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ipc/db-handlers.ts` around lines 27 - 35, The
reindexSingleNoteEmbedding function always resolves successfully because its
internal try-catch swallows errors, preventing callers from knowing whether
reindexing actually succeeded or failed. This causes semantic recompute to
proceed even when reindexing is skipped or fails, potentially using stale
vectors. Change the return type of reindexSingleNoteEmbedding from Promise<void>
to Promise<boolean>, return false when the settings are disabled or when an
error occurs, and return true on successful reindex completion. Then update all
callers of reindexSingleNoteEmbedding to check the boolean return value and only
proceed with semantic recompute if the function returns true, indicating
successful reindexing. Apply this same fix to the other similar function
referenced at lines 154-160.
electron/preload.ts-562-566 (1)

562-566: ⚠️ Potential issue | 🟠 Major

Align embeddings settings bridge types with the actual IPC payload shape.

app:getEmbeddingsSettings returns a partial settings object where enabled and modelId are optional, but preload types both as required. This mismatches the actual contract and can mask undefined values at call sites.

Suggested fix
-    getSettings: () => invoke<{ enabled: boolean; modelId: string } | null>("app:getEmbeddingsSettings"),
-    saveSettings: (config: { enabled: boolean; modelId: string }) => invoke<{ ok: boolean }>(
+    getSettings: () => invoke<{ enabled?: boolean; modelId?: string } | null>("app:getEmbeddingsSettings"),
+    saveSettings: (config: { enabled?: boolean; modelId?: string }) => invoke<{ ok: boolean }>(
       "app:saveEmbeddingsSettings",
       { config },
     ),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/preload.ts` around lines 562 - 566, The type definition for the
getSettings function in the preload.ts file incorrectly marks the enabled and
modelId properties as required fields, but the actual app:getEmbeddingsSettings
IPC handler returns a partial settings object where these properties are
optional. Update the invoke type parameter for getSettings to mark both enabled
and modelId as optional properties (using the optional property syntax) so the
type accurately reflects the actual IPC contract and prevents masking of
undefined values at call sites.
🟡 Minor comments (8)
src/components/notes/note-editor.tsx-29-29 (1)

29-29: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clean up the unused SemanticHubsPanel import.

This currently triggers the lint warning and should be removed (or the component should be rendered here).

Proposed fix (if not rendering it here)
-import { SemanticHubsPanel } from "./SemanticHubsPanel";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/notes/note-editor.tsx` at line 29, Remove the unused import
statement for SemanticHubsPanel in the note-editor.tsx file. Since the component
is not being rendered anywhere in the file, the import on line 29 should be
deleted to resolve the lint warning and keep the imports clean.

Source: Linters/SAST tools

src/components/settings/EmbeddingsSettings.tsx-119-128 (1)

119-128: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Don’t route maintenance progress through the model-download listener.

The supplied preload contract wires embeddings.models.onProgress() to embeddings:download-progress with model-download fields, so the !ev.modelId branch will not reliably receive reindex/recompute progress. Add a dedicated maintenance progress listener or poll status() while maintenance is active.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/settings/EmbeddingsSettings.tsx` around lines 119 - 128, The
reindex/recompute progress handling in the
window.electron?.embeddings?.models.onProgress listener is unreliable because
this listener is wired to embeddings:download-progress which carries
model-download specific fields, making the !ev.modelId branch an incorrect
routing path. Remove the status checks for "duplicate", "installed", and "ready"
conditions from within the !ev.modelId branch of the onProgress listener, and
instead create a dedicated maintenance progress listener or implement a polling
mechanism using status() while maintenance is active to properly track and
update the reindex progress state.
electron/embeddings/server.ts-95-97 (1)

95-97: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Return 400 for malformed JSON/validation errors, not 500.

Input parsing failures in handleEmbed currently fall through to the generic 500 path. These are client request errors and should be reported as 400-class responses.

Suggested fix
+class HttpError extends Error {
+  constructor(public status: number, message: string) {
+    super(message);
+  }
+}
+
 async function handleEmbed(
   body: string,
   res: http.ServerResponse,
   configuredModel: string,
 ): Promise<void> {
-  const parsed = JSON.parse(body);
-  const req = EmbedRequest.parse({ ...parsed, model: parsed.model ?? configuredModel });
+  let parsed: unknown;
+  try {
+    parsed = JSON.parse(body);
+  } catch {
+    throw new HttpError(400, "invalid JSON body");
+  }
+  const reqParsed = EmbedRequest.safeParse({
+    ...(parsed as Record<string, unknown>),
+    model: (parsed as { model?: unknown })?.model ?? configuredModel,
+  });
+  if (!reqParsed.success) throw new HttpError(400, "invalid embed request");
+  const req = reqParsed.data;
   if (req.task !== ("search_document" as NomicTask)
     && req.task !== ("search_query" as NomicTask)
     && req.task !== ("clustering" as NomicTask)) {
     sendJson(res, 400, { error: `invalid task: ${req.task}` });
     return;
@@
       } catch (e) {
         const msg = e instanceof Error ? e.message : String(e);
         emit({ kind: "error", msg });
-        sendJson(res, 500, { error: msg });
+        const status = e instanceof HttpError ? e.status : 500;
+        sendJson(res, status, { error: msg });
       }

Also applies to: 123-130

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/embeddings/server.ts` around lines 95 - 97, In the handleEmbed
function, wrap the JSON.parse and EmbedRequest.parse operations in a try-catch
block to catch parsing and validation errors. When either operation fails,
return a 400 status code response with an appropriate error message instead of
allowing the error to propagate to the generic 500 error handler. This applies
to all input parsing sections mentioned in the affected lines, ensuring client
request errors are properly classified as 400-class responses rather than
500-class server errors.
electron/embeddings/client.ts-174-180 (1)

174-180: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Emit ready events to registered progress listeners.

parseStdoutLine() updates internal state for ready but never forwards that event, so listeners never receive the declared { kind: "ready" } notification.

Suggested fix
     case "ready":
       isReady = true;
       if (ev.model) workerModel = ev.model;
+      emitProgress(ev);
       break;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/embeddings/client.ts` around lines 174 - 180, The switch statement
in parseStdoutLine() handles the "ready" event case by updating internal state
(isReady and workerModel) but fails to emit the event to registered listeners.
Add a call to emitProgress(ev) in the "ready" case handler, similar to how the
"progress" case emits events, so that listeners receive the ready notification.
src/components/graph/RadialTreeCanvas.tsx-139-139 (1)

139-139: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use inclusive threshold comparison for semantic edges.

This currently uses >, but the UI communicates a ≥ threshold, so exact-threshold edges are incorrectly hidden.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/graph/RadialTreeCanvas.tsx` at line 139, The semantic edge
filtering logic in RadialTreeCanvas.tsx uses a strict greater-than comparison
with semanticThreshold, but the UI communicates an inclusive threshold behavior.
Change the `>` operator to `>=` in the condition `(e.weight ?? 1) >
semanticThreshold` to ensure that edges with weights equal to the threshold are
included rather than hidden, making the behavior consistent with the UI's
communicated threshold semantics.
electron/embeddings/chunking.test.ts-72-79 (1)

72-79: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Hard timing bound (< 2000ms) is likely CI-flaky.

Line 77 makes this test dependent on machine load/perf rather than correctness invariants.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/embeddings/chunking.test.ts` around lines 72 - 79, Remove the hard
timing bound expectation in the test TERMINATES on very long input (regression:
infinite loop → OOM) by deleting the expect(elapsed).toBeLessThan(2000)
assertion on line 77, as this creates CI flakiness by depending on machine
performance rather than actual correctness. Retain the other assertions
expect(chunks.length).toBeGreaterThan(0) and
expect(chunks.length).toBeLessThan(1000) which validate the actual correctness
invariants that the function terminates and produces a reasonable number of
chunks from the very long input.
src/components/graph/ForceGraphCanvas.tsx-69-69 (1)

69-69: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Threshold comparison should match the UI’s inclusive semantics.

Filtering uses > but the control communicates ≥. Edges with weight exactly equal to the slider value are currently hidden.

🔧 Suggested fix
-      if (link.type === "semantic" && (link.weight ?? 1) <= semanticThreshold) continue;
+      if (link.type === "semantic" && (link.weight ?? 1) < semanticThreshold) continue;
...
-      (e) => e.type !== "semantic" || (e.weight ?? 1) > semanticThreshold,
+      (e) => e.type !== "semantic" || (e.weight ?? 1) >= semanticThreshold,

Also applies to: 143-145

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/graph/ForceGraphCanvas.tsx` at line 69, The threshold
comparison in the semantic link filtering is using `<=` which excludes edges
with weight exactly equal to the semanticThreshold, but the UI semantics expect
these edges to be included. Change the comparison operator from `<=` to `<` in
the condition `(link.weight ?? 1) <= semanticThreshold` on line 69 so that only
edges with weight strictly less than the threshold are filtered out. Apply the
same fix to the similar threshold comparison at lines 143-145 to ensure
consistent behavior throughout the component.
electron/ipc/embeddings-handlers.ts-95-96 (1)

95-96: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

queryNoteId is ignored when excludeIds is present.

At Line 95, nullish coalescing means a provided excludeIds array replaces the queryNoteId exclusion instead of merging it.

♻️ Suggested fix
-    const exclude = args.excludeIds ?? (args.queryNoteId ? [args.queryNoteId] : []);
+    const exclude = [
+      ...(args.excludeIds ?? []),
+      ...(args.queryNoteId ? [args.queryNoteId] : []),
+    ];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ipc/embeddings-handlers.ts` around lines 95 - 96, The exclude
variable construction at line 95 uses nullish coalescing which causes
queryNoteId to be ignored when excludeIds is already provided. Instead of using
the nullish coalescing operator to choose between excludeIds or queryNoteId,
merge both exclusion criteria together by starting with excludeIds (or an empty
array if not provided) and appending queryNoteId to the result when it exists.
This ensures both IDs are included in the exclude array passed to searchAdjacent
regardless of which parameters are present.
🧹 Nitpick comments (2)
src/components/notes/BacklinksPanel.tsx (1)

45-57: ⚡ Quick win

Use the trimmed semantic text for the API call.

The guard uses debouncedSemantic.trim() but the request sends untrimmed text. That can cause avoidable cache misses and extra embedding work for whitespace-only differences.

Proposed fix
   useEffect(() => {
-    if (!semanticEnabled || !workspaceId || !debouncedSemantic.trim() || debouncedSemantic.trim().length < 4) {
+    const trimmed = debouncedSemantic.trim();
+    if (!semanticEnabled || !workspaceId || trimmed.length < 4) {
       setSemanticHits([]);
       setSemanticLoading(false);
       return;
     }
@@
-        const hits = await api.search(workspaceId, debouncedSemantic, {
+        const hits = await api.search(workspaceId, trimmed, {
           queryNoteId: note.id,
           k: 5,
         });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/notes/BacklinksPanel.tsx` around lines 45 - 57, The guard
condition trims debouncedSemantic for validation, but the api.search() method
call passes the untrimmed debouncedSemantic value, causing inconsistency and
unnecessary cache misses. Extract the trimmed semantic text into a variable
before the guard condition and use this trimmed variable in both the condition
check (instead of calling trim() multiple times) and when passing it to the
api.search() method call to ensure the API receives the same trimmed text that
passed validation.
electron/embeddings/chunking.test.ts (1)

25-49: 🏗️ Heavy lift

Testing a copied chunker weakens regression protection.

This suite validates a local reimplementation, not the production function. If service.ts changes independently, these tests can still pass and miss regressions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/embeddings/chunking.test.ts` around lines 25 - 49, The test file
contains a local reimplementation of the chunkLongText function instead of
importing and testing the actual production implementation from service.ts. This
means if the production function changes independently, the tests will still
pass and miss regressions. Remove the local chunkLongText function
implementation from the test file and import the actual production function from
its source module (likely service.ts), then update all test cases to use the
imported production function instead of the copied version.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4acf6b4d-f12c-4490-8f76-4b021f1ed54a

📥 Commits

Reviewing files that changed from the base of the PR and between 2bbd7c6 and b68fb3a.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (42)
  • changelogs/v2.1.1.md
  • docs/plans/local-embeddings.md
  • electron/db/graph-queries.ts
  • electron/db/queries.ts
  • electron/db/schema.ts
  • electron/embeddings/chunking.test.ts
  • electron/embeddings/client.ts
  • electron/embeddings/cosine.test.ts
  • electron/embeddings/cosine.ts
  • electron/embeddings/manifest.ts
  • electron/embeddings/nomic.ts
  • electron/embeddings/pipeline.ts
  • electron/embeddings/port.ts
  • electron/embeddings/projection.test.ts
  • electron/embeddings/projection.ts
  • electron/embeddings/query-cache.test.ts
  • electron/embeddings/server.ts
  • electron/embeddings/service.ts
  • electron/embeddings/types.ts
  • electron/ipc/db-handlers.ts
  • electron/ipc/embeddings-handlers.ts
  • electron/ipc/handlers.ts
  • electron/ipc/registry.ts
  • electron/ipc/settings-handlers.ts
  • electron/lib/config-cache.ts
  • electron/main.ts
  • electron/mcp-server.ts
  • electron/mcp/db.ts
  • electron/preload.ts
  • package.json
  • scripts/build-embeddings-binary.js
  • src/components/graph/ForceGraphCanvas.tsx
  • src/components/graph/KnowledgeGraphView.tsx
  • src/components/graph/RadialTreeCanvas.tsx
  • src/components/notes/BacklinksPanel.tsx
  • src/components/notes/SemanticHubsPanel.tsx
  • src/components/notes/note-editor.tsx
  • src/components/settings/EmbeddingsSettings.tsx
  • src/components/settings/settings-view.tsx
  • src/hooks/useDebouncedValue.ts
  • src/store/slices/graph.ts
  • src/types/index.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/notes/note-editor.tsx`:
- Line 63: Remove the double debounce on semantic content by eliminating the
useDebouncedValue call that creates debouncedSemanticContent in note-editor.tsx.
Instead of debouncing at this layer, pass the raw semanticContent directly to
the BacklinksPanel component, which already handles debouncing internally.
Update any references to debouncedSemanticContent to use semanticContent
instead, keeping the debounce logic only in the BacklinksPanel component to
avoid unnecessary latency and stale data issues.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6314247a-a537-49d2-a9a6-2b2aeb915fbb

📥 Commits

Reviewing files that changed from the base of the PR and between b68fb3a and 88292ab.

📒 Files selected for processing (4)
  • electron/embeddings/client.ts
  • src/components/graph/RadialTreeCanvas.tsx
  • src/components/notes/BacklinksPanel.tsx
  • src/components/notes/note-editor.tsx
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/components/graph/RadialTreeCanvas.tsx
  • src/components/notes/BacklinksPanel.tsx
  • electron/embeddings/client.ts

Comment thread src/components/notes/note-editor.tsx Outdated
ddutchie added 5 commits June 18, 2026 21:47
- electron/embeddings/service.bench.test.ts: 12 tests measuring
  chunkLongText / averageVectors / cosine / topK / projectTo2d across
  tiny→xlarge fixtures. Documents observed ONNX worker timings and memory
  footprint in footer comment for M-series Macs (cold start, warm embed,
  batch latencies, RSS).
- electron/embeddings/service.ts: emit onProgress per note (was per batch
  of 16) in reindexNotes and recomputeProjections, plus an initial 0/total
  tick so the Settings progress bar shows scale immediately and advances
  per note instead of jumping in steps of 16.

npm test: 475 passing; lint clean; type-check clean
Xenova's pipeline() silently fell back to onnxruntime-web (WASM) which
pre-allocated a 17.4 GB WebAssembly.Memory arena at idle. Bypass that by
loading the tokenizer via xenova's AutoTokenizer and running inference
directly through onnxruntime-node's InferenceSession with the native CPU
execution provider.

electron/embeddings/pipeline.ts: rewrite to use
  AutoTokenizer.from_pretrained (xenova) for tokenization
  ort.InferenceSession.create (native onnxruntime-node) for inference
  inline mean-pooling + L2 normalization (replaces xenova's pipeline)
  enableCpuMemArena=false, enableMemPattern=false in session options

electron/embeddings/client.ts: lower --max-old-space-size from 4096 to 512
  (JS heap only; ONNX C++ memory is separate)

electron/embeddings/service.bench.test.ts: update documented timings with
  real M5 Pro measurements (native vs WASM comparison)

Measured on M5 Pro (native CPU backend):
  idle RSS:     444 MB  (was 17.4 GB on WASM — 40× improvement)
  small note:    79 ms  (was 156 ms — 2× faster)
  medium note:  462 ms  (was 825 ms — 1.8× faster)
  batch 16×2KB: 1.2 s   (was 2.4 s — 2× faster)

Peak RSS during unchunked 32 KB inference is ~20 GB (attention matrix
O(seq_len²)); production chunking at 4 KB keeps peak under 1 GB.

npm test: 475 passing; lint clean; type-check clean
- pipeline.ts: remove dead WASM env config (numThreads, proxy) — we no
  longer use onnxruntime-web; fix _pipelinePromise self-reference bug
  (was always true)
- server.ts: remove unused withNomicPrefix import + re-export, redundant
  NomicTask type import + manual task validation (zod EmbedRequest schema
  already validates the task enum)
- nomic.ts: remove unused exports (taskForIndexing, taskForQuery,
  taskForClustering, defaultModelId, normalizeModel)
embedChunkedDocument previously sent chunks in batches of 16 to the
worker in a single HTTP /embed call. The worker's native ONNX session
creates an attention matrix of [batch, 12, seqLen, seqLen] per call, so
a 16-chunk batch (16 × 1000 tokens) peaked at 4–5 GB of RSS.

Fix: send each chunk as a separate single-text HTTP call. The worker's
HTTP server processes them sequentially (one session.run() at a time),
keeping peak RSS at ~750 MB regardless of note size.

Measured on M5 Pro with 512 MB JS heap cap:
  4 KB note (1 chunk):  174 ms,  RSS 701 MB
  8 KB note (3 chunks): 344 ms,  RSS 718 MB
 32 KB note (9 chunks): 1.5 s,   RSS 743 MB
 100 sequential 4 KB:  no leak (RSS even dropped to 646 MB)

Previously: 32 KB note in one batch → 4 GB peak RSS.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@electron/embeddings/pipeline.ts`:
- Line 46: The single-flight check in the pipeline initialization ignores the
modelId parameter, causing requests with different model IDs to reuse the same
cached pipeline promise and produce embeddings with the wrong model. Modify the
caching logic to be model-aware by either tracking which modelId the cached
_pipelinePromise is associated with or using a map keyed by modelId to store
separate pipeline promises for each model. Only return the cached
_pipelinePromise if the modelId matches the current request, otherwise proceed
to create a new pipeline for the different model.

In `@electron/embeddings/service.bench.test.ts`:
- Around line 124-133: The topK(1000→5) benchmark ceiling value is set too
tightly at 2ms in the CEILINGS object, causing CI flake failures when actual
performance (2.289ms) exceeds this threshold. Increase the ceiling value for the
"topK(1000→5)" entry from 2 to a higher value (such as 3 or more) to accommodate
natural CI variance while still catching real performance regressions.
Alternatively, you could add conditional logic to skip this benchmark when
running in a CI environment by checking for CI environment variables.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: abae5469-3bc5-4f52-9a5d-2c453bd7b41d

📥 Commits

Reviewing files that changed from the base of the PR and between 0996d59 and fea2772.

📒 Files selected for processing (6)
  • electron/embeddings/client.ts
  • electron/embeddings/nomic.ts
  • electron/embeddings/pipeline.ts
  • electron/embeddings/server.ts
  • electron/embeddings/service.bench.test.ts
  • electron/embeddings/service.ts
💤 Files with no reviewable changes (1)
  • electron/embeddings/nomic.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • electron/embeddings/service.ts
  • electron/embeddings/client.ts

Comment thread electron/embeddings/pipeline.ts Outdated
Comment thread electron/embeddings/service.bench.test.ts
ddutchie added 13 commits June 19, 2026 07:15
The reindex button in Settings called reindexNotes() but never
computeSemanticRelationships() — so no 'semantic' rows were ever
written to relationship_cache. The incremental path (single note save
in db-handlers.ts) did call it, but a first-time full reindex from
Settings produced embeddings without any semantic edges in the graph.

Fix: call computeSemanticRelationships() immediately after a successful
reindex in the db:embeddings:reindex handler, passing through the
noteIds for incremental mode.

Tests: 12 new tests in electron/db/graph-queries.test.ts covering:
  - No edges when embeddings table empty
  - Edges created for similar note pairs (cosine ≥ 0.78)
  - No edges for dissimilar notes
  - Deduplication (canonical source < target ordering)
  - Incremental mode (entityIds filter)
  - Idempotency (calling twice doesn't duplicate)
  - loadGraph integration: includeAuto, edgeTypes filter, includeAuto=false
  - Weight rounding to 2 decimal places
  - Client-side threshold filter simulation

npm test: 487 passing; lint clean; type-check clean
Previous guard 'if (result.indexed > 0)' skipped semantic edge
computation when all notes already had up-to-date embeddings. But
semantic relationships may not exist yet even if embeddings do —
e.g. user indexed once before the semantic feature was wired up, then
clicks Reindex and nothing gets re-embedded, so no semantic edges are
created.

Changed to 'if (result.total > 0)' — compute relationships as long as
there are notes in scope, regardless of whether any were re-indexed.
Previous behaviour: only note pairs with cosine >= 0.78 were connected.
nomic-embed-text-v1.5 cosines for related-but-not-duplicate notes typically
land in 0.60-0.75 range, so most notes ended up with zero semantic edges
even after a successful reindex.

New behaviour:
  - Each note connects to its K=5 most similar peers (regardless of absolute
    cosine), provided cosine >= 0.55 floor (filters truly unrelated noise)
  - Edges deduplicated via canonical source<target ordering
  - k = min(5, pool_size - 1) handles tiny workspaces
  - Slider still filters client-side by weight, so users can still tighten

Removed unused SEMANTIC_THRESHOLD constant.

Tests: 16 tests in graph-queries.test.ts covering top-K behaviour, floor,
deduplication, incremental mode, idempotency, weight rounding, and KG
integration (includeAuto, edgeTypes filter, client-side threshold).
npm test: 491 passing; lint clean; type-check clean.
4 new tests in electron/db/graph-queries.test.ts:

1. Topic clusters test — 6 notes representing real-world topics
   (React Hooks, Vue Composables, Svelte Stores, Python Decorators,
   Ruby Blocks, Pizza Recipe) with hand-crafted embedding vectors
   reflecting realistic cosine similarities. Verifies:
   - Frontend cluster (React/Vue/Svelte) gets 3 inter-cluster edges
   - Backend cluster (Python/Ruby) gets 1 edge
   - Unrelated topic (Pizza) has 0 edges
   - No cross-cluster edges between frontend and backend
   - Exact weights match computed cosines

2. Slider threshold test — verifies that lowering the threshold
   reveals weaker edges (React↔Svelte 0.88) while strong edges
   (React↔Vue 0.98) remain visible at all threshold settings.

3. Slider at 1.0 hides all semantic edges — confirms the default
   'off' setting produces a hard-links-only graph.

4. Incremental recompute preserves other clusters' edges — editing
   one note only recomputes edges touching that note, leaving other
   cluster edges intact.

Added cosineApprox() and round2() helpers for in-test verification.

Tests: 495 passing; lint clean; type-check clean.
Previously, each note got a single embedding vector — the averaged result of
all its content. This diluted multi-topic notes: a note with '## Architecture'
and '## Marketing' sections ended up semantically distant from both pure-
architecture and pure-marketing notes, leaving it disconnected in the graph.

Now notes are split by ## and # headers, and each section gets its own
embedding vector. This creates far more — and more precise — connections:
  - A multi-topic note connects to architecture notes via its Architecture
    section AND marketing notes via its Marketing section
  - BacklinksPanel shows which section matched
  - KG semantic edges carry source/target section titles

Changes:
  - New: electron/embeddings/sections.ts — splitIntoSections() parser
    (splits on # and ## only; ### and deeper stay in parent section)
  - Schema v18: note_embeddings PK (note_id) → (note_id, section_idx),
    adds section_title column. relationship_cache gets source_section_title
    and target_section_title columns for semantic edges.
  - queries.ts: upsertNoteEmbedding now takes sectionIdx + sectionTitle;
    getNoteEmbedding → getNoteEmbeddings (returns array);
    deleteNoteEmbeddingSections prunes from a given section_idx onward;
    NoteEmbeddingRecord has sectionIdx + sectionTitle fields
  - service.ts: reindexNotes splits each note into sections and embeds
    each separately; searchAdjacent deduplicates by noteId keeping best
    section score + returns sectionTitle; recomputeProjections averages
    section vectors per note for the 2D scatter plot
  - graph-queries.ts: computeSemanticRelationships compares all section
    vectors across all notes, finds top-K nearest sections, maps back to
    note-pair level keeping best weight per pair
  - BacklinksPanel: shows matching section title under hit title
  - GraphEdge type: sourceSectionTitle + targetSectionTitle fields

Tests: 512 passing (21 new — 12 section parser + 9 multi-section scenarios)
  - 'A note with 2 sections gets edges from BOTH sections'
  - 'Section titles are stored on the semantic edge'
  - 'Best weight per note-pair wins when multiple sections match'
  - 'Multi-topic note discovers connections via sections'
  - 'Plan connects to architecture AND marketing notes but not cooking'
1. pipeline.ts: single-flight cache was not model-aware — if a request
   for model B arrived while model A was still loading, it would reuse
   model A's in-flight promise and produce embeddings with the wrong
   model. Added _pipelinePromiseModelId tracking so the cached promise
   is only reused when the modelId matches. Cleared on reset/error.

2. service.bench.test.ts: topK(1000→5) ceiling was 2ms, too tight for
   CI variance (observed 2.289ms). Raised to 5ms — still catches
   catastrophic regressions while accommodating natural jitter.

Tests: 512 passing; lint clean; type-check clean.
…ding

Section texts have varying lengths. Without padding:true and
truncation:true, the tokenizer produces jagged tensors that ONNX
rejects with HTTP 500: 'Unable to create tensor, you should probably
activate truncation and/or padding.'

This only surfaced after the section-based embeddings change — before,
each note was embedded individually (batch size 1), so all tensors
were uniform by construction. Now multiple section texts are batched
together, requiring padding to the longest sequence in the batch.
The section-based embeddings change accidentally reverted the
sequential embedding optimization from v2.1.1. It was batching all
sections from up to 16 notes into a single embed() call, which:
  1. Caused the ONNX tensor shape error (varying-length texts batched)
  2. Recreated the 4GB attention matrix spike we had eliminated
  3. Was slower (benchmarks showed sequential is 15× faster for 4KB)

Fixed both call sites:
  - reindexNotes: embed one section text per HTTP call
  - recomputeProjections: same

The padding:true/truncation:true fix from the previous commit is kept
as a safety net for the query path, but the primary fix is sequential.
…ote progress

1. Schema: v18 migration was initially deployed without the ALTER TABLE
   statements for source_section_title / target_section_title on
   relationship_cache. They were added in a later edit, but databases that
   already ran v18 had user_version=18 and never re-ran the migration.
   v19 adds them idempotently (checks PRAGMA table_info before ALTER).

2. reindexNotes: progress now fires per-note (not per-batch of 16).
   Previous code collected all sections from 16 notes, embedded them,
   then emitted progress for all 16 at once. Now each note is processed
   individually: split → embed sections → upsert → emit progress.

3. recomputeProjections: same per-note progress fix.

4. Removed unused 'didAnything' variable in recomputeProjections.

Tests: 512 passing; lint clean; type-check clean.
computeAutoRelationships used a DELETE without a type filter, which
wiped all relationship_cache rows for each entity — including semantic
edges that were computed separately by computeSemanticRelationships.

Added 'AND type != \'semantic\'' to the delete statement so only
co-mention, keyword, assignee, and wikilink edges are cleared and
recomputed. Semantic edges persist until the next reindex/recompute
explicitly calls computeSemanticRelationships.
When a node is selected:
  - Connected edges brighten (opacity 0.85, width 2.5)
  - Non-connected edges dim to ~12% opacity
  - Connected nodes stay full opacity
  - Non-connected nodes dim (#alpha 30)
  - Tree links (radial) dim to 10% opacity

Clicking background clears selection → all edges/nodes reset to normal.

Applied to both ForceGraphCanvas and RadialTreeCanvas.
…linksPanel

- MCP tool cairn_get_semantic_neighbors(noteId): queries pre-computed
  relationship_cache for semantic edges, returns neighbor titles, weights,
  and matching section titles
- KG edge hover tooltips: hovering semantic edges in ForceGraph and
  RadialTree shows 'NoteA › SectionA ↔ NoteB › SectionB · 87%'
- Section-level BacklinksPanel: tracks active ## section from CodeMirror
  cursor, runs a second semantic search (k=3) on section text, renders
  'Related to SectionName' group deduplicated against whole-note hits
- ForceGraph: onRenderFramePost draws hovered/selected nodes on top of
  all other nodes/edges; full title (60 chars) on highlight; removed
  nodeLabel to eliminate duplicate browser tooltip
- RadialTree: bring highlighted nodes to front via DOM re-append;
  full title on highlight; removed 120-edge cap; semantic edges use
  0.5 opacity (was 0.1); edges sorted to prioritize semantic/wikilink
- Tooltip wrapping: max-w-[280px] break-words in both canvases
- Tooltip hide: clears on node hover, background click, mouseleave
- toc-utils: findSectionTitleAtOffset + extractSectionTextAtOffset
  helpers for cursor-based section tracking
Repository owner deleted a comment from coderabbitai Bot Jun 19, 2026
@ddutchie

Copy link
Copy Markdown
Owner Author

@coderabbitai you seem to be stuck are we good to merge?

@coderabbitai

coderabbitai Bot commented Jun 19, 2026 •

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== PR STATUS ==="
gh pr view 54 --repo ddutchie/cairn --json state,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup

echo ""
echo "=== OPEN REVIEW COMMENTS ==="
gh pr view 54 --repo ddutchie/cairn --json reviews,comments

echo ""
echo "=== MY (coderabbitai) COMMENTS ==="
gh api repos/ddutchie/cairn/pulls/54/comments --jq '.[] | select(.user.login == "coderabbitai") | {id: .id, path: .path, body: .body[0:120], resolved: .resolved}'

echo ""
echo "=== PR CHECKS ==="
gh pr checks 54 --repo ddutchie/cairn

Length of output: 85976



@ddutchie — not stuck, just paused! Here's the current picture:

✅ CI: All green

Check Status
Build ✅ pass
E2E tests ✅ pass
Unit tests ✅ pass
Type check ✅ pass
Lint ✅ pass

⏸️ CodeRabbit review: Auto-paused + new review in progress

The review was automatically paused due to the burst of commits, and there's a new review run processing the latest changes right now. Let me resume to complete it.

⚠️ Outstanding findings from prior review rounds

Before merging, you may want to consider these previously flagged items (not exhaustive):

  • Linux binary name mismatch (scripts/build-embeddings-binary.js Line 37): output is cairn-embeddings-linux but client.ts resolves it as cairn-embeddings — embeddings won't start on Linux packages
  • Incremental semantic recompute skips valid pairs (electron/db/graph-queries.ts Line 768): the a.id >= b.id guard incorrectly drops pairs when the changed entity ID sorts after the unchanged one
  • Stale overwrite race on rapid saves (electron/ipc/db-handlers.ts Lines 152-160): concurrent fire-and-forget reindex jobs for the same note can overwrite newer vectors with older ones
  • withLock broadcasts success on failure (electron/ipc/embeddings-handlers.ts Lines 64-68): finally always sends status: "done" even on exceptions
  • queryNoteId ignored when excludeIds is provided (electron/ipc/embeddings-handlers.ts Line 95): nullish coalescing replaces rather than merges both exclusion lists

Let me trigger the full review now so you get complete inline comments on the latest commits.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (3)
changelogs/v2.1.1.md (1)

21-21: 💤 Low value

Use American English "afterward" instead of "afterwards".

American English prefers "afterward"; "afterwards" is more common in British English.

✏️ Proposed fix
- ... Embeddings to backfill existing notes; afterwards, the index stays fresh on its own.
+ ... Embeddings to backfill existing notes; afterward, the index stays fresh on its own.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@changelogs/v2.1.1.md` at line 21, In the changelog entry for the "Incremental
Auto-Reindex on Save" feature, replace the word "afterwards" with "afterward" to
use American English spelling conventions, which is the preferred standard for
this project.

Source: Linters/SAST tools

src/components/notes/BacklinksPanel.tsx (1)

104-108: 💤 Low value

Redundant === true in boolean expressions.

The chained equality ?.kind === "loading" === true works but is confusing due to left-to-right associativity parsing as (?.kind === "loading") === true. Since the comparison already yields a boolean (or undefined, which is falsy), the === true suffix adds no value.

♻️ Simplify boolean expressions
-  const sectionLoading = canSectionSearch && sectionSearch?.kind === "loading" === true;
+  const sectionLoading = canSectionSearch && sectionSearch?.kind === "loading";
   const sectionNoteIds = new Set(sectionHits.map((h) => h.noteId));
   const semanticHits = search?.kind === "results" ? search.hits.filter((h) => !sectionNoteIds.has(h.noteId)) : [];
-  const semanticLoading = search?.kind === "loading" === true;
+  const semanticLoading = search?.kind === "loading";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/notes/BacklinksPanel.tsx` around lines 104 - 108, The boolean
expressions for sectionLoading and semanticLoading variables contain redundant
`=== true` suffixes that add no value since the comparisons already evaluate to
boolean values. Remove the `=== true` portion from both the sectionLoading
assignment (where it checks `sectionSearch?.kind === "loading"`) and the
semanticLoading assignment (where it checks `search?.kind === "loading"`),
leaving just the direct comparison which will evaluate to the appropriate
boolean value.
src/components/graph/RadialTreeCanvas.tsx (1)

160-164: 💤 Low value

Stale comment references removed functionality.

The comment mentions "120-edge cap" but the edge limit (slice(0, 120)) was removed per the PR changes. Update or remove this comment to avoid confusion.

🔧 Suggested fix
-    // Prioritise semantic and wikilink edges so they aren't lost in the 120-edge cap
-    crossEdges.sort((a, b) => {
+    // Render semantic and wikilink edges first for visual prominence
+    crossEdges.sort((a, b) => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/graph/RadialTreeCanvas.tsx` around lines 160 - 164, The
comment in the crossEdges sort function references a "120-edge cap" that no
longer exists after removing the slice(0, 120) limit from the code. Update the
comment to remove the reference to the 120-edge cap and simply describe the
current sorting behavior, or remove the comment entirely if it becomes redundant
after the update. Ensure the remaining comment accurately reflects what the sort
function is actually doing.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@electron/embeddings/service.ts`:
- Around line 301-312: The hash comparison in the staleness check is using
incompatible hashing schemes that will never match, causing all notes to be
re-embedded unnecessarily. The issue is in the loop where you compute `hash`
from the full note content (`sha256(text)` where text is
`${n.title}\n\n${n.content_text}`) but then compare it against `r.contentHash`
in the `recs.some()` condition, which stores per-section hashes computed
differently. Fix this by either storing a note-level hash that matches your
full-content hash computation, or by changing the comparison logic to check
section-level hashes correctly by recomputing section hashes and comparing them
against the stored per-section hashes in the records. Ensure the hash
computation method is consistent between what you store and what you compare.

In `@electron/mcp/tools/graph.ts`:
- Around line 44-50: The get_semantic_neighbors function lacks workspace
scoping, allowing cross-workspace data exposure. Update the function signature
to extract and validate workspaceId from the args parameter (similar to how
noteId is validated), returning an error if workspaceId is missing. Then pass
the validated workspaceId to the getSemanticNeighbors function call. Finally,
modify the underlying getSemanticNeighbors implementation to filter results by
workspace, ensuring the semantic neighbors query includes a workspace predicate
through note ownership constraints to maintain data isolation consistent with
get_knowledge_graph and get_neighbors.

In `@src/components/notes/markdown-editor.tsx`:
- Around line 180-186: When coordsAtPos(from) returns null in the
markdown-editor.tsx file, the onSelectionChange callback is not being invoked,
leaving stale selection UI displayed to consumers. Modify the selection change
logic to handle the case when coordsFrom is falsy by still calling
onSelectionChange but with a payload that signals to clear the selection state
(such as passing null or undefined for coords, or an explicit flag). This
ensures that consumers can properly clear outdated selection UI when position
coordinates are unavailable.

In `@src/components/notes/toc-utils.ts`:
- Around line 63-69: The loop logic incorrectly breaks out of the iteration
before evaluating whether the current line is a heading. When the cursor offset
falls within a heading line (the condition `offset < lineEnd` becomes true), the
code breaks immediately without checking if that line matches the heading
pattern with the regex `/^(#{1,2})\s+(.+)$/`. This causes the function to return
the previous section title instead of the current one. Reorder the conditions in
the loop so that the heading match check and currentTitle update happen before
the break condition check, ensuring that if the cursor is positioned on a
heading line, that heading is correctly identified as the current title before
exiting the loop.

---

Nitpick comments:
In `@changelogs/v2.1.1.md`:
- Line 21: In the changelog entry for the "Incremental Auto-Reindex on Save"
feature, replace the word "afterwards" with "afterward" to use American English
spelling conventions, which is the preferred standard for this project.

In `@src/components/graph/RadialTreeCanvas.tsx`:
- Around line 160-164: The comment in the crossEdges sort function references a
"120-edge cap" that no longer exists after removing the slice(0, 120) limit from
the code. Update the comment to remove the reference to the 120-edge cap and
simply describe the current sorting behavior, or remove the comment entirely if
it becomes redundant after the update. Ensure the remaining comment accurately
reflects what the sort function is actually doing.

In `@src/components/notes/BacklinksPanel.tsx`:
- Around line 104-108: The boolean expressions for sectionLoading and
semanticLoading variables contain redundant `=== true` suffixes that add no
value since the comparisons already evaluate to boolean values. Remove the `===
true` portion from both the sectionLoading assignment (where it checks
`sectionSearch?.kind === "loading"`) and the semanticLoading assignment (where
it checks `search?.kind === "loading"`), leaving just the direct comparison
which will evaluate to the appropriate boolean value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0c3de4be-f050-4ddf-8da1-671304448047

📥 Commits

Reviewing files that changed from the base of the PR and between fea2772 and d562b4a.

📒 Files selected for processing (23)
  • changelogs/v2.1.1.md
  • electron/db/graph-queries.test.ts
  • electron/db/graph-queries.ts
  • electron/db/queries.ts
  • electron/db/schema.ts
  • electron/embeddings/chunking.test.ts
  • electron/embeddings/pipeline.ts
  • electron/embeddings/sections.test.ts
  • electron/embeddings/sections.ts
  • electron/embeddings/service.bench.test.ts
  • electron/embeddings/service.ts
  • electron/ipc/embeddings-handlers.ts
  • electron/lib/tool-schemas.ts
  • electron/mcp/tools/graph.ts
  • electron/mcp/tools/index.ts
  • electron/preload.ts
  • src/components/graph/ForceGraphCanvas.tsx
  • src/components/graph/RadialTreeCanvas.tsx
  • src/components/notes/BacklinksPanel.tsx
  • src/components/notes/markdown-editor.tsx
  • src/components/notes/note-editor.tsx
  • src/components/notes/toc-utils.ts
  • src/types/index.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • electron/embeddings/chunking.test.ts
  • electron/embeddings/service.bench.test.ts
  • electron/preload.ts
  • electron/ipc/embeddings-handlers.ts
  • electron/embeddings/pipeline.ts
  • src/components/notes/note-editor.tsx

Comment thread electron/embeddings/service.ts
Comment thread electron/mcp/tools/graph.ts
Comment thread src/components/notes/markdown-editor.tsx
Comment thread src/components/notes/toc-utils.ts
@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
{"name":"HttpError","status":404,"request":{"method":"PATCH","url":"https://api.github.com/repos/ddutchie/cairn/issues/comments/4752314673","headers":{"accept":"application/vnd.github.v3+json","user-agent":"octokit.js/0.0.0-development octokit-core.js/7.0.6 Node.js/24","authorization":"token [REDACTED]","content-type":"application/json; charset=utf-8"},"body":{"body":"<!-- This is an auto-generated reply by CodeRabbit -->\n<!-- CodeRabbit review command invocation: b02bc278-7c6a-492b-aed4-c4f4e8bb4ca5 -->\n`@ddutchie` Sure! I'll kick off a full review of this PR right away.\n\n<details>\n<summary>✅ Action performed</summary>\n\nReview finished.\n\n> Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.\n\n</details>"},"request":{"retryCount":1,"signal":{}}},"response":{"url":"https://api.github.com/repos/ddutchie/cairn/issues/comments/4752314673","status":404,"headers":{"access-control-allow-origin":"*","access-control-expose-headers":"ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning","content-encoding":"gzip","content-security-policy":"default-src 'none'","content-type":"application/json; charset=utf-8","date":"Fri, 19 Jun 2026 14:30:23 GMT","referrer-policy":"origin-when-cross-origin, strict-origin-when-cross-origin","server":"github.com","strict-transport-security":"max-age=31536000; includeSubdomains; preload","transfer-encoding":"chunked","vary":"Accept-Encoding, Accept, X-Requested-With","x-accepted-github-permissions":"issues=write; pull_requests=write","x-content-type-options":"nosniff","x-frame-options":"deny","x-github-api-version-selected":"2022-11-28","x-github-media-type":"github.v3; format=json","x-github-request-id":"0CB1:2E4FF9:8F98A2:2132C99:6A35527E","x-ratelimit-limit":"5000","x-ratelimit-remaining":"4946","x-ratelimit-reset":"1781879597","x-ratelimit-resource":"core","x-ratelimit-used":"54","x-xss-protection":"0"},"data":{"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#update-an-issue-comment","status":"404"}}}

@ddutchie

Copy link
Copy Markdown
Owner Author

@coderabbitai thoughts on this new feature?

@coderabbitai

coderabbitai Bot commented Jun 19, 2026 •

Copy link
Copy Markdown

@ddutchie — happy to share my overall take!

This is a genuinely well-designed feature. Here are my thoughts:


🟢 What's excellent

Architecture — Running the ONNX model in a separate HTTP worker process is the right call. It isolates ~750 MB of model memory from the Electron main process, allows clean lifecycle management (spawn-on-demand, health polling, SIGTERM on quit), and keeps the preload bridge thin.

Section-based embeddings — This is the most interesting design decision. Rather than one vector per note, each ##/# section gets its own vector. The payoff is that a multi-topic note (e.g., one covering architecture and marketing) can form semantic edges to topic-specific notes via its matching section, rather than having a diluted note-level vector that connects to nothing well. The section splitter tests validate this thoroughly.

Incremental correctness — content_hash deduplication on save means the system skips re-embedding unchanged sections, which is important for keeping saves fast. The computeSemanticRelationships incremental path (entityIds scoping) also limits the blast radius of recomputes.

Test coverage — 506-line graph-queries test suite with realistic cluster scenarios, plus section parser, cosine/topK, chunking termination, projection determinism, and LRU cache tests. This is solid for a feature of this complexity.

Semantic UX — The similarity slider (off → 1.0) and the BacklinksPanel "Semantic" section with auto-expand are well thought-out affordances. Users can tune sensitivity rather than live with a hard threshold.


🟡 Concerns to weigh before merging

These were flagged in the review rounds — some matter more than others:

Severity Issue Location
🔴 Critical Linux binary name mismatch — build-embeddings-binary.js emits cairn-embeddings-linux but client.ts resolves cairn-embeddings; embeddings won't start in Linux packages scripts/build-embeddings-binary.js Line 37
🟠 High Incremental recompute drops valid pairs — a.id >= b.id guard skips pairs where the changed entity sorts after the unchanged one electron/db/graph-queries.ts Line 768
🟠 High Stale overwrite race — rapid saves trigger concurrent fire-and-forget reindex jobs that can overwrite newer vectors with older results electron/ipc/db-handlers.ts Lines 152–160
🟡 Medium withLock always broadcasts "done" — the finally block sends success even on exceptions electron/ipc/embeddings-handlers.ts Lines 64–68
🟡 Medium queryNoteId exclusion ignored — ?? replaces rather than merges with excludeIds electron/ipc/embeddings-handlers.ts Line 95

The Linux binary mismatch is a ship-blocker for any cross-platform release. The incremental recompute and race condition are correctness issues but degrade gracefully (a full reindex fixes them). The withLock/excludeIds ones are minor.


📋 Bottom line

The feature is production-quality in design and testing. It's a meaningful capability addition — local, private semantic search with no cloud dependency is genuinely useful. The Linux binary name is the one thing I'd fix before a cross-platform release; the others can be addressed in a follow-up. If this is Mac-only for v2.1.1, you're likely fine to merge now.

Would you like me to open issues for the items you want to track post-merge?

✅ Action performed

Full review finished.

@ddutchie

Copy link
Copy Markdown
Owner Author

@coderabbitai. those comments have already been adressed?

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== 1. Linux binary name (build-embeddings-binary.js) ==="
grep -n "cairn-embeddings" scripts/build-embeddings-binary.js || echo "NOT FOUND"

echo ""
echo "=== client.ts binary resolution ==="
grep -n "cairn-embeddings" electron/embeddings/client.ts || echo "NOT FOUND"

echo ""
echo "=== 2. Incremental recompute pair guard (graph-queries.ts ~line 768) ==="
grep -n "a\.id.*b\.id\|b\.id.*a\.id\|>=\|canonical" electron/db/graph-queries.ts | head -30

echo ""
echo "=== 3. Stale overwrite race (db-handlers.ts ~lines 152-160) ==="
sed -n '140,180p' electron/ipc/db-handlers.ts

echo ""
echo "=== 4. withLock broadcasts done on failure (embeddings-handlers.ts ~lines 60-75) ==="
sed -n '55,80p' electron/ipc/embeddings-handlers.ts

echo ""
echo "=== 5. queryNoteId exclusion (embeddings-handlers.ts ~line 95) ==="
sed -n '85,110p' electron/ipc/embeddings-handlers.ts

Length of output: 5928


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== reindexSingleNoteEmbedding implementation ==="
grep -n "reindexSingleNoteEmbedding\|debounce\|pending\|inFlight\|lock\|queue" electron/ipc/db-handlers.ts | head -30

echo ""
echo "=== Full search handler (queryNoteId + excludeIds) ==="
sed -n '110,160p' electron/ipc/embeddings-handlers.ts

echo ""
echo "=== reindexSingleNoteEmbedding body ==="
grep -n -A 20 "async function reindexSingleNoteEmbedding" electron/ipc/db-handlers.ts

Length of output: 4400



@ddutchie — yes, all 5 are addressed! Here's the confirmation:

# Issue Status
🔴 Linux binary name mismatch build-embeddings-binary.js Line 36 now outputs cairn-embeddings (not cairn-embeddings-linux), matching client.ts ✅ Fixed
🟠 Incremental recompute drops valid pairs Line 679 now checks !filteredIds.has(a.id) && !filteredIds.has(b.id) — if either note in a pair is in the changed set, the pair is processed ✅ Fixed
🟠 Stale overwrite race reindexSingleNoteEmbedding now uses a reindexInFlight Map — if a reindex for the same note is already running, it awaits it before starting a new one, serializing per-note jobs ✅ Fixed
🟡 withLock broadcasts done on failure The finally block now conditionally broadcasts "error" with the message on failure, and only "done" on success ✅ Fixed
🟡 queryNoteId exclusion ignored The search handler now spreads both args.excludeIds and args.queryNoteId into a unified exclude array ✅ Fixed

You're good to merge. 🐇✨

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.

1 participant