graph-indexer 2.1.1 → 3.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 +260 -1
- package/README.md +232 -312
- package/SECURITY.md +37 -59
- package/bin/graph-indexer.mjs +251 -0
- package/integrations/claude-code/.claude-plugin/plugin.json +8 -0
- package/integrations/claude-code/.mcp.json +8 -0
- package/integrations/claude-code/agents/code-structure.md +22 -0
- package/integrations/claude-code/hooks/gi-hook.mjs +99 -0
- package/integrations/claude-code/hooks/hooks.json +16 -0
- package/integrations/opencode/graph-indexer.js +124 -0
- package/package.json +32 -109
- package/src/cli/helper.mjs +67 -0
- package/src/cli/hook.mjs +356 -0
- package/src/cli/init.mjs +255 -0
- package/src/cli/resident.mjs +175 -0
- package/src/index/discover.mjs +130 -0
- package/src/index/fingerprint.mjs +30 -0
- package/src/index/graph.mjs +61 -0
- package/src/index/indexer.mjs +476 -0
- package/src/index/modules.mjs +331 -0
- package/src/index/resolver.mjs +954 -0
- package/src/mcp/render.mjs +60 -0
- package/src/mcp/server.mjs +147 -0
- package/src/mcp/tools.mjs +973 -0
- package/src/parse/arity.mjs +152 -0
- package/src/parse/extract.mjs +1031 -0
- package/src/parse/lang/csharp.mjs +106 -0
- package/src/parse/lang/ecma.mjs +438 -0
- package/src/parse/lang/go.mjs +194 -0
- package/src/parse/lang/jvm.mjs +118 -0
- package/src/parse/lang/kotlin.mjs +97 -0
- package/src/parse/lang/misc.mjs +115 -0
- package/src/parse/lang/native.mjs +126 -0
- package/src/parse/lang/python.mjs +153 -0
- package/src/parse/lang/rust.mjs +157 -0
- package/src/parse/lang/scripting.mjs +219 -0
- package/src/parse/languages.mjs +48 -0
- package/src/parse/runtime.mjs +56 -0
- package/src/query/check.mjs +343 -0
- package/src/query/facts.mjs +323 -0
- package/src/query/intel.mjs +680 -0
- package/src/query/read.mjs +81 -0
- package/src/query/testcmd.mjs +119 -0
- package/src/query/textsearch.mjs +160 -0
- package/src/search/search.mjs +210 -0
- package/src/search/tokenize.mjs +188 -0
- package/src/store/db.mjs +169 -0
- package/src/util/paths.mjs +24 -0
- package/vendor/grammars/bash.wasm.gz +0 -0
- package/vendor/grammars/c.wasm.gz +0 -0
- package/vendor/grammars/c_sharp.wasm.gz +0 -0
- package/vendor/grammars/cpp.wasm.gz +0 -0
- package/vendor/grammars/css.wasm.gz +0 -0
- package/vendor/grammars/go.wasm.gz +0 -0
- package/vendor/grammars/java.wasm.gz +0 -0
- package/vendor/grammars/javascript.wasm.gz +0 -0
- package/vendor/grammars/kotlin.wasm.gz +0 -0
- package/vendor/grammars/manifest.json +148 -0
- package/vendor/grammars/php.wasm.gz +0 -0
- package/vendor/grammars/python.wasm.gz +0 -0
- package/vendor/grammars/ruby.wasm.gz +0 -0
- package/vendor/grammars/rust.wasm.gz +0 -0
- package/vendor/grammars/scala.wasm.gz +0 -0
- package/vendor/grammars/tsx.wasm.gz +0 -0
- package/vendor/grammars/typescript.wasm.gz +0 -0
- package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
- package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
- package/cli-ui.mjs +0 -78
- package/colbert.mjs +0 -168
- package/config.mjs +0 -494
- package/daemon-ctl.mjs +0 -175
- package/daemon-lock.mjs +0 -69
- package/embedders/python/mlx_embed_server.py +0 -61
- package/embedders/python/requirements-mlx.txt +0 -5
- package/embedders/setup-mlx.mjs +0 -110
- package/embeddings.mjs +0 -571
- package/engine/binary.mjs +0 -437
- package/engine/memory.mjs +0 -1003
- package/engine/sqlite.mjs +0 -1136
- package/enrichment.mjs +0 -521
- package/git-signals.mjs +0 -248
- package/indexer.mjs +0 -546
- package/init.mjs +0 -1809
- package/layout.mjs +0 -123
- package/mcp/centrality.mjs +0 -117
- package/mcp/format.mjs +0 -61
- package/mcp/resolver.mjs +0 -116
- package/mcp/symbolgraph.mjs +0 -137
- package/mcp/taint.mjs +0 -225
- package/mcp/tools.mjs +0 -1582
- package/mcp/topology.mjs +0 -676
- package/mcp-server.mjs +0 -197
- package/parse/extractor.mjs +0 -628
- package/parse/imports.mjs +0 -365
- package/parse/interprocedural.mjs +0 -134
- package/parse/languages.mjs +0 -253
- package/parse/metadata.mjs +0 -701
- package/parse/routes.mjs +0 -327
- package/parse/scip.mjs +0 -391
- package/parse/taint-patterns.mjs +0 -119
- package/prompts/CORE.md +0 -91
- package/prompts/DOMAIN_TEMPLATE.md +0 -46
- package/prompts/INTEGRATION.md +0 -123
- package/prompts/frameworks/ANDROID.md +0 -17
- package/prompts/frameworks/ASPNET_CORE.md +0 -19
- package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
- package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
- package/prompts/frameworks/RAILS.md +0 -20
- package/prompts/frameworks/REACT.md +0 -20
- package/prompts/frameworks/SPRING_BOOT.md +0 -20
- package/prompts/languages/BASH.md +0 -22
- package/prompts/languages/C.md +0 -21
- package/prompts/languages/CSHARP.md +0 -24
- package/prompts/languages/CSS_SCSS.md +0 -22
- package/prompts/languages/GO.md +0 -24
- package/prompts/languages/JAVA.md +0 -23
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
- package/prompts/languages/KOTLIN.md +0 -24
- package/prompts/languages/PHP.md +0 -23
- package/prompts/languages/PYTHON.md +0 -23
- package/prompts/languages/RUBY.md +0 -23
- package/prompts/languages/RUST.md +0 -21
- package/prompts/languages/SWIFT.md +0 -21
- package/seal.mjs +0 -400
- package/search-core.mjs +0 -777
- package/search-sparse.mjs +0 -183
- package/storage.mjs +0 -73
- package/watch-daemon.mjs +0 -289
package/embeddings.mjs
DELETED
|
@@ -1,571 +0,0 @@
|
|
|
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
|
-
// Code-specialized in-process embedder (provider 'code-local'). Same optional
|
|
35
|
-
// @huggingface/transformers feature-extraction path as the general-purpose 'local'
|
|
36
|
-
// MiniLM, but a model trained on code semantics (identifiers, call patterns, type
|
|
37
|
-
// names) where MiniLM is weakest. jina-embeddings-v2-base-code is 768-dim, MIT, ships
|
|
38
|
-
// first-party ONNX (transformers.js reads it directly, no trust_remote_code), supports
|
|
39
|
-
// an 8192-token context, and is ~160 MB (q8 quantized) on first download (air-gapped thereafter).
|
|
40
|
-
// Override with --code-embed-model / INDEXER_CODE_EMBED_MODEL (e.g. the Xenova mirror
|
|
41
|
-
// `Xenova/jina-embeddings-v2-base-code`, or `Xenova/codet5p-110m-embedding`).
|
|
42
|
-
export const CODE_EMBED_MODEL_DEFAULT = 'jinaai/jina-embeddings-v2-base-code';
|
|
43
|
-
export const CODE_EMBED_DIM = 768; // informational only — dim is derived lazily from the first vector.
|
|
44
|
-
// Passed to the Python server at spawn time (argv) AND stamped into the .meta.json
|
|
45
|
-
// sidecar so index time and query time always agree; the indexer's model-change
|
|
46
|
-
// detection forces a clean re-embed when this differs. Override via config.mlxEmbedModel.
|
|
47
|
-
export const MLX_EMBED_MODEL = 'mlx-community/all-MiniLM-L6-v2-4bit';
|
|
48
|
-
const DOC_CHAR_LIMIT = 8000;
|
|
49
|
-
|
|
50
|
-
// A larger model (e.g. qwen3-embedding:4b) embeds a 64-chunk batch far slower than
|
|
51
|
-
// nomic; the old hard 60s cap silently aborted every batch and produced no vectors.
|
|
52
|
-
// Raised to 120s and overridable for very large models / slow hardware.
|
|
53
|
-
const EMBED_DOC_TIMEOUT_MS = Number(process.env.INDEXER_EMBED_TIMEOUT_MS) > 0
|
|
54
|
-
? Number(process.env.INDEXER_EMBED_TIMEOUT_MS) : 120000;
|
|
55
|
-
|
|
56
|
-
// ─── Availability probes ─────────────────────────────────────────────────────
|
|
57
|
-
|
|
58
|
-
/** Is a local Ollama reachable? A fast, cheap GET so `auto` can decide quickly. */
|
|
59
|
-
export async function probeOllama(host, timeoutMs = 1500) {
|
|
60
|
-
try {
|
|
61
|
-
const res = await fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
62
|
-
return res.ok;
|
|
63
|
-
} catch { return false; }
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Does the reachable Ollama actually have `model` pulled? `auto` uses this so a
|
|
68
|
-
* configured embed model that was never `ollama pull`-ed doesn't crash the indexer
|
|
69
|
-
* (embedDocuments would throw on every batch) — instead `auto` falls back to the
|
|
70
|
-
* bundled in-process model. Matches an untagged name against any tag and the
|
|
71
|
-
* implicit `:latest`. Returns false (not throwing) on any error.
|
|
72
|
-
*/
|
|
73
|
-
export async function ollamaHasModel(host, model, timeoutMs = 1500) {
|
|
74
|
-
if (!model) return false;
|
|
75
|
-
try {
|
|
76
|
-
const res = await fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
77
|
-
if (!res.ok) return false;
|
|
78
|
-
const names = ((await res.json()).models || []).map(m => m.name);
|
|
79
|
-
return names.some(n => n === model || n === `${model}:latest`
|
|
80
|
-
|| (!model.includes(':') && n.startsWith(`${model}:`)));
|
|
81
|
-
} catch { return false; }
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Is the optional in-process embedding backend installed? */
|
|
85
|
-
export async function localEmbedAvailable() {
|
|
86
|
-
try { await import('@huggingface/transformers'); return true; }
|
|
87
|
-
catch { return false; }
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Decide which provider to use for THIS index/run.
|
|
92
|
-
* • INDEXER_EMBEDDINGS=off / embedProvider 'off' → no vectors (lexical-only).
|
|
93
|
-
* • 'ollama' / 'local' / 'code-local' / 'mlx' → forced (code-local is the
|
|
94
|
-
* in-process code-specialized embedder; mlx is a native Python embedder on the
|
|
95
|
-
* Apple Metal GPU, macOS-only).
|
|
96
|
-
* • 'auto' (default) → running Ollama, else the bundled local model, else off.
|
|
97
|
-
* Probes are injectable for deterministic tests.
|
|
98
|
-
*
|
|
99
|
-
* @returns {Promise<{provider:'ollama'|'local'|'code-local'|'mlx'|'off', model:(string|null)}>}
|
|
100
|
-
*/
|
|
101
|
-
export async function resolveEmbedProvider(config, { probe = probeOllama, hasLocal = localEmbedAvailable, hasModel = ollamaHasModel } = {}) {
|
|
102
|
-
if (config.embeddingsEnabled === false) return { provider: 'off', model: null };
|
|
103
|
-
const want = config.embedProvider || 'auto';
|
|
104
|
-
const localModel = config.localEmbedModel || LOCAL_EMBED_MODEL_DEFAULT;
|
|
105
|
-
if (want === 'off') return { provider: 'off', model: null };
|
|
106
|
-
if (want === 'ollama') return { provider: 'ollama', model: config.embedModel };
|
|
107
|
-
if (want === 'local') return { provider: 'local', model: localModel };
|
|
108
|
-
// code-local: forced, in-process, code-specialized. Never reached by `auto` (it
|
|
109
|
-
// is an explicit opt-in — `auto` stays MiniLM so the no-config path is unchanged).
|
|
110
|
-
if (want === 'code-local') return { provider: 'code-local', model: config.codeEmbedModel || CODE_EMBED_MODEL_DEFAULT };
|
|
111
|
-
if (want === 'mlx') { assertProviderPlatform('mlx'); return { provider: 'mlx', model: config.mlxEmbedModel || MLX_EMBED_MODEL }; }
|
|
112
|
-
// Prefer Ollama only when it actually HAS the configured model pulled — otherwise
|
|
113
|
-
// the indexer would crash on the first batch. Fall back to in-process, then off.
|
|
114
|
-
if (await probe(config.ollamaHost)) {
|
|
115
|
-
if (await hasModel(config.ollamaHost, config.embedModel)) {
|
|
116
|
-
return { provider: 'ollama', model: config.embedModel };
|
|
117
|
-
}
|
|
118
|
-
if (await hasLocal()) return { provider: 'local', model: localModel };
|
|
119
|
-
return { provider: 'off', model: null };
|
|
120
|
-
}
|
|
121
|
-
if (await hasLocal()) return { provider: 'local', model: localModel };
|
|
122
|
-
return { provider: 'off', model: null };
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// ─── Low-level backends (injectable for tests) ───────────────────────────────
|
|
126
|
-
|
|
127
|
-
async function _withRetry(fn, tries = 3) {
|
|
128
|
-
for (let a = 0; a <= tries; a++) {
|
|
129
|
-
try { return await fn(); }
|
|
130
|
-
catch (e) { if (a === tries) throw e; await new Promise(r => setTimeout(r, 500 * 2 ** a)); } // exponential backoff
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// nomic-style models are ASYMMETRIC and require `search_query:` / `search_document:`
|
|
135
|
-
// prefixes. Other embedders (qwen3-embedding, mxbai, …) are trained on raw text —
|
|
136
|
-
// injecting nomic's prefix tokens into them adds noise and degrades retrieval — so
|
|
137
|
-
// the prefix is gated on the model family. Index time and query time MUST agree on
|
|
138
|
-
// the prefix, which they do because both derive it from the same model name.
|
|
139
|
-
export function needsNomicPrefix(model) { return /nomic/i.test(model || ''); }
|
|
140
|
-
|
|
141
|
-
async function _ollamaEmbedOne(host, model, text) {
|
|
142
|
-
const prompt = needsNomicPrefix(model) ? 'search_query: ' + truncateForEmbedding(text) : truncateForEmbedding(text);
|
|
143
|
-
const res = await fetch(`${host}/api/embeddings`, {
|
|
144
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
145
|
-
body: JSON.stringify({ model, prompt }),
|
|
146
|
-
signal: AbortSignal.timeout(15000),
|
|
147
|
-
});
|
|
148
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
149
|
-
return (await res.json()).embedding;
|
|
150
|
-
}
|
|
151
|
-
async function _ollamaEmbedMany(host, model, texts) {
|
|
152
|
-
const pfx = needsNomicPrefix(model) ? 'search_document: ' : '';
|
|
153
|
-
const res = await fetch(`${host}/api/embed`, {
|
|
154
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
155
|
-
body: JSON.stringify({ model, input: texts.map(t => pfx + (t.length > DOC_CHAR_LIMIT ? t.slice(0, DOC_CHAR_LIMIT) : t)) }),
|
|
156
|
-
signal: AbortSignal.timeout(EMBED_DOC_TIMEOUT_MS),
|
|
157
|
-
});
|
|
158
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
159
|
-
return (await res.json()).embeddings;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// all-MiniLM is symmetric (unlike nomic), so query and document text need no prefixes.
|
|
163
|
-
let _pipe = null, _pipeModel = null;
|
|
164
|
-
async function _localPipeline(model) {
|
|
165
|
-
if (_pipe && _pipeModel === model) return _pipe;
|
|
166
|
-
let mod;
|
|
167
|
-
try { mod = await import('@huggingface/transformers'); }
|
|
168
|
-
catch {
|
|
169
|
-
throw new Error(
|
|
170
|
-
"Local embeddings need the optional '@huggingface/transformers' package. "
|
|
171
|
-
+ "Install it (`npm run embed:setup:local`, or `npm i @huggingface/transformers`) "
|
|
172
|
-
+ "or set embedProvider to 'ollama'/'off'."
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
_pipe = await mod.pipeline('feature-extraction', model);
|
|
176
|
-
_pipeModel = model;
|
|
177
|
-
return _pipe;
|
|
178
|
-
}
|
|
179
|
-
async function _localEmbedMany(model, texts) {
|
|
180
|
-
const pipe = await _localPipeline(model);
|
|
181
|
-
const out = await pipe(
|
|
182
|
-
texts.map(t => (t.length > DOC_CHAR_LIMIT ? t.slice(0, DOC_CHAR_LIMIT) : t)),
|
|
183
|
-
{ pooling: 'mean', normalize: true }
|
|
184
|
-
);
|
|
185
|
-
return out.tolist();
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** Reset the cached local pipeline (tests). */
|
|
189
|
-
export function _resetLocalPipeline() { _pipe = null; _pipeModel = null; }
|
|
190
|
-
|
|
191
|
-
// Code-specialized in-process embedder (provider 'code-local'). A SEPARATE lazy
|
|
192
|
-
// singleton from _localPipeline so a code-local and a plain-local run in the same
|
|
193
|
-
// process don't evict each other's model. The import lives inside the function, so
|
|
194
|
-
// the default path (lexical / `auto` → MiniLM) NEVER loads the code model — it is
|
|
195
|
-
// pulled only when 'code-local' is actually selected and a vector is requested.
|
|
196
|
-
// `_codeLoads` lets tests prove that lazy-load contract without a network call.
|
|
197
|
-
let _codePipe = null, _codePipeModel = null, _codeLoads = 0;
|
|
198
|
-
async function _codeLocalPipeline(model) {
|
|
199
|
-
if (_codePipe && _codePipeModel === model) return _codePipe;
|
|
200
|
-
let mod;
|
|
201
|
-
try { mod = await import('@huggingface/transformers'); }
|
|
202
|
-
catch {
|
|
203
|
-
throw new Error(
|
|
204
|
-
"The code-local embedder needs the optional '@huggingface/transformers' package. "
|
|
205
|
-
+ "Install it (`npm run embed:setup:local`, or `npm i @huggingface/transformers`) "
|
|
206
|
-
+ "or set embedProvider to 'ollama'/'local'/'off'."
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
// q8 quantized weights: a 161M-param code model is ~4× faster on CPU at q8 with
|
|
210
|
-
// negligible recall loss (the same quantized-ONNX choice the cross-encoder makes),
|
|
211
|
-
// which is what makes an in-process code embedder practical on a laptop. transformers.js
|
|
212
|
-
// falls back to fp32 automatically if a model ships no quantized ONNX.
|
|
213
|
-
_codePipe = await mod.pipeline('feature-extraction', model, { dtype: 'q8' });
|
|
214
|
-
_codePipeModel = model;
|
|
215
|
-
_codeLoads++;
|
|
216
|
-
return _codePipe;
|
|
217
|
-
}
|
|
218
|
-
async function _codeLocalEmbedMany(model, texts) {
|
|
219
|
-
const pipe = await _codeLocalPipeline(model);
|
|
220
|
-
// jina-code is symmetric (no nomic-style query/document prefixes); mean-pool +
|
|
221
|
-
// normalize, identical handling to the MiniLM path, so cache keys / windowing are
|
|
222
|
-
// unchanged. The 8000-char cap matches search-core's EMBEDDING_CONTEXT_LIMIT.
|
|
223
|
-
const out = await pipe(
|
|
224
|
-
texts.map(t => (t.length > DOC_CHAR_LIMIT ? t.slice(0, DOC_CHAR_LIMIT) : t)),
|
|
225
|
-
{ pooling: 'mean', normalize: true }
|
|
226
|
-
);
|
|
227
|
-
return out.tolist();
|
|
228
|
-
}
|
|
229
|
-
/** Reset the cached code-local pipeline (tests). */
|
|
230
|
-
export function _resetCodeLocalPipeline() { _codePipe = null; _codePipeModel = null; }
|
|
231
|
-
/** Number of times the code-local model has been lazy-loaded (tests: lazy-load contract). */
|
|
232
|
-
export function _codeLocalPipelineLoads() { return _codeLoads; }
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Should the cached vectors be discarded before this run? True only when the previous
|
|
236
|
-
* build's embed-meta records a DIFFERENT model than the embedder about to run. Vectors
|
|
237
|
-
* of different models/dims must never be mixed in one `.bin` — a 768-dim code-local
|
|
238
|
-
* vector and a 384-dim MiniLM vector under the same cache would silently no-op the
|
|
239
|
-
* dense channel for the mismatched dim (scan/sketch skip a wrong-dim entry). Model name
|
|
240
|
-
* is the pre-embed signal (the actual dim is unknown until the first vector returns), so
|
|
241
|
-
* switching `--embed-provider local` → `code-local` forces a clean re-embed rather than a
|
|
242
|
-
* silent half-empty index.
|
|
243
|
-
*
|
|
244
|
-
* @param {object|null} prevMeta The prior `.embeddings.bin.meta.json` ({ provider, model, dim }).
|
|
245
|
-
* @param {string|null} model The model the current embedder will use.
|
|
246
|
-
* @returns {boolean}
|
|
247
|
-
*/
|
|
248
|
-
export function embedModelChanged(prevMeta, model) {
|
|
249
|
-
return Boolean(prevMeta?.model && model && prevMeta.model !== model);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// ─── Native Python embedder (mlx) ────────────────────────────────────────────
|
|
253
|
-
// MLX (Apple Metal) is Python-first with no Node bindings, so it runs as a long-lived
|
|
254
|
-
// child process speaking newline-delimited JSON:
|
|
255
|
-
// Node → Python: {"texts": ["t1", …, "t32"]}\n
|
|
256
|
-
// Python → Node: {"embeddings": [[…], …]}\n
|
|
257
|
-
// The process is a module-level singleton, started lazily on first use and reused
|
|
258
|
-
// for the lifetime of this process (matching the _localPipeline lazy-cache idea). It
|
|
259
|
-
// runs under the dedicated embedders/venv-mlx interpreter (see embedders/setup-mlx.mjs)
|
|
260
|
-
// so MLX deps never pollute the system Python.
|
|
261
|
-
|
|
262
|
-
// We memoize the in-flight SPAWN PROMISE (not just the resolved proc) so concurrent
|
|
263
|
-
// first callers share ONE spawn instead of each launching its own model server.
|
|
264
|
-
const _procState = {
|
|
265
|
-
mlx: { proc: null, promise: null },
|
|
266
|
-
};
|
|
267
|
-
// Tracked so the process-exit handler can reap all children on shutdown.
|
|
268
|
-
const _liveProcs = new Set();
|
|
269
|
-
let _cleanupInstalled = false;
|
|
270
|
-
function _installProcessCleanup() {
|
|
271
|
-
if (_cleanupInstalled) return;
|
|
272
|
-
_cleanupInstalled = true;
|
|
273
|
-
// Deliberately NOT trapping SIGINT/SIGTERM: a no-op listener would suppress Node's
|
|
274
|
-
// default termination and break Ctrl-C. On Ctrl-C, children get the same group
|
|
275
|
-
// signal directly; on any parent death their stdin closes (EOF) and the Python
|
|
276
|
-
// `for line in sys.stdin` loop self-terminates.
|
|
277
|
-
process.on('exit', () => {
|
|
278
|
-
for (const p of _liveProcs) { try { if (!p.killed) p.kill(); } catch { /* best effort */ } }
|
|
279
|
-
});
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// MLX Metal benefits from large batches (GPU parallelism); sending one text at a time
|
|
283
|
-
// drowns the Metal win in IPC overhead.
|
|
284
|
-
const MLX_BATCH_SIZE = Number(process.env.INDEXER_MLX_BATCH_SIZE) || 32;
|
|
285
|
-
// The server downloads ~90MB and loads the model before printing READY, so a slow
|
|
286
|
-
// first build must not be killed. Distinct from the dep pre-check, which fails fast.
|
|
287
|
-
const EMBED_STARTUP_TIMEOUT_MS = Number(process.env.INDEXER_EMBED_STARTUP_TIMEOUT_MS) > 0
|
|
288
|
-
? Number(process.env.INDEXER_EMBED_STARTUP_TIMEOUT_MS) : 120000;
|
|
289
|
-
|
|
290
|
-
/** mlx is macOS-only. Throw a clear, actionable error elsewhere. */
|
|
291
|
-
function assertProviderPlatform(provider) {
|
|
292
|
-
if (provider === 'mlx' && process.platform !== 'darwin') {
|
|
293
|
-
throw new Error(
|
|
294
|
-
`MLX embedder requires macOS Apple Silicon. Current platform: ${process.platform}. `
|
|
295
|
-
+ `Use --embed-provider local (any) or --embed-provider ollama.`
|
|
296
|
-
);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Fast-fails before spawning: a missing venv or package raises near-instantly with
|
|
301
|
-
// the one command that fixes it.
|
|
302
|
-
function checkPythonDeps(provider) {
|
|
303
|
-
// Runs under embedders/venv-mlx so its deps never touch system Python.
|
|
304
|
-
if (!mlxEnvReady()) {
|
|
305
|
-
throw new Error(
|
|
306
|
-
`The MLX embedder environment is not ready (missing embedders/venv-mlx or its deps).\n`
|
|
307
|
-
+ `Run: npm run embed:setup:mlx\n`
|
|
308
|
-
+ `Then restart the indexer.`
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/** True for a never-spawned, killed, or self-exited (crashed/OOM) process. */
|
|
314
|
-
function _isProcDead(proc) {
|
|
315
|
-
return !proc || proc.killed || proc.exitCode !== null || proc.signalCode != null;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/** Kill a provider's subprocess (if any) and clear its cached state so it respawns. */
|
|
319
|
-
function _killProc(provider) {
|
|
320
|
-
const st = _procState[provider];
|
|
321
|
-
const proc = st.proc;
|
|
322
|
-
st.proc = null;
|
|
323
|
-
st.promise = null;
|
|
324
|
-
if (proc && !proc.killed) {
|
|
325
|
-
// Reject in-flight batches now so siblings degrade immediately instead of
|
|
326
|
-
// waiting out their own per-batch timeout (close() would also drain, later).
|
|
327
|
-
const pending = proc._pending || [];
|
|
328
|
-
proc._pending = [];
|
|
329
|
-
for (const r of pending) r.reject(new Error(`${provider} subprocess killed`));
|
|
330
|
-
try { proc.kill(); } catch { /* best effort */ }
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/** Spawn an embed server, await its "READY" line, and wire up the response demux. */
|
|
335
|
-
async function spawnEmbedServer(provider, model) {
|
|
336
|
-
assertProviderPlatform(provider);
|
|
337
|
-
checkPythonDeps(provider); // fast-fail before spawning
|
|
338
|
-
_installProcessCleanup();
|
|
339
|
-
|
|
340
|
-
const scripts = {
|
|
341
|
-
mlx: join(PYTHON_DIR, 'mlx_embed_server.py'),
|
|
342
|
-
};
|
|
343
|
-
// Pass the resolved model id as argv so the server loads exactly what the meta
|
|
344
|
-
// sidecar stamps. mlxVenvPython() falls back to bare 'python3' only if venv is absent.
|
|
345
|
-
const proc = spawn(mlxVenvPython(), [scripts[provider], model || MLX_EMBED_MODEL], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
346
|
-
_liveProcs.add(proc);
|
|
347
|
-
proc.stderr.on('data', (d) => process.stderr.write(`[${provider}] ${d}`));
|
|
348
|
-
|
|
349
|
-
// FIFO of pending {resolve,reject}: the Python server answers in order, so each
|
|
350
|
-
// response line resolves the oldest pending entry.
|
|
351
|
-
proc._pending = [];
|
|
352
|
-
proc._ready = false;
|
|
353
|
-
|
|
354
|
-
let onReady, onReadyErr;
|
|
355
|
-
const ready = new Promise((resolve, reject) => { onReady = resolve; onReadyErr = reject; });
|
|
356
|
-
|
|
357
|
-
// On close / stdin error, rejects all in-flight requests so awaiters degrade to
|
|
358
|
-
// lexical instead of hanging forever.
|
|
359
|
-
const drainPending = (err) => {
|
|
360
|
-
const pending = proc._pending;
|
|
361
|
-
proc._pending = [];
|
|
362
|
-
for (const r of pending) r.reject(err);
|
|
363
|
-
};
|
|
364
|
-
|
|
365
|
-
// Single persistent readline: one interface avoids dropping buffered bytes that a
|
|
366
|
-
// closed-then-reopened interface could lose.
|
|
367
|
-
proc._rl = createInterface({ input: proc.stdout });
|
|
368
|
-
proc._rl.on('line', (line) => {
|
|
369
|
-
if (!proc._ready) {
|
|
370
|
-
const t = line.trim();
|
|
371
|
-
if (t === 'READY') { proc._ready = true; onReady(); return; }
|
|
372
|
-
// Server prints {"error":...} then exits if the model fails to load; surface
|
|
373
|
-
// that specific error rather than a generic unexpected-line message.
|
|
374
|
-
let parsed = null; try { parsed = JSON.parse(t); } catch { /* not JSON */ }
|
|
375
|
-
proc.kill();
|
|
376
|
-
onReadyErr(new Error(parsed?.error
|
|
377
|
-
? `${provider} failed to start: ${parsed.error}`
|
|
378
|
-
: `${provider} subprocess sent unexpected first line: ${line}`));
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
const resolver = proc._pending.shift();
|
|
382
|
-
if (!resolver) return;
|
|
383
|
-
try {
|
|
384
|
-
const msg = JSON.parse(line);
|
|
385
|
-
if (msg.error) resolver.reject(new Error(`${provider}: ${msg.error}`));
|
|
386
|
-
else resolver.resolve(msg.embeddings);
|
|
387
|
-
} catch {
|
|
388
|
-
resolver.reject(new Error(`${provider}: bad JSON: ${line}`));
|
|
389
|
-
}
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
const timeout = setTimeout(() => {
|
|
393
|
-
proc.kill();
|
|
394
|
-
onReadyErr(new Error(
|
|
395
|
-
`${provider} embedder subprocess did not print READY within ${EMBED_STARTUP_TIMEOUT_MS}ms `
|
|
396
|
-
+ `(first run downloads the model — raise INDEXER_EMBED_STARTUP_TIMEOUT_MS if needed).`));
|
|
397
|
-
}, EMBED_STARTUP_TIMEOUT_MS);
|
|
398
|
-
|
|
399
|
-
proc.on('error', (err) => { clearTimeout(timeout); onReadyErr(err); drainPending(err); });
|
|
400
|
-
// A broken pipe (subprocess died mid-write) emits an async 'error' on stdin; an
|
|
401
|
-
// unhandled stream 'error' is FATAL to the whole Node process — swallow it here
|
|
402
|
-
// and fail the in-flight batch so the indexer/server degrades gracefully.
|
|
403
|
-
proc.stdin.on('error', (err) => drainPending(new Error(`${provider} stdin error: ${err.message}`)));
|
|
404
|
-
proc.on('close', (code, signal) => {
|
|
405
|
-
clearTimeout(timeout);
|
|
406
|
-
_liveProcs.delete(proc);
|
|
407
|
-
// Guard: only clear the singleton if it still points at THIS proc, not a fresh respawn.
|
|
408
|
-
if (_procState[provider].proc === proc) _procState[provider].proc = null;
|
|
409
|
-
const why = `${provider} subprocess exited (code ${code}${signal ? `, signal ${signal}` : ''})`;
|
|
410
|
-
if (!proc._ready) onReadyErr(new Error(`${why} before READY`));
|
|
411
|
-
drainPending(new Error(why));
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
await ready;
|
|
415
|
-
clearTimeout(timeout);
|
|
416
|
-
return proc;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/**
|
|
420
|
-
* Resolve the provider's live subprocess, spawning lazily. Memoizes the in-flight
|
|
421
|
-
* spawn PROMISE so concurrent first callers share one spawn (no duplicate servers).
|
|
422
|
-
* The model is fixed for the lifetime of an index/query run, so the first spawn's
|
|
423
|
-
* model wins for the reused singleton.
|
|
424
|
-
*/
|
|
425
|
-
function _getEmbedProc(provider, model) {
|
|
426
|
-
const st = _procState[provider];
|
|
427
|
-
if (!_isProcDead(st.proc)) return Promise.resolve(st.proc);
|
|
428
|
-
if (!st.promise) {
|
|
429
|
-
st.promise = spawnEmbedServer(provider, model).then(
|
|
430
|
-
(proc) => { st.proc = proc; st.promise = null; return proc; },
|
|
431
|
-
(err) => { st.proc = null; st.promise = null; throw err; },
|
|
432
|
-
);
|
|
433
|
-
}
|
|
434
|
-
return st.promise;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/** Embed `texts` via the provider's subprocess, batching to keep IPC efficient. */
|
|
438
|
-
async function _subprocessEmbedMany(provider, texts, model) {
|
|
439
|
-
const proc = await _getEmbedProc(provider, model);
|
|
440
|
-
const batchSize = MLX_BATCH_SIZE;
|
|
441
|
-
|
|
442
|
-
const results = [];
|
|
443
|
-
for (let i = 0; i < texts.length; i += batchSize) {
|
|
444
|
-
const batch = texts.slice(i, i + batchSize);
|
|
445
|
-
const embeddings = await new Promise((resolve, reject) => {
|
|
446
|
-
let settled = false;
|
|
447
|
-
// A hung inference (no crash, no exit) must not hang the indexer forever;
|
|
448
|
-
// mirrors the Ollama path's EMBED_DOC_TIMEOUT_MS guard.
|
|
449
|
-
const to = setTimeout(() => {
|
|
450
|
-
if (settled) return; settled = true;
|
|
451
|
-
_killProc(provider); // unrecoverable: kill so the next call respawns
|
|
452
|
-
reject(new Error(`${provider}: embedding batch timed out after ${EMBED_DOC_TIMEOUT_MS}ms`));
|
|
453
|
-
}, EMBED_DOC_TIMEOUT_MS);
|
|
454
|
-
const entry = {
|
|
455
|
-
resolve: (v) => { if (settled) return; settled = true; clearTimeout(to); resolve(v); },
|
|
456
|
-
reject: (e) => { if (settled) return; settled = true; clearTimeout(to); reject(e); },
|
|
457
|
-
};
|
|
458
|
-
// Write FIRST, enqueue only on success: a synchronous write throw (broken
|
|
459
|
-
// pipe) must not leave a phantom entry in the FIFO — that would desync the
|
|
460
|
-
// demux and assign every later batch the wrong vectors.
|
|
461
|
-
try {
|
|
462
|
-
proc.stdin.write(JSON.stringify({ texts: batch }) + '\n');
|
|
463
|
-
proc._pending.push(entry);
|
|
464
|
-
} catch (e) { entry.reject(e); }
|
|
465
|
-
});
|
|
466
|
-
results.push(...embeddings);
|
|
467
|
-
}
|
|
468
|
-
return results; // number[][]
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/** Reset (kill) the cached embed subprocesses (tests / clean shutdown). */
|
|
472
|
-
export function _resetSubprocesses() {
|
|
473
|
-
_killProc('mlx');
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/** Canonical model id for a forced provider when no explicit model is supplied. */
|
|
477
|
-
function defaultModelForProvider(provider, config) {
|
|
478
|
-
if (provider === 'local') return config.localEmbedModel || LOCAL_EMBED_MODEL_DEFAULT;
|
|
479
|
-
if (provider === 'code-local') return config.codeEmbedModel || CODE_EMBED_MODEL_DEFAULT;
|
|
480
|
-
if (provider === 'mlx') return config.mlxEmbedModel || MLX_EMBED_MODEL;
|
|
481
|
-
return config.embedModel;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
// ─── Public embedder ─────────────────────────────────────────────────────────
|
|
485
|
-
|
|
486
|
-
/**
|
|
487
|
-
* Build an embedder bound to a resolved provider/model.
|
|
488
|
-
*
|
|
489
|
-
* @param {object} config
|
|
490
|
-
* @param {object} [opts]
|
|
491
|
-
* @param {'ollama'|'local'|'code-local'|'mlx'|'off'} [opts.provider] Force a provider
|
|
492
|
-
* (e.g. the one stamped in the index meta). When omitted, resolveEmbedProvider decides.
|
|
493
|
-
* @param {string} [opts.model]
|
|
494
|
-
* @param {object} [opts.backends] Inject { ollamaEmbedOne, ollamaEmbedMany, localEmbedMany,
|
|
495
|
-
* codeLocalEmbedMany } (tests).
|
|
496
|
-
* @returns {Promise<{provider:string, model:(string|null), dim:(number|null),
|
|
497
|
-
* embedQuery:(t:string)=>Promise<number[]|null>,
|
|
498
|
-
* embedDocuments:(t:string[])=>Promise<number[][]|null>}>}
|
|
499
|
-
*/
|
|
500
|
-
export async function createEmbedder(config, opts = {}) {
|
|
501
|
-
const resolved = opts.provider
|
|
502
|
-
? { provider: opts.provider, model: opts.model ?? defaultModelForProvider(opts.provider, config) }
|
|
503
|
-
: await resolveEmbedProvider(config, opts);
|
|
504
|
-
const { provider, model } = resolved;
|
|
505
|
-
const host = config.ollamaHost;
|
|
506
|
-
const b = opts.backends || {};
|
|
507
|
-
const ollamaOne = b.ollamaEmbedOne || _ollamaEmbedOne;
|
|
508
|
-
const ollamaMany = b.ollamaEmbedMany || _ollamaEmbedMany;
|
|
509
|
-
const localMany = b.localEmbedMany || _localEmbedMany;
|
|
510
|
-
const codeLocalMany = b.codeLocalEmbedMany || _codeLocalEmbedMany;
|
|
511
|
-
// Derive dim lazily from the first returned vector. Pre-setting LOCAL_EMBED_DIM=384
|
|
512
|
-
// mis-stamps models whose dim isn't 384 (e.g. jina-embeddings-v2-base-code = 768)
|
|
513
|
-
// into the .meta.json before the first embed call.
|
|
514
|
-
let dim = null;
|
|
515
|
-
|
|
516
|
-
async function embedQuery(text) {
|
|
517
|
-
if (provider === 'off' || !text) return null;
|
|
518
|
-
try {
|
|
519
|
-
if (provider === 'ollama') return await _withRetry(() => ollamaOne(host, model, text));
|
|
520
|
-
if (provider === 'mlx') {
|
|
521
|
-
const [v] = await _subprocessEmbedMany(provider, [text], model);
|
|
522
|
-
if (v && dim == null) dim = v.length;
|
|
523
|
-
return v ?? null;
|
|
524
|
-
}
|
|
525
|
-
const many = provider === 'code-local' ? codeLocalMany : localMany;
|
|
526
|
-
const [v] = await many(model, [text]);
|
|
527
|
-
if (v && dim == null) dim = v.length;
|
|
528
|
-
return v ?? null;
|
|
529
|
-
} catch { return null; } // graceful: caller drops to lexical
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
async function embedDocuments(texts) {
|
|
533
|
-
if (provider === 'off' || !texts || texts.length === 0) return null;
|
|
534
|
-
let vecs;
|
|
535
|
-
if (provider === 'ollama') vecs = await _withRetry(() => ollamaMany(host, model, texts));
|
|
536
|
-
else if (provider === 'mlx') vecs = await _subprocessEmbedMany(provider, texts, model);
|
|
537
|
-
else if (provider === 'code-local') vecs = await codeLocalMany(model, texts);
|
|
538
|
-
else vecs = await localMany(model, texts);
|
|
539
|
-
if (vecs && vecs[0] && dim == null) dim = vecs[0].length;
|
|
540
|
-
return vecs;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
return { provider, model, get dim() { return dim; }, embedQuery, embedDocuments };
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// ─── Sidecar embed-meta (provider/model/dim that produced the shared bin) ─────
|
|
547
|
-
// Both backends share `code-index.embeddings.bin`; this records which model wrote
|
|
548
|
-
// it so (a) the indexer re-embeds from scratch when the model changes (vectors of
|
|
549
|
-
// different models/dims must not be mixed), and (b) the server queries with the
|
|
550
|
-
// same provider the index was built with.
|
|
551
|
-
|
|
552
|
-
export function embedMetaPath(embeddingPath) { return `${embeddingPath}.meta.json`; }
|
|
553
|
-
|
|
554
|
-
export function readEmbedMeta(embeddingPath) {
|
|
555
|
-
try { return JSON.parse(fs.readFileSync(embedMetaPath(embeddingPath), 'utf8')); }
|
|
556
|
-
catch { return null; }
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
export function writeEmbedMeta(embeddingPath, meta) {
|
|
560
|
-
try { fs.writeFileSync(embedMetaPath(embeddingPath), JSON.stringify(meta)); }
|
|
561
|
-
catch { /* best-effort: server falls back to config */ }
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
/** One-line, human-readable provider label for logs / list_index_stats. */
|
|
565
|
-
export function describeEmbedder({ provider, model } = {}) {
|
|
566
|
-
if (provider === 'ollama') return `🧠 Ollama · ${model}`;
|
|
567
|
-
if (provider === 'local') return `🧠 Local (in-process) · ${model}`;
|
|
568
|
-
if (provider === 'code-local') return `🧠 Code-local (in-process, code-specialized) · ${model}`;
|
|
569
|
-
if (provider === 'mlx') return `🧠 MLX (Apple Metal) · ${model}`;
|
|
570
|
-
return '🔤 Lexical only';
|
|
571
|
-
}
|