codesearch-mcp 0.1.0-alpha → 0.2.0-alpha

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 (44) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/README.md +141 -39
  3. package/dist/app.js +2 -2
  4. package/dist/src/commands/IndexCommand.js +42 -1
  5. package/dist/src/config-error.d.ts +7 -0
  6. package/dist/src/config-error.js +14 -0
  7. package/dist/src/config-store.d.ts +30 -12
  8. package/dist/src/config-store.js +81 -26
  9. package/dist/src/config.d.ts +5 -4
  10. package/dist/src/config.js +14 -16
  11. package/dist/src/constants.d.ts +8 -2
  12. package/dist/src/constants.js +8 -5
  13. package/dist/src/help.js +1 -1
  14. package/dist/src/interfaces/IDatabase.d.ts +17 -0
  15. package/dist/src/interfaces/ILogger.d.ts +7 -0
  16. package/dist/src/services/AstChunker.d.ts +11 -0
  17. package/dist/src/services/AstChunker.js +44 -0
  18. package/dist/src/services/DatabaseService.d.ts +4 -0
  19. package/dist/src/services/DatabaseService.js +46 -0
  20. package/dist/src/services/EmbedderFactory.d.ts +1 -1
  21. package/dist/src/services/EmbedderFactory.js +2 -5
  22. package/dist/src/services/IndexerService.d.ts +4 -0
  23. package/dist/src/services/IndexerService.js +27 -7
  24. package/dist/src/services/InfinityEmbedder.d.ts +14 -55
  25. package/dist/src/services/InfinityEmbedder.js +30 -141
  26. package/dist/src/services/LoggerService.d.ts +3 -1
  27. package/dist/src/services/LoggerService.js +17 -3
  28. package/dist/src/services/SearcherService.d.ts +15 -1
  29. package/dist/src/services/SearcherService.js +105 -23
  30. package/dist/src/services/WebServerService.d.ts +41 -3
  31. package/dist/src/services/WebServerService.js +90 -2
  32. package/dist/src/services/infinity-provisioning.d.ts +115 -0
  33. package/dist/src/services/infinity-provisioning.js +516 -0
  34. package/dist/src/services/legacy-ollama-index.d.ts +24 -0
  35. package/dist/src/services/legacy-ollama-index.js +61 -0
  36. package/dist/src/services/reranker.d.ts +3 -3
  37. package/dist/src/services/reranker.js +3 -3
  38. package/dist/src/setup.d.ts +37 -0
  39. package/dist/src/setup.js +111 -157
  40. package/docker/docker-compose.yml +5 -15
  41. package/docs/web-dashboard.md +36 -17
  42. package/package.json +1 -5
  43. package/dist/src/services/OllamaEmbedder.d.ts +0 -25
  44. package/dist/src/services/OllamaEmbedder.js +0 -111
package/dist/src/setup.js CHANGED
@@ -33,17 +33,19 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.EMBEDDING_PROVIDER_CHOICES = void 0;
36
37
  exports.dockerPromptDefault = dockerPromptDefault;
38
+ exports.prepareComposeAndStartDb = prepareComposeAndStartDb;
39
+ exports.provisionInfinity = provisionInfinity;
37
40
  exports.runSetup = runSetup;
38
41
  const readline = __importStar(require("readline"));
39
42
  const fs = __importStar(require("fs"));
40
43
  const path = __importStar(require("path"));
41
44
  const os = __importStar(require("os"));
42
45
  const child_process_1 = require("child_process");
43
- const constants_1 = require("./constants");
44
46
  const config_store_1 = require("./config-store");
45
47
  const config_1 = require("./config");
46
- const InfinityEmbedder_1 = require("./services/InfinityEmbedder");
48
+ const infinity_provisioning_1 = require("./services/infinity-provisioning");
47
49
  const DEFAULT_LOG_DIR = path.join(config_store_1.CONFIG_DIR, 'logs');
48
50
  const _projectRoot = fs.existsSync(path.join(__dirname, '..', 'package.json'))
49
51
  ? path.join(__dirname, '..')
@@ -52,7 +54,6 @@ const DOCKER_COMPOSE_SRC = path.join(_projectRoot, 'docker', 'docker-compose.yml
52
54
  const DOCKER_COMPOSE_DST = path.join(config_store_1.CONFIG_DIR, 'docker-compose.yml');
53
55
  const DOCKER_GPU_OVERRIDE_SRC = path.join(_projectRoot, 'docker', 'docker-compose.infinity-gpu.yml');
54
56
  const DOCKER_GPU_OVERRIDE_DST = path.join(config_store_1.CONFIG_DIR, 'docker-compose.infinity-gpu.yml');
55
- const DEFAULT_INFINITY_RERANK_MODEL = 'Alibaba-NLP/gte-reranker-modernbert-base';
56
57
  function ask(rl, question) {
57
58
  return new Promise(resolve => rl.question(question, resolve));
58
59
  }
@@ -66,14 +67,15 @@ function expandHome(p) {
66
67
  return p;
67
68
  }
68
69
  const PROVIDER_DEFAULTS = {
69
- 'ollama': { baseURL: 'http://localhost:11434', model: constants_1.DEFAULT_EMBED_MODEL, envVar: '' },
70
70
  'openai-compatible': { baseURL: 'https://api.openai.com/v1', model: 'text-embedding-3-small', envVar: 'OPENAI_API_KEY' },
71
71
  'voyage': { baseURL: 'https://api.voyageai.com/v1', model: 'voyage-code-3', envVar: 'VOYAGE_API_KEY' },
72
72
  'infinity': { baseURL: 'http://localhost:7997', model: 'jinaai/jina-embeddings-v2-base-code', envVar: '' },
73
73
  };
74
+ /** Providers the wizard offers, in prompt order; the first is the preselected default. Exported for unit testing. */
75
+ exports.EMBEDDING_PROVIDER_CHOICES = ['infinity', 'openai-compatible', 'voyage'];
74
76
  /** Providers that run a local inference backend we can probe / provision. */
75
77
  function isLocalBackend(p) {
76
- return p === 'ollama' || p === 'infinity';
78
+ return p === 'infinity';
77
79
  }
78
80
  /**
79
81
  * Probe whether the embedding backend is already reachable, so the "start
@@ -83,24 +85,7 @@ function isLocalBackend(p) {
83
85
  async function checkEmbeddingBackend(provider, baseURL) {
84
86
  if (!isLocalBackend(provider))
85
87
  return true;
86
- if (provider === 'infinity')
87
- return (0, InfinityEmbedder_1.probeInfinityHealth)(baseURL);
88
- // ollama — probe /api/tags
89
- try {
90
- const http = await Promise.resolve().then(() => __importStar(require('http')));
91
- await new Promise((resolve, reject) => {
92
- const req = http.get(`${baseURL.replace(/\/+$/, '')}/api/tags`, res => {
93
- res.resume();
94
- res.statusCode === 200 ? resolve() : reject(new Error(`status ${res.statusCode}`));
95
- });
96
- req.on('error', reject);
97
- req.setTimeout(2000, () => { req.destroy(); reject(new Error('timeout')); });
98
- });
99
- return true;
100
- }
101
- catch {
102
- return false;
103
- }
88
+ return (0, infinity_provisioning_1.probeInfinityHealth)(baseURL);
104
89
  }
105
90
  /**
106
91
  * Default answer for the "start Docker containers?" prompt: no only when both
@@ -110,15 +95,6 @@ async function checkEmbeddingBackend(provider, baseURL) {
110
95
  function dockerPromptDefault(dbReachable, embReachable) {
111
96
  return (dbReachable && embReachable) ? 'n' : 'y';
112
97
  }
113
- /** True when an NVIDIA GPU + `nvidia-smi` is available on this host. */
114
- function hasNvidiaGpu() {
115
- try {
116
- return (0, child_process_1.spawnSync)('nvidia-smi', ['-L'], { stdio: 'ignore' }).status === 0;
117
- }
118
- catch {
119
- return false;
120
- }
121
- }
122
98
  /**
123
99
  * Write (or replace) a single `KEY=VALUE` line in `~/.config/codesearch-mcp/.env`,
124
100
  * chmod 600. API keys live here — never in config.json — referenced as `env:KEY`.
@@ -160,58 +136,6 @@ async function waitForDb(host, port, user, pass, db, maxWaitSec = 60, logger, st
160
136
  logger?.error('waitForDb: timeout', { reason, steps: JSON.stringify(steps) });
161
137
  process.exit(1);
162
138
  }
163
- async function waitForOllama(host, maxWaitSec = 60, logger, steps) {
164
- const deadline = Date.now() + maxWaitSec * 1000;
165
- process.stdout.write('Waiting for Ollama to be ready');
166
- while (Date.now() < deadline) {
167
- try {
168
- const http = await Promise.resolve().then(() => __importStar(require('http')));
169
- await new Promise((resolve, reject) => {
170
- const req = http.get(`${host}/api/tags`, res => {
171
- res.resume();
172
- res.statusCode === 200 ? resolve() : reject(new Error(`status ${res.statusCode}`));
173
- });
174
- req.on('error', reject);
175
- req.setTimeout(2000, () => { req.destroy(); reject(new Error('timeout')); });
176
- });
177
- console.log(' ready.');
178
- return;
179
- }
180
- catch {
181
- process.stdout.write('.');
182
- await new Promise(r => setTimeout(r, 2000));
183
- }
184
- }
185
- const reason = 'Ollama did not become ready in time.';
186
- console.error(`\n${reason}`);
187
- logger?.error('waitForOllama: timeout', { reason, steps: JSON.stringify(steps) });
188
- process.exit(1);
189
- }
190
- /**
191
- * Poll Infinity's `/health` until ready. Unlike the DB/Ollama waiters this is
192
- * NON-fatal: on CPU the first boot downloads the model set AND runs a lengthy
193
- * per-model warmup (a reranker warmup batch alone can take minutes), so a
194
- * timeout here does not mean failure — the container is usually just still
195
- * warming up. Returns whether it became ready; the caller keeps going and
196
- * writes the (valid) config either way. Generous default (30 min) for CPU.
197
- */
198
- async function waitForInfinity(baseURL, maxWaitSec = 1800, logger, steps) {
199
- const deadline = Date.now() + maxWaitSec * 1000;
200
- process.stdout.write('Waiting for Infinity to be ready (first boot downloads + warms up the model set, this can take several minutes on CPU)');
201
- while (Date.now() < deadline) {
202
- if (await (0, InfinityEmbedder_1.probeInfinityHealth)(baseURL)) {
203
- console.log(' ready.');
204
- return true;
205
- }
206
- process.stdout.write('.');
207
- await new Promise(r => setTimeout(r, 3000));
208
- }
209
- const reason = 'Infinity did not answer /health in time — it is likely still warming up.';
210
- console.warn(`\n${reason}`);
211
- console.warn(` The container is running; check "docker logs codesearch-mcp-infinity-1" and, once /health responds, run: rag-index`);
212
- logger?.warn('waitForInfinity: timeout (non-fatal)', { reason, steps: JSON.stringify(steps) });
213
- return false;
214
- }
215
139
  async function checkRemoteDb(host, port, user, pass, db, logger, steps) {
216
140
  try {
217
141
  const { Client } = await Promise.resolve().then(() => __importStar(require('pg')));
@@ -227,8 +151,16 @@ async function checkRemoteDb(host, port, user, pass, db, logger, steps) {
227
151
  return false;
228
152
  }
229
153
  }
230
- async function startDockerContainers(opts) {
231
- const { provider, ollamaHost, embBaseURL, dbPort, dbUser, dbPass, embModel, rerankModel, startDb, startBackend, logger, steps } = opts;
154
+ /**
155
+ * Regenerate `CONFIG_DIR/docker-compose.yml` from the shipped template
156
+ * (carrying over an existing literal `INFINITY_MODEL_ID`, written atomically),
157
+ * copy the GPU override, and start the DB container when requested. Runs
158
+ * BEFORE the config file is written — its failure aborts setup unsaved,
159
+ * matching the existing "abort without saving" behavior of the DB step. Does
160
+ * NOT touch Infinity provisioning (moved after `writeConfigJson` — see D6a).
161
+ */
162
+ async function prepareComposeAndStartDb(opts) {
163
+ const { dbPort, dbUser, dbPass, startDb, logger, steps } = opts;
232
164
  console.log('\nWriting docker-compose.yml to', DOCKER_COMPOSE_DST);
233
165
  const yamlQuote = (s) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
234
166
  let composeContent = fs.readFileSync(DOCKER_COMPOSE_SRC, 'utf-8');
@@ -236,72 +168,99 @@ async function startDockerContainers(opts) {
236
168
  .replace('POSTGRES_USER: dev', `POSTGRES_USER: ${yamlQuote(dbUser)}`)
237
169
  .replace('POSTGRES_PASSWORD: dev', `POSTGRES_PASSWORD: ${yamlQuote(dbPass)}`)
238
170
  .replace('"5433:5432"', `"${dbPort}:5432"`);
171
+ // Carry over an existing literal INFINITY_MODEL_ID — never the set of the
172
+ // config still being entered — so a setup that aborts before the config is
173
+ // written leaves a file consistent with the previously saved config.
174
+ let existingModelId = null;
175
+ try {
176
+ existingModelId = (0, infinity_provisioning_1.extractModelIdValue)(fs.readFileSync(DOCKER_COMPOSE_DST, 'utf-8'));
177
+ }
178
+ catch { /* no prior file */ }
179
+ if (existingModelId) {
180
+ composeContent = composeContent.replace(/INFINITY_MODEL_ID:\s*"[^"]*"/, `INFINITY_MODEL_ID: "${existingModelId}"`);
181
+ }
239
182
  fs.mkdirSync(config_store_1.CONFIG_DIR, { recursive: true });
240
- fs.writeFileSync(DOCKER_COMPOSE_DST, composeContent, 'utf-8');
183
+ let existingMode = 0o600;
184
+ try {
185
+ existingMode = fs.statSync(DOCKER_COMPOSE_DST).mode;
186
+ }
187
+ catch { /* first-ever write */ }
188
+ (0, infinity_provisioning_1.atomicWriteFile)(DOCKER_COMPOSE_DST, composeContent, existingMode);
241
189
  // The GPU override is copied verbatim so the managed Infinity path can layer it.
242
190
  try {
243
191
  fs.copyFileSync(DOCKER_GPU_OVERRIDE_SRC, DOCKER_GPU_OVERRIDE_DST);
244
192
  }
245
193
  catch { /* absent → CPU only */ }
246
- const dockerFail = (reason) => {
247
- console.error(reason);
248
- logger?.error('startDockerContainers: docker startup failed', { reason, steps: JSON.stringify(steps) });
194
+ if (!startDb)
195
+ return;
196
+ console.log('\nStarting the database container...');
197
+ const r = (0, child_process_1.spawnSync)('docker', ['compose', '-f', DOCKER_COMPOSE_DST, 'up', '-d', 'db'], { stdio: 'inherit' });
198
+ if (r.status !== 0) {
199
+ console.error('Failed to start the database container. Make sure Docker is running.');
200
+ logger?.error('prepareComposeAndStartDb: docker startup failed', { steps: JSON.stringify(steps) });
249
201
  process.exit(1);
250
- };
251
- // --- Database ---
252
- if (startDb) {
253
- console.log('\nStarting the database container...');
254
- const r = (0, child_process_1.spawnSync)('docker', ['compose', '-f', DOCKER_COMPOSE_DST, 'up', '-d', 'db'], { stdio: 'inherit' });
255
- if (r.status !== 0)
256
- dockerFail('Failed to start the database container. Make sure Docker is running.');
257
- await waitForDb('localhost', parseInt(dbPort), dbUser, dbPass, 'postgres', 60, logger, steps);
258
202
  }
259
- // --- Embedding backend ---
260
- if (!startBackend) {
261
- if (isLocalBackend(provider))
262
- console.log(`${provider} is already reachable — not starting a managed container.`);
263
- else
264
- console.log('Cloud embedding provider — no local backend to start.');
265
- return;
203
+ await waitForDb('localhost', parseInt(dbPort), dbUser, dbPass, 'postgres', 60, logger, steps);
204
+ }
205
+ /**
206
+ * Infinity provisioning (D6a) — runs AFTER the config file is written, so no
207
+ * outcome here can leave the config unsaved. `savedConfig` is a fresh read of
208
+ * the just-written file (never the process's own startup config). Exit-status
209
+ * rules: 0 for served / timeout (warning) / external instructions / declined
210
+ * Docker; 1 for any other provisioning failure — the cause is printed and the
211
+ * remaining setup steps (DB schema init) still run.
212
+ */
213
+ async function provisionInfinity(savedConfig, startDocker, logger, deps = {}, composePathOverride) {
214
+ const composePath = composePathOverride ?? DOCKER_COMPOSE_DST;
215
+ const baseURL = savedConfig.embedding.baseURL;
216
+ const base = (0, infinity_provisioning_1.serverRoot)(baseURL);
217
+ const set = (0, infinity_provisioning_1.requiredModelSet)(savedConfig);
218
+ const managed = (0, infinity_provisioning_1.isManagedInfinity)(baseURL, composePath);
219
+ // Targeted write happens for a managed backend regardless of what follows
220
+ // (yes/no, served/missing), so the compose file always matches the saved
221
+ // config — even when the running container already serves the set.
222
+ if (managed) {
223
+ try {
224
+ (0, infinity_provisioning_1.writeComposeModelSet)(composePath, set);
225
+ }
226
+ catch (err) {
227
+ console.error(`\nFailed to write the managed compose file: ${err.message}`);
228
+ logger?.error('provisionInfinity: compose write failed', { reason: err.message });
229
+ process.exitCode = 1;
230
+ }
266
231
  }
267
- if (provider === 'ollama') {
268
- console.log('\nStarting the Ollama container...');
269
- const r = (0, child_process_1.spawnSync)('docker', ['compose', '-f', DOCKER_COMPOSE_DST, 'up', '-d', 'ollama'], { stdio: 'inherit' });
270
- if (r.status !== 0)
271
- dockerFail('Failed to start the Ollama container. Make sure Docker is running.');
272
- await waitForOllama(ollamaHost, 60, logger, steps);
273
- const ollamaId = (0, child_process_1.spawnSync)('docker', ['compose', '-f', DOCKER_COMPOSE_DST, 'ps', '-q', 'ollama'], { encoding: 'utf-8' })
274
- .stdout?.trim();
275
- if (!ollamaId) {
276
- const reason = 'Could not resolve ollama container ID from compose. Pull the model manually:';
277
- console.error(reason);
278
- console.error(` docker exec <ollama-container> ollama pull ${embModel}`);
279
- logger?.error('startDockerContainers: ollama container not found', { reason, steps: JSON.stringify(steps) });
280
- process.exit(1);
232
+ if (startDocker) {
233
+ try {
234
+ await (0, infinity_provisioning_1.ensureModelSetServed)(baseURL, composePath, set, deps);
235
+ console.log(`\nInfinity is serving the configured model set: ${set.join(';')}`);
236
+ }
237
+ catch (err) {
238
+ const msg = err.message;
239
+ if (/did not become ready within/.test(msg)) {
240
+ console.warn(`\nWarning: ${msg}`);
241
+ console.warn('The container is running and likely still warming up. Check again once /health responds, then run: rag-index');
242
+ logger?.warn('provisionInfinity: readiness timeout (non-fatal)', { reason: msg });
243
+ }
244
+ else {
245
+ console.error(`\nInfinity provisioning failed: ${msg}`);
246
+ logger?.error('provisionInfinity: failed', { reason: msg });
247
+ process.exitCode = 1;
248
+ }
281
249
  }
282
- console.log(`Pulling ${embModel} model (this may take a while)...`);
283
- // `-t` allocates a pseudo-TTY so `ollama pull` renders its live progress bar;
284
- // only when our stdout is itself a TTY (docker refuses `-t` without one).
285
- const ttyFlag = process.stdout.isTTY ? ['-t'] : [];
286
- (0, child_process_1.spawnSync)('docker', ['exec', ...ttyFlag, ollamaId, 'ollama', 'pull', embModel], { stdio: 'inherit' });
287
250
  return;
288
251
  }
289
- if (provider === 'infinity') {
290
- // Both models are bound at boot via a ";"-separated INFINITY_MODEL_ID.
291
- const modelIds = [embModel, ...(rerankModel ? [rerankModel] : [])].join(';');
292
- // Only layer the GPU override when it actually exists on disk — otherwise a
293
- // GPU host whose override copy failed would get `-f <missing>` → hard failure
294
- // instead of the intended CPU fallback.
295
- const gpu = hasNvidiaGpu() && fs.existsSync(DOCKER_GPU_OVERRIDE_DST);
296
- const files = ['-f', DOCKER_COMPOSE_DST, ...(gpu ? ['-f', DOCKER_GPU_OVERRIDE_DST] : [])];
297
- console.log(`\nStarting the Infinity container (${gpu ? 'GPU' : 'CPU'}) serving: ${modelIds}`);
298
- // `--force-recreate` so a changed model set recreates the container (D7) —
299
- // Infinity binds its models at startup and won't pick up a new set otherwise.
300
- const r = (0, child_process_1.spawnSync)('docker', ['compose', ...files, 'up', '-d', '--force-recreate', 'infinity'], { stdio: 'inherit', env: { ...process.env, INFINITY_MODEL_ID: modelIds } });
301
- if (r.status !== 0)
302
- dockerFail('Failed to start the Infinity container. Make sure Docker is running.');
303
- await waitForInfinity(embBaseURL, 1800, logger, steps);
252
+ // Docker declined — no Docker command; print what would apply the set.
253
+ const evaluation = await (0, infinity_provisioning_1.evaluateBackend)(baseURL, composePath, set, deps);
254
+ if (evaluation.action === 'served')
304
255
  return;
256
+ if (evaluation.managed) {
257
+ const files = (0, infinity_provisioning_1.composeFileArgs)(composePath);
258
+ console.log(`\nInfinity does not yet serve the required model set (${set.join(';')}).`);
259
+ console.log('Run this to apply it:');
260
+ console.log(` docker compose ${files.join(' ')} up -d --force-recreate infinity`);
261
+ }
262
+ else {
263
+ console.log(`\n${(0, infinity_provisioning_1.manualInstructionMessage)(evaluation, base, set)}`);
305
264
  }
306
265
  }
307
266
  async function runSetup(logger) {
@@ -340,24 +299,22 @@ async function runSetup(logger) {
340
299
  } while (!workspaceInput.trim());
341
300
  const workspaceRoot = expandHome(workspaceInput.trim());
342
301
  console.log('\n--- Embedding backend ---');
343
- console.log(' ollama — local, zero-setup (default)');
344
- console.log(' infinity — local, one container serves embeddings + reranking (GPU/CPU)');
345
- console.log(' openai-compatible — OpenAI cloud, or a local Infinity/TEI/vLLM server');
302
+ console.log(' infinity — local, one container serves embeddings + reranking (GPU/CPU) (default)');
303
+ console.log(' openai-compatible — OpenAI cloud, or a local Infinity/TEI/vLLM/Ollama (via its /v1 endpoint) server');
346
304
  console.log(' voyage — Voyage AI cloud');
347
- const validProviders = ['ollama', 'infinity', 'openai-compatible', 'voyage'];
348
305
  let embProvider;
349
306
  do {
350
- embProvider = (await loggedPrompt('Provider (ollama/infinity/openai-compatible/voyage)', 'ollama'));
351
- if (!validProviders.includes(embProvider))
352
- console.log(` Invalid provider. Choose: ${validProviders.join(', ')}`);
353
- } while (!validProviders.includes(embProvider));
307
+ embProvider = (await loggedPrompt('Provider (infinity/openai-compatible/voyage)', exports.EMBEDDING_PROVIDER_CHOICES[0]));
308
+ if (!exports.EMBEDDING_PROVIDER_CHOICES.includes(embProvider))
309
+ console.log(` Invalid provider. Choose: ${exports.EMBEDDING_PROVIDER_CHOICES.join(', ')}`);
310
+ } while (!exports.EMBEDDING_PROVIDER_CHOICES.includes(embProvider));
354
311
  const pd = PROVIDER_DEFAULTS[embProvider];
355
312
  const embBaseURL = await loggedPrompt('Base URL', pd.baseURL);
356
313
  const embModel = await loggedPrompt('Embedding model', pd.model);
357
314
  // Infinity serves a reranker from the same container — offer to provision one.
358
315
  let rerankModel = '';
359
316
  if (embProvider === 'infinity') {
360
- rerankModel = (await loggedPrompt(`Reranker model (served by the same Infinity container; blank to disable)`, DEFAULT_INFINITY_RERANK_MODEL)).trim();
317
+ rerankModel = (await loggedPrompt(`Reranker model (served by the same Infinity container; blank to disable)`, config_store_1.CONFIG_DEFAULTS['reranker.model'])).trim();
361
318
  }
362
319
  // API keys go into .env (chmod 600); config.json only stores the env: reference.
363
320
  let apiKeyRef = '';
@@ -373,8 +330,6 @@ async function runSetup(logger) {
373
330
  console.log(` No key entered — set it later: echo '${pd.envVar}=sk-…' >> ${config_store_1.ENV_FILE}`);
374
331
  }
375
332
  }
376
- // Ollama host used by the Docker helper below (only meaningful for ollama).
377
- const ollamaHost = embProvider === 'ollama' ? embBaseURL : 'http://localhost:11434';
378
333
  console.log('\n--- Database ---');
379
334
  const dbHost = await loggedPrompt('DB host', 'localhost');
380
335
  const dbPort = await loggedPrompt('DB port', '5433');
@@ -398,15 +353,9 @@ async function runSetup(logger) {
398
353
  const startDockerRaw = await loggedAsk(`Start Docker containers automatically? (y/n) (${dockerDefault}): `);
399
354
  const startDocker = (startDockerRaw.trim().toLowerCase() || dockerDefault) === 'y';
400
355
  if (startDocker) {
401
- await startDockerContainers({
402
- provider: embProvider,
403
- ollamaHost,
404
- embBaseURL,
356
+ await prepareComposeAndStartDb({
405
357
  dbPort, dbUser, dbPass,
406
- embModel,
407
- rerankModel: rerankModel || undefined,
408
358
  startDb: !dbReachable,
409
- startBackend: isLocalBackend(embProvider) && !embReachable,
410
359
  logger, steps,
411
360
  });
412
361
  }
@@ -469,6 +418,11 @@ async function runSetup(logger) {
469
418
  (0, config_store_1.writeConfigJson)(cfg);
470
419
  console.log(`\nConfig saved to ${config_store_1.CONFIG_FILE}`);
471
420
  rl.close();
421
+ // Infinity provisioning happens only now — after the config is safely on
422
+ // disk — so a provisioning failure never leaves the wizard's answers unsaved.
423
+ if (embProvider === 'infinity') {
424
+ await provisionInfinity(new config_1.Config(), startDocker, logger);
425
+ }
472
426
  console.log('\nInitializing database schema...');
473
427
  const { DatabaseService } = await Promise.resolve().then(() => __importStar(require('./services/DatabaseService')));
474
428
  const dbService = new DatabaseService(new config_1.Config());
@@ -12,19 +12,12 @@ services:
12
12
  volumes:
13
13
  - pgdata:/var/lib/postgresql/data
14
14
 
15
- ollama:
16
- image: ollama/ollama
17
- ports:
18
- - "11434:11434"
19
- volumes:
20
- - ollama_data:/root/.ollama
21
-
22
15
  # One Infinity container serves both /embeddings and /rerank. The model set is
23
16
  # bound at startup via INFINITY_MODEL_ID (";"-separated for multiple models,
24
- # e.g. "BAAI/bge-m3;BAAI/bge-reranker-v2-m3"). Defaults to CPU so it starts on
25
- # any host; GPU is opt-in via docker-compose.infinity-gpu.yml (see rag-setup).
26
- # Behind an "infinity" profile so `docker compose up -d` (ollama path) doesn't
27
- # pull this multi-GB image — it starts only when explicitly targeted.
17
+ # e.g. "BAAI/bge-m3;BAAI/bge-reranker-v2-m3") and rewritten in place by the app
18
+ # (a literal, not an env interpolation) so a plain `docker compose up -d`
19
+ # always serves the configured set. Defaults to CPU so it starts on any host;
20
+ # GPU is opt-in via docker-compose.infinity-gpu.yml (see rag-setup).
28
21
  # Model warmup defaults OFF here: on CPU it runs multi-minute benchmark
29
22
  # batches at every boot (a pure perf measurement, not required to serve). The
30
23
  # GPU override turns it back on, where it costs seconds.
@@ -34,16 +27,13 @@ services:
34
27
  ports:
35
28
  - "7997:7997"
36
29
  environment:
37
- INFINITY_MODEL_ID: ${INFINITY_MODEL_ID:-jinaai/jina-embeddings-v2-base-code}
30
+ INFINITY_MODEL_ID: "jinaai/jina-embeddings-v2-base-code;mixedbread-ai/mxbai-rerank-xsmall-v1"
38
31
  INFINITY_MODEL_WARMUP: ${INFINITY_MODEL_WARMUP:-false}
39
32
  INFINITY_PORT: "7997"
40
33
  command: ["v2"]
41
34
  volumes:
42
35
  - infinity_cache:/app/.cache
43
- profiles:
44
- - infinity
45
36
 
46
37
  volumes:
47
38
  pgdata:
48
- ollama_data:
49
39
  infinity_cache:
@@ -49,7 +49,7 @@ can only reach the dashboard while the process is alive.
49
49
  `rag-config edit` (CLI) instead.
50
50
  - A failed **DB** connection is non-fatal: the config editor still works; the
51
51
  Projects and Graph tabs show an in-page error.
52
- - A failed **Ollama** connection only affects model suggestions (see
52
+ - A failed **embedding backend** connection only affects model suggestions (see
53
53
  *Field options*); everything else works.
54
54
 
55
55
  ---
@@ -105,16 +105,15 @@ Some settings have a constrained set of valid values, so the form offers a list
105
105
  rather than free text. `GET /api/field-options` returns
106
106
  `{ [dottedKey]: { values: string[], strict: boolean } }`:
107
107
 
108
- - **`ollama.model` — strict.** The embedding model is tied to the vector column
109
- dimension (`bge-m3` → 1024). Values come from `SUPPORTED_EMBED_MODELS`
110
- (`src/constants.ts`); the dropdown does **not** allow free text, so a
111
- dimension-incompatible model cannot be set. To add a model, confirm its
112
- dimension matches and append it to `SUPPORTED_EMBED_MODELS`.
113
- - **`search.translateModel`, `reranker.model` — non-strict.** Suggestions are the
114
- user's installed Ollama models (`ollama.list()`), but free text is allowed
115
- because these are not dimension-bound and the user may pull a new model later.
116
- If Ollama is unreachable the list is empty and the field degrades to plain
117
- text.
108
+ - **`embedding.model`, `reranker.model` — non-strict.** `embedding.model`
109
+ suggests `MODEL_REGISTRY` (`src/constants.ts`) entries plus any model the
110
+ backend currently serves; `reranker.model` suggests only served models. Both
111
+ allow free text — a model outside the registry still works (its dimension is
112
+ probed at index time). Served models come from `IEmbedder.listModels()`,
113
+ implemented only by `InfinityEmbedder` (2-second-limited `/models` listing);
114
+ if the backend is unreachable, or the provider doesn't implement `listModels`
115
+ (`openai-compatible`, `voyage`), the served-model list is empty and the field
116
+ degrades to registry suggestions only (or plain text for `reranker.model`).
118
117
 
119
118
  A strict `<select>` preserves an unknown current value as an extra option, so
120
119
  saving never silently drops a value that isn't in the list.
@@ -132,19 +131,39 @@ All routes are served from `127.0.0.1:<port>`.
132
131
  | `GET` | `/api/defaults` | `CONFIG_DEFAULTS` — dotted key → default value |
133
132
  | `GET` | `/api/field-options` | dotted key → `{ values, strict }` |
134
133
  | `GET` | `/api/config` | Current config, `db.password` masked |
135
- | `PUT` | `/api/config` | Save. Body = config JSON. `200 {ok:true}` or `400 {error}` |
134
+ | `PUT` | `/api/config` | Save. Body = config JSON. `200 {ok:true, backend:{action,message}}` or `400 {error}`. For `embedding.provider: infinity`, `backend.action` reports whether the Infinity container needed a background recreate (`started`/`already-running`/`not-needed`/`manual-required`/`failed`) — see *Infinity backend recreate* below |
135
+ | `GET` | `/api/backend-status` | `{ state, message, startedAt, finishedAt }` — progress of the most recent background Infinity recreate this server process started |
136
136
  | `GET` | `/api/projects` | `SearcherService.listProjects()` |
137
137
  | `GET` | `/api/graph` | `{ nodes, edges }` JSON (`buildGraphData()`) |
138
138
  | `GET` | `/graph` | Full interactive graph page (`buildHtml()`), embedded by the Graph tab's iframe |
139
139
 
140
140
  ---
141
141
 
142
+ ## Infinity backend recreate
143
+
144
+ When a save's `embedding.provider` is `infinity`, the server computes the
145
+ required model set from the **saved** config (freshly re-read from disk, not
146
+ the embedder the process started with) and compares it against what the
147
+ backend currently serves. If a managed container needs to be started or
148
+ recreated to serve the new set, that runs as an in-process background task —
149
+ the `PUT` responds immediately (`action: "started"`) without waiting for it.
150
+ Poll `GET /api/backend-status` for progress (`state: "recreating"` →
151
+ `"ready"`/`"failed"`). Only one `docker compose` run is active at a time; a
152
+ save that arrives while a recreate is already in flight for a managed backend
153
+ responds `already-running`, and at most one follow-up run applies the latest
154
+ such save once the in-flight one finishes. An external (unmanaged) backend
155
+ missing a required model never triggers Docker — the response's `message`
156
+ carries manual `--model-id`/launch instructions instead.
157
+
158
+ ---
159
+
142
160
  ## Architecture
143
161
 
144
162
  | File | Responsibility |
145
163
  |---|---|
146
164
  | `src/commands/WebCommand.ts` | CLI entry. `db.initDb()`, starts the server, keeps `execute()` pending until `SIGINT`/`SIGTERM` (mirrors `McpCommand`), then `server.stop()`. Parses `--port` (default `4319`). |
147
- | `src/services/WebServerService.ts` | `WebServer(searcher, config, logger)` — `node:http` server, routing, and the API handlers. Exports the pure `mergeConfigForSave()` and the `FieldOption` type. |
165
+ | `src/services/WebServerService.ts` | `WebServer(searcher, config, logger, embedder, deps?)` — `node:http` server, routing, the API handlers, and the background Infinity recreate + `/api/backend-status` state. Exports the pure `mergeConfigForSave()` and the `FieldOption` type. |
166
+ | `src/services/infinity-provisioning.ts` | The readiness/recreate primitive the background task delegates to (shared with `InfinityEmbedder` and `rag-setup`) — classification, compose-file rewrite, `docker compose` invocation, readiness polling. |
148
167
  | `src/web/dashboard.ts` | `renderDashboardHtml()` — the entire page (HTML + CSS + JS) as one self-contained string, mirroring `graph.ts:buildHtml`. |
149
168
 
150
169
  Reused primitives (no duplication):
@@ -153,7 +172,8 @@ Reused primitives (no duplication):
153
172
  `CONFIG_DEFAULTS` — `src/config-store.ts`
154
173
  - `buildGraphData` — `src/graph.ts`
155
174
  - `listProjects` — `src/services/SearcherService.ts`
156
- - `SUPPORTED_EMBED_MODELS` — `src/constants.ts`
175
+ - `MODEL_REGISTRY` — `src/constants.ts`
176
+ - `evaluateBackend` / `ensureModelSetServed` / `requiredModelSet` — `src/services/infinity-provisioning.ts`
157
177
 
158
178
  Wiring: `app.ts` detects the `web` mode and dispatches
159
179
  `new WebCommand(container, effectiveArgs)`. The command uses the shared
@@ -182,6 +202,5 @@ changes were needed.
182
202
  `.default(...)` calls, so the runtime default and the dashboard-displayed
183
203
  default cannot diverge — no manual sync needed. (A unit test still asserts
184
204
  every key is a valid config key.)
185
- - `SUPPORTED_EMBED_MODELS` (`constants.ts`) must contain only models whose
186
- embedding dimension matches the `vector(...)` column — this one is still a
187
- manual invariant, guarded only at the membership level by a unit test.
205
+ - `MODEL_REGISTRY` (`constants.ts`) is advisory, not exhaustive — a model
206
+ outside it still works via the runtime dimension probe at index time.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codesearch-mcp",
3
- "version": "0.1.0-alpha",
3
+ "version": "0.2.0-alpha",
4
4
  "description": "Local semantic codebase search via MCP — indexes projects into pgvector, exposes search to Claude",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -18,9 +18,6 @@
18
18
  "engines": {
19
19
  "node": ">=18"
20
20
  },
21
- "publishConfig": {
22
- "tag": "alpha"
23
- },
24
21
  "main": "dist/app.js",
25
22
  "bin": {
26
23
  "rag-index": "dist/app.js",
@@ -58,7 +55,6 @@
58
55
  "cli-progress": "^3.12.0",
59
56
  "drizzle-orm": "^0.45.2",
60
57
  "ignore": "^7.0.5",
61
- "ollama": "^0.5.0",
62
58
  "pg": "^8.13.0",
63
59
  "pino": "^10.3.1",
64
60
  "pino-roll": "^4.0.0",
@@ -1,25 +0,0 @@
1
- import type { Config } from '../config';
2
- import type { IEmbedder, InputType } from '../interfaces';
3
- import type { ILogger } from '../interfaces';
4
- import type { EmbedderDeps } from './OpenAICompatibleEmbedder';
5
- export declare class OllamaEmbedder implements IEmbedder {
6
- private config;
7
- private logger;
8
- readonly dimensions: number;
9
- private client;
10
- rerank?: (query: string, documents: string[], topN: number) => Promise<number[]>;
11
- constructor(config: Config, logger?: ILogger, deps?: EmbedderDeps);
12
- embed(text: string, _inputType?: InputType): Promise<number[]>;
13
- embedBatch(texts: string[], _inputType?: InputType): Promise<number[][]>;
14
- listModels(): Promise<string[]>;
15
- /**
16
- * Pull the configured model if it is not already present locally, so a plain
17
- * `rag-index` works after switching to (or back to) a local model that was
18
- * never pulled — `rag-setup` is no longer the only place that fetches models.
19
- * A tagless model name (`bge-m3`) matches its `:latest`/tagged install.
20
- * Streams pull progress to the console: a live single-line bar on a TTY, or
21
- * de-duplicated status lines otherwise.
22
- */
23
- ensureModelAvailable(): Promise<void>;
24
- private static formatPullProgress;
25
- }