@softerist/heuristic-mcp 2.0.0
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/ARCHITECTURE.md +287 -0
- package/CONTRIBUTING.md +308 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/config.json +66 -0
- package/example.png +0 -0
- package/features/clear-cache.js +75 -0
- package/features/find-similar-code.js +127 -0
- package/features/hybrid-search.js +173 -0
- package/features/index-codebase.js +811 -0
- package/how-its-works.png +0 -0
- package/index.js +208 -0
- package/lib/cache.js +163 -0
- package/lib/config.js +257 -0
- package/lib/embedding-worker.js +67 -0
- package/lib/ignore-patterns.js +314 -0
- package/lib/project-detector.js +75 -0
- package/lib/tokenizer.js +142 -0
- package/lib/utils.js +301 -0
- package/package.json +65 -0
- package/scripts/clear-cache.js +31 -0
- package/test/clear-cache.test.js +288 -0
- package/test/embedding-model.test.js +230 -0
- package/test/helpers.js +128 -0
- package/test/hybrid-search.test.js +243 -0
- package/test/index-codebase.test.js +246 -0
- package/test/integration.test.js +223 -0
- package/test/tokenizer.test.js +225 -0
- package/vitest.config.js +29 -0
package/lib/utils.js
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { estimateTokens, getChunkingParams, getModelTokenLimit } from "./tokenizer.js";
|
|
4
|
+
|
|
5
|
+
// Re-export tokenizer utilities
|
|
6
|
+
export { estimateTokens, getChunkingParams, getModelTokenLimit, MODEL_TOKEN_LIMITS } from "./tokenizer.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Calculate cosine similarity between two vectors
|
|
10
|
+
*/
|
|
11
|
+
export function cosineSimilarity(a, b) {
|
|
12
|
+
let dot = 0, normA = 0, normB = 0;
|
|
13
|
+
for (let i = 0; i < a.length; i++) {
|
|
14
|
+
dot += a[i] * b[i];
|
|
15
|
+
normA += a[i] * a[i];
|
|
16
|
+
normB += b[i] * b[i];
|
|
17
|
+
}
|
|
18
|
+
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Fast similarity for normalized vectors (dot product)
|
|
23
|
+
*/
|
|
24
|
+
export function dotSimilarity(a, b) {
|
|
25
|
+
let dot = 0;
|
|
26
|
+
for (let i = 0; i < a.length; i++) {
|
|
27
|
+
dot += a[i] * b[i];
|
|
28
|
+
}
|
|
29
|
+
return dot;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Generate hash for file content to detect changes
|
|
34
|
+
*/
|
|
35
|
+
export function hashContent(content) {
|
|
36
|
+
return crypto.createHash("md5").update(content).digest("hex");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Intelligent chunking with token limit awareness
|
|
41
|
+
* Tries to split by function/class boundaries while respecting token limits
|
|
42
|
+
*
|
|
43
|
+
* @param {string} content - File content to chunk
|
|
44
|
+
* @param {string} file - File path (for language detection)
|
|
45
|
+
* @param {object} config - Configuration object with embeddingModel
|
|
46
|
+
* @returns {Array<{text: string, startLine: number, endLine: number, tokenCount: number}>}
|
|
47
|
+
*/
|
|
48
|
+
export function smartChunk(content, file, config) {
|
|
49
|
+
const lines = content.split("\n");
|
|
50
|
+
const chunks = [];
|
|
51
|
+
const ext = path.extname(file);
|
|
52
|
+
|
|
53
|
+
// Get model-specific chunking parameters
|
|
54
|
+
const { targetTokens, overlapTokens } = getChunkingParams(config.embeddingModel);
|
|
55
|
+
|
|
56
|
+
// Language-specific patterns for function/class detection
|
|
57
|
+
const patterns = {
|
|
58
|
+
// JavaScript/TypeScript
|
|
59
|
+
js: /^(export\s+)?(async\s+)?(function|class|const|let|var)\s+\w+/,
|
|
60
|
+
jsx: /^(export\s+)?(async\s+)?(function|class|const|let|var)\s+\w+/,
|
|
61
|
+
ts: /^(export\s+)?(async\s+)?(function|class|const|let|var|interface|type)\s+\w+/,
|
|
62
|
+
tsx: /^(export\s+)?(async\s+)?(function|class|const|let|var|interface|type)\s+\w+/,
|
|
63
|
+
mjs: /^(export\s+)?(async\s+)?(function|class|const|let|var)\s+\w+/,
|
|
64
|
+
cjs: /^(export\s+)?(async\s+)?(function|class|const|let|var)\s+\w+/,
|
|
65
|
+
|
|
66
|
+
// Python
|
|
67
|
+
py: /^(class|def|async\s+def)\s+\w+/,
|
|
68
|
+
pyw: /^(class|def|async\s+def)\s+\w+/,
|
|
69
|
+
pyx: /^(cdef|cpdef|def|class)\s+\w+/, // Cython
|
|
70
|
+
|
|
71
|
+
// Java/Kotlin/Scala
|
|
72
|
+
java: /^(public|private|protected)?\s*(static\s+)?(class|interface|enum|void|int|String|boolean)\s+\w+/,
|
|
73
|
+
kt: /^(class|interface|object|fun|val|var)\s+\w+/,
|
|
74
|
+
kts: /^(class|interface|object|fun|val|var)\s+\w+/,
|
|
75
|
+
scala: /^(class|object|trait|def|val|var)\s+\w+/,
|
|
76
|
+
|
|
77
|
+
// C/C++
|
|
78
|
+
c: /^(struct|enum|union|void|int|char|float|double)\s+\w+/,
|
|
79
|
+
cpp: /^(class|struct|namespace|template|void|int|bool)\s+\w+/,
|
|
80
|
+
cc: /^(class|struct|namespace|template|void|int|bool)\s+\w+/,
|
|
81
|
+
cxx: /^(class|struct|namespace|template|void|int|bool)\s+\w+/,
|
|
82
|
+
h: /^(class|struct|namespace|template|void|int|bool)\s+\w+/,
|
|
83
|
+
hpp: /^(class|struct|namespace|template|void|int|bool)\s+\w+/,
|
|
84
|
+
hxx: /^(class|struct|namespace|template|void|int|bool)\s+\w+/,
|
|
85
|
+
|
|
86
|
+
// C#
|
|
87
|
+
cs: /^(public|private|protected)?\s*(static\s+)?(class|interface|struct|enum|void|int|string|bool)\s+\w+/,
|
|
88
|
+
csx: /^(public|private|protected)?\s*(static\s+)?(class|interface|struct|enum|void|int|string|bool)\s+\w+/,
|
|
89
|
+
|
|
90
|
+
// Go
|
|
91
|
+
go: /^(func|type|const|var)\s+\w+/,
|
|
92
|
+
|
|
93
|
+
// Rust
|
|
94
|
+
rs: /^(pub\s+)?(fn|struct|enum|trait|impl|const|static|mod)\s+\w+/,
|
|
95
|
+
|
|
96
|
+
// PHP
|
|
97
|
+
php: /^(class|interface|trait|function|const)\s+\w+/,
|
|
98
|
+
phtml: /^(<\?php|class|interface|trait|function)\s*/,
|
|
99
|
+
|
|
100
|
+
// Ruby
|
|
101
|
+
rb: /^(class|module|def)\s+\w+/,
|
|
102
|
+
rake: /^(class|module|def|task|namespace)\s+\w+/,
|
|
103
|
+
|
|
104
|
+
// Swift
|
|
105
|
+
swift: /^(class|struct|enum|protocol|func|var|let|extension)\s+\w+/,
|
|
106
|
+
|
|
107
|
+
// R
|
|
108
|
+
r: /^(\w+)\s*(<-|=)\s*function/,
|
|
109
|
+
R: /^(\w+)\s*(<-|=)\s*function/,
|
|
110
|
+
|
|
111
|
+
// Lua
|
|
112
|
+
lua: /^(function|local\s+function)\s+\w+/,
|
|
113
|
+
|
|
114
|
+
// Shell scripts
|
|
115
|
+
sh: /^(\w+\s*\(\)|function\s+\w+)/,
|
|
116
|
+
bash: /^(\w+\s*\(\)|function\s+\w+)/,
|
|
117
|
+
zsh: /^(\w+\s*\(\)|function\s+\w+)/,
|
|
118
|
+
fish: /^function\s+\w+/,
|
|
119
|
+
|
|
120
|
+
// CSS/Styles
|
|
121
|
+
css: /^(\.|#|@media|@keyframes|@font-face|\w+)\s*[{,]/,
|
|
122
|
+
scss: /^(\$\w+:|@mixin|@function|@include|\.|#|@media)\s*/,
|
|
123
|
+
sass: /^(\$\w+:|=\w+|\+\w+|\.|#|@media)\s*/,
|
|
124
|
+
less: /^(@\w+:|\.|\#|@media)\s*/,
|
|
125
|
+
styl: /^(\$\w+\s*=|\w+\(|\.|\#)\s*/,
|
|
126
|
+
|
|
127
|
+
// Markup/HTML
|
|
128
|
+
html: /^(<(div|section|article|header|footer|nav|main|aside|form|table|template|script|style)\b)/i,
|
|
129
|
+
htm: /^(<(div|section|article|header|footer|nav|main|aside|form|table|template|script|style)\b)/i,
|
|
130
|
+
xml: /^(<\w+|\s*<!\[CDATA\[)/,
|
|
131
|
+
svg: /^(<svg|<g|<path|<defs|<symbol)\b/,
|
|
132
|
+
|
|
133
|
+
// Config files
|
|
134
|
+
json: /^(\s*"[\w-]+"\s*:\s*[\[{])/,
|
|
135
|
+
yaml: /^(\w[\w-]*:\s*[|>]?$|\w[\w-]*:\s*$)/,
|
|
136
|
+
yml: /^(\w[\w-]*:\s*[|>]?$|\w[\w-]*:\s*$)/,
|
|
137
|
+
toml: /^(\[\[?\w+\]?\]?|\w+\s*=)/,
|
|
138
|
+
ini: /^(\[\w+\]|\w+\s*=)/,
|
|
139
|
+
env: /^[A-Z_][A-Z0-9_]*=/,
|
|
140
|
+
|
|
141
|
+
// Documentation
|
|
142
|
+
md: /^(#{1,6}\s+|```|\*{3}|_{3})/,
|
|
143
|
+
mdx: /^(#{1,6}\s+|```|import\s+|export\s+)/,
|
|
144
|
+
txt: /^.{50,}/, // Split on long paragraphs
|
|
145
|
+
rst: /^(={3,}|-{3,}|~{3,}|\.\.\s+\w+::)/,
|
|
146
|
+
|
|
147
|
+
// Database
|
|
148
|
+
sql: /^(CREATE|ALTER|INSERT|UPDATE|DELETE|SELECT|DROP|GRANT|REVOKE|WITH|DECLARE|BEGIN|END)\s+/i,
|
|
149
|
+
|
|
150
|
+
// Perl
|
|
151
|
+
pl: /^(sub|package|use|require)\s+\w+/,
|
|
152
|
+
pm: /^(sub|package|use|require)\s+\w+/,
|
|
153
|
+
|
|
154
|
+
// Vim
|
|
155
|
+
vim: /^(function|command|autocmd|let\s+g:)\s*/,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const langPattern = patterns[ext.slice(1)] || patterns.js;
|
|
159
|
+
let currentChunk = [];
|
|
160
|
+
let chunkStartLine = 0;
|
|
161
|
+
let currentTokenCount = 0;
|
|
162
|
+
|
|
163
|
+
// Track bracket depth for better boundary detection
|
|
164
|
+
let bracketDepth = 0;
|
|
165
|
+
let braceDepth = 0;
|
|
166
|
+
let parenDepth = 0;
|
|
167
|
+
let inString = false;
|
|
168
|
+
let inComment = false;
|
|
169
|
+
let stringChar = null; // ' or " or `
|
|
170
|
+
|
|
171
|
+
for (let i = 0; i < lines.length; i++) {
|
|
172
|
+
const line = lines[i];
|
|
173
|
+
const lineTokens = estimateTokens(line);
|
|
174
|
+
const trimmed = line.trim();
|
|
175
|
+
|
|
176
|
+
// Simple state tracking for heuristics (not a full parser)
|
|
177
|
+
if (inComment) {
|
|
178
|
+
// Look for end of block comment
|
|
179
|
+
if (line.includes('*/')) {
|
|
180
|
+
const parts = line.split('*/');
|
|
181
|
+
// If there's content after the comment, process it (simplified)
|
|
182
|
+
if (parts[parts.length - 1].trim().length > 0) {
|
|
183
|
+
inComment = false;
|
|
184
|
+
// Recursive call or continue logic would be better, but for heuristic this is fine
|
|
185
|
+
// We just assume the line is mixed and skip granular checks
|
|
186
|
+
} else {
|
|
187
|
+
inComment = false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
for (let j = 0; j < line.length; j++) {
|
|
192
|
+
const char = line[j];
|
|
193
|
+
const nextChar = line[j+1];
|
|
194
|
+
|
|
195
|
+
if (inString) {
|
|
196
|
+
if (char === '\\') {
|
|
197
|
+
j++; // Skip escaped char
|
|
198
|
+
} else if (char === stringChar) {
|
|
199
|
+
inString = false;
|
|
200
|
+
stringChar = null;
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
// Check for comment start
|
|
204
|
+
if (char === '/' && nextChar === '*') {
|
|
205
|
+
inComment = true;
|
|
206
|
+
j++;
|
|
207
|
+
// Check if it ends on same line
|
|
208
|
+
if (line.indexOf('*/', j) !== -1) {
|
|
209
|
+
inComment = false;
|
|
210
|
+
j = line.indexOf('*/', j) + 1;
|
|
211
|
+
} else {
|
|
212
|
+
break; // Rest of line is comment
|
|
213
|
+
}
|
|
214
|
+
} else if (char === '/' && nextChar === '/') {
|
|
215
|
+
break; // Skip rest of line (line comment)
|
|
216
|
+
} else if (char === '\'' || char === '"' || char === '`') {
|
|
217
|
+
inString = true;
|
|
218
|
+
stringChar = char;
|
|
219
|
+
} else {
|
|
220
|
+
// Only count brackets if not in string or comment
|
|
221
|
+
if (char === '{') braceDepth++;
|
|
222
|
+
else if (char === '}') braceDepth = Math.max(0, braceDepth - 1);
|
|
223
|
+
else if (char === '[') bracketDepth++;
|
|
224
|
+
else if (char === ']') bracketDepth = Math.max(0, bracketDepth - 1);
|
|
225
|
+
else if (char === '(') parenDepth++;
|
|
226
|
+
else if (char === ')') parenDepth = Math.max(0, parenDepth - 1);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Check if adding this line would exceed token limit
|
|
233
|
+
const wouldExceedLimit = (currentTokenCount + lineTokens) > targetTokens;
|
|
234
|
+
|
|
235
|
+
// Check if this is a good split point using multiple heuristics
|
|
236
|
+
const matchesPattern = langPattern.test(trimmed);
|
|
237
|
+
const atTopLevel = braceDepth === 0 && bracketDepth === 0 && parenDepth === 0 && !inString && !inComment;
|
|
238
|
+
const startsAtColumn0 = line.length > 0 && /^\S/.test(line);
|
|
239
|
+
const isEmptyLine = trimmed.length === 0;
|
|
240
|
+
const prevWasEmpty = i > 0 && currentChunk.length > 0 && currentChunk[currentChunk.length - 1].trim().length === 0;
|
|
241
|
+
const isCommentStart = /^\s*(\/\*\*|\/\/\s*[-=]{3,}|#\s*[-=]{3,})/.test(line);
|
|
242
|
+
|
|
243
|
+
const isGoodSplitPoint = currentChunk.length > 3 && (
|
|
244
|
+
(matchesPattern && (atTopLevel || braceDepth <= 1)) ||
|
|
245
|
+
(atTopLevel && startsAtColumn0 && !isEmptyLine) ||
|
|
246
|
+
(prevWasEmpty && (matchesPattern || isCommentStart))
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const shouldSplit = wouldExceedLimit || (isGoodSplitPoint && currentTokenCount > targetTokens * 0.6);
|
|
250
|
+
|
|
251
|
+
// Avoid splitting in weird states if possible
|
|
252
|
+
const safeToSplit = (braceDepth <= 1 && !inString) || wouldExceedLimit;
|
|
253
|
+
|
|
254
|
+
if (shouldSplit && safeToSplit && currentChunk.length > 0) {
|
|
255
|
+
const chunkText = currentChunk.join("\n");
|
|
256
|
+
if (chunkText.trim().length > 20) {
|
|
257
|
+
chunks.push({
|
|
258
|
+
text: chunkText,
|
|
259
|
+
startLine: chunkStartLine + 1,
|
|
260
|
+
endLine: i,
|
|
261
|
+
tokenCount: currentTokenCount
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Calculate overlap
|
|
266
|
+
let overlapLines = [];
|
|
267
|
+
let overlapTokensCount = 0;
|
|
268
|
+
for (let j = currentChunk.length - 1; j >= 0 && overlapTokensCount < overlapTokens; j--) {
|
|
269
|
+
const lineT = estimateTokens(currentChunk[j]);
|
|
270
|
+
if (overlapTokensCount + lineT <= overlapTokens) {
|
|
271
|
+
overlapLines.unshift(currentChunk[j]);
|
|
272
|
+
overlapTokensCount += lineT;
|
|
273
|
+
} else {
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
currentChunk = overlapLines;
|
|
279
|
+
currentTokenCount = overlapTokensCount;
|
|
280
|
+
chunkStartLine = i - overlapLines.length;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
currentChunk.push(line);
|
|
284
|
+
currentTokenCount += lineTokens;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Add remaining chunk
|
|
288
|
+
if (currentChunk.length > 0) {
|
|
289
|
+
const chunkText = currentChunk.join("\n");
|
|
290
|
+
if (chunkText.trim().length > 20) {
|
|
291
|
+
chunks.push({
|
|
292
|
+
text: chunkText,
|
|
293
|
+
startLine: chunkStartLine + 1,
|
|
294
|
+
endLine: lines.length,
|
|
295
|
+
tokenCount: currentTokenCount
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return chunks;
|
|
301
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@softerist/heuristic-mcp",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "An enhanced MCP server providing intelligent semantic code search with find-similar-code, recency ranking, and improved chunking. Fork of smart-coding-mcp.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"heuristic-mcp": "index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node index.js",
|
|
12
|
+
"dev": "node --watch index.js",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"test:watch": "vitest",
|
|
15
|
+
"clear-cache": "node scripts/clear-cache.js"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"mcp",
|
|
19
|
+
"semantic-search",
|
|
20
|
+
"code-search",
|
|
21
|
+
"embeddings",
|
|
22
|
+
"ai",
|
|
23
|
+
"model-context-protocol",
|
|
24
|
+
"hybrid-search",
|
|
25
|
+
"code-intelligence",
|
|
26
|
+
"cursor",
|
|
27
|
+
"vscode",
|
|
28
|
+
"claude",
|
|
29
|
+
"codex",
|
|
30
|
+
"openai",
|
|
31
|
+
"gemini",
|
|
32
|
+
"anthropic",
|
|
33
|
+
"antigravity",
|
|
34
|
+
"heuristic"
|
|
35
|
+
],
|
|
36
|
+
"author": {
|
|
37
|
+
"name": "Softerist",
|
|
38
|
+
"url": "https://softerist.com"
|
|
39
|
+
},
|
|
40
|
+
"contributors": [
|
|
41
|
+
{
|
|
42
|
+
"name": "Omar Haris",
|
|
43
|
+
"url": "https://www.linkedin.com/in/omarharis/"
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "https://github.com/softerist/heuristic-mcp"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://github.com/softerist/heuristic-mcp#readme",
|
|
51
|
+
"license": "MIT",
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
54
|
+
"@xenova/transformers": "^2.17.2",
|
|
55
|
+
"chokidar": "^3.5.3",
|
|
56
|
+
"fdir": "^6.5.0",
|
|
57
|
+
"glob": "^10.3.10"
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=18.0.0"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"vitest": "^4.0.16"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
async function clearCache() {
|
|
6
|
+
try {
|
|
7
|
+
const configPath = path.join(process.cwd(), "config.json");
|
|
8
|
+
let cacheDir = "./.smart-coding-cache";
|
|
9
|
+
|
|
10
|
+
// Try to load cache directory from config
|
|
11
|
+
try {
|
|
12
|
+
const configData = await fs.readFile(configPath, "utf-8");
|
|
13
|
+
const config = JSON.parse(configData);
|
|
14
|
+
if (config.cacheDirectory) {
|
|
15
|
+
cacheDir = path.resolve(config.cacheDirectory);
|
|
16
|
+
}
|
|
17
|
+
} catch {
|
|
18
|
+
console.log("Using default cache directory");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Remove cache directory
|
|
22
|
+
await fs.rm(cacheDir, { recursive: true, force: true });
|
|
23
|
+
console.log(`Cache cleared successfully: ${cacheDir}`);
|
|
24
|
+
console.log("Next startup will perform a full reindex.");
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.error(`Error clearing cache: ${error.message}`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
clearCache();
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for CacheClearer feature
|
|
3
|
+
*
|
|
4
|
+
* Tests the cache clearing functionality including:
|
|
5
|
+
* - Basic cache clearing
|
|
6
|
+
* - Protection during indexing
|
|
7
|
+
* - Protection during save operations
|
|
8
|
+
* - Concurrent clear prevention
|
|
9
|
+
* - Tool handler responses
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
13
|
+
import {
|
|
14
|
+
createTestFixtures,
|
|
15
|
+
cleanupFixtures,
|
|
16
|
+
clearTestCache,
|
|
17
|
+
createMockRequest
|
|
18
|
+
} from './helpers.js';
|
|
19
|
+
import * as ClearCacheFeature from '../features/clear-cache.js';
|
|
20
|
+
import { CacheClearer } from '../features/clear-cache.js';
|
|
21
|
+
import fs from 'fs/promises';
|
|
22
|
+
import path from 'path';
|
|
23
|
+
|
|
24
|
+
describe('CacheClearer', () => {
|
|
25
|
+
let fixtures;
|
|
26
|
+
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
fixtures = await createTestFixtures({ workerThreads: 2 });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterAll(async () => {
|
|
32
|
+
await cleanupFixtures(fixtures);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
// Reset state
|
|
37
|
+
fixtures.indexer.isIndexing = false;
|
|
38
|
+
fixtures.cache.isSaving = false;
|
|
39
|
+
fixtures.cacheClearer.isClearing = false;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('Basic Cache Clearing', () => {
|
|
43
|
+
it('should clear cache successfully', async () => {
|
|
44
|
+
// First ensure we have a cache
|
|
45
|
+
await fixtures.indexer.indexAll(true);
|
|
46
|
+
|
|
47
|
+
// Verify cache exists
|
|
48
|
+
expect(fixtures.cache.getVectorStore().length).toBeGreaterThan(0);
|
|
49
|
+
|
|
50
|
+
// Clear cache
|
|
51
|
+
const result = await fixtures.cacheClearer.execute();
|
|
52
|
+
|
|
53
|
+
expect(result.success).toBe(true);
|
|
54
|
+
expect(result.message).toContain('Cache cleared successfully');
|
|
55
|
+
expect(result.cacheDirectory).toBe(fixtures.config.cacheDirectory);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should empty vectorStore and fileHashes', async () => {
|
|
59
|
+
// Create some cache
|
|
60
|
+
await fixtures.indexer.indexAll(true);
|
|
61
|
+
|
|
62
|
+
// Clear
|
|
63
|
+
await fixtures.cacheClearer.execute();
|
|
64
|
+
|
|
65
|
+
// Both should be empty
|
|
66
|
+
expect(fixtures.cache.getVectorStore().length).toBe(0);
|
|
67
|
+
expect(fixtures.cache.fileHashes.size).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should delete cache directory', async () => {
|
|
71
|
+
// Create cache
|
|
72
|
+
await fixtures.indexer.indexAll(true);
|
|
73
|
+
|
|
74
|
+
// Verify cache directory exists
|
|
75
|
+
await expect(fs.access(fixtures.config.cacheDirectory)).resolves.not.toThrow();
|
|
76
|
+
|
|
77
|
+
// Clear
|
|
78
|
+
await fixtures.cacheClearer.execute();
|
|
79
|
+
|
|
80
|
+
// Directory should not exist
|
|
81
|
+
await expect(fs.access(fixtures.config.cacheDirectory)).rejects.toThrow();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('Protection During Indexing', () => {
|
|
86
|
+
it('should prevent clear while indexing is in progress', async () => {
|
|
87
|
+
// Simulate indexing in progress
|
|
88
|
+
await clearTestCache(fixtures.config);
|
|
89
|
+
fixtures.cache.setVectorStore([]);
|
|
90
|
+
fixtures.cache.fileHashes = new Map();
|
|
91
|
+
|
|
92
|
+
const indexPromise = fixtures.indexer.indexAll(true);
|
|
93
|
+
|
|
94
|
+
// Wait for indexing to start
|
|
95
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
96
|
+
expect(fixtures.indexer.isIndexing).toBe(true);
|
|
97
|
+
|
|
98
|
+
// Try to clear - should fail
|
|
99
|
+
await expect(fixtures.cacheClearer.execute()).rejects.toThrow(
|
|
100
|
+
'Cannot clear cache while indexing is in progress'
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
await indexPromise;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should allow clear after indexing completes', async () => {
|
|
107
|
+
// Complete indexing
|
|
108
|
+
await fixtures.indexer.indexAll(true);
|
|
109
|
+
expect(fixtures.indexer.isIndexing).toBe(false);
|
|
110
|
+
|
|
111
|
+
// Clear should work
|
|
112
|
+
const result = await fixtures.cacheClearer.execute();
|
|
113
|
+
expect(result.success).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('Protection During Save', () => {
|
|
118
|
+
it('should prevent clear while cache is being saved', async () => {
|
|
119
|
+
// Simulate save in progress
|
|
120
|
+
fixtures.cache.isSaving = true;
|
|
121
|
+
|
|
122
|
+
// Try to clear - should fail
|
|
123
|
+
await expect(fixtures.cacheClearer.execute()).rejects.toThrow(
|
|
124
|
+
'Cannot clear cache while cache is being saved'
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Reset
|
|
128
|
+
fixtures.cache.isSaving = false;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should allow clear after save completes', async () => {
|
|
132
|
+
// Index first
|
|
133
|
+
await fixtures.indexer.indexAll(true);
|
|
134
|
+
|
|
135
|
+
// isSaving should be false after indexing
|
|
136
|
+
expect(fixtures.cache.isSaving).toBe(false);
|
|
137
|
+
|
|
138
|
+
// Clear should work
|
|
139
|
+
const result = await fixtures.cacheClearer.execute();
|
|
140
|
+
expect(result.success).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('Concurrent Clear Prevention', () => {
|
|
145
|
+
it('should prevent multiple concurrent clears', async () => {
|
|
146
|
+
// Index first
|
|
147
|
+
await fixtures.indexer.indexAll(true);
|
|
148
|
+
|
|
149
|
+
// Reset the isClearing flag
|
|
150
|
+
fixtures.cacheClearer.isClearing = false;
|
|
151
|
+
|
|
152
|
+
// Start multiple concurrent clears
|
|
153
|
+
const promises = [
|
|
154
|
+
fixtures.cacheClearer.execute(),
|
|
155
|
+
fixtures.cacheClearer.execute(),
|
|
156
|
+
fixtures.cacheClearer.execute()
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
const results = await Promise.allSettled(promises);
|
|
160
|
+
|
|
161
|
+
// Exactly one should succeed
|
|
162
|
+
const successes = results.filter(r => r.status === 'fulfilled');
|
|
163
|
+
const failures = results.filter(r => r.status === 'rejected');
|
|
164
|
+
|
|
165
|
+
expect(successes.length).toBe(1);
|
|
166
|
+
expect(failures.length).toBe(2);
|
|
167
|
+
|
|
168
|
+
// Failures should have correct error message
|
|
169
|
+
for (const failure of failures) {
|
|
170
|
+
expect(failure.reason.message).toContain('already in progress');
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should reset isClearing flag after completion', async () => {
|
|
175
|
+
// Index first
|
|
176
|
+
await fixtures.indexer.indexAll(true);
|
|
177
|
+
|
|
178
|
+
expect(fixtures.cacheClearer.isClearing).toBe(false);
|
|
179
|
+
|
|
180
|
+
// Clear
|
|
181
|
+
await fixtures.cacheClearer.execute();
|
|
182
|
+
|
|
183
|
+
// Flag should be reset
|
|
184
|
+
expect(fixtures.cacheClearer.isClearing).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should reset isClearing flag even on error', async () => {
|
|
188
|
+
// Set up for failure
|
|
189
|
+
fixtures.cache.isSaving = true;
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
await fixtures.cacheClearer.execute();
|
|
193
|
+
} catch {
|
|
194
|
+
// Expected to fail
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// isClearing should not have been set (failed before setting)
|
|
198
|
+
expect(fixtures.cacheClearer.isClearing).toBe(false);
|
|
199
|
+
|
|
200
|
+
// Reset
|
|
201
|
+
fixtures.cache.isSaving = false;
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('Clear Cache Tool Handler', () => {
|
|
207
|
+
let fixtures;
|
|
208
|
+
|
|
209
|
+
beforeAll(async () => {
|
|
210
|
+
fixtures = await createTestFixtures({ workerThreads: 2 });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
afterAll(async () => {
|
|
214
|
+
await cleanupFixtures(fixtures);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
beforeEach(async () => {
|
|
218
|
+
fixtures.indexer.isIndexing = false;
|
|
219
|
+
fixtures.cache.isSaving = false;
|
|
220
|
+
fixtures.cacheClearer.isClearing = false;
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('Tool Definition', () => {
|
|
224
|
+
it('should have correct tool definition', () => {
|
|
225
|
+
const toolDef = ClearCacheFeature.getToolDefinition();
|
|
226
|
+
|
|
227
|
+
expect(toolDef.name).toBe('c_clear_cache');
|
|
228
|
+
expect(toolDef.description).toContain('cache');
|
|
229
|
+
expect(toolDef.annotations.destructiveHint).toBe(true);
|
|
230
|
+
expect(toolDef.inputSchema.properties).toEqual({});
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('Tool Handler', () => {
|
|
235
|
+
it('should return success message on cleared cache', async () => {
|
|
236
|
+
// Index first
|
|
237
|
+
await fixtures.indexer.indexAll(true);
|
|
238
|
+
|
|
239
|
+
const request = createMockRequest('c_clear_cache', {});
|
|
240
|
+
const result = await ClearCacheFeature.handleToolCall(request, fixtures.cacheClearer);
|
|
241
|
+
|
|
242
|
+
expect(result.content[0].text).toContain('Cache cleared successfully');
|
|
243
|
+
expect(result.content[0].text).toContain('Cache directory:');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should return error message when indexing is in progress', async () => {
|
|
247
|
+
// Simulate indexing
|
|
248
|
+
await clearTestCache(fixtures.config);
|
|
249
|
+
fixtures.cache.setVectorStore([]);
|
|
250
|
+
fixtures.cache.fileHashes = new Map();
|
|
251
|
+
|
|
252
|
+
const indexPromise = fixtures.indexer.indexAll(true);
|
|
253
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
254
|
+
|
|
255
|
+
const request = createMockRequest('c_clear_cache', {});
|
|
256
|
+
const result = await ClearCacheFeature.handleToolCall(request, fixtures.cacheClearer);
|
|
257
|
+
|
|
258
|
+
expect(result.content[0].text).toContain('Failed to clear cache');
|
|
259
|
+
expect(result.content[0].text).toContain('indexing is in progress');
|
|
260
|
+
|
|
261
|
+
await indexPromise;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('should return error message when save is in progress', async () => {
|
|
265
|
+
fixtures.cache.isSaving = true;
|
|
266
|
+
|
|
267
|
+
const request = createMockRequest('c_clear_cache', {});
|
|
268
|
+
const result = await ClearCacheFeature.handleToolCall(request, fixtures.cacheClearer);
|
|
269
|
+
|
|
270
|
+
expect(result.content[0].text).toContain('Failed to clear cache');
|
|
271
|
+
expect(result.content[0].text).toContain('being saved');
|
|
272
|
+
|
|
273
|
+
fixtures.cache.isSaving = false;
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('should return error message when clear is already in progress', async () => {
|
|
277
|
+
fixtures.cacheClearer.isClearing = true;
|
|
278
|
+
|
|
279
|
+
const request = createMockRequest('c_clear_cache', {});
|
|
280
|
+
const result = await ClearCacheFeature.handleToolCall(request, fixtures.cacheClearer);
|
|
281
|
+
|
|
282
|
+
expect(result.content[0].text).toContain('Failed to clear cache');
|
|
283
|
+
expect(result.content[0].text).toContain('already in progress');
|
|
284
|
+
|
|
285
|
+
fixtures.cacheClearer.isClearing = false;
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
});
|