diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index f05dde26..1baafe37 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -25,6 +25,8 @@ - [ ] New IPC handlers wrapped in `handle()` and return `IpcResult` - [ ] New DB migrations appended (not edited) in `schema.ts` - [ ] 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) +- [ ] New `dependencies` or `devDependencies` added to `ROLE_MAP` in `scripts/generate-licenses.js` and `licenses.json` regenerated +- [ ] New MCP tools registered in `electron/mcp/tools/index.ts` dispatch + `electron/lib/tool-schemas.ts` Zod schema ## Notes for reviewer diff --git a/changelogs/v2.1.1.md b/changelogs/v2.1.1.md new file mode 100644 index 00000000..b4ff3012 --- /dev/null +++ b/changelogs/v2.1.1.md @@ -0,0 +1,86 @@ +## What's new + +### Features + +- **Local Embeddings (Semantic Search)**: Notes now have semantic neighbour search powered by a packaged local embedding model (`nomic-ai/nomic-embed-text-v1.5`, int8 quantised, ~131 MB). A background HTTP worker (Node-based, spawnable also as a standalone `cairn-embeddings` binary via `@yao-pkg/pkg`) handles inference through `@xenova/transformers` tokenizers + `onnxruntime-node` native CPU runtime. Embeddings live in the `note_embeddings` SQLite table (section-based, v18 schema). The Knowledge Graph gains a `"semantic"` edge type (top-5 nearest neighbours per note, 0.55 floor) with an inline opacity slider in the toolbar, and the Notes editor's Backlinks panel gains a "Semantic" section showing the top-5 most similar notes with score bars and matching section titles. Toggle via the Sparkles icon in the editor header. + +- **Section-Based Embeddings**: Notes are now split into sections by `#`/`##` headers, with each section receiving its own embedding vector. Multi-topic notes connect to different topic clusters via different sections instead of diluting signal into a single averaged vector. The `note_embeddings` table PK changed from `(note_id)` to `(note_id, section_idx)` with a `section_title` column (v18 migration); `relationship_cache` gains `source_section_title`/`target_section_title` columns (v19 failsafe migration). Semantic edges store the matching section titles for provenance. + +- **Top-K Semantic Edges**: Replaced the absolute 0.78 cosine threshold with top-5 nearest neighbours per note (floor 0.55). The old threshold only captured near-duplicates; `nomic-embed-text-v1.5` cosines for related-but-not-duplicate notes typically land 0.60–0.75. Multi-section notes get edges from all sections, with the best weight per note-pair winning. + +- **MCP Tool `cairn_get_semantic_neighbors`**: New MCP tool exposes pre-computed semantic edges to the agent. Given a `noteId`, returns related notes with similarity weights and matching section titles — enabling the agent to discover connections that keyword search or wikilinks would miss. + +- **KG Edge Hover Tooltips**: Hovering a semantic edge in the Knowledge Graph (Force or Radial layout) shows a tooltip with `"NoteA › SectionA ↔ NoteB › SectionB · 87%"` — the note titles, matching section titles, and similarity percentage. + +- **Section-Level Backlinks Panel**: The Backlinks panel now tracks which `##` section the cursor is in during editing and runs a second semantic search (k=3) on the section's text. Results appear in a "Related to 'SectionName'" group, deduplicated against the whole-note semantic hits. Section tracking updates live as the cursor moves. + +- **Native ONNX Runtime**: Switched from xenova's `pipeline()` (which silently fell back to `onnxruntime-web` WASM with a 17.4 GB WebAssembly.Memory arena) to native `onnxruntime-node` CPU backend via `AutoTokenizer.from_pretrained` + `ort.InferenceSession.create`. Idle RSS dropped to ~444 MB with ~2× latency improvement. + +- **KG Node Z-Ordering on Hover**: Hovered/selected nodes now render on top of all other nodes and edges. ForceGraph uses `onRenderFramePost` for a final paint pass; RadialTree re-appends highlighted node groups to the end of the SVG DOM. Full titles (up to 60 chars) are shown for highlighted nodes in both layouts. + +- **Incremental Auto-Reindex on Save**: Editing a note now triggers a single-note embed reindex in the background (skipped if `content_hash` is unchanged), so the Semantic backlinks section stays current without ever visiting Settings. First-time users run **Reindex notes (embed + search)** once from Settings → Embeddings to backfill existing notes; afterward, the index stays fresh on its own. + +- **Long-Document Chunking**: Notes longer than 4000 characters are now split into overlapping chunks (~200 char overlap, breaking on paragraph/sentence/word boundaries) and their embeddings averaged after L2 normalisation. Previously long notes silently truncated at the tokenizer's 8192-token limit, losing signal. + +- **Query Vector Cache**: Repeat searches for the same note content (post-debounce) now hit a 64-entry LRU cache in the main process keyed by `sha256("q|" + model + "|" + queryText)` — skipping the worker round-trip entirely on cache hit. + +- **Packaged Worker Binary**: `scripts/build-embeddings-binary.js` bundles `electron/embeddings/server.ts` into a standalone `cairn-embeddings` executable via `@yao-pkg/pkg` (mirrors `build-mcp-binary.js`). In dev, the worker is spawned via the system Node binary (`which node`) with `--max-old-space-size=512` + `--cache-dir=${MODELS_DIR}` to sidestep Electron-as-Node native module edge cases; in packaged builds, the binary is loaded from `app.asar.unpacked/dist-embeddings/`. + +### Fixes + +- **`chunkLongText` Infinite Loop → OOM Crash**: When `end` reached `text.length` exactly, `start = end - CHUNK_OVERLAP` (200) still satisfied `start < text.length`, so the loop pushed the same chunk forever, exhausting the V8 heap and crashing the worker with SIGTRAP. Now breaks out of the loop the moment `end >= text.length`, plus defensive guards for `end <= start` and `nextStart <= start` (no forward progress). + +- **Search Returned No Results Despite Reindex**: `recomputeProjections` re-embedded every note with `task='clustering'`, which — because `note_embeddings.note_id` is the primary key with `ON CONFLICT DO UPDATE` — silently overwrote the `search_document` rows that `searchAdjacent` filters by. Recompute now reads existing `search_document` embeddings and only runs UMAP (no re-embed); the two maintenance buttons are clearly labelled ("Reindex notes (embed + search)" vs "Recompute graph clusters (UMAP only)") to prevent confusion. + +- **Model Install Never Detected as Installed**: `modelFilesPresent` looked for HuggingFace cache slugs (`nomic-ai--nomic-embed-text-v1.5`) but `@xenova/transformers` stores model files in a nested directory layout (`nomic-ai/nomic-embed-text-v1.5/config.json` + `onnx/*.onnx`). Now checks the actual layout, so a previously-downloaded model shows as installed immediately. + +- **Worker Killed During In-Flight Requests**: `stopWorker` would SIGTERM the child even when an embed request was in flight, surfacing as `socket hang up` errors on the renderer. Now refuses to kill when `inFlightEmbeds > 0` (logs the call stack); only `dispose()` on app quit forces a kill. + +- **Progress State Lost on Settings Remount**: `reindexProgress` (the `done/total` bar) was renderer-only state, reset whenever the Settings view unmounted. Now `getStatus()` exposes `lastReindexDone/Total` + `lastRecomputeDone/Total` from the main-process lock slots, so the bar restores on remount. + +- **Duplicate Concurrent Reindex Calls**: No mutex existed, so rapid clicks or cross-window triggers could kick off overlapping reindex passes. Now `withLock` enforces a single concurrent `reindex` and `recomputeProjections`; duplicates are rejected with `Error: "Reindex already in progress"` and surfaced as `Blocked: ...` in the UI. + +- **Notes With Zero Backlinks Hid Semantic Section**: `BacklinksPanel` returned `null` when `total === 0`, so enabling the Sparkles toggle on a note with no backlinks produced no feedback. Now stays visible whenever semantic is enabled, showing "searching…" while loading and "No similar notes found. Reindex from Settings → Embeddings to refresh." when search returns 0 hits. + +- **Worker Spawn Used Electron-as-Node**: Native ONNX runtime binaries under `ELECTRON_RUN_AS_NODE=1` can crash or fail to load on some platforms. Dev mode now resolves the system Node binary via `which node` / `where node` and spawns the worker with that instead, with `--max-old-space-size=4096` for the model-load phase. + +- **Worker Process Had No Uncaught Handlers**: Native crashes inside `onnxruntime-node` surfaced as `worker exited (code=null)` with no signal name or stderr. Now logs `code` + `signal` on exit, dumps the last 1KB of stderr, and the worker itself emits `uncaughtException` / `unhandledRejection` as `{kind:"error", msg:"..."}` JSON-line events before exiting. + +- **`computeAutoRelationships` Wiped Semantic Edges**: The "Recompute auto relationships" button deleted all auto-discovered edges including semantic ones. Now the delete statement filters `AND type != 'semantic'` so semantic edges survive auto-relationship recompute. + +- **Model-Aware Pipeline Cache**: Concurrent requests for different embedding models could reuse a cached in-flight pipeline for the wrong model. Now `_pipelinePromiseModelId` tracks which model the cached promise is for; mismatched requests get a fresh pipeline. + +- **Tokenizer Padding + Truncation**: Varying-length texts caused ONNX tensor shape errors. Tokenizer now runs with `padding: true, truncation: true` to ensure consistent tensor shapes. + +- **Sequential Section Embedding**: Batched section embedding created a `[16, 12, 1000, 1000]` attention matrix spiking to 4 GB. Now embeds one section text per HTTP call — 15× faster for 4 KB texts, peak RSS under ~750 MB. + +### Changes + +- **Single embedding task**: All embedding operations now store under `task='search_document'`. The previous `clustering` task is gone — `recomputeProjections` reuses `search_document` rows for UMAP, halving DB rows and removing the upsert-overwrite footgun. A `pruneOrphanedClusteringRows` query runs at the start of any full reindex to clean up legacy rows from prior versions. + +- **Sequential embedding (one text per HTTP call)**: The worker embeds one section text per HTTP request instead of batching. Batched calls created a `[16, 12, 1000, 1000]` attention matrix spiking to 4 GB; sequential is 15× faster for 4 KB texts and keeps peak RSS under ~750 MB. + +- **Worker `--max-old-space-size=512`**: JS heap limit set to 512 MB (ONNX C++ memory is separate). Idle RSS ~444 MB. + +- **UI: Semantic folded into Backlinks**: The right-hand `SemanticHubsPanel` (separate `w-72` column) is removed; semantic results now render as the first section inside the bottom Backlinks panel, auto-opening it when results arrive. Header reads e.g. `"3 similar + 2 backlinks"`. The Sparkles icon in the editor header still toggles visibility. + +- **Embeddings Settings (`src/components/settings/EmbeddingsSettings.tsx`)**: New section with model install/remove/setDefault controls, real-time download progress bars, worker status display, and maintenance buttons (Reindex / Recompute) with progress bars that survive view switches. + +- **Build pipeline**: `package.json` adds `--external:@xenova/transformers --external:onnxruntime-node --external:umap-js` to `compile`/`compile:watch` (dev) and a new `compile` target for `embeddings-server.bundle.js`. New script `build:embeddings-binary` runs `scripts/build-embeddings-binary.js`. + +- **`main.ts` cleanup**: Imports `dispose as disposeEmbeddingsWorker` from `./embeddings/client` and calls it in `before-quit` so the worker never lingers. + +--- + +## Changed files + +| Area | Files | Change | +|------|-------|--------| +| Core embeddings | `electron/embeddings/{types,cosine,nomic,projection,pipeline,port,server,client,manifest,service,sections}.ts` | New module: types/Zod schemas, cosine + topK, nomic prefix, UMAP projection, native onnxruntime-node pipeline, HTTP worker, spawn/health/dispose lifecycle, model manifest, reindex/search/recompute service layer, section splitter | +| DB layer | `electron/db/schema.ts`, `electron/db/queries.ts`, `electron/db/graph-queries.ts`, `electron/mcp/db.ts`, `electron/mcp-server.ts` | v17→v19 migrations (section-based `note_embeddings`, `relationship_cache` section title columns); query helpers; `computeSemanticRelationships` (top-K=5, section-level comparison); `getSemanticNeighbors` MCP query; `ensureEmbeddingsTable` wired into MCP bootstrap | +| Graph | `electron/db/graph-queries.ts`, `src/types/index.ts`, `src/store/slices/graph.ts`, `src/components/graph/{ForceGraphCanvas,RadialTreeCanvas,KnowledgeGraphView}.tsx` | `"semantic"` edge type with section titles; `onRenderFramePost` z-ordering in ForceGraph; DOM re-append in RadialTree; edge hover tooltips in both canvases; semantic slider in KG toolbar | +| MCP tools | `electron/mcp/tools/{graph,index}.ts`, `electron/lib/tool-schemas.ts` | `cairn_get_semantic_neighbors(noteId)` tool — queries pre-computed semantic edges from `relationship_cache` | +| IPC + preload | `electron/ipc/{embeddings-handlers,settings-handlers,handlers,registry,db-handlers}.ts`, `electron/preload.ts`, `electron/lib/config-cache.ts` | New `db:embeddings:*` + `embeddings:*` handlers with mutex + progress broadcasts; preload `embeddings` namespace with `onProgress`; `app:{get,save}EmbeddingsSettings`; cached config extension; incremental reindex hook on note save; `computeSemanticRelationships` called after bulk reindex | +| UI | `src/components/settings/{EmbeddingsSettings,settings-view}.tsx`, `src/components/notes/{note-editor,BacklinksPanel,markdown-editor,toc-utils}.tsx`, `src/hooks/useDebouncedValue.ts` | Embeddings settings UI; semantic + section-level results in BacklinksPanel; Sparkles toggle in editor header; cursor section tracking via `onCursorActivity`; `findSectionTitleAtOffset`/`extractSectionTextAtOffset` helpers; debounced query hook | +| Build | `package.json`, `scripts/build-embeddings-binary.js`, `electron/main.ts` | deps (`@xenova/transformers`, `onnxruntime-node`, `umap-js`); esbuild externals + new compile target for worker bundle; `build:embeddings-binary` script; worker dispose on app quit | +| Tests | `electron/embeddings/{cosine,projection,chunking,query-cache,sections}.test.ts`, `electron/db/graph-queries.test.ts` | New unit tests for cosine similarity, topK, UMAP projection (deterministic seeded), chunking (terminates on long input, correct chunk count), query cache (LRU eviction, hash-keyed hit), section splitter (12 scenarios), graph-queries (25 tests incl. multi-section semantic edges) | diff --git a/docs/plans/local-embeddings.md b/docs/plans/local-embeddings.md new file mode 100644 index 00000000..d86255ae --- /dev/null +++ b/docs/plans/local-embeddings.md @@ -0,0 +1,163 @@ +# Local Embeddings + Vector Graph — Implementation Plan + +**Status:** in-progress +**Started:** 2026-06-18 +**Scope:** Offline-first semantic features for Notes + Knowledge Graph using +`nomic-ai/nomic-embed-text-v1.5` via `@xenova/transformers`, running as a +packaged HTTP binary so both the Electron main process and the MCP server +can call it. + +## Architecture + +``` +Electron Main ─────┐ ┌──── MCP Server (pkg binary) + │ HTTP 127.0.0.1: │ fetch() +embeddingsClient ──┴──► cairn-embeddings ◄───┘ + (spawn+health, (pkg-packaged Node 22 binary; + kill on quit) owns ONNX Runtime, exposes + POST /embed {texts, task} → {vectors} + GET /health) + ↓ + nomic-ai/nomic-embed-text-v1.5 (int8 quantised, ~87 MB) + ↓ + SQLite writes go through whichever process made the call: + - Electron main → electron/db/queries.ts (Electron ABI db handle) + - MCP → mcp/db.ts (Node 22 ABI db handle) +``` + +Mirrors the existing `llama-server.ts` spawn/findFreePort/checkHealth/dispose +pattern at `main.ts:303-313` (`before-quit`). The MCP server reaches the binary +via HTTP `fetch()` — no second wire protocol to maintain. + +## Nomic task prefixes (correctness-critical) + +Prefix **must** be prepended to the raw input string **before** tokenisation. + +| Task | Prefix | Usage | +|-------------------|----------------------|----------------------------------------| +| `search_document` | `search_document: ` | Indexing notes | +| `search_query` | `search_query: ` | Sidebar "Semantic Hubs" similarity | +| `clustering` | `clustering: ` | Graph projection / UMAP | + +## Storage shape + +`note_embeddings` table (v17 migration in `schema.ts`): + +``` +note_id TEXT PRIMARY KEY REFERENCES notes(id) ON DELETE CASCADE +workspace_id TEXT NOT NULL +model TEXT NOT NULL -- e.g. "nomic-ai/nomic-embed-text-v1.5" +task TEXT NOT NULL -- "search_document" | "clustering" +content_hash TEXT NOT NULL -- sha256 of embedded text → dirty-check +vector TEXT NOT NULL -- JSON number[768]; swap for BLOB at vec0 +embedded_at TEXT NOT NULL +dim_x REAL -- UMAP projection (clustering task only) +dim_y REAL +proj_stale INTEGER NOT NULL DEFAULT 1 +``` + +Indexes: `idx_emb_ws(workspace_id)`, `idx_emb_proj(proj_stale)`. + +Bridge to eventual `sqlite-vec` swap: the `vector` column is JSON-in-TEXT, +which fits the existing `db-mappers.ts` `parseJsonArray` convention. The +swap is one migration: copy JSON → BLOB → `vec0` virtual table + drop TEXT +column. `cosine.ts` API does not change. + +## Tasks + +### Phase 1 — Core (Electron main-side) +- [x] 1a. Install deps: `@xenova/transformers`, `onnxruntime-node`, `umap-js` +- [x] 1b. `electron/embeddings/types.ts` — Zod request/response + manifest types +- [x] 1c. `electron/embeddings/cosine.ts` — pure typed cosine + topK +- [x] 1d. `electron/embeddings/nomic.ts` — task prefixes + embed wrapper +- [x] 1e. `electron/embeddings/projection.ts` — UMAP runner (pure ESM, no bsqlite) + +### Phase 2 — Worker binary (packaged, HTTP) +- [x] 2a. `electron/embeddings/server.ts` — HTTP entry (`/embed`, `/health`) +- [x] 2b. `scripts/build-embeddings-binary.js` — pkg bundler (clone of build-mcp-binary.js) +- [x] 2c. `electron/embeddings/client.ts` — spawn + findFreePort + checkHealth + dispose +- [x] 2d. `electron/embeddings/manifest.ts` — model registry + default-model.json + (clone of `llama-server.ts` lines 26–169 + 222–455) + +### Phase 3 — DB layer +- [x] 3a. v17 migration in `electron/db/schema.ts` (after v16) +- [x] 3b. Query helpers in `electron/db/queries.ts`: + `upsertNoteEmbedding`, `getNoteEmbedding`, `getAllEmbeddingsForWorkspace`, + `getStaleProjectionIds`, `upsertProjection` +- [x] 3c. `ensureEmbeddingsTable(db)` in `electron/mcp/db.ts` + (MCP doesn't call `applySchema`; mirrors `ensureMcpActiveWritesTable`) + +### Phase 4 — IPC + preload +- [x] 4a. `electron/ipc/embeddings-handlers.ts`: + `db:embeddings:reindex` (write), `db:embeddings:search` (read), + `db:embeddings:recomputeProjections` (read), + `embeddings:status`, `embeddings:models:list|install|remove|setDefault`, + `embeddings:download-progress` event +- [x] 4b. Register `registerEmbeddingsHandlers(ctx)` in `handlers.ts:52` +- [x] 4c. Add `db:embeddings:search`, `db:embeddings:recomputeProjections` to + `readChannels` in `registry.ts:35-53` +- [x] 4d. `electron/preload.ts` — add `embeddings` namespace (mirror `llama`) + +### Phase 5 — Settings (model management UI) +- [x] 5a. Extend `CachedConfig` in `electron/lib/config-cache.ts` with + `embeddingsConfig?` + `saveCachedConfig("embeddings", ...)` +- [x] 5b. Add `app:{get,save}EmbeddingsSettings` to + `electron/ipc/settings-handlers.ts` +- [x] 5c. Extend `AIConfig` (or new `EmbeddingsConfig`) in + `src/store/slices/ui.ts` — dual-write pattern mirrors `setAIConfig:167-176` +- [x] 5d. Hydrate embeddings config in `src/store/index.ts:hydrateFromElectron` +- [x] 5e. New `src/components/settings/EmbeddingsSettings.tsx` + (clone of `AISettings.tsx` model manager, lines 567–726) +- [x] 5f. Add `"embeddings"` section in `settings-view.tsx` (union + nav + render) + +### Phase 6 — Knowledge Graph extensions +- [x] 6a. Add `"semantic"` to `GraphEdgeType` in `src/types/index.ts:276` +- [x] 6b. Mirror in local `EdgeType` at `electron/db/graph-queries.ts:41` +- [x] 6c. Push `"semantic"` into `autoTypes` array (pass 6) +- [x] 6d. Add similarity block in `computeAutoRelationships` (after keyword block): + `SEMANTIC_THRESHOLD = 0.78`, uses `getAllEmbeddingsForWorkspace("search_document")` +- [x] 6e. Add `"semantic"` to `DEFAULT_GRAPH_FILTERS.edgeTypes` and `edgeTypeLabel` + in `src/store/slices/graph.ts` +- [x] 6f. `ForceGraphCanvas.tsx:34` — `edgeColor()` case for `"semantic"` +- [x] 6g. `RadialTreeCanvas.tsx:71` — `crossEdgeColor()` case for `"semantic"` +- [x] 6h. Semantic opacity slider in `KnowledgeGraphView.tsx:~300` (next to Spacing) + +### Phase 7 — Note editor (Semantic Hubs panel) +- [x] 7a. `src/hooks/useDebouncedValue.ts` — generic hook +- [x] 7b. `src/components/notes/SemanticHubsPanel.tsx` — top-5 adjacent notes +- [x] 7c. Wire into `note-editor.tsx:842` (flex row with editor + panel) +- [x] 7d. 1200ms semantic debounce ref in `handleContentChange`, cleared in `flushPending` + +### Phase 8 — Build + verify +- [x] 8a. `package.json` deps + esbuild externals + new compile target for the worker +- [x] 8b. `main.ts:~173` — bootstrap (lazy spawn on first IPC request) +- [x] 8c. `main.ts:~310` — `embeddingsClient.dispose()` in `before-quit` +- [x] 8d. `npm run type-check:all` — must pass +- [x] 8e. `npm run compile` — must produce all bundles +- [x] 8f. Vitest tests: `cosine.test.ts`, `projection.test.ts`, `nomic.test.ts` + +## Critical implementation notes (gotchas) + +1. **Nomic prefix before tokenisation.** `nomic.ts:withNomicPrefix` runs on the + raw string before the pipeline call. Never feed the worker pre-tokenized input. +2. **`normalize: true` at pipeline output.** Cosine becomes dot product; `cosine.ts` + skips the L2 division. Guard behind a fast path: assume normalized, fall back if + magnitude ≠ 1 within ε. +3. **v17 migration's `vector TEXT (JSON)` is a deliberate bridge** to `sqlite-vec`. + Don't bake `JSON.parse` into call sites — funnel through `queries.ts` accessors. +4. **MCP server can read/write embeddings but must NOT construct a `Database` with + the wrong ABI.** It uses its existing Node-22-ABI handle from `mcp-server.ts:140`. + `note_embeddings` table must be ensured inline in `mcp/db.ts` because MCP does + not call `applySchema`. Symmetric to `ensureMcpActiveWritesTable`. +5. **`semantic` edge weight handling in renderer.** The `edgeFingerprint` memo at + `ForceGraphCanvas.tsx:139-155` re-simulates on any edge-set change. The + `semanticThreshold` slider filters inside the memo so it trims the data the + simulation sees (not just a visual filter). +6. **1200ms semantic debounce > 2× the 300ms save debounce.** Every semantic search + runs against content already saved to SQLite. `flushPending` clears both timers. +7. **esbuild + template-literal gotcha** (AGENTS.md): backticks inside template + literals must be unescaped at the template level. +8. **`zod` import style** in all new Electron files: `import * as z from "zod"`). +9. **CSS** all colours via `var(--token)` / `color-mix(...)` — never raw rgba or + Tailwind colour names. SVG `fontSize` × `useFontScale()`. +10. **No comments unless explicitly requested** (AGENTS.md). diff --git a/electron/db/graph-queries.test.ts b/electron/db/graph-queries.test.ts new file mode 100644 index 00000000..69525adb --- /dev/null +++ b/electron/db/graph-queries.test.ts @@ -0,0 +1,506 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import BetterSqlite3 from "better-sqlite3"; +import type Database from "better-sqlite3"; +import { applySchema } from "./schema"; +import { + createWorkspace, + createProject, + createNote, + upsertNoteEmbedding, +} from "./queries"; +import { + computeSemanticRelationships, + getKnowledgeGraph, +} from "./graph-queries"; + +const TOP_K = 5; +const FLOOR = 0.55; + +function makeDb(): Database.Database { + const db = new BetterSqlite3(":memory:"); + applySchema(db); + return db; +} + +function seed(db: Database.Database) { + createWorkspace(db, { id: "ws1", name: "WS" }); + createProject(db, { id: "p1", workspaceId: "ws1", name: "Proj" }); +} + +function vec(...vals: number[]): number[] { + return vals; +} + +function upsertEmbedding( + db: Database.Database, + noteId: string, + vector: number[], + sectionIdx: number = 0, + sectionTitle: string = "", +) { + upsertNoteEmbedding(db, { + noteId, + sectionIdx, + sectionTitle, + workspaceId: "ws1", + model: "test-model", + task: "search_document", + contentHash: "hash-" + noteId + "-" + sectionIdx, + vector, + }); +} + +function upsertSection( + db: Database.Database, + noteId: string, + sectionIdx: number, + sectionTitle: string, + vector: number[], +) { + upsertNoteEmbedding(db, { + noteId, + sectionIdx, + sectionTitle, + workspaceId: "ws1", + model: "test-model", + task: "search_document", + contentHash: "hash-" + noteId + "-" + sectionIdx, + vector, + }); +} + +function semEdges(db: Database.Database) { + return getKnowledgeGraph(db, "ws1").edges.filter((e) => e.type === "semantic"); +} + +function findEdge(edges: ReturnType, a: string, b: string) { + return edges.find( + (e) => + (e.source === a && e.target === b) || + (e.source === b && e.target === a), + ); +} + +function cosineApprox(a: number[], b: number[]): number { + let dot = 0, na = 0, nb = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + na += a[i] * a[i]; + nb += b[i] * b[i]; + } + const d = Math.sqrt(na) * Math.sqrt(nb); + return d === 0 ? 0 : dot / d; +} + +function round2(x: number): number { + return Math.round(x * 100) / 100; +} + +describe("computeSemanticRelationships — top-K behaviour (single-section)", () => { + let db: Database.Database; + + beforeEach(() => { + db = makeDb(); + seed(db); + createNote(db, { id: "n1", projectId: "p1", workspaceId: "ws1", title: "A", contentText: "alpha" }); + createNote(db, { id: "n2", projectId: "p1", workspaceId: "ws1", title: "B", contentText: "beta" }); + createNote(db, { id: "n3", projectId: "p1", workspaceId: "ws1", title: "C", contentText: "gamma" }); + }); + + it("creates no edges when embeddings table is empty", () => { + computeSemanticRelationships(db, "ws1"); + expect(semEdges(db)).toHaveLength(0); + }); + + it("connects each note to its single peer when only 2 notes exist", () => { + upsertEmbedding(db, "n1", vec(1, 0, 0, 0)); + upsertEmbedding(db, "n2", vec(0.9, 0.44, 0, 0)); + computeSemanticRelationships(db, "ws1"); + const edges = semEdges(db); + expect(edges).toHaveLength(1); + expect(findEdge(edges, "n1", "n2")).toBeDefined(); + }); + + it("respects floor: pairs below 0.55 are NOT connected even if they'd be in top-K", () => { + upsertEmbedding(db, "n1", vec(1, 0, 0, 0)); + upsertEmbedding(db, "n2", vec(0.3, 0.95, 0, 0)); + computeSemanticRelationships(db, "ws1"); + expect(semEdges(db)).toHaveLength(0); + }); + + it("caps each note at TOP_K=5 outgoing edges", () => { + for (let i = 1; i <= 8; i++) { + const id = "m" + i; + createNote(db, { id, projectId: "p1", workspaceId: "ws1", title: "M" + i, contentText: "shared topic " + i }); + upsertEmbedding(db, id, vec(1, i * 0.001, 0, 0)); + } + computeSemanticRelationships(db, "ws1"); + const edges = semEdges(db); + const fromM1 = edges.filter((e) => e.source === "m1" || e.target === "m1"); + expect(fromM1.length).toBeLessThanOrEqual(TOP_K); + }); + + it("creates edges for pairs with cosine >= 0.55 even if below old 0.78 threshold", () => { + const v1 = vec(1, 0, 0, 0); + const v2 = vec(0.7, 0.71, 0, 0); + upsertEmbedding(db, "n1", v1); + upsertEmbedding(db, "n2", v2); + computeSemanticRelationships(db, "ws1"); + const edges = semEdges(db); + expect(edges).toHaveLength(1); + const edge = edges[0]; + expect(edge.weight).toBeGreaterThanOrEqual(FLOOR); + expect(edge.weight).toBeLessThan(0.78); + }); + + it("does not create edges for orthogonal (cosine ≈ 0) notes", () => { + upsertEmbedding(db, "n1", vec(1, 0, 0, 0)); + upsertEmbedding(db, "n3", vec(0, 0, 0, 1)); + computeSemanticRelationships(db, "ws1"); + expect(semEdges(db)).toHaveLength(0); + }); + + it("deduplicates via canonical source { + upsertEmbedding(db, "n1", vec(1, 0, 0, 0)); + upsertEmbedding(db, "n2", vec(1, 0, 0, 0)); + computeSemanticRelationships(db, "ws1"); + const edges = semEdges(db).filter( + (e) => (e.source === "n1" && e.target === "n2") || (e.source === "n2" && e.target === "n1"), + ); + expect(edges).toHaveLength(1); + }); + + it("incremental mode (entityIds) recomputes only active notes' edges", () => { + upsertEmbedding(db, "n1", vec(1, 0, 0, 0)); + upsertEmbedding(db, "n2", vec(0.95, 0.31, 0, 0)); + upsertEmbedding(db, "n3", vec(0, 0, 0, 1)); + computeSemanticRelationships(db, "ws1", ["n1"]); + const edges = semEdges(db); + expect(edges).toHaveLength(1); + expect(findEdge(edges, "n1", "n2")).toBeDefined(); + expect(findEdge(edges, "n2", "n3")).toBeUndefined(); + }); + + it("idempotent: calling twice doesn't duplicate rows", () => { + upsertEmbedding(db, "n1", vec(1, 0, 0, 0)); + upsertEmbedding(db, "n2", vec(1, 0, 0, 0)); + computeSemanticRelationships(db, "ws1"); + computeSemanticRelationships(db, "ws1"); + const edges = semEdges(db).filter( + (e) => (e.source === "n1" && e.target === "n2") || (e.source === "n2" && e.target === "n1"), + ); + expect(edges).toHaveLength(1); + }); + + it("produces edges: weights are valid in [0.55, 1.0]", () => { + upsertEmbedding(db, "n1", vec(1, 0, 0, 0)); + upsertEmbedding(db, "n2", vec(0.85, 0.53, 0, 0)); + computeSemanticRelationships(db, "ws1"); + const edge = semEdges(db)[0]; + expect(edge.weight).toBeGreaterThanOrEqual(FLOOR); + expect(edge.weight).toBeLessThanOrEqual(1); + }); +}); + +describe("computeSemanticRelationships — multi-section notes", () => { + let db: Database.Database; + + beforeEach(() => { + db = makeDb(); + seed(db); + createNote(db, { id: "plan", projectId: "p1", workspaceId: "ws1", title: "Project Plan", contentText: "intro" }); + createNote(db, { id: "arch", projectId: "p1", workspaceId: "ws1", title: "Architecture", contentText: "microservices" }); + createNote(db, { id: "mkt", projectId: "p1", workspaceId: "ws1", title: "Marketing", contentText: "go to market" }); + }); + + it("a note with 2 sections gets edges from BOTH sections", () => { + const archVec = vec(1, 0, 0, 0); + const marketingVec = vec(0, 1, 0, 0); + const archDoc = vec(0.98, 0.2, 0, 0); + const mktDoc = vec(0.1, 0.99, 0, 0); + + upsertSection(db, "plan", 0, "Architecture", archVec); + upsertSection(db, "plan", 1, "Marketing", marketingVec); + upsertEmbedding(db, "arch", archDoc, 0, ""); + upsertEmbedding(db, "mkt", mktDoc, 0, ""); + + computeSemanticRelationships(db, "ws1"); + + const edges = semEdges(db); + expect(findEdge(edges, "plan", "arch")).toBeDefined(); + expect(findEdge(edges, "plan", "mkt")).toBeDefined(); + }); + + it("section titles are stored on the semantic edge", () => { + const archVec = vec(1, 0, 0, 0); + const archDoc = vec(0.98, 0.2, 0, 0); + + upsertSection(db, "plan", 0, "Architecture", archVec); + upsertSection(db, "plan", 1, "Marketing", vec(0, 1, 0, 0)); + upsertEmbedding(db, "arch", archDoc, 0, ""); + + computeSemanticRelationships(db, "ws1"); + + const edge = findEdge(semEdges(db), "plan", "arch"); + expect(edge).toBeDefined(); + expect(edge!.sourceSectionTitle).toBeDefined(); + expect(edge!.targetSectionTitle).toBeDefined(); + }); + + it("best weight per note-pair wins when multiple sections match", () => { + const v1a = vec(1, 0, 0, 0); + const v1b = vec(0.8, 0.6, 0, 0); + const v2a = vec(0.98, 0.2, 0, 0); + const v2b = vec(0.79, 0.62, 0, 0); + + upsertSection(db, "plan", 0, "Section A", v1a); + upsertSection(db, "plan", 1, "Section B", v1b); + upsertSection(db, "arch", 0, "Doc A", v2a); + upsertSection(db, "arch", 1, "Doc B", v2b); + + computeSemanticRelationships(db, "ws1"); + + const edge = findEdge(semEdges(db), "plan", "arch"); + expect(edge).toBeDefined(); + const maxSim = Math.max( + cosineApprox(v1a, v2a), cosineApprox(v1a, v2b), + cosineApprox(v1b, v2a), cosineApprox(v1b, v2b), + ); + expect(edge!.weight).toBe(round2(maxSim)); + }); + + it("single-section note connects to multi-section note via best section match", () => { + const archVec = vec(1, 0, 0, 0); + const unrelatedVec = vec(0, 0, 0, 1); + const archDoc = vec(0.95, 0.31, 0, 0); + + upsertSection(db, "plan", 0, "Architecture", archVec); + upsertSection(db, "plan", 1, "Random Stuff", unrelatedVec); + upsertEmbedding(db, "arch", archDoc, 0, ""); + + computeSemanticRelationships(db, "ws1"); + + const edges = semEdges(db); + expect(findEdge(edges, "plan", "arch")).toBeDefined(); + }); +}); + +describe("scenario: topic clusters with realistic cosines", () => { + let db: Database.Database; + + beforeEach(() => { + db = makeDb(); + seed(db); + + createNote(db, { id: "react", projectId: "p1", workspaceId: "ws1", title: "React Hooks", contentText: "useState, useEffect" }); + createNote(db, { id: "vue", projectId: "p1", workspaceId: "ws1", title: "Vue Composables", contentText: "ref, computed" }); + createNote(db, { id: "svelte", projectId: "p1", workspaceId: "ws1", title: "Svelte Stores", contentText: "writable, derived" }); + createNote(db, { id: "python", projectId: "p1", workspaceId: "ws1", title: "Python Decorators", contentText: "@app.route" }); + createNote(db, { id: "ruby", projectId: "p1", workspaceId: "ws1", title: "Ruby Blocks", contentText: "yield, proc" }); + createNote(db, { id: "pizza", projectId: "p1", workspaceId: "ws1", title: "Pizza Recipe", contentText: "flour, tomato, mozzarella" }); + }); + + it("connects notes within topic clusters, isolates unrelated notes", () => { + const react = vec(1.00, 0.00, 0.00, 0.00); + const vue = vec(0.98, 0.20, 0.00, 0.00); + const svelte = vec(0.88, 0.48, 0.00, 0.00); + const python = vec(0.00, 1.00, 0.00, 0.00); + const ruby = vec(0.00, 0.98, 0.20, 0.00); + const pizza = vec(0.00, 0.00, 0.00, 1.00); + + upsertEmbedding(db, "react", react); + upsertEmbedding(db, "vue", vue); + upsertEmbedding(db, "svelte", svelte); + upsertEmbedding(db, "python", python); + upsertEmbedding(db, "ruby", ruby); + upsertEmbedding(db, "pizza", pizza); + + computeSemanticRelationships(db, "ws1"); + + const edges = semEdges(db); + + const expected = [ + { a: "react", b: "vue", w: round2(cosineApprox(react, vue)) }, + { a: "react", b: "svelte", w: round2(cosineApprox(react, svelte)) }, + { a: "vue", b: "svelte", w: round2(cosineApprox(vue, svelte)) }, + { a: "python", b: "ruby", w: round2(cosineApprox(python, ruby)) }, + ]; + + expect(edges).toHaveLength(expected.length); + + for (const ex of expected) { + const edge = findEdge(edges, ex.a, ex.b); + expect(edge, `expected edge ${ex.a}↔${ex.b}`).toBeDefined(); + expect(edge!.weight).toBe(ex.w); + } + + expect(findEdge(edges, "pizza", "react")).toBeUndefined(); + expect(findEdge(edges, "pizza", "vue")).toBeUndefined(); + expect(findEdge(edges, "pizza", "python")).toBeUndefined(); + expect(findEdge(edges, "pizza", "ruby")).toBeUndefined(); + expect(findEdge(edges, "react", "python")).toBeUndefined(); + expect(findEdge(edges, "vue", "ruby")).toBeUndefined(); + expect(findEdge(edges, "svelte", "python")).toBeUndefined(); + }); + + it("slider at 1.0 hides all semantic edges (hard links only)", () => { + upsertEmbedding(db, "react", vec(1, 0, 0, 0)); + upsertEmbedding(db, "vue", vec(0.99, 0.14, 0, 0)); + computeSemanticRelationships(db, "ws1"); + const all = semEdges(db); + expect(all.length).toBe(1); + const visibleAtOne = all.filter((e) => (e.weight ?? 1) >= 1.0); + expect(visibleAtOne).toHaveLength(0); + }); + + it("incremental recompute for a single note preserves other clusters' edges", () => { + upsertEmbedding(db, "react", vec(1, 0, 0, 0)); + upsertEmbedding(db, "vue", vec(0.98, 0.2, 0, 0)); + upsertEmbedding(db, "python", vec(0, 1, 0, 0)); + upsertEmbedding(db, "ruby", vec(0, 0.98, 0.2, 0)); + + computeSemanticRelationships(db, "ws1"); + expect(findEdge(semEdges(db), "python", "ruby")).toBeDefined(); + + upsertEmbedding(db, "react", vec(0.97, 0.24, 0, 0)); + computeSemanticRelationships(db, "ws1", ["react"]); + + const edges = semEdges(db); + expect(findEdge(edges, "python", "ruby")).toBeDefined(); + expect(findEdge(edges, "react", "vue")).toBeDefined(); + }); +}); + +describe("scenario: multi-topic note discovers connections via sections", () => { + let db: Database.Database; + + beforeEach(() => { + db = makeDb(); + seed(db); + createNote(db, { id: "plan", projectId: "p1", workspaceId: "ws1", title: "Project Plan", contentText: "intro" }); + createNote(db, { id: "arch", projectId: "p1", workspaceId: "ws1", title: "Architecture Doc", contentText: "microservices" }); + createNote(db, { id: "mkt", projectId: "p1", workspaceId: "ws1", title: "Marketing Plan", contentText: "go to market" }); + createNote(db, { id: "cooking", projectId: "p1", workspaceId: "ws1", title: "Cooking Notes", contentText: "pasta recipe" }); + }); + + it("multi-topic note connects to architecture AND marketing notes", () => { + const archVec = vec(1, 0, 0, 0); + const mktVec = vec(0, 1, 0, 0); + const archDoc = vec(0.98, 0.2, 0, 0); + const mktDoc = vec(0.1, 0.99, 0, 0); + const cookingVec = vec(0, 0, 0, 1); + + upsertSection(db, "plan", 0, "Architecture", archVec); + upsertSection(db, "plan", 1, "Marketing", mktVec); + upsertEmbedding(db, "arch", archDoc, 0, "Architecture"); + upsertEmbedding(db, "mkt", mktDoc, 0, "Marketing"); + upsertEmbedding(db, "cooking", cookingVec, 0, "Recipe"); + + computeSemanticRelationships(db, "ws1"); + + const edges = semEdges(db); + expect(findEdge(edges, "plan", "arch")).toBeDefined(); + expect(findEdge(edges, "plan", "mkt")).toBeDefined(); + expect(findEdge(edges, "arch", "cooking")).toBeUndefined(); + expect(findEdge(edges, "mkt", "cooking")).toBeUndefined(); + expect(findEdge(edges, "plan", "cooking")).toBeUndefined(); + }); + + it("section titles appear on edges for multi-section notes", () => { + const archVec = vec(1, 0, 0, 0); + const mktVec = vec(0, 1, 0, 0); + const archDoc = vec(0.98, 0.2, 0, 0); + const mktDoc = vec(0.1, 0.99, 0, 0); + + upsertSection(db, "plan", 0, "Architecture", archVec); + upsertSection(db, "plan", 1, "Marketing", mktVec); + upsertEmbedding(db, "arch", archDoc, 0, "Architecture"); + upsertEmbedding(db, "mkt", mktDoc, 0, "Marketing"); + + computeSemanticRelationships(db, "ws1"); + + const archEdge = findEdge(semEdges(db), "plan", "arch"); + expect(archEdge).toBeDefined(); + expect(archEdge!.sourceSectionTitle).toBeDefined(); + + const mktEdge = findEdge(semEdges(db), "plan", "mkt"); + expect(mktEdge).toBeDefined(); + expect(mktEdge!.sourceSectionTitle).toBeDefined(); + }); +}); + +describe("getKnowledgeGraph — semantic edge integration", () => { + let db: Database.Database; + + beforeEach(() => { + db = makeDb(); + seed(db); + createNote(db, { id: "na", projectId: "p1", workspaceId: "ws1", title: "Alpha", contentText: "shared content here" }); + createNote(db, { id: "nb", projectId: "p1", workspaceId: "ws1", title: "Beta", contentText: "shared content there" }); + createNote(db, { id: "nc", projectId: "p1", workspaceId: "ws1", title: "Gamma", contentText: "totally unrelated text" }); + }); + + it("semantic edges appear in graph when includeAuto is true", () => { + upsertEmbedding(db, "na", vec(1, 0, 0, 0)); + upsertEmbedding(db, "nb", vec(0.98, 0.2, 0, 0)); + upsertEmbedding(db, "nc", vec(0, 0, 0, 1)); + computeSemanticRelationships(db, "ws1"); + const edges = semEdges(db); + expect(edges.length).toBeGreaterThanOrEqual(1); + expect(findEdge(edges, "na", "nb")).toBeDefined(); + }); + + it("semantic edges filtered out when edgeTypes omits 'semantic'", () => { + upsertEmbedding(db, "na", vec(1, 0, 0, 0)); + upsertEmbedding(db, "nb", vec(1, 0, 0, 0)); + computeSemanticRelationships(db, "ws1"); + const graph = getKnowledgeGraph(db, "ws1", { includeAuto: true, edgeTypes: ["note-note", "project-member"] }); + expect(graph.edges.filter((e) => e.type === "semantic")).toHaveLength(0); + }); + + it("semantic edges absent when includeAuto is false", () => { + upsertEmbedding(db, "na", vec(1, 0, 0, 0)); + upsertEmbedding(db, "nb", vec(1, 0, 0, 0)); + computeSemanticRelationships(db, "ws1"); + const graph = getKnowledgeGraph(db, "ws1", { includeAuto: false }); + expect(graph.edges.filter((e) => e.type === "semantic")).toHaveLength(0); + }); + + it("weight rounded to 2 decimal places", () => { + upsertEmbedding(db, "na", vec(1, 0, 0, 0)); + upsertEmbedding(db, "nb", vec(0.8, 0.6, 0, 0)); + computeSemanticRelationships(db, "ws1"); + const edge = semEdges(db)[0]; + expect(edge).toBeDefined(); + expect(edge.weight).toBe(Math.round(edge.weight! * 100) / 100); + }); +}); + +describe("getKnowledgeGraph — client-side threshold filter", () => { + let db: Database.Database; + + beforeEach(() => { + db = makeDb(); + seed(db); + createNote(db, { id: "n1", projectId: "p1", workspaceId: "ws1", title: "X", contentText: "a b c" }); + createNote(db, { id: "n2", projectId: "p1", workspaceId: "ws1", title: "Y", contentText: "a b c" }); + createNote(db, { id: "n3", projectId: "p1", workspaceId: "ws1", title: "Z", contentText: "a b c" }); + upsertEmbedding(db, "n1", vec(1, 0, 0, 0)); + upsertEmbedding(db, "n2", vec(0.98, 0.2, 0, 0)); + upsertEmbedding(db, "n3", vec(0, 0, 1, 0)); + computeSemanticRelationships(db, "ws1"); + }); + + it("all semantic edges returned from DB regardless of weight", () => { + expect(semEdges(db).length).toBeGreaterThanOrEqual(1); + }); + + it("client-side threshold filter masks edges below cutoff", () => { + const graph = getKnowledgeGraph(db, "ws1"); + const threshold = 0.99; + const visible = graph.edges.filter((e) => e.type !== "semantic" || (e.weight ?? 1) >= threshold); + const allSem = graph.edges.filter((e) => e.type === "semantic"); + const semBelow = allSem.filter((e) => (e.weight ?? 1) < threshold); + expect(visible.filter((e) => e.type === "semantic").length + semBelow.length).toBe(allSem.length); + }); +}); diff --git a/electron/db/graph-queries.ts b/electron/db/graph-queries.ts index b1ce60cc..9e326abd 100644 --- a/electron/db/graph-queries.ts +++ b/electron/db/graph-queries.ts @@ -14,6 +14,9 @@ */ import type Database from "better-sqlite3"; +import { getAllEmbeddingsForWorkspace } from "./queries"; +import type { NoteEmbeddingRecord } from "./queries"; +import { cosine, toFloat32 } from "../embeddings/cosine"; // ── Public types ────────────────────────────────────────────────────────────── @@ -48,7 +51,8 @@ export type EdgeType = | "co-mention" | "keyword" | "assignee" - | "wikilink"; + | "wikilink" + | "semantic"; export interface GraphEdge { id: string; @@ -56,7 +60,9 @@ export interface GraphEdge { target: string; type: EdgeType; label?: string; - weight?: number; // 0–1 for auto edges + weight?: number; + sourceSectionTitle?: string; + targetSectionTitle?: string; } export interface KnowledgeGraph { @@ -366,17 +372,18 @@ export function getKnowledgeGraph( } // ── 6. Auto relationships (relationship_cache) ───────────────────────────── - if (includeAuto && (wantsEdge("co-mention") || wantsEdge("keyword") || wantsEdge("assignee") || wantsEdge("wikilink"))) { + if (includeAuto && (wantsEdge("co-mention") || wantsEdge("keyword") || wantsEdge("assignee") || wantsEdge("wikilink") || wantsEdge("semantic"))) { const autoTypes: string[] = []; if (wantsEdge("co-mention")) autoTypes.push("co-mention"); if (wantsEdge("keyword")) autoTypes.push("keyword"); if (wantsEdge("assignee")) autoTypes.push("assignee"); if (wantsEdge("wikilink")) autoTypes.push("wikilink"); + if (wantsEdge("semantic")) autoTypes.push("semantic"); if (autoTypes.length > 0) { const typePlaceholders = autoTypes.map(() => "?").join(","); const cacheRows = db.prepare( - `SELECT source_id, target_id, type, weight + `SELECT source_id, target_id, type, weight, source_section_title, target_section_title FROM relationship_cache WHERE type IN (${typePlaceholders})` ).all(...autoTypes) as Row[]; @@ -384,7 +391,6 @@ export function getKnowledgeGraph( for (const r of cacheRows) { const src = r.source_id as string; const tgt = r.target_id as string; - // Only include if both endpoints are in the current graph scope if (nodeSet.has(src) && nodeSet.has(tgt)) { edges.push({ id: edgeId(r.type as string, src, tgt), @@ -393,6 +399,8 @@ export function getKnowledgeGraph( type: r.type as EdgeType, weight: r.weight as number, label: r.type as string, + sourceSectionTitle: (r.source_section_title as string | null) ?? undefined, + targetSectionTitle: (r.target_section_title as string | null) ?? undefined, }); } } @@ -554,16 +562,19 @@ export function computeAutoRelationships( ).all(workspaceId) as Row[]; const upsert = db.prepare(` - INSERT INTO relationship_cache (source_id, target_id, type, weight, computed_at) - VALUES (?, ?, ?, ?, ?) + INSERT INTO relationship_cache (source_id, target_id, type, weight, computed_at, source_section_title, target_section_title) + VALUES (?, ?, ?, ?, ?, NULL, NULL) ON CONFLICT(source_id, target_id, type) DO UPDATE SET weight = excluded.weight, - computed_at = excluded.computed_at + computed_at = excluded.computed_at, + source_section_title = NULL, + target_section_title = NULL `); const deleteOld = db.prepare(` DELETE FROM relationship_cache - WHERE source_id = ? OR target_id = ? + WHERE (source_id = ? OR target_id = ?) + AND type != 'semantic' `); // ── Incremental mode: filter to only entities that need recomputing ──────── @@ -706,3 +717,165 @@ export function invalidateRelationshipCache( "DELETE FROM relationship_cache WHERE source_id = ? OR target_id = ?" ).run(entityId, entityId); } + +const SEMANTIC_TOP_K = 5; +const SEMANTIC_TOP_K_FLOOR = 0.55; +/** + * Recompute embedding-based semantic edges by cosine-similarity between the + * stored `search_document` vectors for this workspace. Since v18, vectors are + * per-section, so one note can have multiple embedding rows. This function + * compares all sections across all notes, finds the top-K most-similar + * *sections* for each active section, then maps those back to note-pair edges + * (keeping the best weight per note pair). Output goes to `relationship_cache` + * under the `"semantic"` type so the existing pass-6 loader picks it up. + * + * Strategy: top-K nearest sections per active section, cosine >= floor 0.55. + * Edges are between notes (not sections) — the best section-pair weight wins. + * Canonical source ({ + noteId: r.noteId, + sectionIdx: r.sectionIdx, + sectionTitle: r.sectionTitle, + vec: toFloat32(r.vector), + })); + + const noteIdsInPool = new Set(fullPool.map((s) => s.noteId)); + if (noteIdsInPool.size < 2) return; + + let activePool: SectionVec[] = fullPool; + let activeIds: Set | null = null; + if (entityIds && entityIds.length > 0) { + activeIds = new Set(entityIds); + activePool = fullPool.filter((s) => activeIds!.has(s.noteId)); + } + + const k = Math.min(SEMANTIC_TOP_K, Math.max(1, noteIdsInPool.size - 1)); + + const now = Math.floor(Date.now() / 1000); + const upsert = db.prepare(` + INSERT INTO relationship_cache (source_id, target_id, type, weight, computed_at, source_section_title, target_section_title) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(source_id, target_id, type) DO UPDATE SET + weight = excluded.weight, + computed_at = excluded.computed_at, + source_section_title = excluded.source_section_title, + target_section_title = excluded.target_section_title + `); + const deleteOld = db.prepare(` + DELETE FROM relationship_cache WHERE (source_id = ? OR target_id = ?) AND type = 'semantic' + `); + + const tx = db.transaction(() => { + if (activeIds) { + for (const id of activeIds) deleteOld.run(id, id); + } else { + for (const id of noteIdsInPool) deleteOld.run(id, id); + } + + const bestPerPair = new Map(); + + for (const a of activePool) { + const scored: Array<{ sec: SectionVec; sim: number }> = []; + for (const b of fullPool) { + if (a.noteId === b.noteId) continue; + const sim = cosine(a.vec, b.vec); + if (sim >= SEMANTIC_TOP_K_FLOOR) { + scored.push({ sec: b, sim }); + } + } + scored.sort((x, y) => y.sim - x.sim); + const top = scored.slice(0, k); + for (const t of top) { + const [srcNote, tgtNote, srcSec, tgtSec] = + a.noteId < t.sec.noteId + ? [a.noteId, t.sec.noteId, a.sectionTitle, t.sec.sectionTitle] + : [t.sec.noteId, a.noteId, t.sec.sectionTitle, a.sectionTitle]; + const key = `${srcNote}|${tgtNote}`; + const ex = bestPerPair.get(key); + if (!ex || t.sim > ex.weight) { + bestPerPair.set(key, { weight: t.sim, sourceTitle: srcSec, targetTitle: tgtSec }); + } + } + } + + for (const [key, val] of bestPerPair) { + const [src, tgt] = key.split("|"); + upsert.run(src, tgt, "semantic", Math.round(val.weight * 100) / 100, now, val.sourceTitle, val.targetTitle); + } + }); + tx(); +} + +export interface SemanticNeighbor { + noteId: string; + title: string; + weight: number; + sourceSectionTitle: string | null; + targetSectionTitle: string | null; +} + +export function getSemanticNeighbors( + db: Database.Database, + noteId: string, + workspaceId?: string, +): SemanticNeighbor[] { + const wsClause = workspaceId ? `AND n.workspace_id = ?` : ""; + const wsParams = workspaceId ? [workspaceId, workspaceId] : []; + const rows = db.prepare(` + SELECT + rc.source_id, rc.target_id, rc.weight, + rc.source_section_title, rc.target_section_title, + CASE WHEN rc.source_id = ? THEN rc.target_id ELSE rc.source_id END AS other_id, + CASE WHEN rc.source_id = ? THEN rc.source_section_title ELSE rc.target_section_title END AS this_section, + CASE WHEN rc.source_id = ? THEN rc.target_section_title ELSE rc.source_section_title END AS other_section + FROM relationship_cache rc + WHERE rc.type = 'semantic' + AND (rc.source_id = ? OR rc.target_id = ?) + ORDER BY rc.weight DESC + `).all( + noteId, noteId, noteId, noteId, noteId, + ) as Array<{ + other_id: string; + weight: number; + this_section: string | null; + other_section: string | null; + }>; + + if (rows.length === 0) return []; + + const ids = rows.map((r) => r.other_id); + const placeholders = ids.map(() => "?").join(","); + const titleRows = db.prepare( + `SELECT n.id, n.title FROM notes n WHERE n.id IN (${placeholders}) ${wsClause}`, + ).all(...ids, ...wsParams) as Array<{ id: string; title: string }>; + const titleMap = new Map(titleRows.map((r) => [r.id, r.title] as const)); + + return rows + .filter((r) => titleMap.has(r.other_id)) + .map((r) => ({ + noteId: r.other_id, + title: titleMap.get(r.other_id) ?? r.other_id, + weight: r.weight, + sourceSectionTitle: r.this_section, + targetSectionTitle: r.other_section, + })); +} diff --git a/electron/db/queries.ts b/electron/db/queries.ts index 3d3bea14..c411dd2c 100644 --- a/electron/db/queries.ts +++ b/electron/db/queries.ts @@ -1261,3 +1261,139 @@ export function getCodebaseFilesByRoot(db: Database.Database, rootPath: string) export function deleteCodebaseFile(db: Database.Database, fileId: string) { db.prepare("DELETE FROM codebase_files WHERE id = ?").run(fileId); } + +// ── Note Embeddings ─────────────────────────── +// Vectors are stored as JSON-in-TEXT (sqlite-vec-bridge shape). +// Since v18, one note can have multiple rows — one per markdown section. +// All JSON-parse/serialize happens here; callers receive typed objects. + +export interface NoteEmbeddingRecord { + noteId: string; + sectionIdx: number; + sectionTitle: string; + workspaceId: string; + model: string; + task: string; + contentHash: string; + vector: number[]; + embeddedAt: string; + dimX: number | null; + dimY: number | null; + projStale: number; +} + +interface NoteEmbeddingRow { + note_id: string; + section_idx: number; + section_title: string; + workspace_id: string; + model: string; + task: string; + content_hash: string; + vector: string; + embedded_at: string; + dim_x: number | null; + dim_y: number | null; + proj_stale: number; +} + +function toNoteEmbedding(row: NoteEmbeddingRow): NoteEmbeddingRecord { + return { + noteId: row.note_id, + sectionIdx: row.section_idx, + sectionTitle: row.section_title, + workspaceId: row.workspace_id, + model: row.model, + task: row.task, + contentHash: row.content_hash, + vector: JSON.parse(row.vector) as number[], + embeddedAt: row.embedded_at, + dimX: row.dim_x, + dimY: row.dim_y, + projStale: row.proj_stale, + }; +} + +export function upsertNoteEmbedding( + db: Database.Database, + e: { + noteId: string; + sectionIdx: number; + sectionTitle: string; + workspaceId: string; + model: string; + task: string; + contentHash: string; + vector: number[]; + }, +): void { + const now = ts(); + db.prepare(` + INSERT INTO note_embeddings + (note_id, section_idx, section_title, workspace_id, model, task, content_hash, vector, embedded_at, proj_stale) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1) + ON CONFLICT(note_id, section_idx) DO UPDATE SET + section_title = excluded.section_title, + workspace_id = excluded.workspace_id, + model = excluded.model, + task = excluded.task, + content_hash = excluded.content_hash, + vector = excluded.vector, + embedded_at = excluded.embedded_at, + proj_stale = 1 + `).run(e.noteId, e.sectionIdx, e.sectionTitle, e.workspaceId, e.model, e.task, e.contentHash, JSON.stringify(e.vector), now); +} + +export function deleteNoteEmbeddingSections(db: Database.Database, noteId: string, keepFromIdx?: number): void { + if (keepFromIdx !== undefined) { + db.prepare("DELETE FROM note_embeddings WHERE note_id = ? AND section_idx >= ?").run(noteId, keepFromIdx); + } else { + db.prepare("DELETE FROM note_embeddings WHERE note_id = ?").run(noteId); + } +} + +export function markEmbeddingProjectionFresh(db: Database.Database, noteId: string, x: number, y: number): void { + db.prepare(` + UPDATE note_embeddings SET dim_x = ?, dim_y = ?, proj_stale = 0 WHERE note_id = ? + `).run(x, y, noteId); +} + +export function markAllProjectionsStale(db: Database.Database, workspaceId: string): void { + db.prepare("UPDATE note_embeddings SET proj_stale = 1 WHERE workspace_id = ?").run(workspaceId); +} + +export function getNoteEmbeddings(db: Database.Database, noteId: string): NoteEmbeddingRecord[] { + const rows = db.prepare("SELECT * FROM note_embeddings WHERE note_id = ? ORDER BY section_idx").all(noteId) as NoteEmbeddingRow[]; + return rows.map(toNoteEmbedding); +} + +export function getAllEmbeddingsForWorkspace( + db: Database.Database, + workspaceId: string, + task: string, +): NoteEmbeddingRecord[] { + const rows = db.prepare( + "SELECT * FROM note_embeddings WHERE workspace_id = ? AND task = ? ORDER BY note_id, section_idx" + ).all(workspaceId, task) as NoteEmbeddingRow[]; + return rows.map(toNoteEmbedding); +} + +export function getStaleProjectionNoteIds(db: Database.Database, workspaceId: string): string[] { + const rows = db.prepare( + "SELECT DISTINCT note_id FROM note_embeddings WHERE workspace_id = ? AND proj_stale = 1 AND task = 'search_document'" + ).all(workspaceId) as Array<{ note_id: string }>; + return rows.map((r) => r.note_id); +} + +export function deleteNoteEmbedding(db: Database.Database, noteId: string): void { + db.prepare("DELETE FROM note_embeddings WHERE note_id = ?").run(noteId); +} + +export function pruneOrphanedClusteringRows(db: Database.Database): number { + const info = db.prepare("DELETE FROM note_embeddings WHERE task = 'clustering'").run(); + return info.changes; +} + +export function deleteWorkspaceEmbeddings(db: Database.Database, workspaceId: string): void { + db.prepare("DELETE FROM note_embeddings WHERE workspace_id = ?").run(workspaceId); +} diff --git a/electron/db/schema.ts b/electron/db/schema.ts index d7311401..0f6b44ce 100644 --- a/electron/db/schema.ts +++ b/electron/db/schema.ts @@ -404,6 +404,82 @@ const MIGRATIONS: Migration[] = [ CREATE INDEX IF NOT EXISTS idx_codebase_relations_target_name ON codebase_relations(target_name); `); }, + + // v17: Embedding storage for semantic graph + adjacent-notes (JSON-TEXT vector + // bridge; the column shape is deliberately sqlite-vec-compatible so the eventual + // swap to a vec0 virtual table only needs a column rename, not a rewrite). + (db) => { + db.exec(` + CREATE TABLE IF NOT EXISTS note_embeddings ( + note_id TEXT PRIMARY KEY REFERENCES notes(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL, + model TEXT NOT NULL, + task TEXT NOT NULL, + content_hash TEXT NOT NULL, + vector TEXT NOT NULL, + embedded_at TEXT NOT NULL, + dim_x REAL, + dim_y REAL, + proj_stale INTEGER NOT NULL DEFAULT 1 + ); + CREATE INDEX IF NOT EXISTS idx_emb_workspace ON note_embeddings(workspace_id); + CREATE INDEX IF NOT EXISTS idx_emb_proj_stale ON note_embeddings(proj_stale); + CREATE INDEX IF NOT EXISTS idx_emb_task ON note_embeddings(task); + `); + }, + + // v18: Section-based embeddings — one note can have multiple embedding rows, + // one per markdown section (## / # header boundary). Adds section_idx + section_title, + // changes PK from (note_id) to (note_id, section_idx). Old data is migrated as + // section_idx=0, section_title=''. This enables much finer-grained semantic matching + // because notes with multiple topics no longer have their embedding diluted by averaging. + // Also adds source_section_title / target_section_title to relationship_cache so the + // semantic edges can carry which sections matched. + (db) => { + db.exec(` + ALTER TABLE note_embeddings RENAME TO note_embeddings_v17; + CREATE TABLE note_embeddings ( + note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + section_idx INTEGER NOT NULL DEFAULT 0, + workspace_id TEXT NOT NULL, + model TEXT NOT NULL, + task TEXT NOT NULL, + section_title TEXT NOT NULL DEFAULT '', + content_hash TEXT NOT NULL, + vector TEXT NOT NULL, + embedded_at TEXT NOT NULL, + dim_x REAL, + dim_y REAL, + proj_stale INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (note_id, section_idx) + ); + INSERT INTO note_embeddings (note_id, section_idx, workspace_id, model, task, section_title, content_hash, vector, embedded_at, dim_x, dim_y, proj_stale) + SELECT note_id, 0, workspace_id, model, task, '', content_hash, vector, embedded_at, dim_x, dim_y, proj_stale + FROM note_embeddings_v17; + DROP TABLE note_embeddings_v17; + CREATE INDEX IF NOT EXISTS idx_emb_workspace ON note_embeddings(workspace_id); + CREATE INDEX IF NOT EXISTS idx_emb_proj_stale ON note_embeddings(proj_stale); + CREATE INDEX IF NOT EXISTS idx_emb_task ON note_embeddings(task); + + ALTER TABLE relationship_cache ADD COLUMN source_section_title TEXT; + ALTER TABLE relationship_cache ADD COLUMN target_section_title TEXT; + `); + }, + + // v19: Failsafe — if v18 ran before the ALTER TABLE statements were added + // (the v18 migration was edited after initial deployment), the + // source_section_title / target_section_title columns won't exist on + // relationship_cache. This migration adds them idempotently. + (db) => { + const cols = db.prepare("PRAGMA table_info(relationship_cache)").all() as Array<{ name: string }>; + const has = (name: string) => cols.some((c) => c.name === name); + if (!has("source_section_title")) { + db.exec("ALTER TABLE relationship_cache ADD COLUMN source_section_title TEXT"); + } + if (!has("target_section_title")) { + db.exec("ALTER TABLE relationship_cache ADD COLUMN target_section_title TEXT"); + } + }, ]; export function applySchema(db: Database.Database): void { diff --git a/electron/embeddings/chunking.test.ts b/electron/embeddings/chunking.test.ts new file mode 100644 index 00000000..91cdec06 --- /dev/null +++ b/electron/embeddings/chunking.test.ts @@ -0,0 +1,114 @@ +/** + * Tests for the long-document chunking helper used by `embedSectionText`. + * + * `chunkLongText` lives inside `service.ts` but is not exported. To test it + * we re-implement the same logic in a pure form here and assert behavioural + * invariants. The actual production code is verified by compile + type-check + * to match the typed signatures; the tests guard against regressions in the + * chunking algorithm itself (notably the infinite-loop OOM bug fixed in v2.1.1). + */ +import { describe, it, expect } from "vitest"; +import * as crypto from "crypto"; + +const CHUNK_CHAR_LIMIT = 4000; +const CHUNK_OVERLAP = 200; + +function sha256(s: string): string { + return crypto.createHash("sha256").update(s, "utf8").digest("hex"); +} + +interface TextChunk { + text: string; + hash: string; +} + +function chunkLongText(text: string): TextChunk[] { + if (text.length <= CHUNK_CHAR_LIMIT) { + return [{ text, hash: sha256(text) }]; + } + const chunks: TextChunk[] = []; + let start = 0; + while (start < text.length) { + let end = Math.min(start + CHUNK_CHAR_LIMIT, text.length); + if (end < text.length) { + const lastBreak = Math.max( + text.lastIndexOf("\n", end), + text.lastIndexOf(". ", end), + text.lastIndexOf(" ", end), + ); + if (lastBreak > start + CHUNK_CHAR_LIMIT / 2) end = lastBreak; + } + if (end <= start) end = start + 1; + chunks.push({ text: text.slice(start, end), hash: sha256(text.slice(start, end)) }); + if (end >= text.length) break; + const nextStart = end - CHUNK_OVERLAP; + if (nextStart <= start) break; + start = nextStart; + } + return chunks; +} + +describe("chunkLongText", () => { + it("returns a single chunk for short text", () => { + const text = "Hello, world."; + const chunks = chunkLongText(text); + expect(chunks.length).toBe(1); + expect(chunks[0].text).toBe(text); + expect(chunks[0].hash).toBe(sha256(text)); + }); + + it("returns a single chunk at exactly CHUNK_CHAR_LIMIT", () => { + const text = "a".repeat(CHUNK_CHAR_LIMIT); + const chunks = chunkLongText(text); + expect(chunks.length).toBe(1); + }); + + it("splits text just over CHUNK_CHAR_LIMIT into multiple chunks", () => { + const text = "a".repeat(CHUNK_CHAR_LIMIT + 1); + const chunks = chunkLongText(text); + expect(chunks.length).toBeGreaterThan(1); + }); + + it("TERMINATES on very long input (regression: infinite loop → OOM)", () => { + const text = "x".repeat(1_000_000); + const chunks = chunkLongText(text); + expect(chunks.length).toBeGreaterThan(0); + expect(chunks.length).toBeLessThan(1000); + }); + + it("covers the entire input (last chunk reaches text end)", () => { + const text = Array.from({ length: 10000 }, (_, i) => `word${i}`).join(" "); + const chunks = chunkLongText(text); + const lastChunkEnd = chunks.reduce((acc, c, i) => { + const advance = i === chunks.length - 1 ? c.text.length : c.text.length - CHUNK_OVERLAP; + return acc + advance; + }, 0); + expect(lastChunkEnd).toBeGreaterThanOrEqual(text.length - 1); + }); + + it("produces short chunks (each <= CHUNK_CHAR_LIMIT + small overhead)", () => { + const text = "sentence. ".repeat(2000); + const chunks = chunkLongText(text); + for (const c of chunks) { + expect(c.text.length).toBeLessThanOrEqual(CHUNK_CHAR_LIMIT); + } + }); + + it("includes paragraph breaks when possible", () => { + const para = "para ".repeat(800) + "\n"; + const text = para.repeat(10); + const chunks = chunkLongText(text); + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.some((c) => c.text.includes("\n"))).toBe(true); + }); + + it("respects the overlap between consecutive chunks", () => { + const text = "abcdef".repeat(2000); + const chunks = chunkLongText(text); + if (chunks.length >= 2) { + const tail = chunks[0].text.slice(-CHUNK_OVERLAP); + const head = chunks[1].text.slice(0, CHUNK_OVERLAP); + expect(tail).toBe(head); + } + }); +}); diff --git a/electron/embeddings/client.ts b/electron/embeddings/client.ts new file mode 100644 index 00000000..6ef996bc --- /dev/null +++ b/electron/embeddings/client.ts @@ -0,0 +1,425 @@ +import { app } from "electron"; +import * as fs from "fs"; +import * as http from "http"; +import * as path from "path"; +import { spawn, type ChildProcess, execSync } from "child_process"; + +import { findFreePort } from "./port"; +import { NOMIC_MODEL_ID, NOMIC_DIM } from "./types"; +import type { NomicTask, EmbeddingsStatus } from "./types"; +import * as manifest from "./manifest"; + +const MODELS_DIR = manifest.MODELS_DIR; + +const LOCAL_BIN_DIR = path.join(app.getPath("userData"), "embeddings-bin"); +const LOCAL_BIN_PATH = path.join( + LOCAL_BIN_DIR, + process.platform === "win32" ? "cairn-embeddings.exe" : "cairn-embeddings", +); + +function resolveDevServerScript(): string | null { + const candidates = [ + path.join(__dirname, "embeddings-server.bundle.js"), + path.join(__dirname, "..", "dist-electron", "embeddings-server.bundle.js"), + ]; + for (const c of candidates) { + if (fs.existsSync(c)) return c; + } + return null; +} + +function resolveBinaryPath(): string | null { + if (fs.existsSync(LOCAL_BIN_PATH)) return LOCAL_BIN_PATH; + if (app.isPackaged) { + const unpacked = path.join( + process.resourcesPath, + "app.asar.unpacked", + "dist-embeddings", + process.platform === "win32" ? "cairn-embeddings.exe" : "cairn-embeddings", + ); + if (fs.existsSync(unpacked)) return unpacked; + } + if (!app.isPackaged) { + const script = resolveDevServerScript(); + if (script) return script; + } + return null; +} + +function resolveNodeRunner(): string | null { + if (!app.isPackaged) { + try { + const nodeBin = execSync( + process.platform === "win32" ? "where node" : "which node", + ) + .toString() + .trim() + .split(/\r?\n/)[0]; + if (nodeBin && fs.existsSync(nodeBin)) return nodeBin; + } catch { + // ignore — not on PATH + } + } + return null; +} + +function isScriptPath(p: string): boolean { + return p.endsWith(".js"); +} + +export function getDefaultModelId(): string { + return manifest.readDefaultModelId() ?? NOMIC_MODEL_ID; +} + +interface StdoutEvent { + kind: "listening" | "ready" | "progress" | "error" | "log"; + port?: number; + model?: string | null; + status?: string; + file?: string; + progress?: number; + loaded?: number; + total?: number; + msg?: string; +} + +type ProgressListener = ( + ev: + | { + kind: "progress"; + modelId: string; + status: string; + file?: string; + progress?: number; + loaded?: number; + total?: number; + } + | { kind: "ready"; modelId: string }, +) => void; + +let workerProcess: ChildProcess | null = null; +let workerPort: number | null = null; +let workerModel: string | null = null; +let isReady = false; +let bootError = ""; +const progressListeners = new Set(); + +let startPromise: Promise | null = null; +let inFlightEmbeds = 0; +let lastStopTrace = ""; + +let reindexInProgress = false; +let recomputeInProgress = false; +let lastReindexDone = 0; +let lastReindexTotal = 0; +let lastRecomputeDone = 0; +let lastRecomputeTotal = 0; + +export function setReindexInProgress(v: boolean): void { + reindexInProgress = v; + if (v) { + lastReindexDone = 0; + lastReindexTotal = 0; + } +} +export function setRecomputeInProgress(v: boolean): void { + recomputeInProgress = v; + if (v) { + lastRecomputeDone = 0; + lastRecomputeTotal = 0; + } +} +export function setLastReindexProgress(done: number, total: number): void { + lastReindexDone = done; + lastReindexTotal = total; +} +export function setLastRecomputeProgress(done: number, total: number): void { + lastRecomputeDone = done; + lastRecomputeTotal = total; +} + +function emitProgress(ev: StdoutEvent): void { + if (ev.kind !== "progress" && ev.kind !== "ready") return; + const modelId = ev.model ?? workerModel ?? NOMIC_MODEL_ID; + for (const l of progressListeners) { + if (ev.kind === "ready") { + l({ kind: "ready", modelId }); + } else { + l({ + kind: "progress", + modelId, + status: ev.status ?? "unknown", + file: ev.file, + progress: ev.progress, + loaded: ev.loaded, + total: ev.total, + }); + } + } +} + +function parseStdoutLine(line: string): void { + const trimmed = line.trim(); + if (!trimmed) return; + let ev: StdoutEvent; + try { + ev = JSON.parse(trimmed) as StdoutEvent; + } catch { + console.log("[embeddings-worker stdout]:", trimmed); + return; + } + switch (ev.kind) { + case "listening": + if (typeof ev.port === "number") workerPort = ev.port; + break; + case "ready": + isReady = true; + if (ev.model) workerModel = ev.model; + emitProgress(ev); + break; + case "progress": + emitProgress(ev); + break; + case "error": + bootError = ev.msg ?? "unknown error"; + break; + case "log": + console.log("[embeddings-worker]:", ev.msg ?? ""); + break; + } +} + +async function spawnWorker(model: string): Promise { + const port = await findFreePort(); + const binaryPath = resolveBinaryPath(); + if (!binaryPath) { + throw new Error( + "Embeddings worker binary not found. Run `node scripts/build-embeddings-binary.js` or run in dev mode after `npm run compile`.", + ); + } + const isScript = isScriptPath(binaryPath); + const nodeRunner = isScript ? resolveNodeRunner() : null; + const args: string[] = []; + if (isScript) { + args.push("--max-old-space-size=512"); + if (nodeRunner) args.push(binaryPath); + else args.push(binaryPath); + } + args.push(`--port=${port}`, `--cache-dir=${MODELS_DIR}`, `--model=${model}`); + + const cmd = nodeRunner ?? (isScript ? process.execPath : binaryPath); + const childEnv: NodeJS.ProcessEnv = { + ...process.env, + TRANSFORMERS_CACHE: MODELS_DIR, + }; + if (isScript && !nodeRunner) childEnv.ELECTRON_RUN_AS_NODE = "1"; + if (nodeRunner) console.log(`[embeddings-client] spawning worker via Node: ${nodeRunner}`); + const child = spawn(cmd, args, { + stdio: ["ignore", "pipe", "pipe"], + detached: false, + env: childEnv, + }); + + child.stdout?.setEncoding("utf8"); + let stdoutBuf = ""; + child.stdout?.on("data", (data: string) => { + stdoutBuf += data; + const lines = stdoutBuf.split("\n"); + stdoutBuf = lines.pop() ?? ""; + for (const line of lines) parseStdoutLine(line); + }); + + let stderrBuf = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (data: string) => { + stderrBuf += data; + console.error("[embeddings-worker stderr]:", data.trim()); + }); + + child.on("exit", (code, signal) => { + console.log( + `[embeddings-client] worker exited code=${code} signal=${signal} ` + + `inFlight=${inFlightEmbeds} ready=${isReady} bootError="${bootError}"`, + ); + if (!signal && code !== 0) { + console.log("[embeddings-client] last 1KB stderr:\n" + stderrBuf.slice(-1024)); + } + workerProcess = null; + workerPort = null; + isReady = false; + startPromise = null; + }); + + workerProcess = child; + workerModel = model; + return port; +} + +async function checkHealth(port: number, timeoutMs = 1000): Promise { + return new Promise((resolve) => { + const req = http.get(`http://127.0.0.1:${port}/health`, { timeout: timeoutMs }, (res) => { + resolve(res.statusCode === 200); + }); + req.on("error", () => resolve(false)); + req.on("timeout", () => { + req.destroy(); + resolve(false); + }); + }); +} + +export async function ensureStarted(): Promise { + if (startPromise) return startPromise; + if (workerProcess && workerPort) { + for (let i = 0; i < 5; i++) { + if (await checkHealth(workerPort, 1500)) return workerPort; + if (!workerProcess) break; + } + console.warn( + "[embeddings-client] worker alive but unhealthy, restarting. inFlightEmbeds=" + + inFlightEmbeds + + " lastStopTrace:\n" + + lastStopTrace, + ); + await stopWorker({ force: true }); + } + startPromise = (async () => { + const port = await spawnWorker(getDefaultModelId()); + for (let i = 0; i < 600; i++) { + if (workerProcess?.killed || workerProcess?.exitCode !== null) break; + await new Promise((r) => setTimeout(r, 200)); + if (await checkHealth(port)) { + isReady = true; + return port; + } + } + await stopWorker(); + throw new Error( + `embeddings worker failed to start within 120s.${bootError ? ` Last error: ${bootError}` : ""}`, + ); + })().catch((e) => { + startPromise = null; + throw e; + }); + return startPromise; +} + +export async function stopWorker(opts?: { force?: boolean }): Promise { + if (!workerProcess) return; + if (!opts?.force && inFlightEmbeds > 0) { + console.warn( + `[embeddings-client] stopWorker suppressed — ${inFlightEmbeds} in-flight embed(s). Stack:\n` + + new Error().stack, + ); + return; + } + lastStopTrace = new Error().stack ?? "(no stack)"; + const proc = workerProcess; + workerProcess = null; + workerPort = null; + isReady = false; + startPromise = null; + try { + proc.kill("SIGTERM"); + } catch { + // ignore + } + await new Promise((resolve) => { + const timer = setTimeout(() => { + try { proc.kill("SIGKILL"); } catch { /* ignore */ } + resolve(); + }, 2000); + proc.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +export async function dispose(): Promise { + await stopWorker({ force: true }); + progressListeners.clear(); +} + +export function onProgress(listener: ProgressListener): () => void { + progressListeners.add(listener); + return () => progressListeners.delete(listener); +} + +export async function embed( + texts: string[], + task: NomicTask, + model = getDefaultModelId(), +): Promise { + if (texts.length === 0) return []; + inFlightEmbeds++; + try { + const port = await ensureStarted(); + const body = JSON.stringify({ texts, task, model }); + const res = await new Promise<{ status: number; body: string }>((resolve, reject) => { + const req = http.request( + `http://127.0.0.1:${port}/embed`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + }, + timeout: 600_000, + }, + (r) => { + const chunks: Buffer[] = []; + r.on("data", (c: Buffer) => chunks.push(c)); + r.on("end", () => { + resolve({ status: r.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf8") }); + }); + }, + ); + req.on("error", reject); + req.on("timeout", () => { + req.destroy(new Error("embed request timed out after 600s")); + }); + req.write(body); + req.end(); + }); + if (res.status !== 200) { + throw new Error(`embed http ${res.status}: ${res.body}`); + } + const parsed = JSON.parse(res.body) as { + vectors: number[][]; + dim: number; + model: string; + }; + if (parsed.dim !== NOMIC_DIM) { + throw new Error(`embed: expected dim ${NOMIC_DIM}, got ${parsed.dim}`); + } + return parsed.vectors; + } finally { + inFlightEmbeds--; + } +} + +export function getStatus(): EmbeddingsStatus { + const running = workerProcess !== null && !workerProcess.killed && workerProcess.exitCode === null; + return { + running, + port: running ? workerPort : null, + activeModelId: running ? workerModel : null, + defaultModelId: getDefaultModelId(), + installed: resolveBinaryPath() !== null, + error: bootError || null, + reindexInProgress, + recomputeInProgress, + lastReindexDone, + lastReindexTotal, + lastRecomputeDone, + lastRecomputeTotal, + }; +} + +export function isReadyForInference(): boolean { + return isReady && workerProcess !== null && workerPort !== null; +} + +export const MODELS_DIRECTORY = MODELS_DIR; +export const BINARY_DIRECTORY = LOCAL_BIN_DIR; diff --git a/electron/embeddings/cosine.test.ts b/electron/embeddings/cosine.test.ts new file mode 100644 index 00000000..2812aa75 --- /dev/null +++ b/electron/embeddings/cosine.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { cosine, topK, toFloat32, dotNormalized, magnitude } from "./cosine"; + +describe("cosine", () => { + it("returns 1 for identical vectors", () => { + const a = toFloat32([1, 2, 3]); + expect(cosine(a, a)).toBeCloseTo(1, 6); + }); + + it("returns 0 for orthogonal vectors", () => { + const a = toFloat32([1, 0, 0]); + const b = toFloat32([0, 1, 0]); + expect(cosine(a, b)).toBeCloseTo(0, 6); + }); + + it("returns -1 for opposite vectors", () => { + const a = toFloat32([1, 2, 3]); + const b = toFloat32([-1, -2, -3]); + expect(cosine(a, b)).toBeCloseTo(-1, 6); + }); + + it("throws on dimension mismatch", () => { + expect(() => cosine(toFloat32([1, 2]), toFloat32([1, 2, 3]))).toThrow(/dim mismatch/); + }); + + it("returns 0 for zero vectors (denominator guard)", () => { + const z = toFloat32([0, 0, 0]); + expect(cosine(z, toFloat32([1, 2, 3]))).toBe(0); + }); +}); + +describe("dotNormalized", () => { + it("equals cosine for unit-norm vectors", () => { + const a = toFloat32([0.6, 0.8, 0]); + const b = toFloat32([0.8, -0.6, 0]); + expect(dotNormalized(a, b)).toBeCloseTo(cosine(a, b), 6); + }); +}); + +describe("magnitude", () => { + it("computes L2 norm", () => { + expect(magnitude(toFloat32([3, 4]))).toBeCloseTo(5, 6); + }); +}); + +describe("topK", () => { + const pool = [ + { noteId: "a", vector: toFloat32([1, 0, 0]) }, + { noteId: "b", vector: toFloat32([0, 1, 0]) }, + { noteId: "c", vector: toFloat32([0.9, 0.1, 0]) }, + { noteId: "d", vector: toFloat32([0, 0, 1]) }, + ]; + + it("returns top-k sorted by descending score", () => { + const res = topK(toFloat32([1, 0, 0]), pool, 3, 0.1); + expect(res.map((r) => r.item.noteId)).toEqual(["a", "c"]); + expect(res.length).toBe(2); + expect(res[0].score).toBeGreaterThanOrEqual(res[1].score); + }); + + it("respects threshold", () => { + const res = topK(toFloat32([1, 0, 0]), pool, 10, 0.95); + expect(res.map((r) => r.item.noteId)).toEqual(["a", "c"]); + expect(res.every((r) => r.score >= 0.95)).toBe(true); + }); + + it("excludes ids in the excludeSet", () => { + const res = topK(toFloat32([1, 0, 0]), pool, 10, 0.5, new Set(["a"])); + expect(res.map((r) => r.item.noteId)).toEqual(["c"]); + }); + + it("returns empty pool unchanged", () => { + expect(topK(toFloat32([1, 0]), [], 5)).toEqual([]); + }); + + it("handles k larger than pool size", () => { + const res = topK(toFloat32([1, 0, 0]), pool, 100); + expect(res.length).toBe(pool.length); + }); +}); diff --git a/electron/embeddings/cosine.ts b/electron/embeddings/cosine.ts new file mode 100644 index 00000000..a7362677 --- /dev/null +++ b/electron/embeddings/cosine.ts @@ -0,0 +1,54 @@ +const EPS = 1e-9; + +export function cosine(a: Float32Array, b: Float32Array): number { + if (a.length !== b.length) { + throw new Error(`cosine: dim mismatch ${a.length} vs ${b.length}`); + } + let dot = 0; + let normA = 0; + let normB = 0; + for (let i = 0; i < a.length; i++) { + const av = a[i]; + const bv = b[i]; + dot += av * bv; + normA += av * av; + normB += bv * bv; + } + const denom = Math.sqrt(normA) * Math.sqrt(normB); + if (denom < EPS) return 0; + return dot / denom; +} + +export function topK( + query: Float32Array, + pool: readonly T[], + k: number, + threshold = 0, + excludeIds: ReadonlySet = new Set(), +): Array<{ item: T; score: number }> { + const scored: Array<{ item: T; score: number }> = []; + for (const item of pool) { + if (excludeIds.has(item.noteId)) continue; + const score = cosine(query, item.vector); + if (score >= threshold) scored.push({ item, score }); + } + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, k); +} + +export function dotNormalized(a: Float32Array, b: Float32Array): number { + let dot = 0; + for (let i = 0; i < a.length; i++) dot += a[i] * b[i]; + return dot; +} + +export function magnitude(a: Float32Array): number { + let sum = 0; + for (let i = 0; i < a.length; i++) sum += a[i] * a[i]; + return Math.sqrt(sum); +} + +export function toFloat32(vec: number[] | Float32Array): Float32Array { + if (vec instanceof Float32Array) return vec; + return new Float32Array(vec); +} diff --git a/electron/embeddings/manifest.ts b/electron/embeddings/manifest.ts new file mode 100644 index 00000000..a2b0106c --- /dev/null +++ b/electron/embeddings/manifest.ts @@ -0,0 +1,158 @@ +import { app } from "electron"; +import * as fs from "fs"; +import * as path from "path"; + +import { NOMIC_MODEL_ID, NOMIC_DIM } from "./types"; +import type { EmbeddingModelManifestEntry } from "./types"; + +export const MODELS_DIR = path.join(app.getPath("userData"), "embedding-models"); +const MANIFEST_PATH = path.join(MODELS_DIR, "manifest.json"); +const DEFAULT_MODEL_PATH = path.join(MODELS_DIR, "default-model.json"); + +export interface SupportedEmbeddingModel { + id: string; + name: string; + repo: string; + dim: number; + maxTokens: number; + sizeBytes: number; +} + +export const SUPPORTED_EMBEDDING_MODELS: Record = { + [NOMIC_MODEL_ID]: { + id: NOMIC_MODEL_ID, + name: "Nomic Embed Text v1.5 (int8 quantised)", + repo: "nomic-ai/nomic-embed-text-v1.5", + dim: NOMIC_DIM, + maxTokens: 8192, + sizeBytes: 91_000_000, + }, +}; + +function readManifestFile(): Record> { + if (!fs.existsSync(MANIFEST_PATH)) return {}; + try { + return JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf8")); + } catch { + return {}; + } +} + +function writeManifestFile(data: Record>): void { + if (!fs.existsSync(MODELS_DIR)) fs.mkdirSync(MODELS_DIR, { recursive: true }); + fs.writeFileSync(MANIFEST_PATH, JSON.stringify(data, null, 2), "utf8"); +} + +function modelFilesPresent(modelId: string): boolean { + if (!fs.existsSync(MODELS_DIR)) return false; + const [org, name] = modelId.split("/"); + if (!org || !name) return false; + const nested = path.join(MODELS_DIR, org, name); + if (!fs.existsSync(nested)) return false; + const hasConfig = fs.existsSync(path.join(nested, "config.json")); + const onnxDir = path.join(nested, "onnx"); + const hasOnnx = fs.existsSync(onnxDir) + && fs.readdirSync(onnxDir).some((f) => f.endsWith(".onnx")); + return hasConfig && hasOnnx; +} + +export function getEmbeddingModelsManifest(): EmbeddingModelManifestEntry[] { + if (!fs.existsSync(MODELS_DIR)) fs.mkdirSync(MODELS_DIR, { recursive: true }); + const manifest = readManifestFile(); + let updated = false; + const result: EmbeddingModelManifestEntry[] = []; + for (const [id, def] of Object.entries(SUPPORTED_EMBEDDING_MODELS)) { + const diskHas = modelFilesPresent(id); + const stored = manifest[id] ?? {}; + let status: EmbeddingModelManifestEntry["status"]; + if (stored.status === "downloading" && diskHas) { + status = "installed"; + } else if (stored.status === "downloading") { + status = stored.status; + } else if (diskHas) { + status = "installed"; + } else { + status = "not_downloaded"; + } + const entry: EmbeddingModelManifestEntry = { + id, + name: def.name, + repo: def.repo, + dim: def.dim, + maxTokens: def.maxTokens, + sizeBytes: def.sizeBytes, + status, + 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, + }; + result.push(entry); + if (stored.status !== entry.status) { + manifest[id] = { status: entry.status }; + updated = true; + } + } + if (updated) writeManifestFile(manifest); + return result; +} + +export function setEmbeddingModelStatus( + modelId: string, + status: EmbeddingModelManifestEntry["status"], + patch?: { progress?: number; speed?: string; error?: string }, +): void { + const manifest = readManifestFile(); + manifest[modelId] = { + ...manifest[modelId], + status, + downloadProgress: patch?.progress, + downloadSpeed: patch?.speed, + error: patch?.error, + }; + writeManifestFile(manifest); +} + +export function removeEmbeddingModel(modelId: string): void { + if (!fs.existsSync(MODELS_DIR)) return; + const [org, name] = modelId.split("/"); + if (!org || !name) return; + const nested = path.join(MODELS_DIR, org, name); + if (fs.existsSync(nested)) { + try { + fs.rmSync(nested, { recursive: true, force: true }); + } catch { + // ignore + } + } + const orgDir = path.join(MODELS_DIR, org); + if (fs.existsSync(orgDir)) { + try { + const remaining = fs.readdirSync(orgDir); + if (remaining.length === 0) fs.rmSync(orgDir, { recursive: true, force: true }); + } catch { + // ignore + } + } + setEmbeddingModelStatus(modelId, "not_downloaded", { progress: 0 }); +} + +export function readDefaultModelId(): string | null { + if (!fs.existsSync(DEFAULT_MODEL_PATH)) return null; + try { + const data = JSON.parse(fs.readFileSync(DEFAULT_MODEL_PATH, "utf8")); + if (typeof data.defaultModelId === "string" && data.defaultModelId) return data.defaultModelId; + } catch { + // ignore + } + return null; +} + +export function writeDefaultModelId(modelId: string): void { + if (!fs.existsSync(MODELS_DIR)) fs.mkdirSync(MODELS_DIR, { recursive: true }); + fs.writeFileSync(DEFAULT_MODEL_PATH, JSON.stringify({ defaultModelId: modelId }), "utf8"); +} diff --git a/electron/embeddings/nomic.ts b/electron/embeddings/nomic.ts new file mode 100644 index 00000000..d353b5c1 --- /dev/null +++ b/electron/embeddings/nomic.ts @@ -0,0 +1,11 @@ +import type { NomicTask } from "./types"; + +export const NOMIC_PREFIX: Record = { + search_document: "search_document: ", + search_query: "search_query: ", + clustering: "clustering: ", +}; + +export function withNomicPrefix(task: NomicTask, text: string): string { + return `${NOMIC_PREFIX[task]}${text}`; +} diff --git a/electron/embeddings/pipeline.ts b/electron/embeddings/pipeline.ts new file mode 100644 index 00000000..15f55b75 --- /dev/null +++ b/electron/embeddings/pipeline.ts @@ -0,0 +1,156 @@ +import { AutoTokenizer, env, type PreTrainedTokenizer } from "@xenova/transformers"; +import * as ort from "onnxruntime-node"; +import type { NomicTask } from "./types"; +import { NOMIC_MODEL_ID, NOMIC_DIM } from "./types"; +import { withNomicPrefix } from "./nomic"; + +env.allowLocalModels = false; +env.useBrowserCache = false; + +export interface EmbedProgress { + status: string; + file?: string; + progress?: number; + loaded?: number; + total?: number; +} + +export type ProgressCallback = (p: EmbedProgress) => void; + +interface LoadedPipeline { + tokenizer: PreTrainedTokenizer; + session: ort.InferenceSession; + modelId: string; +} + +let _pipeline: LoadedPipeline | null = null; +let _pipelinePromise: Promise | null = null; +let _pipelinePromiseModelId: string | null = null; + +export function setCacheDir(dir: string): void { + env.cacheDir = dir; +} + +export function isLoaded(): boolean { + return _pipeline !== null; +} + +export function loadedModelId(): string | null { + return _pipeline?.modelId ?? null; +} + +export async function loadPipeline( + modelId: string = NOMIC_MODEL_ID, + onProgress?: ProgressCallback, +): Promise { + if (_pipeline && _pipeline.modelId === modelId) return _pipeline; + if (_pipelinePromise && _pipelinePromiseModelId === modelId) return _pipelinePromise; + + _pipelinePromiseModelId = modelId; + + _pipelinePromise = (async () => { + onProgress?.({ status: "initiate" }); + + const tokenizer = await AutoTokenizer.from_pretrained(modelId, { + progress_callback: onProgress + ? (data: unknown) => { + if (data && typeof data === "object") { + const d = data as Record; + onProgress({ + status: typeof d.status === "string" ? d.status : "unknown", + file: typeof d.file === "string" ? d.file : undefined, + progress: typeof d.progress === "number" ? d.progress : undefined, + loaded: typeof d.loaded === "number" ? d.loaded : undefined, + total: typeof d.total === "number" ? d.total : undefined, + }); + } + } + : undefined, + }); + + const modelPath = `${env.cacheDir || ""}/${modelId}/onnx/model_quantized.onnx`; + const session = await ort.InferenceSession.create(modelPath, { + executionProviders: ["cpu"], + graphOptimizationLevel: "all", + enableCpuMemArena: false, + enableMemPattern: false, + }); + + _pipeline = { tokenizer, session, modelId }; + onProgress?.({ status: "ready" }); + return _pipeline; + })().catch((err) => { + _pipelinePromise = null; + _pipelinePromiseModelId = null; + throw err; + }); + + return _pipelinePromise; +} + +export async function embed( + texts: string[], + task: NomicTask, + modelId: string = NOMIC_MODEL_ID, +): Promise { + const pipe = await loadPipeline(modelId); + const prefixed = texts.map((t) => withNomicPrefix(task, t)); + + const encoded = await pipe.tokenizer(prefixed, { padding: true, truncation: true }); + const batchSize = encoded.input_ids.dims[0]; + const seqLen = encoded.input_ids.dims[1]; + const total = batchSize * seqLen; + + const inputIdsData = new BigInt64Array(total); + const attentionData = new BigInt64Array(total); + const tokenTypeData = new BigInt64Array(total); + for (let i = 0; i < total; i++) { + inputIdsData[i] = encoded.input_ids.data[i]; + attentionData[i] = encoded.attention_mask.data[i]; + if (encoded.token_type_ids) tokenTypeData[i] = encoded.token_type_ids.data[i]; + } + + const feeds: Record = { + input_ids: new ort.Tensor("int64", inputIdsData, [batchSize, seqLen]), + attention_mask: new ort.Tensor("int64", attentionData, [batchSize, seqLen]), + token_type_ids: new ort.Tensor("int64", tokenTypeData, [batchSize, seqLen]), + }; + + const output = await pipe.session.run(feeds); + const lastHidden = output.last_hidden_state; + const dims = lastHidden.dims; + const hidden = dims[2]; + const data = lastHidden.data as Float32Array; + + const vectors: number[][] = []; + for (let b = 0; b < dims[0]; b++) { + const sumVec = new Float32Array(hidden); + let count = 0; + for (let s = 0; s < dims[1]; s++) { + if (attentionData[b * seqLen + s] === 0n) continue; + const offset = (b * dims[1] + s) * hidden; + for (let h = 0; h < hidden; h++) sumVec[h] += data[offset + h]; + count++; + } + if (count > 0) for (let h = 0; h < hidden; h++) sumVec[h] /= count; + let norm = 0; + for (let h = 0; h < hidden; h++) norm += sumVec[h] * sumVec[h]; + norm = Math.sqrt(norm); + if (norm < 1e-9) norm = 1e-9; + for (let h = 0; h < hidden; h++) sumVec[h] /= norm; + vectors.push(Array.from(sumVec)); + } + + for (const v of vectors) { + if (v.length !== NOMIC_DIM) { + throw new Error(`embed: expected dim ${NOMIC_DIM}, got ${v.length}`); + } + } + return vectors; +} + +export function resetPipeline(): void { + _pipeline = null; + _pipelinePromise = null; + _pipelinePromiseModelId = null; +} diff --git a/electron/embeddings/port.ts b/electron/embeddings/port.ts new file mode 100644 index 00000000..95b6fb0a --- /dev/null +++ b/electron/embeddings/port.ts @@ -0,0 +1,16 @@ +import * as net from "net"; + +export function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + const port = addr && typeof addr === "object" ? addr.port : 0; + server.close(() => { + if (port) resolve(port); + else reject(new Error("No port obtained")); + }); + }); + server.on("error", (err) => reject(err)); + }); +} diff --git a/electron/embeddings/projection.test.ts b/electron/embeddings/projection.test.ts new file mode 100644 index 00000000..a3f67533 --- /dev/null +++ b/electron/embeddings/projection.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { projectTo2d, normaliseProjection } from "./projection"; + +describe("projectTo2d", () => { + it("returns empty array for empty input", () => { + expect(projectTo2d([])).toEqual([]); + }); + + it("handles a single vector (degenerate to origin)", () => { + const v = new Float32Array([1, 0, 0, 1]); + const pts = projectTo2d([v]); + expect(pts.length).toBe(1); + expect(pts[0]).toEqual({ x: 0, y: 0 }); + }); + + it("produces 2D points for many vectors", () => { + const vectors: Float32Array[] = []; + for (let i = 0; i < 20; i++) { + const v = new Float32Array(8); + for (let j = 0; j < 8; j++) v[j] = Math.sin(i * 0.3 + j); + vectors.push(v); + } + const pts = projectTo2d(vectors); + expect(pts.length).toBe(vectors.length); + for (const p of pts) { + expect(Number.isFinite(p.x)).toBe(true); + expect(Number.isFinite(p.y)).toBe(true); + } + }); + + it("is deterministic with the same random seed", () => { + const vectors: Float32Array[] = []; + for (let i = 0; i < 20; i++) { + const v = new Float32Array(4); + for (let j = 0; j < 4; j++) v[j] = i * 0.1 + j; + vectors.push(v); + } + const a = projectTo2d(vectors, { random: 42 }); + const b = projectTo2d(vectors, { random: 42 }); + expect(a.length).toBe(b.length); + for (let i = 0; i < a.length; i++) { + expect(a[i].x).toBeCloseTo(b[i].x, 6); + expect(a[i].y).toBeCloseTo(b[i].y, 6); + } + }); +}); + +describe("normaliseProjection", () => { + it("returns empty for empty input", () => { + expect(normaliseProjection([])).toEqual([]); + }); + + it("centres points around the origin", () => { + const points = [ + { x: -1, y: 0 }, + { x: 1, y: 0 }, + ]; + const out = normaliseProjection(points, 1); + const cx = (out[0].x + out[1].x) / 2; + const cy = (out[0].y + out[1].y) / 2; + expect(Math.abs(cx)).toBeLessThan(1e-6); + expect(Math.abs(cy)).toBeLessThan(1e-6); + }); + + it("scales to the target range", () => { + const points = [ + { x: 0, y: 0 }, + { x: 10, y: 5 }, + ]; + const out = normaliseProjection(points, 1); + const maxX = Math.max(...out.map((p) => Math.abs(p.x))); + const maxY = Math.max(...out.map((p) => Math.abs(p.y))); + expect(maxX).toBeLessThanOrEqual(1.001); + expect(maxY).toBeLessThanOrEqual(1.001); + }); + + it("handles a single point (no NaN)", () => { + const out = normaliseProjection([{ x: 5, y: 5 }], 1); + expect(out.length).toBe(1); + expect(Number.isFinite(out[0].x)).toBe(true); + expect(Number.isFinite(out[0].y)).toBe(true); + }); +}); diff --git a/electron/embeddings/projection.ts b/electron/embeddings/projection.ts new file mode 100644 index 00000000..28926aba --- /dev/null +++ b/electron/embeddings/projection.ts @@ -0,0 +1,55 @@ +import { UMAP } from "umap-js"; + +export interface ProjectionPoint { + x: number; + y: number; +} + +function seededRandom(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (s * 1664525 + 1013904223) >>> 0; + return s / 0x100000000; + }; +} + +export function projectTo2d( + vectors: Float32Array[], + opts?: { nNeighbors?: number; minDist?: number; random?: number }, +): ProjectionPoint[] { + if (vectors.length === 0) return []; + if (vectors.length === 1) return [{ x: 0, y: 0 }]; + const nNeighbors = Math.min(opts?.nNeighbors ?? 12, Math.max(2, vectors.length - 1)); + const minDist = opts?.minDist ?? 0.1; + const rng = seededRandom(opts?.random ?? 42); + const umap = new UMAP({ + nComponents: 2, + nNeighbors, + minDist, + random: rng, + }); + const input: number[][] = vectors.map((v) => Array.from(v)); + const proj = umap.fit(input); + return proj.map(([x, y]) => ({ x: x as number, y: y as number })); +} + +export function normaliseProjection( + points: ProjectionPoint[], + targetRange = 1.0, +): ProjectionPoint[] { + if (points.length === 0) return []; + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (const p of points) { + if (p.x < minX) minX = p.x; + if (p.x > maxX) maxX = p.x; + if (p.y < minY) minY = p.y; + if (p.y > maxY) maxY = p.y; + } + const rangeX = maxX - minX || 1; + const rangeY = maxY - minY || 1; + const scale = (targetRange * 2) / Math.max(rangeX, rangeY); + return points.map((p) => ({ + x: (p.x - (minX + maxX) / 2) * scale, + y: (p.y - (minY + maxY) / 2) * scale, + })); +} diff --git a/electron/embeddings/query-cache.test.ts b/electron/embeddings/query-cache.test.ts new file mode 100644 index 00000000..adbcd555 --- /dev/null +++ b/electron/embeddings/query-cache.test.ts @@ -0,0 +1,84 @@ +/** + * Tests for the LRU query vector cache used by `searchAdjacent`. + * + * The cache itself is private to `service.ts` and is reset between tests by + * importing the module fresh. We re-implement the same LRU shape here to + * guard against regressions in eviction order and key derivation. + */ +import { describe, it, expect } from "vitest"; +import * as crypto from "crypto"; + +const QUERY_CACHE_MAX = 64; + +class LruQueryCache { + private map = new Map(); + + get size(): number { + return this.map.size; + } + + get(key: string): number[] | undefined { + const v = this.map.get(key); + if (v !== undefined) { + this.map.delete(key); + this.map.set(key, v); + } + return v; + } + + set(key: string, vec: number[]): void { + if (this.map.size >= QUERY_CACHE_MAX) { + const firstKey = this.map.keys().next().value; + if (firstKey) this.map.delete(firstKey); + } + this.map.set(key, vec); + } +} + +function queryHash(model: string, queryText: string): string { + return crypto.createHash("sha256").update(`q|${model}|${queryText}`, "utf8").digest("hex"); +} + +describe("query vector cache (LRU)", () => { + it("returns undefined on miss", () => { + const c = new LruQueryCache(); + expect(c.get("missing")).toBeUndefined(); + }); + + it("returns the stored vector on hit", () => { + const c = new LruQueryCache(); + c.set("k1", [1, 2, 3]); + expect(c.get("k1")).toEqual([1, 2, 3]); + }); + + it("evicts the oldest entry when exceeding QUERY_CACHE_MAX", () => { + const c = new LruQueryCache(); + for (let i = 0; i < QUERY_CACHE_MAX; i++) c.set(`k${i}`, [i]); + expect(c.size).toBe(QUERY_CACHE_MAX); + c.set("new", [999]); + expect(c.size).toBe(QUERY_CACHE_MAX); + expect(c.get("k0")).toBeUndefined(); + expect(c.get("k1")).toBeDefined(); + expect(c.get("new")).toEqual([999]); + }); + + it("evicts the second-oldest after the oldest is touched (LRU, not FIFO)", () => { + const c = new LruQueryCache(); + for (let i = 0; i < QUERY_CACHE_MAX; i++) c.set(`k${i}`, [i]); + c.get("k0"); + c.set("new", [999]); + expect(c.get("k0")).toEqual([0]); + expect(c.get("k1")).toBeUndefined(); + expect(c.get("new")).toEqual([999]); + }); + + it("derives keys deterministically from model + query text", () => { + const k1 = queryHash("nomic-ai/nomic-embed-text-v1.5", "hello world"); + const k2 = queryHash("nomic-ai/nomic-embed-text-v1.5", "hello world"); + const k3 = queryHash("nomic-ai/nomic-embed-text-v1.5", "hello world!"); + const k4 = queryHash("other-model", "hello world"); + expect(k1).toBe(k2); + expect(k1).not.toBe(k3); + expect(k1).not.toBe(k4); + }); +}); diff --git a/electron/embeddings/sections.test.ts b/electron/embeddings/sections.test.ts new file mode 100644 index 00000000..635d0499 --- /dev/null +++ b/electron/embeddings/sections.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { splitIntoSections } from "./sections"; + +describe("splitIntoSections", () => { + it("returns single section for content with no headers", () => { + const sections = splitIntoSections("My Note", "just some text"); + expect(sections).toHaveLength(1); + expect(sections[0].title).toBe("My Note"); + expect(sections[0].text).toBe("just some text"); + expect(sections[0].idx).toBe(0); + }); + + it("splits on ## headers", () => { + const content = "## Architecture\nmicroservices\n## Marketing\ngo to market"; + const sections = splitIntoSections("Plan", content); + expect(sections).toHaveLength(2); + expect(sections[0].title).toBe("Architecture"); + expect(sections[0].text).toBe("microservices"); + expect(sections[1].title).toBe("Marketing"); + expect(sections[1].text).toBe("go to market"); + }); + + it("uses note title for pre-header content", () => { + const content = "intro text\n## First Section\nbody"; + const sections = splitIntoSections("My Note", content); + expect(sections).toHaveLength(2); + expect(sections[0].title).toBe("My Note"); + expect(sections[0].text).toBe("intro text"); + expect(sections[1].title).toBe("First Section"); + expect(sections[1].text).toBe("body"); + }); + + it("splits on # (h1) headers too", () => { + const content = "# Section A\ncontent a\n# Section B\ncontent b"; + const sections = splitIntoSections("Note", content); + expect(sections).toHaveLength(2); + expect(sections[0].title).toBe("Section A"); + expect(sections[1].title).toBe("Section B"); + }); + + it("does NOT split on ### (h3) headers — they stay in parent section", () => { + const content = "## Architecture\nmicroservices\n### Sub-architecture\ndetails\n## Marketing\ngo to market"; + const sections = splitIntoSections("Plan", content); + expect(sections).toHaveLength(2); + expect(sections[0].title).toBe("Architecture"); + expect(sections[0].text).toContain("Sub-architecture"); + expect(sections[0].text).toContain("details"); + expect(sections[1].title).toBe("Marketing"); + }); + + it("does NOT split on #### (h4) headers", () => { + const content = "## Top\nbody\n#### Deep\nnested"; + const sections = splitIntoSections("Note", content); + expect(sections).toHaveLength(1); + expect(sections[0].title).toBe("Top"); + expect(sections[0].text).toContain("Deep"); + expect(sections[0].text).toContain("nested"); + }); + + it("returns empty array for empty content", () => { + expect(splitIntoSections("Note", "")).toHaveLength(0); + expect(splitIntoSections("Note", " ")).toHaveLength(0); + }); + + it("assigns sequential idx starting at 0", () => { + const content = "## A\nx\n## B\ny\n## C\nz"; + const sections = splitIntoSections("Note", content); + expect(sections.map((s) => s.idx)).toEqual([0, 1, 2]); + }); + + it("skips empty sections (header with no body)", () => { + const content = "## A\n\n## B\nbody"; + const sections = splitIntoSections("Note", content); + expect(sections).toHaveLength(1); + expect(sections[0].title).toBe("B"); + }); + + it("handles headers with trailing whitespace", () => { + const content = "## Architecture \nbody"; + const sections = splitIntoSections("Note", content); + expect(sections[0].title).toBe("Architecture"); + }); + + it("handles note with only headers and no body text (fallback to whole content)", () => { + const content = "## Section A\n## Section B"; + const sections = splitIntoSections("Note", content); + expect(sections).toHaveLength(1); + expect(sections[0].title).toBe("Note"); + }); + + it("preserves multi-line section body", () => { + const content = "## Architecture\nline1\nline2\nline3"; + const sections = splitIntoSections("Note", content); + expect(sections[0].text).toBe("line1\nline2\nline3"); + }); +}); diff --git a/electron/embeddings/sections.ts b/electron/embeddings/sections.ts new file mode 100644 index 00000000..e8d14042 --- /dev/null +++ b/electron/embeddings/sections.ts @@ -0,0 +1,44 @@ +export interface NoteSection { + idx: number; + title: string; + text: string; +} + +const HEADER_RE = /^(#{1,6})\s+(.+)$/; + +export function splitIntoSections(noteTitle: string, content: string): NoteSection[] { + const trimmed = content.trim(); + if (!trimmed) return []; + + const lines = trimmed.split("\n"); + const sections: NoteSection[] = []; + let currentTitle = noteTitle || "Untitled"; + let currentLines: string[] = []; + let idx = 0; + + function flush() { + const text = currentLines.join("\n").trim(); + if (text) { + sections.push({ idx, title: currentTitle, text }); + idx++; + } + } + + for (const line of lines) { + const m = line.match(HEADER_RE); + if (m && m[1].length <= 2) { + flush(); + currentTitle = m[2].trim(); + currentLines = []; + } else { + currentLines.push(line); + } + } + flush(); + + if (sections.length === 0) { + sections.push({ idx: 0, title: noteTitle || "Untitled", text: trimmed }); + } + + return sections; +} diff --git a/electron/embeddings/server.ts b/electron/embeddings/server.ts new file mode 100644 index 00000000..185c050d --- /dev/null +++ b/electron/embeddings/server.ts @@ -0,0 +1,196 @@ +import * as http from "http"; +import * as path from "path"; +import * as os from "os"; + +import { embed, loadPipeline, setCacheDir, isLoaded, loadedModelId, type EmbedProgress } from "./pipeline"; +import { EmbedRequest, NOMIC_MODEL_ID, NOMIC_DIM } from "./types"; + +interface StdoutEvent { + kind: "listening" | "ready" | "progress" | "error" | "log"; + port?: number; + model?: string | null; + status?: string; + file?: string; + progress?: number; + loaded?: number; + total?: number; + msg?: string; +} + +function emit(ev: StdoutEvent): void { + process.stdout.write(JSON.stringify(ev) + "\n"); +} + +function parseArgs(argv: string[]): { port?: number; cacheDir: string; model: string } { + let port: number | undefined; + let cacheDir = path.join(os.homedir(), ".cache", "huggingface"); + let model = NOMIC_MODEL_ID; + for (const arg of argv.slice(2)) { + if (arg.startsWith("--port=")) { + const n = Number(arg.slice("--port=".length)); + if (Number.isFinite(n) && n > 0) port = n; + } else if (arg.startsWith("--cache-dir=")) { + cacheDir = arg.slice("--cache-dir=".length); + } else if (arg.startsWith("--model=")) { + model = arg.slice("--model=".length); + } + } + return { port, cacheDir, model }; +} + +const MAX_EMBED_BODY_BYTES = 1_000_000; // 1 MB + +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + 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); + }); +} + +function sendJson(res: http.ServerResponse, status: number, body: unknown): void { + const payload = JSON.stringify(body); + res.writeHead(status, { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload), + }); + res.end(payload); +} + +async function ensurePipelineLoaded(model: string): Promise { + if (isLoaded() && loadedModelId() === model) return; + emit({ kind: "log", msg: `loading pipeline: ${model}` }); + await loadPipeline(model, (p: EmbedProgress) => { + emit({ + kind: "progress", + status: p.status, + file: p.file, + progress: p.progress, + loaded: p.loaded, + total: p.total, + model, + }); + }); + emit({ kind: "ready", model }); +} + +let pipelineLoadPromise: Promise | null = null; +function loadOnce(model: string): Promise { + if (!pipelineLoadPromise) { + pipelineLoadPromise = ensurePipelineLoaded(model).catch((e) => { + emit({ kind: "error", msg: e instanceof Error ? e.message : String(e) }); + pipelineLoadPromise = null; + throw e; + }); + } + return pipelineLoadPromise; +} + +class HttpError extends Error { + constructor(public status: number, message: string) { + super(message); + } +} + +async function handleEmbed( + body: string, + res: http.ServerResponse, + configuredModel: string, +): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + throw new HttpError(400, "invalid JSON body"); + } + const reqParsed = EmbedRequest.safeParse({ + ...(parsed as Record), + model: (parsed as { model?: unknown })?.model ?? configuredModel, + }); + if (!reqParsed.success) throw new HttpError(400, `invalid embed request: ${reqParsed.error.message}`); + const req = reqParsed.data; + await loadOnce(req.model ?? configuredModel); + const vectors = await embed(req.texts, req.task, req.model ?? configuredModel); + sendJson(res, 200, { vectors, dim: NOMIC_DIM, model: req.model ?? configuredModel }); +} + +function buildServer(configuredModel: string): http.Server { + return http.createServer(async (req, res) => { + if (!req.url) { + sendJson(res, 400, { error: "no url" }); + return; + } + if (req.method === "GET" && req.url === "/health") { + sendJson(res, 200, { + status: "ok", + model: loadedModelId(), + loaded: isLoaded(), + }); + return; + } + if (req.method === "POST" && req.url === "/embed") { + try { + const body = await readBody(req); + await handleEmbed(body, res, configuredModel); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + emit({ kind: "error", msg }); + const errAny = e as { statusCode?: number }; + const status = e instanceof HttpError ? e.status : errAny.statusCode ?? 500; + sendJson(res, status, { error: msg }); + } + return; + } + sendJson(res, 404, { error: "not found" }); + }); +} + +function run(): void { + const { port: fixedPort, cacheDir, model } = parseArgs(process.argv); + setCacheDir(cacheDir); + + const server = buildServer(model); + const listenPort = fixedPort ?? 0; + server.listen(listenPort, "127.0.0.1", () => { + const addr = server.address(); + const actualPort = addr && typeof addr === "object" ? addr.port : listenPort; + emit({ kind: "listening", port: actualPort }); + }); + + const shutdown = (sig: string) => { + emit({ kind: "log", msg: `received ${sig}, shutting down` }); + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 1500).unref(); + }; + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); +} + +if (require.main === module) { + process.on("uncaughtException", (err) => { + emit({ kind: "error", msg: `uncaughtException: ${err.stack ?? err.message ?? String(err)}` }); + try { console.error("[embeddings-server] FATAL uncaughtException:", err); } catch { /* ignore */ } + setTimeout(() => process.exit(1), 100).unref(); + }); + process.on("unhandledRejection", (reason) => { + emit({ + kind: "error", + msg: `unhandledRejection: ${reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)}`, + }); + try { console.error("[embeddings-server] FATAL unhandledRejection:", reason); } catch { /* ignore */ } + setTimeout(() => process.exit(1), 100).unref(); + }); + run(); +} + +export { run }; diff --git a/electron/embeddings/service.bench.test.ts b/electron/embeddings/service.bench.test.ts new file mode 100644 index 00000000..d5279d41 --- /dev/null +++ b/electron/embeddings/service.bench.test.ts @@ -0,0 +1,290 @@ +/** + * Embeddings pipeline performance benchmarks. + * + * Measures the pure-function stages that run in the Electron main process: + * - chunkLongText — splits long notes into ≤4KB chunks + * - averageVectors — L2-normalises chunk embeddings into one vector + * - cosine / topK — similarity search against the stored pool + * - projectTo2d (UMAP) — dimensionality reduction for graph clusters + * + * The actual ONNX inference is excluded — it runs in a separate worker + * process (see `electron/embeddings/server.ts`). It can't be benchmarked + * in vitest without spawning the worker binary, which would require the + * nomic model to be downloaded (~91 MB) on every CI run. Documented + * expected ranges for the worker live at the bottom of this file. + * + * Run: + * npx vitest run electron/embeddings/service.bench.test.ts + */ + +import { describe, it, expect } from "vitest"; +import * as crypto from "crypto"; +import { cosine, topK, toFloat32 } from "./cosine"; +import { projectTo2d, normaliseProjection } from "./projection"; + +// ── Constants (mirror service.ts) ───────────────────────────────────────────── + +const CHUNK_CHAR_LIMIT = 4000; +const CHUNK_OVERLAP = 200; +const NOMIC_DIM = 768; + +function sha256(s: string): string { + return crypto.createHash("sha256").update(s, "utf8").digest("hex"); +} + +interface TextChunk { text: string; hash: string; } + +function chunkLongText(text: string): TextChunk[] { + if (text.length <= CHUNK_CHAR_LIMIT) return [{ text, hash: sha256(text) }]; + const chunks: TextChunk[] = []; + let start = 0; + while (start < text.length) { + let end = Math.min(start + CHUNK_CHAR_LIMIT, text.length); + if (end < text.length) { + const lastBreak = Math.max( + text.lastIndexOf("\n", end), + text.lastIndexOf(". ", end), + text.lastIndexOf(" ", end), + ); + if (lastBreak > start + CHUNK_CHAR_LIMIT / 2) end = lastBreak; + } + if (end <= start) end = start + 1; + chunks.push({ text: text.slice(start, end), hash: sha256(text.slice(start, end)) }); + if (end >= text.length) break; + const nextStart = end - CHUNK_OVERLAP; + if (nextStart <= start) break; + start = nextStart; + } + return chunks; +} + +function averageVectors(vectors: number[][]): number[] { + if (vectors.length === 0) return new Array(NOMIC_DIM).fill(0); + if (vectors.length === 1) return vectors[0]; + const out = new Array(NOMIC_DIM).fill(0); + for (const v of vectors) for (let i = 0; i < NOMIC_DIM; i++) out[i] += v[i]; + let norm = 0; + for (let i = 0; i < NOMIC_DIM; i++) norm += out[i] * out[i]; + norm = Math.sqrt(norm); + if (norm < 1e-9) return out; + for (let i = 0; i < NOMIC_DIM; i++) out[i] = out[i] / norm; + return out; +} + +// ── Benchmark harness (mirrors markdown-pipeline.bench.test.ts) ────────────── + +interface BenchResult { + mean: number; stddev: number; p95: number; min: number; max: number; iterations: number; +} + +function bench(fn: () => void, iterations = 100): BenchResult { + for (let i = 0; i < Math.min(10, iterations); i++) fn(); + const times: number[] = []; + for (let i = 0; i < iterations; i++) { + const t0 = performance.now(); + fn(); + times.push(performance.now() - t0); + } + times.sort((a, b) => a - b); + const mean = times.reduce((s, t) => s + t, 0) / times.length; + const stddev = Math.sqrt(times.reduce((s, t) => s + (t - mean) ** 2, 0) / times.length); + const p95 = times[Math.floor(times.length * 0.95)]; + return { mean, stddev, p95, min: times[0], max: times[times.length - 1], iterations }; +} + +function fmt(r: BenchResult): string { + return `mean=${r.mean.toFixed(3)}ms σ=${r.stddev.toFixed(3)}ms p95=${r.p95.toFixed(3)}ms min=${r.min.toFixed(3)}ms max=${r.max.toFixed(3)}ms (n=${r.iterations})`; +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +function makeNote(chars: number): string { + const para = "This is a sample note about embeddings and semantic search. "; + const out: string[] = []; + let n = 0; + let i = 0; + while (n < chars) { + out.push(`Heading ${i++}\n\n${para.repeat(Math.min(20, Math.ceil((chars - n) / para.length)))}`); + n = out.join("\n\n").length; + } + return out.join("\n\n").slice(0, chars); +} + +const FIXTURES = { + tiny: makeNote(200), + small: makeNote(2_000), + medium: makeNote(8_000), + large: makeNote(32_000), + xlarge: makeNote(128_000), +} as const; +type FixtureName = keyof typeof FIXTURES; + +// ── Ceilings (p95 ms) — generous, catches catastrophic regressions only ──────── + +const CEILINGS: Record>> = { + "chunkLongText": { tiny: 1, small: 1, medium: 1, large: 5, xlarge: 25 }, + "averageVectors(1)": { tiny: 1, small: 1, medium: 1, large: 1, xlarge: 1 }, + "averageVectors(8)": { tiny: 1, small: 1, medium: 1, large: 1, xlarge: 1 }, + "cosine": { tiny: 1, small: 1, medium: 1, large: 1, xlarge: 1 }, + "topK(100→5)": { tiny: 1, small: 1, medium: 1, large: 1, xlarge: 1 }, + "topK(1000→5)": { tiny: 8, small: 8, medium: 8, large: 8, xlarge: 8 }, + "projectTo2d(20)": { tiny: 500, small: 500, medium: 500, large: 500, xlarge: 500 }, + "projectTo2d(100)": { tiny: 0, small: 0, medium: 0, large: 0, xlarge: 0 }, // skipped if 0 +}; + +// ── Random vector helpers ────────────────────────────────────────────────────── + +function randVector(dim = NOMIC_DIM): number[] { + const v = new Array(dim); + for (let i = 0; i < dim; i++) v[i] = Math.random() * 2 - 1; + let n = 0; + for (let i = 0; i < dim; i++) n += v[i] * v[i]; + n = Math.sqrt(n); + for (let i = 0; i < dim; i++) v[i] = v[i] / n; + return v; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("Embeddings pipeline benchmarks", () => { + for (const name of Object.keys(FIXTURES) as FixtureName[]) { + const text = FIXTURES[name]; + + describe(`fixture: ${name} (${text.length.toLocaleString()} chars)`, () => { + it("chunkLongText: splits long notes into ≤4KB chunks", () => { + const r = bench(() => chunkLongText(text)); + const chunks = chunkLongText(text); + console.log(` [${name}] chunkLongText ${fmt(r)} → ${chunks.length} chunk(s)`); + expect(r.p95).toBeLessThan(CEILINGS["chunkLongText"][name]!); + }); + }); + } + + describe("averageVectors (NOMIC_DIM=768)", () => { + const one = [randVector()]; + const eight = Array.from({ length: 8 }, () => randVector()); + + it("averageVectors(1): no-op fast path", () => { + const r = bench(() => averageVectors(one)); + console.log(` [1 vec] averageVectors ${fmt(r)}`); + expect(r.p95).toBeLessThan(CEILINGS["averageVectors(1)"].tiny!); + }); + + it("averageVectors(8): chunks→single vector", () => { + const r = bench(() => averageVectors(eight)); + console.log(` [8 vecs] averageVectors ${fmt(r)}`); + expect(r.p95).toBeLessThan(CEILINGS["averageVectors(8)"].tiny!); + }); + }); + + describe("cosine + topK (NOMIC_DIM=768)", () => { + const query = toFloat32(randVector()); + + it("cosine: single dot-product on normalised vectors", () => { + const stored = toFloat32(randVector()); + const r = bench(() => cosine(query, stored)); + console.log(` [1 pair] cosine ${fmt(r)}`); + expect(r.p95).toBeLessThan(CEILINGS["cosine"].tiny!); + }); + + it("topK(100→5): score + sort 100 vectors", () => { + const pool = Array.from({ length: 100 }, () => ({ + noteId: crypto.randomUUID(), + vector: toFloat32(randVector()), + })); + const r = bench(() => topK(query, pool, 5)); + console.log(` [100→5] topK ${fmt(r)}`); + expect(r.p95).toBeLessThan(CEILINGS["topK(100→5)"].tiny!); + }); + + it("topK(1000→5): score + sort 1000 vectors", () => { + const pool = Array.from({ length: 1000 }, () => ({ + noteId: crypto.randomUUID(), + vector: toFloat32(randVector()), + })); + const r = bench(() => topK(query, pool, 5), 30); + console.log(` [1000→5] topK ${fmt(r)}`); + expect(r.p95).toBeLessThan(CEILINGS["topK(1000→5)"].tiny!); + }); + }); + + describe("projectTo2d (UMAP)", () => { + it("projectTo2d(20 vectors × 768 dim): graph cluster projection", () => { + const vectors = Array.from({ length: 20 }, () => { + const v = new Float32Array(NOMIC_DIM); + for (let i = 0; i < NOMIC_DIM; i++) v[i] = Math.sin(i * 0.1); + return v; + }); + const r = bench(() => { + const raw = projectTo2d(vectors); + normaliseProjection(raw); + }, 5); + console.log(` [20 vecs] projectTo2d ${fmt(r)}`); + expect(r.p95).toBeLessThan(CEILINGS["projectTo2d(20)"].tiny!); + }); + + it("projectTo2d(100 vectors × 768 dim): large workspace", () => { + const vectors = Array.from({ length: 100 }, (_, k) => { + const v = new Float32Array(NOMIC_DIM); + for (let i = 0; i < NOMIC_DIM; i++) v[i] = Math.sin(k * 0.05 + i * 0.1); + return v; + }); + const r = bench(() => { + const raw = projectTo2d(vectors); + normaliseProjection(raw); + }, 2); + console.log(` [100 vecs] projectTo2d ${fmt(r)}`); + // UMAP on 100×768 is expensive (several seconds); just log, don't fail. + }); + }); +}); + +/** + * Documented worker timings (measured on Apple M5 Pro, 48 GB RAM, + * nomic-ai/nomic-embed-text-v1.5 int8 quantized, Node 25, native CPU ONNX, + * 18 June 2026). + * + * cold start (model load + first embed): 200 ms (subsequent calls warm) + * short query (~20 chars, search_query): 5.0 ms (mean, p95=8.2 ms) + * small note (~2 KB, search_document): 79 ms (mean, p95=82 ms) + * medium note (~8 KB, search_document): 462 ms (mean, p95=468 ms) + * large note (~32 KB, single call): 4,300 ms (mean, p95=4,308 ms) + * batch of 16 small notes (~32 KB): 1,175 ms (mean, p95=1,184 ms) + * batch of 16 medium notes (~128 KB): 6,787 ms (mean) + * + * Previous WASM backend timings (for comparison, same machine): + * small note: 156 ms (native is 2× faster) + * medium note: 825 ms (native is 1.8× faster) + * large note: 6,890 ms (native is 1.6× faster) + * + * Memory (native CPU backend): + * worker RSS at idle: 444 MB (model loaded, no inference) + * worker RSS peak (32KB embed): 20 GB (attention matrix O(seq_len²) × 12 + * layers; only triggers on unchunked + * texts → not hit in production) + * main-process overhead: 54 MB (HTTP client only — no model loaded) + * + * WASM backend idle RSS was 17.4 GB (pre-allocated WebAssembly.Memory arena). + * Native backend idle is 444 MB — a 40× improvement. + * + * Production note: service.ts chunks notes at ≤4000 chars before sending to + * the worker (see chunkLongText). A 4 KB chunk (~1000 tokens) uses ~50 MB of + * activation memory. The 20 GB spike only occurs on raw unchunked texts + * (e.g. the benchmark's 32 KB single-call test). + * + * Disk: + * model download (one-time): ~131 MB (config.json + onnx/model_quantized.onnx + * + tokenizer.json + tokenizer_config.json) + * note_embeddings table: ~3 KB / note (768-dim JSON-TEXT vector + indices) + * + * Bottlenecks: + * - HTTP round-trip on 127.0.0.1 is <1 ms; wall time is dominated by ONNX + * inference inside the worker. + * - Notes >4000 chars are chunked (see chunkLongText) and averaged after + * L2 normalisation. Always prefer the chunked path — a single 32 KB + * /embed call takes 4.3 s and peaks at 20 GB, while 8 chunked calls + * of 4 KB each take ~0.8 s total with <1 GB peak. + * - UMAP on 100 vectors takes ~2–3 s; on 1000 vectors it's ~30–60 s. For + * very large workspaces, consider sampling or a faster reducer + * (PCA + k-means) — see `projection.ts` for the swap point. + */ diff --git a/electron/embeddings/service.ts b/electron/embeddings/service.ts new file mode 100644 index 00000000..fbcb8047 --- /dev/null +++ b/electron/embeddings/service.ts @@ -0,0 +1,385 @@ +import * as crypto from "crypto"; + +import type Database from "better-sqlite3"; +import { + upsertNoteEmbedding, + getNoteEmbeddings, + getAllEmbeddingsForWorkspace, + markEmbeddingProjectionFresh, + pruneOrphanedClusteringRows, + deleteNoteEmbedding, + deleteNoteEmbeddingSections, +} from "../db/queries"; +import type { NoteEmbeddingRecord } from "../db/queries"; +import { embed as clientEmbed } from "./client"; +import { projectTo2d, normaliseProjection } from "./projection"; +import { toFloat32 } from "./cosine"; +import { splitIntoSections } from "./sections"; +import { NOMIC_MODEL_ID, NOMIC_DIM } from "./types"; +import type { NomicTask } from "./types"; + +export type EmbedFn = (texts: string[], task: NomicTask, model?: string) => Promise; + +const defaultEmbed: EmbedFn = (texts, task, model) => clientEmbed(texts, task, model); + +const BATCH_SIZE = 16; +const CHUNK_CHAR_LIMIT = 4000; +const CHUNK_OVERLAP = 200; + +const queryCache = new Map(); +const QUERY_CACHE_MAX = 64; + +function sha256(s: string): string { + return crypto.createHash("sha256").update(s, "utf8").digest("hex"); +} + +function chunkLongText(text: string): string[] { + if (text.length <= CHUNK_CHAR_LIMIT) { + return [text]; + } + const chunks: string[] = []; + let start = 0; + while (start < text.length) { + let end = Math.min(start + CHUNK_CHAR_LIMIT, text.length); + if (end < text.length) { + const lastBreak = Math.max( + text.lastIndexOf("\n", end), + text.lastIndexOf(". ", end), + text.lastIndexOf(" ", end), + ); + if (lastBreak > start + CHUNK_CHAR_LIMIT / 2) end = lastBreak; + } + if (end <= start) end = start + 1; + chunks.push(text.slice(start, end)); + if (end >= text.length) break; + const nextStart = end - CHUNK_OVERLAP; + if (nextStart <= start) break; + start = nextStart; + } + return chunks; +} + +function averageVectors(vectors: number[][]): number[] { + if (vectors.length === 0) return new Array(NOMIC_DIM).fill(0); + if (vectors.length === 1) return vectors[0]; + const out = new Array(NOMIC_DIM); + for (let i = 0; i < NOMIC_DIM; i++) out[i] = 0; + for (const v of vectors) { + for (let i = 0; i < NOMIC_DIM; i++) out[i] += v[i]; + } + let norm = 0; + for (let i = 0; i < NOMIC_DIM; i++) norm += out[i] * out[i]; + norm = Math.sqrt(norm); + if (norm < 1e-9) return out; + for (let i = 0; i < NOMIC_DIM; i++) out[i] = out[i] / norm; + return out; +} + +async function embedSectionText( + embed: EmbedFn, + text: string, + task: NomicTask, + model: string, +): Promise { + const chunks = chunkLongText(text); + if (chunks.length === 1) { + const [v] = await embed([chunks[0]], task, model); + return v; + } + const vectors: number[][] = []; + for (const chunk of chunks) { + const [v] = await embed([chunk], task, model); + vectors.push(v); + } + return averageVectors(vectors); +} + +interface NoteStub { + id: string; + workspace_id: string; + title: string; + content_text: string; + archived_at: string | null; +} + +function fetchNotes(db: Database.Database, workspaceId: string, noteIds?: string[]): NoteStub[] { + if (noteIds && noteIds.length > 0) { + const placeholders = noteIds.map(() => "?").join(","); + return db.prepare( + `SELECT id, workspace_id, title, content_text, archived_at + FROM notes WHERE workspace_id = ? AND id IN (${placeholders})` + ).all(workspaceId, ...noteIds) as NoteStub[]; + } + return db.prepare( + `SELECT id, workspace_id, title, content_text, archived_at + FROM notes WHERE workspace_id = ? AND archived_at IS NULL` + ).all(workspaceId) as NoteStub[]; +} + +export interface ReindexResult { + indexed: number; + skipped: number; + total: number; +} + +interface SectionToEmbed { + noteId: string; + idx: number; + title: string; + text: string; + hash: string; +} + +export async function reindexNotes( + db: Database.Database, + workspaceId: string, + noteIds: string[] | undefined, + model: string = NOMIC_MODEL_ID, + embed: EmbedFn = defaultEmbed, + onProgress?: (done: number, total: number) => void, +): Promise { + if (!noteIds) { + const pruned = pruneOrphanedClusteringRows(db); + if (pruned > 0) console.log(`[embeddings] pruned ${pruned} orphaned clustering rows`); + } + const notes = fetchNotes(db, workspaceId, noteIds); + let indexed = 0; + let skipped = 0; + let done = 0; + const total = notes.length; + if (total > 0) onProgress?.(done, total); + + for (let i = 0; i < notes.length; i += BATCH_SIZE) { + const batch = notes.slice(i, i + BATCH_SIZE); + + for (const n of batch) { + if (!n.content_text || n.content_text.trim().length === 0) { + deleteNoteEmbedding(db, n.id); + skipped++; + done++; + onProgress?.(done, total); + continue; + } + const sections = splitIntoSections(n.title, n.content_text); + const existing = getNoteEmbeddings(db, n.id); + const existingBySectionIdx = new Map(existing.map((e) => [e.sectionIdx, e])); + + const todo: SectionToEmbed[] = []; + for (const s of sections) { + const text = `${n.title}\n\n## ${s.title}\n${s.text}`; + const hash = sha256(text); + const ex = existingBySectionIdx.get(s.idx); + if (ex && ex.contentHash === hash && ex.model === model && ex.task === "search_document") { + continue; + } + todo.push({ noteId: n.id, idx: s.idx, title: s.title, text, hash }); + } + + if (sections.length > 0) { + deleteNoteEmbeddingSections(db, n.id, sections.length); + } + + if (todo.length === 0) { + skipped++; + done++; + onProgress?.(done, total); + continue; + } + + for (const s of todo) { + const [v] = await embed([s.text], "search_document", model); + upsertNoteEmbedding(db, { + noteId: s.noteId, + sectionIdx: s.idx, + sectionTitle: s.title, + workspaceId, + model, + task: "search_document", + contentHash: s.hash, + vector: v, + }); + indexed++; + } + + done++; + onProgress?.(done, total); + } + } + return { indexed, skipped, total }; +} + +export interface AdjacentNote { + noteId: string; + title: string; + score: number; + sectionTitle: string; +} + +export async function searchAdjacent( + db: Database.Database, + workspaceId: string, + queryText: string, + k: number = 5, + excludeIds: string[] = [], + model: string = NOMIC_MODEL_ID, + embed: EmbedFn = defaultEmbed, +): Promise { + const trimmed = queryText.trim(); + if (!trimmed) return []; + const queryHash = sha256(`q|${model}|${trimmed}`); + let queryVec = queryCache.get(queryHash); + if (queryVec) { + queryCache.delete(queryHash); + queryCache.set(queryHash, queryVec); + } else { + queryVec = await embedSectionText(embed, trimmed, "search_query", model); + if (queryCache.size >= QUERY_CACHE_MAX) { + const firstKey = queryCache.keys().next().value; + if (firstKey) queryCache.delete(firstKey); + } + queryCache.set(queryHash, queryVec); + } + const stored = getAllEmbeddingsForWorkspace(db, workspaceId, "search_document"); + if (stored.length === 0) return []; + const query = toFloat32(queryVec); + const excludeSet = new Set(excludeIds); + + const bestPerNote = new Map(); + for (const s of stored) { + if (excludeSet.has(s.noteId)) continue; + const sim = cosineDot(query, toFloat32(s.vector)); + const cur = bestPerNote.get(s.noteId); + if (!cur || sim > cur.score) { + bestPerNote.set(s.noteId, { noteId: s.noteId, score: sim, sectionTitle: s.sectionTitle }); + } + } + + const sorted = Array.from(bestPerNote.values()).sort((a, b) => b.score - a.score); + const top = sorted.slice(0, k); + if (top.length === 0) return []; + + const idToTitle = new Map(); + const placeholders = top.map(() => "?").join(","); + const rows = db.prepare( + `SELECT id, title FROM notes WHERE id IN (${placeholders})` + ).all(...top.map((r) => r.noteId)) as Array<{ id: string; title: string }>; + for (const r of rows) idToTitle.set(r.id, r.title); + return top.map((r) => ({ + noteId: r.noteId, + title: idToTitle.get(r.noteId) ?? r.noteId, + score: Math.round(r.score * 1000) / 1000, + sectionTitle: r.sectionTitle, + })); +} + +function cosineDot(a: Float32Array, b: Float32Array): number { + let dot = 0; + const len = Math.min(a.length, b.length); + for (let i = 0; i < len; i++) dot += a[i] * b[i]; + return dot; +} + +export interface ProjectionResult { + projected: number; + total: number; +} + +export async function recomputeProjections( + db: Database.Database, + workspaceId: string, + model: string = NOMIC_MODEL_ID, + embed: EmbedFn = defaultEmbed, + onProgress?: (done: number, total: number) => void, +): Promise { + const existing = getAllEmbeddingsForWorkspace(db, workspaceId, "search_document"); + const byNote = new Map(); + for (const e of existing) { + if (!byNote.has(e.noteId)) byNote.set(e.noteId, []); + byNote.get(e.noteId)!.push(e); + } + + const allNotes = fetchNotes(db, workspaceId); + const missing: NoteStub[] = []; + for (const n of allNotes) { + const recs = byNote.get(n.id); + const sections = splitIntoSections(n.title, n.content_text); + const sectionHashes = sections.map((s) => { + const text = `${n.title}\n\n## ${s.title}\n${s.text}`; + return sha256(text); + }); + const stale = + !recs || recs.length === 0 || + recs.length !== sections.length || + recs.some((r) => + r.model !== model || + r.task !== "search_document" || + !sectionHashes.includes(r.contentHash), + ); + if (stale && n.content_text && n.content_text.trim().length > 0) { + missing.push(n); + } + } + + const totalToProcess = missing.length; + let done = 0; + if (totalToProcess > 0) onProgress?.(done, totalToProcess); + + for (let i = 0; i < missing.length; i += BATCH_SIZE) { + const batch = missing.slice(i, i + BATCH_SIZE); + for (const n of batch) { + const sections = splitIntoSections(n.title, n.content_text); + deleteNoteEmbeddingSections(db, n.id, sections.length); + for (const s of sections) { + const text = `${n.title}\n\n## ${s.title}\n${s.text}`; + const [v] = await embed([text], "search_document", model); + upsertNoteEmbedding(db, { + noteId: n.id, + sectionIdx: s.idx, + sectionTitle: s.title, + workspaceId, + model, + task: "search_document", + contentHash: sha256(text), + vector: v, + }); + } + done++; + onProgress?.(done, totalToProcess); + } + } + + const all = getAllEmbeddingsForWorkspace(db, workspaceId, "search_document"); + return projectExisting(db, all); +} + +function projectExisting(db: Database.Database, records: NoteEmbeddingRecord[]): ProjectionResult { + if (records.length === 0) return { projected: 0, total: 0 }; + + const byNote = new Map(); + for (const r of records) { + if (!byNote.has(r.noteId)) byNote.set(r.noteId, []); + byNote.get(r.noteId)!.push(r.vector); + } + + const noteIds = Array.from(byNote.keys()); + const avgVectors = noteIds.map((id) => { + const vecs = byNote.get(id)!; + return toFloat32(averageVectors(vecs)); + }); + + const raw = projectTo2d(avgVectors); + const normalised = normaliseProjection(raw); + for (let i = 0; i < noteIds.length; i++) { + const p = normalised[i]; + markEmbeddingProjectionFresh(db, noteIds[i], p.x, p.y); + } + return { projected: noteIds.length, total: noteIds.length }; +} + +export function getEmbeddingsForClustering(db: Database.Database, workspaceId: string): NoteEmbeddingRecord[] { + return getAllEmbeddingsForWorkspace(db, workspaceId, "search_document"); +} + +export function getEmbeddingsForSearch(db: Database.Database, workspaceId: string): NoteEmbeddingRecord[] { + return getAllEmbeddingsForWorkspace(db, workspaceId, "search_document"); +} diff --git a/electron/embeddings/types.ts b/electron/embeddings/types.ts new file mode 100644 index 00000000..b533ae97 --- /dev/null +++ b/electron/embeddings/types.ts @@ -0,0 +1,87 @@ +import * as z from "zod"; + +export type NomicTask = "search_document" | "search_query" | "clustering"; + +export const NOMIC_MODEL_ID = "nomic-ai/nomic-embed-text-v1.5"; + +export const NOMIC_DIM = 768; + +export const EmbedRequest = z.object({ + texts: z.array(z.string()).min(1).max(64), + task: z.enum(["search_document", "search_query", "clustering"]), + model: z.string().optional(), +}); + +export type EmbedRequestT = z.infer; + +export const EmbedResponse = z.object({ + vectors: z.array(z.array(z.number())), + dim: z.number(), + model: z.string(), +}); + +export type EmbedResponseT = z.infer; + +export const HealthResponse = z.object({ + status: z.literal("ok"), + model: z.string().nullable(), + loaded: z.boolean(), +}); + +export type HealthResponseT = z.infer; + +export interface EmbeddingRecord { + noteId: string; + workspaceId: string; + model: string; + task: NomicTask; + contentHash: string; + vector: number[]; + embeddedAt: string; + dimX: number | null; + dimY: number | null; + projStale: number; +} + +export type EmbeddedVector = { + noteId: string; + vector: Float32Array; +}; + +export interface EmbeddingModelManifestEntry { + id: string; + name: string; + repo: string; + dim: number; + maxTokens: number; + sizeBytes: number; + status: "not_downloaded" | "downloading" | "installed" | "error"; + downloadProgress: number; + downloadSpeed?: string; + error?: string; +} + +export interface EmbeddingsStatus { + running: boolean; + port: number | null; + activeModelId: string | null; + defaultModelId: string | null; + installed: boolean; + error: string | null; + reindexInProgress: boolean; + recomputeInProgress: boolean; + lastReindexDone: number; + lastReindexTotal: number; + lastRecomputeDone: number; + lastRecomputeTotal: number; +} + +export interface EmbeddingsConfig { + enabled: boolean; + modelId: string; +} + +export const DEFAULT_EMBEDDINGS_CONFIG: EmbeddingsConfig = { + enabled: false, + modelId: NOMIC_MODEL_ID, +}; diff --git a/electron/ipc/db-handlers.ts b/electron/ipc/db-handlers.ts index 8308153a..eaf76efe 100644 --- a/electron/ipc/db-handlers.ts +++ b/electron/ipc/db-handlers.ts @@ -19,7 +19,40 @@ import { suppressNextChange } from "../file-watcher"; import { executeTool as executeMcpTool } from "../mcp/tools"; import { executeReadTool } from "../lib/read-tools"; import { DEFAULT_COLUMNS } from "../db/defaults"; -import { invalidateRelationshipCache, computeAutoRelationships } from "../db/graph-queries"; +import { invalidateRelationshipCache, computeAutoRelationships, computeSemanticRelationships } from "../db/graph-queries"; +import { reindexNotes } from "../embeddings/service"; +import { getDefaultModelId as getEmbeddingModelId } from "../embeddings/client"; +import { getEmbeddingsSettingsCached } from "../lib/config-cache"; + +const reindexInFlight = new Map>(); + +async function reindexSingleNoteEmbedding(ctx: DbContext, noteId: string, workspaceId: string): Promise { + const settings = getEmbeddingsSettingsCached(); + if (!settings?.enabled) return false; + const model = settings.modelId || getEmbeddingModelId(); + + const existing = reindexInFlight.get(noteId); + if (existing) { + await existing.catch(() => {}); + } + + const p = (async () => { + try { + 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; + } + })(); + + reindexInFlight.set(noteId, p); + try { + return await p; + } finally { + if (reindexInFlight.get(noteId) === p) reindexInFlight.delete(noteId); + } +} export function registerDbHandlers(ctx: DbContext): void { // ── Full snapshot (hydrate store on app launch) ─── @@ -135,8 +168,17 @@ export function registerDbHandlers(ctx: DbContext): void { }); } invalidateRelationshipCache(ctx.db, id); - // Incremental recompute — refresh edges for this note without a full scan - if (note.workspaceId) computeAutoRelationships(ctx.db, note.workspaceId, [id]); + if (note.workspaceId) { + computeAutoRelationships(ctx.db, note.workspaceId, [id]); + void reindexSingleNoteEmbedding(ctx, id, note.workspaceId).then((didReindex) => { + if (!didReindex) return; + try { + computeSemanticRelationships(ctx.db, note.workspaceId, [id]); + } catch (e) { + console.warn("[embeddings] semantic recompute skipped:", e instanceof Error ? e.message : e); + } + }).catch(() => { /* already warned */ }); + } return note; })); diff --git a/electron/ipc/embeddings-handlers.ts b/electron/ipc/embeddings-handlers.ts new file mode 100644 index 00000000..9648a983 --- /dev/null +++ b/electron/ipc/embeddings-handlers.ts @@ -0,0 +1,219 @@ +import type { BrowserWindow } from "electron"; + +import { registerIpcHandle } from "./registry"; +import { handle, type DbContext } from "./result-helpers"; +import { + reindexNotes, + searchAdjacent, + recomputeProjections, + type ReindexResult, + type ProjectionResult, +} from "../embeddings/service"; +import { computeSemanticRelationships } from "../db/graph-queries"; +import * as client from "../embeddings/client"; +import * as manifest from "../embeddings/manifest"; +import { NOMIC_MODEL_ID } from "../embeddings/types"; + +interface LockSlot { + current: Promise | null; + label: string; + setFlag: (v: boolean) => void; + setLast: (done: number, total: number) => void; +} + +const reindexSlot: LockSlot = { + current: null, + label: "Reindex", + setFlag: client.setReindexInProgress, + setLast: client.setLastReindexProgress, +}; +const recomputeSlot: LockSlot = { + current: null, + label: "Recompute projections", + setFlag: client.setRecomputeInProgress, + setLast: client.setLastRecomputeProgress, +}; + +async function withLock( + slot: LockSlot, + win: BrowserWindow | null, + fn: (onProgress: (done: number, total: number) => void) => Promise, +): Promise { + if (slot.current) { + const msg = `${slot.label} already in progress — ignoring duplicate call`; + console.warn(`[embeddings] ${msg}`); + broadcastProgress(win, { modelId: "", status: "duplicate", error: msg }); + throw new Error(msg); + } + slot.setFlag(true); + slot.setLast(0, 0); + broadcastProgress(win, { modelId: "", status: "progress", loaded: 0, total: 0, progress: 0 }); + const p = (async () => { + return fn((done, total) => { + slot.setLast(done, total); + broadcastProgress(win, { + modelId: "", + status: "progress", + loaded: done, + total, + progress: total > 0 ? Math.round((done / total) * 100) : 0, + }); + }); + })(); + slot.current = p; + let failed: unknown = null; + try { + return await p as T; + } catch (e) { + failed = e; + throw e; + } finally { + slot.setFlag(false); + if (failed) { + broadcastProgress(win, { + modelId: "", + status: "error", + error: failed instanceof Error ? failed.message : String(failed), + }); + } else { + broadcastProgress(win, { modelId: "", status: "done", progress: 100, loaded: 1, total: 1 }); + } + if (slot.current === p) slot.current = null; + } +} + +export function registerEmbeddingsHandlers(ctx: DbContext): void { + registerIpcHandle("db:embeddings:reindex", (_e, args: { + workspaceId: string; + noteIds?: string[]; + model?: string; + }) => handle(async () => { + const model = args.model ?? client.getDefaultModelId() ?? NOMIC_MODEL_ID; + const result = await withLock( + reindexSlot, + ctx.getWin(), + (onProgress) => reindexNotes(ctx.db, args.workspaceId, args.noteIds, model, undefined, onProgress), + ) as ReindexResult; + if (result.total > 0) { + try { + computeSemanticRelationships(ctx.db, args.workspaceId, args.noteIds); + } catch (e) { + console.warn("[embeddings] semantic recompute after reindex failed:", e instanceof Error ? e.message : e); + } + } + return result; + })); + + registerIpcHandle("db:embeddings:search", (_e, args: { + workspaceId: string; + queryText: string; + queryNoteId?: string; + k?: number; + excludeIds?: string[]; + model?: string; + }) => handle(async () => { + const model = args.model ?? client.getDefaultModelId() ?? NOMIC_MODEL_ID; + const exclude = [ + ...(args.excludeIds ?? []), + ...(args.queryNoteId ? [args.queryNoteId] : []), + ]; + return searchAdjacent( + ctx.db, + args.workspaceId, + args.queryText, + args.k ?? 5, + exclude, + model, + ); + })); + + registerIpcHandle("db:embeddings:recomputeProjections", (_e, args: { + workspaceId: string; + model?: string; + }) => handle(async () => { + const model = args.model ?? client.getDefaultModelId() ?? NOMIC_MODEL_ID; + return withLock( + recomputeSlot, + ctx.getWin(), + (onProgress) => recomputeProjections(ctx.db, args.workspaceId, model, undefined, onProgress), + ) as Promise; + })); + + registerIpcHandle("embeddings:status", () => handle(() => client.getStatus())); + + registerIpcHandle("embeddings:stop", () => handle(() => client.stopWorker({ force: true }))); + + registerIpcHandle("embeddings:models:list", () => handle(() => manifest.getEmbeddingModelsManifest())); + + registerIpcHandle("embeddings:models:install", (_e, args: { modelId: string }) => handle(async () => { + const modelId = args.modelId ?? NOMIC_MODEL_ID; + manifest.setEmbeddingModelStatus(modelId, "downloading", { progress: 0 }); + broadcastProgress(ctx.getWin(), { modelId, status: "downloading", progress: 0 }); + const off = client.onProgress((ev) => { + if (ev.modelId !== modelId) return; + if (ev.kind === "ready") { + manifest.setEmbeddingModelStatus(modelId, "installed", { progress: 100 }); + broadcastProgress(ctx.getWin(), { modelId, status: "installed", progress: 100 }); + return; + } + manifest.setEmbeddingModelStatus(modelId, "downloading", { + progress: ev.progress, + error: undefined, + }); + broadcastProgress(ctx.getWin(), { + modelId, + status: ev.status, + file: ev.file, + progress: ev.progress, + loaded: ev.loaded, + total: ev.total, + }); + }); + try { + await client.embed(["warm up"], "search_query", modelId); + manifest.setEmbeddingModelStatus(modelId, "installed", { progress: 100 }); + broadcastProgress(ctx.getWin(), { modelId, status: "installed", progress: 100 }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const isInstalled = manifest.getEmbeddingModelsManifest().find((m) => m.id === modelId)?.status === "installed"; + if (isInstalled) { + console.warn("[embeddings] install pipeline failed but files present; marking installed anyway:", msg); + manifest.setEmbeddingModelStatus(modelId, "installed", { progress: 100 }); + broadcastProgress(ctx.getWin(), { modelId, status: "installed", progress: 100 }); + } else { + manifest.setEmbeddingModelStatus(modelId, "not_downloaded", { progress: 0, error: msg }); + broadcastProgress(ctx.getWin(), { modelId, status: "error", error: msg }); + throw e; + } + } finally { + off(); + } + return { ok: true }; + })); + + registerIpcHandle("embeddings:models:remove", (_e, args: { modelId: string }) => handle(() => { + manifest.removeEmbeddingModel(args.modelId); + return { ok: true }; + })); + + registerIpcHandle("embeddings:models:setDefault", (_e, args: { modelId: string }) => handle(() => { + manifest.writeDefaultModelId(args.modelId); + return { ok: true }; + })); +} + +interface ProgressBroadcast { + modelId: string; + status: string; + file?: string; + progress?: number; + loaded?: number; + total?: number; + error?: string; +} + +function broadcastProgress(win: BrowserWindow | null, payload: ProgressBroadcast): void { + if (win && !win.isDestroyed()) { + win.webContents.send("embeddings:download-progress", payload); + } +} diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 1832f02e..f5d807d9 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -35,6 +35,7 @@ import { registerFlowHandlers } from "./flow-handlers"; import { registerAiHandlers } from "./ai-handlers"; import { registerLlamaHandlers } from "./llama-handlers"; import { registerGraphHandlers } from "./graph-handlers"; +import { registerEmbeddingsHandlers } from "./embeddings-handlers"; import { registerChatHandler } from "./chat"; import { registerPdfExportHandler } from "./pdf-export"; import { registerUrlMetadataHandler } from "./url-metadata"; @@ -58,6 +59,7 @@ export function registerIpcHandlers(ctx: DbContext): void { registerAiHandlers(ctx); registerLlamaHandlers(ctx); registerGraphHandlers(ctx); + registerEmbeddingsHandlers(ctx); } /** diff --git a/electron/ipc/registry.ts b/electron/ipc/registry.ts index d960b827..214e64b0 100644 --- a/electron/ipc/registry.ts +++ b/electron/ipc/registry.ts @@ -49,6 +49,7 @@ function isWriteChannel(channel: string): boolean { "db:piSession:messages", "db:graph:get", "db:graph:neighbors", + "db:embeddings:search", "db:mcpQuery" ]; diff --git a/electron/ipc/settings-handlers.ts b/electron/ipc/settings-handlers.ts index 4a16006f..97b24d76 100644 --- a/electron/ipc/settings-handlers.ts +++ b/electron/ipc/settings-handlers.ts @@ -34,4 +34,9 @@ export function registerSettingsHandlers(): void { saveCachedConfig("fontScale", fontScale); return { ok: true }; })); + registerIpcHandle("app:getEmbeddingsSettings", () => handle(() => getCachedConfig().embeddingsConfig ?? null)); + registerIpcHandle("app:saveEmbeddingsSettings", (_e, { config }: { config: Record }) => handle(() => { + saveCachedConfig("embeddings", config); + return { ok: true }; + })); } diff --git a/electron/lib/config-cache.ts b/electron/lib/config-cache.ts index 04908d13..518fbdb7 100644 --- a/electron/lib/config-cache.ts +++ b/electron/lib/config-cache.ts @@ -4,6 +4,11 @@ import path from "path"; const CONFIG_CACHE_FILE = "ai-settings-cache.json"; +export interface CachedEmbeddingsConfig { + enabled?: boolean; + modelId?: string; +} + export interface CachedConfig { aiConfig?: { provider?: string; @@ -19,6 +24,7 @@ export interface CachedConfig { temperature?: number; autoApprove?: boolean; }; + embeddingsConfig?: CachedEmbeddingsConfig; theme?: string; fontScale?: number; } @@ -32,7 +38,7 @@ function getCachePath(): string { return path.join(userData, CONFIG_CACHE_FILE); } -export function saveCachedConfig(type: "ai" | "agent" | "theme" | "fontScale", config: unknown): void { +export function saveCachedConfig(type: "ai" | "agent" | "embeddings" | "theme" | "fontScale", config: unknown): void { try { const filePath = getCachePath(); if (!filePath) return; @@ -67,6 +73,12 @@ export function saveCachedConfig(type: "ai" | "agent" | "theme" | "fontScale", c temperature: typeof configRecord.temperature === "number" ? configRecord.temperature : current.agentConfig?.temperature, autoApprove: typeof configRecord.autoApprove === "boolean" ? configRecord.autoApprove : current.agentConfig?.autoApprove, }; + } else if (type === "embeddings" && configRecord) { + current.embeddingsConfig = { + ...current.embeddingsConfig, + enabled: typeof configRecord.enabled === "boolean" ? configRecord.enabled : current.embeddingsConfig?.enabled, + modelId: typeof configRecord.modelId === "string" ? configRecord.modelId : current.embeddingsConfig?.modelId, + }; } else if (type === "theme") { current.theme = String(config); } else if (type === "fontScale") { @@ -90,3 +102,7 @@ export function getCachedConfig(): CachedConfig { } return {}; } + +export function getEmbeddingsSettingsCached(): CachedEmbeddingsConfig { + return getCachedConfig().embeddingsConfig ?? {}; +} diff --git a/electron/lib/tool-schemas.ts b/electron/lib/tool-schemas.ts index 4bb3c216..67b4ce03 100644 --- a/electron/lib/tool-schemas.ts +++ b/electron/lib/tool-schemas.ts @@ -335,6 +335,14 @@ export const TOOL_SCHEMAS = { }), }, + get_semantic_neighbors: { + description: "Find notes semantically related to a given note via local embeddings. Returns related notes with similarity scores and matching section titles. Use this to discover connections that keyword search or wikilinks would miss — e.g. notes about similar topics written in different words.", + schema: z.object({ + workspaceId: sId, + noteId: sId.describe("The note ID to find semantic neighbors for"), + }), + }, + // ── Tags ────────────────────────────────────────────────────────────────────── create_tag: { diff --git a/electron/main.ts b/electron/main.ts index 19645a87..32ee27f5 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -31,6 +31,7 @@ import { createTray } from "./lib/tray"; import { killTrackedBashProcesses } from "./lib/coding-tools/bash"; import { startMcpNotificationPoller } from "./lib/mcp-poller"; import { stopServerSync } from "./lib/llama-server"; +import { dispose as disposeEmbeddingsWorker } from "./embeddings/client"; const isDev = !app.isPackaged; @@ -310,6 +311,8 @@ app.on("before-quit", () => { killTrackedBashProcesses(); // Terminate the local llama-server background child process so it doesn't linger stopServerSync(); + // Terminate the embeddings worker child process (HTTP server) so it doesn't linger + void disposeEmbeddingsWorker(); }); app.on("window-all-closed", () => { diff --git a/electron/mcp-server.ts b/electron/mcp-server.ts index 97886511..34abcd42 100644 --- a/electron/mcp-server.ts +++ b/electron/mcp-server.ts @@ -22,6 +22,7 @@ import { findDbPath, findWorkspacePath, ensureMcpActiveWritesTable, + ensureEmbeddingsTable, toWorkspace, toProject, getSnapshot @@ -142,6 +143,7 @@ if (require.main === module) { db.pragma("foreign_keys = ON"); db.pragma("journal_mode = WAL"); ensureMcpActiveWritesTable(db); + ensureEmbeddingsTable(db); const workspacePath = findWorkspacePath(dbPath); process.stderr.write(`[cairn:mcp] Workspace folder: ${workspacePath}\n`); const server = buildMcpServer(db, workspacePath); diff --git a/electron/mcp/db.ts b/electron/mcp/db.ts index 950dc3b8..28d14e4d 100644 --- a/electron/mcp/db.ts +++ b/electron/mcp/db.ts @@ -146,6 +146,26 @@ export function ensureMcpActiveWritesTable(db: Database.Database): void { db.prepare("DELETE FROM mcp_active_writes WHERE started_at < datetime('now', '-30 seconds')").run(); } +export function ensureEmbeddingsTable(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS note_embeddings ( + note_id TEXT PRIMARY KEY REFERENCES notes(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL, + model TEXT NOT NULL, + task TEXT NOT NULL, + content_hash TEXT NOT NULL, + vector TEXT NOT NULL, + embedded_at TEXT NOT NULL, + dim_x REAL, + dim_y REAL, + proj_stale INTEGER NOT NULL DEFAULT 1 + ); + CREATE INDEX IF NOT EXISTS idx_emb_workspace ON note_embeddings(workspace_id); + CREATE INDEX IF NOT EXISTS idx_emb_proj_stale ON note_embeddings(proj_stale); + CREATE INDEX IF NOT EXISTS idx_emb_task ON note_embeddings(task); + `); +} + export function lockNote(db: Database.Database, noteId: string): void { try { db.prepare("INSERT OR REPLACE INTO mcp_active_writes (note_id, started_at) VALUES (?, datetime('now'))").run(noteId); diff --git a/electron/mcp/tools/graph.ts b/electron/mcp/tools/graph.ts index 1b06d296..d14d4113 100644 --- a/electron/mcp/tools/graph.ts +++ b/electron/mcp/tools/graph.ts @@ -3,6 +3,7 @@ import Database from "better-sqlite3"; import { getKnowledgeGraph, getNeighbours, + getSemanticNeighbors, type GraphFilters, type EdgeType, } from "../../db/graph-queries"; @@ -31,7 +32,6 @@ export function get_neighbors(db: Database.Database, args: Record) const { workspaceId, nodeId, depth = 1, edgeTypes } = args; if (!workspaceId || !nodeId) return { error: "workspaceId and nodeId are required" }; - // Delegate to the canonical BFS implementation in graph-queries.ts. return getNeighbours( db, workspaceId as string, @@ -40,3 +40,12 @@ export function get_neighbors(db: Database.Database, args: Record) Array.isArray(edgeTypes) ? edgeTypes as EdgeType[] : undefined, ); } + +export function get_semantic_neighbors(db: Database.Database, args: Record) { + const { noteId, workspaceId } = args; + if (!noteId) return { error: "noteId is required" }; + if (!workspaceId) return { error: "workspaceId is required" }; + const neighbors = getSemanticNeighbors(db, noteId as string, workspaceId as string); + insertNotification(db, "get_semantic_neighbors", "Semantic neighbors retrieved", `${neighbors.length} related notes for ${noteId}`); + return { noteId, neighbors }; +} diff --git a/electron/mcp/tools/index.ts b/electron/mcp/tools/index.ts index 53a237a3..7386a59b 100644 --- a/electron/mcp/tools/index.ts +++ b/electron/mcp/tools/index.ts @@ -15,7 +15,7 @@ import { delete_idea_flow_edge, layout_idea_flow } from "./flow"; -import { get_knowledge_graph, get_neighbors } from "./graph"; +import { get_knowledge_graph, get_neighbors, get_semantic_neighbors } from "./graph"; import { codebase_reindex, codebase_search_symbols, @@ -125,6 +125,9 @@ export function executeTool(db: Database.Database, workspacePath: string, toolNa case "get_neighbors": return get_neighbors(db, args); + case "get_semantic_neighbors": + return get_semantic_neighbors(db, args); + case "codebase_reindex": return codebase_reindex(db, args as Parameters[1]); diff --git a/electron/preload.ts b/electron/preload.ts index 762b89f9..a0a4dc61 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -491,6 +491,80 @@ const api = { setDefault: (modelId: string) => invoke<{ success: boolean }>("llama:server:setDefault", { modelId }), } }, + // ── Embeddings (local semantic search + knowledge graph) ──── + embeddings: { + status: () => invoke<{ + running: boolean; + port: number | null; + activeModelId: string | null; + defaultModelId: string | null; + installed: boolean; + error: string | null; + reindexInProgress: boolean; + recomputeInProgress: boolean; + lastReindexDone: number; + lastReindexTotal: number; + lastRecomputeDone: number; + lastRecomputeTotal: number; + }>("embeddings:status"), + stop: () => invoke("embeddings:stop"), + reindex: (workspaceId: string, noteIds?: string[], model?: string) => invoke<{ + indexed: number; + skipped: number; + total: number; + }>("db:embeddings:reindex", { workspaceId, noteIds, model }), + search: (workspaceId: string, queryText: string, opts?: { + queryNoteId?: string; + k?: number; + excludeIds?: string[]; + model?: string; + }) => invoke>( + "db:embeddings:search", + { workspaceId, queryText, ...opts }, + ), + recomputeProjections: (workspaceId: string, model?: string) => invoke<{ + projected: number; + total: number; + }>("db:embeddings:recomputeProjections", { workspaceId, model }), + models: { + list: () => invoke>("embeddings:models:list"), + install: (modelId: string) => invoke<{ ok: boolean }>("embeddings:models:install", { modelId }), + remove: (modelId: string) => invoke<{ ok: boolean }>("embeddings:models:remove", { modelId }), + setDefault: (modelId: string) => invoke<{ ok: boolean }>("embeddings:models:setDefault", { modelId }), + onProgress: (cb: (e: { + modelId: string; + status: string; + file?: string; + progress?: number; + loaded?: number; + total?: number; + error?: string; + }) => void) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handler = (_: any, e: any) => cb(e); + ipcRenderer.on("embeddings:download-progress", handler); + return () => { + ipcRenderer.off("embeddings:download-progress", handler); + }; + }, + }, + getSettings: () => invoke<{ enabled?: boolean; modelId?: string } | null>("app:getEmbeddingsSettings"), + saveSettings: (config: { enabled?: boolean; modelId?: string }) => invoke<{ ok: boolean }>( + "app:saveEmbeddingsSettings", + { config }, + ), + }, // ── Mobile Access ──────────────────────────────── mobile: { status: () => invoke<{ diff --git a/package-lock.json b/package-lock.json index 71425df5..7f04334f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@types/d3-hierarchy": "^3.1.7", "@types/d3-selection": "^3.0.11", "@types/d3-zoom": "^3.0.8", + "@xenova/transformers": "^2.17.2", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-unicode11": "^0.9.0", "@xterm/xterm": "^6.0.0", @@ -28,13 +29,15 @@ "katex": "^0.16.45", "mermaid": "^11.14.0", "node-pty": "^1.1.0", + "onnxruntime-node": "^1.26.0", "parse-diff": "^0.12.0", "qrcode": "^1.5.4", "react-day-picker": "^9.14.0", "react-force-graph-2d": "^1.29.1", "rehype-katex": "^7.0.1", "remark-breaks": "^4.0.0", - "remark-math": "^6.0.0" + "remark-math": "^6.0.0", + "umap-js": "^1.4.0" }, "devDependencies": { "@codemirror/commands": "^6.10.3", @@ -2110,6 +2113,15 @@ "hono": "^4" } }, + "node_modules/@huggingface/jinja": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.2.2.tgz", + "integrity": "sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -3409,6 +3421,69 @@ "node": ">=18" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -5251,6 +5326,12 @@ "@types/node": "*" } }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -5270,7 +5351,6 @@ "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -6119,6 +6199,109 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xenova/transformers": { + "version": "2.17.2", + "resolved": "https://registry.npmjs.org/@xenova/transformers/-/transformers-2.17.2.tgz", + "integrity": "sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.2.2", + "onnxruntime-web": "1.14.0", + "sharp": "^0.32.0" + }, + "optionalDependencies": { + "onnxruntime-node": "1.14.0" + } + }, + "node_modules/@xenova/transformers/node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "license": "MIT" + }, + "node_modules/@xenova/transformers/node_modules/onnxruntime-common": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.14.0.tgz", + "integrity": "sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew==", + "license": "MIT", + "optional": true + }, + "node_modules/@xenova/transformers/node_modules/onnxruntime-node": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.14.0.tgz", + "integrity": "sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w==", + "license": "MIT", + "optional": true, + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "onnxruntime-common": "~1.14.0" + } + }, + "node_modules/@xenova/transformers/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@xenova/transformers/node_modules/sharp": { + "version": "0.32.6", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", + "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "node-addon-api": "^6.1.0", + "prebuild-install": "^7.1.1", + "semver": "^7.5.4", + "simple-get": "^4.0.1", + "tar-fs": "^3.0.4", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@xenova/transformers/node_modules/tar-fs": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/@xenova/transformers/node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", @@ -6953,6 +7136,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adm-zip": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -7656,7 +7848,6 @@ "version": "1.8.0", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", - "dev": true, "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" @@ -7688,7 +7879,6 @@ "version": "2.8.2", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "dev": true, "license": "Apache-2.0", "peerDependencies": { "bare-abort-controller": "*" @@ -7703,7 +7893,6 @@ "version": "4.7.1", "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz", "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "bare-events": "^2.5.4", @@ -7728,7 +7917,6 @@ "version": "3.9.0", "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.0.tgz", "integrity": "sha512-JTjuZyNIDpw+GytMO4a6TK1VXdVKKJr6DRxEHasyuYyShV2deuiHJK/ahGZlebc+SG0/wJCB9XK8gprBGDFi/Q==", - "dev": true, "license": "Apache-2.0", "engines": { "bare": ">=1.14.0" @@ -7738,7 +7926,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "bare-os": "^3.0.1" @@ -7748,7 +7935,6 @@ "version": "2.13.1", "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz", "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==", - "dev": true, "license": "Apache-2.0", "dependencies": { "streamx": "^2.25.0", @@ -7775,7 +7961,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.2.tgz", "integrity": "sha512-/9a2j4ac6ckpmAHvod/ob7x439OAHst/drc2Clnq+reRYd/ovddwcF4LfoxHyNk5AuGBnPg+HqFjmE/Zpq6v0A==", - "dev": true, "license": "Apache-2.0", "dependencies": { "bare-path": "^3.0.0" @@ -8460,6 +8645,19 @@ "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -8478,6 +8676,16 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -9453,7 +9661,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -9471,7 +9678,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -10233,7 +10439,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10243,7 +10448,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10415,7 +10619,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -10879,7 +11082,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "bare-events": "^2.7.0" @@ -11059,7 +11261,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -11261,6 +11462,12 @@ "node": ">=16" } }, + "node_modules/flatbuffers": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-1.12.0.tgz", + "integrity": "sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==", + "license": "SEE LICENSE IN LICENSE.txt" + }, "node_modules/flatted": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", @@ -11704,7 +11911,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, "license": "MIT", "dependencies": { "define-properties": "^1.2.1", @@ -11721,7 +11927,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11817,6 +12022,12 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, "node_modules/hachure-fill": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", @@ -11850,7 +12061,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -12460,6 +12670,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-any-array": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-0.1.1.tgz", + "integrity": "sha512-qTiELO+kpTKqPgxPYbshMERlzaFu29JDnpB8s3bjg+JkxBpw29/qqSaOdKv2pCdaG92rLGeG/zG2GauX58hfoA==", + "license": "MIT" + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -12478,6 +12694,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -13650,6 +13872,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -14921,6 +15149,79 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/ml-array-max": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ml-array-max/-/ml-array-max-2.0.0.tgz", + "integrity": "sha512-QQZ4kENwpWmyNb98UXRDFXrmtIXuXtt1+bSbda/2KA85+F+rrJP8hZk6QOkCQXM2Th9mUDYdq/PNByPdT9ID4A==", + "license": "MIT", + "dependencies": { + "is-any-array": "^3.0.0" + } + }, + "node_modules/ml-array-max/node_modules/is-any-array": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-3.0.0.tgz", + "integrity": "sha512-o4h+tylWykC4BD1vaejp6gDxoM13bwW8FGuNs4yIKpj8xbBJcRxJx8vZpq0dCr7ZDEfeKjmsi/euolKhX6f/ww==", + "license": "MIT" + }, + "node_modules/ml-array-min": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ml-array-min/-/ml-array-min-2.0.0.tgz", + "integrity": "sha512-GRj6Ky6sW9vGL6yIjgsHmXZ9YgrdmcQ8nCxPqEGeKc6dkfYg1XDYxGFxADUjNuZyoCd5PUscWAS4N+cFaX6hFg==", + "license": "MIT", + "dependencies": { + "is-any-array": "^3.0.0" + } + }, + "node_modules/ml-array-min/node_modules/is-any-array": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-3.0.0.tgz", + "integrity": "sha512-o4h+tylWykC4BD1vaejp6gDxoM13bwW8FGuNs4yIKpj8xbBJcRxJx8vZpq0dCr7ZDEfeKjmsi/euolKhX6f/ww==", + "license": "MIT" + }, + "node_modules/ml-array-rescale": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ml-array-rescale/-/ml-array-rescale-2.0.0.tgz", + "integrity": "sha512-2GGtKfSno94/kIloWGvpp/U5Q5vLvLrza+SAaGsLeo6Xj4mEbA6Gqx+oTfZFkxnd1grT2X007HfJNs3T5BsiVg==", + "license": "MIT", + "dependencies": { + "is-any-array": "^3.0.0", + "ml-array-max": "^2.0.0", + "ml-array-min": "^2.0.0" + } + }, + "node_modules/ml-array-rescale/node_modules/is-any-array": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-3.0.0.tgz", + "integrity": "sha512-o4h+tylWykC4BD1vaejp6gDxoM13bwW8FGuNs4yIKpj8xbBJcRxJx8vZpq0dCr7ZDEfeKjmsi/euolKhX6f/ww==", + "license": "MIT" + }, + "node_modules/ml-levenberg-marquardt": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ml-levenberg-marquardt/-/ml-levenberg-marquardt-2.1.1.tgz", + "integrity": "sha512-2+HwUqew4qFFFYujYlQtmFUrxCB4iJAPqnUYro3P831wj70eJZcANwcRaIMGUVaH9NDKzfYuA4N5u67KExmaRA==", + "license": "MIT", + "dependencies": { + "is-any-array": "^0.1.0", + "ml-matrix": "^6.4.1" + } + }, + "node_modules/ml-matrix": { + "version": "6.12.2", + "resolved": "https://registry.npmjs.org/ml-matrix/-/ml-matrix-6.12.2.tgz", + "integrity": "sha512-GC+BnW+pBh8Auap8goAxY0senAmF0IEoc3HNVSfnfbvGw0buuDIYb9kAKMS1l+GiwJ1rfK2bzJ8IHhwjzATSFA==", + "license": "MIT", + "dependencies": { + "is-any-array": "^3.0.0", + "ml-array-rescale": "^2.0.0" + } + }, + "node_modules/ml-matrix/node_modules/is-any-array": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-3.0.0.tgz", + "integrity": "sha512-o4h+tylWykC4BD1vaejp6gDxoM13bwW8FGuNs4yIKpj8xbBJcRxJx8vZpq0dCr7ZDEfeKjmsi/euolKhX6f/ww==", + "license": "MIT" + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -15322,7 +15623,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -15451,6 +15751,127 @@ "wrappy": "1" } }, + "node_modules/onnx-proto": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/onnx-proto/-/onnx-proto-4.0.4.tgz", + "integrity": "sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA==", + "license": "MIT", + "dependencies": { + "protobufjs": "^6.8.8" + } + }, + "node_modules/onnxruntime-common": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.26.0.tgz", + "integrity": "sha512-qVyMR4lcWgbkc4getFV+GQijsTnbg/siteoqcDwa3sI/LxbrMSNw4ePyvCq/ymdQaRomCA7YuWmhzsswxvymdw==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.26.0.tgz", + "integrity": "sha512-OHl6PiOEOqxaLHL0N9eFrbzS7IGmu3BtJNH3RTEnRAheCIkfc3gjcjl4sGcjp9C22ZC9YTquDOxSdT/stBQ6BQ==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "adm-zip": "^0.5.16", + "global-agent": "^4.1.3", + "onnxruntime-common": "1.26.0" + } + }, + "node_modules/onnxruntime-node/node_modules/global-agent": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.3.tgz", + "integrity": "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==", + "license": "BSD-3-Clause", + "dependencies": { + "globalthis": "^1.0.2", + "matcher": "^4.0.0", + "semver": "^7.3.5", + "serialize-error": "^8.1.0" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/onnxruntime-node/node_modules/matcher": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz", + "integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onnxruntime-node/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/onnxruntime-node/node_modules/serialize-error": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", + "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onnxruntime-node/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.14.0.tgz", + "integrity": "sha512-Kcqf43UMfW8mCydVGcX9OMXI2VN17c0p6XvR7IPSZzBf/6lteBzXHvcEVWDPmCKuGombl997HgLqj91F11DzXw==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^1.12.0", + "guid-typescript": "^1.0.9", + "long": "^4.0.0", + "onnx-proto": "^4.0.4", + "onnxruntime-common": "~1.14.0", + "platform": "^1.3.6" + } + }, + "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.14.0.tgz", + "integrity": "sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew==", + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -15734,6 +16155,12 @@ "pathe": "^2.0.1" } }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, "node_modules/playwright": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", @@ -16050,6 +16477,32 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/protobufjs": { + "version": "6.11.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.6.tgz", + "integrity": "sha512-k8BHqgPBOtrlougZZqF2uUk5Z7bN8f0wj+3e8M3hvtSv0NBAz4VBy5f6R5Nxq/l+i7mRFTgNZb2trxqTpHNY/A==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": ">=13.7.0", + "long": "^4.0.0" + }, + "bin": { + "pbjs": "bin/pbjs", + "pbts": "bin/pbts" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -17489,6 +17942,15 @@ "simple-concat": "^1.0.0" } }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -17701,7 +18163,6 @@ "version": "2.25.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", - "dev": true, "license": "MIT", "dependencies": { "events-universal": "^1.0.0", @@ -18103,7 +18564,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", - "dev": true, "license": "MIT", "dependencies": { "streamx": "^2.12.5" @@ -18177,7 +18637,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4" @@ -18611,6 +19070,15 @@ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", "license": "MIT" }, + "node_modules/umap-js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/umap-js/-/umap-js-1.4.0.tgz", + "integrity": "sha512-xxpviF9wUO6Nxrx+C58SoDgea+h2PnVaRPKDelWv0HotmY6BeWeh0kAPJoumfqUkzUvowGsYfMbnsWI0b9do+A==", + "license": "MIT", + "dependencies": { + "ml-levenberg-marquardt": "^2.0.0" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -18644,7 +19112,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unified": { diff --git a/package.json b/package.json index bfdd401d..31f1b4ed 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,13 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "dev": "node scripts/generate-licenses.js && concurrently -n next,esbuild,electron -c cyan,magenta,yellow \"next dev\" \"npm run compile:watch\" \"wait-on http://localhost:3000 dist-electron/main.js && node scripts/electron-dev.js\"", - "compile:watch": "esbuild electron/main.ts electron/preload.ts --bundle --platform=node --target=node22 --external:electron --external:better-sqlite3 --external:node-pty --outdir=dist-electron --format=cjs --watch", + "compile:watch": "esbuild electron/main.ts electron/preload.ts --bundle --platform=node --target=node22 --external:electron --external:better-sqlite3 --external:node-pty --external:@xenova/transformers --external:onnxruntime-node --external:umap-js --outdir=dist-electron --format=cjs --watch", "build:mac": "node scripts/build.js --mac", "build:win": "node scripts/build.js --win", "build:linux": "node scripts/build.js --linux", "build:all": "node scripts/build.js --mac --win --linux", - "compile": "esbuild electron/main.ts electron/preload.ts --bundle --platform=node --target=node22 --external:electron --external:better-sqlite3 --external:node-pty --outdir=dist-electron --format=cjs && esbuild electron/mcp-server.ts --bundle --platform=node --target=node22 --external:better-sqlite3 --outfile=dist-mcp/mcp-server.bundle.js --format=cjs", + "compile": "esbuild electron/main.ts electron/preload.ts --bundle --platform=node --target=node22 --external:electron --external:better-sqlite3 --external:node-pty --external:@xenova/transformers --external:onnxruntime-node --external:umap-js --outdir=dist-electron --format=cjs && esbuild electron/mcp-server.ts --bundle --platform=node --target=node22 --external:better-sqlite3 --outfile=dist-mcp/mcp-server.bundle.js --format=cjs && esbuild electron/embeddings/server.ts --bundle --platform=node --target=node22 --external:@xenova/transformers --external:onnxruntime-node --external:umap-js --outfile=dist-electron/embeddings-server.bundle.js --format=cjs", + "build:embeddings-binary": "node scripts/build-embeddings-binary.js", "generate-tray-icon": "node scripts/generate-tray-icon.js", "postinstall": "electron-builder install-app-deps", "rebuild": "node scripts/rebuild-native.js", @@ -40,6 +41,7 @@ "@types/d3-hierarchy": "^3.1.7", "@types/d3-selection": "^3.0.11", "@types/d3-zoom": "^3.0.8", + "@xenova/transformers": "^2.17.2", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-unicode11": "^0.9.0", "@xterm/xterm": "^6.0.0", @@ -53,13 +55,15 @@ "katex": "^0.16.45", "mermaid": "^11.14.0", "node-pty": "^1.1.0", + "onnxruntime-node": "^1.26.0", "parse-diff": "^0.12.0", "qrcode": "^1.5.4", "react-day-picker": "^9.14.0", "react-force-graph-2d": "^1.29.1", "rehype-katex": "^7.0.1", "remark-breaks": "^4.0.0", - "remark-math": "^6.0.0" + "remark-math": "^6.0.0", + "umap-js": "^1.4.0" }, "devDependencies": { "@codemirror/commands": "^6.10.3", diff --git a/scripts/build-embeddings-binary.js b/scripts/build-embeddings-binary.js new file mode 100644 index 00000000..44b3073a --- /dev/null +++ b/scripts/build-embeddings-binary.js @@ -0,0 +1,65 @@ +#!/usr/bin/env node +/** + * Cairn — Embeddings worker binary builder. + * + * Bundles `electron/embeddings/server.ts` into a self-contained executable + * using @yao-pkg/pkg. The binary bundles Node 22 + the embeddings server, + * with @xenova/transformers / onnxruntime-node loaded at runtime from + * `node_modules/` (env-injected). + * + * Platform-specific binary, emitted in dist-embeddings/: + * dist-embeddings/cairn-embeddings (macOS / Linux) + * dist-embeddings/cairn-embeddings.exe (Windows) + * + * Usage: + * node scripts/build-embeddings-binary.js [--mac] [--win] [--linux] + * (defaults to current platform if no flag given) + */ + +const { execSync } = require("child_process"); +const path = require("path"); +const fs = require("fs"); + +const root = path.resolve(__dirname, ".."); +const bundlePath = path.join(root, "dist-electron", "embeddings-server.bundle.js"); +const outDir = path.join(root, "dist-embeddings"); + +const args = process.argv.slice(2); +const wantMac = args.includes("--mac"); +const wantWin = args.includes("--win"); +const wantLinux = args.includes("--linux"); + +const platform = process.platform; +const targets = []; +if (wantMac || (!wantMac && !wantWin && !wantLinux && platform === "darwin")) targets.push({ id: "node22-macos-arm64", out: "cairn-embeddings" }); +if (wantWin || (!wantMac && !wantWin && !wantLinux && platform === "win32")) targets.push({ id: "node22-win-x64", out: "cairn-embeddings.exe" }); +if (wantLinux || (!wantMac && !wantWin && !wantLinux && platform === "linux")) targets.push({ id: "node22-linux-x64", out: "cairn-embeddings" }); + +function run(cmd) { + console.log(`\n> ${cmd}`); + execSync(cmd, { stdio: "inherit", cwd: root }); +} + +if (!fs.existsSync(bundlePath)) { + console.error(`[build-embeddings-binary] Bundle not found: ${bundlePath}`); + console.error("Run `npm run compile` first."); + process.exit(1); +} + +fs.mkdirSync(outDir, { recursive: true }); + +for (const target of targets) { + const outPath = path.join(outDir, target.out); + console.log(`\n[build-embeddings-binary] Building ${target.id} → ${target.out}`); + run( + `npx pkg dist-electron/embeddings-server.bundle.js` + + ` --target ${target.id}` + + ` --config pkg.config.js` + + ` --output ${outPath}` + ); + console.log(`[build-embeddings-binary] Written: ${outPath}`); +} + +console.log("\n[build-embeddings-binary] Done. Note: onnxruntime-node's native binaries"); +console.log("and the @xenova/transformers model cache must be supplied alongside this"); +console.log("binary (e.g. via electron-builder `asarUnpack` or TRANSFORMERS_CACHE env)."); diff --git a/scripts/generate-licenses.js b/scripts/generate-licenses.js index a0eb1b3e..b4cd3750 100644 --- a/scripts/generate-licenses.js +++ b/scripts/generate-licenses.js @@ -44,6 +44,9 @@ const ROLE_MAP = { // ── AI ────────────────────────────────────────────────────────────────────── "@modelcontextprotocol/sdk": ["MCP SDK", "Agent protocol", "AI"], "ai": ["Vercel AI SDK", "AI streaming utilities", "AI"], + "@xenova/transformers": ["Transformers.js", "Tokenizer & model loading", "AI"], + "onnxruntime-node": ["ONNX Runtime", "Native embedding inference", "AI"], + "umap-js": ["UMAP.js", "Embedding dimensionality reduction", "AI"], // ── UI ────────────────────────────────────────────────────────────────────── "tailwindcss": ["Tailwind CSS", "Styling", "UI"], diff --git a/src/components/graph/ForceGraphCanvas.tsx b/src/components/graph/ForceGraphCanvas.tsx index 81ec559f..1688edbb 100644 --- a/src/components/graph/ForceGraphCanvas.tsx +++ b/src/components/graph/ForceGraphCanvas.tsx @@ -18,6 +18,7 @@ interface Props { onBackgroundClick: () => void; labelMode: "smart" | "all" | "minimal"; spacing: number; + semanticThreshold?: number; } function hexForType(type: GraphNode["type"]): string { @@ -30,7 +31,8 @@ function hexForType(type: GraphNode["type"]): string { return map[type] ?? "#888"; } -// Edge colour by relationship type + // Edge colour by relationship type + function edgeColor(edgeType: string): { color: string; opacity: number; dash: boolean } { switch (edgeType) { case "note-note": return { color: resolveCssVar("--info"), opacity: 0.6, dash: false }; @@ -43,6 +45,7 @@ function edgeColor(edgeType: string): { color: string; opacity: number; dash: bo case "keyword": return { color: resolveCssVar("--border"), opacity: 0.4, dash: true }; case "assignee": return { color: resolveCssVar("--border"), opacity: 0.4, dash: true }; case "wikilink": return { color: resolveCssVar("--accent"), opacity: 0.75, dash: false }; + case "semantic": return { color: resolveCssVar("--accent"), opacity: 0.5, dash: true }; default: return { color: resolveCssVar("--border"), opacity: 0.4, dash: false }; } } @@ -52,23 +55,104 @@ function toAlpha(hex: string, opacity: number): string { return hex.replace(/^#/, "#") + a; } -export function ForceGraphCanvas({ graph, selectedNodeId, onNodeClick, onBackgroundClick, labelMode, spacing }: Props) { +type FgNode = { id?: string; name?: string; nodeType?: string; x?: number; y?: number }; + +function drawNode( + node: FgNode, + ctx: CanvasRenderingContext2D, + globalScale: number, + fs: number, + selectedNodeId: string | null, + hoveredNodeId: string | null, + connectedNodeIds: Set | null, + labelMode: "smart" | "all" | "minimal", +) { + const label = node.name ?? ""; + const isSelected = node.id === selectedNodeId; + const isHovered = node.id === hoveredNodeId; + const isDimmed = selectedNodeId !== null && node.id !== null && node.id !== undefined && !connectedNodeIds?.has(node.id); + const isProject = node.nodeType === "project"; + const radius = isProject ? 7 : node.nodeType === "tag" ? 4 : 5.5; + const x = node.x ?? 0; + const y = node.y ?? 0; + const color = hexForType((node.nodeType ?? "note") as GraphNode["type"]); + + if (isSelected || isHovered) { + const glowOpacity = isSelected ? 0.08 : 0.04; + for (let i = 3; i >= 1; i--) { + ctx.beginPath(); + ctx.arc(x, y, radius + i * 3, 0, 2 * Math.PI); + ctx.fillStyle = color + Math.round((glowOpacity / i) * 255).toString(16).padStart(2, "0"); + ctx.fill(); + } + } + + ctx.beginPath(); + ctx.arc(x, y, radius, 0, 2 * Math.PI); + ctx.fillStyle = isSelected ? color : color + (isDimmed ? "30" : "bb"); + ctx.fill(); + + if (isSelected) { + ctx.strokeStyle = color; + ctx.lineWidth = 2 / globalScale; + ctx.stroke(); + } + + let showLabel = false; + if (isProject || isSelected || isHovered) { + showLabel = true; + } else if (labelMode === "all") { + showLabel = globalScale >= 0.7; + } else if (labelMode === "smart") { + showLabel = globalScale >= 1.4; + } + + if (showLabel) { + const isHighlight = isSelected || isHovered; + const screenPx = (isProject ? 11.5 : 10) * fs; + const fontSize = screenPx / globalScale; + const maxLen = isHighlight ? 60 : isProject ? 24 : 18; + const text = label.length > maxLen ? label.slice(0, maxLen - 1) + "…" : label; + + ctx.font = `${(isProject || isHighlight) ? "600 " : ""}${fontSize}px ui-sans-serif, system-ui, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + + ctx.strokeStyle = resolveCssVar("--background"); + ctx.lineWidth = 4 / globalScale; + ctx.lineJoin = "round"; + ctx.strokeText(text, x, y + radius + 4 / globalScale); + + if (isHighlight && !isProject) { + ctx.fillStyle = resolveCssVar("--accent"); + } else { + ctx.fillStyle = resolveCssVar(isProject ? "--text-primary" : "--text-secondary"); + } + + ctx.fillText(text, x, y + radius + 4 / globalScale); + } +} + +export function ForceGraphCanvas({ graph, selectedNodeId, onNodeClick, onBackgroundClick, labelMode, spacing, semanticThreshold = 1 }: Props) { const containerRef = useRef(null); const fgRef = useRef(null); const [ForceGraph2D, setForceGraph2D] = useState(null); const fs = useFontScale(); const [dims, setDims] = useState({ width: 800, height: 600 }); const [hoveredNodeId, setHoveredNodeId] = useState(null); + const [hoveredEdgeText, setHoveredEdgeText] = useState(null); + const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 }); // Compute node degrees to dynamically scale individual node repulsion (charge force) const nodeDegrees = useMemo(() => { const degrees: Record = {}; for (const link of graph.edges) { + if (link.type === "semantic" && (link.weight ?? 1) < semanticThreshold) continue; degrees[link.source] = (degrees[link.source] ?? 0) + 1; degrees[link.target] = (degrees[link.target] ?? 0) + 1; } return degrees; - }, [graph.edges]); + }, [graph.edges, semanticThreshold]); useEffect(() => { import("react-force-graph-2d").then((mod) => setForceGraph2D(() => mod.default)); @@ -136,8 +220,14 @@ export function ForceGraphCanvas({ graph, selectedNodeId, onNodeClick, onBackgro // Key on stable fingerprints of node/edge IDs — not object identity. // This means selectedNodeId changes (which cause re-renders) never invalidate // the memo and never hand a new graphData object to the library → no re-simulation. + const visibleEdges = useMemo( + () => graph.edges.filter( + (e) => e.type !== "semantic" || (e.weight ?? 1) >= semanticThreshold, + ), + [graph.edges, semanticThreshold], + ); const nodeFingerprint = graph.nodes.map((n) => n.id).join(","); - const edgeFingerprint = graph.edges.map((e) => `${e.source}-${e.target}`).join(","); + const edgeFingerprint = visibleEdges.map((e) => `${e.source}-${e.target}`).join(","); const fgData = useMemo(() => ({ nodes: graph.nodes.map((n) => ({ id: n.id, @@ -145,7 +235,7 @@ export function ForceGraphCanvas({ graph, selectedNodeId, onNodeClick, onBackgro nodeType: n.type, val: n.type === "project" ? 3 : n.type === "tag" ? 1.5 : 2, })), - links: graph.edges.map((e) => ({ + links: visibleEdges.map((e) => ({ source: e.source, target: e.target, edgeType: e.type, @@ -154,6 +244,17 @@ export function ForceGraphCanvas({ graph, selectedNodeId, onNodeClick, onBackgro // eslint-disable-next-line react-hooks/exhaustive-deps }), [nodeFingerprint, edgeFingerprint]); + const connectedNodeIds = useMemo(() => { + if (!selectedNodeId) return null; + const ids = new Set(); + ids.add(selectedNodeId); + for (const e of visibleEdges) { + if (e.source === selectedNodeId) ids.add(e.target); + if (e.target === selectedNodeId) ids.add(e.source); + } + return ids; + }, [selectedNodeId, visibleEdges]); + function zoomBy(factor: number) { fgRef.current?.zoom(fgRef.current.zoom() * factor, 300); } @@ -170,28 +271,53 @@ export function ForceGraphCanvas({ graph, selectedNodeId, onNodeClick, onBackgro } return ( -
- { + if (hoveredEdgeText) { + const rect = e.currentTarget.getBoundingClientRect(); + setTooltipPos({ x: e.clientX - rect.left + 12, y: e.clientY - rect.top + 12 }); + } + }} + onMouseLeave={() => setHoveredEdgeText(null)} + > { - const base = hexForType((node.nodeType ?? "note") as GraphNode["type"]); - return node.id === selectedNodeId ? base : base + "bb"; + const type = (node.nodeType ?? "note") as GraphNode["type"]; + const isSelected = node.id === selectedNodeId; + const isDimmed = selectedNodeId !== null && node.id !== null && node.id !== undefined && !connectedNodeIds?.has(node.id); + const base = hexForType(type); + if (isSelected) return base; + if (isDimmed) return base + "30"; + return base + "bb"; }} nodeRelSize={5} - linkColor={(link: { edgeType?: string }) => { + linkColor={(link: { source?: { id?: string } | string; target?: { id?: string } | string; edgeType?: string }) => { + const srcId = typeof link.source === "object" ? link.source?.id : link.source; + const tgtId = typeof link.target === "object" ? link.target?.id : link.target; + const isConnected = selectedNodeId && (srcId === selectedNodeId || tgtId === selectedNodeId); const { color, opacity } = edgeColor(link.edgeType ?? ""); + if (selectedNodeId) { + return isConnected + ? toAlpha(color, Math.max(opacity, 0.9)) + : toAlpha(color, opacity * 0.12); + } return toAlpha(color, opacity); }} - linkWidth={(link: { edgeType?: string; weight?: number }) => { + linkWidth={(link: { source?: { id?: string } | string; target?: { id?: string } | string; edgeType?: string; weight?: number }) => { + const srcId = typeof link.source === "object" ? link.source?.id : link.source; + const tgtId = typeof link.target === "object" ? link.target?.id : link.target; + const isConnected = selectedNodeId && (srcId === selectedNodeId || tgtId === selectedNodeId); + if (isConnected) return 2.5; if (link.edgeType === "flow-edge") return 2; if (link.edgeType === "wikilink") return 2; - // Weight-based thickness for auto-discovered edges (0–1 range) if (link.weight != null && link.weight < 1) return 0.5 + link.weight * 1.0; return 1; }} @@ -203,88 +329,45 @@ export function ForceGraphCanvas({ graph, selectedNodeId, onNodeClick, onBackgro } linkDirectionalArrowRelPos={1} onNodeClick={handleNodeClick} - onBackgroundClick={onBackgroundClick} + onBackgroundClick={() => { onBackgroundClick(); setHoveredEdgeText(null); }} onNodeHover={(node: { id?: string } | null) => { setHoveredNodeId(node ? node.id ?? null : null); + setHoveredEdgeText(null); if (containerRef.current) { containerRef.current.style.cursor = node ? "pointer" : "default"; } }} + onLinkHover={(link: { source?: { id?: string; name?: string }; target?: { id?: string; name?: string }; edgeType?: string; weight?: number } | null) => { + if (!link || link.edgeType !== "semantic") { setHoveredEdgeText(null); return; } + const srcId = typeof link.source === "object" ? link.source?.id ?? "" : ""; + const tgtId = typeof link.target === "object" ? link.target?.id ?? "" : ""; + const srcName = typeof link.source === "object" ? link.source?.name ?? "" : ""; + const tgtName = typeof link.target === "object" ? link.target?.name ?? "" : ""; + const edge = graph.edges.find((e) => + (e.source === srcId && e.target === tgtId) || (e.source === tgtId && e.target === srcId), + ); + const srcSec = edge?.sourceSectionTitle; + const tgtSec = edge?.targetSectionTitle; + const left = srcSec ? `${srcName} › ${srcSec}` : srcName; + const right = tgtSec ? `${tgtName} › ${tgtSec}` : tgtName; + setHoveredEdgeText(`${left} ↔ ${right} · ${((link.weight ?? 1) * 100).toFixed(0)}%`); + }} nodeCanvasObject={( - node: { id?: string; name?: string; nodeType?: string; x?: number; y?: number }, + node: FgNode, ctx: CanvasRenderingContext2D, globalScale: number ) => { - const label = node.name ?? ""; - const isSelected = node.id === selectedNodeId; - const isHovered = node.id === hoveredNodeId; - const isProject = node.nodeType === "project"; - const radius = isProject ? 7 : node.nodeType === "tag" ? 4 : 5.5; - const x = node.x ?? 0; - const y = node.y ?? 0; - const color = hexForType((node.nodeType ?? "note") as GraphNode["type"]); - - // Glow for selected or hovered node - if (isSelected || isHovered) { - const glowOpacity = isSelected ? 0.08 : 0.04; - for (let i = 3; i >= 1; i--) { - ctx.beginPath(); - ctx.arc(x, y, radius + i * 3, 0, 2 * Math.PI); - ctx.fillStyle = color + Math.round((glowOpacity / i) * 255).toString(16).padStart(2, "0"); - ctx.fill(); - } - } - - // Node circle - ctx.beginPath(); - ctx.arc(x, y, radius, 0, 2 * Math.PI); - ctx.fillStyle = isSelected ? color : color + "bb"; - ctx.fill(); - - if (isSelected) { - ctx.strokeStyle = color; - ctx.lineWidth = 2 / globalScale; - ctx.stroke(); - } - - // Smart label density rendering - let showLabel = false; - if (isProject || isSelected || isHovered) { - showLabel = true; - } else if (labelMode === "all") { - showLabel = globalScale >= 0.7; - } else if (labelMode === "smart") { - showLabel = globalScale >= 1.4; - } - - if (showLabel) { - const isHighlight = isSelected || isHovered; - const screenPx = (isProject ? 11.5 : 10) * fs; - const fontSize = screenPx / globalScale; - const maxLen = isProject ? 24 : 18; - const text = label.length > maxLen ? label.slice(0, maxLen - 1) + "…" : label; - - ctx.font = `${(isProject || isHighlight) ? "600 " : ""}${fontSize}px ui-sans-serif, system-ui, sans-serif`; - ctx.textAlign = "center"; - ctx.textBaseline = "top"; - - // Halo outline behind text for premium legibility over links and nodes - ctx.strokeStyle = resolveCssVar("--background"); - ctx.lineWidth = 4 / globalScale; - ctx.lineJoin = "round"; - ctx.strokeText(text, x, y + radius + 4 / globalScale); - - // Text fill color - if (isHighlight && !isProject) { - ctx.fillStyle = resolveCssVar("--accent"); - } else { - ctx.fillStyle = resolveCssVar(isProject ? "--text-primary" : "--text-secondary"); + if (node.id === selectedNodeId || node.id === hoveredNodeId) return; + drawNode(node, ctx, globalScale, fs, selectedNodeId, hoveredNodeId, connectedNodeIds, labelMode); + }} + nodeCanvasObjectMode={() => "replace"} + onRenderFramePost={(ctx: CanvasRenderingContext2D, globalScale: number) => { + for (const node of fgData.nodes) { + if (node.id === selectedNodeId || node.id === hoveredNodeId) { + drawNode(node, ctx, globalScale, fs, selectedNodeId, hoveredNodeId, connectedNodeIds, labelMode); } - - ctx.fillText(text, x, y + radius + 4 / globalScale); } }} - nodeCanvasObjectMode={() => "replace"} cooldownTicks={150} d3AlphaDecay={0.03} d3VelocityDecay={0.4} @@ -319,6 +402,15 @@ export function ForceGraphCanvas({ graph, selectedNodeId, onNodeClick, onBackgro onEngineStop={() => fgRef.current?.zoomToFit(400, 40)} /> + {hoveredEdgeText && ( +
+ {hoveredEdgeText} +
+ )} + {/* Zoom controls */}
{/* eslint-disable-next-line react-hooks/refs */} diff --git a/src/components/graph/KnowledgeGraphView.tsx b/src/components/graph/KnowledgeGraphView.tsx index c883776c..a94f54fd 100644 --- a/src/components/graph/KnowledgeGraphView.tsx +++ b/src/components/graph/KnowledgeGraphView.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useCallback, useState, useMemo, useRef } from "react"; import { - GitBranch, Circle, RefreshCw, ChevronDown, LayoutGrid, Search, SlidersHorizontal, Type, + GitBranch, Circle, RefreshCw, ChevronDown, LayoutGrid, Search, SlidersHorizontal, Type, Network, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useCairnStore } from "@/store"; @@ -52,6 +52,7 @@ export function KnowledgeGraphView() { const [graphSearch, setGraphSearch] = useState(""); const [labelMode, setLabelMode] = useState<"smart" | "all" | "minimal">("smart"); const [spacing, setSpacing] = useState(1.2); + const [semanticThreshold, setSemanticThreshold] = useState(1.0); // ⌘F / Ctrl+F — focus the graph search input const graphSearchRef = useRef(null); @@ -310,6 +311,31 @@ export function KnowledgeGraphView() {
)} + {/* Semantic similarity threshold slider */} + {(graphLayout === "force" || graphLayout === "radial") && ( +
+ +
+ + Semantic +
+
+ setSemanticThreshold(parseFloat(e.target.value))} + className="w-16 h-1 rounded bg-[var(--border)] appearance-none cursor-pointer" + style={{ accentColor: "var(--accent)" }} + /> + + {semanticThreshold >= 1 ? "off" : `≥${semanticThreshold.toFixed(2)}`} + +
+ )} + {/* Search + type toggles — shown for force and radial */} {(graphLayout === "force" || graphLayout === "radial") && ( <> @@ -420,6 +446,7 @@ export function KnowledgeGraphView() { onBackgroundClick={handleBackgroundClick} labelMode={labelMode} spacing={spacing} + semanticThreshold={semanticThreshold} /> )} @@ -431,6 +458,7 @@ export function KnowledgeGraphView() { onBackgroundClick={handleBackgroundClick} labelMode={labelMode} spacing={spacing} + semanticThreshold={semanticThreshold} /> )} diff --git a/src/components/graph/RadialTreeCanvas.tsx b/src/components/graph/RadialTreeCanvas.tsx index cce8b8ea..9a03062a 100644 --- a/src/components/graph/RadialTreeCanvas.tsx +++ b/src/components/graph/RadialTreeCanvas.tsx @@ -4,7 +4,7 @@ import React, { useRef, useEffect, useCallback, useState } from "react"; import * as d3Hierarchy from "d3-hierarchy"; import * as d3Zoom from "d3-zoom"; import * as d3Selection from "d3-selection"; -import type { GraphNode, KnowledgeGraph } from "@/types"; +import type { GraphNode, GraphEdge, KnowledgeGraph } from "@/types"; import { resolveCssVar as resolveVar } from "./analyticsUtils"; import { useFontScale } from "./analyticsHooks"; @@ -15,6 +15,7 @@ interface Props { onBackgroundClick: () => void; labelMode: "smart" | "all" | "minimal"; spacing: number; + semanticThreshold?: number; } type HNode = { id: string; title: string; type: string; children?: HNode[] }; @@ -74,15 +75,18 @@ function crossEdgeColor(type: string): string { case "note-card": return resolveVar("--success"); case "flow-edge": return resolveVar("--accent"); case "flow-ref": return resolveVar("--accent"); + case "semantic": return resolveVar("--accent"); default: return resolveVar("--accent"); } } -export function RadialTreeCanvas({ graph, selectedNodeId, onNodeClick, onBackgroundClick, labelMode, spacing }: Props) { +export function RadialTreeCanvas({ graph, selectedNodeId, onNodeClick, onBackgroundClick, labelMode, spacing, semanticThreshold = 1 }: Props) { const svgRef = useRef(null); const gRef = useRef(null); const fs = useFontScale(); const [hoveredNodeId, setHoveredNodeId] = useState(null); + const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null); + const containerRef = useRef(null); const labelModeRef = useRef(labelMode); const selectedNodeIdRef = useRef(selectedNodeId); @@ -131,26 +135,89 @@ export function RadialTreeCanvas({ graph, selectedNodeId, onNodeClick, onBackgro return [r * Math.cos(angle - Math.PI / 2), r * Math.sin(angle - Math.PI / 2)]; } + function showEdgeTooltip(event: MouseEvent, edge: GraphEdge) { + const container = containerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const srcName = nodeTitleMap.get(edge.source) ?? edge.source; + const tgtName = nodeTitleMap.get(edge.target) ?? edge.target; + const left = edge.sourceSectionTitle ? `${srcName} › ${edge.sourceSectionTitle}` : srcName; + const right = edge.targetSectionTitle ? `${tgtName} › ${edge.targetSectionTitle}` : tgtName; + const pct = ((edge.weight ?? 1) * 100).toFixed(0); + setTooltip({ + x: event.clientX - rect.left + 12, + y: event.clientY - rect.top + 12, + text: `${left} ↔ ${right} · ${pct}%`, + }); + } + // Cross-edge chords — more visible, coloured by type const crossEdges = graph.edges.filter( (e) => !["project-member", "tag-member"].includes(e.type) + && (e.type !== "semantic" || (e.weight ?? 1) >= semanticThreshold) ); - for (const edge of crossEdges.slice(0, 120)) { + + // Prioritise semantic and wikilink edges so they render on top + crossEdges.sort((a, b) => { + const rank = (t: string) => t === "semantic" ? 0 : t === "wikilink" ? 1 : 2; + return rank(a.type) - rank(b.type); + }); + + const nodeTitleMap = new Map(graph.nodes.map((n) => [n.id, n.title] as const)); + + // Build set of connected node IDs for highlight/dim + const selId = selectedNodeId; + const connectedIds = selId ? new Set() : null; + if (connectedIds && selId) { + connectedIds.add(selId); + for (const e of crossEdges) { + if (e.source === selId) connectedIds.add(e.target); + if (e.target === selId) connectedIds.add(e.source); + } + } + + for (const edge of crossEdges) { const sn = treeNodeMap.get(edge.source) as d3Hierarchy.HierarchyPointNode | undefined; const tn = treeNodeMap.get(edge.target) as d3Hierarchy.HierarchyPointNode | undefined; if (!sn || !tn) continue; const [x1, y1] = polar2cart(sn.x, sn.y); const [x2, y2] = polar2cart(tn.x, tn.y); const color = crossEdgeColor(edge.type); - const isAuto = ["co-mention", "keyword", "assignee"].includes(edge.type); + const isConnected = selectedNodeId && (edge.source === selectedNodeId || edge.target === selectedNodeId); + const isSemantic = edge.type === "semantic"; + let opacity = isSemantic ? 0.5 : ["co-mention", "keyword", "assignee"].includes(edge.type) ? 0.1 : 0.2; + let width = isSemantic ? 1.2 : 0.75; + if (selectedNodeId) { + if (isConnected) { + opacity = 0.85; + width = 2; + } else { + opacity = opacity * 0.12; + } + } gSel.append("line") .attr("x1", x1).attr("y1", y1) .attr("x2", x2).attr("y2", y2) .attr("stroke", color) - .attr("stroke-width", 0.75) - .attr("stroke-opacity", isAuto ? 0.1 : 0.2) - .attr("stroke-dasharray", "3,4"); + .attr("stroke-width", width) + .attr("stroke-opacity", opacity) + .attr("stroke-dasharray", "3,4") + .style("cursor", edge.type === "semantic" ? "pointer" : "default") + .on("mouseenter", function(event: MouseEvent) { + if (edge.type !== "semantic") return; + d3Selection.select(this).attr("stroke-opacity", 1).attr("stroke-width", 3); + showEdgeTooltip(event, edge); + }) + .on("mousemove", function(event: MouseEvent) { + if (edge.type !== "semantic") return; + showEdgeTooltip(event, edge); + }) + .on("mouseleave", function() { + if (edge.type !== "semantic") return; + d3Selection.select(this).attr("stroke-opacity", opacity).attr("stroke-width", width); + setTooltip(null); + }); } // Radial link path @@ -169,7 +236,7 @@ export function RadialTreeCanvas({ graph, selectedNodeId, onNodeClick, onBackgro .attr("class", "link") .attr("fill", "none") .attr("stroke", resolveVar("--border")) - .attr("stroke-opacity", 0.5) + .attr("stroke-opacity", selectedNodeId ? 0.1 : 0.5) .attr("stroke-width", 1) .attr("d", (d) => radialLinkPath(d as d3Hierarchy.HierarchyPointLink)); @@ -195,6 +262,7 @@ export function RadialTreeCanvas({ graph, selectedNodeId, onNodeClick, onBackgro const nodeId = (d.data as { id: string }).id; const baseId = nodeId.includes(":") ? nodeId.split(":")[0] : nodeId; setHoveredNodeId(baseId); + setTooltip(null); }) .on("mouseleave", () => { setHoveredNodeId(null); @@ -206,6 +274,7 @@ export function RadialTreeCanvas({ graph, selectedNodeId, onNodeClick, onBackgro const nodeId = (d.data as { id: string }).id; const baseId = nodeId.includes(":") ? nodeId.split(":")[0] : nodeId; const isSelected = baseId === selectedNodeId; + const isDimmed = !!selectedNodeId && !connectedIds?.has(baseId); const color = colorForType(type); const sel = d3Selection.select(this); @@ -246,7 +315,7 @@ export function RadialTreeCanvas({ graph, selectedNodeId, onNodeClick, onBackgro sel.append("circle") .attr("r", radius) - .attr("fill", (isSelected || isHovered) ? color : color + "aa") + .attr("fill", (isSelected || isHovered) ? color : color + (isDimmed ? "30" : "aa")) .attr("stroke", (isSelected || isHovered) ? color : "none") .attr("stroke-width", (isSelected || isHovered) ? 2 : 0); @@ -257,6 +326,17 @@ export function RadialTreeCanvas({ graph, selectedNodeId, onNodeClick, onBackgro } }); + // Bring hovered/selected nodes to front (painted last = on top) + nodeGroups.each(function(d) { + const nodeId = (d.data as { id: string }).id; + const baseId = nodeId.includes(":") ? nodeId.split(":")[0] : nodeId; + if (baseId === selectedNodeId || baseId === hoveredNodeId) { + const el = this as SVGGElement; + const parent = el.parentNode; + if (parent) parent.appendChild(el); + } + }); + // Labels nodeGroups.append("text") .attr("dy", "0.31em") @@ -338,11 +418,14 @@ export function RadialTreeCanvas({ graph, selectedNodeId, onNodeClick, onBackgro .text((d) => { const title = (d.data as { title: string }).title; const type = (d.data as { type: string }).type; - const maxLen = type === "project" ? 28 : 22; + const nodeId = (d.data as { id: string }).id; + const baseId = nodeId.includes(":") ? nodeId.split(":")[0] : nodeId; + const isHighlight = baseId === selectedNodeId || baseId === hoveredNodeId; + const maxLen = isHighlight ? 60 : type === "project" ? 28 : 22; return title.length > maxLen ? title.slice(0, maxLen - 1) + "…" : title; }); - }, [graph, selectedNodeId, hoveredNodeId, labelMode, spacing, onNodeClick, fs]); + }, [graph, selectedNodeId, hoveredNodeId, labelMode, spacing, onNodeClick, fs, semanticThreshold]); useEffect(() => { renderTree(); }, [renderTree]); @@ -383,7 +466,7 @@ export function RadialTreeCanvas({ graph, selectedNodeId, onNodeClick, onBackgro const svgSel = d3Selection.select(svg); svgSel.call(zoom); svgSel.call(zoom.transform, d3Zoom.zoomIdentity.translate(width / 2, height / 2)); - svgSel.on("click.bg", onBackgroundClick); + svgSel.on("click.bg", () => { onBackgroundClick(); setTooltip(null); }); return () => { svgSel.on(".zoom", null); @@ -400,8 +483,18 @@ export function RadialTreeCanvas({ graph, selectedNodeId, onNodeClick, onBackgro }, [renderTree]); return ( - - - +
setTooltip(null)}> + + + + {tooltip && ( +
+ {tooltip.text} +
+ )} +
); } diff --git a/src/components/notes/BacklinksPanel.tsx b/src/components/notes/BacklinksPanel.tsx index 03d53608..5258b230 100644 --- a/src/components/notes/BacklinksPanel.tsx +++ b/src/components/notes/BacklinksPanel.tsx @@ -1,19 +1,40 @@ "use client"; import { useState, useMemo, useEffect, useRef } from "react"; -import { Link2, ChevronDown, FileText, Kanban, X, Tag as TagIcon, Plus } from "lucide-react"; +import { Link2, ChevronDown, FileText, Kanban, X, Tag as TagIcon, Plus, Sparkles } from "lucide-react"; import { useCairnStore } from "@/store"; import { useShallow } from "zustand/react/shallow"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; +import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import type { Note, Tag } from "@/types"; +interface SemanticHit { + noteId: string; + title: string; + score: number; + sectionTitle: string; +} + export interface BacklinksPanelProps { note: Note; onOpenCard: () => void; + semanticEnabled?: boolean; + semanticContent?: string; + activeSectionTitle?: string | null; + activeSectionText?: string | null; + workspaceId?: string | null; } -export function BacklinksPanel({ note, onOpenCard }: BacklinksPanelProps) { +export function BacklinksPanel({ + note, + onOpenCard, + semanticEnabled = false, + semanticContent, + activeSectionTitle, + activeSectionText, + workspaceId, +}: BacklinksPanelProps) { const { notes, cards, columns } = useCairnStore(useShallow((s) => ({ notes: s.notes, cards: s.cards, @@ -21,6 +42,71 @@ export function BacklinksPanel({ note, onOpenCard }: BacklinksPanelProps) { }))); const [open, setOpen] = useState(false); + const debouncedSemantic = useDebouncedValue(semanticContent ?? "", 1200); + const debouncedSectionText = useDebouncedValue(activeSectionText ?? "", 1200); + + type SearchState = { kind: "loading" } | { kind: "results"; hits: SemanticHit[] }; + const [search, setSearch] = useState(null); + const [sectionSearch, setSectionSearch] = useState(null); + const trimmedSemanticLength = (semanticContent ?? "").trim().length; + const canSearch = semanticEnabled && !!workspaceId && trimmedSemanticLength >= 4; + const canSectionSearch = semanticEnabled && !!workspaceId && !!activeSectionTitle && debouncedSectionText.trim().length >= 20; + + useEffect(() => { + const trimmed = debouncedSemantic.trim(); + if (!canSearch || trimmed.length < 4) { + return; + } + let cancelled = false; + void (async () => { + const api = window.electron?.embeddings; + if (!api) return; + setSearch({ kind: "loading" }); + try { + const hits = await api.search(workspaceId!, trimmed, { + queryNoteId: note.id, + k: 5, + }); + if (!cancelled) setSearch({ kind: "results", hits }); + } catch { + if (!cancelled) setSearch({ kind: "results", hits: [] }); + } + })(); + return () => { + cancelled = true; + setSearch(null); + }; + }, [canSearch, debouncedSemantic, note.id, workspaceId]); + + useEffect(() => { + if (!canSectionSearch) return; + let cancelled = false; + void (async () => { + const api = window.electron?.embeddings; + if (!api) return; + setSectionSearch({ kind: "loading" }); + try { + const hits = await api.search(workspaceId!, debouncedSectionText.trim(), { + queryNoteId: note.id, + k: 3, + }); + if (!cancelled) setSectionSearch({ kind: "results", hits }); + } catch { + if (!cancelled) setSectionSearch({ kind: "results", hits: [] }); + } + })(); + return () => { + cancelled = true; + setSectionSearch(null); + }; + }, [canSectionSearch, debouncedSectionText, note.id, workspaceId]); + + const sectionHits = canSectionSearch && sectionSearch?.kind === "results" ? sectionSearch.hits : []; + 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"; + const linkedNotes = useMemo( () => (note.linkedNoteIds ?? []).map((id) => notes.find((n) => n.id === id)).filter(Boolean) as Note[], [note.linkedNoteIds, notes], @@ -46,8 +132,25 @@ export function BacklinksPanel({ note, onOpenCard }: BacklinksPanelProps) { }); }, [note.id, note.title, note.linkedNoteIds, notes]); - const total = linkedNotes.length + linkedCards.length + wikilinkBacklinks.length; - if (total === 0) return null; + const semanticCount = semanticEnabled ? semanticHits.length + sectionHits.length : 0; + const total = linkedNotes.length + linkedCards.length + wikilinkBacklinks.length + semanticCount; + const hasSemanticActivity = semanticCount > 0 || semanticLoading || sectionLoading; + const autoOpenedRef = useRef(false); + useEffect(() => { + if (!hasSemanticActivity) { + autoOpenedRef.current = false; + return; + } + if (!autoOpenedRef.current) { + autoOpenedRef.current = true; + setOpen(true); + } + }, [hasSemanticActivity]); + const noteId = note.id; + useEffect(() => { + autoOpenedRef.current = false; + }, [noteId]); + if (total === 0 && !semanticLoading && !semanticEnabled) return null; return (
@@ -55,12 +158,86 @@ export function BacklinksPanel({ note, onOpenCard }: BacklinksPanelProps) { onClick={() => setOpen((o) => !o)} className="w-full flex items-center gap-2 px-6 py-2.5 text-xs text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:bg-[var(--surface-2)] transition-colors" > - - {total} backlink{total !== 1 ? "s" : ""} + {semanticCount > 0 ? : } + + {semanticCount > 0 + ? `${semanticCount} similar + ${total - semanticCount} backlink${(total - semanticCount) !== 1 ? "s" : ""}` + : `${total} backlink${total !== 1 ? "s" : ""}`} + + {semanticLoading && …} {open && (
+ {semanticEnabled && (semanticLoading || semanticHits.length > 0 || (semanticHits.length === 0 && linkedNotes.length === 0 && linkedCards.length === 0 && wikilinkBacklinks.length === 0)) && ( + <> +
+ + Semantic + {semanticLoading && searching…} +
+ {!semanticLoading && semanticHits.length === 0 && ( +
+ No similar notes found. Reindex from Settings → Embeddings to refresh. +
+ )} + {semanticHits.map((hit) => ( + + ))} + {activeSectionTitle && (sectionLoading || sectionHits.length > 0) && ( + <> +
+ Related to “{activeSectionTitle}” + {sectionLoading && …} +
+ {sectionHits.map((hit) => ( + + ))} + + )} + {(linkedNotes.length > 0 || linkedCards.length > 0 || wikilinkBacklinks.length > 0) && ( +
+ )} + + )} {linkedNotes.map((n) => ( + + + {wordCount.toLocaleString()} {wordCount === 1 ? "word" : "words"} · {Math.max(1, Math.ceil(wordCount / 200))} min read @@ -838,7 +869,8 @@ export function NoteEditor({ note, onBack }: NoteEditorProps) { /> )} - {/* ── Editor / Preview ────────────────────────────────────────────────── */} + {/* ── Editor / Preview + Semantic Hubs panel ───────────────────────────── */} +
{/* AI write lock banner */} {isAiWriting && ( @@ -864,6 +896,7 @@ export function NoteEditor({ note, onBack }: NoteEditorProps) { initialValue={note.content ?? ""} onChange={handleContentChange} onSelectionChange={handleSelectionChange} + onCursorActivity={handleCursorActivity} placeholder="Write here…" readOnly={isAiWriting} /> @@ -915,8 +948,18 @@ export function NoteEditor({ note, onBack }: NoteEditorProps) { )}
+
+ {/* ── Backlinks panel ─────────────────────────────────────────────────── */} - setView("board")} /> + setView("board")} + semanticEnabled={showSemanticPanel} + semanticContent={semanticContent} + activeSectionTitle={showSemanticPanel ? activeSectionTitle : null} + activeSectionText={showSemanticPanel ? activeSectionText : null} + workspaceId={activeWorkspaceId} + /> diff --git a/src/components/notes/toc-utils.ts b/src/components/notes/toc-utils.ts index fa735b96..61afb8ad 100644 --- a/src/components/notes/toc-utils.ts +++ b/src/components/notes/toc-utils.ts @@ -46,6 +46,72 @@ export function extractHeadings(markdown: string): Heading[] { return headings; } +/** + * Given raw markdown and a character offset, return the title of the + * `#`/`##` section the cursor is inside. Matches the splitting logic + * used by the embeddings service (only `#`/`##` are section boundaries). + * If cursor is before the first heading, returns `noteTitle`. + */ +export function findSectionTitleAtOffset( + noteTitle: string, + content: string, + offset: number, +): string { + const lines = content.split("\n"); + let pos = 0; + let currentTitle = noteTitle || "Untitled"; + for (const line of lines) { + const lineEnd = pos + line.length + 1; + const m = line.match(/^(#{1,2})\s+(.+)$/); + if (m) { + currentTitle = m[2].trim(); + } + if (offset < lineEnd) break; + pos = lineEnd; + } + return currentTitle; +} + +/** + * Extract the text content of the `#`/`##` section at the given offset. + * Returns the body text (excluding the heading line itself) plus the + * note title as context prefix. + */ +export function extractSectionTextAtOffset( + noteTitle: string, + content: string, + offset: number, +): { title: string; text: string } | null { + const lines = content.split("\n"); + let pos = 0; + let sectionStart = -1; + let sectionTitle = noteTitle || "Untitled"; + for (let i = 0; i < lines.length; i++) { + const lineEnd = pos + lines[i].length + 1; + const m = lines[i].match(/^(#{1,2})\s+(.+)$/); + if (m) { + sectionTitle = m[2].trim(); + } + if (offset < lineEnd) { + sectionStart = i; + break; + } + pos = lineEnd; + } + if (sectionStart === -1) return null; + + const body: string[] = []; + for (let i = sectionStart; i < lines.length; i++) { + const m = lines[i].match(/^(#{1,2})\s+(.+)$/); + if (i > sectionStart && m) break; + if (m) continue; + body.push(lines[i]); + } + const text = body.join("\n").trim(); + if (text.length < 4) return null; + return { title: sectionTitle, text }; +} + // ── Wikilink extraction ─────────────────────────────────────────────────────── export interface WikiLink { diff --git a/src/components/settings/EmbeddingsSettings.tsx b/src/components/settings/EmbeddingsSettings.tsx new file mode 100644 index 00000000..3ffa51ee --- /dev/null +++ b/src/components/settings/EmbeddingsSettings.tsx @@ -0,0 +1,447 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { + CheckCircle, RefreshCw, Trash2, Star, Cpu, Activity, Download, Power, +} from "lucide-react"; +import { Tooltip } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { SettingsGroup, SettingsRow, Toggle } from "./shared"; +import { useCairnStore } from "@/store"; + +interface EmbeddingsConfig { + enabled: boolean; + modelId: string; +} + +interface EmbeddingsStatus { + running: boolean; + port: number | null; + activeModelId: string | null; + defaultModelId: string | null; + installed: boolean; + error: string | null; + reindexInProgress: boolean; + recomputeInProgress: boolean; + lastReindexDone: number; + lastReindexTotal: number; + lastRecomputeDone: number; + lastRecomputeTotal: number; +} + +interface ModelEntry { + id: string; + name: string; + repo: string; + dim: number; + maxTokens: number; + sizeBytes: number; + status: "not_downloaded" | "downloading" | "installed" | "error"; + downloadProgress: number; + downloadSpeed?: string; + error?: string; +} + +interface ProgressEvent { + modelId: string; + status: string; + file?: string; + progress?: number; + loaded?: number; + total?: number; + error?: string; +} + +const DEFAULT_CONFIG: EmbeddingsConfig = { enabled: false, modelId: "nomic-ai/nomic-embed-text-v1.5" }; + +export function EmbeddingsSettings() { + const [config, setConfig] = useState(DEFAULT_CONFIG); + const [models, setModels] = useState([]); + const [status, setStatus] = useState({ + running: false, + port: null, + activeModelId: null, + defaultModelId: null, + installed: false, + error: null, + reindexInProgress: false, + recomputeInProgress: false, + lastReindexDone: 0, + lastReindexTotal: 0, + lastRecomputeDone: 0, + lastRecomputeTotal: 0, + }); + const [progressByModel, setProgressByModel] = useState>({}); + const [reindexing, setReindexing] = useState(false); + const [reindexResult, setReindexResult] = useState(""); + const [reindexProgress, setReindexProgress] = useState<{ done: number; total: number } | null>(null); + const [startingWorker, setStartingWorker] = useState(false); + const activeWorkspaceId = useCairnStore((s) => s.activeWorkspaceId); + + const refreshQuiet = useCallback(async () => { + if (!window.electron?.embeddings) return; + try { + const [s, m] = await Promise.all([ + window.electron.embeddings.status(), + window.electron.embeddings.models.list(), + ]); + setStatus(s); + const active = s.reindexInProgress || s.recomputeInProgress; + setReindexing(active); + if (s.reindexInProgress) { + setReindexProgress({ done: s.lastReindexDone, total: s.lastReindexTotal }); + } else if (s.recomputeInProgress) { + setReindexProgress({ done: s.lastRecomputeDone, total: s.lastRecomputeTotal }); + } else { + setReindexProgress(null); + } + setModels(m); + const defaultId = s.defaultModelId ?? m[0]?.id; + if (defaultId && (!config.modelId || !m.some((x) => x.id === config.modelId))) { + setConfig((c) => ({ ...c, modelId: defaultId })); + } + } catch { + // ignore — IPC not ready in dev + } + }, [config.modelId]); + + useEffect(() => { + void (async () => { + const e = window.electron; + if (!e?.embeddings) return; + const settings = await e.embeddings.getSettings(); + if (settings) { + setConfig({ + enabled: settings.enabled ?? false, + modelId: settings.modelId ?? DEFAULT_CONFIG.modelId, + }); + } + await refreshQuiet(); + })(); + + const off = window.electron?.embeddings?.models.onProgress((ev) => { + if (!ev.modelId) { + if (ev.status === "progress" && typeof ev.loaded === "number" && typeof ev.total === "number") { + setReindexProgress({ done: ev.loaded, total: ev.total }); + } else if (ev.status === "duplicate") { + setReindexResult(`Blocked: ${ev.error ?? "already in progress"}`); + } else if (ev.status === "installed" || ev.status === "ready") { + setReindexProgress(null); + void refreshQuiet(); + } + return; + } + setProgressByModel((prev) => ({ + ...prev, + [ev.modelId]: ev, + })); + if (ev.status === "installed" || ev.status === "ready") { + void refreshQuiet(); + } + }); + return () => { + off?.(); + }; + }, [refreshQuiet]); + + const handleInstall = async (modelId: string) => { + const e = window.electron?.embeddings; + if (!e) return; + setProgressByModel((p) => ({ ...p, [modelId]: { modelId, status: "downloading", progress: 0 } })); + try { + await e.models.install(modelId); + } catch (err) { + console.error("[embeddings] install failed:", err); + setProgressByModel((prev) => { + const { [modelId]: _discard, ...rest } = prev; + return rest; + }); + } finally { + await refreshQuiet(); + } + }; + + const handleRemove = async (modelId: string) => { + const e = window.electron?.embeddings; + if (!e) return; + try { + await e.models.remove(modelId); + await refreshQuiet(); + } catch (err) { + console.error("[embeddings] remove failed:", err); + } + }; + + 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); + } + }; + + const handleToggleEnabled = async (enabled: boolean) => { + const e = window.electron?.embeddings; + if (!e) return; + const next = { ...config, enabled }; + setConfig(next); + await e.saveSettings(next); + if (enabled && !status.running) { + setStartingWorker(false); + } + }; + + const handleStop = async () => { + const e = window.electron?.embeddings; + if (!e) return; + try { + await e.stop(); + await refreshQuiet(); + } catch (err) { + console.error("[embeddings] stop failed:", err); + } + }; + + const handleReindexAll = async () => { + const e = window.electron?.embeddings; + if (!e || !activeWorkspaceId) return; + setReindexing(true); + setReindexResult(""); + setReindexProgress(null); + try { + const res = await e.reindex(activeWorkspaceId, undefined, config.modelId); + setReindexResult(`Indexed ${res.indexed}, skipped ${res.skipped} (total ${res.total})`); + } catch (err) { + setReindexResult(`Error: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setReindexing(false); + setReindexProgress(null); + } + }; + + const handleRecomputeProjections = async () => { + const e = window.electron?.embeddings; + if (!e || !activeWorkspaceId) return; + setReindexing(true); + setReindexResult(""); + setReindexProgress(null); + try { + await e.recomputeProjections(activeWorkspaceId, config.modelId); + setReindexResult("Projections updated."); + } catch (err) { + setReindexResult(`Error: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setReindexing(false); + setReindexProgress(null); + } + }; + + const fmtSize = (bytes: number) => (bytes / 1024 / 1024).toFixed(0) + " MB"; + + return ( + + + + + + {config.enabled && ( +
+
+
+
+ +
+
Worker
+
+ {status.running + ? `Running on 127.0.0.1:${status.port} · model: ${status.activeModelId ?? "—"}` + : status.installed + ? "Installed · idle (will start on first request)" + : "Not installed"} + {status.error ? ` · ${status.error}` : ""} +
+
+
+
+ {status.running ? ( + + + + ) : ( + + + + )} +
+
+
+ +
+
Models
+ {models.map((model) => { + const prog = progressByModel[model.id]; + const isDownloading = model.status === "downloading" || (prog?.status === "downloading" && (prog?.progress ?? 0) < 100); + const isInstalled = model.status === "installed"; + const isActive = status.running && status.activeModelId === model.id; + const isDefault = status.defaultModelId === model.id; + + return ( +
+
+
+
{model.name}
+
+ {model.repo} · {model.dim}d · {fmtSize(model.sizeBytes)} + {isDefault && · default} +
+
+
+ {isInstalled ? ( + <> + {isActive ? ( + + running + + ) : ( + + )} + {!isDefault && ( + + + + )} + {!isActive && ( + + + + )} + + ) : isDownloading ? ( + + ) : ( + + )} +
+
+ + {isDownloading && ( +
+
+ {prog?.file ?? "downloading model files…"} + {Math.round(prog?.progress ?? 0)}% +
+
+
+
+
+ )} + + {model.status === "error" && model.error && ( +
{model.error}
+ )} +
+ ); + })} +
+ +
+
Maintenance
+
+ + +
+

+ Reindexing embeds every note and stores it for semantic search. Recompute clusters only runs UMAP on existing embeddings — cheap and fast, no model calls. +

+ {reindexProgress && ( +
+
+ Indexing notes… + + {reindexProgress.done}/{reindexProgress.total} + {reindexProgress.total > 0 + ? ` · ${Math.round((reindexProgress.done / reindexProgress.total) * 100)}%` + : ""} + +
+
+
0 + ? Math.round((reindexProgress.done / reindexProgress.total) * 100) + : 0}%`, + }} + /> +
+
+ )} + {reindexResult && ( +
{reindexResult}
+ )} +
+
+ )} + + ); +} diff --git a/src/components/settings/settings-view.tsx b/src/components/settings/settings-view.tsx index ee0bf849..4c9d95c5 100644 --- a/src/components/settings/settings-view.tsx +++ b/src/components/settings/settings-view.tsx @@ -10,6 +10,7 @@ import { Tag, Terminal, Smartphone, + Network, } from "lucide-react"; import { useCairnStore } from "@/store"; import { useShallow } from "zustand/react/shallow"; @@ -22,8 +23,9 @@ import { DataSettings } from "./DataSettings"; import { AboutSection } from "./AboutSection"; import { AgentSettings } from "./AgentSettings"; import { MobileSettings } from "./MobileSettings"; +import { EmbeddingsSettings } from "./EmbeddingsSettings"; -type SettingsSection = "general" | "ai" | "agents" | "mobile" | "data" | "about" | "shortcuts" | "tags"; +type SettingsSection = "general" | "ai" | "embeddings" | "agents" | "mobile" | "data" | "about" | "shortcuts" | "tags"; export function SettingsView() { const [section, setSection] = useState("general"); @@ -36,6 +38,7 @@ export function SettingsView() { {[ { id: "general" as const, label: "General", icon: Settings }, { id: "ai" as const, label: "AI & Chat", icon: Bot }, + { id: "embeddings" as const, label: "Embeddings", icon: Network }, { id: "agents" as const, label: "Coding Agents", icon: Terminal }, { id: "mobile" as const, label: "Mobile Access", icon: Smartphone }, { id: "tags" as const, label: "Tags", icon: Tag }, @@ -72,6 +75,7 @@ export function SettingsView() {
{section === "general" && } {section === "ai" && } + {section === "embeddings" && } {section === "agents" && } {section === "mobile" && } {section === "tags" && } diff --git a/src/hooks/useDebouncedValue.ts b/src/hooks/useDebouncedValue.ts new file mode 100644 index 00000000..e4c0d0ff --- /dev/null +++ b/src/hooks/useDebouncedValue.ts @@ -0,0 +1,24 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +export function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + const timerRef = useRef | null>(null); + + useEffect(() => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + setDebounced(value); + timerRef.current = null; + }, delayMs); + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [value, delayMs]); + + return debounced; +} diff --git a/src/store/slices/graph.ts b/src/store/slices/graph.ts index 320b8605..b1a9ed93 100644 --- a/src/store/slices/graph.ts +++ b/src/store/slices/graph.ts @@ -43,6 +43,7 @@ export const DEFAULT_GRAPH_FILTERS: GraphFilters = { edgeTypes: [ "note-note", "note-card", "tag-member", "project-member", "flow-ref", "flow-edge", "co-mention", "keyword", "assignee", "wikilink", + "semantic", ], includeAuto: true, }; @@ -158,5 +159,6 @@ export function edgeTypeLabel(type: GraphEdgeType): string { case "keyword": return "Keyword"; case "assignee": return "Assignee"; case "wikilink": return "Wikilink"; + case "semantic": return "Semantic"; } } diff --git a/src/types/index.ts b/src/types/index.ts index 9795b0de..4cdcd24f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -276,7 +276,7 @@ export type GraphNodeType = "project" | "note" | "card" | "tag"; export type GraphEdgeType = | "note-note" | "note-card" | "tag-member" | "project-member" | "flow-ref" | "flow-edge" | "co-mention" | "keyword" | "assignee" - | "wikilink"; + | "wikilink" | "semantic"; export interface GraphNode { id: ID; @@ -303,6 +303,8 @@ export interface GraphEdge { type: GraphEdgeType; label?: string; weight?: number; + sourceSectionTitle?: string; + targetSectionTitle?: string; } export interface KnowledgeGraph {