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/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": { 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 {