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.
Files changed (64) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +146 -41
  3. package/dist/app.js +4 -19
  4. package/dist/src/commands/IndexCommand.js +68 -7
  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/db/index.d.ts +1 -1
  14. package/dist/src/db/index.js +2 -1
  15. package/dist/src/db/schema.d.ts +75 -0
  16. package/dist/src/db/schema.js +10 -1
  17. package/dist/src/enriched-prompt.d.ts +8 -0
  18. package/dist/src/enriched-prompt.js +87 -0
  19. package/dist/src/help.js +1 -1
  20. package/dist/src/interfaces/IAstParser.d.ts +18 -2
  21. package/dist/src/interfaces/IDatabase.d.ts +31 -0
  22. package/dist/src/interfaces/ILogger.d.ts +7 -0
  23. package/dist/src/interfaces/ISearcher.d.ts +3 -0
  24. package/dist/src/services/AstChunker.d.ts +11 -0
  25. package/dist/src/services/AstChunker.js +44 -0
  26. package/dist/src/services/AstParserService.d.ts +39 -7
  27. package/dist/src/services/AstParserService.js +504 -104
  28. package/dist/src/services/DatabaseService.d.ts +17 -0
  29. package/dist/src/services/DatabaseService.js +130 -2
  30. package/dist/src/services/EmbedderFactory.d.ts +1 -1
  31. package/dist/src/services/EmbedderFactory.js +2 -5
  32. package/dist/src/services/IndexerService.d.ts +4 -14
  33. package/dist/src/services/IndexerService.js +54 -156
  34. package/dist/src/services/InfinityEmbedder.d.ts +14 -55
  35. package/dist/src/services/InfinityEmbedder.js +30 -141
  36. package/dist/src/services/LoggerService.d.ts +3 -1
  37. package/dist/src/services/LoggerService.js +17 -3
  38. package/dist/src/services/McpServer.js +3 -3
  39. package/dist/src/services/SearcherService.d.ts +39 -4
  40. package/dist/src/services/SearcherService.js +254 -81
  41. package/dist/src/services/WebServerService.d.ts +41 -3
  42. package/dist/src/services/WebServerService.js +90 -2
  43. package/dist/src/services/infinity-provisioning.d.ts +115 -0
  44. package/dist/src/services/infinity-provisioning.js +516 -0
  45. package/dist/src/services/legacy-ollama-index.d.ts +24 -0
  46. package/dist/src/services/legacy-ollama-index.js +61 -0
  47. package/dist/src/services/reranker.d.ts +3 -3
  48. package/dist/src/services/reranker.js +3 -3
  49. package/dist/src/setup.d.ts +37 -0
  50. package/dist/src/setup.js +111 -157
  51. package/dist/src/symbol-name.d.ts +44 -0
  52. package/dist/src/symbol-name.js +68 -0
  53. package/dist/src/symbol-rows.d.ts +12 -0
  54. package/dist/src/symbol-rows.js +38 -0
  55. package/dist/src/tools/FindSymbolTool.js +30 -7
  56. package/dist/src/tools/GetCallersTool.js +20 -5
  57. package/docker/docker-compose.yml +5 -15
  58. package/docs/architecture.svg +103 -0
  59. package/docs/web-dashboard.md +36 -17
  60. package/package.json +1 -6
  61. package/dist/src/commands/BackfillCommand.d.ts +0 -7
  62. package/dist/src/commands/BackfillCommand.js +0 -37
  63. package/dist/src/services/OllamaEmbedder.d.ts +0 -25
  64. 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` (un-migrated DB / not yet indexed) does not block.
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 (!meta)
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 (meta.provider !== provider || meta.model !== model) {
147
- throw new Error(`Index was built with ${meta.provider}/${meta.model} but the config selects ${provider}/${model}. ` +
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 !== meta.dimensions) {
157
- throw new Error(`Index was built at ${meta.dimensions} dimensions but ${provider}/${model} now resolves to ${dim}. ` +
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 fetchLimit = this.embedder.rerank
177
- ? Math.min(limit * this.config.reranker.overfetch, 200)
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 params = [`[${embedding}]`, effectiveThreshold, fetchLimit];
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 candidates = await this.db.query(`SELECT "serviceName", "filePath", "startLine", content, "ownerPath",
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
- 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]);
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
- 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,
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 params = [symbol, project];
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 "calleeName" = $1 AND project = $2
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
- // Methods are never indexed — always resolved via grep. Other kinds use index with grep fallback.
555
+ // Every kind, including 'method', is looked up in the index first.
439
556
  findSymbol(name, project, kind) {
440
- if (!/^[\w$]+$/.test(name))
557
+ const parsed = (0, symbol_name_1.parseFindSymbolName)(name);
558
+ if (!parsed)
441
559
  throw new Error('Invalid symbol name');
442
- if (kind === 'method')
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(name, project, kind) {
562
+ async findSymbolFromIndex(parsed, project, kind) {
447
563
  const t0 = Date.now();
448
- this.logger.debug('SearcherService: findSymbol', { name, project, kind, source: 'index' });
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
- FROM code_symbol
464
- WHERE name = $1 ${projectFilter} ${kindFilter}
465
- ORDER BY project, "filePath"
466
- LIMIT $${params.length}`, params);
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
- return { hits, truncated };
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', { name, project, ms: Date.now() - t0 });
480
- return this.grepFindSymbol(name, project, kind);
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(name, project, kind) {
619
+ async grepFindSymbol(parsed, project, kind) {
484
620
  const t0 = Date.now();
485
- this.logger.debug('SearcherService: findSymbol', { name, project, kind, source: 'grep' });
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
- return { hits: [], truncated: false };
500
- const hits = [];
501
- for (const raw of result.split('\n')) {
502
- if (hits.length >= FIND_SYMBOL_LIMIT)
503
- break;
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 = matchedLine.indexOf(name);
652
+ const col = SearcherService.wholeWordIndex(matchedLine, grepName);
512
653
  if (col === -1)
513
654
  continue;
514
- hits.push({
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, name),
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
- this.logger.debug('SearcherService: findSymbol result', { source: 'grep', hits: hits.length, truncated: hits.length >= FIND_SYMBOL_LIMIT, ms: Date.now() - t0 });
523
- return { hits, truncated: hits.length >= FIND_SYMBOL_LIMIT };
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:]]+${name}[[:space:](<]`,
664
- class: `(export[[:space:]]+)?(abstract[[:space:]]+)?class[[:space:]]+${name}[[:space:]{<]`,
665
- type: `(export[[:space:]]+)?type[[:space:]]+${name}[[:space:]=<]`,
666
- interface: `(export[[:space:]]+)?interface[[:space:]]+${name}[[:space:]{<]`,
667
- variable: `(export[[:space:]]+)?(const|let|var)[[:space:]]+${name}[[:space:]=:]`,
668
- method: `(public[[:space:]]+|async[[:space:]]+|static[[:space:]]+|override[[:space:]]+)+(async[[:space:]]+)?${name}[[:space:]]*[(<]`,
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
- const before = line.slice(0, line.indexOf(name));
676
- if (/\bfunction\s+$/.test(before))
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 (/\bclass\s+$/.test(before))
853
+ if (last === 'class')
679
854
  return 'class';
680
- if (/\binterface\s+$/.test(before))
855
+ if (last === 'interface')
681
856
  return 'interface';
682
- if (/\btype\s+$/.test(before))
857
+ if (last === 'type')
683
858
  return 'type';
684
- if (/\b(public|async|static|override)\s+$/.test(before))
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
- * Returns stdout as a string, or null if grep returned no results or errored.
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(null);
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 !== 0 && code !== 1) {
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
- if (chunks.length === 0) {
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 type { Config } from '../config';
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 Ollama model the user has pulled).
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
- constructor(searcher: ISearcher, config: Config, logger: ILogger, embedder: IEmbedder);
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