@softerist/heuristic-mcp 3.0.17 → 3.2.0

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,17 +1,15 @@
1
- import fs from 'fs/promises';
2
- import path from 'path';
3
- import os from 'os';
4
- import { getGlobalCacheDir } from './config.js';
5
-
6
- /**
7
- * Check if a process is running
8
- */
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { getGlobalCacheDir, isNonProjectDirectory } from './config.js';
5
+
6
+
9
7
  function isProcessRunning(pid) {
10
8
  try {
11
9
  process.kill(pid, 0);
12
10
  return true;
13
11
  } catch (err) {
14
- // On Windows, EPERM can happen even if process exists
12
+
15
13
  if (err && err.code === 'EPERM') {
16
14
  return true;
17
15
  }
@@ -19,42 +17,35 @@ function isProcessRunning(pid) {
19
17
  }
20
18
  }
21
19
 
22
- /**
23
- * Normalize workspace path for comparison
24
- * - Resolves symlinks
25
- * - Lowercase on Windows
26
- * - Normalizes path separators
27
- */
20
+
28
21
  async function normalizeWorkspacePath(workspacePath) {
29
22
  if (!workspacePath) return null;
30
23
 
31
24
  try {
32
25
  let normalized = workspacePath;
33
26
 
34
- // Resolve symlinks if path exists
27
+
35
28
  try {
36
29
  normalized = await fs.realpath(normalized);
37
30
  } catch {
38
- // Path doesn't exist, use as-is
31
+
39
32
  }
40
33
 
41
- // Lowercase on Windows for case-insensitive comparison
34
+
42
35
  if (process.platform === 'win32') {
43
36
  normalized = normalized.toLowerCase();
44
37
  }
45
38
 
46
- // Normalize path separators
39
+
47
40
  normalized = path.normalize(normalized);
48
41
 
49
42
  return normalized;
50
43
  } catch {
51
- return workspacePath; // Return original if normalization fails
44
+ return workspacePath;
52
45
  }
53
46
  }
54
47
 
55
- /**
56
- * Check if workspace path indicates a temporary/test workspace
57
- */
48
+
58
49
  function isTemporaryWorkspace(workspacePath) {
59
50
  if (!workspacePath) return false;
60
51
 
@@ -65,7 +56,7 @@ function isTemporaryWorkspace(workspacePath) {
65
56
  return true;
66
57
  }
67
58
 
68
- // Check if under OS temp directory
59
+
69
60
  try {
70
61
  const tempDir = os.tmpdir().toLowerCase();
71
62
  return normalized.startsWith(tempDir);
@@ -74,9 +65,7 @@ function isTemporaryWorkspace(workspacePath) {
74
65
  }
75
66
  }
76
67
 
77
- /**
78
- * Get timestamp from progress data
79
- */
68
+
80
69
  function getProgressTimestamp(cacheInfo) {
81
70
  if (cacheInfo.progress?.updatedAt) {
82
71
  const timestamp = Date.parse(cacheInfo.progress.updatedAt);
@@ -85,7 +74,7 @@ function getProgressTimestamp(cacheInfo) {
85
74
  }
86
75
  }
87
76
 
88
- // Fallback to file mtime
77
+
89
78
  if (cacheInfo.stats.progressFile) {
90
79
  return cacheInfo.stats.progressFile.mtimeMs;
91
80
  }
@@ -93,9 +82,7 @@ function getProgressTimestamp(cacheInfo) {
93
82
  return 0;
94
83
  }
95
84
 
96
- /**
97
- * Check if progress was updated recently
98
- */
85
+
99
86
  function hasRecentProgress(cacheInfo, thresholdMs = 5 * 60 * 1000) {
100
87
  if (!cacheInfo.progress) return false;
101
88
 
@@ -103,9 +90,7 @@ function hasRecentProgress(cacheInfo, thresholdMs = 5 * 60 * 1000) {
103
90
  return Date.now() - progressTime < thresholdMs;
104
91
  }
105
92
 
106
- /**
107
- * Safe file stat - returns null on error
108
- */
93
+
109
94
  async function safeStat(filePath) {
110
95
  try {
111
96
  return await fs.stat(filePath);
@@ -114,9 +99,7 @@ async function safeStat(filePath) {
114
99
  }
115
100
  }
116
101
 
117
- /**
118
- * Safe JSON parse - returns null on error
119
- */
102
+
120
103
  async function safeReadJson(filePath) {
121
104
  try {
122
105
  const content = await fs.readFile(filePath, 'utf-8');
@@ -126,14 +109,12 @@ async function safeReadJson(filePath) {
126
109
  }
127
110
  }
128
111
 
129
- /**
130
- * Collect comprehensive cache information
131
- */
112
+
132
113
  async function collectCacheInfo(cacheDir) {
133
114
  const cacheId = path.basename(cacheDir);
134
115
  const errors = [];
135
116
 
136
- // File paths
117
+
137
118
  const metaPath = path.join(cacheDir, 'meta.json');
138
119
  const progressPath = path.join(cacheDir, 'progress.json');
139
120
  const lockPath = path.join(cacheDir, 'server.lock.json');
@@ -142,14 +123,14 @@ async function collectCacheInfo(cacheDir) {
142
123
  const vectorsSqlitePath = path.join(cacheDir, 'vectors.sqlite');
143
124
  const annIndexPath = path.join(cacheDir, 'ann-index.bin');
144
125
 
145
- // Read metadata
126
+
146
127
  const [meta, progress, lock] = await Promise.all([
147
128
  safeReadJson(metaPath),
148
129
  safeReadJson(progressPath),
149
130
  safeReadJson(lockPath),
150
131
  ]);
151
132
 
152
- // Gather file stats
133
+
153
134
  const [
154
135
  cacheDirStat,
155
136
  metaFileStat,
@@ -170,13 +151,13 @@ async function collectCacheInfo(cacheDir) {
170
151
  safeStat(annIndexPath),
171
152
  ]);
172
153
 
173
- // Determine embeddings file (prefer binary/sqlite over JSON)
154
+
174
155
  const embeddingsFileStat = vectorsBinStat || vectorsSqliteStat || embeddingsJsonStat;
175
156
 
176
- // Extract workspace path
157
+
177
158
  const workspacePath = meta?.workspace || null;
178
159
 
179
- // Check workspace existence
160
+
180
161
  let workspaceExists = false;
181
162
  if (workspacePath) {
182
163
  try {
@@ -187,10 +168,10 @@ async function collectCacheInfo(cacheDir) {
187
168
  }
188
169
  }
189
170
 
190
- // Normalize workspace path
171
+
191
172
  const workspacePathNormalized = await normalizeWorkspacePath(workspacePath);
192
173
 
193
- // Calculate lastActivityMs from all known timestamps
174
+
194
175
  const timestamps = [
195
176
  meta?.lastSaveTime ? Date.parse(meta.lastSaveTime) : null,
196
177
  getProgressTimestamp({ progress, stats: { progressFile: progressFileStat } }),
@@ -204,10 +185,10 @@ async function collectCacheInfo(cacheDir) {
204
185
  ? Math.max(...timestamps)
205
186
  : cacheDirStat?.mtimeMs || 0;
206
187
 
207
- // Determine if cache is active
188
+
208
189
  let isActive = false;
209
190
 
210
- // Check lock file with valid PID
191
+
211
192
  if (lock && Number.isInteger(lock.pid)) {
212
193
  isActive = isProcessRunning(lock.pid);
213
194
  }
@@ -235,28 +216,49 @@ async function collectCacheInfo(cacheDir) {
235
216
  };
236
217
  }
237
218
 
238
- /**
239
- * Evaluate a single cache using the safe decision pipeline
240
- */
241
- function evaluateCache(cacheInfo, thresholds) {
242
- const now = Date.now();
243
- const age = now - cacheInfo.lastActivityMs;
244
-
245
- // Step 1: Keep if active or within safety window
246
- if (cacheInfo.isActive || age < thresholds.safetyWindowMs) {
247
- return {
248
- action: 'KEEP',
249
- reason: cacheInfo.isActive ? 'active_lock' : 'recent_activity',
250
- details: {
251
- isActive: cacheInfo.isActive,
252
- lockPid: cacheInfo.lock?.pid,
253
- ageMs: age,
254
- },
255
- };
256
- }
257
219
 
258
- // Step 2: No meta.json
259
- if (!cacheInfo.meta) {
220
+ function evaluateCache(cacheInfo, thresholds) {
221
+ const now = Date.now();
222
+ const age = now - cacheInfo.lastActivityMs;
223
+
224
+
225
+ if (cacheInfo.isActive) {
226
+ return {
227
+ action: 'KEEP',
228
+ reason: 'active_lock',
229
+ details: {
230
+ isActive: true,
231
+ lockPid: cacheInfo.lock?.pid,
232
+ ageMs: age,
233
+ },
234
+ };
235
+ }
236
+
237
+
238
+ if (cacheInfo.workspacePath && isNonProjectDirectory(cacheInfo.workspacePath)) {
239
+ return {
240
+ action: 'REMOVE',
241
+ reason: 'system_workspace_cache',
242
+ details: {
243
+ workspace: cacheInfo.workspacePath,
244
+ ageMs: age,
245
+ },
246
+ };
247
+ }
248
+
249
+
250
+ if (age < thresholds.safetyWindowMs) {
251
+ return {
252
+ action: 'KEEP',
253
+ reason: 'recent_activity',
254
+ details: {
255
+ ageMs: age,
256
+ },
257
+ };
258
+ }
259
+
260
+
261
+ if (!cacheInfo.meta) {
260
262
  const dirAge = now - (cacheInfo.stats.cacheDir?.mtimeMs || 0);
261
263
  if (dirAge > thresholds.staleNoMetaMs && !hasRecentProgress(cacheInfo)) {
262
264
  return {
@@ -272,8 +274,8 @@ function evaluateCache(cacheInfo, thresholds) {
272
274
  };
273
275
  }
274
276
 
275
- // Step 3: Temporary workspace (check BEFORE missing workspace - shorter threshold!)
276
- // This ensures temp workspaces use 24h threshold instead of 7-day grace period
277
+
278
+
277
279
  if (isTemporaryWorkspace(cacheInfo.workspacePath)) {
278
280
  if (age > thresholds.tempThresholdMs) {
279
281
  return {
@@ -285,7 +287,7 @@ function evaluateCache(cacheInfo, thresholds) {
285
287
  },
286
288
  };
287
289
  }
288
- // Recent temp workspace - keep for now
290
+
289
291
  return {
290
292
  action: 'KEEP',
291
293
  reason: 'recent_temp_workspace',
@@ -296,7 +298,7 @@ function evaluateCache(cacheInfo, thresholds) {
296
298
  };
297
299
  }
298
300
 
299
- // Step 4: Empty cache
301
+
300
302
  const filesIndexed = cacheInfo.meta.filesIndexed ?? 0;
301
303
  const chunksStored = cacheInfo.meta.chunksStored ?? 0;
302
304
 
@@ -323,7 +325,7 @@ function evaluateCache(cacheInfo, thresholds) {
323
325
  };
324
326
  }
325
327
 
326
- // Step 5: Missing workspace (non-temp workspaces only, due to Step 3 early return)
328
+
327
329
  if (cacheInfo.workspacePath && !cacheInfo.workspaceExists) {
328
330
  if (age > thresholds.workspaceGraceMs) {
329
331
  return {
@@ -345,7 +347,7 @@ function evaluateCache(cacheInfo, thresholds) {
345
347
  };
346
348
  }
347
349
 
348
- // Step 6: Stuck indexing
350
+
349
351
  if (cacheInfo.progress && !hasRecentProgress(cacheInfo, thresholds.safetyWindowMs)) {
350
352
  const progressAge = now - getProgressTimestamp(cacheInfo);
351
353
  if (progressAge > thresholds.staleProgressMs) {
@@ -360,7 +362,7 @@ function evaluateCache(cacheInfo, thresholds) {
360
362
  }
361
363
  }
362
364
 
363
- // Step 7: Long unused
365
+
364
366
  if (age > thresholds.maxUnusedMs) {
365
367
  return {
366
368
  action: 'REMOVE',
@@ -369,7 +371,7 @@ function evaluateCache(cacheInfo, thresholds) {
369
371
  };
370
372
  }
371
373
 
372
- // Step 8: Default - keep
374
+
373
375
  return {
374
376
  action: 'KEEP',
375
377
  reason: 'valid_cache',
@@ -381,16 +383,14 @@ function evaluateCache(cacheInfo, thresholds) {
381
383
  };
382
384
  }
383
385
 
384
- /**
385
- * Find duplicate workspace caches
386
- */
386
+
387
387
  function findDuplicateWorkspaces(cacheInfos) {
388
- const workspaceMap = new Map(); // normalized workspace -> [cacheInfos]
388
+ const workspaceMap = new Map();
389
389
 
390
390
  for (const info of cacheInfos) {
391
391
  if (!info.workspacePathNormalized) continue;
392
392
 
393
- // Key includes embedding model + dimension to avoid deleting caches for different embeddings
393
+
394
394
  const dimLabel = info.meta?.embeddingDimension ?? 'default';
395
395
  const key = `${info.workspacePathNormalized}::${info.meta?.embeddingModel || 'default'}::${dimLabel}`;
396
396
 
@@ -403,10 +403,10 @@ function findDuplicateWorkspaces(cacheInfos) {
403
403
  const duplicates = [];
404
404
  for (const [key, infos] of workspaceMap) {
405
405
  if (infos.length > 1) {
406
- // Sort by lastActivityMs descending
406
+
407
407
  infos.sort((a, b) => b.lastActivityMs - a.lastActivityMs);
408
408
 
409
- // Keep newest, mark others for removal (if not active)
409
+
410
410
  for (let i = 1; i < infos.length; i++) {
411
411
  if (!infos[i].isActive) {
412
412
  duplicates.push({
@@ -427,9 +427,7 @@ function findDuplicateWorkspaces(cacheInfos) {
427
427
  return duplicates;
428
428
  }
429
429
 
430
- /**
431
- * Main cache cleanup function with intelligent sanitization
432
- */
430
+
433
431
  export async function clearStaleCaches(options = {}) {
434
432
  const config = {
435
433
  staleNoMetaHours: 6,
@@ -445,7 +443,7 @@ export async function clearStaleCaches(options = {}) {
445
443
  ...options,
446
444
  };
447
445
 
448
- // Convert to milliseconds
446
+
449
447
  const thresholds = {
450
448
  staleNoMetaMs: config.staleNoMetaHours * 60 * 60 * 1000,
451
449
  emptyThresholdMs: config.emptyThresholdHours * 60 * 60 * 1000,
@@ -463,12 +461,12 @@ export async function clearStaleCaches(options = {}) {
463
461
  return { removed: 0, kept: 0, dryRun: config.dryRun, decisions: [] };
464
462
  }
465
463
 
466
- // Step 1: Collect all cache info
464
+
467
465
  const cacheInfos = await Promise.all(
468
466
  cacheDirs.map((dir) => collectCacheInfo(path.join(globalCacheRoot, dir)))
469
467
  );
470
468
 
471
- // Step 2: Evaluate each cache individually
469
+
472
470
  const decisions = cacheInfos.map((info) => {
473
471
  const evaluation = evaluateCache(info, thresholds);
474
472
  return {
@@ -479,11 +477,11 @@ export async function clearStaleCaches(options = {}) {
479
477
  };
480
478
  });
481
479
 
482
- // Step 3: Find duplicates
480
+
483
481
  if (config.removeDuplicates) {
484
482
  const duplicates = findDuplicateWorkspaces(cacheInfos);
485
483
  for (const dup of duplicates) {
486
- // Override decision if not already marked for removal
484
+
487
485
  const existing = decisions.find((d) => d.cacheId === dup.info.cacheId);
488
486
  if (existing && existing.action === 'KEEP') {
489
487
  existing.action = dup.action;
@@ -493,7 +491,7 @@ export async function clearStaleCaches(options = {}) {
493
491
  }
494
492
  }
495
493
 
496
- // Step 4: Execute removals
494
+
497
495
  let removed = 0;
498
496
  let kept = 0;
499
497
 
@@ -514,7 +512,7 @@ export async function clearStaleCaches(options = {}) {
514
512
  `[Cache] Failed to remove ${decision.cacheId}: ${err.message}`
515
513
  );
516
514
  }
517
- // Count as kept if removal failed
515
+
518
516
  kept++;
519
517
  decision.action = 'KEEP';
520
518
  decision.reason = 'removal_failed';
@@ -547,9 +545,7 @@ export async function clearStaleCaches(options = {}) {
547
545
  };
548
546
  }
549
547
 
550
- /**
551
- * Format age in human-readable form
552
- */
548
+
553
549
  function formatAge(ms) {
554
550
  if (!Number.isFinite(ms)) return 'unknown';
555
551