@softerist/heuristic-mcp 3.2.4 → 3.2.6

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.
@@ -1,6 +1,24 @@
1
1
  import path from 'path';
2
2
  import { dotSimilarity, smartChunk, estimateTokens, getModelTokenLimit } from '../lib/utils.js';
3
3
 
4
+ function alignQueryVectorDimension(vector, targetDim) {
5
+ if (!(vector instanceof Float32Array)) {
6
+ vector = new Float32Array(vector);
7
+ }
8
+ if (!Number.isInteger(targetDim) || targetDim <= 0 || vector.length <= targetDim) {
9
+ return vector;
10
+ }
11
+
12
+ const sliced = vector.slice(0, targetDim);
13
+ let mag = 0;
14
+ for (let i = 0; i < sliced.length; i += 1) mag += sliced[i] * sliced[i];
15
+ mag = Math.sqrt(mag);
16
+ if (mag > 0) {
17
+ for (let i = 0; i < sliced.length; i += 1) sliced[i] /= mag;
18
+ }
19
+ return sliced;
20
+ }
21
+
4
22
  export class FindSimilarCode {
5
23
  constructor(embedder, cache, config) {
6
24
  this.embedder = embedder;
@@ -84,6 +102,7 @@ export class FindSimilarCode {
84
102
  } catch {}
85
103
  }
86
104
  }
105
+ codeVector = alignQueryVectorDimension(codeVector, this.config.embeddingDimension);
87
106
 
88
107
  let candidates = vectorStore;
89
108
  let usedAnn = false;
@@ -9,6 +9,24 @@ import {
9
9
  PARTIAL_MATCH_BOOST,
10
10
  } from '../lib/constants.js';
11
11
 
12
+ function alignQueryVectorDimension(vector, targetDim) {
13
+ if (!(vector instanceof Float32Array)) {
14
+ vector = new Float32Array(vector);
15
+ }
16
+ if (!Number.isInteger(targetDim) || targetDim <= 0 || vector.length <= targetDim) {
17
+ return vector;
18
+ }
19
+
20
+ const sliced = vector.slice(0, targetDim);
21
+ let mag = 0;
22
+ for (let i = 0; i < sliced.length; i += 1) mag += sliced[i] * sliced[i];
23
+ mag = Math.sqrt(mag);
24
+ if (mag > 0) {
25
+ for (let i = 0; i < sliced.length; i += 1) sliced[i] /= mag;
26
+ }
27
+ return sliced;
28
+ }
29
+
12
30
  export class HybridSearch {
13
31
  constructor(embedder, cache, config) {
14
32
  this.embedder = embedder;
@@ -134,6 +152,7 @@ export class HybridSearch {
134
152
  }
135
153
  }
136
154
  }
155
+ queryVector = alignQueryVectorDimension(queryVector, this.config.embeddingDimension);
137
156
 
138
157
  let candidateIndices = null;
139
158
  let usedAnn = false;
@@ -336,7 +355,7 @@ export class HybridSearch {
336
355
  for (let k = 0; k < queryWordCount; k++) {
337
356
  if (lowerContent.includes(queryWords[k])) matchedWords++;
338
357
  }
339
- chunk.score += (matchedWords / queryWordCount) * 0.3;
358
+ chunk.score += (matchedWords / queryWordCount) * PARTIAL_MATCH_BOOST;
340
359
  }
341
360
 
342
361
  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,
@@ -167,6 +167,9 @@ export class SetWorkspaceFeature {
167
167
 
168
168
  if (this.cache && typeof this.cache.load === 'function') {
169
169
  try {
170
+ if (typeof this.cache.clearInMemoryState === 'function') {
171
+ this.cache.clearInMemoryState();
172
+ }
170
173
  if (this.config.vectorStoreFormat === 'binary') {
171
174
  await cleanupStaleBinaryArtifacts(newCacheDir);
172
175
  }
@@ -201,11 +204,12 @@ export class SetWorkspaceFeature {
201
204
  }
202
205
 
203
206
  export function createHandleToolCall(featureInstance) {
204
- return async (request) => {
207
+ return async (request, instance) => {
208
+ const activeInstance = instance ?? featureInstance;
205
209
  const args = request.params?.arguments || {};
206
210
  const { workspacePath, reindex } = args;
207
211
 
208
- const result = await featureInstance.execute({
212
+ const result = await activeInstance.execute({
209
213
  workspacePath,
210
214
  reindex: reindex !== false,
211
215
  });
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
  }
@@ -50,6 +47,7 @@ import {
50
47
  registerSignalHandlers,
51
48
  setupPidFile,
52
49
  acquireWorkspaceLock,
50
+ releaseWorkspaceLock,
53
51
  stopOtherHeuristicServers,
54
52
  } from './lib/server-lifecycle.js';
55
53
 
@@ -79,6 +77,13 @@ import {
79
77
  } from './lib/constants.js';
80
78
  const PID_FILE_NAME = '.heuristic-mcp.pid';
81
79
 
80
+ function isTestRuntime() {
81
+ return (
82
+ process.env.VITEST === 'true' ||
83
+ process.env.NODE_ENV === 'test'
84
+ );
85
+ }
86
+
82
87
  async function readLogTail(logPath, maxLines = 2000) {
83
88
  const data = await fs.readFile(logPath, 'utf-8');
84
89
  if (!data) return [];
@@ -134,6 +139,36 @@ async function printMemorySnapshot(workspaceDir) {
134
139
  return true;
135
140
  }
136
141
 
142
+ async function flushLogsSafely(options) {
143
+ if (typeof flushLogs !== 'function') {
144
+ console.warn('[Logs] flushLogs helper is unavailable; skipping log flush.');
145
+ return;
146
+ }
147
+
148
+ try {
149
+ await flushLogs(options);
150
+ } catch (error) {
151
+ const message = error instanceof Error ? error.message : String(error);
152
+ console.warn(`[Logs] Failed to flush logs: ${message}`);
153
+ }
154
+ }
155
+
156
+ function assertCacheContract(cacheInstance) {
157
+ const requiredMethods = [
158
+ 'load',
159
+ 'save',
160
+ 'consumeAutoReindex',
161
+ 'clearInMemoryState',
162
+ 'getStoreSize',
163
+ ];
164
+ const missing = requiredMethods.filter((name) => typeof cacheInstance?.[name] !== 'function');
165
+ if (missing.length > 0) {
166
+ throw new Error(
167
+ `[Server] Cache implementation contract violation: missing method(s): ${missing.join(', ')}`
168
+ );
169
+ }
170
+ }
171
+
137
172
  let embedder = null;
138
173
  let unloadMainEmbedder = null;
139
174
  let cache = null;
@@ -150,6 +185,10 @@ let setWorkspaceFeatureInstance = null;
150
185
  let autoWorkspaceSwitchPromise = null;
151
186
  let rootsCapabilitySupported = null;
152
187
  let rootsProbeInFlight = null;
188
+ let lastRootsProbeTime = 0;
189
+ let keepAliveTimer = null;
190
+ let stdioShutdownHandlers = null;
191
+ const ROOTS_PROBE_COOLDOWN_MS = 2000;
153
192
  const WORKSPACE_BOUND_TOOL_NAMES = new Set([
154
193
  'a_semantic_search',
155
194
  'b_index_codebase',
@@ -202,11 +241,22 @@ function formatCrashDetail(detail) {
202
241
  }
203
242
  }
204
243
 
244
+ function isBrokenPipeError(detail) {
245
+ return Boolean(detail && typeof detail === 'object' && detail.code === 'EPIPE');
246
+ }
247
+
205
248
  function isCrashShutdownReason(reason) {
206
249
  const normalized = String(reason || '').toLowerCase();
207
250
  return normalized.includes('uncaughtexception') || normalized.includes('unhandledrejection');
208
251
  }
209
252
 
253
+ function getShutdownExitCode(reason) {
254
+ const normalized = String(reason || '').trim().toUpperCase();
255
+ if (normalized === 'SIGINT') return 130;
256
+ if (normalized === 'SIGTERM') return 143;
257
+ return isCrashShutdownReason(reason) ? 1 : 0;
258
+ }
259
+
210
260
  function registerProcessDiagnostics({ isServerMode, requestShutdown, getShutdownReason }) {
211
261
  if (!isServerMode) return;
212
262
 
@@ -223,6 +273,10 @@ function registerProcessDiagnostics({ isServerMode, requestShutdown, getShutdown
223
273
  let fatalHandled = false;
224
274
  const handleFatalError = (reason, detail) => {
225
275
  if (fatalHandled) return;
276
+ if (isBrokenPipeError(detail)) {
277
+ requestShutdown('stdio-epipe');
278
+ return;
279
+ }
226
280
  fatalHandled = true;
227
281
  console.error(`[Server] Fatal ${reason}: ${formatCrashDetail(detail)}`);
228
282
  requestShutdown(reason);
@@ -241,6 +295,44 @@ function registerProcessDiagnostics({ isServerMode, requestShutdown, getShutdown
241
295
  });
242
296
  }
243
297
 
298
+ function registerStdioShutdownHandlers(requestShutdown) {
299
+ if (stdioShutdownHandlers) return;
300
+
301
+ const onStdinEnd = () => requestShutdown('stdin-end');
302
+ const onStdinClose = () => requestShutdown('stdin-close');
303
+ const onStdoutError = (err) => {
304
+ if (err?.code === 'EPIPE') {
305
+ requestShutdown('stdout-epipe');
306
+ }
307
+ };
308
+ const onStderrError = (err) => {
309
+ if (err?.code === 'EPIPE') {
310
+ requestShutdown('stderr-epipe');
311
+ }
312
+ };
313
+
314
+ process.stdin?.on?.('end', onStdinEnd);
315
+ process.stdin?.on?.('close', onStdinClose);
316
+ process.stdout?.on?.('error', onStdoutError);
317
+ process.stderr?.on?.('error', onStderrError);
318
+
319
+ stdioShutdownHandlers = {
320
+ onStdinEnd,
321
+ onStdinClose,
322
+ onStdoutError,
323
+ onStderrError,
324
+ };
325
+ }
326
+
327
+ function unregisterStdioShutdownHandlers() {
328
+ if (!stdioShutdownHandlers) return;
329
+ process.stdin?.off?.('end', stdioShutdownHandlers.onStdinEnd);
330
+ process.stdin?.off?.('close', stdioShutdownHandlers.onStdinClose);
331
+ process.stdout?.off?.('error', stdioShutdownHandlers.onStdoutError);
332
+ process.stderr?.off?.('error', stdioShutdownHandlers.onStderrError);
333
+ stdioShutdownHandlers = null;
334
+ }
335
+
244
336
  async function resolveWorkspaceFromEnvValue(rawValue) {
245
337
  if (!rawValue || rawValue.includes('${')) return null;
246
338
  const resolved = path.resolve(rawValue);
@@ -460,12 +552,15 @@ async function maybeAutoSwitchWorkspaceToPath(
460
552
 
461
553
  if (autoWorkspaceSwitchPromise) {
462
554
  await autoWorkspaceSwitchPromise;
463
- return;
555
+ const currentNow = normalizePathForCompare(config.searchDirectory);
556
+ const targetNow = normalizePathForCompare(targetWorkspacePath);
557
+ if (targetNow === currentNow) return;
464
558
  }
465
559
 
466
- autoWorkspaceSwitchPromise = (async () => {
560
+ const switchPromise = (async () => {
561
+ const latestWorkspace = normalizePathForCompare(config.searchDirectory);
467
562
  console.info(
468
- `[Server] Auto-switching workspace from ${currentWorkspace} to ${targetWorkspacePath} (${source || 'auto'})`
563
+ `[Server] Auto-switching workspace from ${latestWorkspace} to ${targetWorkspacePath} (${source || 'auto'})`
469
564
  );
470
565
  const result = await setWorkspaceFeatureInstance.execute({
471
566
  workspacePath: targetWorkspacePath,
@@ -477,11 +572,14 @@ async function maybeAutoSwitchWorkspaceToPath(
477
572
  }
478
573
  trustWorkspacePath(targetWorkspacePath);
479
574
  })();
575
+ autoWorkspaceSwitchPromise = switchPromise;
480
576
 
481
577
  try {
482
- await autoWorkspaceSwitchPromise;
578
+ await switchPromise;
483
579
  } finally {
484
- autoWorkspaceSwitchPromise = null;
580
+ if (autoWorkspaceSwitchPromise === switchPromise) {
581
+ autoWorkspaceSwitchPromise = null;
582
+ }
485
583
  }
486
584
  }
487
585
 
@@ -494,6 +592,9 @@ async function maybeAutoSwitchWorkspaceFromRoots(request) {
494
592
  if (rootsProbeInFlight) {
495
593
  return await rootsProbeInFlight;
496
594
  }
595
+ const now = Date.now();
596
+ if (now - lastRootsProbeTime < ROOTS_PROBE_COOLDOWN_MS) return null;
597
+ lastRootsProbeTime = now;
497
598
 
498
599
  rootsProbeInFlight = (async () => {
499
600
  const rootWorkspace = await detectWorkspaceFromRoots({ quiet: true });
@@ -566,7 +667,7 @@ async function initialize(workspaceDir) {
566
667
  }
567
668
  }
568
669
 
569
- const isTest = Boolean(process.env.VITEST || process.env.VITEST_WORKER_ID);
670
+ const isTest = isTestRuntime();
570
671
  if (config.enableExplicitGc && typeof global.gc !== 'function' && !isTest) {
571
672
  console.warn(
572
673
  '[Server] enableExplicitGc=true but this process was not started with --expose-gc; continuing with explicit GC disabled.'
@@ -820,6 +921,7 @@ async function initialize(workspaceDir) {
820
921
  }
821
922
 
822
923
  cache = new EmbeddingsCache(config);
924
+ assertCacheContract(cache);
823
925
  console.info(`[Server] Cache directory: ${config.cacheDirectory}`);
824
926
 
825
927
  indexer = new CodebaseIndexer(embedder, cache, config, server);
@@ -865,7 +967,7 @@ async function initialize(workspaceDir) {
865
967
  );
866
968
  } else {
867
969
  console.warn(
868
- `[Server] Cache corruption detected while ${context}. This server is secondary read-only and cannot re-index. Reload the IDE window for this workspace or use the primary instance to rebuild the cache.`
970
+ `[Server] Cache corruption detected while ${context}. This server is secondary read-only and cannot re-index. Restart the MCP client session for this workspace or use the primary instance to rebuild the cache.`
869
971
  );
870
972
  }
871
973
  return true;
@@ -936,7 +1038,8 @@ async function initialize(workspaceDir) {
936
1038
  context: 'loading cache in secondary read-only mode',
937
1039
  canReindex: false,
938
1040
  });
939
- if (cache.getStoreSize() === 0) {
1041
+ const storeSize = cache.getStoreSize();
1042
+ if (storeSize === 0) {
940
1043
  await tryAutoAttachWorkspaceCache('secondary-empty-cache', { canReindex: false });
941
1044
  }
942
1045
  if (config.verbose) {
@@ -1107,7 +1210,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1107
1210
  content: [
1108
1211
  {
1109
1212
  type: 'text',
1110
- text: `Attached cache for ${normalizedPath}, but it is corrupt. This secondary read-only instance cannot rebuild it. Reload the IDE window for this workspace or run indexing from the primary instance.`,
1213
+ text: `Attached cache for ${normalizedPath}, but it is corrupt. This secondary read-only instance cannot rebuild it. Restart the MCP client session for this workspace or run indexing from the primary instance.`,
1111
1214
  },
1112
1215
  ],
1113
1216
  isError: true,
@@ -1217,10 +1320,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1217
1320
 
1218
1321
  const searchTools = ['a_semantic_search', 'd_find_similar_code'];
1219
1322
  if (config.unloadModelAfterSearch && searchTools.includes(toolDef.name)) {
1220
- setImmediate(async () => {
1221
- if (typeof unloadMainEmbedder === 'function') {
1222
- await unloadMainEmbedder();
1223
- }
1323
+ setImmediate(() => {
1324
+ const unloadFn = unloadMainEmbedder;
1325
+ if (typeof unloadFn !== 'function') return;
1326
+ void Promise.resolve()
1327
+ .then(() => unloadFn())
1328
+ .catch((err) => {
1329
+ const message = err instanceof Error ? err.message : String(err);
1330
+ console.warn(`[Server] Post-search model unload failed: ${message}`);
1331
+ });
1224
1332
  });
1225
1333
  }
1226
1334
 
@@ -1270,13 +1378,14 @@ export async function main(argv = process.argv) {
1270
1378
  console.info(`[Server] Shutdown requested (${reason}).`);
1271
1379
  void gracefulShutdown(reason);
1272
1380
  };
1381
+ const isTestEnv = isTestRuntime();
1273
1382
  registerProcessDiagnostics({
1274
1383
  isServerMode,
1275
1384
  requestShutdown,
1276
1385
  getShutdownReason: () => shutdownReason,
1277
1386
  });
1278
1387
 
1279
- if (isServerMode && !(process.env.VITEST === 'true' || process.env.NODE_ENV === 'test')) {
1388
+ if (isServerMode && !isTestEnv) {
1280
1389
  enableStderrOnlyLogging();
1281
1390
  }
1282
1391
  if (wantsVersion) {
@@ -1403,41 +1512,37 @@ export async function main(argv = process.argv) {
1403
1512
 
1404
1513
  registerSignalHandlers(requestShutdown);
1405
1514
 
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
- };
1515
+ const detectedRootPromise = isTestEnv
1516
+ ? Promise.resolve(null)
1517
+ : new Promise((resolve) => {
1518
+ const HANDSHAKE_TIMEOUT_MS = 1000;
1519
+ let settled = false;
1520
+ const resolveOnce = (value) => {
1521
+ if (settled) return;
1522
+ settled = true;
1523
+ resolve(value);
1524
+ };
1414
1525
 
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
- });
1526
+ const timer = setTimeout(() => {
1527
+ console.warn(
1528
+ `[Server] MCP handshake timed out after ${HANDSHAKE_TIMEOUT_MS}ms, proceeding without roots.`
1529
+ );
1530
+ resolveOnce(null);
1531
+ }, HANDSHAKE_TIMEOUT_MS);
1532
+
1533
+ server.oninitialized = async () => {
1534
+ clearTimeout(timer);
1535
+ console.info('[Server] MCP handshake complete.');
1536
+ const root = await detectWorkspaceFromRoots();
1537
+ resolveOnce(root);
1538
+ };
1539
+ });
1429
1540
 
1430
1541
  const transport = new StdioServerTransport();
1431
1542
  await server.connect(transport);
1432
1543
  console.info('[Server] MCP transport connected.');
1433
1544
  if (isServerMode) {
1434
- process.stdin?.on?.('end', () => requestShutdown('stdin-end'));
1435
- process.stdin?.on?.('close', () => requestShutdown('stdin-close'));
1436
- process.stdout?.on?.('error', (err) => {
1437
- if (err?.code === 'EPIPE') {
1438
- requestShutdown('stdout-epipe');
1439
- }
1440
- });
1545
+ registerStdioShutdownHandlers(requestShutdown);
1441
1546
  }
1442
1547
 
1443
1548
  const detectedRoot = await detectedRootPromise;
@@ -1446,28 +1551,28 @@ export async function main(argv = process.argv) {
1446
1551
  if (detectedRoot) {
1447
1552
  console.info(`[Server] Using workspace from MCP roots: ${detectedRoot}`);
1448
1553
  }
1449
- const initPromise = initialize(effectiveWorkspace);
1450
- const initWithResolve = initPromise
1451
- .then((result) => {
1452
- configReadyResolve();
1453
- return result;
1454
- })
1455
- .catch((err) => {
1456
- configInitError = err;
1457
- configReadyResolve();
1458
- throw err;
1459
- });
1460
- const { startBackgroundTasks } = await initWithResolve;
1554
+ let startBackgroundTasks;
1555
+ try {
1556
+ const initResult = await initialize(effectiveWorkspace);
1557
+ startBackgroundTasks = initResult.startBackgroundTasks;
1558
+ } catch (err) {
1559
+ configInitError = err;
1560
+ configReadyResolve();
1561
+ throw err;
1562
+ }
1461
1563
 
1462
1564
  console.info('[Server] Heuristic MCP server started.');
1463
1565
 
1464
- void startBackgroundTasks().catch((err) => {
1566
+ try {
1567
+ await startBackgroundTasks();
1568
+ } catch (err) {
1465
1569
  console.error(`[Server] Background task error: ${err.message}`);
1466
- });
1570
+ }
1571
+ configReadyResolve();
1467
1572
  // Keep-Alive mechanism: ensure the process stays alive even if StdioServerTransport
1468
1573
  // temporarily loses its active handle status or during complex async chains.
1469
- if (isServerMode) {
1470
- setInterval(() => {
1574
+ if (isServerMode && !isTestEnv && !keepAliveTimer) {
1575
+ keepAliveTimer = setInterval(() => {
1471
1576
  // Logic to keep event loop active.
1472
1577
  // We don't need to do anything, just the presence of the timer is enough.
1473
1578
  }, SERVER_KEEP_ALIVE_INTERVAL_MS);
@@ -1478,7 +1583,14 @@ export async function main(argv = process.argv) {
1478
1583
 
1479
1584
  async function gracefulShutdown(signal) {
1480
1585
  console.info(`[Server] Received ${signal}, shutting down gracefully...`);
1481
- const exitCode = isCrashShutdownReason(signal) ? 1 : 0;
1586
+ const exitCode = getShutdownExitCode(signal);
1587
+
1588
+ if (keepAliveTimer) {
1589
+ clearInterval(keepAliveTimer);
1590
+ keepAliveTimer = null;
1591
+ }
1592
+
1593
+ unregisterStdioShutdownHandlers();
1482
1594
 
1483
1595
  const cleanupTasks = [];
1484
1596
 
@@ -1502,37 +1614,57 @@ async function gracefulShutdown(signal) {
1502
1614
  }
1503
1615
 
1504
1616
  if (cache) {
1505
- if (!workspaceLockAcquired) {
1506
- console.info('[Server] Secondary/fallback mode: skipping cache save.');
1507
- } else {
1508
- cleanupTasks.push(
1509
- cache
1510
- .save()
1511
- .then(() => console.info('[Server] Cache saved'))
1512
- .catch((err) => console.error(`[Server] Failed to save cache: ${err.message}`))
1513
- );
1514
- }
1617
+ cleanupTasks.push(
1618
+ (async () => {
1619
+ if (!workspaceLockAcquired) {
1620
+ console.info('[Server] Secondary/fallback mode: skipping cache save.');
1621
+ } else {
1622
+ await cache.save();
1623
+ console.info('[Server] Cache saved');
1624
+ }
1625
+ if (typeof cache.close === 'function') {
1626
+ await cache.close();
1627
+ }
1628
+ })().catch((err) => console.error(`[Server] Cache shutdown cleanup failed: ${err.message}`))
1629
+ );
1630
+ }
1631
+
1632
+ if (workspaceLockAcquired && config?.cacheDirectory) {
1633
+ cleanupTasks.push(
1634
+ releaseWorkspaceLock({ cacheDirectory: config.cacheDirectory }).catch((err) =>
1635
+ console.warn(`[Server] Failed to release workspace lock: ${err.message}`)
1636
+ )
1637
+ );
1515
1638
  }
1516
1639
 
1517
1640
  await Promise.allSettled(cleanupTasks);
1518
1641
  console.info('[Server] Goodbye!');
1519
- await flushLogs({ close: true, timeoutMs: 1500 }).catch(() => {});
1642
+ await flushLogsSafely({ close: true, timeoutMs: 1500 });
1520
1643
 
1521
1644
  setTimeout(() => process.exit(exitCode), 100);
1522
1645
  }
1523
1646
 
1647
+ function isLikelyCliEntrypoint(argvPath) {
1648
+ const base = path.basename(argvPath || '').toLowerCase();
1649
+ return (
1650
+ base === 'heuristic-mcp' ||
1651
+ base === 'heuristic-mcp.js' ||
1652
+ base === 'heuristic-mcp.mjs' ||
1653
+ base === 'heuristic-mcp.cjs' ||
1654
+ base === 'heuristic-mcp.cmd'
1655
+ );
1656
+ }
1657
+
1524
1658
  const isMain =
1525
1659
  process.argv[1] &&
1526
1660
  (path.resolve(process.argv[1]).toLowerCase() === fileURLToPath(import.meta.url).toLowerCase() ||
1527
- process.argv[1].endsWith('heuristic-mcp') ||
1528
- process.argv[1].endsWith('heuristic-mcp.js') ||
1529
- path.basename(process.argv[1]) === 'index.js') &&
1661
+ isLikelyCliEntrypoint(process.argv[1])) &&
1530
1662
  !(process.env.VITEST === 'true' || process.env.NODE_ENV === 'test');
1531
1663
 
1532
1664
  if (isMain) {
1533
1665
  main().catch(async (err) => {
1534
1666
  console.error(err);
1535
- await flushLogs({ close: true, timeoutMs: 500 }).catch(() => {});
1667
+ await flushLogsSafely({ close: true, timeoutMs: 500 });
1536
1668
  process.exit(1);
1537
1669
  });
1538
1670
  }
package/lib/cache.js CHANGED
@@ -223,14 +223,7 @@ function normalizeFileHashEntry(entry) {
223
223
  }
224
224
 
225
225
  function serializeFileHashEntry(entry) {
226
- if (!entry) return null;
227
- if (typeof entry === 'string') return { hash: entry };
228
- if (typeof entry !== 'object') return null;
229
- if (typeof entry.hash !== 'string') return null;
230
- const serialized = { hash: entry.hash };
231
- if (Number.isFinite(entry.mtimeMs)) serialized.mtimeMs = entry.mtimeMs;
232
- if (Number.isFinite(entry.size)) serialized.size = entry.size;
233
- return serialized;
226
+ return normalizeFileHashEntry(entry);
234
227
  }
235
228
 
236
229
  function computeAnnCapacity(total, config) {
@@ -444,7 +437,7 @@ export class EmbeddingsCache {
444
437
  await Promise.race([
445
438
  waiterPromise,
446
439
  new Promise((resolve) => {
447
- setTimeout(() => {
440
+ const timer = setTimeout(() => {
448
441
  if (!resolved) {
449
442
  resolved = true;
450
443
  timedOut = true;
@@ -454,6 +447,7 @@ export class EmbeddingsCache {
454
447
  resolve();
455
448
  }
456
449
  }, timeoutMs);
450
+ timer.unref?.();
457
451
  }),
458
452
  ]);
459
453
  if (timedOut) {
@@ -789,6 +783,7 @@ export class EmbeddingsCache {
789
783
  this._savePromise = null;
790
784
  });
791
785
  }, debounceMs);
786
+ this._saveTimer.unref?.();
792
787
  });
793
788
 
794
789
  return this._savePromise;
@@ -1528,7 +1523,11 @@ export class EmbeddingsCache {
1528
1523
  if (labels.length === 0) return [];
1529
1524
 
1530
1525
  const filtered = labels.filter(
1531
- (label) => Number.isInteger(label) && label >= 0 && label < this.vectorStore.length
1526
+ (label) =>
1527
+ Number.isInteger(label) &&
1528
+ label >= 0 &&
1529
+ label < this.vectorStore.length &&
1530
+ Boolean(this.vectorStore[label])
1532
1531
  );
1533
1532
 
1534
1533
  return filtered;
@@ -13,6 +13,7 @@ let idleTimer = null;
13
13
  let currentConfig = null;
14
14
  let pendingRequests = [];
15
15
  let isProcessingRequest = false;
16
+ let activeRequest = null;
16
17
 
17
18
  const DEFAULT_IDLE_TIMEOUT_MS = EMBEDDING_POOL_IDLE_TIMEOUT_MS;
18
19
 
@@ -43,36 +44,59 @@ function getOrCreateChild(config) {
43
44
  childReadline.on('line', (line) => {
44
45
  if (!line.trim()) return;
45
46
 
46
- if (pendingRequests.length > 0) {
47
- const { resolve, reject, startTime } = pendingRequests.shift();
48
- try {
49
- const result = JSON.parse(line);
50
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
51
-
52
- if (!result.results || result.results.length === 0) {
53
- reject(new Error('Embedding child process returned no results'));
54
- return;
55
- }
56
-
57
- const embResult = result.results[0];
58
- if (!embResult.success) {
59
- reject(new Error(`Embedding failed: ${embResult.error}`));
60
- return;
61
- }
62
-
63
- const vector = new Float32Array(embResult.vector);
64
-
65
- if (currentConfig?.verbose) {
66
- console.info(`[Search] Query embedding (persistent child) completed in ${elapsed}s`);
67
- }
68
-
69
- resolve(vector);
70
- } catch (err) {
71
- reject(new Error(`Failed to parse embedding result: ${err.message}`));
72
- } finally {
73
- isProcessingRequest = false;
74
- processNextRequest();
47
+ let result;
48
+ try {
49
+ result = JSON.parse(line);
50
+ } catch {
51
+ if (currentConfig?.verbose) {
52
+ console.warn('[EmbedPool] Ignoring non-JSON stdout from embedding child');
53
+ }
54
+ return;
55
+ }
56
+
57
+ if (!result || typeof result !== 'object' || !Object.prototype.hasOwnProperty.call(result, 'results')) {
58
+ if (currentConfig?.verbose) {
59
+ console.warn('[EmbedPool] Ignoring unexpected stdout payload from embedding child');
60
+ }
61
+ return;
62
+ }
63
+
64
+ if (!activeRequest) {
65
+ if (currentConfig?.verbose) {
66
+ console.warn('[EmbedPool] Received embedding response with no active request');
67
+ }
68
+ return;
69
+ }
70
+
71
+ const { resolve, reject, startTime } = activeRequest;
72
+ activeRequest = null;
73
+
74
+ try {
75
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
76
+
77
+ if (!result.results || result.results.length === 0) {
78
+ reject(new Error('Embedding child process returned no results'));
79
+ return;
80
+ }
81
+
82
+ const embResult = result.results[0];
83
+ if (!embResult.success) {
84
+ reject(new Error(`Embedding failed: ${embResult.error}`));
85
+ return;
86
+ }
87
+
88
+ const vector = new Float32Array(embResult.vector);
89
+
90
+ if (currentConfig?.verbose) {
91
+ console.info(`[Search] Query embedding (persistent child) completed in ${elapsed}s`);
75
92
  }
93
+
94
+ resolve(vector);
95
+ } catch (err) {
96
+ reject(new Error(`Failed to parse embedding result: ${err.message}`));
97
+ } finally {
98
+ isProcessingRequest = false;
99
+ processNextRequest();
76
100
  }
77
101
  });
78
102
 
@@ -84,8 +108,14 @@ function getOrCreateChild(config) {
84
108
 
85
109
  persistentChild.on('error', (err) => {
86
110
  console.error(`[EmbedPool] Child process error: ${err.message}`);
111
+ const inflight = activeRequest;
87
112
  cleanupChild();
88
113
 
114
+ if (inflight) {
115
+ inflight.reject(new Error(`Child process error: ${err.message}`));
116
+ activeRequest = null;
117
+ }
118
+
89
119
  for (const { reject } of pendingRequests) {
90
120
  reject(new Error(`Child process error: ${err.message}`));
91
121
  }
@@ -96,8 +126,14 @@ function getOrCreateChild(config) {
96
126
  if (currentConfig?.verbose) {
97
127
  console.info(`[EmbedPool] Child process exited with code ${code}`);
98
128
  }
129
+ const inflight = activeRequest;
99
130
  cleanupChild();
100
131
 
132
+ if (inflight) {
133
+ inflight.reject(new Error(`Child process exited unexpectedly with code ${code}`));
134
+ activeRequest = null;
135
+ }
136
+
101
137
  for (const { reject } of pendingRequests) {
102
138
  reject(new Error(`Child process exited unexpectedly with code ${code}`));
103
139
  }
@@ -132,6 +168,9 @@ function resetIdleTimer(config) {
132
168
  shutdownChild();
133
169
  }
134
170
  }, timeout);
171
+ if (typeof idleTimer.unref === 'function') {
172
+ idleTimer.unref();
173
+ }
135
174
  }
136
175
 
137
176
  function cleanupChild() {
@@ -148,6 +187,15 @@ function cleanupChild() {
148
187
  }
149
188
 
150
189
  function shutdownChild() {
190
+ if (activeRequest) {
191
+ activeRequest.reject(new Error('Embedding pool shutdown requested'));
192
+ activeRequest = null;
193
+ }
194
+ for (const { reject } of pendingRequests) {
195
+ reject(new Error('Embedding pool shutdown requested'));
196
+ }
197
+ pendingRequests = [];
198
+
151
199
  if (persistentChild && !persistentChild.killed) {
152
200
  try {
153
201
  persistentChild.stdin.write(JSON.stringify({ type: 'shutdown' }) + '\n');
@@ -159,19 +207,21 @@ function shutdownChild() {
159
207
  }
160
208
 
161
209
  function processNextRequest() {
162
- if (isProcessingRequest || pendingRequests.length === 0) return;
210
+ if (isProcessingRequest || activeRequest || pendingRequests.length === 0) return;
163
211
 
164
- const request = pendingRequests[0];
212
+ const request = pendingRequests.shift();
165
213
  if (!request) return;
166
214
 
167
215
  isProcessingRequest = true;
216
+ activeRequest = request;
168
217
 
169
218
  try {
170
219
  const child = getOrCreateChild(request.config);
171
220
  child.stdin.write(JSON.stringify(request.payload) + '\n');
172
221
  } catch (err) {
173
- const { reject } = pendingRequests.shift();
222
+ const { reject } = request;
174
223
  reject(err);
224
+ activeRequest = null;
175
225
  isProcessingRequest = false;
176
226
  processNextRequest();
177
227
  }
@@ -713,10 +713,11 @@ parentPort.on('message', async (message) => {
713
713
  maybeRunGc();
714
714
  }
715
715
 
716
+ const taskIndex = fileTasks.length;
716
717
  if (chunks.length > 0) {
717
718
  for (const c of chunks) {
718
719
  allPendingChunks.push({
719
- fileIndex: i,
720
+ fileTaskIndex: taskIndex,
720
721
  text: c.text,
721
722
  startLine: c.startLine,
722
723
  endLine: c.endLine,
@@ -847,7 +848,7 @@ parentPort.on('message', async (message) => {
847
848
 
848
849
  for (const chunkItem of allPendingChunks) {
849
850
  if (chunkItem.vectorBuffer) {
850
- const task = fileTasks[chunkItem.fileIndex];
851
+ const task = fileTasks[chunkItem.fileTaskIndex];
851
852
  task.results.push({
852
853
  startLine: chunkItem.startLine,
853
854
  endLine: chunkItem.endLine,
package/lib/logging.js CHANGED
@@ -4,23 +4,39 @@ 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
- log: console.info,
9
+ // eslint-disable-next-line no-console
10
+ log: console.log,
9
11
  warn: console.warn,
10
12
  error: console.error,
11
13
  info: console.info,
12
14
  };
13
15
 
16
+ function isBrokenPipeError(error) {
17
+ return Boolean(error && typeof error === 'object' && error.code === 'EPIPE');
18
+ }
19
+
20
+ function writeToOriginalStderr(...args) {
21
+ if (!stderrWritable) return;
22
+ try {
23
+ originalConsole.error(...args);
24
+ } catch (error) {
25
+ if (isBrokenPipeError(error)) {
26
+ stderrWritable = false;
27
+ return;
28
+ }
29
+ throw error;
30
+ }
31
+ }
32
+
14
33
  export function enableStderrOnlyLogging() {
15
- const redirect = (...args) => originalConsole.error(...args);
34
+ const redirect = (...args) => writeToOriginalStderr(...args);
16
35
  // eslint-disable-next-line no-console
17
36
  console.log = redirect;
18
37
  console.info = redirect;
19
38
  console.warn = redirect;
20
39
  console.error = redirect;
21
- // eslint-disable-next-line no-console
22
- console.log = redirect;
23
- console.info = redirect;
24
40
  }
25
41
 
26
42
  export async function setupFileLogging(config) {
@@ -49,10 +65,9 @@ export async function setupFileLogging(config) {
49
65
  };
50
66
 
51
67
  const wrap = (method, level) => {
52
- const originalError = originalConsole.error;
53
68
  // eslint-disable-next-line no-console
54
69
  console[method] = (...args) => {
55
- originalError(...args);
70
+ writeToOriginalStderr(...args);
56
71
  writeLine(level, args);
57
72
  };
58
73
  };
@@ -63,7 +78,7 @@ export async function setupFileLogging(config) {
63
78
  wrap('info', 'INFO');
64
79
 
65
80
  logStream.on('error', (err) => {
66
- originalConsole.error(`[Logs] Failed to write log file: ${err.message}`);
81
+ writeToOriginalStderr(`[Logs] Failed to write log file: ${err.message}`);
67
82
  });
68
83
 
69
84
  process.on('exit', () => {
@@ -72,7 +87,7 @@ export async function setupFileLogging(config) {
72
87
 
73
88
  return logPath;
74
89
  } catch (err) {
75
- originalConsole.error(`[Logs] Failed to initialize log file: ${err.message}`);
90
+ writeToOriginalStderr(`[Logs] Failed to initialize log file: ${err.message}`);
76
91
  return null;
77
92
  }
78
93
  }
@@ -4,10 +4,54 @@ 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
+ let registeredSignalHandlers = null;
12
+ const LOCK_RETRY_BASE_DELAY_MS = 50;
13
+ const LOCK_RETRY_MAX_DELAY_MS = 500;
14
+
7
15
  function isTestEnv() {
8
16
  return process.env.VITEST === 'true' || process.env.NODE_ENV === 'test';
9
17
  }
10
18
 
19
+ function cleanupPidFilesOnExit() {
20
+ for (const pidPath of pidFilesToCleanup) {
21
+ try {
22
+ fsSync.unlinkSync(pidPath);
23
+ } catch {}
24
+ }
25
+ }
26
+
27
+ function registerPidFileCleanup(pidPath) {
28
+ if (!pidPath) return;
29
+ pidFilesToCleanup.add(pidPath);
30
+ if (pidExitCleanupRegistered) return;
31
+ process.on('exit', cleanupPidFilesOnExit);
32
+ pidExitCleanupRegistered = true;
33
+ }
34
+
35
+ function cleanupWorkspaceLocksOnExit() {
36
+ for (const lockPath of workspaceLocksToCleanup) {
37
+ try {
38
+ const raw = fsSync.readFileSync(lockPath, 'utf-8');
39
+ const current = JSON.parse(raw);
40
+ if (current && current.pid === process.pid) {
41
+ fsSync.unlinkSync(lockPath);
42
+ }
43
+ } catch {}
44
+ }
45
+ }
46
+
47
+ function registerWorkspaceLockCleanup(lockPath) {
48
+ if (!lockPath) return;
49
+ workspaceLocksToCleanup.add(lockPath);
50
+ if (workspaceLockExitCleanupRegistered) return;
51
+ process.on('exit', cleanupWorkspaceLocksOnExit);
52
+ workspaceLockExitCleanupRegistered = true;
53
+ }
54
+
11
55
  function getPidFilePath({ pidFileName, cacheDirectory }) {
12
56
  if (cacheDirectory) {
13
57
  return path.join(cacheDirectory, pidFileName);
@@ -44,22 +88,41 @@ export async function setupPidFile({
44
88
  return null;
45
89
  }
46
90
 
47
- const cleanup = () => {
48
- try {
49
- fsSync.unlinkSync(pidPath);
50
- } catch {}
51
- };
52
-
53
- process.on('exit', cleanup);
91
+ registerPidFileCleanup(pidPath);
54
92
  return pidPath;
55
93
  }
56
94
 
57
95
  export function registerSignalHandlers(handler) {
58
- process.on('SIGINT', () => handler('SIGINT'));
59
- process.on('SIGTERM', () => handler('SIGTERM'));
96
+ if (typeof handler !== 'function') {
97
+ return () => {};
98
+ }
99
+
100
+ if (registeredSignalHandlers) {
101
+ process.off('SIGINT', registeredSignalHandlers.onSigint);
102
+ process.off('SIGTERM', registeredSignalHandlers.onSigterm);
103
+ registeredSignalHandlers = null;
104
+ }
105
+
106
+ const onSigint = () => handler('SIGINT');
107
+ const onSigterm = () => handler('SIGTERM');
108
+ process.on('SIGINT', onSigint);
109
+ process.on('SIGTERM', onSigterm);
110
+
111
+ registeredSignalHandlers = { onSigint, onSigterm };
112
+
113
+ return () => {
114
+ if (registeredSignalHandlers?.onSigint === onSigint) {
115
+ process.off('SIGINT', onSigint);
116
+ process.off('SIGTERM', onSigterm);
117
+ registeredSignalHandlers = null;
118
+ }
119
+ };
60
120
  }
61
121
 
62
122
  function isProcessRunning(pid) {
123
+ if (!Number.isInteger(pid) || pid <= 0) {
124
+ return false;
125
+ }
63
126
  try {
64
127
  process.kill(pid, 0);
65
128
  return true;
@@ -71,7 +134,8 @@ function isProcessRunning(pid) {
71
134
  }
72
135
  }
73
136
 
74
- export async function acquireWorkspaceLock({ cacheDirectory, workspaceDir = null } = {}) {
137
+ export async function acquireWorkspaceLock({ cacheDirectory, workspaceDir = null, _retryCount = 0 } = {}) {
138
+ const MAX_LOCK_RETRIES = 3;
75
139
  if (!cacheDirectory || isTestEnv()) {
76
140
  return { acquired: true, lockPath: null };
77
141
  }
@@ -137,24 +201,21 @@ export async function acquireWorkspaceLock({ cacheDirectory, workspaceDir = null
137
201
  } else {
138
202
  await fs.unlink(lockPath).catch(() => {});
139
203
  }
140
- return acquireWorkspaceLock({ cacheDirectory, workspaceDir });
204
+ if (_retryCount >= MAX_LOCK_RETRIES) {
205
+ console.warn(`[Server] Lock acquisition failed after ${MAX_LOCK_RETRIES} retries; starting in secondary mode.`);
206
+ return { acquired: false, lockPath, ownerPid: null };
207
+ }
208
+ const retryDelayMs = Math.min(
209
+ LOCK_RETRY_MAX_DELAY_MS,
210
+ LOCK_RETRY_BASE_DELAY_MS * 2 ** _retryCount
211
+ );
212
+ await delay(retryDelayMs);
213
+ return acquireWorkspaceLock({ cacheDirectory, workspaceDir, _retryCount: _retryCount + 1 });
141
214
  }
142
215
  throw err;
143
216
  }
144
217
 
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);
218
+ registerWorkspaceLockCleanup(lockPath);
158
219
 
159
220
  return { acquired: true, lockPath };
160
221
  }
@@ -169,6 +230,7 @@ export async function releaseWorkspaceLock({ cacheDirectory } = {}) {
169
230
  const current = JSON.parse(raw);
170
231
  if (current && current.pid === process.pid) {
171
232
  await fs.unlink(lockPath).catch(() => {});
233
+ workspaceLocksToCleanup.delete(lockPath);
172
234
  }
173
235
  } catch {}
174
236
  }
package/lib/utils.js CHANGED
@@ -241,10 +241,11 @@ export function smartChunk(content, file, config) {
241
241
  if (currentChunk.length > 0) {
242
242
  const chunkText = currentChunk.join('\n');
243
243
  if (chunkText.trim().length > MIN_CHUNK_TEXT_LENGTH) {
244
+ const endLine = chunkStartLine + currentChunk.length;
244
245
  chunks.push({
245
246
  text: chunkText,
246
247
  startLine: chunkStartLine + 1,
247
- endLine: i,
248
+ endLine,
248
249
  tokenCount: currentTokenCount + SPECIAL_TOKENS,
249
250
  });
250
251
  }
@@ -295,10 +296,11 @@ export function smartChunk(content, file, config) {
295
296
  if (shouldSplit && safeToSplit && currentChunk.length > 0) {
296
297
  const chunkText = currentChunk.join('\n');
297
298
  if (chunkText.trim().length > MIN_CHUNK_TEXT_LENGTH) {
299
+ const endLine = chunkStartLine + currentChunk.length;
298
300
  chunks.push({
299
301
  text: chunkText,
300
302
  startLine: chunkStartLine + 1,
301
- endLine: i,
303
+ endLine,
302
304
  tokenCount: currentTokenCount,
303
305
  });
304
306
  }
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.6",
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",