@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.
Files changed (49) hide show
  1. package/README.md +90 -82
  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 +136 -69
  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 +487 -427
  20. package/lib/constants.js +31 -0
  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 -638
  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 -0
  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,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