From dbd18a8c19d85dbcc468673237536bcdfc5adf9a Mon Sep 17 00:00:00 2001 From: Sam Keen Date: Sat, 10 Oct 2026 10:33:09 -0700 Subject: [PATCH 1/2] Update vulnerable frontend transitive dependencies Update source-map-js to 1.2.2 and undici to 7.30.0 within existing dependency ranges to clear both high-severity npm audit findings. Verified with make ci. --- ui/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 04e1612..7846b4f 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -2107,9 +2107,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -2226,9 +2226,9 @@ } }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", "dev": true, "license": "MIT", "engines": { From e8351a234f96bfbcb0734605598481e16dbf9594 Mon Sep 17 00:00:00 2001 From: Sam Keen Date: Sat, 10 Oct 2026 10:47:32 -0700 Subject: [PATCH 2/2] Reconcile vault changes on startup and preserve queued indexing Scan disk on every vault open instead of treating cached embedding coverage as evidence that the vault is current. Preserve delayed saves and filesystem changes across active index runs, while Cancel suppresses earlier embed requests even when reconciliation is already in progress. Restore centroids from cached vectors during projection so copied notes and editor undo remain discoverable without loading the model. Document the existing save and embedding delays. Verify with scheduling regressions, disk-backed projection tests, and make ci. --- crates/b2-core/src/ingest.rs | 13 +- crates/b2-core/tests/project_embed.rs | 130 ++++++++++++ docs/index-engine.md | 10 + docs/quickstart.md | 11 +- ui/src/indexing.test.ts | 293 ++++++++++++++++++++++++++ ui/src/main.ts | 95 +++++---- ui/src/reconcile.test.ts | 6 +- ui/src/reconcile.ts | 7 +- 8 files changed, 514 insertions(+), 51 deletions(-) create mode 100644 ui/src/indexing.test.ts diff --git a/crates/b2-core/src/ingest.rs b/crates/b2-core/src/ingest.rs index 0857844..8dcdbc2 100644 --- a/crates/b2-core/src/ingest.rs +++ b/crates/b2-core/src/ingest.rs @@ -409,7 +409,13 @@ pub fn ingest_vault(conn: &Connection, vault_root: &Path, embedder: &dyn Embedde /// DB-derived pending set for a later embed pass, so saving needs no embedder. pub fn project_file(ctx: ProjectionCtx, rel_path: &str) -> Result<()> { let p = project_note_and_chunks(ctx, rel_path, Rechunk::IfBodyChanged)?; - project_edges(ctx.conn, rel_path, &p.body, &p.relations) + project_edges(ctx.conn, rel_path, &p.body, &p.relations)?; + // A saved body may reuse cached vectors (e.g. undo). Complete coverage lets the + // desktop skip embedding, so restore its centroid here, with no model call (S3). + if db::embedding_space_exists(ctx.conn)? && db::note_fully_embedded(ctx.conn, rel_path)? { + db::refresh_note_centroid(ctx.conn, rel_path)?; + } + Ok(()) } /// A vault file the projection pass could not read and skipped. `reason` describes the @@ -497,6 +503,11 @@ pub fn project_vault(ctx: ProjectionCtx, force: bool) -> Result // unreferenced ones now the chunk set is final. if db::embedding_space_exists(conn)? { db::prune_orphan_vectors(conn)?; + // Copies and external renames can reuse every vector yet have no centroid. + // Coverage is already complete, so no later automatic embed is guaranteed. + for path in db::notes_missing_centroids(conn)? { + db::refresh_note_centroid(conn, &path)?; + } } // Resources before phase 2, which resolves `![[img.png]]` against them (spec §3). diff --git a/crates/b2-core/tests/project_embed.rs b/crates/b2-core/tests/project_embed.rs index 6ab8ae4..1ee2a46 100644 --- a/crates/b2-core/tests/project_embed.rs +++ b/crates/b2-core/tests/project_embed.rs @@ -411,6 +411,136 @@ fn embed_status_reports_the_coverage_fraction() { ); } +#[test] +fn reopening_and_projecting_reconciles_offline_delta_without_reembedding_unchanged_notes() { + let tmp = tempfile::TempDir::new().unwrap(); + let root = tmp.path().join("vault"); + golden_vault_copy(&root); + write_note(&root, "deleted.md", "A note removed while B2 is closed."); + let vault = Vault::open(&root).unwrap(); + vault.reindex().unwrap(); + drop(vault); + + fs::write( + root.join("concepts/memory.md"), + "Offline revision: axolotl.\n", + ) + .unwrap(); + write_note(&root, "new.md", "Offline addition: narwhal."); + fs::remove_file(root.join("deleted.md")).unwrap(); + + let vault = Vault::open(&root).unwrap(); + let stale = vault.embed_status().unwrap(); + assert_eq!( + (stale.embedded, stale.total), + (3, 3), + "coverage cannot detect disk edits" + ); + let projected = vault.project(false).unwrap(); + assert_eq!((projected.indexed, projected.notes_pruned), (3, 1)); + assert!(vault + .search("axolotl", 5) + .unwrap() + .iter() + .any(|hit| hit.path == "concepts/memory.md" && hit.snippet.contains("axolotl"))); + assert!(vault + .search("narwhal", 5) + .unwrap() + .iter() + .any(|hit| hit.path == "new.md" && hit.snippet.contains("narwhal"))); + assert!(vault + .list_notes() + .unwrap() + .iter() + .all(|n| n.path != "deleted.md")); + let pending = vault.embed_status().unwrap(); + assert_eq!((pending.embedded, pending.total), (1, 3)); + + let embedded = vault.embed(&mut |_| ControlFlow::Continue(())).unwrap(); + assert_eq!( + embedded.embedded, 2, + "only the edited and added note need vectors" + ); + vault.project(false).unwrap(); + assert_eq!( + vault + .embed(&mut |_| ControlFlow::Continue(())) + .unwrap() + .embedded, + 0 + ); + assert_eq!( + fs::read_to_string(root.join("concepts/memory.md")).unwrap(), + "Offline revision: axolotl.\n" + ); +} + +#[test] +fn projection_restores_discovery_for_copied_notes_with_cached_vectors() { + let tmp = tempfile::TempDir::new().unwrap(); + let (vault, root) = opened_vault(tmp.path()); + vault.reindex().unwrap(); + fs::copy(root.join("concepts/memory.md"), root.join("copy.md")).unwrap(); + + vault.project(false).unwrap(); + let status = vault.embed_status().unwrap(); + assert_eq!( + (status.embedded, status.total), + (3, 3), + "all vectors are reusable" + ); + let conn = index_conn(&root); + assert_eq!( + count(&conn, "note_centroids"), + 3, + "complete coverage must also be discoverable" + ); + assert!(vault + .similar("notes/spaced-repetition.md", 10) + .unwrap() + .iter() + .any(|n| n.path == "copy.md")); +} + +#[test] +fn editor_undo_restores_the_centroid_from_cached_vectors_without_embedding() { + let tmp = tempfile::TempDir::new().unwrap(); + let (vault, root) = opened_vault(tmp.path()); + vault.reindex().unwrap(); + let conn = index_conn(&root); + let original = vault.read("concepts/memory.md").unwrap(); + let centroid = || -> Vec { + conn.query_row( + "SELECT centroid FROM note_centroids WHERE note_path = 'concepts/memory.md'", + [], + |row| row.get(0), + ) + .unwrap() + }; + let before = centroid(); + vault + .write( + "concepts/memory.md", + "Different body awaiting vectors.\n", + &original.revision, + ) + .unwrap(); + assert_eq!( + count(&conn, "note_centroids"), + 1, + "new text needs embedding" + ); + let edited = vault.read("concepts/memory.md").unwrap(); + vault + .write("concepts/memory.md", &original.body, &edited.revision) + .unwrap(); + assert_eq!( + centroid(), + before, + "undo restores the same summary without running the embedder" + ); +} + /// A note with no body has no chunks, so it counts as embedded and forecasts no work. /// Otherwise the fraction never reaches M/M, and every surface keyed on /// `embedded < total` (caveats, auto-embed passes) stays stuck. diff --git a/docs/index-engine.md b/docs/index-engine.md index dd674c8..5d45cd0 100644 --- a/docs/index-engine.md +++ b/docs/index-engine.md @@ -140,6 +140,16 @@ keyword-searchable (search degrades to BM25-only). The desktop uses exactly this paint the tree, embed in the background. S2 is untouched: a projected-but-unembedded index is a smaller projection, never a wrong one. +The desktop projects on every vault open, including startup: stored embedding coverage +cannot reveal files added, edited or deleted while the app was closed. After projection, +coverage gates the automatic embed pass. While running, filesystem pulses and saves are +debounced; requests arriving during an index run remain pending for a trailing pass, +because an embed operates on the pending chunks it read at its start. + +Projection also restores missing centroids for notes whose vectors are already cached +(copies, renames or an editor undo), without a model call. Complete vector coverage must +not leave those notes waiting for an embed pass just to become discoverable. + **The incremental skip: embed only what changed.** A full re-embed is the one genuinely slow step (a real transformer, on CPU), so a routine reindex must not redo finished work. Per note: if the body is unchanged and its chunks already all have vectors, reuse them and embed diff --git a/docs/quickstart.md b/docs/quickstart.md index ad71966..35e5872 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -400,11 +400,12 @@ Metal produce different vectors, so switching re-embeds the vault once on the ne What the window adds over the terminal: -- **You never run reindex.** It indexes the vault on open, and a native fs-watch keeps up - with edits made outside the app (an external editor, a `git pull`), re-projecting and - re-embedding what changed. Projection and embedding are decoupled, so a cold vault is - browsable and keyword-searchable in seconds while the vectors stream in behind. A manual - reindex is still there in Settings → Index, with live progress and a Cancel. +- **Automatic indexing.** Every vault open scans for offline additions, edits and deletions, + even when the previous index was fully embedded. While B2 runs, the filesystem watcher + coalesces external changes after 300 ms of quiet; in-app edits save after one idle second. + Embedding follows after two quiet seconds, with changes during indexing queued for a + follow-up pass. Keyword search works as soon as projection finishes, while missing vectors + fill in the background. Settings → Index offers a manual Reindex, live progress and Cancel. - **Discovery sits next to what you're reading.** The similar-but-unlinked notes are a pane, not a command you remember to run: always the ranked nearest, with a strength band grading each candidate within the list where a statistic exists (a vault under a dozen candidates diff --git a/ui/src/indexing.test.ts b/ui/src/indexing.test.ts new file mode 100644 index 0000000..9b82cb3 --- /dev/null +++ b/ui/src/indexing.test.ts @@ -0,0 +1,293 @@ +// Exercise the actual main.ts index wiring with controlled IPC and timers. Extracting +// declarations through TypeScript's parser avoids booting the DOM/Tauri shell, without +// copying the scheduling logic into a test double. +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { setImmediate } from "node:timers/promises"; +import { createContext, runInContext } from "node:vm"; +import ts from "typescript"; +import { reconcileIndex } from "./reconcile.ts"; + +const source = ts.createSourceFile( + "main.ts", + readFileSync(new URL("./main.ts", import.meta.url), "utf8"), + ts.ScriptTarget.Latest, + true, +); +const functions = new Set([ + "trackIndexing", "beginIndexRun", "endIndexRun", "autoIndexOnOpen", + "scheduleTrailingEmbed", "runTrailingEmbed", "onVaultChanged", + "reconcileExternalChange", "scheduleAutosave", "saveNow", "runSaveChain", + "cancelReindex", + "canAutoEmbed", "drainVaultChanges", +]); +const variables = new Set([ + "indexingRun", "embedTimer", "inFlight", "reconcileInFlight", "reconcilePending", + "TRAILING_EMBED_MS", + "AUTOSAVE_MS", "autosaveTimer", "autosavePaused", "trailingDirty", "editorView", + "reconcileEmbedPending", +]); +const declarations = source.statements.filter((s) => + ts.isFunctionDeclaration(s) ? functions.has(s.name?.text ?? "") : + ts.isVariableStatement(s) && s.declarationList.declarations.some((d) => + ts.isIdentifier(d.name) && variables.has(d.name.text)), +); +const code = ts.transpileModule(declarations.map((s) => s.getText(source)).join("\n"), { + compilerOptions: { target: ts.ScriptTarget.ES2021, module: ts.ModuleKind.None }, +}).outputText; + +function deferred() { + let resolve = () => {}; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +function harness() { + const calls: string[] = []; + const state = { + vaultRoot: "/vault" as string | null, semantic: true, + notesTotal: 2, notesEmbedded: 2, reindexing: false, + reindexCancelling: false, editing: false, fmEditing: false, + current: null as { path: string; body: string; revision: string } | null, + currentResource: null, + }; + // Disk and indexed contents deliberately start out different despite 2/2 coverage. + const disk = new Map([["a.md", "edited"], ["new.md", "added"]]); + let index = new Map([["a.md", "old"], ["deleted.md", "removed"]]); + let now = 0; + let nextTimer = 0; + const timers = new Map void }>(); + const noOp = () => {}; + const env = { + state, render: noOp, paintReindex: noOp, + flash: (message: string) => calls.push(message), errText: String, + reconcileIndex, + refreshConnections: async () => {}, + isWriteConflict: () => false, + api: { + cancelReindex: async () => {}, + project: async () => { + calls.push("project"); + if (JSON.stringify([...disk]) !== JSON.stringify([...index])) state.notesEmbedded = 0; + index = new Map(disk); + state.notesTotal = index.size; + }, + writeNote: async (path: string, body: string) => { + calls.push("save"); + disk.set(path, body); + index.set(path, body); + state.notesEmbedded = 0; + return { revision: "saved" }; + }, + }, + loadNotes: async () => { calls.push("list"); }, + refreshEmbedStatus: async () => { calls.push("coverage"); }, + embedWithProgress: async () => { + calls.push("embed"); + state.notesEmbedded = state.notesTotal; + return { cancelled: false }; + }, + refreshOpenNoteAfterIndex: async () => { calls.push("refresh"); }, + refreshDiscovery: async () => { calls.push("discovery"); }, + window: { + setTimeout: (fn: () => void, delay: number) => { + const id = ++nextTimer; + timers.set(id, { at: now + delay, fn }); + return id; + }, + }, + clearTimeout: (id: number) => { timers.delete(id); }, + }; + const context = createContext(env); + runInContext(code, context); + function run(expression: string) { return runInContext(expression, context); } + async function settle() { + // Drain promise continuations without advancing the simulated quiet period. + await setImmediate(); + } + async function advance(ms: number) { + now += ms; + for (const [id, timer] of [...timers]) { + if (timer.at <= now) { timers.delete(id); timer.fn(); } + } + await settle(); + } + return { state, calls, disk, env, run, advance, settle, indexed: () => [...index] }; +} + +test("opening a previously complete index reconciles offline edits, adds and deletes", async () => { + const h = harness(); + await h.run("autoIndexOnOpen(state.vaultRoot)"); + assert.deepEqual(h.indexed(), [...h.disk]); + assert.equal(h.state.notesEmbedded, h.disk.size); + assert.ok(h.calls.indexOf("project") < h.calls.indexOf("embed")); +}); + +test("opening a keyword-only vault also reconciles disk changes", async () => { + const h = harness(); + h.state.semantic = false; + h.state.notesEmbedded = 0; + await h.run("autoIndexOnOpen(state.vaultRoot)"); + assert.deepEqual(h.indexed(), [...h.disk]); + assert.ok(!h.calls.includes("embed")); +}); + +test("an unchanged vault is scanned but does not load the embedding model", async () => { + const h = harness(); + h.disk.clear(); + for (const [path, body] of h.indexed()) h.disk.set(path, body); + await h.run("autoIndexOnOpen(state.vaultRoot)"); + assert.ok(h.calls.includes("project")); + assert.ok(!h.calls.includes("embed")); +}); + +test("a stale vault-open request cannot project the newly selected vault", async () => { + const h = harness(); + h.state.notesTotal = 0; + await h.run('autoIndexOnOpen("/departed")'); + assert.deepEqual(h.calls, []); +}); + +test("filesystem pulses during indexing coalesce and reconcile after it finishes", async () => { + const h = harness(); + h.run("beginIndexRun()"); + await h.run("onVaultChanged()"); + await h.run("onVaultChanged()"); + assert.ok(!h.calls.includes("project")); + h.run("endIndexRun()"); + await h.settle(); + assert.deepEqual(h.indexed(), [...h.disk]); + assert.equal(h.calls.filter((c) => c === "project").length, 1); + await h.advance(2000); + assert.equal(h.state.notesEmbedded, h.disk.size); +}); + +test("repeated save requests embed only after two seconds of quiet", async () => { + const h = harness(); + h.state.notesEmbedded = 0; + h.run("scheduleTrailingEmbed()"); + await h.advance(1500); + h.run("scheduleTrailingEmbed()"); + await h.advance(1500); + assert.ok(!h.calls.includes("embed")); + await h.advance(500); + assert.equal(h.calls.filter((c) => c === "embed").length, 1); +}); + +test("a delayed save embed survives an active run without replacing its tracked promise", async () => { + const h = harness(); + const active = deferred(); + Object.assign(h.env, { active: active.promise }); + h.run("beginIndexRun(); trackIndexing(active); globalThis.originalRun = indexingRun"); + h.state.notesEmbedded = 0; + h.run("scheduleTrailingEmbed()"); + await h.advance(2000); + assert.equal(h.run("indexingRun === originalRun"), true); + assert.ok(!h.calls.includes("embed")); + h.run("endIndexRun()"); + active.resolve(); + await h.advance(2000); + assert.ok(h.calls.includes("embed")); +}); + +test("embedding waits for a filesystem projection that is still running", async () => { + const h = harness(); + const projection = deferred(); + h.env.api.project = async () => { h.calls.push("project"); await projection.promise; }; + h.state.notesEmbedded = 0; + const reconcile = h.run("onVaultChanged()"); + h.run("scheduleTrailingEmbed()"); + await h.advance(2000); + assert.ok(!h.calls.includes("embed")); + projection.resolve(); + await reconcile; + await h.advance(2000); + assert.ok(h.calls.includes("embed")); +}); + +test("a trailing embed refreshes coverage and skips an already complete index", async () => { + const h = harness(); + h.run("scheduleTrailingEmbed()"); + await h.advance(2000); + assert.ok(h.calls.includes("coverage")); + assert.ok(!h.calls.includes("embed")); +}); + +test("in-app edits save after one idle second and embed two seconds after saving", async () => { + const h = harness(); + h.state.editing = true; + h.state.current = { path: "a.md", body: "old", revision: "original" }; + h.run('editorView = { state: { doc: { toString: () => "typed content" } } }'); + h.run("scheduleAutosave()"); + await h.advance(750); + h.run("scheduleAutosave()"); + await h.advance(750); + assert.ok(!h.calls.includes("save")); + await h.advance(250); + assert.equal(h.disk.get("a.md"), "typed content"); + assert.equal(h.state.current.revision, "saved"); + assert.ok(!h.calls.includes("embed")); + await h.advance(1999); + assert.ok(!h.calls.includes("embed")); + await h.advance(1); + assert.equal(h.calls.filter((c) => c === "embed").length, 1); +}); + +test("startup cancellation during projection keeps keyword indexing but skips embedding", async () => { + const h = harness(); + const project = h.env.api.project; + h.env.api.project = async () => { + await project(); + h.state.reindexCancelling = true; + }; + await h.run("autoIndexOnOpen(state.vaultRoot)"); + assert.deepEqual(h.indexed(), [...h.disk]); + assert.ok(!h.calls.includes("embed")); + assert.equal(h.state.reindexing, false); +}); + +test("a keyword-only external edit projects without attempting to load a model", async () => { + const h = harness(); + h.state.semantic = false; + await h.run("onVaultChanged()"); + await h.advance(2000); + assert.deepEqual(h.indexed(), [...h.disk]); + assert.ok(!h.calls.includes("embed")); +}); + +test("Cancel drains queued disk changes but does not restart embedding until another edit", async () => { + const h = harness(); + h.run("beginIndexRun()"); + await h.run("onVaultChanged()"); + h.run("scheduleTrailingEmbed()"); + await h.run("cancelReindex()"); + h.run("endIndexRun()"); + await h.settle(); + assert.deepEqual(h.indexed(), [...h.disk]); + await h.advance(2000); + assert.ok(!h.calls.includes("embed"), "the user's Cancel must stick"); + h.disk.set("later.md", "another edit after cancellation"); + await h.run("onVaultChanged()"); + await h.advance(2000); + assert.ok(h.calls.includes("embed")); +}); + +test("Cancel also suppresses embedding from a reconciliation already in flight", async () => { + const h = harness(); + const projection = deferred(); + const project = h.env.api.project; + h.env.api.project = async () => { + await projection.promise; + await project(); + }; + const reconcile = h.run("onVaultChanged()"); + h.run("beginIndexRun()"); + await h.run("cancelReindex()"); + h.run("endIndexRun()"); + projection.resolve(); + await reconcile; + assert.deepEqual(h.indexed(), [...h.disk]); + await h.advance(2000); + assert.ok(!h.calls.includes("embed"), "an earlier pulse must not undo Cancel"); +}); diff --git a/ui/src/main.ts b/ui/src/main.ts index 53e9e49..3c1988a 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -2662,6 +2662,9 @@ function endIndexRun(): void { state.reindexProgress = null; state.reindexCancelling = false; render(); + // A watch pulse during this run may describe files its projection never saw. + // Keep one trailing reconciliation instead of assuming embedding covered them. + if (reconcilePending) void drainVaultChanges(); } /** After a run: re-read the open note (unless an editor holds it, where a fresh revision @@ -2729,32 +2732,30 @@ async function doReindex(): Promise { } } -// Auto-index on open (#25), driven by `VaultInfo`'s coverage (#26): -// • notesTotal === 0 → never projected: project, then embed. -// • notesEmbedded < total → embedding unfinished: fill the pending vectors (§7.2). -// • embedded === total (>0) → complete: nothing to do. -// Embeds only with the real model installed. Silent (meter and Cancel only), with -// doReindex's vault-switch guards (spec §6). +/** Judge automatic embedding only after refreshing coverage for this vault. */ +function canAutoEmbed(startedRoot: string): boolean { + return ( + state.vaultRoot === startedRoot && + state.semantic && + !state.reindexCancelling && + state.notesEmbedded < state.notesTotal + ); +} + +// Coverage describes the cached index, so every open must first reconcile disk. +// Unchanged chunks/vectors survive; only the resulting pending set needs embedding. async function autoIndexOnOpen(startedRoot: string | null): Promise { - if (state.reindexing || state.vaultRoot === null) return; // a run is live, or no vault - const projected = state.notesTotal > 0; - if (projected && state.notesEmbedded >= state.notesTotal) return; // index already complete - const needsProject = !projected; - // Without a model, only a never-projected vault has work (project is model-free). - if (!needsProject && !state.semantic) return; + if (state.reindexing || startedRoot === null || state.vaultRoot !== startedRoot) return; beginIndexRun(); render(); try { - if (needsProject) { - await api.project(); - if (state.vaultRoot !== startedRoot) return; // a switch took over — it owns the UI - await loadNotes(); // the tree paints HERE; keyword search is live - await refreshEmbedStatus(startedRoot); // caveat reads "keyword-only for now (0/M)" - render(); - } - // Embed only with a model, no pending Cancel, and no vault switch. - if (state.vaultRoot !== startedRoot || !state.semantic || state.reindexCancelling) return; + await api.project(); + if (state.vaultRoot !== startedRoot) return; // a switch took over — it owns the UI + await loadNotes(); // keyword search is live, even without a model + await refreshEmbedStatus(startedRoot); + render(); + if (!canAutoEmbed(startedRoot)) return; const r = await embedWithProgress(startedRoot); if (state.vaultRoot !== startedRoot) return; // A cancel we didn't initiate is a vault switch (see doReindex). @@ -2774,6 +2775,13 @@ async function autoIndexOnOpen(startedRoot: string | null): Promise { async function cancelReindex(): Promise { if (!state.reindexing || state.reindexCancelling) return; state.reindexCancelling = true; + // Drain any queued disk projection, but don't let pre-cancel requests restart the + // embedding the user just stopped. A subsequent save or watch pulse can request it. + reconcileEmbedPending = false; + if (embedTimer !== undefined) { + clearTimeout(embedTimer); + embedTimer = undefined; + } paintReindex(); try { await api.cancelReindex(); @@ -3044,22 +3052,27 @@ function scheduleTrailingEmbed(): void { if (embedTimer !== undefined) clearTimeout(embedTimer); embedTimer = window.setTimeout(() => { embedTimer = undefined; + // An embed snapshots its pending chunks at the start. A save or disk projection + // during it needs another pass; don't lose that request or replace indexingRun + // with a no-op promise (vault switching waits on the real run). + if (inFlight || state.reindexing || reconcileInFlight) { + scheduleTrailingEmbed(); + return; + } trackIndexing(runTrailingEmbed()); }, TRAILING_EMBED_MS); } async function runTrailingEmbed(): Promise { - if (inFlight) { - scheduleTrailingEmbed(); // the chain hasn't settled — come back after it has - return; - } - // A live run covers our note; the pending set heals on any later run (split §7.2). - if (state.reindexing || state.vaultRoot === null) return; + if (state.vaultRoot === null) return; const startedRoot = state.vaultRoot; beginIndexRun(); paintReindex(); try { + await refreshEmbedStatus(startedRoot); + if (!canAutoEmbed(startedRoot)) return; await embedWithProgress(startedRoot); + await refreshEmbedStatus(startedRoot); // Vectors are fresh — let `similar` rank with them. if (state.vaultRoot === startedRoot) await refreshDiscovery(); } catch { @@ -3443,19 +3456,25 @@ function focusGlobalSearch(): void { let reconcileInFlight = false; let reconcilePending = false; +// Kept live through reconciliation so Cancel can withdraw an earlier pulse's embed. +let reconcileEmbedPending = false; + +function onVaultChanged(): Promise { + reconcilePending = true; + reconcileEmbedPending = true; + return drainVaultChanges(); +} // Serialize reconciles, coalescing overlapping pulses into one trailing run. -async function onVaultChanged(): Promise { - if (reconcileInFlight) { - reconcilePending = true; - return; - } +async function drainVaultChanges(): Promise { + if (reconcileInFlight || state.reindexing) return; reconcileInFlight = true; try { do { reconcilePending = false; await reconcileExternalChange(); - } while (reconcilePending); + if (!reconcilePending) reconcileEmbedPending = false; + } while (reconcilePending && !state.reindexing); } finally { reconcileInFlight = false; } @@ -3469,14 +3488,14 @@ async function reconcileExternalChange(): Promise { reindexing: state.reindexing, project: api.project, list: loadNotes, - // Re-chunking an edited note drops its vectors, so heal them with the save path's - // trailing embed. Gated on the N/M coverage read (#26), which can over-fire but never - // miss. + // Projection reuses cached vectors; only new chunk text needs embedding. vectorsPending: async () => { await refreshEmbedStatus(state.vaultRoot); return state.notesTotal > 0 && state.notesEmbedded < state.notesTotal; }, - healVectors: scheduleTrailingEmbed, + healVectors: () => { + if (reconcileEmbedPending) scheduleTrailingEmbed(); + }, }); // The open note, except: @@ -4769,7 +4788,7 @@ async function boot(): Promise { flash(`${lostChords.length} saved shortcut(s) couldn't be applied — those are back to their defaults.`); } render(); - // Auto-index on launch (#25); a no-op with no vault or a complete index. + // Reconcile changes made while the app was closed, then fill any missing vectors. trackIndexing(autoIndexOnOpen(state.vaultRoot)); } diff --git a/ui/src/reconcile.test.ts b/ui/src/reconcile.test.ts index 0fa0344..1679928 100644 --- a/ui/src/reconcile.test.ts +++ b/ui/src/reconcile.test.ts @@ -1,6 +1,6 @@ // The vault-changed reconcile sequencing (reconcile.ts), over injected thunks. Pins the // pulse re-deriving as much as it invalidates: an external add must be projected before -// the re-list (#65), and the vectors that projection clears must be re-embedded. +// the re-list (#65), and new chunk text must be embedded afterwards. import { reconcileIndex } from "./reconcile.ts"; let passed = 0; @@ -97,7 +97,7 @@ await check("a failed list propagates (callers already own that error path)", as assert(threw, "the list thunk's error contract (loadNotes' toast-and-false) stays the caller's"); }); -// --- the vector heal: the projection clears what only an embed can put back --------- +// --- the vector heal: new chunk text needs an embed -------------------------------- await check("schedules the trailing embed when the projection left vectors owed", async () => { const calls: string[] = []; @@ -120,7 +120,7 @@ await check("schedules the trailing embed when the projection left vectors owed" ); assert( calls.join(",") === "project,list,pending?,heal", - `an externally edited note's vectors die in the projection and only the embed puts them back (got: ${calls.join(",")})`, + `an externally edited note's new chunk text needs an embed (got: ${calls.join(",")})`, ); }); diff --git a/ui/src/reconcile.ts b/ui/src/reconcile.ts index 296c6f5..4bd29d7 100644 --- a/ui/src/reconcile.ts +++ b/ui/src/reconcile.ts @@ -1,10 +1,9 @@ // Pure sequencing for the `vault-changed` reconcile's index refresh (no DOM, no IPC). // // A pulse means the disk changed, but the tree lists come from the index, so the index is -// re-projected first or an external add stays invisible (#65). Re-projecting a note clears -// its vectors (`db::replace_chunks`) and projection is model-free, so the reconcile also -// schedules the trailing embed the in-app save path uses, or the note drops out of -// discovery. +// re-projected first or an external add stays invisible (#65). Projection reuses cached +// vectors, but new chunk text needs the same trailing embed as an in-app save before it +// participates in discovery. /** The four thunks the sequence composes, plus the one gate it respects. */ export interface ReconcileIndexDeps {