codesearch-mcp 0.0.18-alpha → 0.1.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 (86) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +75 -6
  3. package/dist/app.js +36 -0
  4. package/dist/src/commands/IndexCommand.js +14 -1
  5. package/dist/src/commands/McpCommand.js +6 -2
  6. package/dist/src/config-store.d.ts +49 -11
  7. package/dist/src/config-store.js +79 -19
  8. package/dist/src/config.d.ts +32 -8
  9. package/dist/src/config.js +84 -20
  10. package/dist/src/constants.d.ts +36 -2
  11. package/dist/src/constants.js +58 -7
  12. package/dist/src/container.js +5 -3
  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 +111 -0
  16. package/dist/src/db/schema.js +15 -1
  17. package/dist/src/help.js +5 -4
  18. package/dist/src/interfaces/IAstParser.d.ts +14 -0
  19. package/dist/src/interfaces/IChunker.d.ts +24 -0
  20. package/dist/src/interfaces/IChunker.js +2 -0
  21. package/dist/src/interfaces/IDatabase.d.ts +14 -0
  22. package/dist/src/interfaces/IEmbedder.d.ts +14 -2
  23. package/dist/src/interfaces/ISearcher.d.ts +1 -0
  24. package/dist/src/interfaces/index.d.ts +2 -1
  25. package/dist/src/owner-path.d.ts +10 -0
  26. package/dist/src/owner-path.js +27 -0
  27. package/dist/src/services/AstChunker.d.ts +31 -0
  28. package/dist/src/services/AstChunker.js +114 -0
  29. package/dist/src/services/AstParserService.d.ts +6 -0
  30. package/dist/src/services/AstParserService.js +31 -2
  31. package/dist/src/services/DatabaseService.d.ts +13 -1
  32. package/dist/src/services/DatabaseService.js +70 -25
  33. package/dist/src/services/EmbedderFactory.d.ts +9 -0
  34. package/dist/src/services/EmbedderFactory.js +29 -0
  35. package/dist/src/services/IndexerService.d.ts +17 -5
  36. package/dist/src/services/IndexerService.js +101 -38
  37. package/dist/src/services/InfinityEmbedder.d.ts +79 -0
  38. package/dist/src/services/InfinityEmbedder.js +199 -0
  39. package/dist/src/services/OllamaEmbedder.d.ts +16 -5
  40. package/dist/src/services/OllamaEmbedder.js +84 -36
  41. package/dist/src/services/OpenAICompatibleEmbedder.d.ts +25 -0
  42. package/dist/src/services/OpenAICompatibleEmbedder.js +75 -0
  43. package/dist/src/services/SearcherService.d.ts +7 -1
  44. package/dist/src/services/SearcherService.js +35 -16
  45. package/dist/src/services/VoyageEmbedder.d.ts +22 -0
  46. package/dist/src/services/VoyageEmbedder.js +72 -0
  47. package/dist/src/services/WebServerService.d.ts +16 -11
  48. package/dist/src/services/WebServerService.js +44 -24
  49. package/dist/src/services/http-retry.d.ts +19 -0
  50. package/dist/src/services/http-retry.js +54 -0
  51. package/dist/src/services/reranker.d.ts +15 -0
  52. package/dist/src/services/reranker.js +44 -0
  53. package/dist/src/setup.d.ts +6 -0
  54. package/dist/src/setup.js +243 -25
  55. package/dist/src/tools/SearchCodebaseTool.js +1 -1
  56. package/dist/src/tools/format-helpers.js +2 -1
  57. package/dist/src/web/dashboard.js +5 -0
  58. package/docker/docker-compose.infinity-gpu.yml +19 -0
  59. package/docker/docker-compose.yml +25 -0
  60. package/package.json +21 -2
  61. package/dist/src/ast.d.ts +0 -2
  62. package/dist/src/ast.js +0 -110
  63. package/dist/src/commands/SetupCommand.d.ts +0 -7
  64. package/dist/src/commands/SetupCommand.js +0 -14
  65. package/dist/src/db/data-source.d.ts +0 -2
  66. package/dist/src/db/data-source.js +0 -13
  67. package/dist/src/db/entities/code-chunk.entity.d.ts +0 -9
  68. package/dist/src/db/entities/code-chunk.entity.js +0 -54
  69. package/dist/src/db/entities/code-dependency.entity.d.ts +0 -9
  70. package/dist/src/db/entities/code-dependency.entity.js +0 -54
  71. package/dist/src/db/entities/code-symbol.entity.d.ts +0 -10
  72. package/dist/src/db/entities/code-symbol.entity.js +0 -59
  73. package/dist/src/db/init.d.ts +0 -2
  74. package/dist/src/db/init.js +0 -62
  75. package/dist/src/env-loader.d.ts +0 -1
  76. package/dist/src/env-loader.js +0 -41
  77. package/dist/src/indexer.d.ts +0 -17
  78. package/dist/src/indexer.js +0 -242
  79. package/dist/src/mcp.d.ts +0 -1
  80. package/dist/src/mcp.js +0 -403
  81. package/dist/src/ollama.d.ts +0 -2
  82. package/dist/src/ollama.js +0 -6
  83. package/dist/src/searcher.d.ts +0 -74
  84. package/dist/src/searcher.js +0 -446
  85. package/dist/vitest.config.d.ts +0 -2
  86. package/dist/vitest.config.js +0 -9
@@ -37,36 +37,92 @@ exports.IndexerService = void 0;
37
37
  const fs = __importStar(require("fs"));
38
38
  const path = __importStar(require("path"));
39
39
  const crypto_1 = require("crypto");
40
- const textsplitters_1 = require("@langchain/textsplitters");
41
40
  const cli_progress_1 = require("cli-progress");
42
41
  const drizzle_orm_1 = require("drizzle-orm");
42
+ const owner_path_1 = require("../owner-path");
43
43
  const db_1 = require("../db");
44
44
  const file_utils_1 = require("../file-utils");
45
45
  const LoggerService_1 = require("./LoggerService");
46
+ const constants_1 = require("../constants");
46
47
  const SCHEMA_EXTENSIONS = /\.(prisma|graphql|gql)$/;
47
48
  class IndexerService {
48
49
  db;
49
50
  embedder;
50
51
  ast;
52
+ chunker;
51
53
  config;
52
54
  logger;
53
- jsSplitter;
54
- schemaSplitter;
55
- constructor(db, embedder, ast, config, logger = LoggerService_1.LoggerService.silent()) {
55
+ constructor(db, embedder, ast, chunker, config, logger = LoggerService_1.LoggerService.silent()) {
56
56
  this.db = db;
57
57
  this.embedder = embedder;
58
58
  this.ast = ast;
59
+ this.chunker = chunker;
59
60
  this.config = config;
60
61
  this.logger = logger;
61
- this.jsSplitter = textsplitters_1.RecursiveCharacterTextSplitter.fromLanguage('js', {
62
- chunkSize: config.indexer.chunkSize,
63
- chunkOverlap: config.indexer.chunkOverlap,
64
- });
65
- this.schemaSplitter = new textsplitters_1.RecursiveCharacterTextSplitter({
66
- chunkSize: config.indexer.chunkSize,
67
- chunkOverlap: config.indexer.chunkOverlap,
68
- separators: ['\n\n', '\n', ' '],
69
- });
62
+ }
63
+ /**
64
+ * Resolve the active embedding dimension (config override → registry → probe),
65
+ * reconcile it against `index_meta`, and prepare `code_chunk` accordingly:
66
+ * a dimension change drops+recreates the table; a same-dimension model/provider
67
+ * change clears its rows (vector spaces are not comparable across models); an
68
+ * unchanged model keeps the rows for incremental indexing. Back-compat: an
69
+ * existing populated table with no `index_meta` row is treated as the legacy
70
+ * `ollama`/`bge-m3`/1024 index. Writes `index_meta` to reflect the active model.
71
+ * Call once per run, before the scan loop.
72
+ */
73
+ async prepareEmbeddingSchema() {
74
+ const { provider, model, dimensions: override } = this.config.embedding;
75
+ const next = {
76
+ provider,
77
+ model,
78
+ dimensions: await (0, constants_1.resolveDimension)(override, model, async () => {
79
+ const probe = await this.embedder.embed('probe', 'document');
80
+ return probe.length;
81
+ }),
82
+ };
83
+ const meta = await this.db.readIndexMeta();
84
+ if (!meta) {
85
+ const chunkCount = await this.countChunks();
86
+ if (chunkCount === 0) {
87
+ // Fresh index — create the table at the resolved width and record it.
88
+ await this.db.recreateChunkTable(next.dimensions);
89
+ await this.db.writeIndexMeta(next);
90
+ this.logger.debug('IndexerService: prepared fresh index', { ...next });
91
+ return next;
92
+ }
93
+ // Legacy index (pre-index_meta): assume the historical default model.
94
+ await this.reconcileIndex({ provider: 'ollama', model: 'bge-m3', dimensions: 1024 }, next);
95
+ return next;
96
+ }
97
+ await this.reconcileIndex(meta, next);
98
+ return next;
99
+ }
100
+ async reconcileIndex(prev, next) {
101
+ if (prev.dimensions !== next.dimensions) {
102
+ console.log(`Embedding dimension changed (${prev.dimensions} → ${next.dimensions}) — recreating code_chunk.`);
103
+ this.logger.info('IndexerService: dimension change, recreating code_chunk', { prev, next });
104
+ await this.db.recreateChunkTable(next.dimensions);
105
+ }
106
+ else if (prev.provider !== next.provider || prev.model !== next.model) {
107
+ console.log(`Embedding model changed (${prev.provider}/${prev.model} → ${next.provider}/${next.model}) — clearing index.`);
108
+ this.logger.info('IndexerService: model change, clearing code_chunk', { prev, next });
109
+ await this.db.query(`DELETE FROM code_chunk`);
110
+ }
111
+ await this.db.writeIndexMeta(next);
112
+ }
113
+ async countChunks() {
114
+ try {
115
+ const rows = await this.db.query(`SELECT COUNT(*)::int AS n FROM code_chunk`);
116
+ return Number(rows[0]?.n ?? 0);
117
+ }
118
+ catch (err) {
119
+ // A missing table means a fresh install → 0. Any other (transient) error
120
+ // must propagate — returning 0 here could wrongly recreate/drop a populated
121
+ // table on the `!meta` path.
122
+ if (/does not exist/i.test(err.message))
123
+ return 0;
124
+ throw err;
125
+ }
70
126
  }
71
127
  async backfillSymbols(signal) {
72
128
  const t0 = Date.now();
@@ -290,9 +346,6 @@ class IndexerService {
290
346
  });
291
347
  return stats;
292
348
  }
293
- getSplitter(filePath) {
294
- return SCHEMA_EXTENSIONS.test(filePath) ? this.schemaSplitter : this.jsSplitter;
295
- }
296
349
  static inferRole(relPath) {
297
350
  const p = relPath.toLowerCase();
298
351
  if (p.includes('/services/') || p.endsWith('.service.ts'))
@@ -317,9 +370,9 @@ class IndexerService {
317
370
  return 'Utility';
318
371
  return null;
319
372
  }
320
- getEnrichedPrompt(chunk, relPath, serviceName, _meta, edges, symbols) {
321
- const chunkStart = (chunk.metadata?.loc?.lines?.from ?? 1);
322
- const chunkEnd = (chunk.metadata?.loc?.lines?.to ?? chunkStart);
373
+ getEnrichedPrompt(piece, relPath, serviceName, _meta, edges, symbols) {
374
+ const chunkStart = piece.startLine;
375
+ const chunkEnd = piece.endLine;
323
376
  const lines = [];
324
377
  lines.push(`Project: ${serviceName} | File: ${relPath}`);
325
378
  const role = IndexerService.inferRole(relPath);
@@ -335,12 +388,9 @@ class IndexerService {
335
388
  const depNames = [...new Set(edges.map(e => path.basename(e.toFile, path.extname(e.toFile)).split('.')[0]))];
336
389
  lines.push(`Dependencies: ${depNames.join(', ')}`);
337
390
  }
338
- lines.push(`Code: ${chunk.pageContent}`);
391
+ lines.push(`Code: ${piece.content}`);
339
392
  return lines.join('\n');
340
393
  }
341
- async embedText(prompt) {
342
- return this.embedder.embed(prompt);
343
- }
344
394
  static hashContent(content) {
345
395
  return (0, crypto_1.createHash)('sha256').update(content).digest('hex');
346
396
  }
@@ -364,7 +414,7 @@ class IndexerService {
364
414
  return { status: 'skipped', chunks: 0, pendingMtimeRefresh: relFilePath };
365
415
  }
366
416
  const tAst = Date.now();
367
- const { edges: rawEdges, symbols: rawSymbols, calls: rawCalls } = analysis ?? { edges: [], symbols: [], calls: [] };
417
+ const { edges: rawEdges, symbols: rawSymbols, calls: rawCalls, ownerRanges: rawOwnerRanges = [] } = analysis ?? { edges: [], symbols: [], calls: [] };
368
418
  // astMs is near-zero — actual AST time is spent upfront in analyzeProject()
369
419
  // Normalize edges to workspace-relative paths
370
420
  const edges = rawEdges
@@ -390,25 +440,38 @@ class IndexerService {
390
440
  // Normalize symbols: replace absolute filePath with workspace-relative, strip endLine (not stored)
391
441
  const symbols = [...new Map(rawSymbols.map(s => ({ ...s, filePath: relFilePath })).map(s => [`${s.name}:${s.kind}`, s])).values()].map(({ project, filePath: fp, name, kind, signature, line, col }) => ({ project, filePath: fp, name, kind, signature, line, col }));
392
442
  const astMs = Date.now() - tAst;
393
- const chunks = await this.getSplitter(filePath).createDocuments([code]);
443
+ const chunks = await this.chunker.chunk(code, {
444
+ strategy: this.config.indexer.chunkStrategy,
445
+ isSchemaFile: SCHEMA_EXTENSIONS.test(filePath),
446
+ symbols: rawSymbols,
447
+ });
394
448
  const relPath = path.relative(projectRoot, filePath);
449
+ // Owner-path ranges: top-level symbols (container: null) + method ranges.
450
+ // Deepest range containing a chunk's start line wins → Class.method.
451
+ const ownerRanges = [
452
+ ...rawSymbols
453
+ .filter(s => s.line != null)
454
+ .map(s => ({ name: s.name, container: null, line: s.line, endLine: s.endLine ?? s.line })),
455
+ ...rawOwnerRanges,
456
+ ];
395
457
  const t2 = Date.now();
396
458
  const records = [];
397
- for (let i = 0; i < chunks.length; i += this.config.indexer.batchSize) {
398
- const batch = chunks.slice(i, i + this.config.indexer.batchSize);
399
- const embedded = await Promise.all(batch.map(async (chunk) => {
400
- const startLine = (chunk.metadata?.loc?.lines?.from ?? 1);
401
- const prompt = this.getEnrichedPrompt(chunk, relPath, serviceName, meta ?? 'missing', edges, rawSymbols);
402
- const embedding = await this.embedText(prompt);
403
- return {
404
- serviceName, filePath: relFilePath, startLine,
405
- content: chunk.pageContent,
406
- embedding: `[${embedding}]`,
459
+ const embedBatchSize = this.config.embedding.batchSize;
460
+ for (let i = 0; i < chunks.length; i += embedBatchSize) {
461
+ const batch = chunks.slice(i, i + embedBatchSize);
462
+ const prompts = batch.map(piece => this.getEnrichedPrompt(piece, relPath, serviceName, meta ?? 'missing', edges, rawSymbols));
463
+ const vectors = await this.embedder.embedBatch(prompts, 'document');
464
+ for (let j = 0; j < batch.length; j++) {
465
+ const piece = batch[j];
466
+ records.push({
467
+ serviceName, filePath: relFilePath, startLine: piece.startLine,
468
+ content: piece.content,
469
+ embedding: `[${vectors[j]}]`,
407
470
  lastModified: mtimeMs,
408
471
  contentHash: hash,
409
- };
410
- }));
411
- records.push(...embedded);
472
+ ownerPath: (0, owner_path_1.resolveOwnerPath)(piece.startLine, ownerRanges),
473
+ });
474
+ }
412
475
  }
413
476
  const embedMs = Date.now() - t2;
414
477
  const t3 = Date.now();
@@ -0,0 +1,79 @@
1
+ import type { Config } from '../config';
2
+ import type { ILogger } from '../interfaces/ILogger';
3
+ import { OpenAICompatibleEmbedder, type EmbedderDeps } from './OpenAICompatibleEmbedder';
4
+ /** Result of a shell command run, mirroring the parts of `spawnSync` we use. */
5
+ export interface CommandResult {
6
+ status: number | null;
7
+ stdout?: string;
8
+ }
9
+ /**
10
+ * Extra injection seams for `InfinityEmbedder` beyond the shared `EmbedderDeps`.
11
+ * All optional and defaulted to the real implementations; tests inject fakes so
12
+ * `ensureModelAvailable()` runs without a live Infinity server or Docker.
13
+ */
14
+ export interface InfinityDeps extends EmbedderDeps {
15
+ /** Runs a shell command (docker), optionally with extra env. Defaults to `spawnSync`. */
16
+ runCommand?: (cmd: string, args: string[], env?: Record<string, string>) => CommandResult;
17
+ /**
18
+ * Path to the managed docker-compose.yml. Defaults to the file `rag-setup`
19
+ * writes into `~/.config/codesearch-mcp/`. A missing file (or one without an
20
+ * `infinity` service) means "external backend" — the Docker path is skipped.
21
+ */
22
+ composePath?: string;
23
+ /** Sleep between readiness polls (ms). Defaults to 2000; tests pass 0. */
24
+ pollIntervalMs?: number;
25
+ }
26
+ /**
27
+ * Strip trailing slashes and a trailing `/embeddings` or `/v1` to get the server
28
+ * root — Infinity serves `/health`, `/models`, `/rerank`, `/embeddings` from the
29
+ * root, so a baseURL written with an OpenAI-style suffix must be normalized here
30
+ * to keep the probe/models URLs aligned with the embeddings URL.
31
+ */
32
+ export declare function serverRoot(baseURL: string): string;
33
+ /**
34
+ * Probe an Infinity-style `/health` endpoint. Resolves `true` when the server
35
+ * answers with a 2xx, `false` on any error or non-2xx. Reused by setup's
36
+ * `waitForInfinity`.
37
+ */
38
+ export declare function probeInfinityHealth(baseURL: string, fetchImpl?: typeof fetch, timeoutMs?: number): Promise<boolean>;
39
+ /**
40
+ * List the model ids an Infinity server currently serves via its OpenAI-style
41
+ * `/models` listing (`{ data: [{ id }] }`). Returns `[]` on any error.
42
+ */
43
+ export declare function listInfinityModels(baseURL: string, fetchImpl?: typeof fetch, timeoutMs?: number): Promise<string[]>;
44
+ /**
45
+ * Embedder for a local Infinity server. Inherits the OpenAI-style `/embeddings`
46
+ * HTTP path, batching, retry, and reranker wiring from `OpenAICompatibleEmbedder`
47
+ * unchanged — it adds ONLY container-readiness behavior (`ensureModelAvailable`),
48
+ * because Infinity binds its model set at container startup and exposes no
49
+ * runtime model-pull API.
50
+ */
51
+ export declare class InfinityEmbedder extends OpenAICompatibleEmbedder {
52
+ private readonly base;
53
+ private readonly doFetchImpl;
54
+ private readonly runCommand;
55
+ private readonly composePath;
56
+ private readonly pollIntervalMs;
57
+ private readonly log;
58
+ constructor(config: Config, logger?: ILogger, deps?: InfinityDeps);
59
+ private isHealthy;
60
+ private servedModels;
61
+ /** True when a managed compose file exists AND declares an `infinity` service. */
62
+ private hasManagedService;
63
+ /**
64
+ * Bring the Infinity backend to a serving state per the D4 decision tree:
65
+ * healthy + model served → OK
66
+ * healthy + model NOT served → throw (restart container with correct --model-id)
67
+ * unreachable + managed → `docker compose up -d infinity`, wait /health
68
+ * unreachable + external → print manual instructions (no Docker touched)
69
+ *
70
+ * Called by `IndexCommand` before `prepareEmbeddingSchema()`.
71
+ */
72
+ ensureModelAvailable(): Promise<void>;
73
+ /**
74
+ * Poll `/health` until ready or the timeout elapses. Generous default (30 min)
75
+ * because a CPU first boot downloads the model set and runs a lengthy per-model
76
+ * warmup before `/health` flips to 200.
77
+ */
78
+ private waitForHealthy;
79
+ }
@@ -0,0 +1,199 @@
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.InfinityEmbedder = void 0;
37
+ exports.serverRoot = serverRoot;
38
+ exports.probeInfinityHealth = probeInfinityHealth;
39
+ exports.listInfinityModels = listInfinityModels;
40
+ const fs = __importStar(require("fs"));
41
+ const path = __importStar(require("path"));
42
+ const child_process_1 = require("child_process");
43
+ const LoggerService_1 = require("./LoggerService");
44
+ const config_store_1 = require("../config-store");
45
+ const OpenAICompatibleEmbedder_1 = require("./OpenAICompatibleEmbedder");
46
+ /**
47
+ * Strip trailing slashes and a trailing `/embeddings` or `/v1` to get the server
48
+ * root — Infinity serves `/health`, `/models`, `/rerank`, `/embeddings` from the
49
+ * root, so a baseURL written with an OpenAI-style suffix must be normalized here
50
+ * to keep the probe/models URLs aligned with the embeddings URL.
51
+ */
52
+ function serverRoot(baseURL) {
53
+ return baseURL.replace(/\/+$/, '').replace(/\/(embeddings|v1)$/, '');
54
+ }
55
+ /**
56
+ * Probe an Infinity-style `/health` endpoint. Resolves `true` when the server
57
+ * answers with a 2xx, `false` on any error or non-2xx. Reused by setup's
58
+ * `waitForInfinity`.
59
+ */
60
+ async function probeInfinityHealth(baseURL, fetchImpl = fetch, timeoutMs = 2000) {
61
+ try {
62
+ const res = await fetchImpl(`${serverRoot(baseURL)}/health`, {
63
+ method: 'GET',
64
+ signal: AbortSignal.timeout(timeoutMs),
65
+ });
66
+ return res.ok;
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ /**
73
+ * List the model ids an Infinity server currently serves via its OpenAI-style
74
+ * `/models` listing (`{ data: [{ id }] }`). Returns `[]` on any error.
75
+ */
76
+ async function listInfinityModels(baseURL, fetchImpl = fetch, timeoutMs = 5000) {
77
+ try {
78
+ const res = await fetchImpl(`${serverRoot(baseURL)}/models`, {
79
+ method: 'GET',
80
+ signal: AbortSignal.timeout(timeoutMs),
81
+ });
82
+ if (!res.ok)
83
+ return [];
84
+ const data = await res.json();
85
+ return (data.data ?? []).map(m => m.id).filter((id) => Boolean(id));
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ }
91
+ /**
92
+ * Embedder for a local Infinity server. Inherits the OpenAI-style `/embeddings`
93
+ * HTTP path, batching, retry, and reranker wiring from `OpenAICompatibleEmbedder`
94
+ * unchanged — it adds ONLY container-readiness behavior (`ensureModelAvailable`),
95
+ * because Infinity binds its model set at container startup and exposes no
96
+ * runtime model-pull API.
97
+ */
98
+ class InfinityEmbedder extends OpenAICompatibleEmbedder_1.OpenAICompatibleEmbedder {
99
+ base;
100
+ doFetchImpl;
101
+ runCommand;
102
+ composePath;
103
+ pollIntervalMs;
104
+ log;
105
+ constructor(config, logger = LoggerService_1.LoggerService.silent(), deps = {}) {
106
+ super(config, logger, deps);
107
+ this.base = serverRoot(config.embedding.baseURL);
108
+ this.doFetchImpl = deps.fetchImpl ?? fetch;
109
+ this.runCommand = deps.runCommand ?? ((cmd, args, env) => {
110
+ const r = (0, child_process_1.spawnSync)(cmd, args, { stdio: 'inherit', env: env ? { ...process.env, ...env } : process.env });
111
+ return { status: r.status, stdout: r.stdout?.toString() };
112
+ });
113
+ this.composePath = deps.composePath ?? path.join(config_store_1.CONFIG_DIR, 'docker-compose.yml');
114
+ this.pollIntervalMs = deps.pollIntervalMs ?? 2000;
115
+ this.log = logger;
116
+ }
117
+ isHealthy() {
118
+ return probeInfinityHealth(this.base, this.doFetchImpl);
119
+ }
120
+ servedModels() {
121
+ return listInfinityModels(this.base, this.doFetchImpl);
122
+ }
123
+ /** True when a managed compose file exists AND declares an `infinity` service. */
124
+ hasManagedService() {
125
+ try {
126
+ const content = fs.readFileSync(this.composePath, 'utf-8');
127
+ return /^\s*infinity:\s*$/m.test(content);
128
+ }
129
+ catch {
130
+ return false;
131
+ }
132
+ }
133
+ /**
134
+ * Bring the Infinity backend to a serving state per the D4 decision tree:
135
+ * healthy + model served → OK
136
+ * healthy + model NOT served → throw (restart container with correct --model-id)
137
+ * unreachable + managed → `docker compose up -d infinity`, wait /health
138
+ * unreachable + external → print manual instructions (no Docker touched)
139
+ *
140
+ * Called by `IndexCommand` before `prepareEmbeddingSchema()`.
141
+ */
142
+ async ensureModelAvailable() {
143
+ const model = this.config.embedding.model;
144
+ if (await this.isHealthy()) {
145
+ const served = await this.servedModels();
146
+ // A server that lists no models (older Infinity, or listing disabled) is
147
+ // trusted — the embed request will surface a real error if the model is
148
+ // truly absent.
149
+ if (served.length === 0 || served.includes(model))
150
+ return;
151
+ throw new Error(`Infinity at ${this.base} serves [${served.join(', ')}] but embedding.model is "${model}". ` +
152
+ `Restart the container with the correct --model-id (e.g. recreate the managed container ` +
153
+ `via rag-setup, or fix embedding.model).`);
154
+ }
155
+ // Unreachable.
156
+ if (this.hasManagedService()) {
157
+ console.log(`Infinity not reachable at ${this.base} — starting the managed container...`);
158
+ this.log.info('InfinityEmbedder: starting managed container', { composePath: this.composePath });
159
+ // Bind the SAME model set the config expects (embed + reranker when enabled),
160
+ // so the auto-started container matches the config — otherwise compose would
161
+ // (re)create it with the compose default and silently drop the reranker /
162
+ // serve the wrong embed model. `--force-recreate` picks up a changed set.
163
+ const modelIds = [
164
+ this.config.embedding.model,
165
+ ...(this.config.reranker.enabled && this.config.reranker.model ? [this.config.reranker.model] : []),
166
+ ].join(';');
167
+ const r = this.runCommand('docker', ['compose', '-f', this.composePath, 'up', '-d', '--force-recreate', 'infinity'], { INFINITY_MODEL_ID: modelIds });
168
+ if (r.status !== 0) {
169
+ throw new Error(`Failed to start the managed Infinity container (docker compose exited ${r.status}). ` +
170
+ `Ensure Docker is running, or start Infinity manually.`);
171
+ }
172
+ await this.waitForHealthy();
173
+ return;
174
+ }
175
+ throw new Error(`Infinity is not reachable at ${this.base} and no managed container is configured. ` +
176
+ `Start it manually, e.g.:\n` +
177
+ ` docker run -it --gpus all -p 7997:7997 michaelf34/infinity:latest \\\n` +
178
+ ` v2 --model-id ${model} --port 7997\n` +
179
+ `(drop --gpus all to run on CPU), or re-run rag-setup to provision it.`);
180
+ }
181
+ /**
182
+ * Poll `/health` until ready or the timeout elapses. Generous default (30 min)
183
+ * because a CPU first boot downloads the model set and runs a lengthy per-model
184
+ * warmup before `/health` flips to 200.
185
+ */
186
+ async waitForHealthy(maxWaitSec = 1800) {
187
+ console.log('Waiting for Infinity to be ready (first boot downloads + warms up the model set, this can take several minutes on CPU)...');
188
+ const deadline = Date.now() + maxWaitSec * 1000;
189
+ while (Date.now() < deadline) {
190
+ if (await this.isHealthy()) {
191
+ console.log(' ✓ Infinity ready.');
192
+ return;
193
+ }
194
+ await new Promise(r => setTimeout(r, this.pollIntervalMs));
195
+ }
196
+ throw new Error(`Infinity did not become ready within ${maxWaitSec}s at ${this.base}.`);
197
+ }
198
+ }
199
+ exports.InfinityEmbedder = InfinityEmbedder;
@@ -1,14 +1,25 @@
1
1
  import type { Config } from '../config';
2
- import type { IEmbedder } from '../interfaces';
2
+ import type { IEmbedder, InputType } from '../interfaces';
3
3
  import type { ILogger } from '../interfaces';
4
+ import type { EmbedderDeps } from './OpenAICompatibleEmbedder';
4
5
  export declare class OllamaEmbedder implements IEmbedder {
5
6
  private config;
6
7
  private logger;
8
+ readonly dimensions: number;
7
9
  private client;
8
10
  rerank?: (query: string, documents: string[], topN: number) => Promise<number[]>;
9
- constructor(config: Config, logger?: ILogger);
10
- embed(text: string): 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[][]>;
11
14
  listModels(): Promise<string[]>;
12
- translate(text: string, model: string): Promise<string>;
13
- private _rerank;
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;
14
25
  }
@@ -3,61 +3,109 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OllamaEmbedder = void 0;
4
4
  const ollama_1 = require("ollama");
5
5
  const LoggerService_1 = require("./LoggerService");
6
+ const reranker_1 = require("./reranker");
7
+ const constants_1 = require("../constants");
6
8
  class OllamaEmbedder {
7
9
  config;
8
10
  logger;
11
+ dimensions;
9
12
  client;
10
13
  rerank;
11
- constructor(config, logger = LoggerService_1.LoggerService.silent()) {
14
+ constructor(config, logger = LoggerService_1.LoggerService.silent(), deps = {}) {
12
15
  this.config = config;
13
16
  this.logger = logger;
14
- this.client = new ollama_1.Ollama({ host: config.ollama.host });
15
- if (config.search.rerankerEnabled) {
16
- if (config.ollama.rerankerModel) {
17
- this.rerank = this._rerank.bind(this);
18
- }
19
- else {
20
- this.logger.warn('OllamaEmbedder: rerankerEnabled=true but rerankerModel is not set — reranking disabled');
21
- }
22
- }
17
+ this.client = new ollama_1.Ollama({ host: config.embedding.baseURL });
18
+ this.dimensions = config.embedding.dimensions ?? (0, constants_1.registryDimension)(config.embedding.model) ?? 0;
19
+ this.rerank = (0, reranker_1.makeReranker)(config, logger, deps);
23
20
  }
24
- async embed(text) {
21
+ // Ollama has no query/document asymmetry — inputType is accepted and ignored.
22
+ async embed(text, _inputType) {
25
23
  const { embedding } = await this.client.embeddings({
26
- model: this.config.ollama.model,
24
+ model: this.config.embedding.model,
27
25
  prompt: text,
28
26
  });
29
27
  return embedding;
30
28
  }
29
+ async embedBatch(texts, _inputType) {
30
+ if (texts.length === 0)
31
+ return [];
32
+ try {
33
+ // /api/embed accepts an array input and returns one vector per input.
34
+ const res = await this.client.embed({ model: this.config.embedding.model, input: texts });
35
+ if (res.embeddings?.length === texts.length)
36
+ return res.embeddings;
37
+ this.logger.warn('OllamaEmbedder: batch embed count mismatch, falling back to sequential', {
38
+ expected: texts.length, got: res.embeddings?.length ?? 0,
39
+ });
40
+ }
41
+ catch (err) {
42
+ this.logger.warn('OllamaEmbedder: batch embed failed, falling back to sequential', {
43
+ reason: err instanceof Error ? err.message : String(err),
44
+ });
45
+ }
46
+ // Sequential fallback — the contract (one vector per input, in order) holds.
47
+ const out = [];
48
+ for (const t of texts)
49
+ out.push(await this.embed(t));
50
+ return out;
51
+ }
31
52
  async listModels() {
32
53
  const res = await this.client.list();
33
54
  return res.models.map(m => m.name).filter(Boolean).sort();
34
55
  }
35
- async translate(text, model) {
36
- const response = await this.client.chat({
37
- model,
38
- messages: [{ role: 'user', content: `Translate the following text to English. Return only the translation, no explanations:\n${text}` }],
39
- });
40
- return response.message.content.trim();
56
+ /**
57
+ * Pull the configured model if it is not already present locally, so a plain
58
+ * `rag-index` works after switching to (or back to) a local model that was
59
+ * never pulled — `rag-setup` is no longer the only place that fetches models.
60
+ * A tagless model name (`bge-m3`) matches its `:latest`/tagged install.
61
+ * Streams pull progress to the console: a live single-line bar on a TTY, or
62
+ * de-duplicated status lines otherwise.
63
+ */
64
+ async ensureModelAvailable() {
65
+ const model = this.config.embedding.model;
66
+ try {
67
+ const res = await this.client.list();
68
+ const present = res.models.some(m => m.name === model || m.name.startsWith(`${model}:`));
69
+ if (present)
70
+ return;
71
+ }
72
+ catch (err) {
73
+ // Can't list (Ollama unreachable?) — try the pull; its error is clearer.
74
+ this.logger.warn('OllamaEmbedder: could not list models before pull', {
75
+ reason: err instanceof Error ? err.message : String(err),
76
+ });
77
+ }
78
+ console.log(`Pulling Ollama model "${model}" (not found locally)...`);
79
+ this.logger.info('OllamaEmbedder: pulling model', { model });
80
+ const stream = await this.client.pull({ model, stream: true });
81
+ const isTty = Boolean(process.stdout.isTTY);
82
+ let lastLen = 0;
83
+ let lastLog = '';
84
+ for await (const part of stream) {
85
+ const out = ' ' + OllamaEmbedder.formatPullProgress(part);
86
+ if (isTty) {
87
+ process.stdout.write('\r' + out.padEnd(lastLen));
88
+ lastLen = out.length;
89
+ }
90
+ else if (out !== lastLog) {
91
+ console.log(out);
92
+ lastLog = out;
93
+ }
94
+ }
95
+ if (isTty && lastLen > 0)
96
+ process.stdout.write('\r' + ' '.repeat(lastLen) + '\r');
97
+ console.log(` ✓ ${model} ready.`);
41
98
  }
42
- async _rerank(query, documents, topN) {
43
- const res = await fetch(this.config.ollama.rerankerUrl, {
44
- method: 'POST',
45
- headers: { 'Content-Type': 'application/json' },
46
- signal: AbortSignal.timeout(30_000),
47
- body: JSON.stringify({
48
- model: this.config.ollama.rerankerModel,
49
- query,
50
- documents,
51
- top_n: topN,
52
- truncate: true,
53
- }),
54
- });
55
- if (!res.ok) {
56
- const body = (await res.text().catch(() => res.statusText)).slice(0, 200);
57
- throw new Error(`Ollama rerank failed: ${res.status} ${body}`);
99
+ static formatPullProgress(p) {
100
+ const status = p.status ?? 'pulling';
101
+ if (p.total && p.total > 0) {
102
+ const pct = Math.min(100, Math.floor(((p.completed ?? 0) / p.total) * 100));
103
+ const width = 24;
104
+ const filled = Math.round((pct / 100) * width);
105
+ const bar = '[' + '█'.repeat(filled) + '░'.repeat(width - filled) + ']';
106
+ return `${status} ${bar} ${pct}% (${((p.completed ?? 0) / 1e6).toFixed(1)}/${(p.total / 1e6).toFixed(1)} MB)`;
58
107
  }
59
- const data = await res.json();
60
- return data.results.map(r => r.index);
108
+ return status;
61
109
  }
62
110
  }
63
111
  exports.OllamaEmbedder = OllamaEmbedder;