@softerist/heuristic-mcp 3.0.15 → 3.0.16
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/README.md +104 -104
- package/config.jsonc +173 -173
- package/features/ann-config.js +131 -0
- package/features/clear-cache.js +84 -0
- package/features/find-similar-code.js +291 -0
- package/features/hybrid-search.js +544 -0
- package/features/index-codebase.js +3268 -0
- package/features/lifecycle.js +1189 -0
- package/features/package-version.js +302 -0
- package/features/register.js +408 -0
- package/features/resources.js +156 -0
- package/features/set-workspace.js +265 -0
- package/index.js +96 -96
- package/lib/cache-ops.js +22 -22
- package/lib/cache-utils.js +565 -565
- package/lib/cache.js +1870 -1870
- package/lib/call-graph.js +396 -396
- package/lib/cli.js +1 -1
- package/lib/config.js +517 -517
- package/lib/constants.js +39 -39
- package/lib/embed-query-process.js +7 -7
- package/lib/embedding-process.js +7 -7
- package/lib/embedding-worker.js +299 -299
- package/lib/ignore-patterns.js +316 -316
- package/lib/json-worker.js +14 -14
- package/lib/json-writer.js +337 -337
- package/lib/logging.js +164 -164
- package/lib/memory-logger.js +13 -13
- package/lib/onnx-backend.js +193 -193
- package/lib/project-detector.js +84 -84
- package/lib/server-lifecycle.js +165 -165
- package/lib/settings-editor.js +754 -754
- package/lib/tokenizer.js +256 -256
- package/lib/utils.js +428 -428
- package/lib/vector-store-binary.js +627 -627
- package/lib/vector-store-sqlite.js +95 -95
- package/lib/workspace-env.js +28 -28
- package/mcp_config.json +9 -9
- package/package.json +86 -75
- package/scripts/clear-cache.js +20 -0
- package/scripts/download-model.js +43 -0
- package/scripts/mcp-launcher.js +49 -0
- package/scripts/postinstall.js +12 -0
- package/search-configs.js +36 -36
- package/.prettierrc +0 -7
- package/debug-pids.js +0 -30
- package/eslint.config.js +0 -36
- package/specs/plan.md +0 -23
- package/vitest.config.js +0 -39
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import { dotSimilarity } from '../lib/utils.js';
|
|
4
|
+
import { extractSymbolsFromContent } from '../lib/call-graph.js';
|
|
5
|
+
import { embedQueryInChildProcess } from '../lib/embed-query-process.js';
|
|
6
|
+
import {
|
|
7
|
+
STAT_CONCURRENCY_LIMIT,
|
|
8
|
+
SEARCH_BATCH_SIZE,
|
|
9
|
+
PARTIAL_MATCH_BOOST,
|
|
10
|
+
} from '../lib/constants.js';
|
|
11
|
+
|
|
12
|
+
export class HybridSearch {
|
|
13
|
+
constructor(embedder, cache, config) {
|
|
14
|
+
this.embedder = embedder;
|
|
15
|
+
this.cache = cache;
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.fileModTimes = new Map(); // Cache for file modification times
|
|
18
|
+
this._lastAccess = new Map(); // Track last access time for LRU eviction
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async getChunkContent(chunkOrIndex) {
|
|
22
|
+
return await this.cache.getChunkContent(chunkOrIndex);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
getChunkVector(chunk) {
|
|
26
|
+
return this.cache.getChunkVector(chunk);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
getAnnCandidateCount(maxResults, totalChunks) {
|
|
30
|
+
const minCandidates = this.config.annMinCandidates ?? 0;
|
|
31
|
+
const maxCandidates = this.config.annMaxCandidates ?? totalChunks;
|
|
32
|
+
const multiplier = this.config.annCandidateMultiplier ?? 1;
|
|
33
|
+
const desired = Math.max(minCandidates, Math.ceil(maxResults * multiplier));
|
|
34
|
+
const capped = Math.min(maxCandidates, desired);
|
|
35
|
+
return Math.min(totalChunks, Math.max(maxResults, capped));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async populateFileModTimes(files) {
|
|
39
|
+
const uniqueFiles = new Set(files);
|
|
40
|
+
const missing = [];
|
|
41
|
+
|
|
42
|
+
for (const file of uniqueFiles) {
|
|
43
|
+
if (!this.fileModTimes.has(file)) {
|
|
44
|
+
// Try to get from cache metadata first (fast)
|
|
45
|
+
const meta = this.cache.getFileMeta(file);
|
|
46
|
+
if (meta && typeof meta.mtimeMs === 'number') {
|
|
47
|
+
this.fileModTimes.set(file, meta.mtimeMs);
|
|
48
|
+
this._lastAccess.set(file, Date.now()); // Track for LRU
|
|
49
|
+
} else {
|
|
50
|
+
missing.push(file);
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
this._lastAccess.set(file, Date.now()); // Track access for LRU
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (missing.length === 0) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Concurrency-limited execution to avoid EMFILE
|
|
62
|
+
// Pre-distribute files to workers (no shared mutable state - avoids race condition)
|
|
63
|
+
const workerCount = Math.min(STAT_CONCURRENCY_LIMIT, missing.length);
|
|
64
|
+
|
|
65
|
+
const worker = async (startIdx) => {
|
|
66
|
+
for (let i = startIdx; i < missing.length; i += workerCount) {
|
|
67
|
+
const file = missing[i];
|
|
68
|
+
try {
|
|
69
|
+
const stats = await fs.stat(file);
|
|
70
|
+
this.fileModTimes.set(file, stats.mtimeMs);
|
|
71
|
+
this._lastAccess.set(file, Date.now());
|
|
72
|
+
} catch {
|
|
73
|
+
this.fileModTimes.set(file, null);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
await Promise.all(Array.from({ length: workerCount }, (_, i) => worker(i)));
|
|
79
|
+
|
|
80
|
+
// Prevent unbounded growth (LRU-style eviction based on access time)
|
|
81
|
+
const lruMaxEntries = this.config.lruMaxEntries ?? 5000;
|
|
82
|
+
const lruTargetEntries = this.config.lruTargetEntries ?? 4000;
|
|
83
|
+
if (this.fileModTimes.size > lruMaxEntries) {
|
|
84
|
+
// Convert to array with last-access info, sort by oldest access
|
|
85
|
+
const entries = [...this.fileModTimes.keys()].map((k) => ({
|
|
86
|
+
key: k,
|
|
87
|
+
lastAccess: this._lastAccess?.get(k) ?? 0,
|
|
88
|
+
}));
|
|
89
|
+
entries.sort((a, b) => a.lastAccess - b.lastAccess); // Oldest first
|
|
90
|
+
const toEvict = entries.slice(0, entries.length - lruTargetEntries);
|
|
91
|
+
for (const { key } of toEvict) {
|
|
92
|
+
this.fileModTimes.delete(key);
|
|
93
|
+
this._lastAccess?.delete(key);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Cache invalidation helper
|
|
99
|
+
clearFileModTime(file) {
|
|
100
|
+
this.fileModTimes.delete(file);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Search the indexed codebase for relevant code snippets.
|
|
105
|
+
* Uses a hybrid approach combining semantic similarity (via embeddings) with
|
|
106
|
+
* keyword matching for optimal results.
|
|
107
|
+
* @param {string} query - Natural language or keyword search query
|
|
108
|
+
* @param {number} maxResults - Maximum number of results to return (default: 15)
|
|
109
|
+
* @returns {Promise<{results: Array<{file: string, startLine: number, endLine: number, content: string, score: number}>, message?: string}>}
|
|
110
|
+
* @throws {Error} If embedder is not initialized
|
|
111
|
+
*/
|
|
112
|
+
async search(query, maxResults) {
|
|
113
|
+
try {
|
|
114
|
+
if (typeof this.cache.ensureLoaded === 'function') {
|
|
115
|
+
await this.cache.ensureLoaded();
|
|
116
|
+
}
|
|
117
|
+
this.cache.startRead();
|
|
118
|
+
|
|
119
|
+
const storeSize = this.cache.getStoreSize();
|
|
120
|
+
|
|
121
|
+
if (storeSize === 0) {
|
|
122
|
+
return {
|
|
123
|
+
results: [],
|
|
124
|
+
message: 'No code has been indexed yet. Please wait for initial indexing to complete.',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Generate query embedding
|
|
129
|
+
if (this.config.verbose) {
|
|
130
|
+
console.info(`[Search] Query: "${query}"`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let queryVector;
|
|
134
|
+
|
|
135
|
+
// Use child process for embedding when unloadModelAfterSearch is enabled
|
|
136
|
+
// This ensures the OS completely reclaims memory when the child exits
|
|
137
|
+
if (this.config.unloadModelAfterSearch) {
|
|
138
|
+
queryVector = await embedQueryInChildProcess(query, this.config);
|
|
139
|
+
} else {
|
|
140
|
+
// Use main process embedder (faster for consecutive searches)
|
|
141
|
+
const queryEmbed = await this.embedder(query, {
|
|
142
|
+
pooling: 'mean',
|
|
143
|
+
normalize: true,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
queryVector = new Float32Array(queryEmbed.data);
|
|
148
|
+
} finally {
|
|
149
|
+
if (typeof queryEmbed.dispose === 'function') {
|
|
150
|
+
try {
|
|
151
|
+
queryEmbed.dispose();
|
|
152
|
+
} catch {
|
|
153
|
+
/* ignore */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let candidateIndices = null; // null implies full scan of all chunks
|
|
160
|
+
let usedAnn = false;
|
|
161
|
+
|
|
162
|
+
if (this.config.annEnabled) {
|
|
163
|
+
const candidateCount = this.getAnnCandidateCount(maxResults, storeSize);
|
|
164
|
+
const annLabels = await this.cache.queryAnn(queryVector, candidateCount);
|
|
165
|
+
if (annLabels && annLabels.length >= maxResults) {
|
|
166
|
+
usedAnn = true;
|
|
167
|
+
if (this.config.verbose) {
|
|
168
|
+
console.info(`[Search] Using ANN index (${annLabels.length} candidates)`);
|
|
169
|
+
}
|
|
170
|
+
candidateIndices = Array.from(new Set(annLabels)); // dedupe
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!usedAnn) {
|
|
175
|
+
if (this.config.verbose) {
|
|
176
|
+
console.info(`[Search] Using full scan (${storeSize} chunks)`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (usedAnn && candidateIndices && candidateIndices.length < maxResults) {
|
|
181
|
+
if (this.config.verbose) {
|
|
182
|
+
console.info(
|
|
183
|
+
`[Search] ANN returned fewer results (${candidateIndices.length}) than requested (${maxResults}), augmenting with full scan...`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
candidateIndices = null; // Fallback to full scan to ensure we don't miss anything relevant
|
|
187
|
+
usedAnn = false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const lowerQuery = query.toLowerCase();
|
|
191
|
+
const queryWords =
|
|
192
|
+
lowerQuery.length > 1 ? lowerQuery.split(/\s+/).filter((word) => word.length > 2) : [];
|
|
193
|
+
const queryWordCount = queryWords.length;
|
|
194
|
+
|
|
195
|
+
if (usedAnn && candidateIndices && lowerQuery.length > 1) {
|
|
196
|
+
let exactMatchCount = 0;
|
|
197
|
+
for (const index of candidateIndices) {
|
|
198
|
+
const content = await this.getChunkContent(index);
|
|
199
|
+
if (content && content.toLowerCase().includes(lowerQuery)) {
|
|
200
|
+
exactMatchCount++;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (exactMatchCount < maxResults) {
|
|
205
|
+
// Fallback to full scan if keyword constraint isn't met in candidates
|
|
206
|
+
// Note: This is expensive as it iterates everything.
|
|
207
|
+
// Optimization: Only do this for small-ish codebases to avoid UI freeze
|
|
208
|
+
const MAX_FULL_SCAN_SIZE = this.config.fullScanThreshold ?? 2000;
|
|
209
|
+
|
|
210
|
+
if (storeSize <= MAX_FULL_SCAN_SIZE) {
|
|
211
|
+
const seen = new Set(candidateIndices);
|
|
212
|
+
|
|
213
|
+
// Full scan logic for keyword augmentation
|
|
214
|
+
// Batch content loading to reduce async overhead
|
|
215
|
+
const FALLBACK_BATCH = 100;
|
|
216
|
+
let additionalMatches = 0;
|
|
217
|
+
const targetMatches = maxResults - exactMatchCount;
|
|
218
|
+
|
|
219
|
+
outerLoop:
|
|
220
|
+
for (let i = 0; i < storeSize; i += FALLBACK_BATCH) {
|
|
221
|
+
if (i > 0) await new Promise((r) => setTimeout(r, 0)); // Yield
|
|
222
|
+
|
|
223
|
+
const limit = Math.min(storeSize, i + FALLBACK_BATCH);
|
|
224
|
+
|
|
225
|
+
// Build batch of indices to check (excluding already seen)
|
|
226
|
+
const batchIndices = [];
|
|
227
|
+
for (let j = i; j < limit; j++) {
|
|
228
|
+
if (!seen.has(j)) batchIndices.push(j);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Batch load content in parallel
|
|
232
|
+
const contents = await Promise.all(
|
|
233
|
+
batchIndices.map(idx => this.getChunkContent(idx))
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// Check each loaded content
|
|
237
|
+
for (let k = 0; k < batchIndices.length; k++) {
|
|
238
|
+
const content = contents[k];
|
|
239
|
+
if (content && content.toLowerCase().includes(lowerQuery)) {
|
|
240
|
+
const idx = batchIndices[k];
|
|
241
|
+
seen.add(idx);
|
|
242
|
+
candidateIndices.push(idx);
|
|
243
|
+
additionalMatches++;
|
|
244
|
+
// Early exit once we have enough additional matches
|
|
245
|
+
if (additionalMatches >= targetMatches) break outerLoop;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
console.info(
|
|
251
|
+
`[Search] Skipping full scan fallback (store size ${storeSize} > ${MAX_FULL_SCAN_SIZE})`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Recency pre-processing
|
|
258
|
+
let recencyBoostEnabled = this.config.recencyBoost > 0;
|
|
259
|
+
let now = Date.now();
|
|
260
|
+
let recencyDecayMs = (this.config.recencyDecayDays || 30) * 24 * 60 * 60 * 1000;
|
|
261
|
+
let semanticWeight = this.config.semanticWeight;
|
|
262
|
+
let exactMatchBoost = this.config.exactMatchBoost;
|
|
263
|
+
let recencyBoost = this.config.recencyBoost;
|
|
264
|
+
|
|
265
|
+
if (recencyBoostEnabled) {
|
|
266
|
+
const candidates = candidateIndices
|
|
267
|
+
? candidateIndices.map((idx) => this.cache.getChunk(idx)).filter(Boolean)
|
|
268
|
+
: Array.from({ length: storeSize }, (_, i) => this.cache.getChunk(i)).filter(Boolean);
|
|
269
|
+
// optimization: avoid IO storm during full scan fallbacks
|
|
270
|
+
// For large candidate sets, we strictly rely on cached metadata
|
|
271
|
+
// For small sets, we allow best-effort fs.stat
|
|
272
|
+
if (candidates.length <= 1000) {
|
|
273
|
+
await this.populateFileModTimes(candidates.map((chunk) => chunk.file));
|
|
274
|
+
} else {
|
|
275
|
+
// Bulk pre-populate from cache only (no syscalls)
|
|
276
|
+
for (const chunk of candidates) {
|
|
277
|
+
if (!this.fileModTimes.has(chunk.file)) {
|
|
278
|
+
const meta = this.cache.getFileMeta(chunk.file);
|
|
279
|
+
if (meta && typeof meta.mtimeMs === 'number') {
|
|
280
|
+
this.fileModTimes.set(chunk.file, meta.mtimeMs);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Score all chunks (batched to prevent blocking event loop)
|
|
288
|
+
const scoredChunks = [];
|
|
289
|
+
|
|
290
|
+
// Process in batches
|
|
291
|
+
// Candidates is now implicitly range 0..storeSize OR candidateIndices
|
|
292
|
+
const totalCandidates = candidateIndices ? candidateIndices.length : storeSize;
|
|
293
|
+
const textMatchMaxCandidates = Number.isInteger(this.config.textMatchMaxCandidates)
|
|
294
|
+
? this.config.textMatchMaxCandidates
|
|
295
|
+
: 2000;
|
|
296
|
+
const shouldApplyTextMatch = lowerQuery.length > 1;
|
|
297
|
+
const deferTextMatch = shouldApplyTextMatch && totalCandidates > textMatchMaxCandidates;
|
|
298
|
+
|
|
299
|
+
for (let i = 0; i < totalCandidates; i += SEARCH_BATCH_SIZE) {
|
|
300
|
+
// Allow event loop to tick between batches
|
|
301
|
+
if (i > 0) {
|
|
302
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const limit = Math.min(totalCandidates, i + SEARCH_BATCH_SIZE);
|
|
306
|
+
|
|
307
|
+
for (let j = i; j < limit; j++) {
|
|
308
|
+
const idx = candidateIndices ? candidateIndices[j] : j;
|
|
309
|
+
|
|
310
|
+
// CRITICAL: Fetch chunk info FIRST to ensure atomicity with index.
|
|
311
|
+
// If we fetch vector and chunk separately, the store could be modified
|
|
312
|
+
// between calls (e.g., by removeFileFromStore compacting the array).
|
|
313
|
+
const chunkInfo = this.cache.getChunk(idx);
|
|
314
|
+
if (!chunkInfo) {
|
|
315
|
+
// Chunk was removed or index is stale - skip silently
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Get vector from chunk or via index (now safe since we have valid chunkInfo)
|
|
320
|
+
const vector = this.cache.getChunkVector(chunkInfo, idx);
|
|
321
|
+
if (!vector) continue;
|
|
322
|
+
|
|
323
|
+
// Ensure vector compatibility with try-catch for dimension mismatch
|
|
324
|
+
let score;
|
|
325
|
+
try {
|
|
326
|
+
score = dotSimilarity(queryVector, vector) * semanticWeight;
|
|
327
|
+
} catch (err) {
|
|
328
|
+
// Dimension mismatch indicates config change - log and skip this chunk
|
|
329
|
+
if (this.config.verbose) {
|
|
330
|
+
console.warn(`[Search] ${err.message} at index ${idx}`);
|
|
331
|
+
}
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let content;
|
|
336
|
+
if (shouldApplyTextMatch && !deferTextMatch) {
|
|
337
|
+
content = await this.getChunkContent(idx);
|
|
338
|
+
const lowerContent = content ? content.toLowerCase() : '';
|
|
339
|
+
|
|
340
|
+
if (lowerContent && lowerContent.includes(lowerQuery)) {
|
|
341
|
+
score += exactMatchBoost;
|
|
342
|
+
} else if (lowerContent && queryWordCount > 0) {
|
|
343
|
+
// Partial word matching (optimized)
|
|
344
|
+
let matchedWords = 0;
|
|
345
|
+
for (let k = 0; k < queryWordCount; k++) {
|
|
346
|
+
if (lowerContent.includes(queryWords[k])) matchedWords++;
|
|
347
|
+
}
|
|
348
|
+
score += (matchedWords / queryWordCount) * PARTIAL_MATCH_BOOST;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Recency boost
|
|
353
|
+
if (recencyBoostEnabled) {
|
|
354
|
+
const mtime = this.fileModTimes.get(chunkInfo.file);
|
|
355
|
+
if (typeof mtime === 'number') {
|
|
356
|
+
const ageMs = now - mtime;
|
|
357
|
+
const recencyFactor = Math.max(0, 1 - ageMs / recencyDecayMs);
|
|
358
|
+
score += recencyFactor * recencyBoost;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const scoredChunk = { ...chunkInfo, score };
|
|
363
|
+
if (content !== undefined) {
|
|
364
|
+
scoredChunk.content = content;
|
|
365
|
+
}
|
|
366
|
+
scoredChunks.push(scoredChunk);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Sort by initial score
|
|
371
|
+
scoredChunks.sort((a, b) => b.score - a.score);
|
|
372
|
+
|
|
373
|
+
// Defer expensive text matching for large candidate sets
|
|
374
|
+
if (deferTextMatch) {
|
|
375
|
+
const textMatchCount = Math.min(textMatchMaxCandidates, scoredChunks.length);
|
|
376
|
+
for (let i = 0; i < textMatchCount; i++) {
|
|
377
|
+
const chunk = scoredChunks[i];
|
|
378
|
+
const content = chunk.content ?? (await this.getChunkContent(chunk));
|
|
379
|
+
const lowerContent = content ? content.toLowerCase() : '';
|
|
380
|
+
|
|
381
|
+
if (lowerContent && lowerContent.includes(lowerQuery)) {
|
|
382
|
+
chunk.score += exactMatchBoost;
|
|
383
|
+
} else if (lowerContent && queryWordCount > 0) {
|
|
384
|
+
let matchedWords = 0;
|
|
385
|
+
for (let k = 0; k < queryWordCount; k++) {
|
|
386
|
+
if (lowerContent.includes(queryWords[k])) matchedWords++;
|
|
387
|
+
}
|
|
388
|
+
chunk.score += (matchedWords / queryWordCount) * 0.3;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (chunk.content === undefined) {
|
|
392
|
+
chunk.content = content;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
scoredChunks.sort((a, b) => b.score - a.score);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Apply call graph proximity boost if enabled
|
|
399
|
+
if (this.config.callGraphEnabled && this.config.callGraphBoost > 0) {
|
|
400
|
+
// Extract symbols from top initial results
|
|
401
|
+
const topN = Math.min(5, scoredChunks.length);
|
|
402
|
+
const symbolsFromTop = new Set();
|
|
403
|
+
for (let i = 0; i < topN; i++) {
|
|
404
|
+
const content = await this.getChunkContent(scoredChunks[i]);
|
|
405
|
+
const symbols = extractSymbolsFromContent(content || '');
|
|
406
|
+
for (const sym of symbols) {
|
|
407
|
+
symbolsFromTop.add(sym);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (symbolsFromTop.size > 0) {
|
|
412
|
+
// Get related files from call graph
|
|
413
|
+
const relatedFiles = await this.cache.getRelatedFiles(Array.from(symbolsFromTop));
|
|
414
|
+
|
|
415
|
+
// Apply boost to chunks from related files
|
|
416
|
+
for (const chunk of scoredChunks) {
|
|
417
|
+
const proximity = relatedFiles.get(chunk.file);
|
|
418
|
+
if (proximity) {
|
|
419
|
+
chunk.score += proximity * this.config.callGraphBoost;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// Re-sort after applying call graph boost
|
|
423
|
+
scoredChunks.sort((a, b) => b.score - a.score);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Get top results
|
|
428
|
+
const results = await Promise.all(
|
|
429
|
+
scoredChunks.slice(0, maxResults).map(async (chunk) => {
|
|
430
|
+
if (chunk.content === undefined || chunk.content === null) {
|
|
431
|
+
return { ...chunk, content: await this.getChunkContent(chunk) };
|
|
432
|
+
}
|
|
433
|
+
return chunk;
|
|
434
|
+
})
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
if (results.length > 0) {
|
|
438
|
+
console.info(
|
|
439
|
+
`[Search] Found ${results.length} results. Top score: ${results[0].score.toFixed(4)}`
|
|
440
|
+
);
|
|
441
|
+
} else {
|
|
442
|
+
console.info('[Search] No results found.');
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return { results, message: null };
|
|
446
|
+
} finally {
|
|
447
|
+
this.cache.endRead();
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async formatResults(results) {
|
|
452
|
+
if (results.length === 0) {
|
|
453
|
+
return 'No matching code found for your query.';
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const formatted = await Promise.all(
|
|
457
|
+
results.map(async (r, idx) => {
|
|
458
|
+
if (!r.file) {
|
|
459
|
+
return `## Result ${idx + 1} (Relevance: ${(r.score * 100).toFixed(1)}%)\n**Error:** Missing file path\n`;
|
|
460
|
+
}
|
|
461
|
+
const relPath = path.relative(this.config.searchDirectory, r.file);
|
|
462
|
+
const content = r.content ?? (await this.getChunkContent(r));
|
|
463
|
+
return (
|
|
464
|
+
`## Result ${idx + 1} (Relevance: ${(r.score * 100).toFixed(1)}%)\n` +
|
|
465
|
+
`**File:** \`${relPath}\`\n` +
|
|
466
|
+
`**Lines:** ${r.startLine}-${r.endLine}\n\n` +
|
|
467
|
+
'```' +
|
|
468
|
+
path.extname(r.file).slice(1) +
|
|
469
|
+
'\n' +
|
|
470
|
+
content +
|
|
471
|
+
'\n' +
|
|
472
|
+
'```\n'
|
|
473
|
+
);
|
|
474
|
+
})
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
return formatted.join('\n');
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// MCP Tool definition for this feature
|
|
482
|
+
export function getToolDefinition(config) {
|
|
483
|
+
return {
|
|
484
|
+
name: 'a_semantic_search',
|
|
485
|
+
description:
|
|
486
|
+
"Performs intelligent hybrid code search combining semantic understanding with exact text matching. Ideal for finding code by meaning (e.g., 'authentication logic', 'database queries') even with typos or variations. Returns the most relevant code snippets with file locations and line numbers.",
|
|
487
|
+
inputSchema: {
|
|
488
|
+
type: 'object',
|
|
489
|
+
properties: {
|
|
490
|
+
query: {
|
|
491
|
+
type: 'string',
|
|
492
|
+
description:
|
|
493
|
+
"Search query - can be natural language (e.g., 'where do we handle user login') or specific terms",
|
|
494
|
+
},
|
|
495
|
+
maxResults: {
|
|
496
|
+
type: 'number',
|
|
497
|
+
description: 'Maximum number of results to return (default: from config)',
|
|
498
|
+
default: config.maxResults,
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
required: ['query'],
|
|
502
|
+
},
|
|
503
|
+
annotations: {
|
|
504
|
+
title: 'Semantic Code Search',
|
|
505
|
+
readOnlyHint: true,
|
|
506
|
+
destructiveHint: false,
|
|
507
|
+
idempotentHint: true,
|
|
508
|
+
openWorldHint: false,
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Tool handler
|
|
514
|
+
export async function handleToolCall(request, hybridSearch) {
|
|
515
|
+
const args = request.params?.arguments || {};
|
|
516
|
+
const query = args.query;
|
|
517
|
+
|
|
518
|
+
// Input validation
|
|
519
|
+
if (typeof query !== 'string' || query.trim().length === 0) {
|
|
520
|
+
return {
|
|
521
|
+
content: [{ type: 'text', text: 'Error: A non-empty query string is required.' }],
|
|
522
|
+
isError: true,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const maxResults =
|
|
527
|
+
typeof args.maxResults === 'number' && args.maxResults > 0
|
|
528
|
+
? args.maxResults
|
|
529
|
+
: hybridSearch.config.maxResults;
|
|
530
|
+
|
|
531
|
+
const { results, message } = await hybridSearch.search(query, maxResults);
|
|
532
|
+
|
|
533
|
+
if (message) {
|
|
534
|
+
return {
|
|
535
|
+
content: [{ type: 'text', text: message }],
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const formattedText = await hybridSearch.formatResults(results);
|
|
540
|
+
|
|
541
|
+
return {
|
|
542
|
+
content: [{ type: 'text', text: formattedText }],
|
|
543
|
+
};
|
|
544
|
+
}
|