@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.
- 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 +607 -11
- 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 +405 -10
- package/core/src/notes.semantic-search.test.ts +131 -0
- package/core/src/notes.ts +319 -7
- package/core/src/query-warnings.ts +40 -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 +63 -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/auth.ts +8 -2
- package/src/cli.ts +5 -1
- package/src/contract-errors.test.ts +2 -1
- package/src/contract-honest-queries.test.ts +88 -0
- package/src/contract-search.test.ts +41 -0
- 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 +173 -92
- package/src/routing.ts +17 -0
- package/src/scribe-discovery.test.ts +76 -1
- package/src/scribe-discovery.ts +28 -9
- 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 +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
|
+
});
|