@softerist/heuristic-mcp 3.2.4 → 3.2.5

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.
@@ -336,7 +336,7 @@ export class HybridSearch {
336
336
  for (let k = 0; k < queryWordCount; k++) {
337
337
  if (lowerContent.includes(queryWords[k])) matchedWords++;
338
338
  }
339
- chunk.score += (matchedWords / queryWordCount) * 0.3;
339
+ chunk.score += (matchedWords / queryWordCount) * PARTIAL_MATCH_BOOST;
340
340
  }
341
341
 
342
342
  if (chunk.content === undefined) {
@@ -2769,7 +2769,13 @@ export class CodebaseIndexer {
2769
2769
  lastCheckpointIntervalMs: checkpointIntervalMs,
2770
2770
  lastCheckpointSaves: checkpointSaveCount,
2771
2771
  });
2772
- await this.cache.save({ throwOnError: true });
2772
+ try {
2773
+ await this.cache.save({ throwOnError: true });
2774
+ } catch (error) {
2775
+ const message = error instanceof Error ? error.message : String(error);
2776
+ console.error(`[Indexer] Final cache save failed after embedding pass: ${message}`);
2777
+ throw error;
2778
+ }
2773
2779
 
2774
2780
  this.sendProgress(
2775
2781
  100,
package/index.js CHANGED
@@ -16,9 +16,6 @@ async function getTransformers() {
16
16
  if (transformersModule?.env) {
17
17
  transformersModule.env.cacheDir = path.join(getGlobalCacheDir(), 'xenova');
18
18
  }
19
- if (transformersModule?.env) {
20
- transformersModule.env.cacheDir = path.join(getGlobalCacheDir(), 'xenova');
21
- }
22
19
  }
23
20
  return transformersModule;
24
21
  }
@@ -79,6 +76,13 @@ import {
79
76
  } from './lib/constants.js';
80
77
  const PID_FILE_NAME = '.heuristic-mcp.pid';
81
78
 
79
+ function isTestRuntime() {
80
+ return (
81
+ process.env.VITEST === 'true' ||
82
+ process.env.NODE_ENV === 'test'
83
+ );
84
+ }
85
+
82
86
  async function readLogTail(logPath, maxLines = 2000) {
83
87
  const data = await fs.readFile(logPath, 'utf-8');
84
88
  if (!data) return [];
@@ -134,6 +138,36 @@ async function printMemorySnapshot(workspaceDir) {
134
138
  return true;
135
139
  }
136
140
 
141
+ async function flushLogsSafely(options) {
142
+ if (typeof flushLogs !== 'function') {
143
+ console.warn('[Logs] flushLogs helper is unavailable; skipping log flush.');
144
+ return;
145
+ }
146
+
147
+ try {
148
+ await flushLogs(options);
149
+ } catch (error) {
150
+ const message = error instanceof Error ? error.message : String(error);
151
+ console.warn(`[Logs] Failed to flush logs: ${message}`);
152
+ }
153
+ }
154
+
155
+ function assertCacheContract(cacheInstance) {
156
+ const requiredMethods = [
157
+ 'load',
158
+ 'save',
159
+ 'consumeAutoReindex',
160
+ 'clearInMemoryState',
161
+ 'getStoreSize',
162
+ ];
163
+ const missing = requiredMethods.filter((name) => typeof cacheInstance?.[name] !== 'function');
164
+ if (missing.length > 0) {
165
+ throw new Error(
166
+ `[Server] Cache implementation contract violation: missing method(s): ${missing.join(', ')}`
167
+ );
168
+ }
169
+ }
170
+
137
171
  let embedder = null;
138
172
  let unloadMainEmbedder = null;
139
173
  let cache = null;
@@ -150,6 +184,9 @@ let setWorkspaceFeatureInstance = null;
150
184
  let autoWorkspaceSwitchPromise = null;
151
185
  let rootsCapabilitySupported = null;
152
186
  let rootsProbeInFlight = null;
187
+ let lastRootsProbeTime = 0;
188
+ let keepAliveTimer = null;
189
+ const ROOTS_PROBE_COOLDOWN_MS = 2000;
153
190
  const WORKSPACE_BOUND_TOOL_NAMES = new Set([
154
191
  'a_semantic_search',
155
192
  'b_index_codebase',
@@ -202,6 +239,10 @@ function formatCrashDetail(detail) {
202
239
  }
203
240
  }
204
241
 
242
+ function isBrokenPipeError(detail) {
243
+ return Boolean(detail && typeof detail === 'object' && detail.code === 'EPIPE');
244
+ }
245
+
205
246
  function isCrashShutdownReason(reason) {
206
247
  const normalized = String(reason || '').toLowerCase();
207
248
  return normalized.includes('uncaughtexception') || normalized.includes('unhandledrejection');
@@ -223,6 +264,10 @@ function registerProcessDiagnostics({ isServerMode, requestShutdown, getShutdown
223
264
  let fatalHandled = false;
224
265
  const handleFatalError = (reason, detail) => {
225
266
  if (fatalHandled) return;
267
+ if (isBrokenPipeError(detail)) {
268
+ requestShutdown('stdio-epipe');
269
+ return;
270
+ }
226
271
  fatalHandled = true;
227
272
  console.error(`[Server] Fatal ${reason}: ${formatCrashDetail(detail)}`);
228
273
  requestShutdown(reason);
@@ -460,7 +505,9 @@ async function maybeAutoSwitchWorkspaceToPath(
460
505
 
461
506
  if (autoWorkspaceSwitchPromise) {
462
507
  await autoWorkspaceSwitchPromise;
463
- return;
508
+ const currentNow = normalizePathForCompare(config.searchDirectory);
509
+ const targetNow = normalizePathForCompare(targetWorkspacePath);
510
+ if (targetNow === currentNow) return;
464
511
  }
465
512
 
466
513
  autoWorkspaceSwitchPromise = (async () => {
@@ -494,6 +541,9 @@ async function maybeAutoSwitchWorkspaceFromRoots(request) {
494
541
  if (rootsProbeInFlight) {
495
542
  return await rootsProbeInFlight;
496
543
  }
544
+ const now = Date.now();
545
+ if (now - lastRootsProbeTime < ROOTS_PROBE_COOLDOWN_MS) return null;
546
+ lastRootsProbeTime = now;
497
547
 
498
548
  rootsProbeInFlight = (async () => {
499
549
  const rootWorkspace = await detectWorkspaceFromRoots({ quiet: true });
@@ -566,7 +616,7 @@ async function initialize(workspaceDir) {
566
616
  }
567
617
  }
568
618
 
569
- const isTest = Boolean(process.env.VITEST || process.env.VITEST_WORKER_ID);
619
+ const isTest = isTestRuntime();
570
620
  if (config.enableExplicitGc && typeof global.gc !== 'function' && !isTest) {
571
621
  console.warn(
572
622
  '[Server] enableExplicitGc=true but this process was not started with --expose-gc; continuing with explicit GC disabled.'
@@ -820,6 +870,7 @@ async function initialize(workspaceDir) {
820
870
  }
821
871
 
822
872
  cache = new EmbeddingsCache(config);
873
+ assertCacheContract(cache);
823
874
  console.info(`[Server] Cache directory: ${config.cacheDirectory}`);
824
875
 
825
876
  indexer = new CodebaseIndexer(embedder, cache, config, server);
@@ -936,7 +987,8 @@ async function initialize(workspaceDir) {
936
987
  context: 'loading cache in secondary read-only mode',
937
988
  canReindex: false,
938
989
  });
939
- if (cache.getStoreSize() === 0) {
990
+ const storeSize = cache.getStoreSize();
991
+ if (storeSize === 0) {
940
992
  await tryAutoAttachWorkspaceCache('secondary-empty-cache', { canReindex: false });
941
993
  }
942
994
  if (config.verbose) {
@@ -1219,7 +1271,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1219
1271
  if (config.unloadModelAfterSearch && searchTools.includes(toolDef.name)) {
1220
1272
  setImmediate(async () => {
1221
1273
  if (typeof unloadMainEmbedder === 'function') {
1222
- await unloadMainEmbedder();
1274
+ try {
1275
+ await unloadMainEmbedder();
1276
+ } catch (err) {
1277
+ const message = err instanceof Error ? err.message : String(err);
1278
+ console.warn(`[Server] Post-search model unload failed: ${message}`);
1279
+ }
1223
1280
  }
1224
1281
  });
1225
1282
  }
@@ -1270,13 +1327,14 @@ export async function main(argv = process.argv) {
1270
1327
  console.info(`[Server] Shutdown requested (${reason}).`);
1271
1328
  void gracefulShutdown(reason);
1272
1329
  };
1330
+ const isTestEnv = isTestRuntime();
1273
1331
  registerProcessDiagnostics({
1274
1332
  isServerMode,
1275
1333
  requestShutdown,
1276
1334
  getShutdownReason: () => shutdownReason,
1277
1335
  });
1278
1336
 
1279
- if (isServerMode && !(process.env.VITEST === 'true' || process.env.NODE_ENV === 'test')) {
1337
+ if (isServerMode && !isTestEnv) {
1280
1338
  enableStderrOnlyLogging();
1281
1339
  }
1282
1340
  if (wantsVersion) {
@@ -1403,29 +1461,31 @@ export async function main(argv = process.argv) {
1403
1461
 
1404
1462
  registerSignalHandlers(requestShutdown);
1405
1463
 
1406
- const detectedRootPromise = new Promise((resolve) => {
1407
- const HANDSHAKE_TIMEOUT_MS = 1000;
1408
- let settled = false;
1409
- const resolveOnce = (value) => {
1410
- if (settled) return;
1411
- settled = true;
1412
- resolve(value);
1413
- };
1464
+ const detectedRootPromise = isTestEnv
1465
+ ? Promise.resolve(null)
1466
+ : new Promise((resolve) => {
1467
+ const HANDSHAKE_TIMEOUT_MS = 1000;
1468
+ let settled = false;
1469
+ const resolveOnce = (value) => {
1470
+ if (settled) return;
1471
+ settled = true;
1472
+ resolve(value);
1473
+ };
1414
1474
 
1415
- const timer = setTimeout(() => {
1416
- console.warn(
1417
- `[Server] MCP handshake timed out after ${HANDSHAKE_TIMEOUT_MS}ms, proceeding without roots.`
1418
- );
1419
- resolveOnce(null);
1420
- }, HANDSHAKE_TIMEOUT_MS);
1421
-
1422
- server.oninitialized = async () => {
1423
- clearTimeout(timer);
1424
- console.info('[Server] MCP handshake complete.');
1425
- const root = await detectWorkspaceFromRoots();
1426
- resolveOnce(root);
1427
- };
1428
- });
1475
+ const timer = setTimeout(() => {
1476
+ console.warn(
1477
+ `[Server] MCP handshake timed out after ${HANDSHAKE_TIMEOUT_MS}ms, proceeding without roots.`
1478
+ );
1479
+ resolveOnce(null);
1480
+ }, HANDSHAKE_TIMEOUT_MS);
1481
+
1482
+ server.oninitialized = async () => {
1483
+ clearTimeout(timer);
1484
+ console.info('[Server] MCP handshake complete.');
1485
+ const root = await detectWorkspaceFromRoots();
1486
+ resolveOnce(root);
1487
+ };
1488
+ });
1429
1489
 
1430
1490
  const transport = new StdioServerTransport();
1431
1491
  await server.connect(transport);
@@ -1438,6 +1498,11 @@ export async function main(argv = process.argv) {
1438
1498
  requestShutdown('stdout-epipe');
1439
1499
  }
1440
1500
  });
1501
+ process.stderr?.on?.('error', (err) => {
1502
+ if (err?.code === 'EPIPE') {
1503
+ requestShutdown('stderr-epipe');
1504
+ }
1505
+ });
1441
1506
  }
1442
1507
 
1443
1508
  const detectedRoot = await detectedRootPromise;
@@ -1461,13 +1526,16 @@ export async function main(argv = process.argv) {
1461
1526
 
1462
1527
  console.info('[Server] Heuristic MCP server started.');
1463
1528
 
1464
- void startBackgroundTasks().catch((err) => {
1529
+ const backgroundTaskPromise = startBackgroundTasks().catch((err) => {
1465
1530
  console.error(`[Server] Background task error: ${err.message}`);
1466
1531
  });
1532
+ if (isTestEnv) {
1533
+ await backgroundTaskPromise;
1534
+ }
1467
1535
  // Keep-Alive mechanism: ensure the process stays alive even if StdioServerTransport
1468
1536
  // temporarily loses its active handle status or during complex async chains.
1469
- if (isServerMode) {
1470
- setInterval(() => {
1537
+ if (isServerMode && !isTestEnv && !keepAliveTimer) {
1538
+ keepAliveTimer = setInterval(() => {
1471
1539
  // Logic to keep event loop active.
1472
1540
  // We don't need to do anything, just the presence of the timer is enough.
1473
1541
  }, SERVER_KEEP_ALIVE_INTERVAL_MS);
@@ -1480,6 +1548,11 @@ async function gracefulShutdown(signal) {
1480
1548
  console.info(`[Server] Received ${signal}, shutting down gracefully...`);
1481
1549
  const exitCode = isCrashShutdownReason(signal) ? 1 : 0;
1482
1550
 
1551
+ if (keepAliveTimer) {
1552
+ clearInterval(keepAliveTimer);
1553
+ keepAliveTimer = null;
1554
+ }
1555
+
1483
1556
  const cleanupTasks = [];
1484
1557
 
1485
1558
  if (indexer && indexer.watcher) {
@@ -1516,7 +1589,7 @@ async function gracefulShutdown(signal) {
1516
1589
 
1517
1590
  await Promise.allSettled(cleanupTasks);
1518
1591
  console.info('[Server] Goodbye!');
1519
- await flushLogs({ close: true, timeoutMs: 1500 }).catch(() => {});
1592
+ await flushLogsSafely({ close: true, timeoutMs: 1500 });
1520
1593
 
1521
1594
  setTimeout(() => process.exit(exitCode), 100);
1522
1595
  }
@@ -1525,14 +1598,13 @@ const isMain =
1525
1598
  process.argv[1] &&
1526
1599
  (path.resolve(process.argv[1]).toLowerCase() === fileURLToPath(import.meta.url).toLowerCase() ||
1527
1600
  process.argv[1].endsWith('heuristic-mcp') ||
1528
- process.argv[1].endsWith('heuristic-mcp.js') ||
1529
- path.basename(process.argv[1]) === 'index.js') &&
1601
+ process.argv[1].endsWith('heuristic-mcp.js')) &&
1530
1602
  !(process.env.VITEST === 'true' || process.env.NODE_ENV === 'test');
1531
1603
 
1532
1604
  if (isMain) {
1533
1605
  main().catch(async (err) => {
1534
1606
  console.error(err);
1535
- await flushLogs({ close: true, timeoutMs: 500 }).catch(() => {});
1607
+ await flushLogsSafely({ close: true, timeoutMs: 500 });
1536
1608
  process.exit(1);
1537
1609
  });
1538
1610
  }
package/lib/logging.js CHANGED
@@ -4,6 +4,7 @@ import path from 'path';
4
4
  import util from 'util';
5
5
 
6
6
  let logStream = null;
7
+ let stderrWritable = true;
7
8
  const originalConsole = {
8
9
  log: console.info,
9
10
  warn: console.warn,
@@ -11,8 +12,25 @@ const originalConsole = {
11
12
  info: console.info,
12
13
  };
13
14
 
15
+ function isBrokenPipeError(error) {
16
+ return Boolean(error && typeof error === 'object' && error.code === 'EPIPE');
17
+ }
18
+
19
+ function writeToOriginalStderr(...args) {
20
+ if (!stderrWritable) return;
21
+ try {
22
+ originalConsole.error(...args);
23
+ } catch (error) {
24
+ if (isBrokenPipeError(error)) {
25
+ stderrWritable = false;
26
+ return;
27
+ }
28
+ throw error;
29
+ }
30
+ }
31
+
14
32
  export function enableStderrOnlyLogging() {
15
- const redirect = (...args) => originalConsole.error(...args);
33
+ const redirect = (...args) => writeToOriginalStderr(...args);
16
34
  // eslint-disable-next-line no-console
17
35
  console.log = redirect;
18
36
  console.info = redirect;
@@ -49,10 +67,9 @@ export async function setupFileLogging(config) {
49
67
  };
50
68
 
51
69
  const wrap = (method, level) => {
52
- const originalError = originalConsole.error;
53
70
  // eslint-disable-next-line no-console
54
71
  console[method] = (...args) => {
55
- originalError(...args);
72
+ writeToOriginalStderr(...args);
56
73
  writeLine(level, args);
57
74
  };
58
75
  };
@@ -63,7 +80,7 @@ export async function setupFileLogging(config) {
63
80
  wrap('info', 'INFO');
64
81
 
65
82
  logStream.on('error', (err) => {
66
- originalConsole.error(`[Logs] Failed to write log file: ${err.message}`);
83
+ writeToOriginalStderr(`[Logs] Failed to write log file: ${err.message}`);
67
84
  });
68
85
 
69
86
  process.on('exit', () => {
@@ -72,7 +89,7 @@ export async function setupFileLogging(config) {
72
89
 
73
90
  return logPath;
74
91
  } catch (err) {
75
- originalConsole.error(`[Logs] Failed to initialize log file: ${err.message}`);
92
+ writeToOriginalStderr(`[Logs] Failed to initialize log file: ${err.message}`);
76
93
  return null;
77
94
  }
78
95
  }
@@ -4,10 +4,51 @@ import path from 'path';
4
4
  import os from 'os';
5
5
  import { setTimeout as delay } from 'timers/promises';
6
6
 
7
+ const pidFilesToCleanup = new Set();
8
+ const workspaceLocksToCleanup = new Set();
9
+ let pidExitCleanupRegistered = false;
10
+ let workspaceLockExitCleanupRegistered = false;
11
+
7
12
  function isTestEnv() {
8
13
  return process.env.VITEST === 'true' || process.env.NODE_ENV === 'test';
9
14
  }
10
15
 
16
+ function cleanupPidFilesOnExit() {
17
+ for (const pidPath of pidFilesToCleanup) {
18
+ try {
19
+ fsSync.unlinkSync(pidPath);
20
+ } catch {}
21
+ }
22
+ }
23
+
24
+ function registerPidFileCleanup(pidPath) {
25
+ if (!pidPath) return;
26
+ pidFilesToCleanup.add(pidPath);
27
+ if (pidExitCleanupRegistered) return;
28
+ process.on('exit', cleanupPidFilesOnExit);
29
+ pidExitCleanupRegistered = true;
30
+ }
31
+
32
+ function cleanupWorkspaceLocksOnExit() {
33
+ for (const lockPath of workspaceLocksToCleanup) {
34
+ try {
35
+ const raw = fsSync.readFileSync(lockPath, 'utf-8');
36
+ const current = JSON.parse(raw);
37
+ if (current && current.pid === process.pid) {
38
+ fsSync.unlinkSync(lockPath);
39
+ }
40
+ } catch {}
41
+ }
42
+ }
43
+
44
+ function registerWorkspaceLockCleanup(lockPath) {
45
+ if (!lockPath) return;
46
+ workspaceLocksToCleanup.add(lockPath);
47
+ if (workspaceLockExitCleanupRegistered) return;
48
+ process.on('exit', cleanupWorkspaceLocksOnExit);
49
+ workspaceLockExitCleanupRegistered = true;
50
+ }
51
+
11
52
  function getPidFilePath({ pidFileName, cacheDirectory }) {
12
53
  if (cacheDirectory) {
13
54
  return path.join(cacheDirectory, pidFileName);
@@ -44,13 +85,7 @@ export async function setupPidFile({
44
85
  return null;
45
86
  }
46
87
 
47
- const cleanup = () => {
48
- try {
49
- fsSync.unlinkSync(pidPath);
50
- } catch {}
51
- };
52
-
53
- process.on('exit', cleanup);
88
+ registerPidFileCleanup(pidPath);
54
89
  return pidPath;
55
90
  }
56
91
 
@@ -71,7 +106,8 @@ function isProcessRunning(pid) {
71
106
  }
72
107
  }
73
108
 
74
- export async function acquireWorkspaceLock({ cacheDirectory, workspaceDir = null } = {}) {
109
+ export async function acquireWorkspaceLock({ cacheDirectory, workspaceDir = null, _retryCount = 0 } = {}) {
110
+ const MAX_LOCK_RETRIES = 3;
75
111
  if (!cacheDirectory || isTestEnv()) {
76
112
  return { acquired: true, lockPath: null };
77
113
  }
@@ -137,24 +173,16 @@ export async function acquireWorkspaceLock({ cacheDirectory, workspaceDir = null
137
173
  } else {
138
174
  await fs.unlink(lockPath).catch(() => {});
139
175
  }
140
- return acquireWorkspaceLock({ cacheDirectory, workspaceDir });
176
+ if (_retryCount >= MAX_LOCK_RETRIES) {
177
+ console.warn(`[Server] Lock acquisition failed after ${MAX_LOCK_RETRIES} retries; starting in secondary mode.`);
178
+ return { acquired: false, lockPath, ownerPid: null };
179
+ }
180
+ return acquireWorkspaceLock({ cacheDirectory, workspaceDir, _retryCount: _retryCount + 1 });
141
181
  }
142
182
  throw err;
143
183
  }
144
184
 
145
- const cleanup = () => {
146
- try {
147
- const raw = fsSync.readFileSync(lockPath, 'utf-8');
148
- const current = JSON.parse(raw);
149
- if (current && current.pid === process.pid) {
150
- fsSync.unlinkSync(lockPath);
151
- }
152
- } catch {}
153
- };
154
-
155
- process.on('exit', cleanup);
156
- process.on('SIGINT', cleanup);
157
- process.on('SIGTERM', cleanup);
185
+ registerWorkspaceLockCleanup(lockPath);
158
186
 
159
187
  return { acquired: true, lockPath };
160
188
  }
@@ -169,6 +197,7 @@ export async function releaseWorkspaceLock({ cacheDirectory } = {}) {
169
197
  const current = JSON.parse(raw);
170
198
  if (current && current.pid === process.pid) {
171
199
  await fs.unlink(lockPath).catch(() => {});
200
+ workspaceLocksToCleanup.delete(lockPath);
172
201
  }
173
202
  } catch {}
174
203
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softerist/heuristic-mcp",
3
- "version": "3.2.4",
3
+ "version": "3.2.5",
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",