@softerist/heuristic-mcp 3.0.14 → 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 +90 -82
- 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 +136 -69
- 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 +487 -427
- package/lib/constants.js +31 -0
- 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 -638
- 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 -0
- 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,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Resources Feature
|
|
3
|
+
*
|
|
4
|
+
* Exposes workspace files as MCP resources for discovery and reading.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'fs/promises';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { fdir } from 'fdir';
|
|
10
|
+
import { getMimeType } from '../lib/constants.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Convert a file path to a file:// URI.
|
|
14
|
+
* @param {string} filePath - Absolute file path
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
function pathToUri(filePath) {
|
|
18
|
+
// Normalize path separators and encode for URI
|
|
19
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
20
|
+
// On Windows, paths start with drive letter like C:/
|
|
21
|
+
if (/^[a-zA-Z]:/.test(normalized)) {
|
|
22
|
+
return `file:///${normalized}`;
|
|
23
|
+
}
|
|
24
|
+
return `file://${normalized}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Convert a file:// URI back to a file path.
|
|
29
|
+
* @param {string} uri
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
function uriToPath(uri) {
|
|
33
|
+
if (!uri.startsWith('file://')) {
|
|
34
|
+
throw new Error(`Invalid file URI: ${uri}`);
|
|
35
|
+
}
|
|
36
|
+
let filePath = uri.slice(7); // Remove 'file://'
|
|
37
|
+
// Handle Windows paths (file:///C:/...)
|
|
38
|
+
if (/^\/[a-zA-Z]:/.test(filePath)) {
|
|
39
|
+
filePath = filePath.slice(1); // Remove leading /
|
|
40
|
+
}
|
|
41
|
+
// Decode URI components
|
|
42
|
+
filePath = decodeURIComponent(filePath);
|
|
43
|
+
// Normalize to OS path separators
|
|
44
|
+
if (process.platform === 'win32') {
|
|
45
|
+
filePath = filePath.replace(/\//g, '\\');
|
|
46
|
+
}
|
|
47
|
+
return filePath;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check if a path is within the workspace directory.
|
|
52
|
+
* @param {string} filePath - Absolute file path
|
|
53
|
+
* @param {string} workspaceDir - Workspace directory
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
function isWithinWorkspace(filePath, workspaceDir) {
|
|
57
|
+
const resolvedPath = path.resolve(filePath);
|
|
58
|
+
const resolvedWorkspace = path.resolve(workspaceDir);
|
|
59
|
+
const relativePath = path.relative(resolvedWorkspace, resolvedPath);
|
|
60
|
+
return !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* List resources handler for MCP.
|
|
65
|
+
* @param {object} config - Server configuration
|
|
66
|
+
* @returns {Promise<{resources: Array}>}
|
|
67
|
+
*/
|
|
68
|
+
export async function handleListResources(config) {
|
|
69
|
+
const workspaceDir = config.searchDirectory;
|
|
70
|
+
const maxResults = 500; // Limit to avoid overwhelming clients
|
|
71
|
+
|
|
72
|
+
// Build set of allowed extensions from config
|
|
73
|
+
const allowedExtensions = new Set(
|
|
74
|
+
(config.fileExtensions || []).map(ext => `.${ext.toLowerCase()}`)
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Extract directory names from exclude patterns (e.g., '**/node_modules/**' -> 'node_modules')
|
|
78
|
+
const excludedDirs = new Set();
|
|
79
|
+
for (const pattern of config.excludePatterns || []) {
|
|
80
|
+
// Match patterns like '**/dirname/**' or 'dirname/**'
|
|
81
|
+
const match = pattern.match(/(?:\*\*\/)?([^/*]+)(?:\/\*\*)?$/);
|
|
82
|
+
if (match && match[1] && !match[1].includes('*')) {
|
|
83
|
+
excludedDirs.add(match[1]);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
// Use fdir to scan workspace
|
|
89
|
+
const crawler = new fdir()
|
|
90
|
+
.withBasePath()
|
|
91
|
+
.withMaxDepth(10)
|
|
92
|
+
.exclude((dirName) => {
|
|
93
|
+
return excludedDirs.has(dirName);
|
|
94
|
+
})
|
|
95
|
+
.filter((filePath) => {
|
|
96
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
97
|
+
return allowedExtensions.has(ext);
|
|
98
|
+
})
|
|
99
|
+
.crawl(workspaceDir);
|
|
100
|
+
|
|
101
|
+
const files = await crawler.withPromise();
|
|
102
|
+
const limitedFiles = files.slice(0, maxResults);
|
|
103
|
+
|
|
104
|
+
const resources = limitedFiles.map((filePath) => {
|
|
105
|
+
const relativePath = path.relative(workspaceDir, filePath);
|
|
106
|
+
return {
|
|
107
|
+
uri: pathToUri(filePath),
|
|
108
|
+
name: relativePath.replace(/\\/g, '/'),
|
|
109
|
+
mimeType: getMimeType(path.extname(filePath)),
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return { resources };
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error(`[Resources] Error listing resources: ${error.message}`);
|
|
116
|
+
return { resources: [] };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Read resource handler for MCP.
|
|
122
|
+
* @param {string} uri - Resource URI
|
|
123
|
+
* @param {object} config - Server configuration
|
|
124
|
+
* @returns {Promise<{contents: Array}>}
|
|
125
|
+
*/
|
|
126
|
+
export async function handleReadResource(uri, config) {
|
|
127
|
+
const workspaceDir = config.searchDirectory;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const filePath = uriToPath(uri);
|
|
131
|
+
|
|
132
|
+
// Security check: ensure path is within workspace
|
|
133
|
+
if (!isWithinWorkspace(filePath, workspaceDir)) {
|
|
134
|
+
throw new Error(`Access denied: ${uri} is outside workspace`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check file exists
|
|
138
|
+
await fs.access(filePath);
|
|
139
|
+
|
|
140
|
+
// Read file content
|
|
141
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
contents: [
|
|
145
|
+
{
|
|
146
|
+
uri,
|
|
147
|
+
mimeType: getMimeType(path.extname(filePath)),
|
|
148
|
+
text: content,
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
} catch (error) {
|
|
153
|
+
console.error(`[Resources] Error reading resource ${uri}: ${error.message}`);
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime Workspace Switching Tool
|
|
3
|
+
*
|
|
4
|
+
* Changes the workspace path at runtime, reinitializing the cache
|
|
5
|
+
* and optionally triggering reindexing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import fs from 'fs/promises';
|
|
10
|
+
import crypto from 'crypto';
|
|
11
|
+
import { acquireWorkspaceLock, releaseWorkspaceLock } from '../lib/server-lifecycle.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generate a workspace-specific cache directory path
|
|
15
|
+
*/
|
|
16
|
+
function getWorkspaceCacheDir(workspacePath, globalCacheDir) {
|
|
17
|
+
const normalized = path.resolve(workspacePath);
|
|
18
|
+
const hash = crypto.createHash('md5').update(normalized).digest('hex').slice(0, 12);
|
|
19
|
+
return path.join(globalCacheDir, 'heuristic-mcp', hash);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// MCP Tool definition
|
|
23
|
+
export function getToolDefinition() {
|
|
24
|
+
return {
|
|
25
|
+
name: 'f_set_workspace',
|
|
26
|
+
description:
|
|
27
|
+
'Changes the current workspace path at runtime. This updates the search directory and cache, and optionally triggers a full reindex. Useful for multi-project workflows.',
|
|
28
|
+
inputSchema: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
workspacePath: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
description: 'Absolute path to the new workspace directory',
|
|
34
|
+
},
|
|
35
|
+
reindex: {
|
|
36
|
+
type: 'boolean',
|
|
37
|
+
description: 'Whether to trigger a full reindex after switching (default: true)',
|
|
38
|
+
default: true,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
required: ['workspacePath'],
|
|
42
|
+
},
|
|
43
|
+
annotations: {
|
|
44
|
+
title: 'Set Workspace',
|
|
45
|
+
readOnlyHint: false,
|
|
46
|
+
destructiveHint: false,
|
|
47
|
+
idempotentHint: true,
|
|
48
|
+
openWorldHint: false,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create the SetWorkspace feature class
|
|
55
|
+
* This needs access to shared state (config, cache, indexer) to actually perform the switch
|
|
56
|
+
*/
|
|
57
|
+
export class SetWorkspaceFeature {
|
|
58
|
+
constructor(config, cache, indexer, getGlobalCacheDir) {
|
|
59
|
+
this.config = config;
|
|
60
|
+
this.cache = cache;
|
|
61
|
+
this.indexer = indexer;
|
|
62
|
+
this.getGlobalCacheDir = getGlobalCacheDir;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async execute({ workspacePath, reindex = true }) {
|
|
66
|
+
// Validate workspace path
|
|
67
|
+
if (!workspacePath || typeof workspacePath !== 'string') {
|
|
68
|
+
return {
|
|
69
|
+
success: false,
|
|
70
|
+
error: 'workspacePath is required and must be a string',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const normalizedPath = path.resolve(workspacePath);
|
|
75
|
+
|
|
76
|
+
// Check if directory exists
|
|
77
|
+
try {
|
|
78
|
+
const stat = await fs.stat(normalizedPath);
|
|
79
|
+
if (!stat.isDirectory()) {
|
|
80
|
+
return {
|
|
81
|
+
success: false,
|
|
82
|
+
error: `Path is not a directory: ${normalizedPath}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return {
|
|
87
|
+
success: false,
|
|
88
|
+
error: `Cannot access directory: ${normalizedPath} (${err.message})`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const previousWorkspace = this.config.searchDirectory;
|
|
93
|
+
const previousCache = this.config.cacheDirectory;
|
|
94
|
+
|
|
95
|
+
// Update config
|
|
96
|
+
this.config.searchDirectory = normalizedPath;
|
|
97
|
+
|
|
98
|
+
// Calculate new cache directory (match config.js behavior)
|
|
99
|
+
const globalCacheDir = this.getGlobalCacheDir();
|
|
100
|
+
let newCacheDir = getWorkspaceCacheDir(normalizedPath, globalCacheDir);
|
|
101
|
+
|
|
102
|
+
// Prefer legacy local cache if present
|
|
103
|
+
const legacyPath = path.join(normalizedPath, '.smart-coding-cache');
|
|
104
|
+
try {
|
|
105
|
+
const legacyStats = await fs.stat(legacyPath);
|
|
106
|
+
if (legacyStats.isDirectory()) {
|
|
107
|
+
newCacheDir = legacyPath;
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// ignore missing legacy cache
|
|
111
|
+
}
|
|
112
|
+
this.config.cacheDirectory = newCacheDir;
|
|
113
|
+
|
|
114
|
+
// Create cache directory if needed
|
|
115
|
+
try {
|
|
116
|
+
await fs.mkdir(newCacheDir, { recursive: true });
|
|
117
|
+
} catch (err) {
|
|
118
|
+
// Revert config on failure
|
|
119
|
+
this.config.searchDirectory = previousWorkspace;
|
|
120
|
+
this.config.cacheDirectory = previousCache;
|
|
121
|
+
return {
|
|
122
|
+
success: false,
|
|
123
|
+
error: `Failed to create cache directory: ${err.message}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Acquire new workspace lock before proceeding
|
|
128
|
+
const lock = await acquireWorkspaceLock({
|
|
129
|
+
cacheDirectory: newCacheDir,
|
|
130
|
+
workspaceDir: normalizedPath,
|
|
131
|
+
});
|
|
132
|
+
if (!lock.acquired) {
|
|
133
|
+
// Revert config on failure
|
|
134
|
+
this.config.searchDirectory = previousWorkspace;
|
|
135
|
+
this.config.cacheDirectory = previousCache;
|
|
136
|
+
return {
|
|
137
|
+
success: false,
|
|
138
|
+
error: `Workspace is already locked by another server (pid ${lock.ownerPid ?? 'unknown'})`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
let indexerUpdateError = null;
|
|
142
|
+
|
|
143
|
+
// Update indexer's workspace root and related state
|
|
144
|
+
if (this.indexer) {
|
|
145
|
+
if (typeof this.indexer.terminateWorkers === 'function') {
|
|
146
|
+
try {
|
|
147
|
+
await this.indexer.terminateWorkers();
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.warn(`[SetWorkspace] Failed to terminate workers: ${err.message}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
if (typeof this.indexer.updateWorkspaceState === 'function') {
|
|
154
|
+
await this.indexer.updateWorkspaceState({ restartWatcher: true });
|
|
155
|
+
} else {
|
|
156
|
+
this.indexer.workspaceRoot = normalizedPath;
|
|
157
|
+
this.indexer.workspaceRootReal = null; // Reset cached realpath
|
|
158
|
+
if (this.config.watchFiles && typeof this.indexer.setupFileWatcher === 'function') {
|
|
159
|
+
await this.indexer.setupFileWatcher();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} catch (err) {
|
|
163
|
+
indexerUpdateError = err;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (indexerUpdateError) {
|
|
168
|
+
// Roll back config + lock on failure to avoid partial switch
|
|
169
|
+
this.config.searchDirectory = previousWorkspace;
|
|
170
|
+
this.config.cacheDirectory = previousCache;
|
|
171
|
+
await releaseWorkspaceLock({ cacheDirectory: newCacheDir });
|
|
172
|
+
if (this.indexer) {
|
|
173
|
+
try {
|
|
174
|
+
if (typeof this.indexer.updateWorkspaceState === 'function') {
|
|
175
|
+
await this.indexer.updateWorkspaceState({ restartWatcher: true });
|
|
176
|
+
} else {
|
|
177
|
+
this.indexer.workspaceRoot = previousWorkspace;
|
|
178
|
+
this.indexer.workspaceRootReal = null;
|
|
179
|
+
if (this.config.watchFiles && typeof this.indexer.setupFileWatcher === 'function') {
|
|
180
|
+
await this.indexer.setupFileWatcher();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} catch (rollbackErr) {
|
|
184
|
+
console.warn(
|
|
185
|
+
`[SetWorkspace] Failed to rollback indexer state: ${rollbackErr.message}`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
success: false,
|
|
191
|
+
error: `Failed to update workspace state: ${indexerUpdateError.message}`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Release old workspace lock after successful indexer update
|
|
196
|
+
if (previousCache) {
|
|
197
|
+
await releaseWorkspaceLock({ cacheDirectory: previousCache });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Re-initialize cache for new workspace if cache has a load method
|
|
201
|
+
if (this.cache && typeof this.cache.load === 'function') {
|
|
202
|
+
try {
|
|
203
|
+
await this.cache.load();
|
|
204
|
+
} catch (err) {
|
|
205
|
+
console.warn(`[SetWorkspace] Failed to load cache: ${err.message}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Optionally trigger reindex
|
|
210
|
+
let reindexStatus = null;
|
|
211
|
+
if (reindex && this.indexer && typeof this.indexer.indexAll === 'function') {
|
|
212
|
+
try {
|
|
213
|
+
// Start indexing asynchronously
|
|
214
|
+
this.indexer.indexAll().catch((err) => {
|
|
215
|
+
console.warn(`[SetWorkspace] Reindex failed: ${err.message}`);
|
|
216
|
+
});
|
|
217
|
+
reindexStatus = 'started';
|
|
218
|
+
} catch (err) {
|
|
219
|
+
reindexStatus = `failed: ${err.message}`;
|
|
220
|
+
}
|
|
221
|
+
} else if (!reindex) {
|
|
222
|
+
reindexStatus = 'skipped';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
success: true,
|
|
227
|
+
previousWorkspace,
|
|
228
|
+
newWorkspace: normalizedPath,
|
|
229
|
+
cacheDirectory: newCacheDir,
|
|
230
|
+
reindexStatus,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Tool handler (needs instance context, so this is a factory)
|
|
236
|
+
export function createHandleToolCall(featureInstance) {
|
|
237
|
+
return async (request) => {
|
|
238
|
+
const args = request.params?.arguments || {};
|
|
239
|
+
const { workspacePath, reindex } = args;
|
|
240
|
+
|
|
241
|
+
const result = await featureInstance.execute({
|
|
242
|
+
workspacePath,
|
|
243
|
+
reindex: reindex !== false, // Default to true
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (result.success) {
|
|
247
|
+
let message = `✓ Workspace switched to: **${result.newWorkspace}**\n`;
|
|
248
|
+
message += `\n- Previous: \`${result.previousWorkspace || '(none)'}\``;
|
|
249
|
+
message += `\n- Cache: \`${result.cacheDirectory}\``;
|
|
250
|
+
if (result.reindexStatus) {
|
|
251
|
+
message += `\n- Reindex: ${result.reindexStatus}`;
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
content: [{ type: 'text', text: message }],
|
|
255
|
+
};
|
|
256
|
+
} else {
|
|
257
|
+
return {
|
|
258
|
+
content: [{ type: 'text', text: `Error: ${result.error}` }],
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Export for use in registration
|
|
265
|
+
export { getWorkspaceCacheDir };
|
package/index.js
CHANGED
|
@@ -52,12 +52,13 @@ import * as AnnConfigFeature from './features/ann-config.js';
|
|
|
52
52
|
import * as PackageVersionFeature from './features/package-version.js';
|
|
53
53
|
import * as SetWorkspaceFeature from './features/set-workspace.js';
|
|
54
54
|
import { handleListResources, handleReadResource } from './features/resources.js';
|
|
55
|
+
import { getWorkspaceEnvKeys } from './lib/workspace-env.js';
|
|
55
56
|
|
|
56
|
-
import {
|
|
57
|
-
MEMORY_LOG_INTERVAL_MS,
|
|
58
|
-
ONNX_THREAD_LIMIT,
|
|
59
|
-
BACKGROUND_INDEX_DELAY_MS,
|
|
60
|
-
} from './lib/constants.js';
|
|
57
|
+
import {
|
|
58
|
+
MEMORY_LOG_INTERVAL_MS,
|
|
59
|
+
ONNX_THREAD_LIMIT,
|
|
60
|
+
BACKGROUND_INDEX_DELAY_MS,
|
|
61
|
+
} from './lib/constants.js';
|
|
61
62
|
const PID_FILE_NAME = '.heuristic-mcp.pid';
|
|
62
63
|
|
|
63
64
|
async function readLogTail(logPath, maxLines = 2000) {
|
|
@@ -124,6 +125,69 @@ let cache = null;
|
|
|
124
125
|
let indexer = null;
|
|
125
126
|
let hybridSearch = null;
|
|
126
127
|
let config = null;
|
|
128
|
+
let setWorkspaceFeatureInstance = null;
|
|
129
|
+
let autoWorkspaceSwitchPromise = null;
|
|
130
|
+
|
|
131
|
+
async function resolveWorkspaceFromEnvValue(rawValue) {
|
|
132
|
+
if (!rawValue || rawValue.includes('${')) return null;
|
|
133
|
+
const resolved = path.resolve(rawValue);
|
|
134
|
+
try {
|
|
135
|
+
const stats = await fs.stat(resolved);
|
|
136
|
+
if (!stats.isDirectory()) return null;
|
|
137
|
+
return resolved;
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function detectRuntimeWorkspaceFromEnv() {
|
|
144
|
+
for (const key of getWorkspaceEnvKeys()) {
|
|
145
|
+
const workspacePath = await resolveWorkspaceFromEnvValue(process.env[key]);
|
|
146
|
+
if (workspacePath) {
|
|
147
|
+
return { workspacePath, envKey: key };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function maybeAutoSwitchWorkspace(request) {
|
|
155
|
+
if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') return;
|
|
156
|
+
if (!setWorkspaceFeatureInstance || !config?.searchDirectory) return;
|
|
157
|
+
if (request?.params?.name === 'f_set_workspace') return;
|
|
158
|
+
|
|
159
|
+
const detected = await detectRuntimeWorkspaceFromEnv();
|
|
160
|
+
if (!detected) return;
|
|
161
|
+
|
|
162
|
+
const currentWorkspace = path.resolve(config.searchDirectory);
|
|
163
|
+
if (detected.workspacePath === currentWorkspace) return;
|
|
164
|
+
|
|
165
|
+
if (autoWorkspaceSwitchPromise) {
|
|
166
|
+
await autoWorkspaceSwitchPromise;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
autoWorkspaceSwitchPromise = (async () => {
|
|
171
|
+
console.info(
|
|
172
|
+
`[Server] Auto-switching workspace from ${currentWorkspace} to ${detected.workspacePath} (env ${detected.envKey})`
|
|
173
|
+
);
|
|
174
|
+
const result = await setWorkspaceFeatureInstance.execute({
|
|
175
|
+
workspacePath: detected.workspacePath,
|
|
176
|
+
reindex: false,
|
|
177
|
+
});
|
|
178
|
+
if (!result.success) {
|
|
179
|
+
console.warn(
|
|
180
|
+
`[Server] Auto workspace switch failed (env ${detected.envKey}): ${result.error}`
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
})();
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
await autoWorkspaceSwitchPromise;
|
|
187
|
+
} finally {
|
|
188
|
+
autoWorkspaceSwitchPromise = null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
127
191
|
|
|
128
192
|
// Feature registry - ordered by priority (semantic_search first as primary tool)
|
|
129
193
|
const features = [
|
|
@@ -181,46 +245,46 @@ async function initialize(workspaceDir) {
|
|
|
181
245
|
}
|
|
182
246
|
}
|
|
183
247
|
|
|
184
|
-
// Skip gc check during tests (VITEST env is set)
|
|
185
|
-
const isTest = Boolean(process.env.VITEST || process.env.VITEST_WORKER_ID);
|
|
186
|
-
if (config.enableExplicitGc && typeof global.gc !== 'function' && !isTest) {
|
|
187
|
-
console.warn(
|
|
188
|
-
'[Server] enableExplicitGc=true but this process was not started with --expose-gc; continuing with explicit GC disabled.'
|
|
189
|
-
);
|
|
190
|
-
console.warn(
|
|
191
|
-
'[Server] Tip: start with "npm start" or add --expose-gc to enable explicit GC again.'
|
|
192
|
-
);
|
|
193
|
-
config.enableExplicitGc = false;
|
|
194
|
-
}
|
|
248
|
+
// Skip gc check during tests (VITEST env is set)
|
|
249
|
+
const isTest = Boolean(process.env.VITEST || process.env.VITEST_WORKER_ID);
|
|
250
|
+
if (config.enableExplicitGc && typeof global.gc !== 'function' && !isTest) {
|
|
251
|
+
console.warn(
|
|
252
|
+
'[Server] enableExplicitGc=true but this process was not started with --expose-gc; continuing with explicit GC disabled.'
|
|
253
|
+
);
|
|
254
|
+
console.warn(
|
|
255
|
+
'[Server] Tip: start with "npm start" or add --expose-gc to enable explicit GC again.'
|
|
256
|
+
);
|
|
257
|
+
config.enableExplicitGc = false;
|
|
258
|
+
}
|
|
195
259
|
|
|
196
260
|
let mainBackendConfigured = false;
|
|
197
261
|
let nativeOnnxAvailable = null;
|
|
198
|
-
const ensureMainOnnxBackend = () => {
|
|
199
|
-
if (mainBackendConfigured) return;
|
|
200
|
-
nativeOnnxAvailable = configureNativeOnnxBackend({
|
|
201
|
-
log: config.verbose ? console.info : null,
|
|
202
|
-
label: '[Server]',
|
|
203
|
-
threads: {
|
|
262
|
+
const ensureMainOnnxBackend = () => {
|
|
263
|
+
if (mainBackendConfigured) return;
|
|
264
|
+
nativeOnnxAvailable = configureNativeOnnxBackend({
|
|
265
|
+
log: config.verbose ? console.info : null,
|
|
266
|
+
label: '[Server]',
|
|
267
|
+
threads: {
|
|
204
268
|
intraOpNumThreads: ONNX_THREAD_LIMIT,
|
|
205
269
|
interOpNumThreads: 1,
|
|
206
270
|
},
|
|
207
271
|
});
|
|
208
|
-
mainBackendConfigured = true;
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
ensureMainOnnxBackend();
|
|
212
|
-
if (nativeOnnxAvailable === false) {
|
|
213
|
-
try {
|
|
214
|
-
const { env } = await getTransformers();
|
|
215
|
-
if (env?.backends?.onnx?.wasm) {
|
|
216
|
-
env.backends.onnx.wasm.numThreads = ONNX_THREAD_LIMIT;
|
|
217
|
-
}
|
|
218
|
-
} catch {
|
|
219
|
-
// ignore: fallback tuning is best effort
|
|
220
|
-
}
|
|
221
|
-
const status = getNativeOnnxStatus();
|
|
222
|
-
const reason = status?.message || 'onnxruntime-node not available';
|
|
223
|
-
console.warn(`[Server] Native ONNX backend unavailable (${reason}); using WASM backend.`);
|
|
272
|
+
mainBackendConfigured = true;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
ensureMainOnnxBackend();
|
|
276
|
+
if (nativeOnnxAvailable === false) {
|
|
277
|
+
try {
|
|
278
|
+
const { env } = await getTransformers();
|
|
279
|
+
if (env?.backends?.onnx?.wasm) {
|
|
280
|
+
env.backends.onnx.wasm.numThreads = ONNX_THREAD_LIMIT;
|
|
281
|
+
}
|
|
282
|
+
} catch {
|
|
283
|
+
// ignore: fallback tuning is best effort
|
|
284
|
+
}
|
|
285
|
+
const status = getNativeOnnxStatus();
|
|
286
|
+
const reason = status?.message || 'onnxruntime-node not available';
|
|
287
|
+
console.warn(`[Server] Native ONNX backend unavailable (${reason}); using WASM backend.`);
|
|
224
288
|
console.warn(
|
|
225
289
|
'[Server] Auto-safety: disabling workers and forcing embeddingProcessPerBatch for memory isolation.'
|
|
226
290
|
);
|
|
@@ -252,12 +316,12 @@ async function initialize(workspaceDir) {
|
|
|
252
316
|
}
|
|
253
317
|
|
|
254
318
|
// Log effective configuration for debugging
|
|
255
|
-
console.info(
|
|
256
|
-
`[Server] Config: workerThreads=${config.workerThreads}, embeddingProcessPerBatch=${config.embeddingProcessPerBatch}`
|
|
257
|
-
);
|
|
258
|
-
console.info(
|
|
259
|
-
`[Server] Config: vectorStoreLoadMode=${config.vectorStoreLoadMode}, vectorCacheEntries=${config.vectorCacheEntries}`
|
|
260
|
-
);
|
|
319
|
+
console.info(
|
|
320
|
+
`[Server] Config: workerThreads=${config.workerThreads}, embeddingProcessPerBatch=${config.embeddingProcessPerBatch}`
|
|
321
|
+
);
|
|
322
|
+
console.info(
|
|
323
|
+
`[Server] Config: vectorStoreLoadMode=${config.vectorStoreLoadMode}, vectorCacheEntries=${config.vectorCacheEntries}`
|
|
324
|
+
);
|
|
261
325
|
|
|
262
326
|
if (pidPath) {
|
|
263
327
|
console.info(`[Server] PID file: ${pidPath}`);
|
|
@@ -342,22 +406,22 @@ async function initialize(workspaceDir) {
|
|
|
342
406
|
cachedEmbedderPromise = null;
|
|
343
407
|
return false;
|
|
344
408
|
}
|
|
345
|
-
};
|
|
346
|
-
|
|
347
|
-
embedder = lazyEmbedder;
|
|
348
|
-
unloadMainEmbedder = unloader; // Store in module scope for tool handler access
|
|
349
|
-
const preloadEmbeddingModel = async () => {
|
|
350
|
-
if (config.preloadEmbeddingModel === false) return;
|
|
351
|
-
try {
|
|
352
|
-
console.info('[Server] Preloading embedding model (background)...');
|
|
353
|
-
await embedder(' ');
|
|
354
|
-
} catch (err) {
|
|
355
|
-
console.warn(`[Server] Embedding model preload failed: ${err.message}`);
|
|
356
|
-
}
|
|
357
|
-
};
|
|
358
|
-
|
|
359
|
-
// NOTE: We no longer auto-load in verbose mode when preloadEmbeddingModel=false.
|
|
360
|
-
// The model will be loaded lazily on first search or by child processes during indexing.
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
embedder = lazyEmbedder;
|
|
412
|
+
unloadMainEmbedder = unloader; // Store in module scope for tool handler access
|
|
413
|
+
const preloadEmbeddingModel = async () => {
|
|
414
|
+
if (config.preloadEmbeddingModel === false) return;
|
|
415
|
+
try {
|
|
416
|
+
console.info('[Server] Preloading embedding model (background)...');
|
|
417
|
+
await embedder(' ');
|
|
418
|
+
} catch (err) {
|
|
419
|
+
console.warn(`[Server] Embedding model preload failed: ${err.message}`);
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// NOTE: We no longer auto-load in verbose mode when preloadEmbeddingModel=false.
|
|
424
|
+
// The model will be loaded lazily on first search or by child processes during indexing.
|
|
361
425
|
|
|
362
426
|
// Initialize cache (load deferred until after server is ready)
|
|
363
427
|
cache = new EmbeddingsCache(config);
|
|
@@ -385,19 +449,20 @@ async function initialize(workspaceDir) {
|
|
|
385
449
|
indexer,
|
|
386
450
|
getGlobalCacheDir
|
|
387
451
|
);
|
|
452
|
+
setWorkspaceFeatureInstance = setWorkspaceInstance;
|
|
388
453
|
features[6].instance = setWorkspaceInstance;
|
|
389
454
|
features[6].handler = SetWorkspaceFeature.createHandleToolCall(setWorkspaceInstance);
|
|
390
455
|
|
|
391
456
|
// Attach hybridSearch to server for cross-feature access (e.g. cache invalidation)
|
|
392
457
|
server.hybridSearch = hybridSearch;
|
|
393
458
|
|
|
394
|
-
const startBackgroundTasks = async () => {
|
|
395
|
-
// Keep startup responsive: do not block server readiness on model preload.
|
|
396
|
-
void preloadEmbeddingModel();
|
|
397
|
-
|
|
398
|
-
try {
|
|
399
|
-
console.info('[Server] Loading cache (deferred)...');
|
|
400
|
-
await cache.load();
|
|
459
|
+
const startBackgroundTasks = async () => {
|
|
460
|
+
// Keep startup responsive: do not block server readiness on model preload.
|
|
461
|
+
void preloadEmbeddingModel();
|
|
462
|
+
|
|
463
|
+
try {
|
|
464
|
+
console.info('[Server] Loading cache (deferred)...');
|
|
465
|
+
await cache.load();
|
|
401
466
|
if (config.verbose) {
|
|
402
467
|
logMemory('[Server] Memory (after cache load)');
|
|
403
468
|
}
|
|
@@ -423,8 +488,8 @@ async function initialize(workspaceDir) {
|
|
|
423
488
|
.catch((err) => {
|
|
424
489
|
console.error('[Server] Background indexing error:', err.message);
|
|
425
490
|
});
|
|
426
|
-
}, BACKGROUND_INDEX_DELAY_MS);
|
|
427
|
-
};
|
|
491
|
+
}, BACKGROUND_INDEX_DELAY_MS);
|
|
492
|
+
};
|
|
428
493
|
|
|
429
494
|
return { startBackgroundTasks, config };
|
|
430
495
|
}
|
|
@@ -467,6 +532,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
467
532
|
|
|
468
533
|
// Handle tool calls
|
|
469
534
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
535
|
+
await maybeAutoSwitchWorkspace(request);
|
|
536
|
+
|
|
470
537
|
for (const feature of features) {
|
|
471
538
|
const toolDef = feature.module.getToolDefinition(config);
|
|
472
539
|
|