graph-indexer 1.1.0 → 2.0.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +412 -0
  2. package/README.md +228 -350
  3. package/SECURITY.md +68 -0
  4. package/TRADEMARK.md +31 -0
  5. package/cli-ui.mjs +78 -0
  6. package/config.mjs +269 -0
  7. package/daemon-ctl.mjs +175 -0
  8. package/daemon-lock.mjs +69 -0
  9. package/embedders/python/mlx_embed_server.py +61 -0
  10. package/embedders/python/requirements-mlx.txt +5 -0
  11. package/embedders/setup-mlx.mjs +110 -0
  12. package/embeddings.mjs +489 -0
  13. package/engine/binary.mjs +399 -0
  14. package/{core-engine.mjs → engine/memory.mjs} +359 -249
  15. package/engine/sqlite.mjs +863 -0
  16. package/enrichment.mjs +426 -0
  17. package/git-signals.mjs +248 -0
  18. package/indexer.mjs +216 -77
  19. package/init.mjs +1604 -170
  20. package/layout.mjs +123 -0
  21. package/mcp/format.mjs +61 -0
  22. package/mcp/tools.mjs +1157 -0
  23. package/mcp/topology.mjs +551 -0
  24. package/mcp-server.mjs +118 -573
  25. package/package.json +56 -16
  26. package/parse/extractor.mjs +617 -0
  27. package/parse/imports.mjs +365 -0
  28. package/parse/languages.mjs +89 -0
  29. package/parse/metadata.mjs +655 -0
  30. package/parse/routes.mjs +327 -0
  31. package/prompts/CORE.md +86 -0
  32. package/prompts/DOMAIN_TEMPLATE.md +46 -0
  33. package/prompts/INTEGRATION.md +123 -0
  34. package/prompts/frameworks/ANDROID.md +17 -0
  35. package/prompts/frameworks/ASPNET_CORE.md +19 -0
  36. package/prompts/frameworks/FASTAPI_DJANGO.md +19 -0
  37. package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
  38. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +18 -0
  39. package/prompts/frameworks/RAILS.md +20 -0
  40. package/prompts/frameworks/REACT.md +20 -0
  41. package/prompts/frameworks/SPRING_BOOT.md +19 -0
  42. package/prompts/languages/BASH.md +22 -0
  43. package/prompts/languages/C.md +21 -0
  44. package/prompts/languages/CSHARP.md +24 -0
  45. package/prompts/languages/CSS_SCSS.md +22 -0
  46. package/prompts/languages/GO.md +23 -0
  47. package/prompts/languages/JAVA.md +23 -0
  48. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +24 -0
  49. package/prompts/languages/KOTLIN.md +24 -0
  50. package/prompts/languages/PHP.md +23 -0
  51. package/prompts/languages/PYTHON.md +23 -0
  52. package/prompts/languages/RUBY.md +23 -0
  53. package/prompts/languages/RUST.md +21 -0
  54. package/prompts/languages/SWIFT.md +21 -0
  55. package/search-core.mjs +627 -0
  56. package/storage.mjs +68 -0
  57. package/watch-daemon.mjs +204 -69
  58. package/PROMPT.md +0 -143
  59. package/parser-utils.mjs +0 -861
package/embeddings.mjs ADDED
@@ -0,0 +1,489 @@
1
+ /**
2
+ * @file embeddings.mjs
3
+ * @description Embedding-provider abstraction. graph-indexer's semantic channel
4
+ * historically required a running Ollama daemon plus a pulled model,
5
+ * so out of the box the index was lexical-only. This module adds a
6
+ * second, **in-process** provider (a small sentence-transformer model via
7
+ * the optional `@huggingface/transformers` dependency) so conceptual
8
+ * search works with no external daemon — and an `auto` policy that
9
+ * prefers a running Ollama (the user's higher-quality choice) and
10
+ * transparently falls back to the bundled local model, then to
11
+ * lexical-only, with nothing to configure.
12
+ *
13
+ * Index time and query time MUST embed with the same model, so the
14
+ * indexer stamps the resolved { provider, model, dim } into the index
15
+ * meta and the server reads it back (see indexer.mjs / mcp-server.mjs).
16
+ *
17
+ * @author MaquinaTech <https://github.com/MaquinaTech>
18
+ * @copyright (c) 2026 MaquinaTech. All rights reserved.
19
+ * @license MIT
20
+ */
21
+ import fs from 'fs';
22
+ import { spawn } from 'child_process';
23
+ import { createInterface } from 'readline';
24
+ import { fileURLToPath } from 'url';
25
+ import { dirname, join } from 'path';
26
+ import { truncateForEmbedding } from './search-core.mjs';
27
+ import { mlxVenvPython, mlxEnvReady } from './embedders/setup-mlx.mjs';
28
+
29
+ const __dirname = dirname(fileURLToPath(import.meta.url));
30
+ const PYTHON_DIR = join(__dirname, 'embedders', 'python');
31
+
32
+ export const LOCAL_EMBED_MODEL_DEFAULT = 'Xenova/all-MiniLM-L6-v2';
33
+ export const LOCAL_EMBED_DIM = 384;
34
+ // Passed to the Python server at spawn time (argv) AND stamped into the .meta.json
35
+ // sidecar so index time and query time always agree; the indexer's model-change
36
+ // detection forces a clean re-embed when this differs. Override via config.mlxEmbedModel.
37
+ export const MLX_EMBED_MODEL = 'mlx-community/all-MiniLM-L6-v2-4bit';
38
+ const DOC_CHAR_LIMIT = 8000;
39
+
40
+ // A larger model (e.g. qwen3-embedding:4b) embeds a 64-chunk batch far slower than
41
+ // nomic; the old hard 60s cap silently aborted every batch and produced no vectors.
42
+ // Raised to 120s and overridable for very large models / slow hardware.
43
+ const EMBED_DOC_TIMEOUT_MS = Number(process.env.INDEXER_EMBED_TIMEOUT_MS) > 0
44
+ ? Number(process.env.INDEXER_EMBED_TIMEOUT_MS) : 120000;
45
+
46
+ // ─── Availability probes ─────────────────────────────────────────────────────
47
+
48
+ /** Is a local Ollama reachable? A fast, cheap GET so `auto` can decide quickly. */
49
+ export async function probeOllama(host, timeoutMs = 1500) {
50
+ try {
51
+ const res = await fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(timeoutMs) });
52
+ return res.ok;
53
+ } catch { return false; }
54
+ }
55
+
56
+ /**
57
+ * Does the reachable Ollama actually have `model` pulled? `auto` uses this so a
58
+ * configured embed model that was never `ollama pull`-ed doesn't crash the indexer
59
+ * (embedDocuments would throw on every batch) — instead `auto` falls back to the
60
+ * bundled in-process model. Matches an untagged name against any tag and the
61
+ * implicit `:latest`. Returns false (not throwing) on any error.
62
+ */
63
+ export async function ollamaHasModel(host, model, timeoutMs = 1500) {
64
+ if (!model) return false;
65
+ try {
66
+ const res = await fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(timeoutMs) });
67
+ if (!res.ok) return false;
68
+ const names = ((await res.json()).models || []).map(m => m.name);
69
+ return names.some(n => n === model || n === `${model}:latest`
70
+ || (!model.includes(':') && n.startsWith(`${model}:`)));
71
+ } catch { return false; }
72
+ }
73
+
74
+ /** Is the optional in-process embedding backend installed? */
75
+ export async function localEmbedAvailable() {
76
+ try { await import('@huggingface/transformers'); return true; }
77
+ catch { return false; }
78
+ }
79
+
80
+ /**
81
+ * Decide which provider to use for THIS index/run.
82
+ * • INDEXER_EMBEDDINGS=off / embedProvider 'off' → no vectors (lexical-only).
83
+ * • 'ollama' / 'local' / 'mlx' → forced (mlx is a native Python
84
+ * embedder on the Apple Metal GPU; macOS-only).
85
+ * • 'auto' (default) → running Ollama, else the bundled local model, else off.
86
+ * Probes are injectable for deterministic tests.
87
+ *
88
+ * @returns {Promise<{provider:'ollama'|'local'|'mlx'|'off', model:(string|null)}>}
89
+ */
90
+ export async function resolveEmbedProvider(config, { probe = probeOllama, hasLocal = localEmbedAvailable, hasModel = ollamaHasModel } = {}) {
91
+ if (config.embeddingsEnabled === false) return { provider: 'off', model: null };
92
+ const want = config.embedProvider || 'auto';
93
+ const localModel = config.localEmbedModel || LOCAL_EMBED_MODEL_DEFAULT;
94
+ if (want === 'off') return { provider: 'off', model: null };
95
+ if (want === 'ollama') return { provider: 'ollama', model: config.embedModel };
96
+ if (want === 'local') return { provider: 'local', model: localModel };
97
+ if (want === 'mlx') { assertProviderPlatform('mlx'); return { provider: 'mlx', model: config.mlxEmbedModel || MLX_EMBED_MODEL }; }
98
+ // Prefer Ollama only when it actually HAS the configured model pulled — otherwise
99
+ // the indexer would crash on the first batch. Fall back to in-process, then off.
100
+ if (await probe(config.ollamaHost)) {
101
+ if (await hasModel(config.ollamaHost, config.embedModel)) {
102
+ return { provider: 'ollama', model: config.embedModel };
103
+ }
104
+ if (await hasLocal()) return { provider: 'local', model: localModel };
105
+ return { provider: 'off', model: null };
106
+ }
107
+ if (await hasLocal()) return { provider: 'local', model: localModel };
108
+ return { provider: 'off', model: null };
109
+ }
110
+
111
+ // ─── Low-level backends (injectable for tests) ───────────────────────────────
112
+
113
+ async function _withRetry(fn, tries = 3) {
114
+ for (let a = 0; a <= tries; a++) {
115
+ try { return await fn(); }
116
+ catch (e) { if (a === tries) throw e; await new Promise(r => setTimeout(r, 500 * 2 ** a)); } // exponential backoff
117
+ }
118
+ }
119
+
120
+ // nomic-style models are ASYMMETRIC and require `search_query:` / `search_document:`
121
+ // prefixes. Other embedders (qwen3-embedding, mxbai, …) are trained on raw text —
122
+ // injecting nomic's prefix tokens into them adds noise and degrades retrieval — so
123
+ // the prefix is gated on the model family. Index time and query time MUST agree on
124
+ // the prefix, which they do because both derive it from the same model name.
125
+ export function needsNomicPrefix(model) { return /nomic/i.test(model || ''); }
126
+
127
+ async function _ollamaEmbedOne(host, model, text) {
128
+ const prompt = needsNomicPrefix(model) ? 'search_query: ' + truncateForEmbedding(text) : truncateForEmbedding(text);
129
+ const res = await fetch(`${host}/api/embeddings`, {
130
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
131
+ body: JSON.stringify({ model, prompt }),
132
+ signal: AbortSignal.timeout(15000),
133
+ });
134
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
135
+ return (await res.json()).embedding;
136
+ }
137
+ async function _ollamaEmbedMany(host, model, texts) {
138
+ const pfx = needsNomicPrefix(model) ? 'search_document: ' : '';
139
+ const res = await fetch(`${host}/api/embed`, {
140
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
141
+ body: JSON.stringify({ model, input: texts.map(t => pfx + (t.length > DOC_CHAR_LIMIT ? t.slice(0, DOC_CHAR_LIMIT) : t)) }),
142
+ signal: AbortSignal.timeout(EMBED_DOC_TIMEOUT_MS),
143
+ });
144
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
145
+ return (await res.json()).embeddings;
146
+ }
147
+
148
+ // all-MiniLM is symmetric (unlike nomic), so query and document text need no prefixes.
149
+ let _pipe = null, _pipeModel = null;
150
+ async function _localPipeline(model) {
151
+ if (_pipe && _pipeModel === model) return _pipe;
152
+ let mod;
153
+ try { mod = await import('@huggingface/transformers'); }
154
+ catch {
155
+ throw new Error(
156
+ "Local embeddings need the optional '@huggingface/transformers' package. "
157
+ + "Install it (`npm i @huggingface/transformers`) or set embedProvider to 'ollama'/'off'."
158
+ );
159
+ }
160
+ _pipe = await mod.pipeline('feature-extraction', model);
161
+ _pipeModel = model;
162
+ return _pipe;
163
+ }
164
+ async function _localEmbedMany(model, texts) {
165
+ const pipe = await _localPipeline(model);
166
+ const out = await pipe(
167
+ texts.map(t => (t.length > DOC_CHAR_LIMIT ? t.slice(0, DOC_CHAR_LIMIT) : t)),
168
+ { pooling: 'mean', normalize: true }
169
+ );
170
+ return out.tolist();
171
+ }
172
+
173
+ /** Reset the cached local pipeline (tests). */
174
+ export function _resetLocalPipeline() { _pipe = null; _pipeModel = null; }
175
+
176
+ // ─── Native Python embedder (mlx) ────────────────────────────────────────────
177
+ // MLX (Apple Metal) is Python-first with no Node bindings, so it runs as a long-lived
178
+ // child process speaking newline-delimited JSON:
179
+ // Node → Python: {"texts": ["t1", …, "t32"]}\n
180
+ // Python → Node: {"embeddings": [[…], …]}\n
181
+ // The process is a module-level singleton, started lazily on first use and reused
182
+ // for the lifetime of this process (matching the _localPipeline lazy-cache idea). It
183
+ // runs under the dedicated embedders/venv-mlx interpreter (see embedders/setup-mlx.mjs)
184
+ // so MLX deps never pollute the system Python.
185
+
186
+ // We memoize the in-flight SPAWN PROMISE (not just the resolved proc) so concurrent
187
+ // first callers share ONE spawn instead of each launching its own model server.
188
+ const _procState = {
189
+ mlx: { proc: null, promise: null },
190
+ };
191
+ // Tracked so the process-exit handler can reap all children on shutdown.
192
+ const _liveProcs = new Set();
193
+ let _cleanupInstalled = false;
194
+ function _installProcessCleanup() {
195
+ if (_cleanupInstalled) return;
196
+ _cleanupInstalled = true;
197
+ // Deliberately NOT trapping SIGINT/SIGTERM: a no-op listener would suppress Node's
198
+ // default termination and break Ctrl-C. On Ctrl-C, children get the same group
199
+ // signal directly; on any parent death their stdin closes (EOF) and the Python
200
+ // `for line in sys.stdin` loop self-terminates.
201
+ process.on('exit', () => {
202
+ for (const p of _liveProcs) { try { if (!p.killed) p.kill(); } catch { /* best effort */ } }
203
+ });
204
+ }
205
+
206
+ // MLX Metal benefits from large batches (GPU parallelism); sending one text at a time
207
+ // drowns the Metal win in IPC overhead.
208
+ const MLX_BATCH_SIZE = Number(process.env.INDEXER_MLX_BATCH_SIZE) || 32;
209
+ // The server downloads ~90MB and loads the model before printing READY, so a slow
210
+ // first build must not be killed. Distinct from the dep pre-check, which fails fast.
211
+ const EMBED_STARTUP_TIMEOUT_MS = Number(process.env.INDEXER_EMBED_STARTUP_TIMEOUT_MS) > 0
212
+ ? Number(process.env.INDEXER_EMBED_STARTUP_TIMEOUT_MS) : 120000;
213
+
214
+ /** mlx is macOS-only. Throw a clear, actionable error elsewhere. */
215
+ function assertProviderPlatform(provider) {
216
+ if (provider === 'mlx' && process.platform !== 'darwin') {
217
+ throw new Error(
218
+ `MLX embedder requires macOS Apple Silicon. Current platform: ${process.platform}. `
219
+ + `Use --embed-provider local (any) or --embed-provider ollama.`
220
+ );
221
+ }
222
+ }
223
+
224
+ // Fast-fails before spawning: a missing venv or package raises near-instantly with
225
+ // the one command that fixes it.
226
+ function checkPythonDeps(provider) {
227
+ // Runs under embedders/venv-mlx so its deps never touch system Python.
228
+ if (!mlxEnvReady()) {
229
+ throw new Error(
230
+ `The MLX embedder environment is not ready (missing embedders/venv-mlx or its deps).\n`
231
+ + `Run: npm run embed:setup:mlx\n`
232
+ + `Then restart the indexer.`
233
+ );
234
+ }
235
+ }
236
+
237
+ /** True for a never-spawned, killed, or self-exited (crashed/OOM) process. */
238
+ function _isProcDead(proc) {
239
+ return !proc || proc.killed || proc.exitCode !== null || proc.signalCode != null;
240
+ }
241
+
242
+ /** Kill a provider's subprocess (if any) and clear its cached state so it respawns. */
243
+ function _killProc(provider) {
244
+ const st = _procState[provider];
245
+ const proc = st.proc;
246
+ st.proc = null;
247
+ st.promise = null;
248
+ if (proc && !proc.killed) {
249
+ // Reject in-flight batches now so siblings degrade immediately instead of
250
+ // waiting out their own per-batch timeout (close() would also drain, later).
251
+ const pending = proc._pending || [];
252
+ proc._pending = [];
253
+ for (const r of pending) r.reject(new Error(`${provider} subprocess killed`));
254
+ try { proc.kill(); } catch { /* best effort */ }
255
+ }
256
+ }
257
+
258
+ /** Spawn an embed server, await its "READY" line, and wire up the response demux. */
259
+ async function spawnEmbedServer(provider, model) {
260
+ assertProviderPlatform(provider);
261
+ checkPythonDeps(provider); // fast-fail before spawning
262
+ _installProcessCleanup();
263
+
264
+ const scripts = {
265
+ mlx: join(PYTHON_DIR, 'mlx_embed_server.py'),
266
+ };
267
+ // Pass the resolved model id as argv so the server loads exactly what the meta
268
+ // sidecar stamps. mlxVenvPython() falls back to bare 'python3' only if venv is absent.
269
+ const proc = spawn(mlxVenvPython(), [scripts[provider], model || MLX_EMBED_MODEL], { stdio: ['pipe', 'pipe', 'pipe'] });
270
+ _liveProcs.add(proc);
271
+ proc.stderr.on('data', (d) => process.stderr.write(`[${provider}] ${d}`));
272
+
273
+ // FIFO of pending {resolve,reject}: the Python server answers in order, so each
274
+ // response line resolves the oldest pending entry.
275
+ proc._pending = [];
276
+ proc._ready = false;
277
+
278
+ let onReady, onReadyErr;
279
+ const ready = new Promise((resolve, reject) => { onReady = resolve; onReadyErr = reject; });
280
+
281
+ // On close / stdin error, rejects all in-flight requests so awaiters degrade to
282
+ // lexical instead of hanging forever.
283
+ const drainPending = (err) => {
284
+ const pending = proc._pending;
285
+ proc._pending = [];
286
+ for (const r of pending) r.reject(err);
287
+ };
288
+
289
+ // Single persistent readline: one interface avoids dropping buffered bytes that a
290
+ // closed-then-reopened interface could lose.
291
+ proc._rl = createInterface({ input: proc.stdout });
292
+ proc._rl.on('line', (line) => {
293
+ if (!proc._ready) {
294
+ const t = line.trim();
295
+ if (t === 'READY') { proc._ready = true; onReady(); return; }
296
+ // Server prints {"error":...} then exits if the model fails to load; surface
297
+ // that specific error rather than a generic unexpected-line message.
298
+ let parsed = null; try { parsed = JSON.parse(t); } catch { /* not JSON */ }
299
+ proc.kill();
300
+ onReadyErr(new Error(parsed?.error
301
+ ? `${provider} failed to start: ${parsed.error}`
302
+ : `${provider} subprocess sent unexpected first line: ${line}`));
303
+ return;
304
+ }
305
+ const resolver = proc._pending.shift();
306
+ if (!resolver) return;
307
+ try {
308
+ const msg = JSON.parse(line);
309
+ if (msg.error) resolver.reject(new Error(`${provider}: ${msg.error}`));
310
+ else resolver.resolve(msg.embeddings);
311
+ } catch {
312
+ resolver.reject(new Error(`${provider}: bad JSON: ${line}`));
313
+ }
314
+ });
315
+
316
+ const timeout = setTimeout(() => {
317
+ proc.kill();
318
+ onReadyErr(new Error(
319
+ `${provider} embedder subprocess did not print READY within ${EMBED_STARTUP_TIMEOUT_MS}ms `
320
+ + `(first run downloads the model — raise INDEXER_EMBED_STARTUP_TIMEOUT_MS if needed).`));
321
+ }, EMBED_STARTUP_TIMEOUT_MS);
322
+
323
+ proc.on('error', (err) => { clearTimeout(timeout); onReadyErr(err); drainPending(err); });
324
+ // A broken pipe (subprocess died mid-write) emits an async 'error' on stdin; an
325
+ // unhandled stream 'error' is FATAL to the whole Node process — swallow it here
326
+ // and fail the in-flight batch so the indexer/server degrades gracefully.
327
+ proc.stdin.on('error', (err) => drainPending(new Error(`${provider} stdin error: ${err.message}`)));
328
+ proc.on('close', (code, signal) => {
329
+ clearTimeout(timeout);
330
+ _liveProcs.delete(proc);
331
+ // Guard: only clear the singleton if it still points at THIS proc, not a fresh respawn.
332
+ if (_procState[provider].proc === proc) _procState[provider].proc = null;
333
+ const why = `${provider} subprocess exited (code ${code}${signal ? `, signal ${signal}` : ''})`;
334
+ if (!proc._ready) onReadyErr(new Error(`${why} before READY`));
335
+ drainPending(new Error(why));
336
+ });
337
+
338
+ await ready;
339
+ clearTimeout(timeout);
340
+ return proc;
341
+ }
342
+
343
+ /**
344
+ * Resolve the provider's live subprocess, spawning lazily. Memoizes the in-flight
345
+ * spawn PROMISE so concurrent first callers share one spawn (no duplicate servers).
346
+ * The model is fixed for the lifetime of an index/query run, so the first spawn's
347
+ * model wins for the reused singleton.
348
+ */
349
+ function _getEmbedProc(provider, model) {
350
+ const st = _procState[provider];
351
+ if (!_isProcDead(st.proc)) return Promise.resolve(st.proc);
352
+ if (!st.promise) {
353
+ st.promise = spawnEmbedServer(provider, model).then(
354
+ (proc) => { st.proc = proc; st.promise = null; return proc; },
355
+ (err) => { st.proc = null; st.promise = null; throw err; },
356
+ );
357
+ }
358
+ return st.promise;
359
+ }
360
+
361
+ /** Embed `texts` via the provider's subprocess, batching to keep IPC efficient. */
362
+ async function _subprocessEmbedMany(provider, texts, model) {
363
+ const proc = await _getEmbedProc(provider, model);
364
+ const batchSize = MLX_BATCH_SIZE;
365
+
366
+ const results = [];
367
+ for (let i = 0; i < texts.length; i += batchSize) {
368
+ const batch = texts.slice(i, i + batchSize);
369
+ const embeddings = await new Promise((resolve, reject) => {
370
+ let settled = false;
371
+ // A hung inference (no crash, no exit) must not hang the indexer forever;
372
+ // mirrors the Ollama path's EMBED_DOC_TIMEOUT_MS guard.
373
+ const to = setTimeout(() => {
374
+ if (settled) return; settled = true;
375
+ _killProc(provider); // unrecoverable: kill so the next call respawns
376
+ reject(new Error(`${provider}: embedding batch timed out after ${EMBED_DOC_TIMEOUT_MS}ms`));
377
+ }, EMBED_DOC_TIMEOUT_MS);
378
+ const entry = {
379
+ resolve: (v) => { if (settled) return; settled = true; clearTimeout(to); resolve(v); },
380
+ reject: (e) => { if (settled) return; settled = true; clearTimeout(to); reject(e); },
381
+ };
382
+ // Write FIRST, enqueue only on success: a synchronous write throw (broken
383
+ // pipe) must not leave a phantom entry in the FIFO — that would desync the
384
+ // demux and assign every later batch the wrong vectors.
385
+ try {
386
+ proc.stdin.write(JSON.stringify({ texts: batch }) + '\n');
387
+ proc._pending.push(entry);
388
+ } catch (e) { entry.reject(e); }
389
+ });
390
+ results.push(...embeddings);
391
+ }
392
+ return results; // number[][]
393
+ }
394
+
395
+ /** Reset (kill) the cached embed subprocesses (tests / clean shutdown). */
396
+ export function _resetSubprocesses() {
397
+ _killProc('mlx');
398
+ }
399
+
400
+ /** Canonical model id for a forced provider when no explicit model is supplied. */
401
+ function defaultModelForProvider(provider, config) {
402
+ if (provider === 'local') return config.localEmbedModel || LOCAL_EMBED_MODEL_DEFAULT;
403
+ if (provider === 'mlx') return config.mlxEmbedModel || MLX_EMBED_MODEL;
404
+ return config.embedModel;
405
+ }
406
+
407
+ // ─── Public embedder ─────────────────────────────────────────────────────────
408
+
409
+ /**
410
+ * Build an embedder bound to a resolved provider/model.
411
+ *
412
+ * @param {object} config
413
+ * @param {object} [opts]
414
+ * @param {'ollama'|'local'|'mlx'|'off'} [opts.provider] Force a provider (e.g.
415
+ * the one stamped in the index meta). When omitted, resolveEmbedProvider decides.
416
+ * @param {string} [opts.model]
417
+ * @param {object} [opts.backends] Inject { ollamaEmbedOne, ollamaEmbedMany, localEmbedMany } (tests).
418
+ * @returns {Promise<{provider:string, model:(string|null), dim:(number|null),
419
+ * embedQuery:(t:string)=>Promise<number[]|null>,
420
+ * embedDocuments:(t:string[])=>Promise<number[][]|null>}>}
421
+ */
422
+ export async function createEmbedder(config, opts = {}) {
423
+ const resolved = opts.provider
424
+ ? { provider: opts.provider, model: opts.model ?? defaultModelForProvider(opts.provider, config) }
425
+ : await resolveEmbedProvider(config, opts);
426
+ const { provider, model } = resolved;
427
+ const host = config.ollamaHost;
428
+ const b = opts.backends || {};
429
+ const ollamaOne = b.ollamaEmbedOne || _ollamaEmbedOne;
430
+ const ollamaMany = b.ollamaEmbedMany || _ollamaEmbedMany;
431
+ const localMany = b.localEmbedMany || _localEmbedMany;
432
+ // Derive dim lazily from the first returned vector. Pre-setting LOCAL_EMBED_DIM=384
433
+ // mis-stamps models whose dim isn't 384 (e.g. jina-embeddings-v2-base-code = 768)
434
+ // into the .meta.json before the first embed call.
435
+ let dim = null;
436
+
437
+ async function embedQuery(text) {
438
+ if (provider === 'off' || !text) return null;
439
+ try {
440
+ if (provider === 'ollama') return await _withRetry(() => ollamaOne(host, model, text));
441
+ if (provider === 'mlx') {
442
+ const [v] = await _subprocessEmbedMany(provider, [text], model);
443
+ if (v && dim == null) dim = v.length;
444
+ return v ?? null;
445
+ }
446
+ const [v] = await localMany(model, [text]);
447
+ if (v && dim == null) dim = v.length;
448
+ return v ?? null;
449
+ } catch { return null; } // graceful: caller drops to lexical
450
+ }
451
+
452
+ async function embedDocuments(texts) {
453
+ if (provider === 'off' || !texts || texts.length === 0) return null;
454
+ let vecs;
455
+ if (provider === 'ollama') vecs = await _withRetry(() => ollamaMany(host, model, texts));
456
+ else if (provider === 'mlx') vecs = await _subprocessEmbedMany(provider, texts, model);
457
+ else vecs = await localMany(model, texts);
458
+ if (vecs && vecs[0] && dim == null) dim = vecs[0].length;
459
+ return vecs;
460
+ }
461
+
462
+ return { provider, model, get dim() { return dim; }, embedQuery, embedDocuments };
463
+ }
464
+
465
+ // ─── Sidecar embed-meta (provider/model/dim that produced the shared bin) ─────
466
+ // Both backends share `code-index.embeddings.bin`; this records which model wrote
467
+ // it so (a) the indexer re-embeds from scratch when the model changes (vectors of
468
+ // different models/dims must not be mixed), and (b) the server queries with the
469
+ // same provider the index was built with.
470
+
471
+ export function embedMetaPath(embeddingPath) { return `${embeddingPath}.meta.json`; }
472
+
473
+ export function readEmbedMeta(embeddingPath) {
474
+ try { return JSON.parse(fs.readFileSync(embedMetaPath(embeddingPath), 'utf8')); }
475
+ catch { return null; }
476
+ }
477
+
478
+ export function writeEmbedMeta(embeddingPath, meta) {
479
+ try { fs.writeFileSync(embedMetaPath(embeddingPath), JSON.stringify(meta)); }
480
+ catch { /* best-effort: server falls back to config */ }
481
+ }
482
+
483
+ /** One-line, human-readable provider label for logs / list_index_stats. */
484
+ export function describeEmbedder({ provider, model } = {}) {
485
+ if (provider === 'ollama') return `🧠 Ollama · ${model}`;
486
+ if (provider === 'local') return `🧠 Local (in-process) · ${model}`;
487
+ if (provider === 'mlx') return `🧠 MLX (Apple Metal) · ${model}`;
488
+ return '🔤 Lexical only';
489
+ }