@openparachute/vault 0.7.2 → 0.7.3-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +5 -3
  2. package/core/src/attachment/policy.test.ts +66 -0
  3. package/core/src/attachment/policy.ts +131 -0
  4. package/core/src/attachment/tickets.test.ts +45 -0
  5. package/core/src/attachment/tickets.ts +117 -0
  6. package/core/src/attachment-tickets-tool.test.ts +286 -0
  7. package/core/src/conformance.ts +2 -1
  8. package/core/src/contract-typed-index.test.ts +4 -3
  9. package/core/src/core.test.ts +607 -11
  10. package/core/src/display-title.test.ts +190 -0
  11. package/core/src/embedding/chunker.test.ts +97 -0
  12. package/core/src/embedding/chunker.ts +180 -0
  13. package/core/src/embedding/provider.ts +108 -0
  14. package/core/src/embedding/staleness.test.ts +87 -0
  15. package/core/src/embedding/staleness.ts +83 -0
  16. package/core/src/embedding/vector-codec.test.ts +99 -0
  17. package/core/src/embedding/vector-codec.ts +60 -0
  18. package/core/src/embedding/vectors.test.ts +163 -0
  19. package/core/src/embedding/vectors.ts +135 -0
  20. package/core/src/expand.ts +11 -3
  21. package/core/src/indexed-fields.test.ts +9 -3
  22. package/core/src/indexed-fields.ts +9 -1
  23. package/core/src/lede.test.ts +96 -0
  24. package/core/src/mcp-semantic-search.test.ts +160 -0
  25. package/core/src/mcp.ts +405 -10
  26. package/core/src/notes.semantic-search.test.ts +131 -0
  27. package/core/src/notes.ts +319 -7
  28. package/core/src/query-warnings.ts +40 -0
  29. package/core/src/schema-defaults.ts +85 -1
  30. package/core/src/schema-v27-note-vectors.test.ts +178 -0
  31. package/core/src/schema.ts +81 -1
  32. package/core/src/search-fts-v25.test.ts +9 -1
  33. package/core/src/search-query.test.ts +42 -0
  34. package/core/src/search-query.ts +27 -0
  35. package/core/src/search-title-boost.test.ts +125 -0
  36. package/core/src/seed-packs.test.ts +117 -1
  37. package/core/src/seed-packs.ts +217 -1
  38. package/core/src/store.semantic-search.test.ts +236 -0
  39. package/core/src/store.ts +162 -5
  40. package/core/src/tag-schemas.ts +27 -12
  41. package/core/src/types.ts +63 -1
  42. package/core/src/vault-projection.ts +48 -0
  43. package/package.json +6 -1
  44. package/src/add-pack.test.ts +32 -0
  45. package/src/attachment-tickets.test.ts +350 -0
  46. package/src/attachment-tickets.ts +264 -0
  47. package/src/auth.ts +8 -2
  48. package/src/cli.ts +5 -1
  49. package/src/contract-errors.test.ts +2 -1
  50. package/src/contract-honest-queries.test.ts +88 -0
  51. package/src/contract-search.test.ts +41 -0
  52. package/src/embedding/capability.test.ts +33 -0
  53. package/src/embedding/capability.ts +34 -0
  54. package/src/embedding/external-api.test.ts +154 -0
  55. package/src/embedding/external-api.ts +144 -0
  56. package/src/embedding/onnx-transformers.test.ts +113 -0
  57. package/src/embedding/onnx-transformers.ts +141 -0
  58. package/src/embedding/select.test.ts +99 -0
  59. package/src/embedding/select.ts +92 -0
  60. package/src/embedding-worker.test.ts +300 -0
  61. package/src/embedding-worker.ts +226 -0
  62. package/src/mcp-http.ts +13 -1
  63. package/src/mcp-tools.ts +49 -14
  64. package/src/onboarding-seed.test.ts +64 -0
  65. package/src/routes.ts +173 -92
  66. package/src/routing.ts +17 -0
  67. package/src/scribe-discovery.test.ts +76 -1
  68. package/src/scribe-discovery.ts +28 -9
  69. package/src/semantic-search-routes.test.ts +161 -0
  70. package/src/server.ts +21 -2
  71. package/src/vault-embeddings-capability.test.ts +82 -0
  72. package/src/vault-store-embedding-wiring.test.ts +69 -0
  73. package/src/vault-store.ts +53 -2
  74. package/src/vault.test.ts +62 -13
@@ -0,0 +1,141 @@
1
+ /**
2
+ * `onnx-transformers` — the bundled zero-config self-host embedding
3
+ * provider (semantic search MVP — two-tier self-host shape, Aaron-
4
+ * ratified: this is the FLOOR tier; `external-api.ts`/Ollama is the
5
+ * recommended quality upgrade).
6
+ *
7
+ * Runs `bge-small-en-v1.5` (q8-quantized ONNX, ~34MB — P0's eval
8
+ * confirmed this is genuinely bundle-sized, unlike bge-m3's 1.1GB) via
9
+ * `@huggingface/transformers`, IN-PROCESS, no subprocess. Model weights
10
+ * download from the Hugging Face hub to a local cache on first use (a
11
+ * base-MODEL download, not note content leaving the box) — every actual
12
+ * embedding call after that runs entirely on this machine.
13
+ *
14
+ * ## bun+onnxruntime verdict (P0/V2 spike)
15
+ *
16
+ * Confirmed WORKING under bun 1.3.13 on this repo: `onnxruntime-node`'s
17
+ * native binary postinstall needs `bun pm trust` to run (blocked by
18
+ * default) — `package.json`'s `trustedDependencies: ["onnxruntime-node",
19
+ * "protobufjs"]` auto-approves it on a fresh `bun install`, no manual step
20
+ * for an operator. `sharp`'s postinstall stays blocked (harmless — image
21
+ * support is unused by this text-only pipeline). Verified end-to-end:
22
+ * `pipeline("feature-extraction", "Xenova/bge-small-en-v1.5", { dtype:
23
+ * "q8" })` loads and produces correct 384-dim vectors under `bun run`.
24
+ *
25
+ * ## Lazy-fail, not crash
26
+ *
27
+ * The model load is NOT attempted at construction or at `available()` —
28
+ * it's deferred to the first real `embed()` call (loading downloads/reads
29
+ * ~34MB, too slow for a capability probe that runs on every `GET
30
+ * /api/vault`). If the load ever throws (a genuinely broken bun/onnxruntime
31
+ * combination on some future platform), the failure is caught, the
32
+ * provider latches into a `broken` state for the rest of this process, and
33
+ * every subsequent `available()`/`embed()` reports the failure honestly
34
+ * (`onnx_unavailable`, non-retriable) instead of retrying a doomed load on
35
+ * every note write or crashing the server.
36
+ */
37
+
38
+ import {
39
+ EmbeddingError,
40
+ type EmbeddingProvider,
41
+ type EmbedInput,
42
+ type EmbedResult,
43
+ type ProviderAvailability,
44
+ } from "../../core/src/embedding/provider.ts";
45
+
46
+ /** P0-verified bundle-sized default: 34MB q8 ONNX, well under the "~100MB zero-config" bar. */
47
+ const DEFAULT_MODEL_ID = "Xenova/bge-small-en-v1.5";
48
+ const DEFAULT_DIMS = 384;
49
+
50
+ /** Structural minimum of the transformers.js feature-extraction pipeline this provider calls. */
51
+ type FeatureExtractionFn = (
52
+ text: string,
53
+ opts: { pooling: "mean"; normalize: boolean; truncation: boolean },
54
+ ) => Promise<{ data: Float32Array | number[] }>;
55
+
56
+ export interface OnnxTransformersProviderOpts {
57
+ /** Hugging Face model id. Default `Xenova/bge-small-en-v1.5`. */
58
+ modelId?: string;
59
+ /** Expected vector dimensionality (confirmed against the first real embed() response). Default 384. */
60
+ dims?: number;
61
+ /**
62
+ * Injection seam for tests — a fake `pipeline("feature-extraction", ...)`
63
+ * loader. Production omits it and dynamically imports `@huggingface/transformers`.
64
+ */
65
+ pipelineLoader?: (modelId: string) => Promise<FeatureExtractionFn>;
66
+ }
67
+
68
+ async function defaultPipelineLoader(modelId: string): Promise<FeatureExtractionFn> {
69
+ // Dynamic import (not a top-level import) — this file lives in `src/`
70
+ // (never `core/`), but keeping the ~34MB dependency's load off the
71
+ // module-init path means a vault that never touches semantic search
72
+ // never pays the import cost either.
73
+ const { pipeline } = await import("@huggingface/transformers");
74
+ const extractor = await pipeline("feature-extraction", modelId, { dtype: "q8" });
75
+ return extractor as unknown as FeatureExtractionFn;
76
+ }
77
+
78
+ export class OnnxTransformersEmbeddingProvider implements EmbeddingProvider {
79
+ readonly name = "onnx-transformers";
80
+ readonly model: string;
81
+ dims: number;
82
+
83
+ private readonly pipelineLoader: (modelId: string) => Promise<FeatureExtractionFn>;
84
+ private extractorPromise: Promise<FeatureExtractionFn> | null = null;
85
+ private broken: string | null = null;
86
+
87
+ constructor(opts: OnnxTransformersProviderOpts = {}) {
88
+ this.model = opts.modelId ?? DEFAULT_MODEL_ID;
89
+ this.dims = opts.dims ?? DEFAULT_DIMS;
90
+ this.pipelineLoader = opts.pipelineLoader ?? defaultPipelineLoader;
91
+ }
92
+
93
+ /**
94
+ * Optimistic until proven otherwise — see the module doc's "lazy-fail,
95
+ * not crash" note. A real load attempt only happens inside `embed()`.
96
+ */
97
+ async available(): Promise<ProviderAvailability> {
98
+ if (this.broken) return { ok: false, reason: this.broken };
99
+ return { ok: true };
100
+ }
101
+
102
+ private async getExtractor(): Promise<FeatureExtractionFn> {
103
+ if (this.broken) {
104
+ throw new EmbeddingError(this.broken, { code: "onnx_unavailable", retriable: false });
105
+ }
106
+ if (!this.extractorPromise) {
107
+ this.extractorPromise = this.pipelineLoader(this.model).catch((err) => {
108
+ const reason = `bundled ONNX embedding model failed to load: ${err instanceof Error ? err.message : String(err)}`;
109
+ this.broken = reason;
110
+ throw new EmbeddingError(reason, { code: "onnx_unavailable", retriable: false });
111
+ });
112
+ }
113
+ return this.extractorPromise;
114
+ }
115
+
116
+ /**
117
+ * Embeds each text through the pipeline one at a time (not a single
118
+ * batched ONNX-session call) — this is the exact per-item invocation
119
+ * shape the P0 eval harness verified working end-to-end; true session-
120
+ * level batching is a future optimization, not required for the MVP's
121
+ * correctness. `truncation: true` lets the model's own tokenizer window
122
+ * (512 tokens for bge-small) truncate long chunks — chunking already
123
+ * targets ~450 tokens per chunk (see `core/src/embedding/chunker.ts`),
124
+ * so this rarely engages in practice. Returns RAW (un-normalized)
125
+ * vectors — normalization is the store's job (one place, for every
126
+ * provider), not each provider's.
127
+ */
128
+ async embed(input: EmbedInput): Promise<EmbedResult> {
129
+ if (input.texts.length === 0) {
130
+ return { vectors: [], model: this.model, dims: this.dims };
131
+ }
132
+ const extractor = await this.getExtractor();
133
+ const vectors: Float32Array[] = [];
134
+ for (const text of input.texts) {
135
+ const out = await extractor(text, { pooling: "mean", normalize: false, truncation: true });
136
+ vectors.push(out.data instanceof Float32Array ? out.data : new Float32Array(out.data));
137
+ }
138
+ if (vectors[0]) this.dims = vectors[0].length;
139
+ return { vectors, model: this.model, dims: this.dims };
140
+ }
141
+ }
@@ -0,0 +1,99 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import {
3
+ resolveEmbeddingApiConfig,
4
+ buildEmbeddingProvider,
5
+ embeddingsExplicitlyDisabled,
6
+ DEFAULT_EXTERNAL_EMBEDDING_MODEL,
7
+ } from "./select.ts";
8
+ import { ExternalApiEmbeddingProvider } from "./external-api.ts";
9
+ import { OnnxTransformersEmbeddingProvider } from "./onnx-transformers.ts";
10
+
11
+ describe("resolveEmbeddingApiConfig", () => {
12
+ test("all undefined when no env vars are set", () => {
13
+ expect(resolveEmbeddingApiConfig({})).toEqual({ url: undefined, apiKey: undefined, model: undefined });
14
+ });
15
+
16
+ test("reads the EMBEDDING_API_URL/EMBEDDING_API_KEY/EMBEDDING_MODEL trio", () => {
17
+ const config = resolveEmbeddingApiConfig({
18
+ EMBEDDING_API_URL: "http://localhost:11434/v1",
19
+ EMBEDDING_API_KEY: "secret",
20
+ EMBEDDING_MODEL: "bge-m3",
21
+ });
22
+ expect(config).toEqual({ url: "http://localhost:11434/v1", apiKey: "secret", model: "bge-m3" });
23
+ });
24
+
25
+ test("blank/whitespace-only values are treated as absent", () => {
26
+ const config = resolveEmbeddingApiConfig({ EMBEDDING_API_URL: " ", EMBEDDING_MODEL: "" });
27
+ expect(config.url).toBeUndefined();
28
+ expect(config.model).toBeUndefined();
29
+ });
30
+ });
31
+
32
+ describe("buildEmbeddingProvider — two-tier selection", () => {
33
+ test("zero-config: no EMBEDDING_API_URL -> the bundled floor (onnx-transformers)", () => {
34
+ const provider = buildEmbeddingProvider({});
35
+ expect(provider).toBeInstanceOf(OnnxTransformersEmbeddingProvider);
36
+ });
37
+
38
+ test("EMBEDDING_API_URL set -> the config upgrade tier (external-api) wins", () => {
39
+ const provider = buildEmbeddingProvider({ EMBEDDING_API_URL: "http://localhost:11434/v1" });
40
+ expect(provider).toBeInstanceOf(ExternalApiEmbeddingProvider);
41
+ expect(provider.model).toBe(DEFAULT_EXTERNAL_EMBEDDING_MODEL);
42
+ });
43
+
44
+ test("EMBEDDING_MODEL alone (no URL) has no effect — still the bundled floor", () => {
45
+ const provider = buildEmbeddingProvider({ EMBEDDING_MODEL: "bge-m3" });
46
+ expect(provider).toBeInstanceOf(OnnxTransformersEmbeddingProvider);
47
+ });
48
+
49
+ test("an explicit EMBEDDING_MODEL overrides the config-tier default", () => {
50
+ const provider = buildEmbeddingProvider({
51
+ EMBEDDING_API_URL: "http://x",
52
+ EMBEDDING_MODEL: "nomic-embed-text",
53
+ });
54
+ expect(provider.model).toBe("nomic-embed-text");
55
+ });
56
+ });
57
+
58
+ describe("embeddingsExplicitlyDisabled — the EMBEDDINGS_ENABLED off switch (M1)", () => {
59
+ test("false only for the literal string \"false\"", () => {
60
+ expect(embeddingsExplicitlyDisabled({ EMBEDDINGS_ENABLED: "false" })).toBe(true);
61
+ });
62
+
63
+ test("case-insensitive and trims whitespace", () => {
64
+ expect(embeddingsExplicitlyDisabled({ EMBEDDINGS_ENABLED: "FALSE" })).toBe(true);
65
+ expect(embeddingsExplicitlyDisabled({ EMBEDDINGS_ENABLED: " false " })).toBe(true);
66
+ });
67
+
68
+ test("unset defaults to enabled (not disabled)", () => {
69
+ expect(embeddingsExplicitlyDisabled({})).toBe(false);
70
+ });
71
+
72
+ test("\"true\" (and any other value) is enabled — this var is an off switch, not an opt-in", () => {
73
+ expect(embeddingsExplicitlyDisabled({ EMBEDDINGS_ENABLED: "true" })).toBe(false);
74
+ expect(embeddingsExplicitlyDisabled({ EMBEDDINGS_ENABLED: "0" })).toBe(false);
75
+ expect(embeddingsExplicitlyDisabled({ EMBEDDINGS_ENABLED: "no" })).toBe(false);
76
+ });
77
+ });
78
+
79
+ describe("buildEmbeddingProvider — EMBEDDINGS_ENABLED=false (M1)", () => {
80
+ test("returns undefined even when the config-upgrade tier is otherwise fully configured", () => {
81
+ const provider = buildEmbeddingProvider({
82
+ EMBEDDINGS_ENABLED: "false",
83
+ EMBEDDING_API_URL: "http://localhost:11434/v1",
84
+ EMBEDDING_MODEL: "bge-m3",
85
+ });
86
+ expect(provider).toBeUndefined();
87
+ });
88
+
89
+ test("returns undefined for the zero-config (bundled-floor) case too", () => {
90
+ expect(buildEmbeddingProvider({ EMBEDDINGS_ENABLED: "false" })).toBeUndefined();
91
+ });
92
+
93
+ test("EMBEDDINGS_ENABLED=true (or unset) is unaffected — both tiers still resolve normally", () => {
94
+ expect(buildEmbeddingProvider({ EMBEDDINGS_ENABLED: "true" })).toBeInstanceOf(OnnxTransformersEmbeddingProvider);
95
+ expect(
96
+ buildEmbeddingProvider({ EMBEDDINGS_ENABLED: "true", EMBEDDING_API_URL: "http://x" }),
97
+ ).toBeInstanceOf(ExternalApiEmbeddingProvider);
98
+ });
99
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Embedding provider selection (semantic search MVP — two-tier self-host
3
+ * shape, Aaron-ratified). Mirrors `src/transcription/select.ts`'s
4
+ * env-driven resolution pattern.
5
+ *
6
+ * Two tiers, config wins over the bundled floor:
7
+ *
8
+ * - **Config upgrade** (`external-api.ts`): `EMBEDDING_API_URL` set (in
9
+ * `~/.parachute/vault/.env`, the same bring-your-own-endpoint pattern
10
+ * as `PARACHUTE_GITHUB_*`) → the OpenAI-compatible client, pointed at
11
+ * it. Covers a local Ollama (`ollama pull bge-m3`, fully private) and
12
+ * any hosted `/v1/embeddings` endpoint. `EMBEDDING_MODEL` names the
13
+ * model the endpoint should embed with; `EMBEDDING_API_KEY` is a
14
+ * bearer token, when the endpoint needs one.
15
+ * - **Bundled floor** (`onnx-transformers.ts`): no env at all → the
16
+ * zero-config default, `bge-small-en-v1.5` (q8 ONNX) running
17
+ * in-process. This is what makes semantic search work on a fresh
18
+ * install with no operator action.
19
+ *
20
+ * **Off switch:** `EMBEDDINGS_ENABLED=false` short-circuits BOTH tiers —
21
+ * `buildEmbeddingProvider` returns `undefined` regardless of what else is
22
+ * configured. Mirrors the `EMBEDDINGS_ENABLED` wrangler var C2 (cloud)
23
+ * plans for the same gate. A caller wired against an `undefined` provider
24
+ * (see `Store.embeddingProvider`) reports the `embeddings` capability as
25
+ * disabled and `semanticSearch` throws `semantic_unavailable` — the exact
26
+ * same honest-failure path as "no provider configured" (never a silent
27
+ * keyword fallback). The embed-on-write drain simply has nothing to
28
+ * invoke, so it no-ops.
29
+ *
30
+ * `EMBEDDING_MODEL` alone (no `EMBEDDING_API_URL`) has no effect — it only
31
+ * shapes the config-upgrade tier. Resolved per-call (not cached here) so
32
+ * `PARACHUTE_HOME`/env overrides apply in tests exactly like every other
33
+ * env-driven selector in this codebase; the ONE shared runtime instance
34
+ * (so the bundled model loads at most once per process) is cached at the
35
+ * call site — see `src/vault-store.ts`'s `getSharedEmbeddingProvider`.
36
+ */
37
+
38
+ import type { EmbeddingProvider } from "../../core/src/embedding/provider.ts";
39
+ import { ExternalApiEmbeddingProvider } from "./external-api.ts";
40
+ import { OnnxTransformersEmbeddingProvider } from "./onnx-transformers.ts";
41
+
42
+ /** Default model requested from the config-upgrade tier when `EMBEDDING_MODEL` is unset. Matches the plan's recommended quality default (Ollama `bge-m3`). */
43
+ export const DEFAULT_EXTERNAL_EMBEDDING_MODEL = "bge-m3";
44
+
45
+ export interface EmbeddingApiConfig {
46
+ url?: string;
47
+ apiKey?: string;
48
+ model?: string;
49
+ }
50
+
51
+ /** Read the `EMBEDDING_API_URL`/`EMBEDDING_API_KEY`/`EMBEDDING_MODEL` env trio. Blank/whitespace-only values are treated as absent. */
52
+ export function resolveEmbeddingApiConfig(env: NodeJS.ProcessEnv = process.env): EmbeddingApiConfig {
53
+ return {
54
+ url: env.EMBEDDING_API_URL?.trim() || undefined,
55
+ apiKey: env.EMBEDDING_API_KEY?.trim() || undefined,
56
+ model: env.EMBEDDING_MODEL?.trim() || undefined,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * `true` only when `EMBEDDINGS_ENABLED` is explicitly the literal string
62
+ * `"false"` (case-insensitive, trimmed) — the off switch. Every other
63
+ * value, INCLUDING unset, is "enabled": the feature defaults ON (the
64
+ * bundled floor tier makes that safe — zero-config still works), and this
65
+ * var exists to opt OUT, not to opt in.
66
+ */
67
+ export function embeddingsExplicitlyDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
68
+ return env.EMBEDDINGS_ENABLED?.trim().toLowerCase() === "false";
69
+ }
70
+
71
+ /**
72
+ * Build the provider the current config selects, or `undefined` when
73
+ * `EMBEDDINGS_ENABLED=false` (see the off-switch doc above). Pure factory
74
+ * (no caching, no I/O beyond what the provider's own constructor does —
75
+ * which is none; both providers lazy-load/lazy-connect on first
76
+ * `embed()`), so it's cheap to call repeatedly in tests. Production
77
+ * callers should go through the shared singleton
78
+ * (`getSharedEmbeddingProvider`) instead of calling this directly, so the
79
+ * bundled ONNX model — when selected — loads at most once per process.
80
+ */
81
+ export function buildEmbeddingProvider(env: NodeJS.ProcessEnv = process.env): EmbeddingProvider | undefined {
82
+ if (embeddingsExplicitlyDisabled(env)) return undefined;
83
+ const config = resolveEmbeddingApiConfig(env);
84
+ if (config.url) {
85
+ return new ExternalApiEmbeddingProvider({
86
+ url: config.url,
87
+ apiKey: config.apiKey,
88
+ model: config.model ?? DEFAULT_EXTERNAL_EMBEDDING_MODEL,
89
+ });
90
+ }
91
+ return new OnnxTransformersEmbeddingProvider();
92
+ }
@@ -0,0 +1,300 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { SqliteStore } from "../core/src/store.ts";
4
+ import { HookRegistry } from "../core/src/hooks.ts";
5
+ import { EmbeddingWorker, registerEmbeddingHook } from "./embedding-worker.ts";
6
+ import { getNoteVectorRows } from "../core/src/embedding/vectors.ts";
7
+ import type { EmbeddingProvider, EmbedInput, EmbedResult, ProviderAvailability } from "../core/src/embedding/provider.ts";
8
+
9
+ const MODEL = "test-model";
10
+ const silentLogger = { error: () => {} };
11
+
12
+ class FakeProvider implements EmbeddingProvider {
13
+ readonly name = "fake";
14
+ readonly model = MODEL;
15
+ readonly dims = 4;
16
+ available_ = true;
17
+ reason_: string | undefined;
18
+ calls: string[][] = [];
19
+ failNext = false;
20
+
21
+ async embed(input: EmbedInput): Promise<EmbedResult> {
22
+ this.calls.push([...input.texts]);
23
+ if (this.failNext) {
24
+ this.failNext = false;
25
+ throw new Error("simulated provider failure");
26
+ }
27
+ return {
28
+ vectors: input.texts.map((_, i) => new Float32Array([1, 0, 0, i])),
29
+ model: this.model,
30
+ dims: this.dims,
31
+ };
32
+ }
33
+
34
+ async available(): Promise<ProviderAvailability> {
35
+ return this.available_ ? { ok: true } : { ok: false, reason: this.reason_ };
36
+ }
37
+ }
38
+
39
+ let db: Database;
40
+ let store: SqliteStore;
41
+ let provider: FakeProvider;
42
+ let worker: EmbeddingWorker;
43
+
44
+ beforeEach(() => {
45
+ db = new Database(":memory:");
46
+ store = new SqliteStore(db);
47
+ provider = new FakeProvider();
48
+ worker = new EmbeddingWorker({
49
+ provider,
50
+ vaultList: () => ["default"],
51
+ getStore: () => store,
52
+ logger: silentLogger,
53
+ });
54
+ });
55
+
56
+ describe("EmbeddingWorker.embedNote", () => {
57
+ it("embeds a fresh note's chunk(s) and writes note_vectors", async () => {
58
+ const note = await store.createNote("hello world", { path: "n" });
59
+ await worker.embedNote(store, note);
60
+ const rows = getNoteVectorRows(db, note.id);
61
+ expect(rows.length).toBe(1);
62
+ expect(rows[0].model).toBe(MODEL);
63
+ expect(provider.calls.length).toBe(1);
64
+ });
65
+
66
+ it("a no-op re-embed (identical content) makes ZERO provider calls", async () => {
67
+ const note = await store.createNote("hello world", { path: "n" });
68
+ await worker.embedNote(store, note);
69
+ expect(provider.calls.length).toBe(1);
70
+
71
+ await worker.embedNote(store, note); // same content, same model
72
+ expect(provider.calls.length).toBe(1); // unchanged — freshness gate short-circuited
73
+ });
74
+
75
+ it("an edited note gets re-embedded (content_hash changed)", async () => {
76
+ let note = await store.createNote("original content", { path: "n" });
77
+ await worker.embedNote(store, note);
78
+ expect(provider.calls.length).toBe(1);
79
+
80
+ note = await store.updateNote(note.id, { content: "changed content" });
81
+ await worker.embedNote(store, note);
82
+ expect(provider.calls.length).toBe(2);
83
+ expect(provider.calls[1]).toEqual(["changed content"]);
84
+ });
85
+
86
+ it("leaves the chunk stale (no throw) when the provider is unavailable", async () => {
87
+ provider.available_ = false;
88
+ provider.reason_ = "not configured";
89
+ const note = await store.createNote("hello world", { path: "n" });
90
+ await expect(worker.embedNote(store, note)).resolves.toBeUndefined();
91
+ expect(getNoteVectorRows(db, note.id)).toEqual([]);
92
+ expect(provider.calls.length).toBe(0);
93
+ });
94
+
95
+ it("swallows an embed() failure (logs, doesn't throw) — retried on the next call", async () => {
96
+ provider.failNext = true;
97
+ const note = await store.createNote("hello world", { path: "n" });
98
+ await expect(worker.embedNote(store, note)).resolves.toBeUndefined();
99
+ expect(getNoteVectorRows(db, note.id)).toEqual([]);
100
+
101
+ // Next attempt succeeds (failNext was consumed).
102
+ await worker.embedNote(store, note);
103
+ expect(getNoteVectorRows(db, note.id).length).toBe(1);
104
+ });
105
+
106
+ it("prunes obsolete chunks when a note shrinks, without needing a provider call", async () => {
107
+ // Force a multi-chunk note via a tiny targetChars indirectly isn't
108
+ // exposed here, so simulate a shrink by manually seeding an extra
109
+ // stale chunk row the current (single-chunk) content no longer covers.
110
+ const note = await store.createNote("short note", { path: "n" });
111
+ await worker.embedNote(store, note);
112
+ expect(getNoteVectorRows(db, note.id).length).toBe(1);
113
+
114
+ db.prepare(
115
+ `INSERT INTO note_vectors (note_id, chunk_ix, vector, dims, model, content_hash, embedded_at) VALUES (?, 1, ?, 4, ?, 'stale', ?)`,
116
+ ).run(note.id, new Uint8Array(16), MODEL, new Date().toISOString());
117
+ expect(getNoteVectorRows(db, note.id).length).toBe(2);
118
+
119
+ await worker.embedNote(store, note); // content unchanged at ix 0 -> no provider call, but ix 1 gets pruned
120
+ const rows = getNoteVectorRows(db, note.id);
121
+ expect(rows.map((r) => r.chunk_ix)).toEqual([0]);
122
+ });
123
+
124
+ it("M1: no-ops (no throw, zero provider calls) when the worker has no provider at all (EMBEDDINGS_ENABLED=false)", async () => {
125
+ const offWorker = new EmbeddingWorker({
126
+ provider: undefined,
127
+ vaultList: () => ["default"],
128
+ getStore: () => store,
129
+ logger: silentLogger,
130
+ });
131
+ const note = await store.createNote("hello world", { path: "n" });
132
+ await expect(offWorker.embedNote(store, note)).resolves.toBeUndefined();
133
+ expect(getNoteVectorRows(db, note.id)).toEqual([]);
134
+ expect(provider.calls.length).toBe(0); // the shared FakeProvider from beforeEach was never touched
135
+ });
136
+
137
+ it("M3: a blank note makes zero provider calls and writes no vector rows", async () => {
138
+ const note = await store.createNote("", { path: "blank" });
139
+ await worker.embedNote(store, note);
140
+ expect(provider.calls.length).toBe(0);
141
+ expect(getNoteVectorRows(db, note.id)).toEqual([]);
142
+ });
143
+
144
+ it("M3: a whitespace-only note makes zero provider calls", async () => {
145
+ const note = await store.createNote(" \n\t ", { path: "whitespace" });
146
+ await worker.embedNote(store, note);
147
+ expect(provider.calls.length).toBe(0);
148
+ expect(getNoteVectorRows(db, note.id)).toEqual([]);
149
+ });
150
+
151
+ it("M3: a note that's edited down to blank prunes its old vector rows (no ghost match survives)", async () => {
152
+ const note = await store.createNote("real content", { path: "n" });
153
+ await worker.embedNote(store, note);
154
+ expect(getNoteVectorRows(db, note.id).length).toBe(1);
155
+
156
+ const emptied = await store.updateNote(note.id, { content: "" });
157
+ await worker.embedNote(store, emptied);
158
+ expect(getNoteVectorRows(db, note.id)).toEqual([]);
159
+ });
160
+ });
161
+
162
+ describe("EmbeddingWorker.kick", () => {
163
+ it("resolves the store and embeds", async () => {
164
+ const note = await store.createNote("hello", { path: "n" });
165
+ await worker.kick("default", note);
166
+ expect(getNoteVectorRows(db, note.id).length).toBe(1);
167
+ });
168
+ });
169
+
170
+ describe("EmbeddingWorker.sweepOnce", () => {
171
+ it("backfills every note lacking a fresh vector under the active model", async () => {
172
+ const a = await store.createNote("note a", { path: "a" });
173
+ const b = await store.createNote("note b", { path: "b" });
174
+ const result = await worker.sweepOnce();
175
+ expect(result.processed).toBe(2);
176
+ expect(result.vaults).toBe(1);
177
+ expect(getNoteVectorRows(db, a.id).length).toBe(1);
178
+ expect(getNoteVectorRows(db, b.id).length).toBe(1);
179
+ });
180
+
181
+ it("a second sweep is a no-op once every note is embedded", async () => {
182
+ await store.createNote("note a", { path: "a" });
183
+ const first = await worker.sweepOnce();
184
+ expect(first.processed).toBe(1);
185
+
186
+ provider.calls = [];
187
+ const second = await worker.sweepOnce();
188
+ expect(second.processed).toBe(0); // nothing left pending
189
+ expect(provider.calls.length).toBe(0);
190
+ });
191
+
192
+ it("is re-entrant-safe — a sweep already in flight short-circuits a concurrent call", async () => {
193
+ await store.createNote("note a", { path: "a" });
194
+ const p1 = worker.sweepOnce();
195
+ const p2 = worker.sweepOnce(); // should short-circuit to {processed:0, vaults:0} immediately
196
+ const [r1, r2] = await Promise.all([p1, p2]);
197
+ expect(r2).toEqual({ processed: 0, vaults: 0 });
198
+ expect(r1.processed).toBe(1);
199
+ });
200
+
201
+ it("M1: no-ops immediately when the worker has no provider (off switch)", async () => {
202
+ const offWorker = new EmbeddingWorker({
203
+ provider: undefined,
204
+ vaultList: () => ["default"],
205
+ getStore: () => store,
206
+ logger: silentLogger,
207
+ });
208
+ await store.createNote("note a", { path: "a" });
209
+ const result = await offWorker.sweepOnce();
210
+ expect(result).toEqual({ processed: 0, vaults: 0 });
211
+ });
212
+
213
+ it("M3: a mix of blank and real notes only processes the real ones — the sweep never gets stuck re-selecting the blank one", async () => {
214
+ const real = await store.createNote("real content", { path: "real" });
215
+ await store.createNote("", { path: "blank" });
216
+ await store.createNote(" ", { path: "whitespace" });
217
+
218
+ const first = await worker.sweepOnce();
219
+ expect(first.processed).toBe(1); // only the real note
220
+ expect(getNoteVectorRows(db, real.id).length).toBe(1);
221
+
222
+ // A second pass finds nothing left pending — the blank notes never
223
+ // enter getNotesPendingEmbedding, so they can't loop the sweep forever.
224
+ provider.calls = [];
225
+ const second = await worker.sweepOnce();
226
+ expect(second.processed).toBe(0);
227
+ expect(provider.calls.length).toBe(0);
228
+ });
229
+
230
+ it("L4: end-to-end — a blank note present in the vault never leaves a phantom embeddings_pending after the sweep", async () => {
231
+ const real = await store.createNote("real content about something", { path: "real" });
232
+ await store.createNote("", { path: "blank" });
233
+
234
+ await worker.sweepOnce(); // embeds `real`; skips `blank` (never a candidate)
235
+ expect(getNoteVectorRows(db, real.id).length).toBe(1);
236
+
237
+ // A second store over the SAME db, this one wired with the provider
238
+ // (the shared `store` fixture above intentionally has none — this
239
+ // suite tests the worker's direct DB effects, not semanticSearch) —
240
+ // exercises the real Store.semanticSearch pendingCount path against
241
+ // what the worker actually left behind.
242
+ const searchStore = new SqliteStore(db, { embeddingProvider: provider });
243
+ // Before the L4 fix, semanticSearchNotes' own candidate query counted
244
+ // the blank note as a "pending" candidate forever (it has no vector
245
+ // and never will), so pendingCount would report 1 here, permanently,
246
+ // even after every EMBEDDABLE note in the vault is fully drained.
247
+ const result = await searchStore.semanticSearch("something");
248
+ expect(result.totalCandidates).toBe(1); // only the real note is a candidate
249
+ expect(result.pendingCount).toBe(0);
250
+ });
251
+ });
252
+
253
+ describe("registerEmbeddingHook", () => {
254
+ it("kicks the worker on note created/updated via the HookRegistry", async () => {
255
+ const hooks = new HookRegistry({ concurrency: 4, logger: silentLogger });
256
+ const hookedStore = new SqliteStore(db, { hooks });
257
+ registerEmbeddingHook(hooks, worker, () => "default", silentLogger);
258
+
259
+ const note = await hookedStore.createNote("hooked content", { path: "n" });
260
+ // Let the microtask-deferred dispatch + handler run.
261
+ await Promise.resolve();
262
+ await Promise.resolve();
263
+ await hooks.drain();
264
+
265
+ expect(getNoteVectorRows(db, note.id).length).toBe(1);
266
+ });
267
+
268
+ it("logs (doesn't throw) when the vault can't be resolved", async () => {
269
+ const hooks = new HookRegistry({ concurrency: 4, logger: silentLogger });
270
+ const hookedStore = new SqliteStore(db, { hooks });
271
+ let loggedError: unknown;
272
+ registerEmbeddingHook(hooks, worker, () => undefined, { error: (msg) => { loggedError = msg; } });
273
+
274
+ await hookedStore.createNote("content", { path: "n" });
275
+ await Promise.resolve();
276
+ await Promise.resolve();
277
+ await hooks.drain();
278
+
279
+ expect(loggedError).toBeTruthy();
280
+ });
281
+
282
+ it("M1: a note create/update through the hook is a harmless no-op when the worker has no provider", async () => {
283
+ const offWorker = new EmbeddingWorker({
284
+ provider: undefined,
285
+ vaultList: () => ["default"],
286
+ getStore: () => store,
287
+ logger: silentLogger,
288
+ });
289
+ const hooks = new HookRegistry({ concurrency: 4, logger: silentLogger });
290
+ const hookedStore = new SqliteStore(db, { hooks });
291
+ registerEmbeddingHook(hooks, offWorker, () => "default", silentLogger);
292
+
293
+ const note = await hookedStore.createNote("hooked content", { path: "n" });
294
+ await Promise.resolve();
295
+ await Promise.resolve();
296
+ await hooks.drain();
297
+
298
+ expect(getNoteVectorRows(db, note.id)).toEqual([]);
299
+ });
300
+ });