@knowall-ai/reverie 0.4.0

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.
@@ -0,0 +1,303 @@
1
+ export const EMBEDDING_FIELDS = ['embedding', 'name_embedding', 'embedding_model', 'embedded_at'];
2
+ const DEFAULT_MODELS = {
3
+ local: 'Xenova/all-MiniLM-L6-v2',
4
+ openai: 'text-embedding-3-small',
5
+ azure: 'text-embedding-3-small',
6
+ ollama: 'nomic-embed-text',
7
+ voyage: 'voyage-3-lite'
8
+ };
9
+ function getProvider(env) {
10
+ const raw = env.REVERIE_EMBEDDINGS?.trim().toLowerCase() ?? 'local';
11
+ if (raw === 'local' || raw === 'openai' || raw === 'azure' || raw === 'ollama' || raw === 'voyage') {
12
+ return raw;
13
+ }
14
+ if (raw === 'none') {
15
+ return 'none';
16
+ }
17
+ console.error(`Embeddings disabled: unknown provider "${env.REVERIE_EMBEDDINGS}"`);
18
+ return 'none';
19
+ }
20
+ function getModel(env, provider) {
21
+ return env.REVERIE_EMBEDDING_MODEL?.trim() || DEFAULT_MODELS[provider];
22
+ }
23
+ const DEFAULT_TIMEOUT_MS = 30_000;
24
+ const MAX_TIMEOUT_MS = 120_000;
25
+ /** REVERIE_EMBED_TIMEOUT_MS, whole milliseconds, clamped to 1s..120s. */
26
+ function configuredTimeoutMs(env) {
27
+ const raw = (env.REVERIE_EMBED_TIMEOUT_MS ?? '').trim();
28
+ const value = /^\d+$/.test(raw) ? Number(raw) : DEFAULT_TIMEOUT_MS;
29
+ return Math.min(Math.max(value, 1_000), MAX_TIMEOUT_MS);
30
+ }
31
+ /** Credentials only travel over TLS; plain http is allowed for loopback hosts (local proxies). */
32
+ export function secureEndpoint(raw, name) {
33
+ let url;
34
+ try {
35
+ url = new URL(raw);
36
+ }
37
+ catch {
38
+ throw new Error(`${name} is not a valid URL`);
39
+ }
40
+ const loopback = ['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname);
41
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
42
+ throw new Error(`${name} must use https (http is only accepted for localhost)`);
43
+ }
44
+ return url.toString().replace(/\/$/, '');
45
+ }
46
+ function withTimeout(promise, ms, message) {
47
+ let timer;
48
+ const deadline = new Promise((_, reject) => {
49
+ timer = setTimeout(() => reject(new Error(message)), ms);
50
+ });
51
+ return Promise.race([promise, deadline]).finally(() => clearTimeout(timer));
52
+ }
53
+ /** Credentials never follow a redirect (a cross-origin or https→http hop would leak them); every request is bounded by timeoutMs. */
54
+ async function fetchJson(url, init, timeoutMs) {
55
+ const controller = new AbortController();
56
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
57
+ try {
58
+ const response = await fetch(url, { ...init, signal: controller.signal, redirect: 'error' });
59
+ if (!response.ok) {
60
+ const body = (await response.text()).slice(0, 200);
61
+ throw new Error(`Embedding request failed ${response.status}: ${body}`);
62
+ }
63
+ return await response.json();
64
+ }
65
+ finally {
66
+ clearTimeout(timer);
67
+ }
68
+ }
69
+ async function embedRemote(texts, doRequest) {
70
+ const vectors = [];
71
+ for (let index = 0; index < texts.length; index += 64) {
72
+ const batch = texts.slice(index, index + 64);
73
+ const batchVectors = await doRequest(batch);
74
+ // Vectors are paired back to inputs by position, so a short batch would shift every later pair.
75
+ if (batchVectors.length !== batch.length) {
76
+ throw new Error(`Embedding provider returned ${batchVectors.length} vectors for ${batch.length} inputs`);
77
+ }
78
+ vectors.push(...batchVectors);
79
+ }
80
+ return vectors;
81
+ }
82
+ /** OpenAI-style `{ data: [{ index, embedding }] }`: order by the declared index, not array position. */
83
+ function vectorsFromData(data) {
84
+ if (!Array.isArray(data)) {
85
+ return [];
86
+ }
87
+ const items = data;
88
+ const ordered = items.every((item) => typeof item.index === 'number')
89
+ ? [...items].sort((a, b) => a.index - b.index)
90
+ : items;
91
+ return ordered.map((item) => normalizeVector(item.embedding));
92
+ }
93
+ function normalizeVector(value) {
94
+ if (!Array.isArray(value)) {
95
+ return [];
96
+ }
97
+ return value.map((item) => Number(item));
98
+ }
99
+ export function createEmbedder(env = process.env) {
100
+ const provider = getProvider(env);
101
+ if (provider === 'none') {
102
+ if ((env.REVERIE_EMBEDDINGS?.trim().toLowerCase() ?? '') === 'none') {
103
+ console.error('Embeddings disabled: provider set to none');
104
+ }
105
+ return null;
106
+ }
107
+ const model = getModel(env, provider);
108
+ const timeoutMs = configuredTimeoutMs(env);
109
+ if (provider === 'local') {
110
+ let extractorPromise = null;
111
+ return {
112
+ id: `${provider}/${model}`,
113
+ async embed(texts) {
114
+ if (texts.length === 0) {
115
+ return [];
116
+ }
117
+ if (!extractorPromise) {
118
+ // Loading may download the model; bound it, and forget a failed load so a later
119
+ // call can retry instead of falling back to keyword search until the process restarts.
120
+ const loading = (async () => {
121
+ const transformers = await import('@huggingface/transformers');
122
+ if (env.REVERIE_MODEL_CACHE?.trim()) {
123
+ transformers.env.cacheDir = env.REVERIE_MODEL_CACHE.trim();
124
+ }
125
+ return transformers.pipeline('feature-extraction', model, { dtype: 'q8' });
126
+ })();
127
+ extractorPromise = withTimeout(loading, timeoutMs, `local embedding model ${model} did not load within ${timeoutMs}ms`)
128
+ .catch((error) => {
129
+ extractorPromise = null;
130
+ throw error;
131
+ });
132
+ }
133
+ const extractor = await extractorPromise;
134
+ const output = await extractor(texts, { pooling: 'mean', normalize: true });
135
+ const vectors = Array.isArray(output) ? output : output.tolist?.();
136
+ if (!Array.isArray(vectors)) {
137
+ throw new Error('Local embedding pipeline returned an unexpected result');
138
+ }
139
+ return vectors.map((vector) => normalizeVector(vector));
140
+ }
141
+ };
142
+ }
143
+ if (provider === 'openai') {
144
+ const apiKey = env.OPENAI_API_KEY?.trim();
145
+ if (!apiKey) {
146
+ throw new Error('OPENAI_API_KEY is required for OpenAI embeddings');
147
+ }
148
+ const baseUrl = secureEndpoint(env.OPENAI_BASE_URL?.trim() || 'https://api.openai.com/v1', 'OPENAI_BASE_URL');
149
+ return {
150
+ id: `${provider}/${model}`,
151
+ async embed(texts) {
152
+ return embedRemote(texts, async (batch) => {
153
+ const data = await fetchJson(`${baseUrl}/embeddings`, {
154
+ method: 'POST',
155
+ headers: {
156
+ 'Authorization': `Bearer ${apiKey}`,
157
+ 'Content-Type': 'application/json'
158
+ },
159
+ body: JSON.stringify({ model, input: batch })
160
+ }, timeoutMs);
161
+ return vectorsFromData(data.data);
162
+ });
163
+ }
164
+ };
165
+ }
166
+ if (provider === 'azure') {
167
+ const rawEndpoint = env.AZURE_OPENAI_ENDPOINT?.trim();
168
+ const apiKey = env.AZURE_OPENAI_API_KEY?.trim();
169
+ const deployment = env.AZURE_OPENAI_EMBEDDING_DEPLOYMENT?.trim();
170
+ const apiVersion = env.AZURE_OPENAI_API_VERSION?.trim() || '2024-10-21';
171
+ if (!rawEndpoint) {
172
+ throw new Error('AZURE_OPENAI_ENDPOINT is required for Azure embeddings');
173
+ }
174
+ const endpoint = secureEndpoint(rawEndpoint, 'AZURE_OPENAI_ENDPOINT');
175
+ if (!apiKey) {
176
+ throw new Error('AZURE_OPENAI_API_KEY is required for Azure embeddings');
177
+ }
178
+ if (!deployment) {
179
+ throw new Error('AZURE_OPENAI_EMBEDDING_DEPLOYMENT is required for Azure embeddings');
180
+ }
181
+ return {
182
+ id: `${provider}/${deployment}`,
183
+ async embed(texts) {
184
+ return embedRemote(texts, async (batch) => {
185
+ const data = await fetchJson(`${endpoint}/openai/deployments/${deployment}/embeddings?api-version=${apiVersion}`, {
186
+ method: 'POST',
187
+ headers: {
188
+ 'api-key': apiKey,
189
+ 'Content-Type': 'application/json'
190
+ },
191
+ body: JSON.stringify({ model, input: batch })
192
+ }, timeoutMs);
193
+ return vectorsFromData(data.data);
194
+ });
195
+ }
196
+ };
197
+ }
198
+ if (provider === 'ollama') {
199
+ const host = secureEndpoint(env.OLLAMA_HOST?.trim() || 'http://127.0.0.1:11434', 'OLLAMA_HOST');
200
+ return {
201
+ id: `${provider}/${model}`,
202
+ async embed(texts) {
203
+ return embedRemote(texts, async (batch) => {
204
+ const data = await fetchJson(`${host}/api/embed`, {
205
+ method: 'POST',
206
+ headers: {
207
+ 'Content-Type': 'application/json'
208
+ },
209
+ body: JSON.stringify({ model, input: batch })
210
+ }, timeoutMs);
211
+ return Array.isArray(data.embeddings)
212
+ ? data.embeddings.map((item) => normalizeVector(item))
213
+ : [];
214
+ });
215
+ }
216
+ };
217
+ }
218
+ const apiKey = env.VOYAGE_API_KEY?.trim();
219
+ if (!apiKey) {
220
+ throw new Error('VOYAGE_API_KEY is required for Voyage embeddings');
221
+ }
222
+ return {
223
+ id: `${provider}/${model}`,
224
+ async embed(texts) {
225
+ return embedRemote(texts, async (batch) => {
226
+ const data = await fetchJson('https://api.voyageai.com/v1/embeddings', {
227
+ method: 'POST',
228
+ headers: {
229
+ 'Authorization': `Bearer ${apiKey}`,
230
+ 'Content-Type': 'application/json'
231
+ },
232
+ body: JSON.stringify({ model, input: batch })
233
+ }, timeoutMs);
234
+ return vectorsFromData(data.data);
235
+ });
236
+ }
237
+ };
238
+ }
239
+ export function embeddingText(label, props) {
240
+ const skipKeys = new Set([...EMBEDDING_FIELDS, 'created_at', 'updated_at']);
241
+ const lines = [`${label}: ${String(props.name ?? '')}`.trim()];
242
+ for (const [key, value] of Object.entries(props)) {
243
+ if (key === 'name' || skipKeys.has(key) || key.endsWith('_id') || key.endsWith('Id')) {
244
+ continue;
245
+ }
246
+ if (typeof value === 'string') {
247
+ lines.push(`${key}: ${value}`);
248
+ continue;
249
+ }
250
+ if (Array.isArray(value) && value.every((item) => typeof item === 'string' || typeof item === 'number')) {
251
+ lines.push(`${key}: ${value.join(', ')}`);
252
+ }
253
+ }
254
+ return lines.join('\n').slice(0, 1000);
255
+ }
256
+ /** Label, name and aliases only. Kept separate because descriptive text dilutes name similarity badly. */
257
+ export function nameText(label, props) {
258
+ const lines = [`${label}: ${String(props.name ?? '')}`.trim()];
259
+ const aliases = props.aliases;
260
+ if (Array.isArray(aliases) && aliases.length > 0) {
261
+ lines.push(`aliases: ${aliases.map((alias) => String(alias)).join(', ')}`);
262
+ }
263
+ return lines.join('\n');
264
+ }
265
+ /** Embed a batch of nodes, two texts each, and pair the vectors back up per node. */
266
+ export async function embedNodes(embedder, nodes) {
267
+ const texts = nodes.flatMap((node) => [embeddingText(node.label, node.props), nameText(node.label, node.props)]);
268
+ const vectors = await embedder.embed(texts);
269
+ return nodes.map((_, index) => ({
270
+ embedding: vectors[index * 2] ?? [],
271
+ name_embedding: vectors[index * 2 + 1] ?? []
272
+ }));
273
+ }
274
+ export function cosine(a, b) {
275
+ if (a.length === 0 || a.length !== b.length) {
276
+ return 0;
277
+ }
278
+ let dot = 0;
279
+ let aNorm = 0;
280
+ let bNorm = 0;
281
+ for (let index = 0; index < a.length; index += 1) {
282
+ dot += a[index] * b[index];
283
+ aNorm += a[index] * a[index];
284
+ bNorm += b[index] * b[index];
285
+ }
286
+ if (aNorm === 0 || bNorm === 0) {
287
+ return 0;
288
+ }
289
+ return dot / (Math.sqrt(aNorm) * Math.sqrt(bNorm));
290
+ }
291
+ export function scrub(value) {
292
+ const blocked = new Set(EMBEDDING_FIELDS);
293
+ if (Array.isArray(value)) {
294
+ return value.map((item) => scrub(item));
295
+ }
296
+ if (value && typeof value === 'object') {
297
+ const entries = Object.entries(value)
298
+ .filter(([key]) => !blocked.has(key))
299
+ .map(([key, item]) => [key, scrub(item)]);
300
+ return Object.fromEntries(entries);
301
+ }
302
+ return value;
303
+ }