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.
- package/CHANGELOG.md +24 -1
- package/README.md +141 -39
- package/dist/app.js +2 -2
- package/dist/src/commands/IndexCommand.js +42 -1
- package/dist/src/config-error.d.ts +7 -0
- package/dist/src/config-error.js +14 -0
- package/dist/src/config-store.d.ts +30 -12
- package/dist/src/config-store.js +81 -26
- package/dist/src/config.d.ts +5 -4
- package/dist/src/config.js +14 -16
- package/dist/src/constants.d.ts +8 -2
- package/dist/src/constants.js +8 -5
- package/dist/src/help.js +1 -1
- package/dist/src/interfaces/IDatabase.d.ts +17 -0
- package/dist/src/interfaces/ILogger.d.ts +7 -0
- package/dist/src/services/AstChunker.d.ts +11 -0
- package/dist/src/services/AstChunker.js +44 -0
- package/dist/src/services/DatabaseService.d.ts +4 -0
- package/dist/src/services/DatabaseService.js +46 -0
- package/dist/src/services/EmbedderFactory.d.ts +1 -1
- package/dist/src/services/EmbedderFactory.js +2 -5
- package/dist/src/services/IndexerService.d.ts +4 -0
- package/dist/src/services/IndexerService.js +27 -7
- package/dist/src/services/InfinityEmbedder.d.ts +14 -55
- package/dist/src/services/InfinityEmbedder.js +30 -141
- package/dist/src/services/LoggerService.d.ts +3 -1
- package/dist/src/services/LoggerService.js +17 -3
- package/dist/src/services/SearcherService.d.ts +15 -1
- package/dist/src/services/SearcherService.js +105 -23
- package/dist/src/services/WebServerService.d.ts +41 -3
- package/dist/src/services/WebServerService.js +90 -2
- package/dist/src/services/infinity-provisioning.d.ts +115 -0
- package/dist/src/services/infinity-provisioning.js +516 -0
- package/dist/src/services/legacy-ollama-index.d.ts +24 -0
- package/dist/src/services/legacy-ollama-index.js +61 -0
- package/dist/src/services/reranker.d.ts +3 -3
- package/dist/src/services/reranker.js +3 -3
- package/dist/src/setup.d.ts +37 -0
- package/dist/src/setup.js +111 -157
- package/docker/docker-compose.yml +5 -15
- package/docs/web-dashboard.md +36 -17
- package/package.json +1 -5
- package/dist/src/services/OllamaEmbedder.d.ts +0 -25
- 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
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
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
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
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`
|
|
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 (
|
|
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 (
|
|
147
|
-
throw new Error(`Index was built with ${
|
|
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 !==
|
|
157
|
-
throw new Error(`Index was built at ${
|
|
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
|
|
177
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
this.
|
|
213
|
-
|
|
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();
|