codesearch-mcp 0.1.0-alpha → 0.2.1-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +146 -41
- package/dist/app.js +4 -19
- package/dist/src/commands/IndexCommand.js +68 -7
- 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/db/index.d.ts +1 -1
- package/dist/src/db/index.js +2 -1
- package/dist/src/db/schema.d.ts +75 -0
- package/dist/src/db/schema.js +10 -1
- package/dist/src/enriched-prompt.d.ts +8 -0
- package/dist/src/enriched-prompt.js +87 -0
- package/dist/src/help.js +1 -1
- package/dist/src/interfaces/IAstParser.d.ts +18 -2
- package/dist/src/interfaces/IDatabase.d.ts +31 -0
- package/dist/src/interfaces/ILogger.d.ts +7 -0
- package/dist/src/interfaces/ISearcher.d.ts +3 -0
- package/dist/src/services/AstChunker.d.ts +11 -0
- package/dist/src/services/AstChunker.js +44 -0
- package/dist/src/services/AstParserService.d.ts +39 -7
- package/dist/src/services/AstParserService.js +504 -104
- package/dist/src/services/DatabaseService.d.ts +17 -0
- package/dist/src/services/DatabaseService.js +130 -2
- 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 -14
- package/dist/src/services/IndexerService.js +54 -156
- 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/McpServer.js +3 -3
- package/dist/src/services/SearcherService.d.ts +39 -4
- package/dist/src/services/SearcherService.js +254 -81
- 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/dist/src/symbol-name.d.ts +44 -0
- package/dist/src/symbol-name.js +68 -0
- package/dist/src/symbol-rows.d.ts +12 -0
- package/dist/src/symbol-rows.js +38 -0
- package/dist/src/tools/FindSymbolTool.js +30 -7
- package/dist/src/tools/GetCallersTool.js +20 -5
- package/docker/docker-compose.yml +5 -15
- package/docs/architecture.svg +103 -0
- package/docs/web-dashboard.md +36 -17
- package/package.json +1 -6
- package/dist/src/commands/BackfillCommand.d.ts +0 -7
- package/dist/src/commands/BackfillCommand.js +0 -37
- package/dist/src/services/OllamaEmbedder.d.ts +0 -25
- package/dist/src/services/OllamaEmbedder.js +0 -111
|
@@ -40,11 +40,14 @@ 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");
|
|
44
|
+
const symbol_name_1 = require("../symbol-name");
|
|
43
45
|
const SILENT_LOGGER = {
|
|
44
46
|
debug() { },
|
|
45
47
|
info() { },
|
|
46
48
|
warn() { },
|
|
47
49
|
error() { },
|
|
50
|
+
metric() { },
|
|
48
51
|
};
|
|
49
52
|
const EMPTY_META = { description: null, stack: null, notes: null, relations: null };
|
|
50
53
|
const GREP_MAX_RESULTS = 200;
|
|
@@ -136,15 +139,28 @@ class SearcherService {
|
|
|
136
139
|
/**
|
|
137
140
|
* Refuse when the configured embedding model disagrees with the one that built
|
|
138
141
|
* the index — embedding a query into a foreign vector space returns noise. A
|
|
139
|
-
* missing `index_meta`
|
|
142
|
+
* missing `index_meta` row with an empty (or altogether absent) `code_chunk`
|
|
143
|
+
* does not block — but a *populated* `code_chunk` with no `index_meta` row is a
|
|
144
|
+
* pre-`index_meta` install, treated as the legacy `ollama`/`bge-m3` index (same
|
|
145
|
+
* interpretation as the `rag-index` legacy-Ollama guard), read-only — this check
|
|
146
|
+
* never writes `index_meta`.
|
|
140
147
|
*/
|
|
141
148
|
async assertIndexMatches() {
|
|
142
149
|
const meta = await this.db.readIndexMeta();
|
|
143
|
-
if (
|
|
150
|
+
if (meta) {
|
|
151
|
+
this.compareIndexed(meta.provider, meta.model, meta.dimensions);
|
|
144
152
|
return;
|
|
153
|
+
}
|
|
154
|
+
// No row — a missing code_chunk table counts as empty; any other DB error
|
|
155
|
+
// while counting propagates rather than being read as "not indexed".
|
|
156
|
+
if (await (0, legacy_ollama_index_1.hasIndexedChunks)(this.db)) {
|
|
157
|
+
this.compareIndexed(legacy_ollama_index_1.LEGACY_OLLAMA_IDENTITY.provider, legacy_ollama_index_1.LEGACY_OLLAMA_IDENTITY.model, null);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
compareIndexed(indexedProvider, indexedModel, indexedDimensions) {
|
|
145
161
|
const { provider, model } = this.config.embedding;
|
|
146
|
-
if (
|
|
147
|
-
throw new Error(`Index was built with ${
|
|
162
|
+
if (indexedProvider !== provider || indexedModel !== model) {
|
|
163
|
+
throw new Error(`Index was built with ${indexedProvider}/${indexedModel} but the config selects ${provider}/${model}. ` +
|
|
148
164
|
`Run \`rag-index --reindex\` to rebuild the index for the new model.`);
|
|
149
165
|
}
|
|
150
166
|
// Same model but a changed dimension (e.g. a Matryoshka `embedding.dimensions`
|
|
@@ -152,9 +168,12 @@ class SearcherService {
|
|
|
152
168
|
// would throw an opaque "different vector dimensions" error mid-query. Catch
|
|
153
169
|
// it here with the friendly hint. `embedder.dimensions` is 0 when the width is
|
|
154
170
|
// only knowable by probing, in which case we skip (can't compare cheaply).
|
|
171
|
+
// `indexedDimensions` is null for the legacy-inferred identity, whose provider
|
|
172
|
+
// can never match a configured provider (ollama is no longer selectable), so
|
|
173
|
+
// this branch is unreached in that case.
|
|
155
174
|
const dim = this.embedder.dimensions;
|
|
156
|
-
if (dim > 0 && dim !==
|
|
157
|
-
throw new Error(`Index was built at ${
|
|
175
|
+
if (indexedDimensions != null && dim > 0 && dim !== indexedDimensions) {
|
|
176
|
+
throw new Error(`Index was built at ${indexedDimensions} dimensions but ${provider}/${model} now resolves to ${dim}. ` +
|
|
158
177
|
`Run \`rag-index --reindex\` to rebuild the index at the new dimension.`);
|
|
159
178
|
}
|
|
160
179
|
}
|
|
@@ -173,48 +192,112 @@ class SearcherService {
|
|
|
173
192
|
const t0 = Date.now();
|
|
174
193
|
await this.assertIndexMatches();
|
|
175
194
|
const effectiveThreshold = threshold ?? this.config.search.similarityThreshold;
|
|
176
|
-
const
|
|
177
|
-
|
|
195
|
+
const reranking = !!this.embedder.rerank;
|
|
196
|
+
// Candidate floor: decouple the pool size from `limit` so a small `limit`
|
|
197
|
+
// (e.g. the default 5) still gives the reranker a large-enough menu. Without
|
|
198
|
+
// a reranker the extra candidates are never reordered, so keep `limit`.
|
|
199
|
+
const fetchLimit = reranking
|
|
200
|
+
? Math.min(Math.max(limit * this.config.reranker.overfetch, this.config.reranker.candidateFloor), 200)
|
|
178
201
|
: limit;
|
|
202
|
+
// With metrics on, fetch one extra row to detect a saturated pool (more
|
|
203
|
+
// under-threshold candidates existed than the window took). The extra row is
|
|
204
|
+
// dropped before reranking so it never affects results.
|
|
205
|
+
const metricsOn = this.config.logging.searchMetrics;
|
|
206
|
+
const dbLimit = metricsOn ? fetchLimit + 1 : fetchLimit;
|
|
179
207
|
this.logger.debug('SearcherService: searchChunks', {
|
|
180
208
|
query: query.slice(0, 120), limit, fetchLimit, project, threshold: effectiveThreshold,
|
|
181
209
|
});
|
|
210
|
+
const tEmbed0 = Date.now();
|
|
182
211
|
const embedding = await this.embedder.embed(query, 'query');
|
|
183
|
-
const
|
|
212
|
+
const embedMs = Date.now() - tEmbed0;
|
|
213
|
+
const params = [`[${embedding}]`, effectiveThreshold, dbLimit];
|
|
184
214
|
let projectFilter = '';
|
|
185
215
|
if (project) {
|
|
186
216
|
params.push(project);
|
|
187
217
|
projectFilter = `AND "serviceName" = $${params.length}`;
|
|
188
218
|
}
|
|
189
|
-
const
|
|
219
|
+
const tDb0 = Date.now();
|
|
220
|
+
const fetched = await this.db.query(`SELECT "serviceName", "filePath", "startLine", content, "ownerPath",
|
|
190
221
|
ROUND((embedding <=> $1)::numeric, 4)::float8 AS score
|
|
191
222
|
FROM code_chunk
|
|
192
223
|
WHERE embedding <=> $1 < $2
|
|
193
224
|
${projectFilter}
|
|
194
225
|
ORDER BY embedding <=> $1
|
|
195
226
|
LIMIT $3`, params);
|
|
227
|
+
const dbMs = Date.now() - tDb0;
|
|
228
|
+
// Saturated iff the extra probe row came back — more candidates were under
|
|
229
|
+
// the threshold than `fetchLimit`. Trim the probe row before reranking.
|
|
230
|
+
const poolSaturated = metricsOn && fetched.length > fetchLimit;
|
|
231
|
+
const candidates = fetched.slice(0, fetchLimit);
|
|
196
232
|
this.logger.debug('SearcherService: searchChunks candidates', {
|
|
197
233
|
count: candidates.length,
|
|
198
234
|
scores: candidates.map(r => r.score),
|
|
199
235
|
files: candidates.map(r => r.filePath),
|
|
200
236
|
ms: Date.now() - t0,
|
|
201
237
|
});
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
238
|
+
let results;
|
|
239
|
+
let rerankMs = 0;
|
|
240
|
+
if (!reranking || candidates.length === 0) {
|
|
241
|
+
results = candidates.slice(0, limit);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
const tRerank0 = Date.now();
|
|
245
|
+
try {
|
|
246
|
+
const indices = await this.embedder.rerank(query, candidates.map(r => r.content), limit);
|
|
247
|
+
rerankMs = Date.now() - tRerank0;
|
|
248
|
+
this.logger.debug('SearcherService: searchChunks rerank done', { indices, ms: Date.now() - t0 });
|
|
249
|
+
// Cap at `limit`: some rerank servers ignore `top_n` and echo back every
|
|
250
|
+
// candidate, so slicing here keeps the result count within the request.
|
|
251
|
+
results = indices.filter(i => i >= 0 && i < candidates.length).slice(0, limit).map(i => candidates[i]);
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
rerankMs = Date.now() - tRerank0;
|
|
255
|
+
this.logger.warn('SearcherService: rerank failed, falling back to vector order', {
|
|
256
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
257
|
+
ms: Date.now() - t0,
|
|
258
|
+
});
|
|
259
|
+
results = candidates.slice(0, limit);
|
|
260
|
+
}
|
|
210
261
|
}
|
|
211
|
-
|
|
212
|
-
this.
|
|
213
|
-
|
|
214
|
-
ms: Date.now() - t0,
|
|
262
|
+
if (metricsOn) {
|
|
263
|
+
this.emitSearchMetrics(query, {
|
|
264
|
+
fetchLimit, candidates, results, poolSaturated, embedMs, dbMs, rerankMs, totalMs: Date.now() - t0,
|
|
215
265
|
});
|
|
216
|
-
return candidates.slice(0, limit);
|
|
217
266
|
}
|
|
267
|
+
return results;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Emit one structured search-tuning line (via `logger.metric`, at `info`
|
|
271
|
+
* regardless of `logging.level`). `candidates` are in cosine order, so a
|
|
272
|
+
* candidate's array index is its cosine rank — `maxCosineRankSurvived` is the
|
|
273
|
+
* deepest cosine position that survived reranking into the returned set. Read
|
|
274
|
+
* it alongside `poolSaturated` to tune `candidateFloor`/`overfetch`/threshold
|
|
275
|
+
* per hardware. Never logs chunk content, embeddings, or credentials.
|
|
276
|
+
*/
|
|
277
|
+
emitSearchMetrics(query, m) {
|
|
278
|
+
const { candidates, results } = m;
|
|
279
|
+
const contentIndex = new Map(candidates.map((c, i) => [c, i]));
|
|
280
|
+
const maxCosineRankSurvived = results.reduce((max, r) => Math.max(max, contentIndex.get(r) ?? -1), -1);
|
|
281
|
+
const fileCounts = new Map();
|
|
282
|
+
for (const r of results)
|
|
283
|
+
fileCounts.set(r.filePath, (fileCounts.get(r.filePath) ?? 0) + 1);
|
|
284
|
+
const topFileCount = results.length ? Math.max(...fileCounts.values()) : 0;
|
|
285
|
+
this.logger.metric('search metrics', {
|
|
286
|
+
query: query.slice(0, 120),
|
|
287
|
+
fetchLimit: m.fetchLimit,
|
|
288
|
+
candidatesReturned: candidates.length,
|
|
289
|
+
poolSaturated: m.poolSaturated,
|
|
290
|
+
maxCosineRankSurvived,
|
|
291
|
+
resultCount: results.length,
|
|
292
|
+
distinctFilesInResult: fileCounts.size,
|
|
293
|
+
topFileShare: results.length ? +(topFileCount / results.length).toFixed(3) : 0,
|
|
294
|
+
bestResultScore: results.length ? Math.min(...results.map(r => r.score)) : null,
|
|
295
|
+
worstResultScore: results.length ? Math.max(...results.map(r => r.score)) : null,
|
|
296
|
+
embedMs: m.embedMs,
|
|
297
|
+
rerankMs: m.rerankMs,
|
|
298
|
+
dbMs: m.dbMs,
|
|
299
|
+
totalMs: m.totalMs,
|
|
300
|
+
});
|
|
218
301
|
}
|
|
219
302
|
async fetchNeighbors(results) {
|
|
220
303
|
const t0 = Date.now();
|
|
@@ -390,7 +473,22 @@ class SearcherService {
|
|
|
390
473
|
async getCallers(symbol, project, calleeFile = null) {
|
|
391
474
|
const t0 = Date.now();
|
|
392
475
|
this.logger.debug('SearcherService: getCallers', { symbol, project, calleeFile });
|
|
393
|
-
const
|
|
476
|
+
const parsed = (0, symbol_name_1.parseCallerSymbol)(symbol);
|
|
477
|
+
if (!parsed)
|
|
478
|
+
throw new Error('Invalid symbol name');
|
|
479
|
+
const params = [project];
|
|
480
|
+
let nameFilter;
|
|
481
|
+
if (parsed.form === 'bare') {
|
|
482
|
+
params.push(parsed.name);
|
|
483
|
+
nameFilter = `"calleeName" = $${params.length}`;
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
params.push(`${parsed.container}.${parsed.name}`);
|
|
487
|
+
const exactParam = params.length;
|
|
488
|
+
params.push(`%.${(0, symbol_name_1.escapeLike)(parsed.container)}.${(0, symbol_name_1.escapeLike)(parsed.name)}`);
|
|
489
|
+
const suffixParam = params.length;
|
|
490
|
+
nameFilter = `("calleeSymbol" = $${exactParam} OR "calleeSymbol" LIKE $${suffixParam} ESCAPE '\\')`;
|
|
491
|
+
}
|
|
394
492
|
let fileFilter = '';
|
|
395
493
|
if (calleeFile) {
|
|
396
494
|
params.push(calleeFile);
|
|
@@ -398,13 +496,32 @@ class SearcherService {
|
|
|
398
496
|
}
|
|
399
497
|
const rows = await this.db.query(`SELECT "fromFile", "fromSymbol", "calleeName", "calleeFile", line
|
|
400
498
|
FROM code_call
|
|
401
|
-
WHERE
|
|
499
|
+
WHERE project = $1 AND ${nameFilter}
|
|
402
500
|
${fileFilter}
|
|
403
501
|
ORDER BY "fromFile", line
|
|
404
502
|
LIMIT 200`, params);
|
|
405
503
|
this.logger.debug('SearcherService: getCallers result', { count: rows.length, ms: Date.now() - t0 });
|
|
406
504
|
return rows;
|
|
407
505
|
}
|
|
506
|
+
/**
|
|
507
|
+
* One-line warning when any project in `scope` (the given `project`, or
|
|
508
|
+
* every project with `code_chunk`/`code_symbol` rows) is outdated — shown
|
|
509
|
+
* first in `find_symbol`/`get_callers` output. Any failure to determine the
|
|
510
|
+
* scope or read markers only drops the notice (warn logged); the lookup
|
|
511
|
+
* itself is unaffected.
|
|
512
|
+
*/
|
|
513
|
+
async outdatedNotice(project) {
|
|
514
|
+
try {
|
|
515
|
+
const outdated = await this.db.listOutdatedProjects(project ? [project] : null);
|
|
516
|
+
if (outdated.length === 0)
|
|
517
|
+
return null;
|
|
518
|
+
return `⚠ Indexed before member indexing: ${[...outdated].sort().join(', ')} — run rag-index --reindex; methods and qualified names may be missing.`;
|
|
519
|
+
}
|
|
520
|
+
catch (err) {
|
|
521
|
+
this.logger.warn('SearcherService: failed to determine outdated-index scope', { reason: err.message });
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
408
525
|
async grepSymbol(symbol, project, excludeFile) {
|
|
409
526
|
const t0 = Date.now();
|
|
410
527
|
this.logger.debug('SearcherService: grepSymbol', { symbol, project, excludeFile });
|
|
@@ -417,11 +534,11 @@ class SearcherService {
|
|
|
417
534
|
symbol,
|
|
418
535
|
projectDir,
|
|
419
536
|
]);
|
|
420
|
-
if (!result) {
|
|
537
|
+
if (!result.ok || result.output === null) {
|
|
421
538
|
return { matches: [], truncated: false };
|
|
422
539
|
}
|
|
423
540
|
const excludeAbsPath = path.join(this.config.workspaceRoot, excludeFile);
|
|
424
|
-
const all = result.split('\n').flatMap(line => {
|
|
541
|
+
const all = result.output.split('\n').flatMap(line => {
|
|
425
542
|
const match = line.match(/^(.+?):(\d+):/);
|
|
426
543
|
if (!match)
|
|
427
544
|
return [];
|
|
@@ -435,19 +552,29 @@ class SearcherService {
|
|
|
435
552
|
return { matches: all.slice(0, GREP_MAX_RESULTS), truncated };
|
|
436
553
|
}
|
|
437
554
|
// Sync wrapper: validates name (throws synchronously), then returns Promise.
|
|
438
|
-
//
|
|
555
|
+
// Every kind, including 'method', is looked up in the index first.
|
|
439
556
|
findSymbol(name, project, kind) {
|
|
440
|
-
|
|
557
|
+
const parsed = (0, symbol_name_1.parseFindSymbolName)(name);
|
|
558
|
+
if (!parsed)
|
|
441
559
|
throw new Error('Invalid symbol name');
|
|
442
|
-
|
|
443
|
-
return this.grepFindSymbol(name, project, kind);
|
|
444
|
-
return this.findSymbolFromIndex(name, project, kind);
|
|
560
|
+
return this.findSymbolFromIndex(parsed, project, kind);
|
|
445
561
|
}
|
|
446
|
-
async findSymbolFromIndex(
|
|
562
|
+
async findSymbolFromIndex(parsed, project, kind) {
|
|
447
563
|
const t0 = Date.now();
|
|
448
|
-
this.logger.debug('SearcherService: findSymbol', {
|
|
564
|
+
this.logger.debug('SearcherService: findSymbol', { parsed, project, kind, source: 'index' });
|
|
565
|
+
const container = 'container' in parsed ? parsed.container : null;
|
|
449
566
|
try {
|
|
450
|
-
const params = [name];
|
|
567
|
+
const params = [parsed.name];
|
|
568
|
+
let nameFilter = 'name = $1';
|
|
569
|
+
let orderPrefix = '';
|
|
570
|
+
if (container !== null) {
|
|
571
|
+
params.push(container);
|
|
572
|
+
const exactParam = params.length;
|
|
573
|
+
params.push(`%.${(0, symbol_name_1.escapeLike)(container)}`);
|
|
574
|
+
const suffixParam = params.length;
|
|
575
|
+
nameFilter += ` AND (container = $${exactParam} OR container LIKE $${suffixParam} ESCAPE '\\')`;
|
|
576
|
+
orderPrefix = `(CASE WHEN container = $${exactParam} THEN 0 ELSE 1 END), `;
|
|
577
|
+
}
|
|
451
578
|
let projectFilter = '';
|
|
452
579
|
if (project) {
|
|
453
580
|
params.push(project);
|
|
@@ -459,11 +586,11 @@ class SearcherService {
|
|
|
459
586
|
kindFilter = `AND kind = $${params.length}`;
|
|
460
587
|
}
|
|
461
588
|
params.push(FIND_SYMBOL_LIMIT + 1);
|
|
462
|
-
const rows = await this.db.query(`SELECT "filePath", name, kind, signature, line, col
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
589
|
+
const rows = await this.db.query(`SELECT "filePath", name, kind, signature, line, col, container, "isStatic"
|
|
590
|
+
FROM code_symbol
|
|
591
|
+
WHERE ${nameFilter} ${projectFilter} ${kindFilter}
|
|
592
|
+
ORDER BY ${orderPrefix}project, "filePath", line
|
|
593
|
+
LIMIT $${params.length}`, params);
|
|
467
594
|
const truncated = rows.length > FIND_SYMBOL_LIMIT;
|
|
468
595
|
const hits = rows.slice(0, FIND_SYMBOL_LIMIT).map(row => ({
|
|
469
596
|
filePath: row.filePath,
|
|
@@ -471,22 +598,34 @@ class SearcherService {
|
|
|
471
598
|
column: row.col ?? 1,
|
|
472
599
|
kind: row.kind,
|
|
473
600
|
signature: row.signature ?? row.name,
|
|
601
|
+
qualified: row.container ? `${row.container}.${row.name}` : row.name,
|
|
602
|
+
isStatic: !!row.isStatic,
|
|
603
|
+
source: 'index',
|
|
474
604
|
}));
|
|
475
605
|
this.logger.debug('SearcherService: findSymbol result', { source: 'index', hits: hits.length, truncated, ms: Date.now() - t0 });
|
|
476
|
-
|
|
606
|
+
if (hits.length > 0)
|
|
607
|
+
return { hits, truncated, grepFailed: false };
|
|
608
|
+
if (parsed.form === 'quoted' || parsed.form === 'computed')
|
|
609
|
+
return { hits: [], truncated: false, grepFailed: false };
|
|
610
|
+
return this.grepFindSymbol(parsed, project, kind);
|
|
477
611
|
}
|
|
478
|
-
catch {
|
|
479
|
-
this.logger.warn('SearcherService: symbol index unavailable, falling back to grep', {
|
|
480
|
-
|
|
612
|
+
catch (err) {
|
|
613
|
+
this.logger.warn('SearcherService: symbol index unavailable, falling back to grep', { reason: err.message, ms: Date.now() - t0 });
|
|
614
|
+
if (parsed.form === 'quoted' || parsed.form === 'computed')
|
|
615
|
+
return { hits: [], truncated: false, grepFailed: false };
|
|
616
|
+
return this.grepFindSymbol(parsed, project, kind);
|
|
481
617
|
}
|
|
482
618
|
}
|
|
483
|
-
async grepFindSymbol(
|
|
619
|
+
async grepFindSymbol(parsed, project, kind) {
|
|
484
620
|
const t0 = Date.now();
|
|
485
|
-
|
|
621
|
+
const grepName = parsed.name; // qualified names are grepped by their last segment, no container filter
|
|
622
|
+
this.logger.debug('SearcherService: findSymbol', { grepName, project, kind, source: 'grep' });
|
|
623
|
+
const pattern = this.buildFindSymbolPattern(grepName, kind);
|
|
624
|
+
if (pattern === null)
|
|
625
|
+
return { hits: [], truncated: false, grepFailed: false };
|
|
486
626
|
const searchDir = project
|
|
487
627
|
? this.resolveProjectRoot(project)
|
|
488
628
|
: this.config.workspaceRoot;
|
|
489
|
-
const pattern = this.buildFindSymbolPattern(name, kind);
|
|
490
629
|
const result = await this.spawnGrep([
|
|
491
630
|
'-rn', '-E',
|
|
492
631
|
'-m', String(FIND_SYMBOL_RAW_LIMIT),
|
|
@@ -495,12 +634,14 @@ class SearcherService {
|
|
|
495
634
|
pattern,
|
|
496
635
|
searchDir,
|
|
497
636
|
]);
|
|
498
|
-
if (!result)
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
637
|
+
if (!result.ok) {
|
|
638
|
+
this.logger.warn('SearcherService: findSymbol grep failed', { grepName, project });
|
|
639
|
+
return { hits: [], truncated: false, grepFailed: true };
|
|
640
|
+
}
|
|
641
|
+
if (result.output === null)
|
|
642
|
+
return { hits: [], truncated: false, grepFailed: false };
|
|
643
|
+
const parsedHits = [];
|
|
644
|
+
for (const raw of result.output.split('\n')) {
|
|
504
645
|
const m = raw.match(/^(.+?):(\d+):(.*)$/);
|
|
505
646
|
if (!m)
|
|
506
647
|
continue;
|
|
@@ -508,19 +649,23 @@ class SearcherService {
|
|
|
508
649
|
const lineNum = parseInt(lineNumStr, 10);
|
|
509
650
|
if (isNaN(lineNum))
|
|
510
651
|
continue;
|
|
511
|
-
const col =
|
|
652
|
+
const col = SearcherService.wholeWordIndex(matchedLine, grepName);
|
|
512
653
|
if (col === -1)
|
|
513
654
|
continue;
|
|
514
|
-
|
|
655
|
+
parsedHits.push({
|
|
515
656
|
filePath: path.relative(this.config.workspaceRoot, absFile),
|
|
516
657
|
line: lineNum,
|
|
517
658
|
column: col + 1,
|
|
518
|
-
kind: this.inferKind(matchedLine,
|
|
659
|
+
kind: this.inferKind(matchedLine, grepName),
|
|
519
660
|
signature: matchedLine.trim(),
|
|
661
|
+
qualified: grepName,
|
|
662
|
+
isStatic: false,
|
|
663
|
+
source: 'grep',
|
|
520
664
|
});
|
|
521
665
|
}
|
|
522
|
-
|
|
523
|
-
|
|
666
|
+
const truncated = parsedHits.length > FIND_SYMBOL_LIMIT;
|
|
667
|
+
this.logger.debug('SearcherService: findSymbol result', { source: 'grep', hits: Math.min(parsedHits.length, FIND_SYMBOL_LIMIT), truncated, ms: Date.now() - t0 });
|
|
668
|
+
return { hits: parsedHits.slice(0, FIND_SYMBOL_LIMIT), truncated, grepFailed: false };
|
|
524
669
|
}
|
|
525
670
|
listFiles(relativePath, project, depth, pattern) {
|
|
526
671
|
const t0 = Date.now();
|
|
@@ -658,36 +803,67 @@ class SearcherService {
|
|
|
658
803
|
}
|
|
659
804
|
}
|
|
660
805
|
}
|
|
806
|
+
/**
|
|
807
|
+
* ERE pattern for a grep-fallback kind, or null when that kind is never
|
|
808
|
+
* grepped (`function_property`/`method_signature` are index-only; a bare
|
|
809
|
+
* `constructor` search only makes sense when the name itself is `constructor`).
|
|
810
|
+
*/
|
|
661
811
|
buildFindSymbolPattern(name, kind) {
|
|
812
|
+
if (kind === 'function_property' || kind === 'method_signature')
|
|
813
|
+
return null;
|
|
814
|
+
if (kind === 'constructor')
|
|
815
|
+
return name === 'constructor' ? `(^|[[:space:]])constructor[[:space:]]*\\(` : null;
|
|
816
|
+
const esc = name.replace(/\$/g, '\\$'); // ERE end-of-line metachar
|
|
817
|
+
const MODIFIER = '(public|private|protected|static|abstract|async|override)';
|
|
818
|
+
const BOUNDARY = '(^|[^[:alnum:]_$])';
|
|
662
819
|
const patterns = {
|
|
663
|
-
function: `(export[[:space:]]+)?(async[[:space:]]+)?function[[:space:]]+${
|
|
664
|
-
class: `(export[[:space:]]+)?(abstract[[:space:]]+)?class[[:space:]]+${
|
|
665
|
-
type: `(export[[:space:]]+)?type[[:space:]]+${
|
|
666
|
-
interface: `(export[[:space:]]+)?interface[[:space:]]+${
|
|
667
|
-
variable: `(export[[:space:]]+)?(const|let|var)[[:space:]]+${
|
|
668
|
-
method:
|
|
820
|
+
function: `(export[[:space:]]+)?(async[[:space:]]+)?function[[:space:]]+${esc}[[:space:](<]`,
|
|
821
|
+
class: `(export[[:space:]]+)?(abstract[[:space:]]+)?class[[:space:]]+${esc}[[:space:]{<]`,
|
|
822
|
+
type: `(export[[:space:]]+)?type[[:space:]]+${esc}[[:space:]=<]`,
|
|
823
|
+
interface: `(export[[:space:]]+)?interface[[:space:]]+${esc}[[:space:]{<]`,
|
|
824
|
+
variable: `(export[[:space:]]+)?(const|let|var)[[:space:]]+${esc}[[:space:]=:]`,
|
|
825
|
+
method: `${BOUNDARY}(${MODIFIER}[[:space:]]+)+\\*?[[:space:]]*${esc}[[:space:]]*[(<]`,
|
|
826
|
+
get_accessor: `(^|[[:space:]])get[[:space:]]+${esc}[[:space:]]*\\(`,
|
|
827
|
+
set_accessor: `(^|[[:space:]])set[[:space:]]+${esc}[[:space:]]*\\(`,
|
|
669
828
|
};
|
|
670
829
|
if (kind === 'all')
|
|
671
830
|
return Object.values(patterns).join('|');
|
|
672
831
|
return patterns[kind];
|
|
673
832
|
}
|
|
833
|
+
/** 1-based index of the first whole-word occurrence of `name` in `line`, or -1. */
|
|
834
|
+
static wholeWordIndex(line, name) {
|
|
835
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
836
|
+
const re = new RegExp(`(?<![\\w$#])${escaped}(?![\\w$#])`);
|
|
837
|
+
const m = re.exec(line);
|
|
838
|
+
return m ? m.index : -1;
|
|
839
|
+
}
|
|
674
840
|
inferKind(line, name) {
|
|
675
|
-
|
|
676
|
-
|
|
841
|
+
if (name === 'constructor')
|
|
842
|
+
return 'constructor';
|
|
843
|
+
const idx = SearcherService.wholeWordIndex(line, name);
|
|
844
|
+
const before = idx === -1 ? line : line.slice(0, idx);
|
|
845
|
+
const tokens = before.trim().split(/\s+/).filter(Boolean);
|
|
846
|
+
const last = (tokens[tokens.length - 1] ?? '').replace(/\*$/, '');
|
|
847
|
+
if (last === 'get')
|
|
848
|
+
return 'get_accessor';
|
|
849
|
+
if (last === 'set')
|
|
850
|
+
return 'set_accessor';
|
|
851
|
+
if (last === 'function')
|
|
677
852
|
return 'function';
|
|
678
|
-
if (
|
|
853
|
+
if (last === 'class')
|
|
679
854
|
return 'class';
|
|
680
|
-
if (
|
|
855
|
+
if (last === 'interface')
|
|
681
856
|
return 'interface';
|
|
682
|
-
if (
|
|
857
|
+
if (last === 'type')
|
|
683
858
|
return 'type';
|
|
684
|
-
if (
|
|
859
|
+
if (['public', 'private', 'protected', 'static', 'abstract', 'async', 'override'].includes(last))
|
|
685
860
|
return 'method';
|
|
686
861
|
return 'variable';
|
|
687
862
|
}
|
|
688
863
|
/**
|
|
689
|
-
* Async wrapper around grep to avoid blocking the event loop.
|
|
690
|
-
*
|
|
864
|
+
* Async wrapper around grep to avoid blocking the event loop. `ok: false`
|
|
865
|
+
* means grep could not be spawned or exited with status ≥ 2 (distinguishable
|
|
866
|
+
* from a clean "no matches", which is `ok: true, output: null`).
|
|
691
867
|
*/
|
|
692
868
|
spawnGrep(args) {
|
|
693
869
|
return new Promise((resolve) => {
|
|
@@ -704,19 +880,16 @@ class SearcherService {
|
|
|
704
880
|
proc.stderr.on('data', () => { });
|
|
705
881
|
proc.on('error', (err) => {
|
|
706
882
|
this.logger.error('SearcherService: grep spawn failed', { reason: err.message });
|
|
707
|
-
resolve(
|
|
883
|
+
resolve({ ok: false });
|
|
708
884
|
});
|
|
709
885
|
proc.on('close', (code) => {
|
|
710
|
-
// grep returns 1 for "no matches" — not an error
|
|
711
|
-
if (code !==
|
|
886
|
+
// grep returns 1 for "no matches" — not an error; 2+ is a real failure
|
|
887
|
+
if (code !== null && code >= 2) {
|
|
712
888
|
this.logger.warn('SearcherService: grep exited with code', { code });
|
|
889
|
+
resolve({ ok: false });
|
|
890
|
+
return;
|
|
713
891
|
}
|
|
714
|
-
|
|
715
|
-
resolve(null);
|
|
716
|
-
}
|
|
717
|
-
else {
|
|
718
|
-
resolve(Buffer.concat(chunks).toString('utf-8'));
|
|
719
|
-
}
|
|
892
|
+
resolve({ ok: true, output: chunks.length === 0 ? null : Buffer.concat(chunks).toString('utf-8') });
|
|
720
893
|
});
|
|
721
894
|
});
|
|
722
895
|
}
|
|
@@ -1,13 +1,34 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { Config } from '../config';
|
|
2
2
|
import type { ISearcher } from '../interfaces';
|
|
3
3
|
import type { IEmbedder } from '../interfaces';
|
|
4
4
|
import type { ILogger } from '../interfaces';
|
|
5
5
|
import { type JsonConfig } from '../config-store';
|
|
6
|
+
import { type ProvisioningDeps } from './infinity-provisioning';
|
|
7
|
+
export type BackendActionKind = 'started' | 'already-running' | 'not-needed' | 'manual-required' | 'failed';
|
|
8
|
+
export interface BackendActionResult {
|
|
9
|
+
action: BackendActionKind;
|
|
10
|
+
message: string | null;
|
|
11
|
+
}
|
|
12
|
+
export type BackendStatusState = 'idle' | 'recreating' | 'ready' | 'failed';
|
|
13
|
+
export interface BackendStatus {
|
|
14
|
+
state: BackendStatusState;
|
|
15
|
+
message: string | null;
|
|
16
|
+
startedAt: string | null;
|
|
17
|
+
finishedAt: string | null;
|
|
18
|
+
}
|
|
19
|
+
export interface WebServerDeps {
|
|
20
|
+
/** Path to the managed docker-compose.yml. Defaults to `CONFIG_DIR/docker-compose.yml`. */
|
|
21
|
+
composePath?: string;
|
|
22
|
+
/** Injection seams (fetchImpl/runCommand/pollIntervalMs/readinessTimeoutMs) for the readiness primitive. */
|
|
23
|
+
provisioning?: ProvisioningDeps;
|
|
24
|
+
/** Reads the just-saved config back — defaults to `() => new Config()`. Injectable for tests. */
|
|
25
|
+
readSavedConfig?: () => Config;
|
|
26
|
+
}
|
|
6
27
|
/**
|
|
7
28
|
* Per-field choice lists for the config form. `strict` → render a dropdown that
|
|
8
29
|
* only accepts a listed value (e.g. the embedding model, tied to the vector
|
|
9
30
|
* dimension). Non-strict → a combobox: the values are suggestions but free text
|
|
10
|
-
* is still allowed (e.g. any
|
|
31
|
+
* is still allowed (e.g. any model served by a self-hosted backend).
|
|
11
32
|
*/
|
|
12
33
|
export interface FieldOption {
|
|
13
34
|
values: string[];
|
|
@@ -41,7 +62,13 @@ export declare class WebServer {
|
|
|
41
62
|
private readonly logger;
|
|
42
63
|
private readonly embedder;
|
|
43
64
|
private server?;
|
|
44
|
-
|
|
65
|
+
private readonly composePath;
|
|
66
|
+
private readonly provisioningDeps;
|
|
67
|
+
private readonly readSavedConfig;
|
|
68
|
+
private backendStatus;
|
|
69
|
+
private taskInFlight;
|
|
70
|
+
private pending;
|
|
71
|
+
constructor(searcher: ISearcher, config: Config, logger: ILogger, embedder: IEmbedder, deps?: WebServerDeps);
|
|
45
72
|
start(port: number, host: string): Promise<void>;
|
|
46
73
|
stop(): Promise<void>;
|
|
47
74
|
/** The bound address once started (e.g. `.port` after listen(0)); null otherwise. */
|
|
@@ -51,6 +78,17 @@ export declare class WebServer {
|
|
|
51
78
|
private handle;
|
|
52
79
|
private maskedConfig;
|
|
53
80
|
private saveConfig;
|
|
81
|
+
/**
|
|
82
|
+
* After a successful config write, decide the Infinity backend action per
|
|
83
|
+
* the first-match decision table (`local-backend-provisioning`). Computes
|
|
84
|
+
* the required set from the SAVED config (a fresh `Config` re-read from the
|
|
85
|
+
* file just written — never the server's startup config/embedder). Never
|
|
86
|
+
* throws — an unexpected error becomes `{ action: 'failed', message }`; the
|
|
87
|
+
* saved config and `GET /api/backend-status` are unaffected either way.
|
|
88
|
+
*/
|
|
89
|
+
private evaluateBackendAfterSave;
|
|
90
|
+
/** Kick off (or continue, via a follow-up) a background Infinity recreate. */
|
|
91
|
+
private startBackgroundTask;
|
|
54
92
|
/**
|
|
55
93
|
* Choice lists for model fields. Both are non-strict comboboxes (free text
|
|
56
94
|
* allowed): the embedding model suggests the known registry models plus any
|