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.
Files changed (129) hide show
  1. package/CHANGELOG.md +260 -1
  2. package/README.md +232 -312
  3. package/SECURITY.md +37 -59
  4. package/bin/graph-indexer.mjs +251 -0
  5. package/integrations/claude-code/.claude-plugin/plugin.json +8 -0
  6. package/integrations/claude-code/.mcp.json +8 -0
  7. package/integrations/claude-code/agents/code-structure.md +22 -0
  8. package/integrations/claude-code/hooks/gi-hook.mjs +99 -0
  9. package/integrations/claude-code/hooks/hooks.json +16 -0
  10. package/integrations/opencode/graph-indexer.js +124 -0
  11. package/package.json +32 -109
  12. package/src/cli/helper.mjs +67 -0
  13. package/src/cli/hook.mjs +356 -0
  14. package/src/cli/init.mjs +255 -0
  15. package/src/cli/resident.mjs +175 -0
  16. package/src/index/discover.mjs +130 -0
  17. package/src/index/fingerprint.mjs +30 -0
  18. package/src/index/graph.mjs +61 -0
  19. package/src/index/indexer.mjs +476 -0
  20. package/src/index/modules.mjs +331 -0
  21. package/src/index/resolver.mjs +954 -0
  22. package/src/mcp/render.mjs +60 -0
  23. package/src/mcp/server.mjs +147 -0
  24. package/src/mcp/tools.mjs +973 -0
  25. package/src/parse/arity.mjs +152 -0
  26. package/src/parse/extract.mjs +1031 -0
  27. package/src/parse/lang/csharp.mjs +106 -0
  28. package/src/parse/lang/ecma.mjs +438 -0
  29. package/src/parse/lang/go.mjs +194 -0
  30. package/src/parse/lang/jvm.mjs +118 -0
  31. package/src/parse/lang/kotlin.mjs +97 -0
  32. package/src/parse/lang/misc.mjs +115 -0
  33. package/src/parse/lang/native.mjs +126 -0
  34. package/src/parse/lang/python.mjs +153 -0
  35. package/src/parse/lang/rust.mjs +157 -0
  36. package/src/parse/lang/scripting.mjs +219 -0
  37. package/src/parse/languages.mjs +48 -0
  38. package/src/parse/runtime.mjs +56 -0
  39. package/src/query/check.mjs +343 -0
  40. package/src/query/facts.mjs +323 -0
  41. package/src/query/intel.mjs +680 -0
  42. package/src/query/read.mjs +81 -0
  43. package/src/query/testcmd.mjs +119 -0
  44. package/src/query/textsearch.mjs +160 -0
  45. package/src/search/search.mjs +210 -0
  46. package/src/search/tokenize.mjs +188 -0
  47. package/src/store/db.mjs +169 -0
  48. package/src/util/paths.mjs +24 -0
  49. package/vendor/grammars/bash.wasm.gz +0 -0
  50. package/vendor/grammars/c.wasm.gz +0 -0
  51. package/vendor/grammars/c_sharp.wasm.gz +0 -0
  52. package/vendor/grammars/cpp.wasm.gz +0 -0
  53. package/vendor/grammars/css.wasm.gz +0 -0
  54. package/vendor/grammars/go.wasm.gz +0 -0
  55. package/vendor/grammars/java.wasm.gz +0 -0
  56. package/vendor/grammars/javascript.wasm.gz +0 -0
  57. package/vendor/grammars/kotlin.wasm.gz +0 -0
  58. package/vendor/grammars/manifest.json +148 -0
  59. package/vendor/grammars/php.wasm.gz +0 -0
  60. package/vendor/grammars/python.wasm.gz +0 -0
  61. package/vendor/grammars/ruby.wasm.gz +0 -0
  62. package/vendor/grammars/rust.wasm.gz +0 -0
  63. package/vendor/grammars/scala.wasm.gz +0 -0
  64. package/vendor/grammars/tsx.wasm.gz +0 -0
  65. package/vendor/grammars/typescript.wasm.gz +0 -0
  66. package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
  67. package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  68. package/cli-ui.mjs +0 -78
  69. package/colbert.mjs +0 -168
  70. package/config.mjs +0 -494
  71. package/daemon-ctl.mjs +0 -175
  72. package/daemon-lock.mjs +0 -69
  73. package/embedders/python/mlx_embed_server.py +0 -61
  74. package/embedders/python/requirements-mlx.txt +0 -5
  75. package/embedders/setup-mlx.mjs +0 -110
  76. package/embeddings.mjs +0 -571
  77. package/engine/binary.mjs +0 -437
  78. package/engine/memory.mjs +0 -1003
  79. package/engine/sqlite.mjs +0 -1136
  80. package/enrichment.mjs +0 -521
  81. package/git-signals.mjs +0 -248
  82. package/indexer.mjs +0 -546
  83. package/init.mjs +0 -1809
  84. package/layout.mjs +0 -123
  85. package/mcp/centrality.mjs +0 -117
  86. package/mcp/format.mjs +0 -61
  87. package/mcp/resolver.mjs +0 -116
  88. package/mcp/symbolgraph.mjs +0 -137
  89. package/mcp/taint.mjs +0 -225
  90. package/mcp/tools.mjs +0 -1582
  91. package/mcp/topology.mjs +0 -676
  92. package/mcp-server.mjs +0 -197
  93. package/parse/extractor.mjs +0 -628
  94. package/parse/imports.mjs +0 -365
  95. package/parse/interprocedural.mjs +0 -134
  96. package/parse/languages.mjs +0 -253
  97. package/parse/metadata.mjs +0 -701
  98. package/parse/routes.mjs +0 -327
  99. package/parse/scip.mjs +0 -391
  100. package/parse/taint-patterns.mjs +0 -119
  101. package/prompts/CORE.md +0 -91
  102. package/prompts/DOMAIN_TEMPLATE.md +0 -46
  103. package/prompts/INTEGRATION.md +0 -123
  104. package/prompts/frameworks/ANDROID.md +0 -17
  105. package/prompts/frameworks/ASPNET_CORE.md +0 -19
  106. package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
  107. package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
  108. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
  109. package/prompts/frameworks/RAILS.md +0 -20
  110. package/prompts/frameworks/REACT.md +0 -20
  111. package/prompts/frameworks/SPRING_BOOT.md +0 -20
  112. package/prompts/languages/BASH.md +0 -22
  113. package/prompts/languages/C.md +0 -21
  114. package/prompts/languages/CSHARP.md +0 -24
  115. package/prompts/languages/CSS_SCSS.md +0 -22
  116. package/prompts/languages/GO.md +0 -24
  117. package/prompts/languages/JAVA.md +0 -23
  118. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
  119. package/prompts/languages/KOTLIN.md +0 -24
  120. package/prompts/languages/PHP.md +0 -23
  121. package/prompts/languages/PYTHON.md +0 -23
  122. package/prompts/languages/RUBY.md +0 -23
  123. package/prompts/languages/RUST.md +0 -21
  124. package/prompts/languages/SWIFT.md +0 -21
  125. package/seal.mjs +0 -400
  126. package/search-core.mjs +0 -777
  127. package/search-sparse.mjs +0 -183
  128. package/storage.mjs +0 -73
  129. 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
- }