codesearch-mcp 0.0.18-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 (96) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/README.md +194 -23
  3. package/dist/app.js +38 -2
  4. package/dist/src/commands/IndexCommand.js +55 -1
  5. package/dist/src/commands/McpCommand.js +6 -2
  6. package/dist/src/config-error.d.ts +7 -0
  7. package/dist/src/config-error.js +14 -0
  8. package/dist/src/config-store.d.ts +73 -17
  9. package/dist/src/config-store.js +142 -27
  10. package/dist/src/config.d.ts +36 -11
  11. package/dist/src/config.js +89 -27
  12. package/dist/src/constants.d.ts +42 -2
  13. package/dist/src/constants.js +61 -7
  14. package/dist/src/container.js +5 -3
  15. package/dist/src/db/index.d.ts +1 -1
  16. package/dist/src/db/index.js +2 -1
  17. package/dist/src/db/schema.d.ts +111 -0
  18. package/dist/src/db/schema.js +15 -1
  19. package/dist/src/help.js +6 -5
  20. package/dist/src/interfaces/IAstParser.d.ts +14 -0
  21. package/dist/src/interfaces/IChunker.d.ts +24 -0
  22. package/dist/src/interfaces/IChunker.js +2 -0
  23. package/dist/src/interfaces/IDatabase.d.ts +31 -0
  24. package/dist/src/interfaces/IEmbedder.d.ts +14 -2
  25. package/dist/src/interfaces/ILogger.d.ts +7 -0
  26. package/dist/src/interfaces/ISearcher.d.ts +1 -0
  27. package/dist/src/interfaces/index.d.ts +2 -1
  28. package/dist/src/owner-path.d.ts +10 -0
  29. package/dist/src/owner-path.js +27 -0
  30. package/dist/src/services/AstChunker.d.ts +42 -0
  31. package/dist/src/services/AstChunker.js +158 -0
  32. package/dist/src/services/AstParserService.d.ts +6 -0
  33. package/dist/src/services/AstParserService.js +31 -2
  34. package/dist/src/services/DatabaseService.d.ts +17 -1
  35. package/dist/src/services/DatabaseService.js +116 -25
  36. package/dist/src/services/EmbedderFactory.d.ts +9 -0
  37. package/dist/src/services/EmbedderFactory.js +26 -0
  38. package/dist/src/services/IndexerService.d.ts +21 -5
  39. package/dist/src/services/IndexerService.js +127 -44
  40. package/dist/src/services/InfinityEmbedder.d.ts +38 -0
  41. package/dist/src/services/InfinityEmbedder.js +88 -0
  42. package/dist/src/services/LoggerService.d.ts +3 -1
  43. package/dist/src/services/LoggerService.js +17 -3
  44. package/dist/src/services/OpenAICompatibleEmbedder.d.ts +25 -0
  45. package/dist/src/services/OpenAICompatibleEmbedder.js +75 -0
  46. package/dist/src/services/SearcherService.d.ts +21 -1
  47. package/dist/src/services/SearcherService.js +129 -28
  48. package/dist/src/services/VoyageEmbedder.d.ts +22 -0
  49. package/dist/src/services/VoyageEmbedder.js +72 -0
  50. package/dist/src/services/WebServerService.d.ts +57 -14
  51. package/dist/src/services/WebServerService.js +134 -26
  52. package/dist/src/services/http-retry.d.ts +19 -0
  53. package/dist/src/services/http-retry.js +54 -0
  54. package/dist/src/services/infinity-provisioning.d.ts +115 -0
  55. package/dist/src/services/infinity-provisioning.js +516 -0
  56. package/dist/src/services/legacy-ollama-index.d.ts +24 -0
  57. package/dist/src/services/legacy-ollama-index.js +61 -0
  58. package/dist/src/services/reranker.d.ts +15 -0
  59. package/dist/src/services/reranker.js +44 -0
  60. package/dist/src/setup.d.ts +43 -0
  61. package/dist/src/setup.js +227 -55
  62. package/dist/src/tools/SearchCodebaseTool.js +1 -1
  63. package/dist/src/tools/format-helpers.js +2 -1
  64. package/dist/src/web/dashboard.js +5 -0
  65. package/docker/docker-compose.infinity-gpu.yml +19 -0
  66. package/docker/docker-compose.yml +20 -5
  67. package/docs/web-dashboard.md +36 -17
  68. package/package.json +18 -3
  69. package/dist/src/ast.d.ts +0 -2
  70. package/dist/src/ast.js +0 -110
  71. package/dist/src/commands/SetupCommand.d.ts +0 -7
  72. package/dist/src/commands/SetupCommand.js +0 -14
  73. package/dist/src/db/data-source.d.ts +0 -2
  74. package/dist/src/db/data-source.js +0 -13
  75. package/dist/src/db/entities/code-chunk.entity.d.ts +0 -9
  76. package/dist/src/db/entities/code-chunk.entity.js +0 -54
  77. package/dist/src/db/entities/code-dependency.entity.d.ts +0 -9
  78. package/dist/src/db/entities/code-dependency.entity.js +0 -54
  79. package/dist/src/db/entities/code-symbol.entity.d.ts +0 -10
  80. package/dist/src/db/entities/code-symbol.entity.js +0 -59
  81. package/dist/src/db/init.d.ts +0 -2
  82. package/dist/src/db/init.js +0 -62
  83. package/dist/src/env-loader.d.ts +0 -1
  84. package/dist/src/env-loader.js +0 -41
  85. package/dist/src/indexer.d.ts +0 -17
  86. package/dist/src/indexer.js +0 -242
  87. package/dist/src/mcp.d.ts +0 -1
  88. package/dist/src/mcp.js +0 -403
  89. package/dist/src/ollama.d.ts +0 -2
  90. package/dist/src/ollama.js +0 -6
  91. package/dist/src/searcher.d.ts +0 -74
  92. package/dist/src/searcher.js +0 -446
  93. package/dist/src/services/OllamaEmbedder.d.ts +0 -14
  94. package/dist/src/services/OllamaEmbedder.js +0 -63
  95. package/dist/vitest.config.d.ts +0 -2
  96. package/dist/vitest.config.js +0 -9
@@ -1,2 +1,45 @@
1
+ import { type EmbeddingProvider } from './constants';
2
+ import { Config } from './config';
3
+ import { type ProvisioningDeps } from './services/infinity-provisioning';
1
4
  import type { LoggerService } from './services/LoggerService';
5
+ type SetupStep = {
6
+ q: string;
7
+ a: string;
8
+ };
9
+ /** Providers the wizard offers, in prompt order; the first is the preselected default. Exported for unit testing. */
10
+ export declare const EMBEDDING_PROVIDER_CHOICES: readonly EmbeddingProvider[];
11
+ /**
12
+ * Default answer for the "start Docker containers?" prompt: no only when both
13
+ * the database and the embedding backend are already reachable. Pure — exported
14
+ * for unit testing.
15
+ */
16
+ export declare function dockerPromptDefault(dbReachable: boolean, embReachable: boolean): 'y' | 'n';
17
+ interface DbDockerOpts {
18
+ dbPort: string;
19
+ dbUser: string;
20
+ dbPass: string;
21
+ /** Bring up the local db container (skip when a remote db is already reachable). */
22
+ startDb: boolean;
23
+ logger?: LoggerService;
24
+ steps?: SetupStep[];
25
+ }
26
+ /**
27
+ * Regenerate `CONFIG_DIR/docker-compose.yml` from the shipped template
28
+ * (carrying over an existing literal `INFINITY_MODEL_ID`, written atomically),
29
+ * copy the GPU override, and start the DB container when requested. Runs
30
+ * BEFORE the config file is written — its failure aborts setup unsaved,
31
+ * matching the existing "abort without saving" behavior of the DB step. Does
32
+ * NOT touch Infinity provisioning (moved after `writeConfigJson` — see D6a).
33
+ */
34
+ export declare function prepareComposeAndStartDb(opts: DbDockerOpts): Promise<void>;
35
+ /**
36
+ * Infinity provisioning (D6a) — runs AFTER the config file is written, so no
37
+ * outcome here can leave the config unsaved. `savedConfig` is a fresh read of
38
+ * the just-written file (never the process's own startup config). Exit-status
39
+ * rules: 0 for served / timeout (warning) / external instructions / declined
40
+ * Docker; 1 for any other provisioning failure — the cause is printed and the
41
+ * remaining setup steps (DB schema init) still run.
42
+ */
43
+ export declare function provisionInfinity(savedConfig: Config, startDocker: boolean, logger?: LoggerService, deps?: ProvisioningDeps, composePathOverride?: string): Promise<void>;
2
44
  export declare function runSetup(logger?: LoggerService): Promise<void>;
45
+ export {};
package/dist/src/setup.js CHANGED
@@ -33,21 +33,27 @@ 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;
37
+ exports.dockerPromptDefault = dockerPromptDefault;
38
+ exports.prepareComposeAndStartDb = prepareComposeAndStartDb;
39
+ exports.provisionInfinity = provisionInfinity;
36
40
  exports.runSetup = runSetup;
37
41
  const readline = __importStar(require("readline"));
38
42
  const fs = __importStar(require("fs"));
39
43
  const path = __importStar(require("path"));
40
44
  const os = __importStar(require("os"));
41
45
  const child_process_1 = require("child_process");
42
- const constants_1 = require("./constants");
43
46
  const config_store_1 = require("./config-store");
44
47
  const config_1 = require("./config");
48
+ const infinity_provisioning_1 = require("./services/infinity-provisioning");
45
49
  const DEFAULT_LOG_DIR = path.join(config_store_1.CONFIG_DIR, 'logs');
46
50
  const _projectRoot = fs.existsSync(path.join(__dirname, '..', 'package.json'))
47
51
  ? path.join(__dirname, '..')
48
52
  : path.join(__dirname, '..', '..');
49
53
  const DOCKER_COMPOSE_SRC = path.join(_projectRoot, 'docker', 'docker-compose.yml');
50
54
  const DOCKER_COMPOSE_DST = path.join(config_store_1.CONFIG_DIR, 'docker-compose.yml');
55
+ const DOCKER_GPU_OVERRIDE_SRC = path.join(_projectRoot, 'docker', 'docker-compose.infinity-gpu.yml');
56
+ const DOCKER_GPU_OVERRIDE_DST = path.join(config_store_1.CONFIG_DIR, 'docker-compose.infinity-gpu.yml');
51
57
  function ask(rl, question) {
52
58
  return new Promise(resolve => rl.question(question, resolve));
53
59
  }
@@ -60,6 +66,54 @@ function expandHome(p) {
60
66
  return path.join(os.homedir(), p.slice(2));
61
67
  return p;
62
68
  }
69
+ const PROVIDER_DEFAULTS = {
70
+ 'openai-compatible': { baseURL: 'https://api.openai.com/v1', model: 'text-embedding-3-small', envVar: 'OPENAI_API_KEY' },
71
+ 'voyage': { baseURL: 'https://api.voyageai.com/v1', model: 'voyage-code-3', envVar: 'VOYAGE_API_KEY' },
72
+ 'infinity': { baseURL: 'http://localhost:7997', model: 'jinaai/jina-embeddings-v2-base-code', envVar: '' },
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'];
76
+ /** Providers that run a local inference backend we can probe / provision. */
77
+ function isLocalBackend(p) {
78
+ return p === 'infinity';
79
+ }
80
+ /**
81
+ * Probe whether the embedding backend is already reachable, so the "start
82
+ * Docker?" prompt can default to no (mirrors the DB `checkRemoteDb` flow).
83
+ * Cloud providers have no local backend → always "reachable" (nothing to start).
84
+ */
85
+ async function checkEmbeddingBackend(provider, baseURL) {
86
+ if (!isLocalBackend(provider))
87
+ return true;
88
+ return (0, infinity_provisioning_1.probeInfinityHealth)(baseURL);
89
+ }
90
+ /**
91
+ * Default answer for the "start Docker containers?" prompt: no only when both
92
+ * the database and the embedding backend are already reachable. Pure — exported
93
+ * for unit testing.
94
+ */
95
+ function dockerPromptDefault(dbReachable, embReachable) {
96
+ return (dbReachable && embReachable) ? 'n' : 'y';
97
+ }
98
+ /**
99
+ * Write (or replace) a single `KEY=VALUE` line in `~/.config/codesearch-mcp/.env`,
100
+ * chmod 600. API keys live here — never in config.json — referenced as `env:KEY`.
101
+ */
102
+ function writeEnvKey(name, value) {
103
+ fs.mkdirSync(config_store_1.CONFIG_DIR, { recursive: true });
104
+ let existing = '';
105
+ try {
106
+ existing = fs.readFileSync(config_store_1.ENV_FILE, 'utf-8');
107
+ }
108
+ catch { /* absent */ }
109
+ const kept = existing.split(/\r?\n/).filter(l => l.trim() && !l.trim().startsWith(`${name}=`));
110
+ kept.push(`${name}=${value}`);
111
+ fs.writeFileSync(config_store_1.ENV_FILE, kept.join('\n') + '\n', { mode: 0o600 });
112
+ try {
113
+ fs.chmodSync(config_store_1.ENV_FILE, 0o600);
114
+ }
115
+ catch { /* best effort */ }
116
+ }
63
117
  async function waitForDb(host, port, user, pass, db, maxWaitSec = 60, logger, steps) {
64
118
  const { Client } = await Promise.resolve().then(() => __importStar(require('pg')));
65
119
  const deadline = Date.now() + maxWaitSec * 1000;
@@ -82,33 +136,6 @@ async function waitForDb(host, port, user, pass, db, maxWaitSec = 60, logger, st
82
136
  logger?.error('waitForDb: timeout', { reason, steps: JSON.stringify(steps) });
83
137
  process.exit(1);
84
138
  }
85
- async function waitForOllama(host, maxWaitSec = 60, logger, steps) {
86
- const deadline = Date.now() + maxWaitSec * 1000;
87
- process.stdout.write('Waiting for Ollama to be ready');
88
- while (Date.now() < deadline) {
89
- try {
90
- const http = await Promise.resolve().then(() => __importStar(require('http')));
91
- await new Promise((resolve, reject) => {
92
- const req = http.get(`${host}/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
- console.log(' ready.');
100
- return;
101
- }
102
- catch {
103
- process.stdout.write('.');
104
- await new Promise(r => setTimeout(r, 2000));
105
- }
106
- }
107
- const reason = 'Ollama did not become ready in time.';
108
- console.error(`\n${reason}`);
109
- logger?.error('waitForOllama: timeout', { reason, steps: JSON.stringify(steps) });
110
- process.exit(1);
111
- }
112
139
  async function checkRemoteDb(host, port, user, pass, db, logger, steps) {
113
140
  try {
114
141
  const { Client } = await Promise.resolve().then(() => __importStar(require('pg')));
@@ -124,7 +151,16 @@ async function checkRemoteDb(host, port, user, pass, db, logger, steps) {
124
151
  return false;
125
152
  }
126
153
  }
127
- async function startDockerContainers(ollamaHost, dbPort, dbUser, dbPass, logger, steps) {
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;
128
164
  console.log('\nWriting docker-compose.yml to', DOCKER_COMPOSE_DST);
129
165
  const yamlQuote = (s) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
130
166
  let composeContent = fs.readFileSync(DOCKER_COMPOSE_SRC, 'utf-8');
@@ -132,35 +168,106 @@ async function startDockerContainers(ollamaHost, dbPort, dbUser, dbPass, logger,
132
168
  .replace('POSTGRES_USER: dev', `POSTGRES_USER: ${yamlQuote(dbUser)}`)
133
169
  .replace('POSTGRES_PASSWORD: dev', `POSTGRES_PASSWORD: ${yamlQuote(dbPass)}`)
134
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
+ }
135
182
  fs.mkdirSync(config_store_1.CONFIG_DIR, { recursive: true });
136
- fs.writeFileSync(DOCKER_COMPOSE_DST, composeContent, 'utf-8');
137
- console.log('\nStarting Docker containers...');
138
- const upResult = (0, child_process_1.spawnSync)('docker', ['compose', '-f', DOCKER_COMPOSE_DST, 'up', '-d'], { stdio: 'inherit' });
139
- if (upResult.status !== 0) {
140
- const reason = 'Failed to start Docker containers. Make sure Docker is running.';
141
- console.error(reason);
142
- logger?.error('startDockerContainers: docker startup failed', { reason, steps: JSON.stringify(steps) });
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);
189
+ // The GPU override is copied verbatim so the managed Infinity path can layer it.
190
+ try {
191
+ fs.copyFileSync(DOCKER_GPU_OVERRIDE_SRC, DOCKER_GPU_OVERRIDE_DST);
192
+ }
193
+ catch { /* absent → CPU only */ }
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) });
143
201
  process.exit(1);
144
202
  }
145
203
  await waitForDb('localhost', parseInt(dbPort), dbUser, dbPass, 'postgres', 60, logger, steps);
146
- await waitForOllama(ollamaHost, 60, logger, steps);
147
- const ollamaId = (0, child_process_1.spawnSync)('docker', ['compose', '-f', DOCKER_COMPOSE_DST, 'ps', '-q', 'ollama'], { encoding: 'utf-8' })
148
- .stdout?.trim();
149
- if (!ollamaId) {
150
- const reason = 'Could not resolve ollama container ID from compose. Pull the model manually:';
151
- console.error(reason);
152
- console.error(` docker exec <ollama-container> ollama pull ${constants_1.DEFAULT_EMBED_MODEL}`);
153
- logger?.error('startDockerContainers: ollama container not found', { reason, steps: JSON.stringify(steps) });
154
- process.exit(1);
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
+ }
231
+ }
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
+ }
249
+ }
250
+ return;
251
+ }
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')
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)}`);
155
264
  }
156
- console.log(`Pulling ${constants_1.DEFAULT_EMBED_MODEL} model (this may take a while)...`);
157
- (0, child_process_1.spawnSync)('docker', ['exec', ollamaId, 'ollama', 'pull', constants_1.DEFAULT_EMBED_MODEL], { stdio: 'inherit' });
158
265
  }
159
266
  async function runSetup(logger) {
160
267
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
161
268
  const steps = [];
162
269
  function logStep(q, raw) {
163
- const a = /password/i.test(q) ? '***' : raw.trim();
270
+ const a = /password|key|secret|token/i.test(q) ? '***' : raw.trim();
164
271
  logger?.debug('setup: prompt', { q, a });
165
272
  steps.push({ q, a });
166
273
  }
@@ -191,23 +298,68 @@ async function runSetup(logger) {
191
298
  console.log(' Path is required.');
192
299
  } while (!workspaceInput.trim());
193
300
  const workspaceRoot = expandHome(workspaceInput.trim());
194
- const ollamaHost = await loggedPrompt('Ollama API URL', 'http://localhost:11434');
301
+ console.log('\n--- Embedding backend ---');
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');
304
+ console.log(' voyage — Voyage AI cloud');
305
+ let embProvider;
306
+ do {
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));
311
+ const pd = PROVIDER_DEFAULTS[embProvider];
312
+ const embBaseURL = await loggedPrompt('Base URL', pd.baseURL);
313
+ const embModel = await loggedPrompt('Embedding model', pd.model);
314
+ // Infinity serves a reranker from the same container — offer to provision one.
315
+ let rerankModel = '';
316
+ if (embProvider === 'infinity') {
317
+ rerankModel = (await loggedPrompt(`Reranker model (served by the same Infinity container; blank to disable)`, config_store_1.CONFIG_DEFAULTS['reranker.model'])).trim();
318
+ }
319
+ // API keys go into .env (chmod 600); config.json only stores the env: reference.
320
+ let apiKeyRef = '';
321
+ if (!isLocalBackend(embProvider)) {
322
+ const key = (await loggedAsk(`API key (stored in ${config_store_1.ENV_FILE}, leave blank to set later): `)).trim();
323
+ if (key) {
324
+ writeEnvKey(pd.envVar, key);
325
+ apiKeyRef = `env:${pd.envVar}`;
326
+ console.log(` Saved API key to ${config_store_1.ENV_FILE} as ${pd.envVar} (config references env:${pd.envVar}).`);
327
+ }
328
+ else {
329
+ apiKeyRef = `env:${pd.envVar}`;
330
+ console.log(` No key entered — set it later: echo '${pd.envVar}=sk-…' >> ${config_store_1.ENV_FILE}`);
331
+ }
332
+ }
195
333
  console.log('\n--- Database ---');
196
334
  const dbHost = await loggedPrompt('DB host', 'localhost');
197
335
  const dbPort = await loggedPrompt('DB port', '5433');
198
336
  const dbUser = await loggedPrompt('DB user', 'dev');
199
337
  const dbPass = await loggedPrompt('DB password', 'dev');
200
338
  process.stdout.write('\nTesting connection...');
201
- const alreadyConnected = await checkRemoteDb(dbHost, dbPort, dbUser, dbPass, 'postgres', logger, steps);
202
- if (alreadyConnected)
339
+ const dbReachable = await checkRemoteDb(dbHost, dbPort, dbUser, dbPass, 'postgres', logger, steps);
340
+ if (dbReachable)
203
341
  console.log(' Database is already reachable.');
204
- const dockerDefault = alreadyConnected ? 'n' : 'y';
342
+ // Symmetric to the DB probe: a reachable embedding backend defaults "start
343
+ // Docker?" to no. Cloud providers have no local backend to start.
344
+ process.stdout.write('Checking embedding backend...');
345
+ const embReachable = await checkEmbeddingBackend(embProvider, embBaseURL);
346
+ if (isLocalBackend(embProvider)) {
347
+ console.log(embReachable ? ` ${embProvider} is already reachable.` : ` ${embProvider} is not reachable — it can be started via Docker.`);
348
+ }
349
+ else {
350
+ console.log(' cloud provider (no local backend to start).');
351
+ }
352
+ const dockerDefault = dockerPromptDefault(dbReachable, embReachable);
205
353
  const startDockerRaw = await loggedAsk(`Start Docker containers automatically? (y/n) (${dockerDefault}): `);
206
354
  const startDocker = (startDockerRaw.trim().toLowerCase() || dockerDefault) === 'y';
207
355
  if (startDocker) {
208
- await startDockerContainers(ollamaHost, dbPort, dbUser, dbPass, logger, steps);
356
+ await prepareComposeAndStartDb({
357
+ dbPort, dbUser, dbPass,
358
+ startDb: !dbReachable,
359
+ logger, steps,
360
+ });
209
361
  }
210
- else if (!alreadyConnected) {
362
+ else if (!dbReachable) {
211
363
  while (true) {
212
364
  console.log('\nTesting connection...');
213
365
  const ok = await checkRemoteDb(dbHost, dbPort, dbUser, dbPass, 'postgres', logger, steps);
@@ -242,15 +394,35 @@ async function runSetup(logger) {
242
394
  user: dbVars.user,
243
395
  password: dbVars.password,
244
396
  },
245
- ollama: { host: ollamaHost },
397
+ embedding: {
398
+ provider: embProvider,
399
+ baseURL: embBaseURL,
400
+ model: embModel,
401
+ ...(apiKeyRef ? { apiKey: apiKeyRef } : {}),
402
+ },
246
403
  logging: { level: logLevel },
247
404
  workspaceRoot,
248
405
  };
406
+ // Infinity serves the reranker from the same container — auto-wire reranker.*
407
+ // to that server. `url` derives from provider + baseURL in the Config constructor.
408
+ if (embProvider === 'infinity' && rerankModel) {
409
+ cfg.reranker = {
410
+ enabled: true,
411
+ provider: 'openai-compatible',
412
+ baseURL: embBaseURL,
413
+ model: rerankModel,
414
+ };
415
+ }
249
416
  if (logDir)
250
417
  cfg.logging.dir = logDir;
251
418
  (0, config_store_1.writeConfigJson)(cfg);
252
419
  console.log(`\nConfig saved to ${config_store_1.CONFIG_FILE}`);
253
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
+ }
254
426
  console.log('\nInitializing database schema...');
255
427
  const { DatabaseService } = await Promise.resolve().then(() => __importStar(require('./services/DatabaseService')));
256
428
  const dbService = new DatabaseService(new config_1.Config());
@@ -18,7 +18,7 @@ class SearchCodebaseTool {
18
18
  definition() {
19
19
  return {
20
20
  name: 'search_codebase',
21
- description: 'Search local project codebase by semantic similarity',
21
+ description: 'Search local project codebase by semantic similarity. Each result shows File, Project, an optional Symbol breadcrumb (owning declaration, e.g. `IndexerService.ingestFile`), Line, the code, and Relevance.',
22
22
  inputSchema: {
23
23
  type: 'object',
24
24
  properties: {
@@ -13,7 +13,8 @@ function errorResponse(text) {
13
13
  return { isError: true, content: [{ type: 'text', text }] };
14
14
  }
15
15
  function formatChunk(r, label) {
16
- return `${label ? `*(${label})*\n` : ''}**File:** ${r.filePath}\n**Project:** ${r.serviceName}\n**Line:** ${r.startLine}\n\`\`\`\n${r.content}\n\`\`\``;
16
+ const symbolLine = r.ownerPath ? `**Symbol:** ${r.ownerPath}\n` : '';
17
+ return `${label ? `*(${label})*\n` : ''}**File:** ${r.filePath}\n**Project:** ${r.serviceName}\n${symbolLine}**Line:** ${r.startLine}\n\`\`\`\n${r.content}\n\`\`\``;
17
18
  }
18
19
  function formatGraphContext(ctx) {
19
20
  const parts = [];
@@ -176,7 +176,9 @@ function renderDashboardHtml() {
176
176
  const isNum = spec.type === 'number' || spec.type === 'integer';
177
177
  inp.dataset.key = key;
178
178
  inp.dataset.type = isNum ? 'number' : 'string';
179
+ const isEnvRef = typeof value === 'string' && value.startsWith('env:');
179
180
  if (key === 'db.password') inp.type = 'password';
181
+ else if (key.endsWith('.apiKey')) inp.type = isEnvRef ? 'text' : 'password';
180
182
  else if (isNum) {
181
183
  inp.type = 'number';
182
184
  // Only apply small, meaningful bounds (skip zod's safe-integer noise).
@@ -197,6 +199,9 @@ function renderDashboardHtml() {
197
199
  const label = document.createElement('label');
198
200
  let html = key.split('.').pop();
199
201
  if (spec.description) html += '<span class="desc">' + spec.description + '</span>';
202
+ if (key.endsWith('.apiKey') && typeof value === 'string' && value.startsWith('env:')) {
203
+ html += '<span class="defval using">✔ set via ' + value.slice(4) + '</span>';
204
+ }
200
205
  if (def !== undefined) {
201
206
  html += '<span class="defval' + (isUnset ? ' using' : '') + '">'
202
207
  + (isUnset ? '↪ using default: ' : 'default: ') + fmtDefault(def) + '</span>';
@@ -0,0 +1,19 @@
1
+ # GPU override for the Infinity service. Layered on top of docker-compose.yml
2
+ # (docker compose -f docker-compose.yml -f docker-compose.infinity-gpu.yml …)
3
+ # only when an NVIDIA GPU + container runtime is detected. Kept separate because
4
+ # a `deploy.reservations.devices` GPU request aborts startup on GPU-less hosts;
5
+ # the base file therefore stays CPU-only and always starts.
6
+ services:
7
+ infinity:
8
+ # Warmup is cheap on GPU (seconds) — re-enable it so the first real request
9
+ # is already fast. On CPU (base file only) it stays off.
10
+ environment:
11
+ INFINITY_MODEL_WARMUP: "true"
12
+ deploy:
13
+ resources:
14
+ reservations:
15
+ devices:
16
+ - driver: nvidia
17
+ count: all
18
+ capabilities:
19
+ - gpu
@@ -12,13 +12,28 @@ services:
12
12
  volumes:
13
13
  - pgdata:/var/lib/postgresql/data
14
14
 
15
- ollama:
16
- image: ollama/ollama
15
+ # One Infinity container serves both /embeddings and /rerank. The model set is
16
+ # bound at startup via INFINITY_MODEL_ID (";"-separated for multiple models,
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).
21
+ # Model warmup defaults OFF here: on CPU it runs multi-minute benchmark
22
+ # batches at every boot (a pure perf measurement, not required to serve). The
23
+ # GPU override turns it back on, where it costs seconds.
24
+ infinity:
25
+ image: michaelf34/infinity:latest
26
+ restart: unless-stopped
17
27
  ports:
18
- - "11434:11434"
28
+ - "7997:7997"
29
+ environment:
30
+ INFINITY_MODEL_ID: "jinaai/jina-embeddings-v2-base-code;mixedbread-ai/mxbai-rerank-xsmall-v1"
31
+ INFINITY_MODEL_WARMUP: ${INFINITY_MODEL_WARMUP:-false}
32
+ INFINITY_PORT: "7997"
33
+ command: ["v2"]
19
34
  volumes:
20
- - ollama_data:/root/.ollama
35
+ - infinity_cache:/app/.cache
21
36
 
22
37
  volumes:
23
38
  pgdata:
24
- ollama_data:
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.