codesearch-mcp 0.1.0-alpha → 0.2.1-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 (64) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +146 -41
  3. package/dist/app.js +4 -19
  4. package/dist/src/commands/IndexCommand.js +68 -7
  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/db/index.d.ts +1 -1
  14. package/dist/src/db/index.js +2 -1
  15. package/dist/src/db/schema.d.ts +75 -0
  16. package/dist/src/db/schema.js +10 -1
  17. package/dist/src/enriched-prompt.d.ts +8 -0
  18. package/dist/src/enriched-prompt.js +87 -0
  19. package/dist/src/help.js +1 -1
  20. package/dist/src/interfaces/IAstParser.d.ts +18 -2
  21. package/dist/src/interfaces/IDatabase.d.ts +31 -0
  22. package/dist/src/interfaces/ILogger.d.ts +7 -0
  23. package/dist/src/interfaces/ISearcher.d.ts +3 -0
  24. package/dist/src/services/AstChunker.d.ts +11 -0
  25. package/dist/src/services/AstChunker.js +44 -0
  26. package/dist/src/services/AstParserService.d.ts +39 -7
  27. package/dist/src/services/AstParserService.js +504 -104
  28. package/dist/src/services/DatabaseService.d.ts +17 -0
  29. package/dist/src/services/DatabaseService.js +130 -2
  30. package/dist/src/services/EmbedderFactory.d.ts +1 -1
  31. package/dist/src/services/EmbedderFactory.js +2 -5
  32. package/dist/src/services/IndexerService.d.ts +4 -14
  33. package/dist/src/services/IndexerService.js +54 -156
  34. package/dist/src/services/InfinityEmbedder.d.ts +14 -55
  35. package/dist/src/services/InfinityEmbedder.js +30 -141
  36. package/dist/src/services/LoggerService.d.ts +3 -1
  37. package/dist/src/services/LoggerService.js +17 -3
  38. package/dist/src/services/McpServer.js +3 -3
  39. package/dist/src/services/SearcherService.d.ts +39 -4
  40. package/dist/src/services/SearcherService.js +254 -81
  41. package/dist/src/services/WebServerService.d.ts +41 -3
  42. package/dist/src/services/WebServerService.js +90 -2
  43. package/dist/src/services/infinity-provisioning.d.ts +115 -0
  44. package/dist/src/services/infinity-provisioning.js +516 -0
  45. package/dist/src/services/legacy-ollama-index.d.ts +24 -0
  46. package/dist/src/services/legacy-ollama-index.js +61 -0
  47. package/dist/src/services/reranker.d.ts +3 -3
  48. package/dist/src/services/reranker.js +3 -3
  49. package/dist/src/setup.d.ts +37 -0
  50. package/dist/src/setup.js +111 -157
  51. package/dist/src/symbol-name.d.ts +44 -0
  52. package/dist/src/symbol-name.js +68 -0
  53. package/dist/src/symbol-rows.d.ts +12 -0
  54. package/dist/src/symbol-rows.js +38 -0
  55. package/dist/src/tools/FindSymbolTool.js +30 -7
  56. package/dist/src/tools/GetCallersTool.js +20 -5
  57. package/docker/docker-compose.yml +5 -15
  58. package/docs/architecture.svg +103 -0
  59. package/docs/web-dashboard.md +36 -17
  60. package/package.json +1 -6
  61. package/dist/src/commands/BackfillCommand.d.ts +0 -7
  62. package/dist/src/commands/BackfillCommand.js +0 -37
  63. package/dist/src/services/OllamaEmbedder.d.ts +0 -25
  64. package/dist/src/services/OllamaEmbedder.js +0 -111
@@ -0,0 +1,516 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.serverRoot = serverRoot;
37
+ exports.probeInfinityHealth = probeInfinityHealth;
38
+ exports.listInfinityModels = listInfinityModels;
39
+ exports.sameServer = sameServer;
40
+ exports.isManagedInfinity = isManagedInfinity;
41
+ exports.extractModelIdValue = extractModelIdValue;
42
+ exports.atomicWriteFile = atomicWriteFile;
43
+ exports.writeComposeModelSet = writeComposeModelSet;
44
+ exports.hasNvidiaGpu = hasNvidiaGpu;
45
+ exports.composeFileArgs = composeFileArgs;
46
+ exports.evaluateBackend = evaluateBackend;
47
+ exports.manualInstructionMessage = manualInstructionMessage;
48
+ exports.ensureBackendReady = ensureBackendReady;
49
+ exports.ensureModelSetServed = ensureModelSetServed;
50
+ exports.requiredModelSet = requiredModelSet;
51
+ const fs = __importStar(require("fs"));
52
+ const path = __importStar(require("path"));
53
+ const child_process_1 = require("child_process");
54
+ /**
55
+ * Strip trailing slashes and a trailing `/embeddings` or `/v1` to get the server
56
+ * root — Infinity serves `/health`, `/models`, `/rerank`, `/embeddings` from the
57
+ * root, so a baseURL written with an OpenAI-style suffix must be normalized here
58
+ * to keep the probe/models URLs aligned with the embeddings URL.
59
+ */
60
+ function serverRoot(baseURL) {
61
+ return baseURL.replace(/\/+$/, '').replace(/\/(embeddings|v1)$/, '');
62
+ }
63
+ /**
64
+ * Probe an Infinity-style `/health` endpoint. Resolves `true` when the server
65
+ * answers with a 2xx, `false` on any error, non-2xx, or timeout.
66
+ */
67
+ async function probeInfinityHealth(baseURL, fetchImpl = fetch, timeoutMs = 2000) {
68
+ try {
69
+ const res = await fetchImpl(`${serverRoot(baseURL)}/health`, {
70
+ method: 'GET',
71
+ signal: AbortSignal.timeout(timeoutMs),
72
+ });
73
+ return res.ok;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ /**
80
+ * List the model ids an Infinity server currently serves via its OpenAI-style
81
+ * `/models` listing (`{ data: [{ id }] }`). Returns `[]` on any error, non-2xx,
82
+ * or timeout — callers treat an empty/unverifiable listing as "served".
83
+ */
84
+ async function listInfinityModels(baseURL, fetchImpl = fetch, timeoutMs = 2000) {
85
+ try {
86
+ const res = await fetchImpl(`${serverRoot(baseURL)}/models`, {
87
+ method: 'GET',
88
+ signal: AbortSignal.timeout(timeoutMs),
89
+ });
90
+ if (!res.ok)
91
+ return [];
92
+ const data = await res.json();
93
+ return (data.data ?? []).map(m => m.id).filter((id) => Boolean(id));
94
+ }
95
+ catch {
96
+ return [];
97
+ }
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // Compose-file parsing — deliberately line-based, not a YAML library: we only
101
+ // ever make targeted, well-anchored edits to our own shipped template, and
102
+ // need "every other byte unchanged" — a round-trip through a YAML AST would
103
+ // not guarantee that.
104
+ // ---------------------------------------------------------------------------
105
+ /** [start, end) line range of a top-level `services:` entry (2-space indent), or null if absent. */
106
+ function findServiceBlock(lines, serviceName) {
107
+ const headerRe = new RegExp(`^ ${serviceName}:\\s*$`);
108
+ const start = lines.findIndex(l => headerRe.test(l));
109
+ if (start === -1)
110
+ return null;
111
+ let end = lines.length;
112
+ for (let i = start + 1; i < lines.length; i++) {
113
+ const line = lines[i];
114
+ if (line.trim() === '')
115
+ continue;
116
+ if (/^\S/.test(line) || /^ \S/.test(line)) {
117
+ end = i;
118
+ break;
119
+ }
120
+ }
121
+ return [start, end];
122
+ }
123
+ /** Extract a published host port from one `ports:` list item's raw line(s), or null if unrecognised. */
124
+ function parsePortItem(item) {
125
+ if (item.length === 1) {
126
+ const raw = item[0].replace(/^["']|["']$/g, '').trim();
127
+ // Short form: "HOST:CONTAINER" or "IP:HOST:CONTAINER" (optional /tcp|/udp).
128
+ const m = raw.match(/^(?:(?:\[[0-9a-fA-F:]+\]|[0-9.]+):)?(\d+):(\d+)(?:\/(?:tcp|udp))?$/);
129
+ return m ? m[1] : null;
130
+ }
131
+ // Long form block ("- target: 7997\n published: 7997\n protocol: tcp").
132
+ for (const line of item) {
133
+ const m = line.match(/published:\s*["']?(\d+)["']?/);
134
+ if (m)
135
+ return m[1];
136
+ }
137
+ return null;
138
+ }
139
+ /** Published host port of a service's `ports:` entries within `serviceLines`, or null if none/unrecognised. */
140
+ function extractPublishedPort(serviceLines) {
141
+ const portsIdx = serviceLines.findIndex(l => /^\s*ports:\s*$/.test(l));
142
+ if (portsIdx === -1)
143
+ return null;
144
+ const portsIndent = serviceLines[portsIdx].match(/^(\s*)/)[1].length;
145
+ const items = [];
146
+ let cur = null;
147
+ for (let i = portsIdx + 1; i < serviceLines.length; i++) {
148
+ const line = serviceLines[i];
149
+ if (line.trim() === '')
150
+ continue;
151
+ const indent = line.match(/^(\s*)/)[1].length;
152
+ if (indent <= portsIndent)
153
+ break;
154
+ const itemStart = line.match(/^(\s*)-\s?(.*)$/);
155
+ if (itemStart && itemStart[1].length === indent) {
156
+ cur = [itemStart[2]];
157
+ items.push(cur);
158
+ }
159
+ else if (cur) {
160
+ cur.push(line.trim());
161
+ }
162
+ }
163
+ for (const item of items) {
164
+ const port = parsePortItem(item);
165
+ if (port != null)
166
+ return port;
167
+ }
168
+ return null;
169
+ }
170
+ function loopbackHost(hostname) {
171
+ const h = hostname.replace(/^\[|\]$/g, '');
172
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1';
173
+ }
174
+ function normalizedHost(hostname) {
175
+ return loopbackHost(hostname) ? '\0loopback' : hostname.toLowerCase();
176
+ }
177
+ function schemeDefaultPort(protocol) {
178
+ return protocol === 'https:' ? '443' : '80';
179
+ }
180
+ /**
181
+ * Two URLs target the same server when scheme, (loopback-normalised) host, and
182
+ * port (explicit or scheme default) all match; path is ignored. An unparsable
183
+ * URL is never the same server as another.
184
+ */
185
+ function sameServer(a, b) {
186
+ try {
187
+ const ua = new URL(a);
188
+ const ub = new URL(b);
189
+ if (ua.protocol !== ub.protocol)
190
+ return false;
191
+ if (normalizedHost(ua.hostname) !== normalizedHost(ub.hostname))
192
+ return false;
193
+ const pa = ua.port || schemeDefaultPort(ua.protocol);
194
+ const pb = ub.port || schemeDefaultPort(ub.protocol);
195
+ return pa === pb;
196
+ }
197
+ catch {
198
+ return false;
199
+ }
200
+ }
201
+ /**
202
+ * "Managed" per the managed-vs-external requirement: the compose file exists,
203
+ * declares an `infinity` service, the base URL host is loopback, and its port
204
+ * (explicit or scheme default) equals the service's published host port.
205
+ * Never throws — an unreadable/unparsable file or an unrecognised port form
206
+ * is simply "external".
207
+ */
208
+ function isManagedInfinity(baseURL, composePath) {
209
+ try {
210
+ let url;
211
+ try {
212
+ url = new URL(baseURL);
213
+ }
214
+ catch {
215
+ return false;
216
+ }
217
+ if (!loopbackHost(url.hostname))
218
+ return false;
219
+ const content = fs.readFileSync(composePath, 'utf-8');
220
+ const lines = content.split(/\r?\n/);
221
+ const block = findServiceBlock(lines, 'infinity');
222
+ if (!block)
223
+ return false;
224
+ const publishedPort = extractPublishedPort(lines.slice(block[0] + 1, block[1]));
225
+ if (publishedPort == null)
226
+ return false;
227
+ const effectivePort = url.port || schemeDefaultPort(url.protocol);
228
+ return effectivePort === publishedPort;
229
+ }
230
+ catch {
231
+ return false;
232
+ }
233
+ }
234
+ /** The literal (non-`${…}`) `INFINITY_MODEL_ID` value in the compose file's `infinity` service, or null. */
235
+ function extractModelIdValue(composeContent) {
236
+ const lines = composeContent.split(/\r?\n/);
237
+ const block = findServiceBlock(lines, 'infinity');
238
+ if (!block)
239
+ return null;
240
+ for (let i = block[0] + 1; i < block[1]; i++) {
241
+ const m = lines[i].match(/^\s*INFINITY_MODEL_ID:\s*(.*)\s*$/);
242
+ if (m) {
243
+ const v = m[1].trim().replace(/^["']|["']$/g, '');
244
+ return v && !v.startsWith('${') ? v : null;
245
+ }
246
+ }
247
+ return null;
248
+ }
249
+ /** Write `content` to `targetPath` via a same-directory temp file + rename; removes the temp file on failure. */
250
+ function atomicWriteFile(targetPath, content, mode) {
251
+ const dir = path.dirname(targetPath);
252
+ const tmpPath = path.join(dir, `.${path.basename(targetPath)}.tmp-${process.pid}-${Date.now()}`);
253
+ try {
254
+ fs.writeFileSync(tmpPath, content, { mode });
255
+ fs.renameSync(tmpPath, targetPath);
256
+ }
257
+ catch (err) {
258
+ try {
259
+ fs.unlinkSync(tmpPath);
260
+ }
261
+ catch { /* best effort cleanup */ }
262
+ throw err;
263
+ }
264
+ }
265
+ /**
266
+ * Write `modelSet` (embedding model first) as the literal `INFINITY_MODEL_ID`
267
+ * of the compose file's `infinity` service, and remove a `profiles:` entry of
268
+ * that service if present (a file written by an earlier version). At most
269
+ * those two targeted edits — every other byte unchanged. Atomic (temp file +
270
+ * rename), keeps the original file's mode. Throws (no write, original file
271
+ * unchanged) when the file cannot be read, has no `infinity` service, has no
272
+ * `INFINITY_MODEL_ID:` line, or the write itself fails.
273
+ */
274
+ function writeComposeModelSet(composePath, modelSet) {
275
+ let content;
276
+ let mode;
277
+ try {
278
+ content = fs.readFileSync(composePath, 'utf-8');
279
+ mode = fs.statSync(composePath).mode;
280
+ }
281
+ catch (err) {
282
+ throw new Error(`Cannot read managed compose file at ${composePath}: ${err.message}`);
283
+ }
284
+ const newline = content.includes('\r\n') ? '\r\n' : '\n';
285
+ const lines = content.split(/\r?\n/);
286
+ const block = findServiceBlock(lines, 'infinity');
287
+ if (!block)
288
+ throw new Error(`No "infinity" service found in ${composePath}; cannot write the model set.`);
289
+ const [start, end] = block;
290
+ let modelIdLineIdx = -1;
291
+ for (let i = start + 1; i < end; i++) {
292
+ if (/^\s*INFINITY_MODEL_ID:/.test(lines[i])) {
293
+ modelIdLineIdx = i;
294
+ break;
295
+ }
296
+ }
297
+ if (modelIdLineIdx === -1) {
298
+ throw new Error(`No "INFINITY_MODEL_ID:" line found in the infinity service of ${composePath}; cannot write the model set.`);
299
+ }
300
+ const indent = lines[modelIdLineIdx].match(/^(\s*)/)[1];
301
+ lines[modelIdLineIdx] = `${indent}INFINITY_MODEL_ID: "${modelSet.join(';')}"`;
302
+ let profilesStart = -1;
303
+ for (let i = start + 1; i < end; i++) {
304
+ if (/^\s*profiles:\s*$/.test(lines[i])) {
305
+ profilesStart = i;
306
+ break;
307
+ }
308
+ }
309
+ let outLines = lines;
310
+ if (profilesStart !== -1) {
311
+ const profIndent = lines[profilesStart].match(/^(\s*)/)[1].length;
312
+ let profilesEnd = profilesStart + 1;
313
+ while (profilesEnd < end) {
314
+ const l = lines[profilesEnd];
315
+ if (l.trim() === '') {
316
+ profilesEnd++;
317
+ continue;
318
+ }
319
+ const ind = l.match(/^(\s*)/)[1].length;
320
+ if (ind <= profIndent)
321
+ break;
322
+ profilesEnd++;
323
+ }
324
+ outLines = [...lines.slice(0, profilesStart), ...lines.slice(profilesEnd)];
325
+ }
326
+ try {
327
+ atomicWriteFile(composePath, outLines.join(newline), mode);
328
+ }
329
+ catch (err) {
330
+ throw new Error(`Failed to write managed compose file at ${composePath}: ${err.message}`);
331
+ }
332
+ }
333
+ /** True when an NVIDIA GPU + `nvidia-smi` is available on this host. */
334
+ function hasNvidiaGpu() {
335
+ try {
336
+ return (0, child_process_1.spawnSync)('nvidia-smi', ['-L'], { stdio: 'ignore' }).status === 0;
337
+ }
338
+ catch {
339
+ return false;
340
+ }
341
+ }
342
+ function gpuOverridePath(composePath) {
343
+ return path.join(path.dirname(composePath), 'docker-compose.infinity-gpu.yml');
344
+ }
345
+ /** The `-f` file args a docker compose invocation against `composePath` should use (base, plus GPU override when present + detected). */
346
+ function composeFileArgs(composePath) {
347
+ const gpu = gpuOverridePath(composePath);
348
+ return (fs.existsSync(gpu) && hasNvidiaGpu()) ? ['-f', composePath, '-f', gpu] : ['-f', composePath];
349
+ }
350
+ function defaultRunCommand(cmd, args) {
351
+ const r = (0, child_process_1.spawnSync)(cmd, args, { encoding: 'utf-8' });
352
+ return { status: r.status, stdout: r.stdout ?? undefined, stderr: r.stderr ?? undefined };
353
+ }
354
+ function sleep(ms) {
355
+ return new Promise(resolve => setTimeout(resolve, ms));
356
+ }
357
+ /** Parse `docker compose ps --all --format json` output (JSON-lines, or an array) for the `infinity` service's state. */
358
+ function parseInfinityState(stdout) {
359
+ const readState = (obj) => {
360
+ const s = String(obj?.State ?? '').toLowerCase();
361
+ return (s === 'running' || s === 'exited' || s === 'dead' || s === 'restarting') ? s : 'other';
362
+ };
363
+ for (const line of stdout.split(/\r?\n/).map(l => l.trim()).filter(Boolean)) {
364
+ try {
365
+ const obj = JSON.parse(line);
366
+ if (obj?.Service === 'infinity')
367
+ return readState(obj);
368
+ }
369
+ catch { /* not a JSON-lines entry — fall through to array parsing below */ }
370
+ }
371
+ try {
372
+ const arr = JSON.parse(stdout);
373
+ if (Array.isArray(arr)) {
374
+ const svc = arr.find((o) => o?.Service === 'infinity');
375
+ if (svc)
376
+ return readState(svc);
377
+ }
378
+ }
379
+ catch { /* not an array either */ }
380
+ return 'missing';
381
+ }
382
+ /**
383
+ * Wait for `/health` after a start/recreate, failing fast on a crashed or
384
+ * restart-looping container instead of waiting out the full timeout, then
385
+ * re-check the models listing once more. Poll interval capped at 5s.
386
+ */
387
+ async function waitForReady(base, files, modelSetToWrite, requiredModels, deps) {
388
+ const fetchImpl = deps.fetchImpl ?? fetch;
389
+ const runCommand = deps.runCommand ?? defaultRunCommand;
390
+ const pollIntervalMs = Math.min(deps.pollIntervalMs ?? 2000, 5000);
391
+ const timeoutMs = deps.readinessTimeoutMs ?? 1_800_000;
392
+ const deadline = Date.now() + timeoutMs;
393
+ let consecutiveRestarting = 0;
394
+ for (;;) {
395
+ if (await probeInfinityHealth(base, fetchImpl, 2000))
396
+ break;
397
+ const ps = runCommand('docker', ['compose', ...files, 'ps', '--all', '--format', 'json']);
398
+ if (ps.status === 0) {
399
+ const state = parseInfinityState(ps.stdout ?? '');
400
+ if (state === 'exited' || state === 'dead' || state === 'missing') {
401
+ throw new Error(`Infinity container ${state === 'missing' ? 'is no longer listed' : `crashed (state: ${state})`} while loading ` +
402
+ `model set [${modelSetToWrite.join(', ')}]. Check: docker compose logs infinity`);
403
+ }
404
+ if (state === 'restarting') {
405
+ consecutiveRestarting++;
406
+ if (consecutiveRestarting >= 2) {
407
+ throw new Error(`Infinity container is restart-looping while loading model set [${modelSetToWrite.join(', ')}] ` +
408
+ `(a model id is likely invalid or unloadable). Check: docker compose logs infinity`);
409
+ }
410
+ }
411
+ else {
412
+ consecutiveRestarting = 0;
413
+ }
414
+ }
415
+ // A failing `ps` is ignored — neither counts toward nor resets the restarting streak.
416
+ if (Date.now() >= deadline) {
417
+ throw new Error(`Infinity did not become ready within ${Math.round(timeoutMs / 1000)}s at ${base}.`);
418
+ }
419
+ await sleep(pollIntervalMs);
420
+ }
421
+ const served = await listInfinityModels(base, fetchImpl, 2000);
422
+ const missing = served.length > 0 ? requiredModels.filter(m => !served.includes(m)) : [];
423
+ if (missing.length > 0) {
424
+ throw new Error(`Infinity at ${base} became ready but its listing [${served.join(', ')}] still omits required model(s) ` +
425
+ `[${missing.join(', ')}]. Check the model id(s) — the container is still running.`);
426
+ }
427
+ }
428
+ async function recreateAndWait(base, composePath, modelSetToWrite, requiredModels, deps) {
429
+ writeComposeModelSet(composePath, modelSetToWrite);
430
+ const files = composeFileArgs(composePath);
431
+ const runCommand = deps.runCommand ?? defaultRunCommand;
432
+ const result = runCommand('docker', ['compose', ...files, 'up', '-d', '--force-recreate', 'infinity']);
433
+ if (result.status !== 0) {
434
+ const cause = result.status === null
435
+ ? 'the "docker" binary was not found'
436
+ : `docker compose exited with status ${result.status}${result.stderr ? `: ${result.stderr.trim()}` : ''}`;
437
+ throw new Error(`Failed to start the managed Infinity container (${cause}). Ensure Docker is installed and running.`);
438
+ }
439
+ await waitForReady(base, files, modelSetToWrite, requiredModels, deps);
440
+ }
441
+ /**
442
+ * Classify the backend against `requiredModels` without touching Docker:
443
+ * reachable+served (or unverifiable listing) → `served`; reachable+missing →
444
+ * `missing-managed`/`missing-external`; unreachable → `unreachable-managed`/
445
+ * `unreachable-external`. Shared by `ensureBackendReady`, the web-dashboard
446
+ * decision table, and `rag-setup`'s declined-Docker branch.
447
+ */
448
+ async function evaluateBackend(baseURL, composePath, requiredModels, deps = {}) {
449
+ const fetchImpl = deps.fetchImpl ?? fetch;
450
+ const base = serverRoot(baseURL);
451
+ const managed = isManagedInfinity(baseURL, composePath);
452
+ if (await probeInfinityHealth(base, fetchImpl, 2000)) {
453
+ const served = await listInfinityModels(base, fetchImpl, 2000);
454
+ const missingModels = served.length === 0 ? [] : requiredModels.filter(m => !served.includes(m));
455
+ if (missingModels.length === 0)
456
+ return { action: 'served', managed, missingModels: [], servedModels: served };
457
+ return { action: managed ? 'missing-managed' : 'missing-external', managed, missingModels, servedModels: served };
458
+ }
459
+ return { action: managed ? 'unreachable-managed' : 'unreachable-external', managed, missingModels: [], servedModels: [] };
460
+ }
461
+ /** Human-readable manual instructions for a `missing-external`/`unreachable-external` evaluation. */
462
+ function manualInstructionMessage(evaluation, base, modelSetToWrite) {
463
+ if (evaluation.action === 'missing-external') {
464
+ return `Infinity at ${base} serves [${evaluation.servedModels.join(', ')}] but is missing [${evaluation.missingModels.join(', ')}]. ` +
465
+ `Restart the external server with the missing model(s) as additional --model-id arguments, e.g.:\n` +
466
+ ` --model-id ${modelSetToWrite.join(' --model-id ')}`;
467
+ }
468
+ return `Infinity is not reachable at ${base} and no managed container is configured. Start it manually, e.g.:\n` +
469
+ ` docker run -it --gpus all -p 7997:7997 michaelf34/infinity:latest \\\n` +
470
+ ` v2 --model-id ${modelSetToWrite.join(';')} --port 7997\n` +
471
+ `(drop --gpus all to run on CPU), or re-run rag-setup to provision it.`;
472
+ }
473
+ /**
474
+ * Bring the Infinity backend to a state serving `requiredModels`, per the
475
+ * managed-vs-external decision tree:
476
+ * served → return (no Docker)
477
+ * missing-managed → recreate (writes modelSetToWrite) + wait
478
+ * missing-external → throw (manual --model-id instructions)
479
+ * unreachable-managed → (re)create + wait
480
+ * unreachable-external → throw (manual launch instructions)
481
+ */
482
+ async function ensureBackendReady(opts) {
483
+ const { baseURL, requiredModels, modelSetToWrite, composePath } = opts;
484
+ const deps = opts.deps ?? {};
485
+ const base = serverRoot(baseURL);
486
+ const evaluation = await evaluateBackend(baseURL, composePath, requiredModels, deps);
487
+ switch (evaluation.action) {
488
+ case 'served':
489
+ return;
490
+ case 'missing-external':
491
+ case 'unreachable-external':
492
+ throw new Error(manualInstructionMessage(evaluation, base, modelSetToWrite));
493
+ case 'missing-managed':
494
+ case 'unreachable-managed':
495
+ await recreateAndWait(base, composePath, modelSetToWrite, requiredModels, deps);
496
+ return;
497
+ }
498
+ }
499
+ /** `ensureBackendReady` with the full set as both the requirement and the write (rag-setup, web dashboard). */
500
+ async function ensureModelSetServed(baseURL, composePath, modelSet, deps) {
501
+ return ensureBackendReady({ baseURL, requiredModels: modelSet, modelSetToWrite: modelSet, composePath, deps });
502
+ }
503
+ /**
504
+ * The model set a managed Infinity container must serve, computed from a
505
+ * `Config`: the embedding model, followed by the reranker model only when
506
+ * reranking is enabled with a model on the same server as embeddings (the
507
+ * effective `reranker.url`, which `Config` has already resolved from an
508
+ * explicit value or derived from `reranker.baseURL`).
509
+ */
510
+ function requiredModelSet(cfg) {
511
+ const set = [cfg.embedding.model];
512
+ if (cfg.reranker.enabled && cfg.reranker.model && sameServer(cfg.reranker.url, cfg.embedding.baseURL)) {
513
+ set.push(cfg.reranker.model);
514
+ }
515
+ return set;
516
+ }
@@ -0,0 +1,24 @@
1
+ import type { IDatabase } from '../interfaces/IDatabase';
2
+ export interface IndexedIdentity {
3
+ provider: string;
4
+ model: string;
5
+ }
6
+ /** A pre-`index_meta` install (populated `code_chunk`, no `index_meta` row) is always this. */
7
+ export declare const LEGACY_OLLAMA_IDENTITY: IndexedIdentity;
8
+ /**
9
+ * Whether `code_chunk` has at least one row, tolerant only of a missing
10
+ * table (→ false). Any other database error propagates. Exported so
11
+ * `SearcherService.assertIndexMatches` can reuse the same strict count
12
+ * (it reads `index_meta` via the existing lenient `IDatabase.readIndexMeta()`,
13
+ * but the chunk count must fail loudly on a real DB error).
14
+ */
15
+ export declare function hasIndexedChunks(db: IDatabase): Promise<boolean>;
16
+ /**
17
+ * Resolve the "indexed identity" under the shared legacy interpretation used
18
+ * both by the `rag-index` legacy-Ollama guard and the search-refusal check:
19
+ * an `index_meta` row wins; absent a row, a populated `code_chunk` is
20
+ * `ollama`/`bge-m3`; an empty (or altogether absent) DB is `null` — not
21
+ * Ollama-built, no refusal. Missing tables count as absent/empty; any other
22
+ * database error propagates rather than being read as "not Ollama-built".
23
+ */
24
+ export declare function resolveIndexedIdentity(db: IDatabase): Promise<IndexedIdentity | null>;
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LEGACY_OLLAMA_IDENTITY = void 0;
4
+ exports.hasIndexedChunks = hasIndexedChunks;
5
+ exports.resolveIndexedIdentity = resolveIndexedIdentity;
6
+ /** A pre-`index_meta` install (populated `code_chunk`, no `index_meta` row) is always this. */
7
+ exports.LEGACY_OLLAMA_IDENTITY = { provider: 'ollama', model: 'bge-m3' };
8
+ function isMissingTableError(err) {
9
+ return /does not exist/i.test(err?.message ?? '');
10
+ }
11
+ /**
12
+ * Read `index_meta`'s (provider, model), tolerant only of a missing table
13
+ * (→ null, un-migrated DB). Any other database error propagates — unlike
14
+ * `IDatabase.readIndexMeta()`, which swallows every error into `null`, this
15
+ * is used where a genuine DB error must fail the caller loudly rather than
16
+ * be misread as "no row".
17
+ */
18
+ async function readIndexMetaStrict(db) {
19
+ try {
20
+ const rows = await db.query(`SELECT provider, model FROM index_meta LIMIT 1`);
21
+ const row = rows[0];
22
+ return row ? { provider: row.provider, model: row.model } : null;
23
+ }
24
+ catch (err) {
25
+ if (isMissingTableError(err))
26
+ return null;
27
+ throw err;
28
+ }
29
+ }
30
+ /**
31
+ * Whether `code_chunk` has at least one row, tolerant only of a missing
32
+ * table (→ false). Any other database error propagates. Exported so
33
+ * `SearcherService.assertIndexMatches` can reuse the same strict count
34
+ * (it reads `index_meta` via the existing lenient `IDatabase.readIndexMeta()`,
35
+ * but the chunk count must fail loudly on a real DB error).
36
+ */
37
+ async function hasIndexedChunks(db) {
38
+ try {
39
+ const rows = await db.query(`SELECT COUNT(*)::int AS n FROM code_chunk`);
40
+ return Number(rows[0]?.n ?? 0) > 0;
41
+ }
42
+ catch (err) {
43
+ if (isMissingTableError(err))
44
+ return false;
45
+ throw err;
46
+ }
47
+ }
48
+ /**
49
+ * Resolve the "indexed identity" under the shared legacy interpretation used
50
+ * both by the `rag-index` legacy-Ollama guard and the search-refusal check:
51
+ * an `index_meta` row wins; absent a row, a populated `code_chunk` is
52
+ * `ollama`/`bge-m3`; an empty (or altogether absent) DB is `null` — not
53
+ * Ollama-built, no refusal. Missing tables count as absent/empty; any other
54
+ * database error propagates rather than being read as "not Ollama-built".
55
+ */
56
+ async function resolveIndexedIdentity(db) {
57
+ const meta = await readIndexMetaStrict(db);
58
+ if (meta)
59
+ return meta;
60
+ return (await hasIndexedChunks(db)) ? exports.LEGACY_OLLAMA_IDENTITY : null;
61
+ }
@@ -4,9 +4,9 @@ import type { ILogger } from '../interfaces/ILogger';
4
4
  * Build a rerank function from `config.reranker`, or return `undefined` when
5
5
  * reranking is disabled / unconfigured. Targets an OpenAI-compatible `/rerank`
6
6
  * endpoint returning `{ results: [{ index }] }` (Infinity, TEI, vLLM,
7
- * Jina/Cohere-shape). Ollama has NO rerank API, so it is not a valid backend
8
- * here — the embedding provider is independent of the rerank server
9
- * (`reranker.baseURL`), so Ollama embeddings + an Infinity reranker is fine.
7
+ * Jina/Cohere-shape). The embedding provider is independent of the rerank
8
+ * server (`reranker.baseURL`), so e.g. Voyage embeddings + an Infinity
9
+ * reranker is fine.
10
10
  * Returns original indices into `documents`, best-first.
11
11
  */
12
12
  export declare function makeReranker(config: Config, logger: ILogger, deps?: {
@@ -7,9 +7,9 @@ const http_retry_1 = require("./http-retry");
7
7
  * Build a rerank function from `config.reranker`, or return `undefined` when
8
8
  * reranking is disabled / unconfigured. Targets an OpenAI-compatible `/rerank`
9
9
  * endpoint returning `{ results: [{ index }] }` (Infinity, TEI, vLLM,
10
- * Jina/Cohere-shape). Ollama has NO rerank API, so it is not a valid backend
11
- * here — the embedding provider is independent of the rerank server
12
- * (`reranker.baseURL`), so Ollama embeddings + an Infinity reranker is fine.
10
+ * Jina/Cohere-shape). The embedding provider is independent of the rerank
11
+ * server (`reranker.baseURL`), so e.g. Voyage embeddings + an Infinity
12
+ * reranker is fine.
13
13
  * Returns original indices into `documents`, best-first.
14
14
  */
15
15
  function makeReranker(config, logger, deps = {}) {
@@ -1,8 +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[];
2
11
  /**
3
12
  * Default answer for the "start Docker containers?" prompt: no only when both
4
13
  * the database and the embedding backend are already reachable. Pure — exported
5
14
  * for unit testing.
6
15
  */
7
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>;
8
44
  export declare function runSetup(logger?: LoggerService): Promise<void>;
45
+ export {};