@openparachute/vault 0.7.3-rc.1 → 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.
- package/README.md +5 -3
- package/core/src/attachment/policy.test.ts +66 -0
- package/core/src/attachment/policy.ts +131 -0
- package/core/src/attachment/tickets.test.ts +45 -0
- package/core/src/attachment/tickets.ts +117 -0
- package/core/src/attachment-tickets-tool.test.ts +286 -0
- package/core/src/conformance.ts +2 -1
- package/core/src/contract-typed-index.test.ts +4 -3
- package/core/src/core.test.ts +521 -4
- package/core/src/display-title.test.ts +190 -0
- package/core/src/embedding/chunker.test.ts +97 -0
- package/core/src/embedding/chunker.ts +180 -0
- package/core/src/embedding/provider.ts +108 -0
- package/core/src/embedding/staleness.test.ts +87 -0
- package/core/src/embedding/staleness.ts +83 -0
- package/core/src/embedding/vector-codec.test.ts +99 -0
- package/core/src/embedding/vector-codec.ts +60 -0
- package/core/src/embedding/vectors.test.ts +163 -0
- package/core/src/embedding/vectors.ts +135 -0
- package/core/src/expand.ts +11 -3
- package/core/src/indexed-fields.test.ts +9 -3
- package/core/src/indexed-fields.ts +9 -1
- package/core/src/lede.test.ts +96 -0
- package/core/src/mcp-semantic-search.test.ts +160 -0
- package/core/src/mcp.ts +381 -10
- package/core/src/notes.semantic-search.test.ts +131 -0
- package/core/src/notes.ts +313 -6
- package/core/src/query-warnings.ts +20 -0
- package/core/src/schema-defaults.ts +85 -1
- package/core/src/schema-v27-note-vectors.test.ts +178 -0
- package/core/src/schema.ts +81 -1
- package/core/src/search-fts-v25.test.ts +9 -1
- package/core/src/search-query.test.ts +42 -0
- package/core/src/search-query.ts +27 -0
- package/core/src/search-title-boost.test.ts +125 -0
- package/core/src/seed-packs.test.ts +117 -1
- package/core/src/seed-packs.ts +217 -1
- package/core/src/store.semantic-search.test.ts +236 -0
- package/core/src/store.ts +162 -5
- package/core/src/tag-schemas.ts +27 -12
- package/core/src/types.ts +55 -1
- package/core/src/vault-projection.ts +48 -0
- package/package.json +6 -1
- package/src/add-pack.test.ts +32 -0
- package/src/attachment-tickets.test.ts +350 -0
- package/src/attachment-tickets.ts +264 -0
- package/src/cli.ts +5 -1
- package/src/contract-errors.test.ts +2 -1
- package/src/embedding/capability.test.ts +33 -0
- package/src/embedding/capability.ts +34 -0
- package/src/embedding/external-api.test.ts +154 -0
- package/src/embedding/external-api.ts +144 -0
- package/src/embedding/onnx-transformers.test.ts +113 -0
- package/src/embedding/onnx-transformers.ts +141 -0
- package/src/embedding/select.test.ts +99 -0
- package/src/embedding/select.ts +92 -0
- package/src/embedding-worker.test.ts +300 -0
- package/src/embedding-worker.ts +226 -0
- package/src/mcp-http.ts +13 -1
- package/src/mcp-tools.ts +49 -14
- package/src/onboarding-seed.test.ts +64 -0
- package/src/routes.ts +146 -92
- package/src/routing.ts +17 -0
- package/src/semantic-search-routes.test.ts +161 -0
- package/src/server.ts +21 -2
- package/src/vault-embeddings-capability.test.ts +82 -0
- package/src/vault-store-embedding-wiring.test.ts +69 -0
- package/src/vault-store.ts +53 -2
- package/src/vault.test.ts +35 -11
|
@@ -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
|
+
});
|