@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.
Files changed (49) hide show
  1. package/README.md +104 -104
  2. package/config.jsonc +173 -173
  3. package/features/ann-config.js +131 -0
  4. package/features/clear-cache.js +84 -0
  5. package/features/find-similar-code.js +291 -0
  6. package/features/hybrid-search.js +544 -0
  7. package/features/index-codebase.js +3268 -0
  8. package/features/lifecycle.js +1189 -0
  9. package/features/package-version.js +302 -0
  10. package/features/register.js +408 -0
  11. package/features/resources.js +156 -0
  12. package/features/set-workspace.js +265 -0
  13. package/index.js +96 -96
  14. package/lib/cache-ops.js +22 -22
  15. package/lib/cache-utils.js +565 -565
  16. package/lib/cache.js +1870 -1870
  17. package/lib/call-graph.js +396 -396
  18. package/lib/cli.js +1 -1
  19. package/lib/config.js +517 -517
  20. package/lib/constants.js +39 -39
  21. package/lib/embed-query-process.js +7 -7
  22. package/lib/embedding-process.js +7 -7
  23. package/lib/embedding-worker.js +299 -299
  24. package/lib/ignore-patterns.js +316 -316
  25. package/lib/json-worker.js +14 -14
  26. package/lib/json-writer.js +337 -337
  27. package/lib/logging.js +164 -164
  28. package/lib/memory-logger.js +13 -13
  29. package/lib/onnx-backend.js +193 -193
  30. package/lib/project-detector.js +84 -84
  31. package/lib/server-lifecycle.js +165 -165
  32. package/lib/settings-editor.js +754 -754
  33. package/lib/tokenizer.js +256 -256
  34. package/lib/utils.js +428 -428
  35. package/lib/vector-store-binary.js +627 -627
  36. package/lib/vector-store-sqlite.js +95 -95
  37. package/lib/workspace-env.js +28 -28
  38. package/mcp_config.json +9 -9
  39. package/package.json +86 -75
  40. package/scripts/clear-cache.js +20 -0
  41. package/scripts/download-model.js +43 -0
  42. package/scripts/mcp-launcher.js +49 -0
  43. package/scripts/postinstall.js +12 -0
  44. package/search-configs.js +36 -36
  45. package/.prettierrc +0 -7
  46. package/debug-pids.js +0 -30
  47. package/eslint.config.js +0 -36
  48. package/specs/plan.md +0 -23
  49. 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
+ }