codesearch-mcp 0.1.0-alpha → 0.2.0-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/README.md +141 -39
  3. package/dist/app.js +2 -2
  4. package/dist/src/commands/IndexCommand.js +42 -1
  5. package/dist/src/config-error.d.ts +7 -0
  6. package/dist/src/config-error.js +14 -0
  7. package/dist/src/config-store.d.ts +30 -12
  8. package/dist/src/config-store.js +81 -26
  9. package/dist/src/config.d.ts +5 -4
  10. package/dist/src/config.js +14 -16
  11. package/dist/src/constants.d.ts +8 -2
  12. package/dist/src/constants.js +8 -5
  13. package/dist/src/help.js +1 -1
  14. package/dist/src/interfaces/IDatabase.d.ts +17 -0
  15. package/dist/src/interfaces/ILogger.d.ts +7 -0
  16. package/dist/src/services/AstChunker.d.ts +11 -0
  17. package/dist/src/services/AstChunker.js +44 -0
  18. package/dist/src/services/DatabaseService.d.ts +4 -0
  19. package/dist/src/services/DatabaseService.js +46 -0
  20. package/dist/src/services/EmbedderFactory.d.ts +1 -1
  21. package/dist/src/services/EmbedderFactory.js +2 -5
  22. package/dist/src/services/IndexerService.d.ts +4 -0
  23. package/dist/src/services/IndexerService.js +27 -7
  24. package/dist/src/services/InfinityEmbedder.d.ts +14 -55
  25. package/dist/src/services/InfinityEmbedder.js +30 -141
  26. package/dist/src/services/LoggerService.d.ts +3 -1
  27. package/dist/src/services/LoggerService.js +17 -3
  28. package/dist/src/services/SearcherService.d.ts +15 -1
  29. package/dist/src/services/SearcherService.js +105 -23
  30. package/dist/src/services/WebServerService.d.ts +41 -3
  31. package/dist/src/services/WebServerService.js +90 -2
  32. package/dist/src/services/infinity-provisioning.d.ts +115 -0
  33. package/dist/src/services/infinity-provisioning.js +516 -0
  34. package/dist/src/services/legacy-ollama-index.d.ts +24 -0
  35. package/dist/src/services/legacy-ollama-index.js +61 -0
  36. package/dist/src/services/reranker.d.ts +3 -3
  37. package/dist/src/services/reranker.js +3 -3
  38. package/dist/src/setup.d.ts +37 -0
  39. package/dist/src/setup.js +111 -157
  40. package/docker/docker-compose.yml +5 -15
  41. package/docs/web-dashboard.md +36 -17
  42. package/package.json +1 -5
  43. package/dist/src/services/OllamaEmbedder.d.ts +0 -25
  44. package/dist/src/services/OllamaEmbedder.js +0 -111
@@ -236,13 +236,12 @@ class IndexerService {
236
236
  const files = preCollectedFiles ?? (0, file_utils_1.collectFiles)(dir, (0, file_utils_1.loadIgnore)(dir), dir);
237
237
  this.logger.debug('IndexerService: scanAndIndex files collected', { serviceName, count: files.length, force });
238
238
  const collectedRelPaths = new Set(files.map(f => path.relative(this.config.workspaceRoot, f)));
239
- const dbPaths = await this.db.query(`SELECT DISTINCT "filePath" FROM code_chunk WHERE "serviceName" = $1`, [serviceName]);
240
- for (const { filePath } of dbPaths) {
241
- if (!collectedRelPaths.has(filePath)) {
242
- 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))));
243
- await this.db.query(`DELETE FROM code_dependency WHERE project = $1 AND "fromFile" = $2`, [serviceName, filePath]);
244
- await this.db.query(`DELETE FROM code_dependency WHERE project = $1 AND "toFile" = $2`, [serviceName, filePath]);
245
- 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)}`);
246
245
  }
247
246
  }
248
247
  const bar = new cli_progress_1.SingleBar({
@@ -346,6 +345,27 @@ class IndexerService {
346
345
  });
347
346
  return stats;
348
347
  }
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 };
368
+ }
349
369
  static inferRole(relPath) {
350
370
  const p = relPath.toLowerCase();
351
371
  if (p.includes('/services/') || p.endsWith('.service.ts'))
@@ -1,79 +1,38 @@
1
1
  import type { Config } from '../config';
2
2
  import type { ILogger } from '../interfaces/ILogger';
3
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;
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 {
17
9
  /**
18
10
  * Path to the managed docker-compose.yml. Defaults to the file `rag-setup`
19
11
  * writes into `~/.config/codesearch-mcp/`. A missing file (or one without an
20
12
  * `infinity` service) means "external backend" — the Docker path is skipped.
21
13
  */
22
14
  composePath?: string;
23
- /** Sleep between readiness polls (ms). Defaults to 2000; tests pass 0. */
24
- pollIntervalMs?: number;
25
15
  }
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
16
  /**
45
17
  * Embedder for a local Infinity server. Inherits the OpenAI-style `/embeddings`
46
18
  * HTTP path, batching, retry, and reranker wiring from `OpenAICompatibleEmbedder`
47
- * unchanged — it adds ONLY container-readiness behavior (`ensureModelAvailable`),
19
+ * unchanged — it adds ONLY container-readiness behavior (`ensureModelAvailable`,
20
+ * `listModels`), delegating to the standalone `infinity-provisioning` primitive
48
21
  * because Infinity binds its model set at container startup and exposes no
49
22
  * runtime model-pull API.
50
23
  */
51
24
  export declare class InfinityEmbedder extends OpenAICompatibleEmbedder {
52
25
  private readonly base;
53
- private readonly doFetchImpl;
54
- private readonly runCommand;
55
26
  private readonly composePath;
56
- private readonly pollIntervalMs;
57
- private readonly log;
27
+ private readonly provisioningDeps;
58
28
  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
29
  /**
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()`.
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.
71
35
  */
72
36
  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;
37
+ listModels(): Promise<string[]>;
79
38
  }
@@ -33,167 +33,56 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
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");
36
+ exports.InfinityEmbedder = exports.listInfinityModels = exports.probeInfinityHealth = exports.serverRoot = void 0;
43
37
  const LoggerService_1 = require("./LoggerService");
44
38
  const config_store_1 = require("../config-store");
45
39
  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
- }
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"));
91
45
  /**
92
46
  * Embedder for a local Infinity server. Inherits the OpenAI-style `/embeddings`
93
47
  * HTTP path, batching, retry, and reranker wiring from `OpenAICompatibleEmbedder`
94
- * unchanged — it adds ONLY container-readiness behavior (`ensureModelAvailable`),
48
+ * unchanged — it adds ONLY container-readiness behavior (`ensureModelAvailable`,
49
+ * `listModels`), delegating to the standalone `infinity-provisioning` primitive
95
50
  * because Infinity binds its model set at container startup and exposes no
96
51
  * runtime model-pull API.
97
52
  */
98
53
  class InfinityEmbedder extends OpenAICompatibleEmbedder_1.OpenAICompatibleEmbedder {
99
54
  base;
100
- doFetchImpl;
101
- runCommand;
102
55
  composePath;
103
- pollIntervalMs;
104
- log;
56
+ provisioningDeps;
105
57
  constructor(config, logger = LoggerService_1.LoggerService.silent(), deps = {}) {
106
58
  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
- });
59
+ this.base = (0, infinity_provisioning_1.serverRoot)(config.embedding.baseURL);
113
60
  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
- }
61
+ this.provisioningDeps = {
62
+ fetchImpl: deps.fetchImpl,
63
+ runCommand: deps.runCommand,
64
+ pollIntervalMs: deps.pollIntervalMs,
65
+ readinessTimeoutMs: deps.readinessTimeoutMs,
66
+ };
132
67
  }
133
68
  /**
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()`.
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.
141
74
  */
142
75
  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.`);
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
+ });
180
83
  }
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}.`);
84
+ async listModels() {
85
+ return (0, infinity_provisioning_1.listInfinityModels)(this.base, this.provisioningDeps.fetchImpl ?? fetch, 2000);
197
86
  }
198
87
  }
199
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;
@@ -106,11 +106,25 @@ export declare class SearcherService {
106
106
  /**
107
107
  * Refuse when the configured embedding model disagrees with the one that built
108
108
  * the index — embedding a query into a foreign vector space returns noise. A
109
- * missing `index_meta` (un-migrated DB / not yet indexed) does not block.
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`.
110
114
  */
111
115
  assertIndexMatches(): Promise<void>;
116
+ private compareIndexed;
112
117
  listProjects(): Promise<ProjectRow[]>;
113
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;
114
128
  fetchNeighbors(results: SearchResult[]): Promise<NeighborResult[]>;
115
129
  fetchGraphContext(results: SearchResult[]): Promise<Map<string, GraphContext>>;
116
130
  getDependencies(filePath: string, project: string, symbol?: string | null, transitive?: boolean): Promise<DependencyRow[]>;
@@ -40,11 +40,13 @@ const readline = __importStar(require("readline"));
40
40
  const child_process_1 = require("child_process");
41
41
  const file_utils_1 = require("../file-utils");
42
42
  const MonorepoAwareDiscoveryService_1 = require("./MonorepoAwareDiscoveryService");
43
+ const legacy_ollama_index_1 = require("./legacy-ollama-index");
43
44
  const SILENT_LOGGER = {
44
45
  debug() { },
45
46
  info() { },
46
47
  warn() { },
47
48
  error() { },
49
+ metric() { },
48
50
  };
49
51
  const EMPTY_META = { description: null, stack: null, notes: null, relations: null };
50
52
  const GREP_MAX_RESULTS = 200;
@@ -136,15 +138,28 @@ class SearcherService {
136
138
  /**
137
139
  * Refuse when the configured embedding model disagrees with the one that built
138
140
  * the index — embedding a query into a foreign vector space returns noise. A
139
- * missing `index_meta` (un-migrated DB / not yet indexed) does not block.
141
+ * missing `index_meta` row with an empty (or altogether absent) `code_chunk`
142
+ * does not block — but a *populated* `code_chunk` with no `index_meta` row is a
143
+ * pre-`index_meta` install, treated as the legacy `ollama`/`bge-m3` index (same
144
+ * interpretation as the `rag-index` legacy-Ollama guard), read-only — this check
145
+ * never writes `index_meta`.
140
146
  */
141
147
  async assertIndexMatches() {
142
148
  const meta = await this.db.readIndexMeta();
143
- if (!meta)
149
+ if (meta) {
150
+ this.compareIndexed(meta.provider, meta.model, meta.dimensions);
144
151
  return;
152
+ }
153
+ // No row — a missing code_chunk table counts as empty; any other DB error
154
+ // while counting propagates rather than being read as "not indexed".
155
+ if (await (0, legacy_ollama_index_1.hasIndexedChunks)(this.db)) {
156
+ this.compareIndexed(legacy_ollama_index_1.LEGACY_OLLAMA_IDENTITY.provider, legacy_ollama_index_1.LEGACY_OLLAMA_IDENTITY.model, null);
157
+ }
158
+ }
159
+ compareIndexed(indexedProvider, indexedModel, indexedDimensions) {
145
160
  const { provider, model } = this.config.embedding;
146
- if (meta.provider !== provider || meta.model !== model) {
147
- throw new Error(`Index was built with ${meta.provider}/${meta.model} but the config selects ${provider}/${model}. ` +
161
+ if (indexedProvider !== provider || indexedModel !== model) {
162
+ throw new Error(`Index was built with ${indexedProvider}/${indexedModel} but the config selects ${provider}/${model}. ` +
148
163
  `Run \`rag-index --reindex\` to rebuild the index for the new model.`);
149
164
  }
150
165
  // Same model but a changed dimension (e.g. a Matryoshka `embedding.dimensions`
@@ -152,9 +167,12 @@ class SearcherService {
152
167
  // would throw an opaque "different vector dimensions" error mid-query. Catch
153
168
  // it here with the friendly hint. `embedder.dimensions` is 0 when the width is
154
169
  // only knowable by probing, in which case we skip (can't compare cheaply).
170
+ // `indexedDimensions` is null for the legacy-inferred identity, whose provider
171
+ // can never match a configured provider (ollama is no longer selectable), so
172
+ // this branch is unreached in that case.
155
173
  const dim = this.embedder.dimensions;
156
- if (dim > 0 && dim !== meta.dimensions) {
157
- throw new Error(`Index was built at ${meta.dimensions} dimensions but ${provider}/${model} now resolves to ${dim}. ` +
174
+ if (indexedDimensions != null && dim > 0 && dim !== indexedDimensions) {
175
+ throw new Error(`Index was built at ${indexedDimensions} dimensions but ${provider}/${model} now resolves to ${dim}. ` +
158
176
  `Run \`rag-index --reindex\` to rebuild the index at the new dimension.`);
159
177
  }
160
178
  }
@@ -173,48 +191,112 @@ class SearcherService {
173
191
  const t0 = Date.now();
174
192
  await this.assertIndexMatches();
175
193
  const effectiveThreshold = threshold ?? this.config.search.similarityThreshold;
176
- const fetchLimit = this.embedder.rerank
177
- ? Math.min(limit * this.config.reranker.overfetch, 200)
194
+ const reranking = !!this.embedder.rerank;
195
+ // Candidate floor: decouple the pool size from `limit` so a small `limit`
196
+ // (e.g. the default 5) still gives the reranker a large-enough menu. Without
197
+ // a reranker the extra candidates are never reordered, so keep `limit`.
198
+ const fetchLimit = reranking
199
+ ? Math.min(Math.max(limit * this.config.reranker.overfetch, this.config.reranker.candidateFloor), 200)
178
200
  : limit;
201
+ // With metrics on, fetch one extra row to detect a saturated pool (more
202
+ // under-threshold candidates existed than the window took). The extra row is
203
+ // dropped before reranking so it never affects results.
204
+ const metricsOn = this.config.logging.searchMetrics;
205
+ const dbLimit = metricsOn ? fetchLimit + 1 : fetchLimit;
179
206
  this.logger.debug('SearcherService: searchChunks', {
180
207
  query: query.slice(0, 120), limit, fetchLimit, project, threshold: effectiveThreshold,
181
208
  });
209
+ const tEmbed0 = Date.now();
182
210
  const embedding = await this.embedder.embed(query, 'query');
183
- const params = [`[${embedding}]`, effectiveThreshold, fetchLimit];
211
+ const embedMs = Date.now() - tEmbed0;
212
+ const params = [`[${embedding}]`, effectiveThreshold, dbLimit];
184
213
  let projectFilter = '';
185
214
  if (project) {
186
215
  params.push(project);
187
216
  projectFilter = `AND "serviceName" = $${params.length}`;
188
217
  }
189
- const candidates = await this.db.query(`SELECT "serviceName", "filePath", "startLine", content, "ownerPath",
218
+ const tDb0 = Date.now();
219
+ const fetched = await this.db.query(`SELECT "serviceName", "filePath", "startLine", content, "ownerPath",
190
220
  ROUND((embedding <=> $1)::numeric, 4)::float8 AS score
191
221
  FROM code_chunk
192
222
  WHERE embedding <=> $1 < $2
193
223
  ${projectFilter}
194
224
  ORDER BY embedding <=> $1
195
225
  LIMIT $3`, params);
226
+ const dbMs = Date.now() - tDb0;
227
+ // Saturated iff the extra probe row came back — more candidates were under
228
+ // the threshold than `fetchLimit`. Trim the probe row before reranking.
229
+ const poolSaturated = metricsOn && fetched.length > fetchLimit;
230
+ const candidates = fetched.slice(0, fetchLimit);
196
231
  this.logger.debug('SearcherService: searchChunks candidates', {
197
232
  count: candidates.length,
198
233
  scores: candidates.map(r => r.score),
199
234
  files: candidates.map(r => r.filePath),
200
235
  ms: Date.now() - t0,
201
236
  });
202
- if (!this.embedder.rerank || candidates.length === 0)
203
- return candidates;
204
- try {
205
- const indices = await this.embedder.rerank(query, candidates.map(r => r.content), limit);
206
- this.logger.debug('SearcherService: searchChunks rerank done', { indices, ms: Date.now() - t0 });
207
- // Cap at `limit`: some rerank servers ignore `top_n` and echo back every
208
- // candidate, so slicing here keeps the result count within the request.
209
- return indices.filter(i => i >= 0 && i < candidates.length).slice(0, limit).map(i => candidates[i]);
237
+ let results;
238
+ let rerankMs = 0;
239
+ if (!reranking || candidates.length === 0) {
240
+ results = candidates.slice(0, limit);
241
+ }
242
+ else {
243
+ const tRerank0 = Date.now();
244
+ try {
245
+ const indices = await this.embedder.rerank(query, candidates.map(r => r.content), limit);
246
+ rerankMs = Date.now() - tRerank0;
247
+ this.logger.debug('SearcherService: searchChunks rerank done', { indices, ms: Date.now() - t0 });
248
+ // Cap at `limit`: some rerank servers ignore `top_n` and echo back every
249
+ // candidate, so slicing here keeps the result count within the request.
250
+ results = indices.filter(i => i >= 0 && i < candidates.length).slice(0, limit).map(i => candidates[i]);
251
+ }
252
+ catch (err) {
253
+ rerankMs = Date.now() - tRerank0;
254
+ this.logger.warn('SearcherService: rerank failed, falling back to vector order', {
255
+ reason: err instanceof Error ? err.message : String(err),
256
+ ms: Date.now() - t0,
257
+ });
258
+ results = candidates.slice(0, limit);
259
+ }
210
260
  }
211
- catch (err) {
212
- this.logger.warn('SearcherService: rerank failed, falling back to vector order', {
213
- reason: err instanceof Error ? err.message : String(err),
214
- ms: Date.now() - t0,
261
+ if (metricsOn) {
262
+ this.emitSearchMetrics(query, {
263
+ fetchLimit, candidates, results, poolSaturated, embedMs, dbMs, rerankMs, totalMs: Date.now() - t0,
215
264
  });
216
- return candidates.slice(0, limit);
217
265
  }
266
+ return results;
267
+ }
268
+ /**
269
+ * Emit one structured search-tuning line (via `logger.metric`, at `info`
270
+ * regardless of `logging.level`). `candidates` are in cosine order, so a
271
+ * candidate's array index is its cosine rank — `maxCosineRankSurvived` is the
272
+ * deepest cosine position that survived reranking into the returned set. Read
273
+ * it alongside `poolSaturated` to tune `candidateFloor`/`overfetch`/threshold
274
+ * per hardware. Never logs chunk content, embeddings, or credentials.
275
+ */
276
+ emitSearchMetrics(query, m) {
277
+ const { candidates, results } = m;
278
+ const contentIndex = new Map(candidates.map((c, i) => [c, i]));
279
+ const maxCosineRankSurvived = results.reduce((max, r) => Math.max(max, contentIndex.get(r) ?? -1), -1);
280
+ const fileCounts = new Map();
281
+ for (const r of results)
282
+ fileCounts.set(r.filePath, (fileCounts.get(r.filePath) ?? 0) + 1);
283
+ const topFileCount = results.length ? Math.max(...fileCounts.values()) : 0;
284
+ this.logger.metric('search metrics', {
285
+ query: query.slice(0, 120),
286
+ fetchLimit: m.fetchLimit,
287
+ candidatesReturned: candidates.length,
288
+ poolSaturated: m.poolSaturated,
289
+ maxCosineRankSurvived,
290
+ resultCount: results.length,
291
+ distinctFilesInResult: fileCounts.size,
292
+ topFileShare: results.length ? +(topFileCount / results.length).toFixed(3) : 0,
293
+ bestResultScore: results.length ? Math.min(...results.map(r => r.score)) : null,
294
+ worstResultScore: results.length ? Math.max(...results.map(r => r.score)) : null,
295
+ embedMs: m.embedMs,
296
+ rerankMs: m.rerankMs,
297
+ dbMs: m.dbMs,
298
+ totalMs: m.totalMs,
299
+ });
218
300
  }
219
301
  async fetchNeighbors(results) {
220
302
  const t0 = Date.now();