@softerist/heuristic-mcp 3.2.1 → 3.2.2

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.
@@ -25,17 +25,28 @@ export class AnnConfigTool {
25
25
  return this.cache.setEfSearch(efSearch);
26
26
  }
27
27
 
28
- if (action === 'rebuild') {
29
-
30
- this.cache.invalidateAnnIndex();
31
- const index = await this.cache.ensureAnnIndex();
32
- return {
33
- success: index !== null,
34
- message: index
35
- ? 'ANN index rebuilt successfully'
36
- : 'ANN index rebuild failed or not available',
37
- };
38
- }
28
+ if (action === 'rebuild') {
29
+ try {
30
+
31
+ this.cache.invalidateAnnIndex();
32
+ const index = await this.cache.ensureAnnIndex();
33
+ if (!index) {
34
+ return {
35
+ success: false,
36
+ error: 'ANN index rebuild failed or not available',
37
+ };
38
+ }
39
+ return {
40
+ success: true,
41
+ message: 'ANN index rebuilt successfully',
42
+ };
43
+ } catch (error) {
44
+ return {
45
+ success: false,
46
+ error: error instanceof Error ? error.message : String(error),
47
+ };
48
+ }
49
+ }
39
50
 
40
51
  return {
41
52
  success: false,
@@ -43,10 +54,10 @@ export class AnnConfigTool {
43
54
  };
44
55
  }
45
56
 
46
- formatResults(result) {
47
- if (result.success === false) {
48
- return `Error: ${result.error}`;
49
- }
57
+ formatResults(result) {
58
+ if (result.success === false) {
59
+ return `Error: ${result.error || result.message || 'Unknown error'}`;
60
+ }
50
61
 
51
62
  if (result.enabled !== undefined) {
52
63
 
@@ -2857,16 +2857,16 @@ export class CodebaseIndexer {
2857
2857
 
2858
2858
  const totalDurationMs = Date.now() - totalStartTime;
2859
2859
  const totalTime = (totalDurationMs / 1000).toFixed(1);
2860
- console.info(
2861
- `[Indexer] Embedding pass complete: ${totalChunks} chunks from ${filesToProcess.length} files in ${totalTime}s`
2862
- );
2863
-
2864
-
2865
- this.sendProgress(
2866
- 100,
2867
- 100,
2868
- `Complete: ${totalChunks} chunks from ${filesToProcess.length} files in ${totalTime}s`
2869
- );
2860
+ console.info(
2861
+ `[Indexer] Embedding pass complete: ${totalChunks} chunks from ${filesToProcess.length} files in ${totalTime}s`
2862
+ );
2863
+
2864
+
2865
+ this.sendProgress(
2866
+ 95,
2867
+ 100,
2868
+ `Embedding complete; saving cache (${totalChunks} chunks from ${filesToProcess.length} files)...`
2869
+ );
2870
2870
 
2871
2871
  this.cache.setLastIndexDuration?.(totalDurationMs);
2872
2872
  this.cache.setLastIndexStats?.({
@@ -2881,7 +2881,13 @@ export class CodebaseIndexer {
2881
2881
  lastCheckpointIntervalMs: checkpointIntervalMs,
2882
2882
  lastCheckpointSaves: checkpointSaveCount,
2883
2883
  });
2884
- await this.cache.save();
2884
+ await this.cache.save({ throwOnError: true });
2885
+
2886
+ this.sendProgress(
2887
+ 100,
2888
+ 100,
2889
+ `Complete: ${totalChunks} chunks from ${filesToProcess.length} files in ${totalTime}s`
2890
+ );
2885
2891
 
2886
2892
  const vectorStoreSnapshot = this.cache.getVectorStore();
2887
2893
  const totalFiles = new Set(vectorStoreSnapshot.map((v) => v.file)).size;
@@ -14,8 +14,23 @@ import {
14
14
  upsertMcpServerEntryInText,
15
15
  } from '../lib/settings-editor.js';
16
16
 
17
- const execPromise = util.promisify(exec);
18
- const PID_FILE_NAME = '.heuristic-mcp.pid';
17
+ const execPromise = util.promisify(exec);
18
+ const PID_FILE_NAME = '.heuristic-mcp.pid';
19
+ const BINARY_TELEMETRY_FILE = 'binary-store-telemetry.json';
20
+
21
+ async function readBinaryTelemetry(cacheDir) {
22
+ const telemetryPath = path.join(cacheDir, BINARY_TELEMETRY_FILE);
23
+ try {
24
+ return JSON.parse(await fs.readFile(telemetryPath, 'utf-8'));
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ function hasNonZeroBinaryTelemetry(totals) {
31
+ if (!totals || typeof totals !== 'object') return false;
32
+ return Object.values(totals).some((value) => Number.isFinite(value) && value > 0);
33
+ }
19
34
 
20
35
  function getUserHomeDir() {
21
36
  if (process.platform === 'win32' && process.env.USERPROFILE) {
@@ -1208,18 +1223,18 @@ export async function status({ fix = false, cacheOnly = false, workspaceDir = nu
1208
1223
  if (progressData.indexMode) {
1209
1224
  console.info(` Current index mode: ${String(progressData.indexMode)}`);
1210
1225
  }
1211
- if (
1212
- progressData.workerCircuitOpen &&
1213
- Number.isFinite(progressData.workersDisabledUntil)
1214
- ) {
1226
+ if (
1227
+ progressData.workerCircuitOpen &&
1228
+ Number.isFinite(progressData.workersDisabledUntil)
1229
+ ) {
1215
1230
  const remainingMs = progressData.workersDisabledUntil - Date.now();
1216
1231
  const remainingLabel = formatDurationMs(Math.max(0, remainingMs));
1217
1232
  console.info(` Workers paused: ${remainingLabel || '0s'} remaining`);
1218
1233
  console.info(
1219
1234
  ` Workers disabled until: ${formatDateTime(progressData.workersDisabledUntil)}`
1220
- );
1221
- }
1222
- } else {
1235
+ );
1236
+ }
1237
+ } else {
1223
1238
  if (metaData) {
1224
1239
  console.info(' Summary: Cached snapshot available; no update running.');
1225
1240
  } else {
@@ -1227,6 +1242,35 @@ export async function status({ fix = false, cacheOnly = false, workspaceDir = nu
1227
1242
  }
1228
1243
  }
1229
1244
 
1245
+ const binaryTelemetry = await readBinaryTelemetry(cacheDir);
1246
+ if (binaryTelemetry?.totals && hasNonZeroBinaryTelemetry(binaryTelemetry.totals)) {
1247
+ const totals = binaryTelemetry.totals;
1248
+ console.info(
1249
+ ` Binary telemetry: swaps=${totals.atomicReplaceAttempts || 0} ok=${totals.atomicReplaceSuccesses || 0} fail=${totals.atomicReplaceFailures || 0}`
1250
+ );
1251
+ console.info(
1252
+ ` Binary telemetry: retries=${totals.renameRetryCount || 0} fallbackCopies=${totals.fallbackCopyCount || 0} rollbacks=${totals.rollbackCount || 0}`
1253
+ );
1254
+ if ((totals.rollbackRestoreFailureCount || 0) > 0) {
1255
+ console.info(
1256
+ ` Binary telemetry: rollback restore failures=${totals.rollbackRestoreFailureCount}`
1257
+ );
1258
+ }
1259
+ if ((totals.startupCleanupRuns || 0) > 0 || (totals.staleTempFilesRemoved || 0) > 0) {
1260
+ console.info(
1261
+ ` Startup temp cleanup: runs=${totals.startupCleanupRuns || 0} removed=${totals.staleTempFilesRemoved || 0} skippedActive=${totals.staleTempFilesSkippedActive || 0}`
1262
+ );
1263
+ }
1264
+ if (binaryTelemetry.lastAtomicReplace?.at) {
1265
+ console.info(
1266
+ ` Last atomic replace: ${formatDateTime(binaryTelemetry.lastAtomicReplace.at)}`
1267
+ );
1268
+ }
1269
+ if (binaryTelemetry.lastError?.message) {
1270
+ console.info(` Last binary error: ${binaryTelemetry.lastError.message}`);
1271
+ }
1272
+ }
1273
+
1230
1274
  if (metaData && isProgressIncomplete) {
1231
1275
  console.info(' Indexing state: Cached snapshot available; incremental update running.');
1232
1276
  } else if (metaData) {
@@ -265,22 +265,22 @@ function ideMatchesFilter(name, filter) {
265
265
 
266
266
  function getServerConfigForIde(name) {
267
267
  const normalizedName = normalizeIdeName(name);
268
+ const workspaceArgs = [
269
+ '--workspace',
270
+ '${workspaceFolder}',
271
+ '--workspace',
272
+ '${workspaceRoot}',
273
+ '--workspace',
274
+ '${workspace}',
275
+ ];
268
276
  const config = {
269
277
  command: 'heuristic-mcp',
270
- args: [],
278
+ // Prefer explicit workspace forwarding when supported by the host.
279
+ // Unexpanded placeholders are safely ignored by CLI workspace parsing.
280
+ args: workspaceArgs,
271
281
  };
272
282
 
273
283
  if (normalizedName === 'antigravity') {
274
- // Prefer explicit workspace forwarding in VS Code-compatible clients.
275
- // If the variable is not expanded by the IDE, CLI parsing safely ignores it.
276
- config.args = [
277
- '--workspace',
278
- '${workspaceFolder}',
279
- '--workspace',
280
- '${workspaceRoot}',
281
- '--workspace',
282
- '${workspace}',
283
- ];
284
284
  // Allow provider-specific workspace env discovery as a backup signal.
285
285
  config.env = {
286
286
  HEURISTIC_MCP_ENABLE_DYNAMIC_WORKSPACE_ENV: 'true',
@@ -4,6 +4,7 @@ import path from 'path';
4
4
  import fs from 'fs/promises';
5
5
  import { acquireWorkspaceLock, releaseWorkspaceLock } from '../lib/server-lifecycle.js';
6
6
  import { getWorkspaceCachePath } from '../lib/workspace-cache-key.js';
7
+ import { cleanupStaleBinaryArtifacts } from '../lib/vector-store-binary.js';
7
8
 
8
9
 
9
10
  function getWorkspaceCacheDir(workspacePath, globalCacheDir) {
@@ -185,13 +186,16 @@ export class SetWorkspaceFeature {
185
186
  await releaseWorkspaceLock({ cacheDirectory: previousCache });
186
187
  }
187
188
 
188
-
189
- if (this.cache && typeof this.cache.load === 'function') {
190
- try {
191
- await this.cache.load();
192
- } catch (err) {
193
- console.warn(`[SetWorkspace] Failed to load cache: ${err.message}`);
194
- }
189
+
190
+ if (this.cache && typeof this.cache.load === 'function') {
191
+ try {
192
+ if (this.config.vectorStoreFormat === 'binary') {
193
+ await cleanupStaleBinaryArtifacts(newCacheDir);
194
+ }
195
+ await this.cache.load();
196
+ } catch (err) {
197
+ console.warn(`[SetWorkspace] Failed to load cache: ${err.message}`);
198
+ }
195
199
  }
196
200
 
197
201
 
package/index.js CHANGED
@@ -49,9 +49,10 @@ import {
49
49
  stopOtherHeuristicServers,
50
50
  } from './lib/server-lifecycle.js';
51
51
 
52
- import { EmbeddingsCache } from './lib/cache.js';
53
- import { CodebaseIndexer } from './features/index-codebase.js';
54
- import { HybridSearch } from './features/hybrid-search.js';
52
+ import { EmbeddingsCache } from './lib/cache.js';
53
+ import { cleanupStaleBinaryArtifacts } from './lib/vector-store-binary.js';
54
+ import { CodebaseIndexer } from './features/index-codebase.js';
55
+ import { HybridSearch } from './features/hybrid-search.js';
55
56
 
56
57
  import * as IndexCodebaseFeature from './features/index-codebase.js';
57
58
  import * as HybridSearchFeature from './features/hybrid-search.js';
@@ -827,12 +828,21 @@ async function initialize(workspaceDir) {
827
828
  }
828
829
  };
829
830
 
830
-
831
-
832
-
833
-
834
- cache = new EmbeddingsCache(config);
835
- console.info(`[Server] Cache directory: ${config.cacheDirectory}`);
831
+
832
+
833
+
834
+
835
+ if (config.vectorStoreFormat === 'binary') {
836
+ try {
837
+ await cleanupStaleBinaryArtifacts(config.cacheDirectory, { logger: console });
838
+ } catch (err) {
839
+ console.warn(`[Cache] Startup temp cleanup failed: ${err.message}`);
840
+ }
841
+ }
842
+
843
+
844
+ cache = new EmbeddingsCache(config);
845
+ console.info(`[Server] Cache directory: ${config.cacheDirectory}`);
836
846
 
837
847
 
838
848
  indexer = new CodebaseIndexer(embedder, cache, config, server);
@@ -883,6 +893,9 @@ async function initialize(workspaceDir) {
883
893
  config.searchDirectory = candidate.workspace;
884
894
  config.cacheDirectory = candidate.cacheDirectory;
885
895
  await fs.mkdir(config.cacheDirectory, { recursive: true });
896
+ if (config.vectorStoreFormat === 'binary') {
897
+ await cleanupStaleBinaryArtifacts(config.cacheDirectory, { logger: console });
898
+ }
886
899
  await cache.load();
887
900
  console.info(
888
901
  `[Server] Auto-attached workspace cache (${reason}): ${candidate.workspace} via ${candidate.source}`
@@ -1162,19 +1175,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1162
1175
  for (const feature of features) {
1163
1176
  const toolDef = feature.module.getToolDefinition(config);
1164
1177
 
1165
- if (request.params.name === toolDef.name) {
1166
-
1167
-
1168
- if (typeof feature.handler !== 'function') {
1169
- return {
1178
+ if (request.params.name === toolDef.name) {
1179
+
1180
+
1181
+ if (typeof feature.handler !== 'function') {
1182
+ return {
1170
1183
  content: [{
1171
1184
  type: 'text',
1172
1185
  text: `Tool "${toolDef.name}" is not ready. Server may still be initializing.`,
1173
1186
  }],
1174
- isError: true,
1175
- };
1187
+ isError: true,
1188
+ };
1189
+ }
1190
+ let result;
1191
+ try {
1192
+ result = await feature.handler(request, feature.instance);
1193
+ } catch (error) {
1194
+ const message = error instanceof Error ? error.message : String(error);
1195
+ console.error(`[Server] Tool ${toolDef.name} failed: ${message}`);
1196
+ return {
1197
+ content: [
1198
+ {
1199
+ type: 'text',
1200
+ text: `Error: ${message || 'Unknown tool failure'}`,
1201
+ },
1202
+ ],
1203
+ isError: true,
1204
+ };
1176
1205
  }
1177
- const result = await feature.handler(request, feature.instance);
1178
1206
  if (toolDef.name === 'f_set_workspace' && !isToolResponseError(result)) {
1179
1207
  trustWorkspacePath(config.searchDirectory);
1180
1208
  }
package/lib/cache.js CHANGED
@@ -266,10 +266,12 @@ export class EmbeddingsCache {
266
266
  };
267
267
 
268
268
 
269
- this.saveQueue = Promise.resolve();
270
- this._saveTimer = null;
271
- this._saveRequested = false;
272
- this._savePromise = null;
269
+ this.saveQueue = Promise.resolve();
270
+ this._saveTimer = null;
271
+ this._saveRequested = false;
272
+ this._savePromise = null;
273
+ this._saveThrowOnError = false;
274
+ this.lastSaveError = null;
273
275
 
274
276
 
275
277
  this.annIndex = null;
@@ -771,31 +773,39 @@ export class EmbeddingsCache {
771
773
 
772
774
 
773
775
 
774
- save() {
775
- if (!this.config.enableCache) return Promise.resolve();
776
-
777
- this._saveRequested = true;
778
-
779
- if (this._saveTimer) return this._savePromise ?? Promise.resolve();
776
+ save({ throwOnError = false } = {}) {
777
+ if (!this.config.enableCache) return Promise.resolve();
778
+
779
+ this._saveRequested = true;
780
+ if (throwOnError) {
781
+ this._saveThrowOnError = true;
782
+ }
783
+
784
+ if (this._saveTimer) return this._savePromise ?? Promise.resolve();
780
785
 
781
786
  const debounceMs = Number.isInteger(this.config.saveDebounceMs)
782
787
  ? this.config.saveDebounceMs
783
788
  : 250;
784
789
 
785
- this._savePromise = new Promise((resolve, reject) => {
786
- this._saveTimer = setTimeout(() => {
787
- this._saveTimer = null;
788
-
789
- this.saveQueue = this.saveQueue
790
- .then(async () => {
791
- while (this._saveRequested) {
792
- this._saveRequested = false;
793
- await this.performSave();
794
- }
795
- })
796
- .then(resolve, reject)
797
- .finally(() => {
798
- this._savePromise = null;
790
+ this._savePromise = new Promise((resolve, reject) => {
791
+ this._saveTimer = setTimeout(() => {
792
+ this._saveTimer = null;
793
+ const rejectOnSaveError = this._saveThrowOnError;
794
+ this._saveThrowOnError = false;
795
+
796
+ this.saveQueue = this.saveQueue
797
+ .catch(() => {
798
+
799
+ })
800
+ .then(async () => {
801
+ while (this._saveRequested) {
802
+ this._saveRequested = false;
803
+ await this.performSave({ throwOnError: rejectOnSaveError });
804
+ }
805
+ })
806
+ .then(resolve, reject)
807
+ .finally(() => {
808
+ this._savePromise = null;
799
809
  });
800
810
  }, debounceMs);
801
811
  });
@@ -803,7 +813,7 @@ export class EmbeddingsCache {
803
813
  return this._savePromise;
804
814
  }
805
815
 
806
- async performSave() {
816
+ async performSave({ throwOnError = false } = {}) {
807
817
 
808
818
  this._saveInProgress = true;
809
819
  if (
@@ -988,9 +998,9 @@ export class EmbeddingsCache {
988
998
 
989
999
 
990
1000
 
991
- if (
992
- this.config.annIndexCache !== false &&
993
- this.annPersistDirty &&
1001
+ if (
1002
+ this.config.annIndexCache !== false &&
1003
+ this.annPersistDirty &&
994
1004
  !this.annDirty &&
995
1005
  !this._annWriting &&
996
1006
  this.annIndex &&
@@ -1009,10 +1019,12 @@ export class EmbeddingsCache {
1009
1019
  console.warn(`[ANN] Failed to persist ANN index: ${error.message}`);
1010
1020
  } finally {
1011
1021
  this._annWriting = false;
1012
- }
1013
- }
1014
- } catch (error) {
1015
- console.warn('[Cache] Failed to save cache:', error.message);
1022
+ }
1023
+ }
1024
+ this.lastSaveError = null;
1025
+ } catch (error) {
1026
+ this.lastSaveError = error instanceof Error ? error : new Error(String(error));
1027
+ console.warn('[Cache] Failed to save cache:', this.lastSaveError.message);
1016
1028
 
1017
1029
  if (
1018
1030
  this.config.vectorStoreFormat === 'binary' &&
@@ -1031,9 +1043,9 @@ export class EmbeddingsCache {
1031
1043
  }
1032
1044
  }
1033
1045
 
1034
- if (
1035
- this.config.vectorStoreFormat === 'sqlite' &&
1036
- !this.sqliteStore
1046
+ if (
1047
+ this.config.vectorStoreFormat === 'sqlite' &&
1048
+ !this.sqliteStore
1037
1049
  ) {
1038
1050
  try {
1039
1051
  console.info('[Cache] Attempting to recover SQLite store after failed save...');
@@ -1043,14 +1055,19 @@ export class EmbeddingsCache {
1043
1055
  }
1044
1056
  } catch (recoverErr) {
1045
1057
  console.warn(`[Cache] Failed to recover SQLite store: ${recoverErr.message}`);
1046
- this.sqliteStore = null;
1047
- }
1048
- }
1049
- } finally {
1050
- this.isSaving = false;
1051
- this._saveInProgress = false;
1052
- }
1053
- }
1058
+ this.sqliteStore = null;
1059
+ }
1060
+ }
1061
+ if (throwOnError) {
1062
+ const wrapped = new Error(`Cache save failed: ${this.lastSaveError.message}`);
1063
+ wrapped.cause = this.lastSaveError;
1064
+ throw wrapped;
1065
+ }
1066
+ } finally {
1067
+ this.isSaving = false;
1068
+ this._saveInProgress = false;
1069
+ }
1070
+ }
1054
1071
 
1055
1072
 
1056
1073
 
@@ -14,30 +14,344 @@ const MAGIC_VECTORS = 'HMCV';
14
14
  const MAGIC_RECORDS = 'HMCR';
15
15
  const MAGIC_CONTENT = 'HMCC';
16
16
 
17
- const VECTORS_FILE = 'vectors.bin';
18
- const RECORDS_FILE = 'records.bin';
19
- const CONTENT_FILE = 'content.bin';
20
- const FILES_FILE = 'files.json';
21
- const RETRYABLE_RENAME_ERRORS = new Set(['EPERM', 'EACCES', 'EBUSY']);
22
-
23
- async function renameWithRetry(source, target, { retries = 5, delayMs = 50 } = {}) {
24
- let attempt = 0;
25
- let delay = delayMs;
26
- while (true) {
27
- try {
28
- await fs.rename(source, target);
29
- return;
30
- } catch (err) {
31
- const code = err?.code;
32
- if (!RETRYABLE_RENAME_ERRORS.has(code) || attempt >= retries) {
33
- throw err;
34
- }
35
- await new Promise((resolve) => setTimeout(resolve, delay));
36
- attempt += 1;
37
- delay *= 2;
38
- }
39
- }
40
- }
17
+ const VECTORS_FILE = 'vectors.bin';
18
+ const RECORDS_FILE = 'records.bin';
19
+ const CONTENT_FILE = 'content.bin';
20
+ const FILES_FILE = 'files.json';
21
+ const TELEMETRY_FILE = 'binary-store-telemetry.json';
22
+ const RETRYABLE_RENAME_ERRORS = new Set(['EPERM', 'EACCES', 'EBUSY']);
23
+ const BINARY_ARTIFACT_BASE_FILES = [VECTORS_FILE, RECORDS_FILE, CONTENT_FILE, FILES_FILE];
24
+ const STARTUP_TMP_CLEANUP_MIN_AGE_MS = 2 * 60 * 1000;
25
+ const TELEMETRY_VERSION = 1;
26
+
27
+ function createTelemetryTotals() {
28
+ return {
29
+ atomicReplaceAttempts: 0,
30
+ atomicReplaceSuccesses: 0,
31
+ atomicReplaceFailures: 0,
32
+ renameRetryCount: 0,
33
+ fallbackCopyCount: 0,
34
+ rollbackCount: 0,
35
+ rollbackRestoreFailureCount: 0,
36
+ startupCleanupRuns: 0,
37
+ staleTempFilesRemoved: 0,
38
+ staleTempFilesSkippedActive: 0,
39
+ };
40
+ }
41
+
42
+ function normalizeTelemetry(raw) {
43
+ const totals = createTelemetryTotals();
44
+ if (raw?.totals && typeof raw.totals === 'object') {
45
+ for (const key of Object.keys(totals)) {
46
+ if (Number.isFinite(raw.totals[key])) {
47
+ totals[key] = raw.totals[key];
48
+ }
49
+ }
50
+ }
51
+ return {
52
+ version: TELEMETRY_VERSION,
53
+ totals,
54
+ updatedAt: typeof raw?.updatedAt === 'string' ? raw.updatedAt : null,
55
+ lastError:
56
+ raw?.lastError && typeof raw.lastError === 'object'
57
+ ? {
58
+ at: typeof raw.lastError.at === 'string' ? raw.lastError.at : null,
59
+ message:
60
+ typeof raw.lastError.message === 'string' ? raw.lastError.message : null,
61
+ }
62
+ : null,
63
+ lastAtomicReplace:
64
+ raw?.lastAtomicReplace && typeof raw.lastAtomicReplace === 'object'
65
+ ? { ...raw.lastAtomicReplace }
66
+ : null,
67
+ lastStartupCleanup:
68
+ raw?.lastStartupCleanup && typeof raw.lastStartupCleanup === 'object'
69
+ ? { ...raw.lastStartupCleanup }
70
+ : null,
71
+ };
72
+ }
73
+
74
+ async function readTelemetryFile(cacheDir) {
75
+ const telemetryPath = path.join(cacheDir, TELEMETRY_FILE);
76
+ try {
77
+ const raw = await fs.readFile(telemetryPath, 'utf-8');
78
+ return normalizeTelemetry(JSON.parse(raw));
79
+ } catch {
80
+ return normalizeTelemetry(null);
81
+ }
82
+ }
83
+
84
+ async function writeTelemetryFile(cacheDir, telemetry) {
85
+ const telemetryPath = path.join(cacheDir, TELEMETRY_FILE);
86
+ await fs.mkdir(cacheDir, { recursive: true }).catch(() => {});
87
+ await fs.writeFile(telemetryPath, JSON.stringify(telemetry, null, 2));
88
+ }
89
+
90
+ async function updateTelemetry(cacheDir, mutate) {
91
+ if (!cacheDir) return;
92
+ try {
93
+ const telemetry = await readTelemetryFile(cacheDir);
94
+ mutate(telemetry);
95
+ telemetry.updatedAt = new Date().toISOString();
96
+ await writeTelemetryFile(cacheDir, telemetry);
97
+ } catch {
98
+
99
+ }
100
+ }
101
+
102
+ function isProcessRunning(pid) {
103
+ if (!Number.isInteger(pid) || pid <= 0) return false;
104
+ try {
105
+ process.kill(pid, 0);
106
+ return true;
107
+ } catch (err) {
108
+ return err?.code === 'EPERM';
109
+ }
110
+ }
111
+
112
+ function parsePidFromBinaryArtifact(fileName) {
113
+ const match = fileName.match(/\.(?:tmp|bak)-(\d+)(?:-|$)/);
114
+ if (!match) return null;
115
+ const pid = Number.parseInt(match[1], 10);
116
+ return Number.isInteger(pid) ? pid : null;
117
+ }
118
+
119
+ function isBinaryTempArtifact(fileName) {
120
+ return BINARY_ARTIFACT_BASE_FILES.some(
121
+ (baseFile) =>
122
+ fileName.startsWith(`${baseFile}.tmp-`) || fileName.startsWith(`${baseFile}.bak-`)
123
+ );
124
+ }
125
+
126
+ function addToMetric(metrics, key, value = 1) {
127
+ if (!metrics || !Number.isFinite(value) || value <= 0) return;
128
+ metrics[key] = (metrics[key] || 0) + value;
129
+ }
130
+
131
+ export async function readBinaryStoreTelemetry(cacheDir) {
132
+ return readTelemetryFile(cacheDir);
133
+ }
134
+
135
+ export async function cleanupStaleBinaryArtifacts(
136
+ cacheDir,
137
+ { minAgeMs = STARTUP_TMP_CLEANUP_MIN_AGE_MS, logger = null } = {}
138
+ ) {
139
+ const result = {
140
+ cacheDir,
141
+ scanned: 0,
142
+ removed: 0,
143
+ skippedActive: 0,
144
+ removedFiles: [],
145
+ };
146
+
147
+ let entries = [];
148
+ try {
149
+ entries = await fs.readdir(cacheDir, { withFileTypes: true });
150
+ } catch {
151
+ return result;
152
+ }
153
+
154
+ const now = Date.now();
155
+ for (const entry of entries) {
156
+ const fileName = typeof entry === 'string' ? entry : entry?.name;
157
+ if (!fileName) continue;
158
+ const isFileEntry = typeof entry === 'string' ? true : entry?.isFile?.() === true;
159
+ if (!isFileEntry) continue;
160
+ if (!isBinaryTempArtifact(fileName)) continue;
161
+ result.scanned += 1;
162
+
163
+ const fullPath = path.join(cacheDir, fileName);
164
+ const stats = await fs.stat(fullPath).catch(() => null);
165
+ if (!stats) continue;
166
+
167
+ const ageMs = now - stats.mtimeMs;
168
+ const ownerPid = parsePidFromBinaryArtifact(fileName);
169
+ if (ownerPid && isProcessRunning(ownerPid)) {
170
+ result.skippedActive += 1;
171
+ continue;
172
+ }
173
+ if (ageMs < minAgeMs) continue;
174
+
175
+ await fs.rm(fullPath, { force: true }).catch(() => {});
176
+ result.removed += 1;
177
+ result.removedFiles.push(fileName);
178
+ }
179
+
180
+ await updateTelemetry(cacheDir, (telemetry) => {
181
+ telemetry.totals.startupCleanupRuns += 1;
182
+ telemetry.totals.staleTempFilesRemoved += result.removed;
183
+ telemetry.totals.staleTempFilesSkippedActive += result.skippedActive;
184
+ telemetry.lastStartupCleanup = {
185
+ at: new Date().toISOString(),
186
+ scanned: result.scanned,
187
+ removed: result.removed,
188
+ skippedActive: result.skippedActive,
189
+ };
190
+ });
191
+
192
+ if (logger && result.removed > 0) {
193
+ logger.info(
194
+ `[Cache] Startup temp cleanup removed ${result.removed} stale artifact(s) from ${cacheDir}`
195
+ );
196
+ }
197
+
198
+ return result;
199
+ }
200
+
201
+ function isRetryableRenameError(err) {
202
+ return RETRYABLE_RENAME_ERRORS.has(err?.code);
203
+ }
204
+
205
+ async function renameWithRetry(
206
+ source,
207
+ target,
208
+ { retries = 12, delayMs = 50, maxDelayMs = 1000 } = {}
209
+ ) {
210
+ let attempt = 0;
211
+ let delay = delayMs;
212
+ while (true) {
213
+ try {
214
+ await fs.rename(source, target);
215
+ return attempt;
216
+ } catch (err) {
217
+ if (!isRetryableRenameError(err) || attempt >= retries) {
218
+ err.renameRetryCount = attempt;
219
+ throw err;
220
+ }
221
+ await new Promise((resolve) => setTimeout(resolve, delay));
222
+ attempt += 1;
223
+ delay = Math.min(delay * 2, maxDelayMs);
224
+ }
225
+ }
226
+ }
227
+
228
+ async function pathExists(filePath) {
229
+ try {
230
+ await fs.access(filePath);
231
+ return true;
232
+ } catch {
233
+ return false;
234
+ }
235
+ }
236
+
237
+ async function removeIfExists(filePath) {
238
+ await fs.rm(filePath, { force: true }).catch(() => {});
239
+ }
240
+
241
+ async function promoteFileWithFallback(source, target, renameOptions = {}, metrics = null) {
242
+ try {
243
+ const retriesUsed = await renameWithRetry(source, target, renameOptions);
244
+ addToMetric(metrics, 'renameRetryCount', retriesUsed);
245
+ return;
246
+ } catch (renameError) {
247
+ const retriesUsed = Number.isFinite(renameError?.renameRetryCount)
248
+ ? renameError.renameRetryCount
249
+ : 0;
250
+ addToMetric(metrics, 'renameRetryCount', retriesUsed);
251
+ if (!isRetryableRenameError(renameError)) {
252
+ throw renameError;
253
+ }
254
+
255
+ try {
256
+ await fs.copyFile(source, target);
257
+ await removeIfExists(source);
258
+ addToMetric(metrics, 'fallbackCopyCount', 1);
259
+ return;
260
+ } catch (copyError) {
261
+ const wrapped = new Error(
262
+ `rename failed (${renameError.message}); fallback copy failed (${copyError.message})`
263
+ );
264
+ wrapped.code = copyError?.code || renameError?.code;
265
+ throw wrapped;
266
+ }
267
+ }
268
+ }
269
+
270
+ async function replaceFilesAtomically(filePairs, renameOptions = {}) {
271
+ const metrics = createTelemetryTotals();
272
+ metrics.atomicReplaceAttempts = 1;
273
+ const cacheDir = filePairs.length > 0 ? path.dirname(filePairs[0].target) : null;
274
+ const backupSuffix = `.bak-${process.pid}-${Date.now()}`;
275
+ const backups = [];
276
+ const replacedTargets = [];
277
+ let operationError = null;
278
+
279
+ try {
280
+ // Stage current files as backups first. If this fails, nothing is replaced.
281
+ for (const pair of filePairs) {
282
+ if (!(await pathExists(pair.target))) continue;
283
+ const backupPath = `${pair.target}${backupSuffix}`;
284
+ await removeIfExists(backupPath);
285
+ await promoteFileWithFallback(pair.target, backupPath, renameOptions, metrics);
286
+ backups.push({ target: pair.target, backupPath });
287
+ }
288
+
289
+ // Replace targets with new temp files.
290
+ for (const pair of filePairs) {
291
+ await promoteFileWithFallback(pair.source, pair.target, renameOptions, metrics);
292
+ replacedTargets.push(pair.target);
293
+ }
294
+ metrics.atomicReplaceSuccesses = 1;
295
+ } catch (error) {
296
+ operationError = error;
297
+ metrics.atomicReplaceFailures = 1;
298
+ metrics.rollbackCount = 1;
299
+ const rollbackErrors = [];
300
+
301
+ // Remove any partially replaced files before restoring backups.
302
+ for (const target of replacedTargets.reverse()) {
303
+ await removeIfExists(target);
304
+ }
305
+
306
+ // Restore original files from backups.
307
+ for (const backup of backups.reverse()) {
308
+ try {
309
+ await promoteFileWithFallback(backup.backupPath, backup.target, renameOptions, metrics);
310
+ } catch (restoreErr) {
311
+ rollbackErrors.push(
312
+ `restore ${path.basename(backup.target)} failed: ${restoreErr.message}`
313
+ );
314
+ }
315
+ }
316
+ if (rollbackErrors.length > 0) {
317
+ metrics.rollbackRestoreFailureCount = rollbackErrors.length;
318
+ }
319
+
320
+ // Clean up temp files left from this failed write attempt.
321
+ await Promise.all(filePairs.map((pair) => removeIfExists(pair.source)));
322
+
323
+ if (rollbackErrors.length > 0) {
324
+ error.message = `${error.message}. Rollback issues: ${rollbackErrors.join('; ')}`;
325
+ }
326
+ throw error;
327
+ } finally {
328
+ // Best-effort cleanup for any backup remnants after success/rollback.
329
+ await Promise.all(backups.map((backup) => removeIfExists(backup.backupPath)));
330
+ await updateTelemetry(cacheDir, (telemetry) => {
331
+ telemetry.totals.atomicReplaceAttempts += metrics.atomicReplaceAttempts;
332
+ telemetry.totals.atomicReplaceSuccesses += metrics.atomicReplaceSuccesses;
333
+ telemetry.totals.atomicReplaceFailures += metrics.atomicReplaceFailures;
334
+ telemetry.totals.renameRetryCount += metrics.renameRetryCount;
335
+ telemetry.totals.fallbackCopyCount += metrics.fallbackCopyCount;
336
+ telemetry.totals.rollbackCount += metrics.rollbackCount;
337
+ telemetry.totals.rollbackRestoreFailureCount += metrics.rollbackRestoreFailureCount;
338
+ telemetry.lastAtomicReplace = {
339
+ at: new Date().toISOString(),
340
+ success: metrics.atomicReplaceSuccesses > 0,
341
+ renameRetryCount: metrics.renameRetryCount,
342
+ fallbackCopyCount: metrics.fallbackCopyCount,
343
+ rollbackCount: metrics.rollbackCount,
344
+ rollbackRestoreFailureCount: metrics.rollbackRestoreFailureCount,
345
+ };
346
+ if (operationError) {
347
+ telemetry.lastError = {
348
+ at: new Date().toISOString(),
349
+ message: operationError.message,
350
+ };
351
+ }
352
+ });
353
+ }
354
+ }
41
355
 
42
356
  function writeMagic(buffer, magic) {
43
357
  buffer.write(magic, 0, 'ascii');
@@ -433,18 +747,19 @@ export class BinaryVectorStore {
433
747
  return map;
434
748
  }
435
749
 
436
- static async write(
437
- cacheDir,
438
- chunks,
439
- {
750
+ static async write(
751
+ cacheDir,
752
+ chunks,
753
+ {
440
754
  contentCacheEntries,
441
755
  vectorCacheEntries,
442
- vectorLoadMode,
443
- getContent,
444
- getVector,
445
- preRename,
446
- } = {}
447
- ) {
756
+ vectorLoadMode,
757
+ getContent,
758
+ getVector,
759
+ preRename,
760
+ renameOptions,
761
+ } = {}
762
+ ) {
448
763
  ensureLittleEndian();
449
764
  const { vectorsPath, recordsPath, contentPath, filesPath } =
450
765
  BinaryVectorStore.getPaths(cacheDir);
@@ -611,15 +926,18 @@ export class BinaryVectorStore {
611
926
  await preRename();
612
927
  }
613
928
 
614
- await Promise.all([
615
- renameWithRetry(vectorsTmp, vectorsPath),
616
- renameWithRetry(recordsTmp, recordsPath),
617
- renameWithRetry(contentTmp, contentPath),
618
- renameWithRetry(filesTmp, filesPath),
619
- ]);
620
-
621
- return BinaryVectorStore.load(cacheDir, {
622
- contentCacheEntries,
929
+ await replaceFilesAtomically(
930
+ [
931
+ { source: vectorsTmp, target: vectorsPath },
932
+ { source: recordsTmp, target: recordsPath },
933
+ { source: contentTmp, target: contentPath },
934
+ { source: filesTmp, target: filesPath },
935
+ ],
936
+ renameOptions
937
+ );
938
+
939
+ return BinaryVectorStore.load(cacheDir, {
940
+ contentCacheEntries,
623
941
  vectorCacheEntries,
624
942
  vectorLoadMode,
625
943
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softerist/heuristic-mcp",
3
- "version": "3.2.1",
3
+ "version": "3.2.2",
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",