@softerist/heuristic-mcp 3.0.13 → 3.0.15

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 CHANGED
@@ -76,14 +76,20 @@ heuristic-mcp --version
76
76
  ### Start/Stop
77
77
 
78
78
  ```bash
79
- heuristic-mcp --start
80
- heuristic-mcp --start antigravity
81
- heuristic-mcp --start cursor
82
- heuristic-mcp --start "Claude Desktop"
83
- heuristic-mcp --stop
84
- ```
85
-
86
- `--start` registers (if needed) and enables the MCP server entry. `--stop` disables it so the IDE won't immediately respawn it. Restart/reload the IDE after `--start` to launch.
79
+ heuristic-mcp --start
80
+ heuristic-mcp --start antigravity
81
+ heuristic-mcp --start codex
82
+ heuristic-mcp --start cursor
83
+ heuristic-mcp --start vscode
84
+ heuristic-mcp --start windsurf
85
+ heuristic-mcp --start warp
86
+ heuristic-mcp --start "Claude Desktop"
87
+ heuristic-mcp --stop
88
+ ```
89
+
90
+ `--start` registers (if needed) and enables the MCP server entry. `--stop` disables it so the IDE won't immediately respawn it. Restart/reload the IDE after `--start` to launch.
91
+
92
+ Warp note: this package now targets `~/.warp/mcp_settings.json` (and `%APPDATA%\\Warp\\mcp_settings.json` on Windows when present). If no local Warp MCP config is writable yet, use Warp MCP settings/UI once to initialize it, then re-run `--start warp`.
87
93
 
88
94
  ### Clear Cache
89
95
 
@@ -97,33 +103,50 @@ Clears the cache for the current working directory (or `--workspace` if provided
97
103
 
98
104
  ## Configuration (`config.jsonc`)
99
105
 
100
- Configuration is loaded from your workspace root when the server runs with `--workspace` (this is how IDEs launch it). In server mode, it falls back to the package `config.jsonc` (or `config.json`) and then your current working directory.
101
-
102
- Example `config.jsonc`:
103
-
104
- ```json
105
- {
106
- "excludePatterns": ["**/legacy-code/**", "**/*.test.ts"],
107
- "fileNames": ["Dockerfile", ".env.example", "Makefile"],
108
- "smartIndexing": true,
109
- "embeddingModel": "jinaai/jina-embeddings-v2-base-code",
110
- "workerThreads": 0,
111
- "embeddingBatchSize": null,
112
- "embeddingProcessNumThreads": 8,
113
- "enableExplicitGc": false,
114
- "recencyBoost": 0.1,
115
- "recencyDecayDays": 30,
116
- "callGraphEnabled": true,
117
- "callGraphBoost": 0.15,
118
- "annEnabled": true,
119
- "vectorStoreFormat": "binary",
120
- "vectorStoreContentMode": "external",
121
- "vectorStoreLoadMode": "disk",
122
- "contentCacheEntries": 256,
123
- "vectorCacheEntries": 64,
124
- "clearCacheAfterIndex": true
125
- }
126
- ```
106
+ Configuration is loaded from your workspace root when the server runs with `--workspace`. If not provided by the IDE, the server auto-detects workspace via environment variables and current working directory. In server mode, it falls back to the package `config.jsonc` (or `config.json`) and then your current working directory.
107
+
108
+ Example `config.jsonc`:
109
+
110
+ ```json
111
+ {
112
+ "excludePatterns": ["**/legacy-code/**", "**/*.test.ts"],
113
+ "fileNames": ["Dockerfile", ".env.example", "Makefile"],
114
+ "indexing": {
115
+ "smartIndexing": true
116
+ },
117
+ "worker": {
118
+ "workerThreads": 0
119
+ },
120
+ "embedding": {
121
+ "embeddingModel": "jinaai/jina-embeddings-v2-base-code",
122
+ "embeddingBatchSize": null,
123
+ "embeddingProcessNumThreads": 8
124
+ },
125
+ "search": {
126
+ "recencyBoost": 0.1,
127
+ "recencyDecayDays": 30
128
+ },
129
+ "callGraph": {
130
+ "callGraphEnabled": true,
131
+ "callGraphBoost": 0.15
132
+ },
133
+ "ann": {
134
+ "annEnabled": true
135
+ },
136
+ "vectorStore": {
137
+ "vectorStoreFormat": "binary",
138
+ "vectorStoreContentMode": "external",
139
+ "vectorStoreLoadMode": "disk",
140
+ "contentCacheEntries": 256,
141
+ "vectorCacheEntries": 64
142
+ },
143
+ "memoryCleanup": {
144
+ "clearCacheAfterIndex": true
145
+ }
146
+ }
147
+ ```
148
+
149
+ Preferred style is namespaced keys (shown above). Legacy top-level keys are still supported for backward compatibility.
127
150
 
128
151
  ### Embedding Model & Dimension Options
129
152
 
@@ -133,12 +156,14 @@ Example `config.jsonc`:
133
156
 
134
157
  For faster search with smaller embeddings, switch to an MRL-compatible model:
135
158
 
136
- ```json
137
- {
138
- "embeddingModel": "nomic-ai/nomic-embed-text-v1.5",
139
- "embeddingDimension": 128
140
- }
141
- ```
159
+ ```json
160
+ {
161
+ "embedding": {
162
+ "embeddingModel": "nomic-ai/nomic-embed-text-v1.5",
163
+ "embeddingDimension": 128
164
+ }
165
+ }
166
+ ```
142
167
 
143
168
  **MRL-compatible models:**
144
169
  - `nomic-ai/nomic-embed-text-v1.5` — recommended for 128d/256d
@@ -153,7 +178,9 @@ Cache location:
153
178
 
154
179
  ### Environment Variables
155
180
 
156
- Selected overrides (prefix `SMART_CODING_`):
181
+ Selected overrides (prefix `SMART_CODING_`):
182
+
183
+ Environment overrides target runtime keys and are synced back into namespaces by `lib/config.js`.
157
184
 
158
185
  - `SMART_CODING_VERBOSE=true|false` — enable detailed logging.
159
186
  - `SMART_CODING_WORKER_THREADS=auto|N` — worker thread count.
@@ -179,35 +206,37 @@ Selected overrides (prefix `SMART_CODING_`):
179
206
 
180
207
  See `lib/config.js` for the full list.
181
208
 
182
- ### Binary Vector Store
183
-
184
- Set `vectorStoreFormat` to `binary` to use the on-disk binary cache. This keeps vectors and content out of JS heap
185
- and reads on demand. Recommended for large repos.
186
-
187
- - `vectorStoreContentMode=external` keeps content in the binary file and only loads for top-N results.
188
- - `contentCacheEntries` controls the small in-memory LRU for decoded content strings.
189
- - `vectorStoreLoadMode=disk` streams vectors from disk to reduce memory usage.
190
- - `vectorCacheEntries` controls the small in-memory LRU for vectors when using disk mode.
191
- - `clearCacheAfterIndex=true` drops in-memory vectors after indexing and reloads lazily on next query.
192
- - `unloadModelAfterIndex=true` (default) unloads the embedding model after indexing to free ~500MB-1GB of RAM; the model will reload on the next search query.
193
- - Note: `annEnabled=true` with `vectorStoreLoadMode=disk` can increase disk reads during ANN rebuilds on large indexes.
209
+ ### Binary Vector Store
210
+
211
+ Set `vectorStore.vectorStoreFormat` to `binary` to use the on-disk binary cache. This keeps vectors and content out of JS heap
212
+ and reads on demand. Recommended for large repos.
213
+
214
+ - `vectorStore.vectorStoreContentMode=external` keeps content in the binary file and only loads for top-N results.
215
+ - `vectorStore.contentCacheEntries` controls the small in-memory LRU for decoded content strings.
216
+ - `vectorStore.vectorStoreLoadMode=disk` streams vectors from disk to reduce memory usage.
217
+ - `vectorStore.vectorCacheEntries` controls the small in-memory LRU for vectors when using disk mode.
218
+ - `memoryCleanup.clearCacheAfterIndex=true` drops in-memory vectors after indexing and reloads lazily on next query.
219
+ - `memoryCleanup.unloadModelAfterIndex=true` (default) unloads the embedding model after indexing to free ~500MB-1GB of RAM; the model will reload on the next search query.
220
+ - Note: `ann.annEnabled=true` with `vectorStore.vectorStoreLoadMode=disk` can increase disk reads during ANN rebuilds on large indexes.
194
221
 
195
222
  ### SQLite Vector Store
196
223
 
197
- Set `vectorStoreFormat` to `sqlite` to use SQLite for persistence. This provides:
224
+ Set `vectorStore.vectorStoreFormat` to `sqlite` to use SQLite for persistence. This provides:
198
225
 
199
226
  - ACID transactions for reliable writes
200
227
  - Simpler concurrent access
201
228
  - Standard database format for inspection
202
229
 
203
- ```json
204
- {
205
- "vectorStoreFormat": "sqlite"
206
- }
207
- ```
208
-
209
- The vectors and content are stored in `vectors.sqlite` in your cache directory. You can inspect it with any SQLite browser.
210
- `vectorStoreContentMode` and `vectorStoreLoadMode` are respected for SQLite (use `vectorStoreLoadMode=disk` to avoid loading vectors into memory).
230
+ ```json
231
+ {
232
+ "vectorStore": {
233
+ "vectorStoreFormat": "sqlite"
234
+ }
235
+ }
236
+ ```
237
+
238
+ The vectors and content are stored in `vectors.sqlite` in your cache directory. You can inspect it with any SQLite browser.
239
+ `vectorStore.vectorStoreContentMode` and `vectorStore.vectorStoreLoadMode` are respected for SQLite (use `vectorStore.vectorStoreLoadMode=disk` to avoid loading vectors into memory).
211
240
 
212
241
  **Tradeoffs vs Binary:**
213
242
  - Slightly higher read overhead (SQL queries vs direct memory access)
@@ -230,7 +259,7 @@ SMART_CODING_VECTOR_STORE_LOAD_MODE=disk node tools/scripts/benchmark-search.js
230
259
  SMART_CODING_VECTOR_STORE_FORMAT=binary SMART_CODING_VECTOR_STORE_LOAD_MODE=disk node tools/scripts/benchmark-search.js --runs 10
231
260
  ```
232
261
 
233
- Note: On small repos, disk mode may be slightly slower and show noisy RSS deltas; benefits are clearer on large indexes with a small `vectorCacheEntries`.
262
+ Note: On small repos, disk mode may be slightly slower and show noisy RSS deltas; benefits are clearer on large indexes with a small `vectorStore.vectorCacheEntries`.
234
263
 
235
264
  ---
236
265
 
@@ -268,12 +297,14 @@ Fetch the latest version of a package from its official registry.
268
297
  - **Homebrew**: `brew:node`
269
298
  - **Conda**: `conda:numpy`
270
299
 
271
- ### `f_set_workspace`
272
- Change the workspace directory at runtime. Updates search directory, cache location, and optionally triggers reindex.
273
-
274
- **Parameters:**
275
- - `workspacePath` (required): Absolute path to the new workspace
276
- - `reindex` (optional, default: `true`): Whether to trigger a full reindex
300
+ ### `f_set_workspace`
301
+ Change the workspace directory at runtime. Updates search directory, cache location, and optionally triggers reindex.
302
+
303
+ The server also attempts this automatically before each tool call when it detects a new workspace path from environment variables (for example `CODEX_WORKSPACE`, `CODEX_PROJECT_ROOT`, `WORKSPACE_FOLDER`).
304
+
305
+ **Parameters:**
306
+ - `workspacePath` (required): Absolute path to the new workspace
307
+ - `reindex` (optional, default: `true`): Whether to trigger a full reindex
277
308
 
278
309
  ---
279
310
 
@@ -293,8 +324,8 @@ Native ONNX backend unavailable: The operating system cannot run %1.
293
324
  ...onnxruntime_binding.node. Falling back to WASM.
294
325
  ```
295
326
 
296
- The server will automatically disable workers and force `embeddingProcessPerBatch` to reduce memory spikes, but you
297
- should fix the native binding to restore stable memory usage:
327
+ The server will automatically disable workers and force `embedding.embeddingProcessPerBatch` to reduce memory spikes, but you
328
+ should fix the native binding to restore stable memory usage:
298
329
 
299
330
  - Ensure you are running **64-bit Node.js** (`node -p "process.arch"` should be `x64`).
300
331
  - Install **Microsoft Visual C++ 2015–2022 Redistributable (x64)**.
@@ -334,7 +365,7 @@ node tools/scripts/cache-stats.js --workspace <path>
334
365
 
335
366
  **Stop doesn't stick**
336
367
 
337
- - The IDE will auto-restart the server if it's still enabled in its config. `--stop` now disables the server entry for Antigravity, Cursor, Claude Desktop, and VS Code (when using common MCP settings keys). Restart the IDE after `--start` to re-enable.
368
+ - The IDE will auto-restart the server if it's still enabled in its config. `--stop` now disables the server entry for Antigravity, Cursor (including `~/.cursor/mcp.json`), Windsurf (`~/.codeium/windsurf/mcp_config.json`), Warp (`~/.warp/mcp_settings.json` and `%APPDATA%\\Warp\\mcp_settings.json` when present), Claude Desktop, and VS Code (when using common MCP settings keys). Restart the IDE after `--start` to re-enable.
338
369
 
339
370
  ---
340
371
 
package/index.js CHANGED
@@ -50,17 +50,18 @@ import * as ClearCacheFeature from './features/clear-cache.js';
50
50
  import * as FindSimilarCodeFeature from './features/find-similar-code.js';
51
51
  import * as AnnConfigFeature from './features/ann-config.js';
52
52
  import * as PackageVersionFeature from './features/package-version.js';
53
- import * as SetWorkspaceFeature from './features/set-workspace.js';
54
- import { handleListResources, handleReadResource } from './features/resources.js';
55
-
53
+ import * as SetWorkspaceFeature from './features/set-workspace.js';
54
+ import { handleListResources, handleReadResource } from './features/resources.js';
55
+ import { getWorkspaceEnvKeys } from './lib/workspace-env.js';
56
+
56
57
  import {
57
58
  MEMORY_LOG_INTERVAL_MS,
58
59
  ONNX_THREAD_LIMIT,
59
60
  BACKGROUND_INDEX_DELAY_MS,
60
61
  } from './lib/constants.js';
61
- const PID_FILE_NAME = '.heuristic-mcp.pid';
62
-
63
- async function readLogTail(logPath, maxLines = 2000) {
62
+ const PID_FILE_NAME = '.heuristic-mcp.pid';
63
+
64
+ async function readLogTail(logPath, maxLines = 2000) {
64
65
  const data = await fs.readFile(logPath, 'utf-8');
65
66
  if (!data) return [];
66
67
  const lines = data.split(/\r?\n/).filter(Boolean);
@@ -118,12 +119,75 @@ async function printMemorySnapshot(workspaceDir) {
118
119
  // Arguments parsed in main()
119
120
 
120
121
  // Global state
121
- let embedder = null;
122
- let unloadMainEmbedder = null; // Function to unload the embedding model
123
- let cache = null;
124
- let indexer = null;
125
- let hybridSearch = null;
126
- let config = null;
122
+ let embedder = null;
123
+ let unloadMainEmbedder = null; // Function to unload the embedding model
124
+ let cache = null;
125
+ let indexer = null;
126
+ let hybridSearch = null;
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);
@@ -379,25 +443,26 @@ async function initialize(workspaceDir) {
379
443
  // Features 5 (PackageVersion) doesn't need instance
380
444
 
381
445
  // Initialize SetWorkspace feature with shared state
382
- const setWorkspaceInstance = new SetWorkspaceFeature.SetWorkspaceFeature(
383
- config,
384
- cache,
385
- indexer,
386
- getGlobalCacheDir
387
- );
388
- features[6].instance = setWorkspaceInstance;
389
- features[6].handler = SetWorkspaceFeature.createHandleToolCall(setWorkspaceInstance);
446
+ const setWorkspaceInstance = new SetWorkspaceFeature.SetWorkspaceFeature(
447
+ config,
448
+ cache,
449
+ indexer,
450
+ getGlobalCacheDir
451
+ );
452
+ setWorkspaceFeatureInstance = setWorkspaceInstance;
453
+ features[6].instance = setWorkspaceInstance;
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
  }
@@ -465,10 +530,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
465
530
  return { tools };
466
531
  });
467
532
 
468
- // Handle tool calls
469
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
470
- for (const feature of features) {
471
- const toolDef = feature.module.getToolDefinition(config);
533
+ // Handle tool calls
534
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
535
+ await maybeAutoSwitchWorkspace(request);
536
+
537
+ for (const feature of features) {
538
+ const toolDef = feature.module.getToolDefinition(config);
472
539
 
473
540
  if (request.params.name === toolDef.name) {
474
541
  // Safety check: handler may be null if initialization is incomplete
package/lib/cli.js CHANGED
@@ -16,7 +16,7 @@ Options:
16
16
  --mem Show last memory snapshot from logs (requires verbose logging)
17
17
  --tail <lines> Lines to show with --logs (default: ${defaultTailLines})
18
18
  --no-follow Do not follow log output with --logs
19
- --start [ide] Register + enable in IDE config (antigravity|cursor|"Claude Desktop")
19
+ --start [ide] Register + enable in IDE config (antigravity|codex|cursor|vscode|windsurf|warp|"Claude Desktop")
20
20
  --stop Stop running server instances
21
21
  --workspace <path> Workspace path (used by IDE launch / log viewer)
22
22
  --version, -v Show version
package/lib/config.js CHANGED
@@ -3,13 +3,14 @@ import path from 'path';
3
3
  import os from 'os';
4
4
  import crypto from 'crypto';
5
5
  import { fileURLToPath } from 'url';
6
- import { ProjectDetector } from './project-detector.js';
7
- import { parseJsonc } from './settings-editor.js';
6
+ import { ProjectDetector } from './project-detector.js';
7
+ import { parseJsonc } from './settings-editor.js';
8
8
  import {
9
9
  EMBEDDING_PROCESS_DEFAULT_GC_MAX_REQUESTS_WITHOUT_COLLECTION,
10
10
  EMBEDDING_PROCESS_DEFAULT_GC_MIN_INTERVAL_MS,
11
11
  EMBEDDING_PROCESS_DEFAULT_GC_RSS_THRESHOLD_MB,
12
12
  } from './constants.js';
13
+ import { getWorkspaceEnvKeys } from './workspace-env.js';
13
14
 
14
15
  const DEFAULT_MEMORY_CLEANUP_CONFIG = {
15
16
  enableExplicitGc: true, // Require --expose-gc for more aggressive memory cleanup
@@ -455,18 +456,7 @@ const DEFAULT_CONFIG = {
455
456
  ann: { ...DEFAULT_ANN_CONFIG },
456
457
  };
457
458
 
458
- let config = { ...DEFAULT_CONFIG };
459
-
460
- const WORKSPACE_ENV_VARS = [
461
- 'HEURISTIC_MCP_WORKSPACE',
462
- 'MCP_WORKSPACE',
463
- 'WORKSPACE_FOLDER',
464
- 'WORKSPACE_ROOT',
465
- 'CURSOR_WORKSPACE',
466
- 'CLAUDE_WORKSPACE',
467
- 'ANTIGRAVITY_WORKSPACE',
468
- 'INIT_CWD',
469
- ];
459
+ let config = { ...DEFAULT_CONFIG };
470
460
 
471
461
  const WORKSPACE_MARKERS = [
472
462
  '.git',
@@ -612,7 +602,7 @@ async function readConfigFile(filePath) {
612
602
  }
613
603
  }
614
604
 
615
- async function findWorkspaceRoot(startDir) {
605
+ async function findWorkspaceRoot(startDir) {
616
606
  let current = path.resolve(startDir);
617
607
  while (true) {
618
608
  for (const marker of WORKSPACE_MARKERS) {
@@ -624,43 +614,113 @@ async function findWorkspaceRoot(startDir) {
624
614
  if (parent === current) break;
625
615
  current = parent;
626
616
  }
627
- return path.resolve(startDir);
628
- }
629
-
630
- async function resolveWorkspaceDir(workspaceDir) {
631
- if (workspaceDir) return path.resolve(workspaceDir);
632
- if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') {
633
- return path.resolve(process.cwd());
634
- }
635
-
636
- for (const key of WORKSPACE_ENV_VARS) {
637
- const value = process.env[key];
638
- if (!value || value.includes('${')) continue;
639
- const candidate = path.resolve(value);
640
- if (await pathExists(candidate)) return candidate;
641
- }
642
-
643
- return await findWorkspaceRoot(process.cwd());
644
- }
645
-
646
- export async function loadConfig(workspaceDir = null) {
647
- try {
617
+ return path.resolve(startDir);
618
+ }
619
+
620
+ async function resolveWorkspaceCandidate(rawValue) {
621
+ if (!rawValue || rawValue.includes('${')) return null;
622
+ const candidate = path.resolve(rawValue);
623
+ if (!(await pathExists(candidate))) return null;
624
+ try {
625
+ const stats = await fs.stat(candidate);
626
+ if (!stats.isDirectory()) return null;
627
+ } catch {
628
+ return null;
629
+ }
630
+ return candidate;
631
+ }
632
+
633
+ function logWorkspaceResolution(resolution) {
634
+ if (!resolution || !resolution.path) return;
635
+
636
+ if (resolution.source === 'workspace-arg') {
637
+ console.info(`[Config] Workspace resolution: --workspace -> ${resolution.path}`);
638
+ return;
639
+ }
640
+
641
+ if (resolution.source === 'env' && resolution.envKey) {
642
+ console.info(`[Config] Workspace resolution: env ${resolution.envKey} -> ${resolution.path}`);
643
+ return;
644
+ }
645
+
646
+ if (resolution.source === 'test-cwd') {
647
+ console.info(`[Config] Workspace resolution: process.cwd() (test mode) -> ${resolution.path}`);
648
+ return;
649
+ }
650
+
651
+ if (resolution.source === 'cwd-root-search') {
652
+ const from = resolution.fromPath || process.cwd();
653
+ console.info(
654
+ `[Config] Workspace resolution: workspace root from cwd (${from}) -> ${resolution.path}`
655
+ );
656
+ return;
657
+ }
658
+
659
+ console.info(`[Config] Workspace resolution: process.cwd() -> ${resolution.path}`);
660
+ }
661
+
662
+ async function resolveWorkspaceDir(workspaceDir) {
663
+ if (workspaceDir) {
664
+ return {
665
+ path: path.resolve(workspaceDir),
666
+ source: 'workspace-arg',
667
+ };
668
+ }
669
+ if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') {
670
+ return {
671
+ path: path.resolve(process.cwd()),
672
+ source: 'test-cwd',
673
+ };
674
+ }
675
+
676
+ for (const key of getWorkspaceEnvKeys()) {
677
+ const candidate = await resolveWorkspaceCandidate(process.env[key]);
678
+ if (candidate) {
679
+ return {
680
+ path: candidate,
681
+ source: 'env',
682
+ envKey: key,
683
+ };
684
+ }
685
+ }
686
+
687
+ const cwd = path.resolve(process.cwd());
688
+ const root = await findWorkspaceRoot(cwd);
689
+ if (root !== cwd) {
690
+ return {
691
+ path: root,
692
+ source: 'cwd-root-search',
693
+ fromPath: cwd,
694
+ };
695
+ }
696
+ return {
697
+ path: cwd,
698
+ source: 'cwd',
699
+ };
700
+ }
701
+
702
+ export async function loadConfig(workspaceDir = null) {
703
+ try {
648
704
  // Determine the base directory for configuration
649
705
  let baseDir;
650
706
  let configPath;
651
-
652
- let serverDir = null;
653
- if (workspaceDir) {
654
- // Workspace mode: load config from workspace root
655
- baseDir = path.resolve(workspaceDir);
656
- console.info(`[Config] Workspace mode: ${baseDir}`);
657
- } else {
658
- // Server mode: load config from server directory for global settings,
659
- // but use process.cwd() as base for searching if not specified otherwise
660
- const scriptDir = path.dirname(fileURLToPath(import.meta.url));
661
- serverDir = path.resolve(scriptDir, '..');
662
- baseDir = await resolveWorkspaceDir(null);
663
- }
707
+
708
+ let serverDir = null;
709
+ if (workspaceDir) {
710
+ // Workspace mode: load config from workspace root
711
+ const workspaceResolution = await resolveWorkspaceDir(workspaceDir);
712
+ baseDir = workspaceResolution.path;
713
+ console.info(`[Config] Workspace mode: ${baseDir}`);
714
+ logWorkspaceResolution(workspaceResolution);
715
+ } else {
716
+ // Server mode: load config from server directory for global settings,
717
+ // but use process.cwd() as base for searching if not specified otherwise
718
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
719
+ serverDir = path.resolve(scriptDir, '..');
720
+ const workspaceResolution = await resolveWorkspaceDir(null);
721
+ baseDir = workspaceResolution.path;
722
+ logWorkspaceResolution(workspaceResolution);
723
+ }
664
724
 
665
725
  let userConfig = {};
666
726
  const configNames = ['config.jsonc', 'config.json'];
package/lib/constants.js CHANGED
@@ -1,11 +1,42 @@
1
- /**
2
- * Centralized constants for the heuristic-mcp project.
3
- * Extracting magic numbers improves maintainability and documents design decisions.
4
- */
5
-
6
- // ================================
7
- // Chunking Constants
8
- // ================================
1
+ /**
2
+ * Centralized constants for the heuristic-mcp project.
3
+ * Extracting magic numbers improves maintainability and documents design decisions.
4
+ */
5
+
6
+ // ================================
7
+ // Workspace Resolution Constants
8
+ // ================================
9
+
10
+ /**
11
+ * Environment variables checked for workspace resolution, in precedence order.
12
+ */
13
+ export const WORKSPACE_ENV_VARS = Object.freeze([
14
+ 'HEURISTIC_MCP_WORKSPACE',
15
+ 'MCP_WORKSPACE',
16
+ 'CODEX_WORKSPACE',
17
+ 'CODEX_PROJECT_ROOT',
18
+ 'CODEX_CWD',
19
+ 'WORKSPACE_FOLDER',
20
+ 'WORKSPACE_ROOT',
21
+ 'CURSOR_WORKSPACE',
22
+ 'CLAUDE_WORKSPACE',
23
+ 'ANTIGRAVITY_WORKSPACE',
24
+ 'INIT_CWD',
25
+ ]);
26
+
27
+ /**
28
+ * Prefix for dynamic workspace-related env vars (provider-specific).
29
+ */
30
+ export const DYNAMIC_WORKSPACE_ENV_PREFIX = 'CODEX_';
31
+
32
+ /**
33
+ * Pattern used when ranking provider-specific workspace env vars.
34
+ */
35
+ export const WORKSPACE_ENV_KEY_PATTERN = /(WORKSPACE|PROJECT|ROOT|CWD|DIR)/i;
36
+
37
+ // ================================
38
+ // Chunking Constants
39
+ // ================================
9
40
 
10
41
  /**
11
42
  * Minimum text length for a chunk to be considered valid.
@@ -469,14 +469,23 @@ function replaceRange(text, start, end, replacement) {
469
469
  return text.slice(0, start) + replacement + text.slice(end);
470
470
  }
471
471
 
472
- function resolveContainer(text, rootRange) {
473
- const containers = [
472
+ function resolveContainer(text, rootRange, preferredContainerKey = 'mcpServers') {
473
+ const baseContainers = [
474
474
  { type: 'key', key: 'mcpServers' },
475
+ { type: 'key', key: 'servers' },
475
476
  { type: 'key', key: 'cline.mcpServers' },
476
477
  { type: 'nested', key: 'cline', child: 'mcpServers' },
477
478
  ];
478
479
 
479
- for (const candidate of containers) {
480
+ const preferredIndex = baseContainers.findIndex(
481
+ (candidate) => candidate.type === 'key' && candidate.key === preferredContainerKey
482
+ );
483
+ if (preferredIndex > 0) {
484
+ const [preferred] = baseContainers.splice(preferredIndex, 1);
485
+ baseContainers.unshift(preferred);
486
+ }
487
+
488
+ for (const candidate of baseContainers) {
480
489
  if (candidate.type === 'key') {
481
490
  const entry = findPropertyValueRange(text, rootRange, candidate.key);
482
491
  if (entry) {
@@ -517,14 +526,19 @@ function resolveContainer(text, rootRange) {
517
526
  return null;
518
527
  }
519
528
 
520
- export function upsertMcpServerEntryInText(text, serverName, serverConfig) {
529
+ export function upsertMcpServerEntryInText(
530
+ text,
531
+ serverName,
532
+ serverConfig,
533
+ preferredContainerKey = 'mcpServers'
534
+ ) {
521
535
  const newline = detectNewline(text);
522
536
  const indentUnit = detectIndentUnit(text);
523
537
  const trimmed = text.trim();
524
538
 
525
539
  if (!trimmed) {
526
540
  const payload = {
527
- mcpServers: {
541
+ [preferredContainerKey]: {
528
542
  [serverName]: serverConfig,
529
543
  },
530
544
  };
@@ -536,7 +550,7 @@ export function upsertMcpServerEntryInText(text, serverName, serverConfig) {
536
550
  return null;
537
551
  }
538
552
 
539
- const container = resolveContainer(text, rootRange);
553
+ const container = resolveContainer(text, rootRange, preferredContainerKey);
540
554
 
541
555
  if (!container) {
542
556
  const objectIndent = getLineIndent(text, rootRange.start);
@@ -547,7 +561,14 @@ export function upsertMcpServerEntryInText(text, serverName, serverConfig) {
547
561
  propertyIndent,
548
562
  newline
549
563
  );
550
- return insertPropertyIntoObject(text, rootRange, 'mcpServers', valueText, indentUnit, newline);
564
+ return insertPropertyIntoObject(
565
+ text,
566
+ rootRange,
567
+ preferredContainerKey,
568
+ valueText,
569
+ indentUnit,
570
+ newline
571
+ );
551
572
  }
552
573
 
553
574
  if (container.needsObjectReplace) {
@@ -622,6 +643,9 @@ export function findMcpServerEntry(config, serverName) {
622
643
  if (config.mcpServers && config.mcpServers[serverName]) {
623
644
  return { containerKey: 'mcpServers', entry: config.mcpServers[serverName] };
624
645
  }
646
+ if (config.servers && config.servers[serverName]) {
647
+ return { containerKey: 'servers', entry: config.servers[serverName] };
648
+ }
625
649
  if (config['cline.mcpServers'] && config['cline.mcpServers'][serverName]) {
626
650
  return {
627
651
  containerKey: 'cline.mcpServers',
@@ -636,3 +660,95 @@ export function findMcpServerEntry(config, serverName) {
636
660
  }
637
661
  return null;
638
662
  }
663
+
664
+ function formatTomlString(value) {
665
+ return JSON.stringify(String(value));
666
+ }
667
+
668
+ function formatTomlArray(values) {
669
+ const list = Array.isArray(values) ? values : [];
670
+ return `[${list.map((value) => formatTomlString(value)).join(', ')}]`;
671
+ }
672
+
673
+ function formatTomlMcpSection(serverName, serverConfig, newline) {
674
+ const lines = [`[mcp_servers.${serverName}]`];
675
+ if (serverConfig.command !== undefined) {
676
+ lines.push(`command = ${formatTomlString(serverConfig.command)}`);
677
+ }
678
+ if (serverConfig.args !== undefined) {
679
+ lines.push(`args = ${formatTomlArray(serverConfig.args)}`);
680
+ }
681
+ if (serverConfig.disabled !== undefined) {
682
+ lines.push(`disabled = ${serverConfig.disabled ? 'true' : 'false'}`);
683
+ }
684
+ return lines.join(newline);
685
+ }
686
+
687
+ function findTomlSectionRange(source, sectionName) {
688
+ const headerRegex = /^\s*\[([^\]\r\n]+)\]\s*$/gm;
689
+ let start = -1;
690
+ let end = source.length;
691
+ let match;
692
+
693
+ while ((match = headerRegex.exec(source)) !== null) {
694
+ const currentSection = String(match[1] || '').trim();
695
+ if (start === -1) {
696
+ if (currentSection === sectionName) {
697
+ start = match.index;
698
+ }
699
+ continue;
700
+ }
701
+
702
+ end = match.index;
703
+ break;
704
+ }
705
+
706
+ if (start === -1) {
707
+ return null;
708
+ }
709
+
710
+ return { start, end };
711
+ }
712
+
713
+ export function upsertMcpServerEntryInToml(text, serverName, serverConfig) {
714
+ const source = String(text || '');
715
+ const newline = detectNewline(source || '\n');
716
+ const section = formatTomlMcpSection(serverName, serverConfig, newline);
717
+ const sectionName = `mcp_servers.${serverName}`;
718
+ const range = findTomlSectionRange(source, sectionName);
719
+
720
+ if (!source.trim()) {
721
+ return `${section}${newline}`;
722
+ }
723
+
724
+ if (range) {
725
+ const before = source.slice(0, range.start);
726
+ const after = source.slice(range.end).replace(/^\s*\r?\n?/, '');
727
+ const normalizedBefore =
728
+ before.endsWith('\n') || before.endsWith('\r') || !before ? before : `${before}${newline}`;
729
+ const between = after ? newline : '';
730
+ return `${normalizedBefore}${section}${between}${after}`;
731
+ }
732
+
733
+ const withTrailingNewline = source.endsWith('\n') || source.endsWith('\r') ? source : `${source}${newline}`;
734
+ return `${withTrailingNewline}${newline}${section}${newline}`;
735
+ }
736
+
737
+ export function setMcpServerDisabledInToml(text, serverName, disabled) {
738
+ const source = String(text || '');
739
+ const sectionName = `mcp_servers.${serverName}`;
740
+ const range = findTomlSectionRange(source, sectionName);
741
+
742
+ if (!range) {
743
+ return source;
744
+ }
745
+
746
+ const sectionBlock = source.slice(range.start, range.end);
747
+ const newline = detectNewline(sectionBlock || '\n');
748
+ const disabledLine = `disabled = ${disabled ? 'true' : 'false'}`;
749
+ const updatedSection = /^\s*disabled\s*=.*$/m.test(sectionBlock)
750
+ ? sectionBlock.replace(/^\s*disabled\s*=.*$/m, disabledLine)
751
+ : `${sectionBlock.trimEnd()}${newline}${disabledLine}${newline}`;
752
+
753
+ return `${source.slice(0, range.start)}${updatedSection}${source.slice(range.end)}`;
754
+ }
@@ -0,0 +1,28 @@
1
+ import {
2
+ DYNAMIC_WORKSPACE_ENV_PREFIX,
3
+ WORKSPACE_ENV_KEY_PATTERN,
4
+ WORKSPACE_ENV_VARS,
5
+ } from './constants.js';
6
+
7
+ export function scoreWorkspaceEnvKey(key) {
8
+ const upper = String(key || '').toUpperCase();
9
+ let score = 0;
10
+ if (upper.includes('WORKSPACE')) score += 8;
11
+ if (upper.includes('PROJECT')) score += 4;
12
+ if (upper.includes('ROOT')) score += 3;
13
+ if (upper.includes('CWD')) score += 2;
14
+ if (upper.includes('DIR')) score += 1;
15
+ return score;
16
+ }
17
+
18
+ export function getDynamicWorkspaceEnvKeys(env = process.env) {
19
+ return Object.keys(env)
20
+ .filter((key) => key.startsWith(DYNAMIC_WORKSPACE_ENV_PREFIX))
21
+ .filter((key) => WORKSPACE_ENV_KEY_PATTERN.test(key))
22
+ .filter((key) => !WORKSPACE_ENV_VARS.includes(key))
23
+ .sort((a, b) => scoreWorkspaceEnvKey(b) - scoreWorkspaceEnvKey(a));
24
+ }
25
+
26
+ export function getWorkspaceEnvKeys(env = process.env) {
27
+ return [...WORKSPACE_ENV_VARS, ...getDynamicWorkspaceEnvKeys(env)];
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softerist/heuristic-mcp",
3
- "version": "3.0.13",
3
+ "version": "3.0.15",
4
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
5
  "type": "module",
6
6
  "main": "index.js",