codesearch-mcp 0.0.15-alpha → 0.0.17-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 (39) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +74 -33
  3. package/dist/app.d.ts +1 -1
  4. package/dist/app.js +67 -16
  5. package/dist/src/commands/BackfillCommand.js +15 -5
  6. package/dist/src/commands/ConfigCommand.d.ts +12 -0
  7. package/dist/src/commands/ConfigCommand.js +98 -0
  8. package/dist/src/commands/IndexCommand.js +1 -1
  9. package/dist/src/config-store.d.ts +53 -0
  10. package/dist/src/config-store.js +197 -0
  11. package/dist/src/config.d.ts +22 -8
  12. package/dist/src/config.js +103 -58
  13. package/dist/src/container.d.ts +1 -1
  14. package/dist/src/container.js +8 -7
  15. package/dist/src/db/index.d.ts +2 -3
  16. package/dist/src/db/index.js +6 -7
  17. package/dist/src/db/schema.d.ts +577 -0
  18. package/dist/src/db/schema.js +47 -0
  19. package/dist/src/help.js +28 -12
  20. package/dist/src/interfaces/IAstParser.d.ts +19 -0
  21. package/dist/src/interfaces/IEmbedder.d.ts +1 -0
  22. package/dist/src/project-selector.d.ts +14 -5
  23. package/dist/src/project-selector.js +74 -60
  24. package/dist/src/services/AstParserService.d.ts +7 -0
  25. package/dist/src/services/AstParserService.js +191 -28
  26. package/dist/src/services/DatabaseService.d.ts +11 -4
  27. package/dist/src/services/DatabaseService.js +107 -48
  28. package/dist/src/services/IndexerService.d.ts +9 -0
  29. package/dist/src/services/IndexerService.js +224 -60
  30. package/dist/src/services/LoggerService.d.ts +5 -1
  31. package/dist/src/services/LoggerService.js +8 -5
  32. package/dist/src/services/McpServer.d.ts +1 -0
  33. package/dist/src/services/McpServer.js +74 -4
  34. package/dist/src/services/OllamaEmbedder.d.ts +2 -0
  35. package/dist/src/services/OllamaEmbedder.js +29 -0
  36. package/dist/src/services/SearcherService.d.ts +13 -0
  37. package/dist/src/services/SearcherService.js +118 -22
  38. package/dist/src/setup.js +57 -62
  39. package/package.json +4 -5
@@ -50,17 +50,20 @@ class LoggerService {
50
50
  static silent() {
51
51
  return new LoggerService((0, pino_1.default)({ level: 'silent' }));
52
52
  }
53
- static fromEnv(name) {
54
- const level = (process.env.LOG_LEVEL ?? 'error');
55
- const logDirEnv = process.env.LOG_DIR;
56
- const enabled = logDirEnv !== 'false' && logDirEnv !== '0';
53
+ static fromConfig(cfg, name) {
54
+ const level = cfg.level;
55
+ const dirVal = cfg.dir;
56
+ const enabled = dirVal !== 'false' && dirVal !== '0';
57
57
  let filePath = null;
58
58
  if (enabled) {
59
- const logDir = logDirEnv || DEFAULT_LOG_DIR;
59
+ const logDir = dirVal || DEFAULT_LOG_DIR;
60
60
  filePath = name
61
61
  ? path.join(logDir, name, `${name}.log`)
62
62
  : path.join(logDir, 'rag.log');
63
63
  }
64
+ return LoggerService.buildFromPath(level, filePath);
65
+ }
66
+ static buildFromPath(level, filePath) {
64
67
  if (filePath)
65
68
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
66
69
  const transport = filePath
@@ -7,6 +7,7 @@ export declare class McpServer {
7
7
  private logger;
8
8
  constructor(searcher: SearcherService, config: Config, logger?: LoggerService);
9
9
  start(startupError?: Error): Promise<void>;
10
+ private formatGraphContext;
10
11
  private formatChunk;
11
12
  private formatDependencies;
12
13
  private formatDependents;
@@ -59,10 +59,13 @@ Pick one workflow based on what you need:
59
59
  | Type | Signals | Workflow |
60
60
  |---|---|---|
61
61
  | **Lookup** | "where is X defined", "find Y" | find_symbol (if name known) → search_codebase (if not found) |
62
+ | **Caller trace** | "who calls X", "find usages of Y", "what invokes Z" | find_symbol → get_callers |
62
63
  | **Conceptual** | "how does X work", "what is Y" | search_codebase → read_file_range |
63
64
  | **Flow audit** | "how does X connect to Y", "audit this flow", "trace the path" | search_codebase → **get_dependencies + get_dependents** → Grep |
64
65
  | **Impact** | "what would break if I change X", "what uses Z" | get_dependents → search_codebase for context |
65
66
 
67
+ > **No results?** Never conclude the code doesn't exist after a single attempt. Retry with \`threshold\` raised by 0.05–0.10 (e.g. 0.45 → 0.50 → 0.55) before giving up. The code may exist under a different name or domain term.
68
+
66
69
  ## Tools
67
70
 
68
71
  - **list_projects** — call this first to see what projects are indexed and their exact names.
@@ -71,6 +74,7 @@ Pick one workflow based on what you need:
71
74
  - **get_dependencies** — show what files a given file imports (direct or transitive). Use to understand what a file depends on.
72
75
  - **get_dependents** — show which files import a given file (reverse graph). Use before refactoring to find all callers.
73
76
  - **find_symbol** — find where a symbol (function, class, type, interface, variable, method) is defined by exact name. Faster than semantic search when you know the name.
77
+ - **get_callers** — find all call sites of a function or method by exact name. Uses the call graph index — faster and more precise than grep. Use after find_symbol to trace where a symbol is actually called.
74
78
  - **list_files** — browse the directory tree of a project path. Use to understand project structure before reading files.
75
79
  - **read_file** — read a complete file by **absolute path** with line numbers. Truncates at max_lines with a hint to continue.
76
80
 
@@ -81,8 +85,9 @@ Pick one workflow based on what you need:
81
85
  2. Use \`search_codebase\` to find relevant code by meaning. If your query uses an ambiguous term (e.g. "subscription" could mean pub/sub or billing), pass a lower \`threshold\` (e.g. \`0.40–0.45\`) to filter out weak matches.
82
86
  3. If a result looks **truncated or incomplete**, call \`read_file_range\` with the returned \`file_path\` and \`start_line\`.
83
87
  4. For exploratory searches where results span chunk boundaries, pass \`include_neighbors: true\`.
84
- 5. Use \`find_symbol\` when you know the exact name — faster than semantic search. Use \`kind: "method"\` for class methods — grep matches lines where \`public\`, \`async\`, \`static\`, or \`override\` appears before the name.
85
- 6. Use \`list_files\` to explore project structure before diving into files. Use \`read_file\` to read a complete file by absolute path.
88
+ 5. To immediately see a file's imports and callers without extra tool calls, pass \`include_graph_context: true\` to \`search_codebase\`. Use this when doing a quick structural survey; switch to \`get_dependencies\`/\`get_dependents\` for full transitive graphs.
89
+ 6. Use \`find_symbol\` when you know the exact name — faster than semantic search. Use \`kind: "method"\` for class methods — grep matches lines where \`public\`, \`async\`, \`static\`, or \`override\` appears before the name. After locating a symbol, use \`get_callers\` to find all call sites — faster and more precise than grep (requires \`rag-index backfill --calls\` to have been run).
90
+ 7. Use \`list_files\` to explore project structure before diving into files. Use \`read_file\` to read a complete file by absolute path.
86
91
 
87
92
  ### Flow audit (mandatory steps — do not skip 3 and 4)
88
93
  1. Use \`list_projects\` to confirm project names.
@@ -129,6 +134,9 @@ class McpServer {
129
134
  this.logger = logger;
130
135
  }
131
136
  async start(startupError) {
137
+ if (startupError) {
138
+ this.logger.error('McpServer: startup failed, server will report error on every tool call', { reason: startupError.message });
139
+ }
132
140
  const server = new index_js_1.Server({ name: 'local-rag', version: PKG_VERSION }, { capabilities: { tools: {} }, instructions: buildInstructions(this.config.search.similarityThreshold) });
133
141
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
134
142
  tools: [
@@ -142,6 +150,7 @@ class McpServer {
142
150
  limit: { type: 'number', description: `Max results (default ${this.config.search.defaultLimit}, max ${this.config.search.maxLimit})` },
143
151
  project: { type: 'string', description: 'Filter by project name (optional). Use list_projects to see available names.' },
144
152
  include_neighbors: { type: 'boolean', description: 'Also include the chunk before and after each result from the same file (Small-to-Big retrieval). Useful when results look truncated.' },
153
+ include_graph_context: { type: 'boolean', description: 'Append direct imports and dependents list to each result. Shows "Imports: X · Y" and "Used by: Z" without extra tool calls.' },
145
154
  threshold: { type: 'number', description: `Max cosine distance (0–1). Lower = stricter. Default: ${this.config.search.similarityThreshold}. Use 0.40–0.45 for precise technical queries, 0.55–0.60 for broad exploration.` },
146
155
  },
147
156
  required: ['query'],
@@ -206,6 +215,19 @@ class McpServer {
206
215
  required: ['name'],
207
216
  },
208
217
  },
218
+ {
219
+ name: 'get_callers',
220
+ description: 'Find all call sites of a function or method by exact name. Uses the call graph index — faster and more precise than grep. Use after find_symbol to trace where a symbol is actually called. Returns empty if rag-index backfill --calls has not been run.',
221
+ inputSchema: {
222
+ type: 'object',
223
+ properties: {
224
+ symbol: { type: 'string', description: 'Exact function or method name (case-sensitive)' },
225
+ project: { type: 'string', description: 'Project name (from list_projects)' },
226
+ callee_file: { type: 'string', description: 'Optional: filter by definition file path (as returned by find_symbol) to disambiguate overloaded names' },
227
+ },
228
+ required: ['symbol', 'project'],
229
+ },
230
+ },
209
231
  {
210
232
  name: 'list_files',
211
233
  description: 'List files and directories in a project path as an indented tree. Use to understand project structure before reading files.',
@@ -244,8 +266,10 @@ class McpServer {
244
266
  };
245
267
  }
246
268
  try {
269
+ const t0 = Date.now();
247
270
  if (req.params.name === 'list_projects') {
248
271
  const rows = await this.searcher.listProjects();
272
+ this.logger.debug('McpServer: list_projects', { count: rows.length, ms: Date.now() - t0 });
249
273
  if (rows.length === 0) {
250
274
  return { content: [{ type: 'text', text: 'No indexed projects found.' }] };
251
275
  }
@@ -271,6 +295,7 @@ class McpServer {
271
295
  const limit = Math.min(Math.max(1, Number(req.params.arguments?.limit) || this.config.search.defaultLimit), this.config.search.maxLimit);
272
296
  const project = req.params.arguments?.project ? String(req.params.arguments.project) : null;
273
297
  const includeNeighbors = Boolean(req.params.arguments?.include_neighbors);
298
+ const includeGraphContext = Boolean(req.params.arguments?.include_graph_context);
274
299
  const rawThreshold = req.params.arguments?.threshold;
275
300
  const parsedThreshold = Number(rawThreshold);
276
301
  const threshold = rawThreshold != null && !isNaN(parsedThreshold)
@@ -278,17 +303,30 @@ class McpServer {
278
303
  : undefined;
279
304
  const { query, translated } = await this.searcher.prepareQuery(raw);
280
305
  const results = await this.searcher.searchChunks(query, limit, project, threshold);
306
+ this.logger.debug('McpServer: search_codebase', {
307
+ query: raw.slice(0, 120),
308
+ project,
309
+ threshold: threshold ?? this.config.search.similarityThreshold,
310
+ limit,
311
+ translated,
312
+ count: results.length,
313
+ ms: Date.now() - t0,
314
+ });
281
315
  if (results.length === 0) {
282
316
  return { content: [{ type: 'text', text: 'No relevant results found.' }] };
283
317
  }
284
318
  const header = translated ? `> Query translated: "${raw}" → "${query}"\n\n` : '';
319
+ let graphCtx = null;
320
+ if (includeGraphContext) {
321
+ graphCtx = await this.searcher.fetchGraphContext(results);
322
+ }
285
323
  if (includeNeighbors) {
286
324
  const withNeighbors = await this.searcher.fetchNeighbors(results);
287
325
  const blocks = withNeighbors.map(({ result, prevChunk, nextChunk }) => {
288
326
  const parts = [];
289
327
  if (prevChunk)
290
328
  parts.push(this.formatChunk(prevChunk, 'prev chunk'));
291
- parts.push(`${this.formatChunk(result)}\n**Relevance:** ${result.score}`);
329
+ parts.push(`${this.formatChunk(result)}\n**Relevance:** ${result.score}${this.formatGraphContext(graphCtx?.get(result.filePath))}`);
292
330
  if (nextChunk)
293
331
  parts.push(this.formatChunk(nextChunk, 'next chunk'));
294
332
  return parts.join('\n\n');
@@ -296,7 +334,7 @@ class McpServer {
296
334
  return { content: [{ type: 'text', text: header + blocks.join('\n\n---\n\n') }] };
297
335
  }
298
336
  const text = results
299
- .map(r => `${this.formatChunk(r)}\n**Relevance:** ${r.score}`)
337
+ .map(r => `${this.formatChunk(r)}\n**Relevance:** ${r.score}${this.formatGraphContext(graphCtx?.get(r.filePath))}`)
300
338
  .join('\n\n---\n\n');
301
339
  return { content: [{ type: 'text', text: header + text }] };
302
340
  }
@@ -305,6 +343,7 @@ class McpServer {
305
343
  const startLine = Math.max(1, Number(req.params.arguments?.start_line) || 1);
306
344
  const endLine = Number(req.params.arguments?.end_line) || startLine + 99;
307
345
  const range = await this.searcher.readFileRange(filePath, startLine, endLine);
346
+ this.logger.debug('McpServer: read_file_range', { file: filePath, startLine, endLine, ok: range.ok, ms: Date.now() - t0 });
308
347
  if (!range.ok) {
309
348
  const message = range.reason === 'invalid_path' ? `Invalid file path (outside workspace): ${filePath}` :
310
349
  range.reason === 'not_indexed' ? `File is not indexed: ${filePath}. Run indexing first.` :
@@ -324,6 +363,7 @@ class McpServer {
324
363
  const symbol = req.params.arguments?.symbol ? String(req.params.arguments.symbol) : null;
325
364
  const transitive = Boolean(req.params.arguments?.transitive);
326
365
  const rows = await this.searcher.getDependencies(filePath, project, symbol, transitive);
366
+ this.logger.debug('McpServer: get_dependencies', { file: filePath, project, transitive, count: rows.length, ms: Date.now() - t0 });
327
367
  if (rows.length === 0) {
328
368
  return { content: [{ type: 'text', text: `No dependencies found for: ${filePath}` }] };
329
369
  }
@@ -335,6 +375,7 @@ class McpServer {
335
375
  const symbol = req.params.arguments?.symbol ? String(req.params.arguments.symbol) : null;
336
376
  const transitive = Boolean(req.params.arguments?.transitive);
337
377
  const rows = await this.searcher.getDependents(filePath, project, symbol, transitive);
378
+ this.logger.debug('McpServer: get_dependents', { file: filePath, project, symbol, transitive, count: rows.length, ms: Date.now() - t0 });
338
379
  if (rows.length > 0) {
339
380
  return { content: [{ type: 'text', text: this.formatDependents(filePath, rows, transitive) }] };
340
381
  }
@@ -365,6 +406,7 @@ class McpServer {
365
406
  const validKinds = ['function', 'class', 'type', 'interface', 'variable', 'method', 'all'];
366
407
  const kind = validKinds.includes(rawKind) ? rawKind : 'all';
367
408
  const { hits, truncated } = await this.searcher.findSymbol(name, project, kind);
409
+ this.logger.debug('McpServer: find_symbol', { name, kind, project, count: hits.length, ms: Date.now() - t0 });
368
410
  if (hits.length === 0) {
369
411
  return { content: [{ type: 'text', text: `No definitions found for symbol: ${name}` }] };
370
412
  }
@@ -384,6 +426,7 @@ class McpServer {
384
426
  const depth = isNaN(rawDepth) || rawDepth < 1 ? 2 : Math.min(5, rawDepth);
385
427
  const pattern = req.params.arguments?.pattern ? String(req.params.arguments.pattern) : null;
386
428
  const { tree, error } = this.searcher.listFiles(relPath, project, depth, pattern);
429
+ this.logger.debug('McpServer: list_files', { path: relPath, project, depth, ms: Date.now() - t0 });
387
430
  if (error) {
388
431
  return { content: [{ type: 'text', text: `list_files error: ${error}` }] };
389
432
  }
@@ -394,6 +437,7 @@ class McpServer {
394
437
  const rawMax = Number(req.params.arguments?.max_lines);
395
438
  const maxLines = isNaN(rawMax) || rawMax < 1 ? 300 : Math.min(1000, rawMax);
396
439
  const result = await this.searcher.readFileAbsolute(filePath, maxLines);
440
+ this.logger.debug('McpServer: read_file', { file: filePath, maxLines, ok: result.ok, ms: Date.now() - t0 });
397
441
  if (!result.ok) {
398
442
  const message = result.reason === 'invalid_path' ? `Invalid path (outside workspace): ${filePath}` :
399
443
  `File not found on disk: ${filePath}`;
@@ -406,6 +450,23 @@ class McpServer {
406
450
  }
407
451
  return { content: [{ type: 'text', text }] };
408
452
  }
453
+ if (req.params.name === 'get_callers') {
454
+ const symbol = String(req.params.arguments?.symbol ?? '');
455
+ const project = String(req.params.arguments?.project ?? '');
456
+ const calleeFile = req.params.arguments?.callee_file ? String(req.params.arguments.callee_file) : null;
457
+ const rows = await this.searcher.getCallers(symbol, project, calleeFile);
458
+ this.logger.debug('McpServer: get_callers', { symbol, project, count: rows.length, ms: Date.now() - t0 });
459
+ if (rows.length === 0) {
460
+ return { content: [{ type: 'text', text: `No call sites found for \`${symbol}\` in project \`${project}\`. Run \`rag-index backfill --calls\` if the call graph has not been populated yet.` }] };
461
+ }
462
+ const lines = [`**Call sites of \`${symbol}\`** (${rows.length} found)\n`];
463
+ for (const r of rows) {
464
+ const loc = r.line != null ? ` line ${r.line}` : '';
465
+ const ctx = r.fromSymbol ? `, in \`${r.fromSymbol}\`` : '';
466
+ lines.push(` → \`${r.fromFile}\`${loc}${ctx}`);
467
+ }
468
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
469
+ }
409
470
  throw new Error(`Unknown tool: ${req.params.name}`);
410
471
  }
411
472
  catch (err) {
@@ -414,6 +475,15 @@ class McpServer {
414
475
  }
415
476
  });
416
477
  await server.connect(new stdio_js_1.StdioServerTransport());
478
+ this.logger.info('McpServer: started', { version: PKG_VERSION });
479
+ }
480
+ formatGraphContext(ctx) {
481
+ const parts = [];
482
+ if (ctx?.imports.length)
483
+ parts.push(`**Imports:** ${ctx.imports.join(' · ')}`);
484
+ if (ctx?.usedBy.length)
485
+ parts.push(`**Used by:** ${ctx.usedBy.join(' · ')}`);
486
+ return parts.length > 0 ? '\n' + parts.join('\n') : '';
417
487
  }
418
488
  formatChunk(r, label) {
419
489
  return `${label ? `*(${label})*\n` : ''}**File:** ${r.filePath}\n**Project:** ${r.serviceName}\n**Line:** ${r.startLine}\n\`\`\`\n${r.content}\n\`\`\``;
@@ -3,7 +3,9 @@ import type { IEmbedder } from '../interfaces/IEmbedder';
3
3
  export declare class OllamaEmbedder implements IEmbedder {
4
4
  private config;
5
5
  private client;
6
+ rerank?: (query: string, documents: string[], topN: number) => Promise<number[]>;
6
7
  constructor(config: Config);
7
8
  embed(text: string): Promise<number[]>;
8
9
  translate(text: string, model: string): Promise<string>;
10
+ private _rerank;
9
11
  }
@@ -5,9 +5,18 @@ const ollama_1 = require("ollama");
5
5
  class OllamaEmbedder {
6
6
  config;
7
7
  client;
8
+ rerank;
8
9
  constructor(config) {
9
10
  this.config = config;
10
11
  this.client = new ollama_1.Ollama({ host: config.ollama.host });
12
+ if (config.search.rerankerEnabled) {
13
+ if (config.ollama.rerankerModel) {
14
+ this.rerank = this._rerank.bind(this);
15
+ }
16
+ else {
17
+ console.warn('[OllamaEmbedder] RERANKER_ENABLED=true but RERANKER_MODEL is not set — reranking disabled');
18
+ }
19
+ }
11
20
  }
12
21
  async embed(text) {
13
22
  const { embedding } = await this.client.embeddings({
@@ -23,5 +32,25 @@ class OllamaEmbedder {
23
32
  });
24
33
  return response.message.content.trim();
25
34
  }
35
+ async _rerank(query, documents, topN) {
36
+ const res = await fetch(this.config.ollama.rerankerUrl, {
37
+ method: 'POST',
38
+ headers: { 'Content-Type': 'application/json' },
39
+ signal: AbortSignal.timeout(30_000),
40
+ body: JSON.stringify({
41
+ model: this.config.ollama.rerankerModel,
42
+ query,
43
+ documents,
44
+ top_n: topN,
45
+ truncate: true,
46
+ }),
47
+ });
48
+ if (!res.ok) {
49
+ const body = (await res.text().catch(() => res.statusText)).slice(0, 200);
50
+ throw new Error(`Ollama rerank failed: ${res.status} ${body}`);
51
+ }
52
+ const data = await res.json();
53
+ return data.results.map(r => r.index);
54
+ }
26
55
  }
27
56
  exports.OllamaEmbedder = OllamaEmbedder;
@@ -17,6 +17,10 @@ export interface NeighborResult {
17
17
  prevChunk: ChunkRow | null;
18
18
  nextChunk: ChunkRow | null;
19
19
  }
20
+ export interface GraphContext {
21
+ imports: string[];
22
+ usedBy: string[];
23
+ }
20
24
  export type FileRangeResult = {
21
25
  ok: true;
22
26
  filePath: string;
@@ -46,6 +50,13 @@ export interface GrepMatch {
46
50
  line: number;
47
51
  }
48
52
  export type SymbolKind = 'function' | 'class' | 'type' | 'interface' | 'variable' | 'method' | 'all';
53
+ export interface CallerRow {
54
+ fromFile: string;
55
+ fromSymbol: string | null;
56
+ calleeName: string;
57
+ calleeFile: string;
58
+ line: number | null;
59
+ }
49
60
  export interface SymbolHit {
50
61
  filePath: string;
51
62
  line: number;
@@ -66,8 +77,10 @@ export declare class SearcherService {
66
77
  listProjects(): Promise<ProjectRow[]>;
67
78
  searchChunks(query: string, limit: number, project: string | null, threshold?: number): Promise<SearchResult[]>;
68
79
  fetchNeighbors(results: SearchResult[]): Promise<NeighborResult[]>;
80
+ fetchGraphContext(results: SearchResult[]): Promise<Map<string, GraphContext>>;
69
81
  getDependencies(filePath: string, project: string, symbol?: string | null, transitive?: boolean): Promise<DependencyRow[]>;
70
82
  getDependents(filePath: string, project: string, symbol?: string | null, transitive?: boolean): Promise<DependencyRow[]>;
83
+ getCallers(symbol: string, project: string, calleeFile?: string | null): Promise<CallerRow[]>;
71
84
  grepSymbol(symbol: string, project: string, excludeFile: string): {
72
85
  matches: GrepMatch[];
73
86
  truncated: boolean;
@@ -58,12 +58,13 @@ class SearcherService {
58
58
  this.logger = logger;
59
59
  }
60
60
  async prepareQuery(raw) {
61
+ const t0 = Date.now();
61
62
  if (this.config.search.autoTranslate && this.isNonEnglish(raw) && this.embedder.translate) {
62
63
  const query = await this.embedder.translate(raw, this.config.search.translateModel);
63
- this.logger.debug('SearcherService: prepareQuery', { translated: true, raw: raw.slice(0, 120), query: query.slice(0, 120) });
64
+ this.logger.debug('SearcherService: prepareQuery', { translated: true, raw: raw.slice(0, 120), query: query.slice(0, 120), ms: Date.now() - t0 });
64
65
  return { query, translated: true };
65
66
  }
66
- this.logger.debug('SearcherService: prepareQuery', { translated: false, query: raw.slice(0, 120) });
67
+ this.logger.debug('SearcherService: prepareQuery', { translated: false, query: raw.slice(0, 120), ms: Date.now() - t0 });
67
68
  return { query: raw, translated: false };
68
69
  }
69
70
  async listProjects() {
@@ -77,32 +78,51 @@ class SearcherService {
77
78
  });
78
79
  }
79
80
  async searchChunks(query, limit, project, threshold) {
81
+ const t0 = Date.now();
80
82
  const effectiveThreshold = threshold ?? this.config.search.similarityThreshold;
83
+ const fetchLimit = this.embedder.rerank
84
+ ? Math.min(limit * this.config.search.rerankerOverfetch, 200)
85
+ : limit;
81
86
  this.logger.debug('SearcherService: searchChunks', {
82
- query: query.slice(0, 120), limit, project, threshold: effectiveThreshold,
87
+ query: query.slice(0, 120), limit, fetchLimit, project, threshold: effectiveThreshold,
83
88
  });
84
89
  const embedding = await this.embedder.embed(query);
85
- const params = [`[${embedding}]`, effectiveThreshold, limit];
90
+ const params = [`[${embedding}]`, effectiveThreshold, fetchLimit];
86
91
  let projectFilter = '';
87
92
  if (project) {
88
93
  params.push(project);
89
94
  projectFilter = `AND "serviceName" = $${params.length}`;
90
95
  }
91
- const results = await this.db.query(`SELECT "serviceName", "filePath", "startLine", content,
96
+ const candidates = await this.db.query(`SELECT "serviceName", "filePath", "startLine", content,
92
97
  ROUND((embedding <=> $1)::numeric, 4)::float8 AS score
93
98
  FROM code_chunk
94
99
  WHERE embedding <=> $1 < $2
95
100
  ${projectFilter}
96
101
  ORDER BY embedding <=> $1
97
102
  LIMIT $3`, params);
98
- this.logger.debug('SearcherService: searchChunks results', {
99
- count: results.length,
100
- scores: results.map(r => r.score),
101
- files: results.map(r => r.filePath),
103
+ this.logger.debug('SearcherService: searchChunks candidates', {
104
+ count: candidates.length,
105
+ scores: candidates.map(r => r.score),
106
+ files: candidates.map(r => r.filePath),
107
+ ms: Date.now() - t0,
102
108
  });
103
- return results;
109
+ if (!this.embedder.rerank || candidates.length === 0)
110
+ return candidates;
111
+ try {
112
+ const indices = await this.embedder.rerank(query, candidates.map(r => r.content), limit);
113
+ this.logger.debug('SearcherService: searchChunks rerank done', { indices, ms: Date.now() - t0 });
114
+ return indices.filter(i => i >= 0 && i < candidates.length).map(i => candidates[i]);
115
+ }
116
+ catch (err) {
117
+ this.logger.warn('SearcherService: rerank failed, falling back to vector order', {
118
+ reason: err.message,
119
+ ms: Date.now() - t0,
120
+ });
121
+ return candidates.slice(0, limit);
122
+ }
104
123
  }
105
124
  async fetchNeighbors(results) {
125
+ const t0 = Date.now();
106
126
  if (results.length === 0)
107
127
  return [];
108
128
  this.logger.debug('SearcherService: fetchNeighbors', { count: results.length });
@@ -128,13 +148,64 @@ class SearcherService {
128
148
  prevChunk: row.prev_fp ? { serviceName: row.prev_sn, filePath: row.prev_fp, startLine: row.prev_sl, content: row.prev_c } : null,
129
149
  nextChunk: row.next_fp ? { serviceName: row.next_sn, filePath: row.next_fp, startLine: row.next_sl, content: row.next_c } : null,
130
150
  }));
131
- this.logger.debug('SearcherService: fetchNeighbors results', {
151
+ this.logger.debug('SearcherService: fetchNeighbors result', {
132
152
  withPrev: neighbors.filter(n => n.prevChunk).length,
133
153
  withNext: neighbors.filter(n => n.nextChunk).length,
154
+ ms: Date.now() - t0,
134
155
  });
135
156
  return neighbors;
136
157
  }
158
+ async fetchGraphContext(results) {
159
+ const t0 = Date.now();
160
+ const seen = new Map();
161
+ for (const r of results) {
162
+ const key = `${r.serviceName}::${r.filePath}`;
163
+ if (!seen.has(key))
164
+ seen.set(key, { filePath: r.filePath, project: r.serviceName });
165
+ }
166
+ const pairs = [...seen.values()];
167
+ this.logger.debug('SearcherService: fetchGraphContext', { files: pairs.length });
168
+ if (pairs.length === 0)
169
+ return new Map();
170
+ const n = pairs.length;
171
+ const firstHalf = pairs.map((_, i) => `($${i * 2 + 1}::text, $${i * 2 + 2}::text)`).join(', ');
172
+ const secondHalf = pairs.map((_, i) => `($${n * 2 + i * 2 + 1}::text, $${n * 2 + i * 2 + 2}::text)`).join(', ');
173
+ const params = [
174
+ ...pairs.flatMap(p => [p.filePath, p.project]),
175
+ ...pairs.flatMap(p => [p.filePath, p.project]),
176
+ ];
177
+ const rows = await this.db.query(`SELECT r.fp AS "filePath", 'import' AS direction, d."toFile" AS related
178
+ FROM (VALUES ${firstHalf}) AS r(fp, proj)
179
+ JOIN code_dependency d ON d."fromFile" = r.fp AND d.project = r.proj
180
+
181
+ UNION ALL
182
+
183
+ SELECT r.fp AS "filePath", 'usedby' AS direction, d."fromFile" AS related
184
+ FROM (VALUES ${secondHalf}) AS r(fp, proj)
185
+ JOIN code_dependency d ON d."toFile" = r.fp AND d.project = r.proj
186
+
187
+ LIMIT 2000`, params);
188
+ const ctx = new Map();
189
+ for (const { filePath } of pairs)
190
+ ctx.set(filePath, { imports: new Set(), usedBy: new Set() });
191
+ for (const row of rows) {
192
+ const g = ctx.get(row.filePath);
193
+ if (row.direction === 'import')
194
+ g.imports.add(row.related);
195
+ else
196
+ g.usedBy.add(row.related);
197
+ }
198
+ this.logger.debug('SearcherService: fetchGraphContext result', { files: pairs.length, rows: rows.length, ms: Date.now() - t0 });
199
+ const result = new Map();
200
+ for (const [fp, g] of ctx)
201
+ result.set(fp, {
202
+ imports: [...g.imports].slice(0, 50),
203
+ usedBy: [...g.usedBy].slice(0, 50),
204
+ });
205
+ return result;
206
+ }
137
207
  async getDependencies(filePath, project, symbol = null, transitive = false) {
208
+ const t0 = Date.now();
138
209
  this.logger.debug('SearcherService: getDependencies', { filePath, project, symbol, transitive });
139
210
  const params = [filePath, project];
140
211
  let symbolFilter = '';
@@ -148,7 +219,7 @@ class SearcherService {
148
219
  ${symbolFilter}
149
220
  ORDER BY "toFile"`, params);
150
221
  if (!transitive) {
151
- this.logger.debug('SearcherService: getDependencies result', { direct: direct.length });
222
+ this.logger.debug('SearcherService: getDependencies result', { direct: direct.length, ms: Date.now() - t0 });
152
223
  return direct;
153
224
  }
154
225
  const indirect = await this.db.query(`WITH RECURSIVE deps AS (
@@ -173,10 +244,11 @@ class SearcherService {
173
244
  WHERE depth > 1
174
245
  GROUP BY "toFile"
175
246
  ORDER BY MIN(depth), "toFile"`, [filePath, project]);
176
- this.logger.debug('SearcherService: getDependencies result', { direct: direct.length, indirect: indirect.length });
247
+ this.logger.debug('SearcherService: getDependencies result', { direct: direct.length, indirect: indirect.length, ms: Date.now() - t0 });
177
248
  return [...direct, ...indirect];
178
249
  }
179
250
  async getDependents(filePath, project, symbol = null, transitive = false) {
251
+ const t0 = Date.now();
180
252
  this.logger.debug('SearcherService: getDependents', { filePath, project, symbol, transitive });
181
253
  const params = [filePath, project];
182
254
  let symbolFilter = '';
@@ -190,7 +262,7 @@ class SearcherService {
190
262
  ${symbolFilter}
191
263
  ORDER BY "fromFile"`, params);
192
264
  if (!transitive) {
193
- this.logger.debug('SearcherService: getDependents result', { direct: direct.length });
265
+ this.logger.debug('SearcherService: getDependents result', { direct: direct.length, ms: Date.now() - t0 });
194
266
  return direct;
195
267
  }
196
268
  const indirect = await this.db.query(`WITH RECURSIVE rdeps AS (
@@ -215,10 +287,29 @@ class SearcherService {
215
287
  WHERE depth > 1
216
288
  GROUP BY "fromFile"
217
289
  ORDER BY MIN(depth), "fromFile"`, [filePath, project]);
218
- this.logger.debug('SearcherService: getDependents result', { direct: direct.length, indirect: indirect.length });
290
+ this.logger.debug('SearcherService: getDependents result', { direct: direct.length, indirect: indirect.length, ms: Date.now() - t0 });
219
291
  return [...direct, ...indirect];
220
292
  }
293
+ async getCallers(symbol, project, calleeFile = null) {
294
+ const t0 = Date.now();
295
+ this.logger.debug('SearcherService: getCallers', { symbol, project, calleeFile });
296
+ const params = [symbol, project];
297
+ let fileFilter = '';
298
+ if (calleeFile) {
299
+ params.push(calleeFile);
300
+ fileFilter = `AND "calleeFile" = $${params.length}`;
301
+ }
302
+ const rows = await this.db.query(`SELECT "fromFile", "fromSymbol", "calleeName", "calleeFile", line
303
+ FROM code_call
304
+ WHERE "calleeName" = $1 AND project = $2
305
+ ${fileFilter}
306
+ ORDER BY "fromFile", line
307
+ LIMIT 200`, params);
308
+ this.logger.debug('SearcherService: getCallers result', { count: rows.length, ms: Date.now() - t0 });
309
+ return rows;
310
+ }
221
311
  grepSymbol(symbol, project, excludeFile) {
312
+ const t0 = Date.now();
222
313
  this.logger.debug('SearcherService: grepSymbol', { symbol, project, excludeFile });
223
314
  const projectDir = path.join(this.config.workspaceRoot, project);
224
315
  const result = (0, child_process_1.spawnSync)('grep', [
@@ -245,7 +336,7 @@ class SearcherService {
245
336
  return [{ filePath: path.relative(this.config.workspaceRoot, absFile), line: parseInt(lineNum, 10) }];
246
337
  });
247
338
  const truncated = all.length > GREP_MAX_RESULTS;
248
- this.logger.debug('SearcherService: grepSymbol result', { matches: Math.min(all.length, GREP_MAX_RESULTS), truncated });
339
+ this.logger.debug('SearcherService: grepSymbol result', { matches: Math.min(all.length, GREP_MAX_RESULTS), truncated, ms: Date.now() - t0 });
249
340
  return { matches: all.slice(0, GREP_MAX_RESULTS), truncated };
250
341
  }
251
342
  // Sync wrapper: validates name (throws synchronously), then returns Promise.
@@ -258,6 +349,7 @@ class SearcherService {
258
349
  return this.findSymbolFromIndex(name, project, kind);
259
350
  }
260
351
  async findSymbolFromIndex(name, project, kind) {
352
+ const t0 = Date.now();
261
353
  this.logger.debug('SearcherService: findSymbol', { name, project, kind, source: 'index' });
262
354
  try {
263
355
  const params = [name];
@@ -285,15 +377,16 @@ class SearcherService {
285
377
  kind: row.kind,
286
378
  signature: row.signature ?? row.name,
287
379
  }));
288
- this.logger.debug('SearcherService: findSymbol result', { source: 'index', hits: hits.length, truncated });
380
+ this.logger.debug('SearcherService: findSymbol result', { source: 'index', hits: hits.length, truncated, ms: Date.now() - t0 });
289
381
  return { hits, truncated };
290
382
  }
291
383
  catch {
292
- this.logger.warn('SearcherService: symbol index unavailable, falling back to grep', { name, project });
384
+ this.logger.warn('SearcherService: symbol index unavailable, falling back to grep', { name, project, ms: Date.now() - t0 });
293
385
  return this.grepFindSymbol(name, project, kind);
294
386
  }
295
387
  }
296
388
  grepFindSymbol(name, project, kind) {
389
+ const t0 = Date.now();
297
390
  this.logger.debug('SearcherService: findSymbol', { name, project, kind, source: 'grep' });
298
391
  const searchDir = project
299
392
  ? path.join(this.config.workspaceRoot, project)
@@ -335,10 +428,11 @@ class SearcherService {
335
428
  signature: matchedLine.trim(),
336
429
  });
337
430
  }
338
- this.logger.debug('SearcherService: findSymbol result', { source: 'grep', hits: hits.length, truncated: hits.length >= FIND_SYMBOL_LIMIT });
431
+ this.logger.debug('SearcherService: findSymbol result', { source: 'grep', hits: hits.length, truncated: hits.length >= FIND_SYMBOL_LIMIT, ms: Date.now() - t0 });
339
432
  return { hits, truncated: hits.length >= FIND_SYMBOL_LIMIT };
340
433
  }
341
434
  listFiles(relativePath, project, depth, pattern) {
435
+ const t0 = Date.now();
342
436
  this.logger.debug('SearcherService: listFiles', { relativePath, project, depth, pattern });
343
437
  const projectRoot = path.join(this.config.workspaceRoot, project);
344
438
  const resolvedTarget = path.resolve(projectRoot, relativePath);
@@ -355,13 +449,14 @@ class SearcherService {
355
449
  const clampedDepth = Math.min(5, Math.max(1, depth));
356
450
  const lines = [];
357
451
  this.walkDir(resolvedTarget, 0, clampedDepth - 1, pattern, lines);
358
- this.logger.debug('SearcherService: listFiles result', { entries: lines.length });
452
+ this.logger.debug('SearcherService: listFiles result', { entries: lines.length, ms: Date.now() - t0 });
359
453
  if (lines.length === 0) {
360
454
  return { tree: pattern ? `No files matching "${pattern}" found.` : '(empty directory)' };
361
455
  }
362
456
  return { tree: lines.join('\n') };
363
457
  }
364
458
  async readFileAbsolute(absPath, maxLines) {
459
+ const t0 = Date.now();
365
460
  this.logger.debug('SearcherService: readFileAbsolute', { absPath, maxLines });
366
461
  const resolvedRoot = path.resolve(this.config.workspaceRoot);
367
462
  const resolved = path.resolve(absPath);
@@ -395,10 +490,11 @@ class SearcherService {
395
490
  throw err;
396
491
  }
397
492
  const relPath = path.relative(resolvedRoot, resolved);
398
- this.logger.debug('SearcherService: readFileAbsolute result', { linesRead: lines.length, hasMore });
493
+ this.logger.debug('SearcherService: readFileAbsolute result', { linesRead: lines.length, hasMore, ms: Date.now() - t0 });
399
494
  return { ok: true, filePath: relPath, startLine: 1, endLine: lines.length, lines, hasMore };
400
495
  }
401
496
  async readFileRange(filePath, startLine, endLine) {
497
+ const t0 = Date.now();
402
498
  this.logger.debug('SearcherService: readFileRange', { filePath, startLine, endLine });
403
499
  const resolvedRoot = path.resolve(this.config.workspaceRoot);
404
500
  const absPath = path.resolve(resolvedRoot, filePath);
@@ -429,7 +525,7 @@ class SearcherService {
429
525
  break;
430
526
  }
431
527
  }
432
- this.logger.debug('SearcherService: readFileRange result', { linesRead: lines.length, hasMore });
528
+ this.logger.debug('SearcherService: readFileRange result', { linesRead: lines.length, hasMore, ms: Date.now() - t0 });
433
529
  return { ok: true, filePath, startLine, endLine: Math.min(endLine, lastRead), lines, hasMore };
434
530
  }
435
531
  isNonEnglish(text) {