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
@@ -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();
@@ -180,13 +236,12 @@ class IndexerService {
180
236
  const files = preCollectedFiles ?? (0, file_utils_1.collectFiles)(dir, (0, file_utils_1.loadIgnore)(dir), dir);
181
237
  this.logger.debug('IndexerService: scanAndIndex files collected', { serviceName, count: files.length, force });
182
238
  const collectedRelPaths = new Set(files.map(f => path.relative(this.config.workspaceRoot, f)));
183
- const dbPaths = await this.db.query(`SELECT DISTINCT "filePath" FROM code_chunk WHERE "serviceName" = $1`, [serviceName]);
184
- for (const { filePath } of dbPaths) {
185
- if (!collectedRelPaths.has(filePath)) {
186
- await this.db.transaction(tx => tx.delete(db_1.codeChunk).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.codeChunk.serviceName, serviceName), (0, drizzle_orm_1.eq)(db_1.codeChunk.filePath, filePath))));
187
- await this.db.query(`DELETE FROM code_dependency WHERE project = $1 AND "fromFile" = $2`, [serviceName, filePath]);
188
- await this.db.query(`DELETE FROM code_dependency WHERE project = $1 AND "toFile" = $2`, [serviceName, filePath]);
189
- console.log(` Removed stale: ${path.basename(filePath)}`);
239
+ const knownFiles = await this.db.listKnownFiles(serviceName);
240
+ const staleFiles = knownFiles.filter(f => !collectedRelPaths.has(f));
241
+ if (staleFiles.length > 0) {
242
+ await this.db.deleteFiles(serviceName, staleFiles);
243
+ for (const f of staleFiles) {
244
+ console.log(` Removed stale: ${path.basename(f)}`);
190
245
  }
191
246
  }
192
247
  const bar = new cli_progress_1.SingleBar({
@@ -290,8 +345,26 @@ class IndexerService {
290
345
  });
291
346
  return stats;
292
347
  }
293
- getSplitter(filePath) {
294
- return SCHEMA_EXTENSIONS.test(filePath) ? this.schemaSplitter : this.jsSplitter;
348
+ async pruneProjects(keep) {
349
+ if (keep.length === 0)
350
+ return { removed: [], failed: [] };
351
+ const indexed = await this.db.listIndexedProjects();
352
+ const keepSet = new Set(keep);
353
+ const toRemove = indexed.filter(name => !keepSet.has(name));
354
+ const removed = [];
355
+ const failed = [];
356
+ for (const name of toRemove) {
357
+ try {
358
+ await this.db.deleteProject(name);
359
+ removed.push(name);
360
+ }
361
+ catch (err) {
362
+ console.error(`Failed to prune project ${name}: ${err.message}`);
363
+ this.logger.error('IndexerService: pruneProject failed', { name, reason: err.message });
364
+ failed.push(name);
365
+ }
366
+ }
367
+ return { removed, failed };
295
368
  }
296
369
  static inferRole(relPath) {
297
370
  const p = relPath.toLowerCase();
@@ -317,9 +390,9 @@ class IndexerService {
317
390
  return 'Utility';
318
391
  return null;
319
392
  }
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);
393
+ getEnrichedPrompt(piece, relPath, serviceName, _meta, edges, symbols) {
394
+ const chunkStart = piece.startLine;
395
+ const chunkEnd = piece.endLine;
323
396
  const lines = [];
324
397
  lines.push(`Project: ${serviceName} | File: ${relPath}`);
325
398
  const role = IndexerService.inferRole(relPath);
@@ -335,12 +408,9 @@ class IndexerService {
335
408
  const depNames = [...new Set(edges.map(e => path.basename(e.toFile, path.extname(e.toFile)).split('.')[0]))];
336
409
  lines.push(`Dependencies: ${depNames.join(', ')}`);
337
410
  }
338
- lines.push(`Code: ${chunk.pageContent}`);
411
+ lines.push(`Code: ${piece.content}`);
339
412
  return lines.join('\n');
340
413
  }
341
- async embedText(prompt) {
342
- return this.embedder.embed(prompt);
343
- }
344
414
  static hashContent(content) {
345
415
  return (0, crypto_1.createHash)('sha256').update(content).digest('hex');
346
416
  }
@@ -364,7 +434,7 @@ class IndexerService {
364
434
  return { status: 'skipped', chunks: 0, pendingMtimeRefresh: relFilePath };
365
435
  }
366
436
  const tAst = Date.now();
367
- const { edges: rawEdges, symbols: rawSymbols, calls: rawCalls } = analysis ?? { edges: [], symbols: [], calls: [] };
437
+ const { edges: rawEdges, symbols: rawSymbols, calls: rawCalls, ownerRanges: rawOwnerRanges = [] } = analysis ?? { edges: [], symbols: [], calls: [] };
368
438
  // astMs is near-zero — actual AST time is spent upfront in analyzeProject()
369
439
  // Normalize edges to workspace-relative paths
370
440
  const edges = rawEdges
@@ -390,25 +460,38 @@ class IndexerService {
390
460
  // Normalize symbols: replace absolute filePath with workspace-relative, strip endLine (not stored)
391
461
  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
462
  const astMs = Date.now() - tAst;
393
- const chunks = await this.getSplitter(filePath).createDocuments([code]);
463
+ const chunks = await this.chunker.chunk(code, {
464
+ strategy: this.config.indexer.chunkStrategy,
465
+ isSchemaFile: SCHEMA_EXTENSIONS.test(filePath),
466
+ symbols: rawSymbols,
467
+ });
394
468
  const relPath = path.relative(projectRoot, filePath);
469
+ // Owner-path ranges: top-level symbols (container: null) + method ranges.
470
+ // Deepest range containing a chunk's start line wins → Class.method.
471
+ const ownerRanges = [
472
+ ...rawSymbols
473
+ .filter(s => s.line != null)
474
+ .map(s => ({ name: s.name, container: null, line: s.line, endLine: s.endLine ?? s.line })),
475
+ ...rawOwnerRanges,
476
+ ];
395
477
  const t2 = Date.now();
396
478
  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}]`,
479
+ const embedBatchSize = this.config.embedding.batchSize;
480
+ for (let i = 0; i < chunks.length; i += embedBatchSize) {
481
+ const batch = chunks.slice(i, i + embedBatchSize);
482
+ const prompts = batch.map(piece => this.getEnrichedPrompt(piece, relPath, serviceName, meta ?? 'missing', edges, rawSymbols));
483
+ const vectors = await this.embedder.embedBatch(prompts, 'document');
484
+ for (let j = 0; j < batch.length; j++) {
485
+ const piece = batch[j];
486
+ records.push({
487
+ serviceName, filePath: relFilePath, startLine: piece.startLine,
488
+ content: piece.content,
489
+ embedding: `[${vectors[j]}]`,
407
490
  lastModified: mtimeMs,
408
491
  contentHash: hash,
409
- };
410
- }));
411
- records.push(...embedded);
492
+ ownerPath: (0, owner_path_1.resolveOwnerPath)(piece.startLine, ownerRanges),
493
+ });
494
+ }
412
495
  }
413
496
  const embedMs = Date.now() - t2;
414
497
  const t3 = Date.now();
@@ -0,0 +1,38 @@
1
+ import type { Config } from '../config';
2
+ import type { ILogger } from '../interfaces/ILogger';
3
+ import { OpenAICompatibleEmbedder, type EmbedderDeps } from './OpenAICompatibleEmbedder';
4
+ import { serverRoot, probeInfinityHealth, listInfinityModels, type ProvisioningDeps } from './infinity-provisioning';
5
+ export { serverRoot, probeInfinityHealth, listInfinityModels };
6
+ export type { CommandResult } from './infinity-provisioning';
7
+ /** Extra injection seams for `InfinityEmbedder` beyond the shared `EmbedderDeps`. */
8
+ export interface InfinityDeps extends EmbedderDeps, ProvisioningDeps {
9
+ /**
10
+ * Path to the managed docker-compose.yml. Defaults to the file `rag-setup`
11
+ * writes into `~/.config/codesearch-mcp/`. A missing file (or one without an
12
+ * `infinity` service) means "external backend" — the Docker path is skipped.
13
+ */
14
+ composePath?: string;
15
+ }
16
+ /**
17
+ * Embedder for a local Infinity server. Inherits the OpenAI-style `/embeddings`
18
+ * HTTP path, batching, retry, and reranker wiring from `OpenAICompatibleEmbedder`
19
+ * unchanged — it adds ONLY container-readiness behavior (`ensureModelAvailable`,
20
+ * `listModels`), delegating to the standalone `infinity-provisioning` primitive
21
+ * because Infinity binds its model set at container startup and exposes no
22
+ * runtime model-pull API.
23
+ */
24
+ export declare class InfinityEmbedder extends OpenAICompatibleEmbedder {
25
+ private readonly base;
26
+ private readonly composePath;
27
+ private readonly provisioningDeps;
28
+ constructor(config: Config, logger?: ILogger, deps?: InfinityDeps);
29
+ /**
30
+ * Index-time embed readiness (see `local-backend-provisioning`): verifies only
31
+ * the embedding model — indexing does not use the reranker, so a missing
32
+ * reranker model never blocks or restarts indexing. Whenever a recreate is
33
+ * needed, the full configured set is written so an already-served reranker
34
+ * model is never dropped. Called by `IndexCommand` before indexing.
35
+ */
36
+ ensureModelAvailable(): Promise<void>;
37
+ listModels(): Promise<string[]>;
38
+ }
@@ -0,0 +1,88 @@
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 = exports.listInfinityModels = exports.probeInfinityHealth = exports.serverRoot = void 0;
37
+ const LoggerService_1 = require("./LoggerService");
38
+ const config_store_1 = require("../config-store");
39
+ const OpenAICompatibleEmbedder_1 = require("./OpenAICompatibleEmbedder");
40
+ const infinity_provisioning_1 = require("./infinity-provisioning");
41
+ Object.defineProperty(exports, "serverRoot", { enumerable: true, get: function () { return infinity_provisioning_1.serverRoot; } });
42
+ Object.defineProperty(exports, "probeInfinityHealth", { enumerable: true, get: function () { return infinity_provisioning_1.probeInfinityHealth; } });
43
+ Object.defineProperty(exports, "listInfinityModels", { enumerable: true, get: function () { return infinity_provisioning_1.listInfinityModels; } });
44
+ const path = __importStar(require("path"));
45
+ /**
46
+ * Embedder for a local Infinity server. Inherits the OpenAI-style `/embeddings`
47
+ * HTTP path, batching, retry, and reranker wiring from `OpenAICompatibleEmbedder`
48
+ * unchanged — it adds ONLY container-readiness behavior (`ensureModelAvailable`,
49
+ * `listModels`), delegating to the standalone `infinity-provisioning` primitive
50
+ * because Infinity binds its model set at container startup and exposes no
51
+ * runtime model-pull API.
52
+ */
53
+ class InfinityEmbedder extends OpenAICompatibleEmbedder_1.OpenAICompatibleEmbedder {
54
+ base;
55
+ composePath;
56
+ provisioningDeps;
57
+ constructor(config, logger = LoggerService_1.LoggerService.silent(), deps = {}) {
58
+ super(config, logger, deps);
59
+ this.base = (0, infinity_provisioning_1.serverRoot)(config.embedding.baseURL);
60
+ this.composePath = deps.composePath ?? path.join(config_store_1.CONFIG_DIR, 'docker-compose.yml');
61
+ this.provisioningDeps = {
62
+ fetchImpl: deps.fetchImpl,
63
+ runCommand: deps.runCommand,
64
+ pollIntervalMs: deps.pollIntervalMs,
65
+ readinessTimeoutMs: deps.readinessTimeoutMs,
66
+ };
67
+ }
68
+ /**
69
+ * Index-time embed readiness (see `local-backend-provisioning`): verifies only
70
+ * the embedding model — indexing does not use the reranker, so a missing
71
+ * reranker model never blocks or restarts indexing. Whenever a recreate is
72
+ * needed, the full configured set is written so an already-served reranker
73
+ * model is never dropped. Called by `IndexCommand` before indexing.
74
+ */
75
+ async ensureModelAvailable() {
76
+ await (0, infinity_provisioning_1.ensureBackendReady)({
77
+ baseURL: this.config.embedding.baseURL,
78
+ requiredModels: [this.config.embedding.model],
79
+ modelSetToWrite: (0, infinity_provisioning_1.requiredModelSet)(this.config),
80
+ composePath: this.composePath,
81
+ deps: this.provisioningDeps,
82
+ });
83
+ }
84
+ async listModels() {
85
+ return (0, infinity_provisioning_1.listInfinityModels)(this.base, this.provisioningDeps.fetchImpl ?? fetch, 2000);
86
+ }
87
+ }
88
+ exports.InfinityEmbedder = InfinityEmbedder;
@@ -3,7 +3,8 @@ import type { ILogger } from '../interfaces/ILogger';
3
3
  export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
4
4
  export declare class LoggerService implements ILogger {
5
5
  private readonly logger;
6
- constructor(logger: pino.Logger);
6
+ private readonly metricsLogger;
7
+ constructor(logger: pino.Logger, metricsLogger?: pino.Logger | null);
7
8
  static silent(): LoggerService;
8
9
  static fromConfig(cfg: {
9
10
  level: string;
@@ -14,4 +15,5 @@ export declare class LoggerService implements ILogger {
14
15
  info(msg: string, data?: Record<string, unknown>): void;
15
16
  warn(msg: string, data?: Record<string, unknown>): void;
16
17
  error(msg: string, data?: Record<string, unknown>): void;
18
+ metric(msg: string, data?: Record<string, unknown>): void;
17
19
  }
@@ -44,8 +44,13 @@ const fs = __importStar(require("fs"));
44
44
  const DEFAULT_LOG_DIR = path.join(os.homedir(), '.config', 'codesearch-mcp', 'logs');
45
45
  class LoggerService {
46
46
  logger;
47
- constructor(logger) {
47
+ // Dedicated `info`-level channel for search telemetry, independent of the
48
+ // main logger's configured level. Null when file logging is disabled so
49
+ // metrics never reach stdout (owned by the MCP JSON transport).
50
+ metricsLogger;
51
+ constructor(logger, metricsLogger = null) {
48
52
  this.logger = logger;
53
+ this.metricsLogger = metricsLogger;
49
54
  }
50
55
  static silent() {
51
56
  return new LoggerService((0, pino_1.default)({ level: 'silent' }));
@@ -66,6 +71,10 @@ class LoggerService {
66
71
  static buildFromPath(level, filePath) {
67
72
  if (filePath)
68
73
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
74
+ // The transport target must accept `info` so the metrics channel can emit
75
+ // even when the main level is `warn`/`error`; the main logger still gates
76
+ // normal calls at `level`, so nothing else leaks below it.
77
+ const targetLevel = pino_1.default.levels.values[level] < pino_1.default.levels.values.info ? level : 'info';
69
78
  const transport = filePath
70
79
  ? pino_1.default.transport({
71
80
  targets: [{
@@ -79,18 +88,23 @@ class LoggerService {
79
88
  limit: { count: 30 },
80
89
  size: '50m',
81
90
  },
82
- level,
91
+ level: targetLevel,
83
92
  }],
84
93
  })
85
94
  : undefined;
86
95
  const logger = transport
87
96
  ? (0, pino_1.default)({ level }, transport)
88
97
  : (0, pino_1.default)({ level: 'silent' });
89
- return new LoggerService(logger);
98
+ // Child logger pinned at `info` regardless of the parent level; only used by
99
+ // metric(). Null when there is no file transport (default stdout must stay
100
+ // clean for the MCP transport).
101
+ const metricsLogger = transport ? logger.child({}, { level: 'info' }) : null;
102
+ return new LoggerService(logger, metricsLogger);
90
103
  }
91
104
  debug(msg, data) { this.logger.debug(data ?? {}, msg); }
92
105
  info(msg, data) { this.logger.info(data ?? {}, msg); }
93
106
  warn(msg, data) { this.logger.warn(data ?? {}, msg); }
94
107
  error(msg, data) { this.logger.error(data ?? {}, msg); }
108
+ metric(msg, data) { this.metricsLogger?.info(data ?? {}, msg); }
95
109
  }
96
110
  exports.LoggerService = LoggerService;
@@ -0,0 +1,25 @@
1
+ import type { Config } from '../config';
2
+ import type { IEmbedder, InputType } from '../interfaces/IEmbedder';
3
+ import type { ILogger } from '../interfaces/ILogger';
4
+ export interface EmbedderDeps {
5
+ fetchImpl?: typeof fetch;
6
+ baseDelayMs?: number;
7
+ }
8
+ /**
9
+ * Embedder for any server exposing an OpenAI-style `/embeddings` endpoint:
10
+ * OpenAI cloud, Infinity, TEI, vLLM, LM Studio, LocalAI. One request carries all
11
+ * inputs (`{ input: string[], model, dimensions? }`) and returns
12
+ * `{ data: [{ embedding, index }] }`, re-ordered to input order.
13
+ */
14
+ export declare class OpenAICompatibleEmbedder implements IEmbedder {
15
+ protected config: Config;
16
+ private logger;
17
+ readonly dimensions: number;
18
+ rerank?: (query: string, documents: string[], topN: number) => Promise<number[]>;
19
+ private readonly url;
20
+ private readonly doFetch;
21
+ private readonly baseDelayMs?;
22
+ constructor(config: Config, logger?: ILogger, deps?: EmbedderDeps);
23
+ embed(text: string, inputType?: InputType): Promise<number[]>;
24
+ embedBatch(texts: string[], _inputType?: InputType): Promise<number[][]>;
25
+ }
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OpenAICompatibleEmbedder = void 0;
4
+ const LoggerService_1 = require("./LoggerService");
5
+ const config_1 = require("../config");
6
+ const reranker_1 = require("./reranker");
7
+ const http_retry_1 = require("./http-retry");
8
+ const constants_1 = require("../constants");
9
+ /**
10
+ * Embedder for any server exposing an OpenAI-style `/embeddings` endpoint:
11
+ * OpenAI cloud, Infinity, TEI, vLLM, LM Studio, LocalAI. One request carries all
12
+ * inputs (`{ input: string[], model, dimensions? }`) and returns
13
+ * `{ data: [{ embedding, index }] }`, re-ordered to input order.
14
+ */
15
+ class OpenAICompatibleEmbedder {
16
+ config;
17
+ logger;
18
+ dimensions;
19
+ rerank;
20
+ url;
21
+ doFetch;
22
+ baseDelayMs;
23
+ constructor(config, logger = LoggerService_1.LoggerService.silent(), deps = {}) {
24
+ this.config = config;
25
+ this.logger = logger;
26
+ const base = config.embedding.baseURL.replace(/\/+$/, '');
27
+ // Allow a baseURL that already includes the path (…/v1) or not.
28
+ this.url = /\/embeddings$/.test(base) ? base : `${base}/embeddings`;
29
+ this.doFetch = deps.fetchImpl ?? fetch;
30
+ this.baseDelayMs = deps.baseDelayMs;
31
+ this.dimensions = config.embedding.dimensions ?? (0, constants_1.registryDimension)(config.embedding.model) ?? 0;
32
+ this.rerank = (0, reranker_1.makeReranker)(config, logger, deps);
33
+ }
34
+ async embed(text, inputType) {
35
+ const [v] = await this.embedBatch([text], inputType);
36
+ return v;
37
+ }
38
+ async embedBatch(texts, _inputType) {
39
+ if (texts.length === 0)
40
+ return [];
41
+ return (0, http_retry_1.withRetry)(async () => {
42
+ const headers = { 'Content-Type': 'application/json' };
43
+ // Resolve the key lazily — an unset `env:` ref throws here (request time),
44
+ // not at construction, so `rag-web`/`rag-mcp` still start.
45
+ const apiKey = (0, config_1.resolveEnvRef)(this.config.embedding.apiKey);
46
+ if (apiKey)
47
+ headers['Authorization'] = `Bearer ${apiKey}`;
48
+ const body = { model: this.config.embedding.model, input: texts };
49
+ if (this.config.embedding.dimensions != null)
50
+ body.dimensions = this.config.embedding.dimensions;
51
+ const res = await this.doFetch(this.url, {
52
+ method: 'POST',
53
+ headers,
54
+ signal: AbortSignal.timeout(120_000),
55
+ body: JSON.stringify(body),
56
+ });
57
+ if (!res.ok) {
58
+ const errBody = (await res.text().catch(() => res.statusText)).slice(0, 200);
59
+ throw new http_retry_1.HttpError(res.status, `embeddings request failed: ${res.status} ${errBody}`);
60
+ }
61
+ const data = await res.json();
62
+ // Guard against a partial/mismatched response — a missing vector would
63
+ // otherwise be inserted as "[undefined]" and fail the whole file, or (with
64
+ // reordering + no index) silently misalign vectors to the wrong chunks.
65
+ if (!Array.isArray(data.data) || data.data.length !== texts.length) {
66
+ throw new Error(`embeddings response returned ${data.data?.length ?? 0} vectors for ${texts.length} inputs`);
67
+ }
68
+ // Sort by `index` so vectors line up with input order regardless of server ordering.
69
+ return [...data.data]
70
+ .sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
71
+ .map(d => d.embedding);
72
+ }, { logger: this.logger, baseDelayMs: this.baseDelayMs, label: 'openai-embed' });
73
+ }
74
+ }
75
+ exports.OpenAICompatibleEmbedder = OpenAICompatibleEmbedder;
@@ -9,6 +9,7 @@ export interface ChunkRow {
9
9
  filePath: string;
10
10
  startLine: number;
11
11
  content: string;
12
+ ownerPath?: string | null;
12
13
  }
13
14
  export interface SearchResult extends ChunkRow {
14
15
  score: number;
@@ -102,8 +103,28 @@ export declare class SearcherService {
102
103
  query: string;
103
104
  translated: boolean;
104
105
  }>;
106
+ /**
107
+ * Refuse when the configured embedding model disagrees with the one that built
108
+ * the index — embedding a query into a foreign vector space returns noise. A
109
+ * missing `index_meta` row with an empty (or altogether absent) `code_chunk`
110
+ * does not block — but a *populated* `code_chunk` with no `index_meta` row is a
111
+ * pre-`index_meta` install, treated as the legacy `ollama`/`bge-m3` index (same
112
+ * interpretation as the `rag-index` legacy-Ollama guard), read-only — this check
113
+ * never writes `index_meta`.
114
+ */
115
+ assertIndexMatches(): Promise<void>;
116
+ private compareIndexed;
105
117
  listProjects(): Promise<ProjectRow[]>;
106
118
  searchChunks(query: string, limit: number, project: string | null, threshold?: number): Promise<SearchResult[]>;
119
+ /**
120
+ * Emit one structured search-tuning line (via `logger.metric`, at `info`
121
+ * regardless of `logging.level`). `candidates` are in cosine order, so a
122
+ * candidate's array index is its cosine rank — `maxCosineRankSurvived` is the
123
+ * deepest cosine position that survived reranking into the returned set. Read
124
+ * it alongside `poolSaturated` to tune `candidateFloor`/`overfetch`/threshold
125
+ * per hardware. Never logs chunk content, embeddings, or credentials.
126
+ */
127
+ private emitSearchMetrics;
107
128
  fetchNeighbors(results: SearchResult[]): Promise<NeighborResult[]>;
108
129
  fetchGraphContext(results: SearchResult[]): Promise<Map<string, GraphContext>>;
109
130
  getDependencies(filePath: string, project: string, symbol?: string | null, transitive?: boolean): Promise<DependencyRow[]>;
@@ -125,7 +146,6 @@ export declare class SearcherService {
125
146
  };
126
147
  readFileAbsolute(absPath: string, maxLines: number): Promise<FileRangeResult>;
127
148
  readFileRange(filePath: string, startLine: number, endLine: number): Promise<FileRangeResult>;
128
- private isNonEnglish;
129
149
  private walkDir;
130
150
  private buildFindSymbolPattern;
131
151
  private inferKind;