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.
- package/CHANGELOG.md +53 -0
- package/README.md +74 -33
- package/dist/app.d.ts +1 -1
- package/dist/app.js +67 -16
- package/dist/src/commands/BackfillCommand.js +15 -5
- package/dist/src/commands/ConfigCommand.d.ts +12 -0
- package/dist/src/commands/ConfigCommand.js +98 -0
- package/dist/src/commands/IndexCommand.js +1 -1
- package/dist/src/config-store.d.ts +53 -0
- package/dist/src/config-store.js +197 -0
- package/dist/src/config.d.ts +22 -8
- package/dist/src/config.js +103 -58
- package/dist/src/container.d.ts +1 -1
- package/dist/src/container.js +8 -7
- package/dist/src/db/index.d.ts +2 -3
- package/dist/src/db/index.js +6 -7
- package/dist/src/db/schema.d.ts +577 -0
- package/dist/src/db/schema.js +47 -0
- package/dist/src/help.js +28 -12
- package/dist/src/interfaces/IAstParser.d.ts +19 -0
- package/dist/src/interfaces/IEmbedder.d.ts +1 -0
- package/dist/src/project-selector.d.ts +14 -5
- package/dist/src/project-selector.js +74 -60
- package/dist/src/services/AstParserService.d.ts +7 -0
- package/dist/src/services/AstParserService.js +191 -28
- package/dist/src/services/DatabaseService.d.ts +11 -4
- package/dist/src/services/DatabaseService.js +107 -48
- package/dist/src/services/IndexerService.d.ts +9 -0
- package/dist/src/services/IndexerService.js +224 -60
- package/dist/src/services/LoggerService.d.ts +5 -1
- package/dist/src/services/LoggerService.js +8 -5
- package/dist/src/services/McpServer.d.ts +1 -0
- package/dist/src/services/McpServer.js +74 -4
- package/dist/src/services/OllamaEmbedder.d.ts +2 -0
- package/dist/src/services/OllamaEmbedder.js +29 -0
- package/dist/src/services/SearcherService.d.ts +13 -0
- package/dist/src/services/SearcherService.js +118 -22
- package/dist/src/setup.js +57 -62
- 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
|
|
54
|
-
const level =
|
|
55
|
-
const
|
|
56
|
-
const enabled =
|
|
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 =
|
|
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.
|
|
85
|
-
6. Use \`
|
|
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,
|
|
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
|
|
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
|
|
99
|
-
count:
|
|
100
|
-
scores:
|
|
101
|
-
files:
|
|
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
|
-
|
|
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
|
|
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) {
|