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.
- package/CHANGELOG.md +412 -0
- package/README.md +228 -350
- package/SECURITY.md +68 -0
- package/TRADEMARK.md +31 -0
- package/cli-ui.mjs +78 -0
- package/config.mjs +269 -0
- package/daemon-ctl.mjs +175 -0
- package/daemon-lock.mjs +69 -0
- package/embedders/python/mlx_embed_server.py +61 -0
- package/embedders/python/requirements-mlx.txt +5 -0
- package/embedders/setup-mlx.mjs +110 -0
- package/embeddings.mjs +489 -0
- package/engine/binary.mjs +399 -0
- package/{core-engine.mjs → engine/memory.mjs} +359 -249
- package/engine/sqlite.mjs +863 -0
- package/enrichment.mjs +426 -0
- package/git-signals.mjs +248 -0
- package/indexer.mjs +216 -77
- package/init.mjs +1604 -170
- package/layout.mjs +123 -0
- package/mcp/format.mjs +61 -0
- package/mcp/tools.mjs +1157 -0
- package/mcp/topology.mjs +551 -0
- package/mcp-server.mjs +118 -573
- package/package.json +56 -16
- package/parse/extractor.mjs +617 -0
- package/parse/imports.mjs +365 -0
- package/parse/languages.mjs +89 -0
- package/parse/metadata.mjs +655 -0
- package/parse/routes.mjs +327 -0
- package/prompts/CORE.md +86 -0
- package/prompts/DOMAIN_TEMPLATE.md +46 -0
- package/prompts/INTEGRATION.md +123 -0
- package/prompts/frameworks/ANDROID.md +17 -0
- package/prompts/frameworks/ASPNET_CORE.md +19 -0
- package/prompts/frameworks/FASTAPI_DJANGO.md +19 -0
- package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +18 -0
- package/prompts/frameworks/RAILS.md +20 -0
- package/prompts/frameworks/REACT.md +20 -0
- package/prompts/frameworks/SPRING_BOOT.md +19 -0
- package/prompts/languages/BASH.md +22 -0
- package/prompts/languages/C.md +21 -0
- package/prompts/languages/CSHARP.md +24 -0
- package/prompts/languages/CSS_SCSS.md +22 -0
- package/prompts/languages/GO.md +23 -0
- package/prompts/languages/JAVA.md +23 -0
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +24 -0
- package/prompts/languages/KOTLIN.md +24 -0
- package/prompts/languages/PHP.md +23 -0
- package/prompts/languages/PYTHON.md +23 -0
- package/prompts/languages/RUBY.md +23 -0
- package/prompts/languages/RUST.md +21 -0
- package/prompts/languages/SWIFT.md +21 -0
- package/search-core.mjs +627 -0
- package/storage.mjs +68 -0
- package/watch-daemon.mjs +204 -69
- package/PROMPT.md +0 -143
- 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
|
+
}
|