minovative-mind-cli 2.9.0 → 2.10.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.
@@ -3,12 +3,144 @@ import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
4
  import { readCache, writeCache } from '../../utils/projectStorage.js';
5
5
  import { debugLog } from '../../utils/logger.js';
6
+ import { levenshteinSimilarity } from '../../utils/fuzzyMatch.js';
6
7
  const CACHE_FILE = 'investigation_cache.json';
7
- const MAX_CACHE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
8
+ export const MAX_CACHE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB cap
9
+ export const DEFAULT_FUZZY_THRESHOLD = 0.85;
10
+ export const DEFAULT_SEMANTIC_THRESHOLD = 0.7;
11
+ const TOPIC_KEYWORDS = {
12
+ authentication: [
13
+ 'auth',
14
+ 'login',
15
+ 'logout',
16
+ 'signin',
17
+ 'signup',
18
+ 'token',
19
+ 'jwt',
20
+ 'session',
21
+ 'oauth',
22
+ 'password',
23
+ 'credential',
24
+ 'byok',
25
+ ],
26
+ billing: ['billing', 'stripe', 'subscription', 'payment', 'checkout', 'invoice', 'pricing', 'tier', 'credit'],
27
+ database: [
28
+ 'database',
29
+ 'db',
30
+ 'firestore',
31
+ 'sql',
32
+ 'postgres',
33
+ 'mongo',
34
+ 'prisma',
35
+ 'schema',
36
+ 'query',
37
+ 'migration',
38
+ 'table',
39
+ ],
40
+ routing: ['routing', 'router', 'route', 'endpoint', 'api', 'controller', 'middleware', 'url', 'param'],
41
+ 'state-management': ['state', 'store', 'slice', 'reducer', 'redux', 'zustand', 'context', 'observable'],
42
+ caching: ['cache', 'caching', 'lru', 'eviction', 'memoize', 'redis', 'ttl', 'store', 'fingerprint', 'memory bank'],
43
+ testing: ['test', 'testing', 'spec', 'unit', 'integration', 'mocha', 'jest', 'vitest', 'fuzz', 'mock', 'assert'],
44
+ 'ui-layout': [
45
+ 'ui',
46
+ 'layout',
47
+ 'css',
48
+ 'style',
49
+ 'tailwind',
50
+ 'component',
51
+ 'frontend',
52
+ 'modal',
53
+ 'navbar',
54
+ 'view',
55
+ 'animation',
56
+ 'button',
57
+ ],
58
+ orchestration: [
59
+ 'orchestrat',
60
+ 'subagent',
61
+ 'task',
62
+ 'graph',
63
+ 'agent',
64
+ 'workflow',
65
+ 'complexity',
66
+ 'trace',
67
+ 'parallel',
68
+ 'dispatch',
69
+ ],
70
+ security: [
71
+ 'security',
72
+ 'sanitiz',
73
+ 'guard',
74
+ 'protect',
75
+ 'permission',
76
+ 'vulnerability',
77
+ 'csrf',
78
+ 'xss',
79
+ 'injection',
80
+ 'audit',
81
+ ],
82
+ performance: [
83
+ 'performance',
84
+ 'optimize',
85
+ 'speed',
86
+ 'memory',
87
+ 'heap',
88
+ 'leak',
89
+ 'benchmark',
90
+ 'latency',
91
+ 'bottleneck',
92
+ 'cpu',
93
+ ],
94
+ 'cli-tooling': ['cli', 'command', 'terminal', 'flag', 'oclif', 'bin', 'prompt', 'interactive', 'console'],
95
+ };
96
+ const INTENT_KEYWORDS = {
97
+ bug_fix: [
98
+ 'fix',
99
+ 'bug',
100
+ 'issue',
101
+ 'error',
102
+ 'crash',
103
+ 'fail',
104
+ 'broken',
105
+ 'repair',
106
+ 'patch',
107
+ 'exception',
108
+ 'regression',
109
+ 'solve',
110
+ ],
111
+ feature_addition: ['add', 'create', 'new', 'implement', 'feature', 'support', 'build', 'extend', 'introduce'],
112
+ refactoring: ['refactor', 'clean', 'restructure', 'reorganize', 'optimize', 'simplify', 'extract', 'modernize'],
113
+ explanation: [
114
+ 'explain',
115
+ 'what',
116
+ 'how',
117
+ 'why',
118
+ 'describe',
119
+ 'understand',
120
+ 'investigate',
121
+ 'analyze',
122
+ 'overview',
123
+ 'summary',
124
+ ],
125
+ performance_optimization: ['speed up', 'optimize', 'leak', 'benchmark', 'reduce memory', 'faster', 'profiling'],
126
+ testing: ['test', 'unit test', 'coverage', 'spec', 'fuzz'],
127
+ };
128
+ /**
129
+ * Normalizes prompt by trimming, collapsing whitespace, and stripping common conversation prefixes.
130
+ */
8
131
  export function normalizePrompt(prompt) {
9
- let normalized = prompt.toLowerCase().trim();
132
+ let normalized = (prompt || '').toLowerCase().trim();
10
133
  normalized = normalized.replace(/\s+/g, ' ');
11
- const fillerWords = ['please ', 'can you ', 'could you ', 'help me ', 'i need '];
134
+ const fillerWords = [
135
+ 'please ',
136
+ 'can you ',
137
+ 'could you ',
138
+ 'help me ',
139
+ 'i need ',
140
+ 'i want to ',
141
+ 'how do i ',
142
+ 'tell me ',
143
+ ];
12
144
  for (const word of fillerWords) {
13
145
  if (normalized.startsWith(word)) {
14
146
  normalized = normalized.slice(word.length).trim();
@@ -16,16 +148,193 @@ export function normalizePrompt(prompt) {
16
148
  }
17
149
  return normalized;
18
150
  }
151
+ /**
152
+ * Computes SHA-256 hash of a normalized prompt string.
153
+ */
19
154
  export function hashPrompt(normalized) {
20
155
  return crypto.createHash('sha256').update(normalized).digest('hex');
21
156
  }
157
+ /**
158
+ * Generates a deterministic signature from topics, components, and intent.
159
+ */
160
+ export function generateSemanticSignature(topics, components, intent) {
161
+ const normTopics = [...new Set(topics.map((t) => t.toLowerCase().trim()))].sort().join(',');
162
+ const normComponents = [...new Set(components.map((c) => c.toLowerCase().trim()))].sort().join(',');
163
+ const normIntent = (intent || 'investigation').toLowerCase().trim();
164
+ return `topics:${normTopics}|components:${normComponents}|intent:${normIntent}`;
165
+ }
166
+ /**
167
+ * Offline heuristic semantic metadata extractor.
168
+ */
169
+ export function extractHeuristicSemanticMetadata(prompt, relevantFiles = []) {
170
+ const normPrompt = normalizePrompt(prompt);
171
+ const lowerPrompt = prompt.toLowerCase();
172
+ const detectedTopics = new Set();
173
+ const detectedComponents = new Set();
174
+ // 1. Topic detection via keyword patterns
175
+ for (const [topic, keywords] of Object.entries(TOPIC_KEYWORDS)) {
176
+ for (const kw of keywords) {
177
+ if (lowerPrompt.includes(kw)) {
178
+ detectedTopics.add(topic);
179
+ break;
180
+ }
181
+ }
182
+ }
183
+ // 2. Component extraction from prompt tokens and file paths
184
+ const fileExtensionRegex = /\b[\w\-./\\]+\.(?:ts|tsx|js|jsx|json|py|cpp|h|css|md|yaml|yml)\b/gi;
185
+ let match;
186
+ while ((match = fileExtensionRegex.exec(prompt)) !== null) {
187
+ const raw = match[0].trim();
188
+ const baseName = path.basename(raw).toLowerCase();
189
+ detectedComponents.add(baseName);
190
+ }
191
+ // Identifier extraction (camelCase, PascalCase, hyphenated identifiers)
192
+ const identifierRegex = /\b[a-zA-Z][a-zA-Z0-9_-]{3,30}\b/g;
193
+ const promptTokens = prompt.match(identifierRegex) || [];
194
+ for (const token of promptTokens) {
195
+ const lowerToken = token.toLowerCase();
196
+ if (/[A-Z]/.test(token) || token.includes('_') || token.includes('-')) {
197
+ if (token.length > 3 && !['const', 'function', 'import', 'export', 'return'].includes(lowerToken)) {
198
+ detectedComponents.add(lowerToken);
199
+ }
200
+ }
201
+ }
202
+ // Include relevantFiles base names if provided
203
+ for (const file of relevantFiles) {
204
+ const base = path.basename(file).toLowerCase();
205
+ detectedComponents.add(base);
206
+ const ext = path.extname(base);
207
+ if (ext) {
208
+ detectedComponents.add(base.slice(0, -ext.length));
209
+ }
210
+ }
211
+ // 3. Intent detection
212
+ let detectedIntent = 'investigation';
213
+ for (const [intentName, keywords] of Object.entries(INTENT_KEYWORDS)) {
214
+ for (const kw of keywords) {
215
+ if (lowerPrompt.includes(kw)) {
216
+ detectedIntent = intentName;
217
+ break;
218
+ }
219
+ }
220
+ if (detectedIntent !== 'investigation')
221
+ break;
222
+ }
223
+ if (detectedTopics.size === 0) {
224
+ detectedTopics.add('general');
225
+ }
226
+ return {
227
+ topics: Array.from(detectedTopics),
228
+ components: Array.from(detectedComponents),
229
+ intent: detectedIntent,
230
+ };
231
+ }
232
+ /**
233
+ * Classifies prompt semantic intent and entity tags using AI or offline heuristic fallback.
234
+ */
235
+ export async function classifySemanticIntent(userPrompt, abortSignal, customSession, timeoutMs = 60000) {
236
+ if (abortSignal?.aborted) {
237
+ const err = new Error('Operation aborted');
238
+ err.name = 'AbortError';
239
+ throw err;
240
+ }
241
+ try {
242
+ let session = customSession;
243
+ if (!session) {
244
+ const { createInvestigationSemanticSession } = await import('../ai.js');
245
+ session = createInvestigationSemanticSession();
246
+ }
247
+ if (session && typeof session.sendMessage === 'function') {
248
+ const sendPromise = session.sendMessage(userPrompt, undefined, abortSignal);
249
+ let timeoutHandle;
250
+ const timeoutPromise = new Promise((_, reject) => {
251
+ timeoutHandle = setTimeout(() => reject(new Error('Semantic classification timeout')), timeoutMs);
252
+ });
253
+ const response = await Promise.race([sendPromise, timeoutPromise]).finally(() => {
254
+ if (timeoutHandle)
255
+ clearTimeout(timeoutHandle);
256
+ });
257
+ let rawText = '';
258
+ if (typeof response === 'string') {
259
+ rawText = response;
260
+ }
261
+ else if (typeof response?.response?.text === 'function') {
262
+ rawText = response.response.text();
263
+ }
264
+ else if (typeof response?.text === 'function') {
265
+ rawText = response.text();
266
+ }
267
+ else if (typeof response?.text === 'string') {
268
+ rawText = response.text;
269
+ }
270
+ let parsed = null;
271
+ if (rawText) {
272
+ const cleaned = rawText
273
+ .replace(/^```(?:json)?\s*/i, '')
274
+ .replace(/\s*```$/i, '')
275
+ .trim();
276
+ parsed = JSON.parse(cleaned);
277
+ }
278
+ else if (response && typeof response === 'object' && !response.response) {
279
+ parsed = response;
280
+ }
281
+ if (parsed && Array.isArray(parsed.topics)) {
282
+ const topics = parsed.topics.map((t) => String(t).toLowerCase().trim()).filter(Boolean);
283
+ const components = Array.isArray(parsed.components)
284
+ ? parsed.components.map((c) => String(c).toLowerCase().trim()).filter(Boolean)
285
+ : [];
286
+ const intent = typeof parsed.intent === 'string' ? parsed.intent.toLowerCase().trim() : 'investigation';
287
+ const reasoning = typeof parsed.reasoning === 'string' ? parsed.reasoning : undefined;
288
+ return {
289
+ topics: topics.length > 0 ? topics : ['general'],
290
+ components,
291
+ intent,
292
+ reasoning,
293
+ };
294
+ }
295
+ }
296
+ }
297
+ catch (err) {
298
+ if (err?.name === 'AbortError' || abortSignal?.aborted) {
299
+ throw err;
300
+ }
301
+ debugLog(`[InvestigationCache] Semantic AI classifier failed or offline, falling back to heuristics: ${err?.message || err}`);
302
+ }
303
+ return extractHeuristicSemanticMetadata(userPrompt);
304
+ }
305
+ /**
306
+ * Calculates word-level Jaccard similarity between two texts.
307
+ */
308
+ function tokenJaccardSimilarity(a, b) {
309
+ const tokensA = new Set(a
310
+ .toLowerCase()
311
+ .split(/\s+/)
312
+ .filter((t) => t.length > 2));
313
+ const tokensB = new Set(b
314
+ .toLowerCase()
315
+ .split(/\s+/)
316
+ .filter((t) => t.length > 2));
317
+ if (tokensA.size === 0 && tokensB.size === 0)
318
+ return 1.0;
319
+ if (tokensA.size === 0 || tokensB.size === 0)
320
+ return 0.0;
321
+ let intersection = 0;
322
+ for (const t of tokensA) {
323
+ if (tokensB.has(t))
324
+ intersection++;
325
+ }
326
+ const union = tokensA.size + tokensB.size - intersection;
327
+ return union === 0 ? 0 : intersection / union;
328
+ }
329
+ /**
330
+ * Computes a SHA-256 fingerprint of the relevant files' mtime and sizes.
331
+ */
22
332
  export async function generateWorkspaceFingerprint(workspaceRoot, relevantFiles) {
23
333
  const fileStats = [];
24
334
  for (const file of relevantFiles) {
25
335
  try {
26
336
  const fullPath = path.join(workspaceRoot, file);
27
337
  const stat = await fs.stat(fullPath);
28
- // Use mtimeMs and size for a robust fingerprint
29
338
  fileStats.push(`${file}:${stat.mtimeMs}:${stat.size}`);
30
339
  }
31
340
  catch (e) {
@@ -33,11 +342,9 @@ export async function generateWorkspaceFingerprint(workspaceRoot, relevantFiles)
33
342
  fileStats.push(`${file}:deleted`);
34
343
  }
35
344
  else if (e.code === 'EACCES' || e.code === 'EPERM') {
36
- // Handle permission issues by marking as changed to force re-evaluation
37
345
  fileStats.push(`${file}:inaccessible`);
38
346
  }
39
347
  else {
40
- // For other errors, assume it's changed
41
348
  fileStats.push(`${file}:error`);
42
349
  }
43
350
  }
@@ -45,65 +352,268 @@ export async function generateWorkspaceFingerprint(workspaceRoot, relevantFiles)
45
352
  fileStats.sort();
46
353
  return crypto.createHash('sha256').update(fileStats.join('\n')).digest('hex');
47
354
  }
48
- export async function lookupInvestigation(workspaceRoot, userPrompt) {
49
- const normalized = normalizePrompt(userPrompt);
50
- const hash = hashPrompt(normalized);
355
+ /**
356
+ * Enforces LRU eviction to keep cache store below MAX_CACHE_SIZE_BYTES (5MB).
357
+ */
358
+ export function pruneCacheStore(store) {
359
+ let storeJson = JSON.stringify(store);
360
+ while (Buffer.byteLength(storeJson, 'utf8') > MAX_CACHE_SIZE_BYTES) {
361
+ const keys = Object.keys(store.entries);
362
+ if (keys.length === 0)
363
+ break;
364
+ let oldestKey = keys[0];
365
+ let oldestTime = store.entries[oldestKey].lastAccessedAt || store.entries[oldestKey].createdAt;
366
+ for (let i = 1; i < keys.length; i++) {
367
+ const entryTime = store.entries[keys[i]].lastAccessedAt || store.entries[keys[i]].createdAt;
368
+ if (entryTime < oldestTime) {
369
+ oldestKey = keys[i];
370
+ oldestTime = entryTime;
371
+ }
372
+ }
373
+ delete store.entries[oldestKey];
374
+ storeJson = JSON.stringify(store);
375
+ }
376
+ }
377
+ /**
378
+ * Looks up cached investigation results using a 2-Tier matching pipeline:
379
+ * - Tier 1A: Exact hash match
380
+ * - Tier 1B: Local fuzzy Levenshtein & token similarity match
381
+ * - Tier 2: AI semantic topic & component classification match
382
+ * All candidates validate the workspace fingerprint before returning.
383
+ */
384
+ export async function lookupInvestigation(workspaceRoot, userPrompt, options = {}) {
385
+ const { abortSignal, allowSemanticFallback = true, semanticSession, onProgress, fuzzyThreshold = DEFAULT_FUZZY_THRESHOLD, semanticThreshold = DEFAULT_SEMANTIC_THRESHOLD, } = options;
386
+ if (abortSignal?.aborted) {
387
+ const err = new Error('Operation aborted');
388
+ err.name = 'AbortError';
389
+ throw err;
390
+ }
51
391
  const store = readCache(workspaceRoot, CACHE_FILE);
52
- if (!store || !store.entries || !store.entries[hash]) {
392
+ if (!store || !store.entries || Object.keys(store.entries).length === 0) {
53
393
  return null;
54
394
  }
55
- const entry = store.entries[hash];
56
- const currentFingerprint = await generateWorkspaceFingerprint(workspaceRoot, entry.relevantFiles);
57
- if (entry.workspaceFingerprint !== currentFingerprint) {
58
- debugLog(`Investigation cache: relevant files modified, invalidating cache entry`);
59
- delete store.entries[hash];
395
+ const normalized = normalizePrompt(userPrompt);
396
+ const hash = hashPrompt(normalized);
397
+ // ─── TIER 1A: Exact Hash Match ──────────────────────────────────────────
398
+ if (store.entries[hash]) {
399
+ const entry = store.entries[hash];
400
+ const currentFingerprint = await generateWorkspaceFingerprint(workspaceRoot, entry.relevantFiles);
401
+ if (entry.workspaceFingerprint !== currentFingerprint) {
402
+ debugLog(`Investigation cache: relevant files modified for exact hash ${hash}, invalidating entry`);
403
+ delete store.entries[hash];
404
+ writeCache(workspaceRoot, CACHE_FILE, store);
405
+ return null;
406
+ }
407
+ entry.lastAccessedAt = Date.now();
408
+ entry.hitCount = (entry.hitCount || 0) + 1;
409
+ store.totalHits = (store.totalHits || 0) + 1;
60
410
  writeCache(workspaceRoot, CACHE_FILE, store);
61
- return null;
411
+ debugLog(`Investigation cache: Tier 1 Exact HIT for hash ${hash}`);
412
+ if (onProgress)
413
+ onProgress(`⚡ Memory Bank Exact HIT — loaded ${entry.relevantFiles.length} files`);
414
+ return {
415
+ entry,
416
+ bytesSaved: JSON.stringify(entry).length,
417
+ matchTier: 'exact',
418
+ similarityScore: 1.0,
419
+ };
420
+ }
421
+ // ─── TIER 1B: Local Fuzzy Levenshtein & Token Match ──────────────────────
422
+ let bestFuzzyEntry = null;
423
+ let bestFuzzyKey = null;
424
+ let bestFuzzyScore = 0;
425
+ const queryMeta = extractHeuristicSemanticMetadata(userPrompt);
426
+ for (const [key, entry] of Object.entries(store.entries)) {
427
+ const entryPrompt = entry.normalizedPrompt || normalizePrompt(entry.summary || '');
428
+ if (!entryPrompt)
429
+ continue;
430
+ const levSim = levenshteinSimilarity(normalized, entryPrompt);
431
+ const jaccardSim = tokenJaccardSimilarity(normalized, entryPrompt);
432
+ const combinedScore = 0.6 * levSim + 0.4 * jaccardSim;
433
+ // Prevent false fuzzy matches between distinct file/component targets
434
+ const entryComponents = entry.components || [];
435
+ const hasDisjointComponents = queryMeta.components.length > 0 &&
436
+ entryComponents.length > 0 &&
437
+ !queryMeta.components.some((qc) => entryComponents.includes(qc) ||
438
+ entry.relevantFiles.some((rf) => rf.toLowerCase().includes(qc) || path.basename(rf).toLowerCase().includes(qc)));
439
+ if (!hasDisjointComponents && levSim >= 0.8 && jaccardSim >= 0.55 && combinedScore >= fuzzyThreshold) {
440
+ if (combinedScore > bestFuzzyScore) {
441
+ bestFuzzyScore = combinedScore;
442
+ bestFuzzyEntry = entry;
443
+ bestFuzzyKey = key;
444
+ }
445
+ }
446
+ }
447
+ if (bestFuzzyEntry && bestFuzzyKey) {
448
+ const currentFingerprint = await generateWorkspaceFingerprint(workspaceRoot, bestFuzzyEntry.relevantFiles);
449
+ if (bestFuzzyEntry.workspaceFingerprint !== currentFingerprint) {
450
+ debugLog(`Investigation cache: relevant files modified for fuzzy match ${bestFuzzyKey}, invalidating entry`);
451
+ delete store.entries[bestFuzzyKey];
452
+ writeCache(workspaceRoot, CACHE_FILE, store);
453
+ }
454
+ else {
455
+ bestFuzzyEntry.lastAccessedAt = Date.now();
456
+ bestFuzzyEntry.hitCount = (bestFuzzyEntry.hitCount || 0) + 1;
457
+ store.totalHits = (store.totalHits || 0) + 1;
458
+ writeCache(workspaceRoot, CACHE_FILE, store);
459
+ debugLog(`Investigation cache: Tier 1 Fuzzy HIT (${(bestFuzzyScore * 100).toFixed(1)}%) for ${bestFuzzyKey}`);
460
+ if (onProgress)
461
+ onProgress(`⚡ Memory Bank Fuzzy HIT (${(bestFuzzyScore * 100).toFixed(0)}%) — loaded ${bestFuzzyEntry.relevantFiles.length} files`);
462
+ return {
463
+ entry: bestFuzzyEntry,
464
+ bytesSaved: JSON.stringify(bestFuzzyEntry).length,
465
+ matchTier: 'fuzzy',
466
+ similarityScore: bestFuzzyScore,
467
+ };
468
+ }
469
+ }
470
+ // ─── TIER 2: AI Semantic Classification Match ────────────────────────────
471
+ if (allowSemanticFallback) {
472
+ if (onProgress)
473
+ onProgress(`🔍 Checking Memory Bank semantic classification...`);
474
+ const querySemantic = await classifySemanticIntent(userPrompt, abortSignal, semanticSession);
475
+ if (querySemantic && (querySemantic.topics.length > 0 || querySemantic.components.length > 0)) {
476
+ let bestSemanticEntry = null;
477
+ let bestSemanticKey = null;
478
+ let bestSemanticScore = 0;
479
+ for (const [key, entry] of Object.entries(store.entries)) {
480
+ const entryTopics = entry.topics || [];
481
+ const entryComponents = entry.components || [];
482
+ // Calculate topic intersection over query topics (excluding generic fallback)
483
+ let topicMatches = 0;
484
+ const specificQueryTopics = querySemantic.topics.filter((t) => t !== 'general');
485
+ for (const t of specificQueryTopics) {
486
+ if (entryTopics.includes(t))
487
+ topicMatches++;
488
+ }
489
+ const topicScore = specificQueryTopics.length > 0 ? topicMatches / specificQueryTopics.length : 0;
490
+ // Calculate component intersection
491
+ let componentMatches = 0;
492
+ for (const c of querySemantic.components) {
493
+ if (entryComponents.includes(c) ||
494
+ entry.relevantFiles.some((f) => f.toLowerCase().includes(c) || path.basename(f).toLowerCase().includes(c))) {
495
+ componentMatches++;
496
+ }
497
+ }
498
+ const componentScore = querySemantic.components.length > 0 ? componentMatches / querySemantic.components.length : 0;
499
+ // If components were detected on both sides and there's 0 overlap, skip this candidate
500
+ if (querySemantic.components.length > 0 && entryComponents.length > 0 && componentMatches === 0) {
501
+ continue;
502
+ }
503
+ // If no specific topics and no components match, skip
504
+ if (topicScore === 0 && componentScore === 0) {
505
+ continue;
506
+ }
507
+ // Intent match bonus
508
+ const intentBonus = entry.intent && querySemantic.intent && entry.intent === querySemantic.intent ? 0.1 : 0.0;
509
+ let score = 0;
510
+ if (querySemantic.components.length > 0 && specificQueryTopics.length > 0) {
511
+ score = 0.5 * componentScore + 0.4 * topicScore + intentBonus;
512
+ }
513
+ else if (querySemantic.components.length > 0) {
514
+ score = 0.85 * componentScore + intentBonus;
515
+ }
516
+ else {
517
+ score = 0.85 * topicScore + intentBonus;
518
+ }
519
+ if (score > bestSemanticScore && score >= semanticThreshold) {
520
+ bestSemanticScore = score;
521
+ bestSemanticEntry = entry;
522
+ bestSemanticKey = key;
523
+ }
524
+ }
525
+ if (bestSemanticEntry && bestSemanticKey) {
526
+ const currentFingerprint = await generateWorkspaceFingerprint(workspaceRoot, bestSemanticEntry.relevantFiles);
527
+ if (bestSemanticEntry.workspaceFingerprint !== currentFingerprint) {
528
+ debugLog(`Investigation cache: relevant files modified for semantic match ${bestSemanticKey}, invalidating entry`);
529
+ delete store.entries[bestSemanticKey];
530
+ writeCache(workspaceRoot, CACHE_FILE, store);
531
+ }
532
+ else {
533
+ bestSemanticEntry.lastAccessedAt = Date.now();
534
+ bestSemanticEntry.hitCount = (bestSemanticEntry.hitCount || 0) + 1;
535
+ store.totalHits = (store.totalHits || 0) + 1;
536
+ writeCache(workspaceRoot, CACHE_FILE, store);
537
+ debugLog(`Investigation cache: Tier 2 Semantic HIT (${(bestSemanticScore * 100).toFixed(1)}%) for ${bestSemanticKey}`);
538
+ if (onProgress)
539
+ onProgress(`⚡ Memory Bank Semantic HIT (${(bestSemanticScore * 100).toFixed(0)}%) — loaded ${bestSemanticEntry.relevantFiles.length} files`);
540
+ return {
541
+ entry: bestSemanticEntry,
542
+ bytesSaved: JSON.stringify(bestSemanticEntry).length,
543
+ matchTier: 'semantic',
544
+ similarityScore: bestSemanticScore,
545
+ };
546
+ }
547
+ }
548
+ }
62
549
  }
63
- debugLog(`Investigation cache: HIT for hash ${hash}`);
64
- const bytesSaved = JSON.stringify(entry).length;
65
- return { entry, bytesSaved };
550
+ store.totalMisses = (store.totalMisses || 0) + 1;
551
+ writeCache(workspaceRoot, CACHE_FILE, store);
552
+ return null;
66
553
  }
67
- export async function saveInvestigation(workspaceRoot, userPrompt, relevantFiles, summary) {
554
+ /**
555
+ * Saves investigation context with semantic tagging, workspace fingerprint, and LRU pruning.
556
+ */
557
+ export async function saveInvestigation(workspaceRoot, userPrompt, relevantFiles, summary, semanticMetadata, abortSignal) {
558
+ if (abortSignal?.aborted) {
559
+ const err = new Error('Operation aborted');
560
+ err.name = 'AbortError';
561
+ throw err;
562
+ }
68
563
  const store = readCache(workspaceRoot, CACHE_FILE) || { entries: {} };
564
+ if (!store.entries)
565
+ store.entries = {};
69
566
  const normalized = normalizePrompt(userPrompt);
70
567
  const hash = hashPrompt(normalized);
71
568
  const fingerprint = await generateWorkspaceFingerprint(workspaceRoot, relevantFiles);
569
+ // Use provided metadata or extract heuristic tags
570
+ let meta;
571
+ if (semanticMetadata && Array.isArray(semanticMetadata.topics) && semanticMetadata.topics.length > 0) {
572
+ meta = {
573
+ topics: semanticMetadata.topics.map((t) => t.toLowerCase().trim()),
574
+ components: (semanticMetadata.components || []).map((c) => c.toLowerCase().trim()),
575
+ intent: semanticMetadata.intent || 'investigation',
576
+ reasoning: semanticMetadata.reasoning,
577
+ };
578
+ }
579
+ else {
580
+ meta = extractHeuristicSemanticMetadata(userPrompt, relevantFiles);
581
+ }
582
+ const signature = generateSemanticSignature(meta.topics, meta.components, meta.intent);
72
583
  store.entries[hash] = {
73
584
  promptHash: hash,
585
+ normalizedPrompt: normalized,
586
+ topics: meta.topics,
587
+ components: meta.components,
588
+ intent: meta.intent,
589
+ semanticSignature: signature,
74
590
  relevantFiles,
75
591
  summary,
76
592
  workspaceFingerprint: fingerprint,
77
593
  createdAt: Date.now(),
594
+ lastAccessedAt: Date.now(),
595
+ hitCount: 0,
78
596
  };
79
- // Enforce 5MB LRU limit
80
- let storeJson = JSON.stringify(store);
81
- while (Buffer.byteLength(storeJson, 'utf8') > MAX_CACHE_SIZE_BYTES) {
82
- const keys = Object.keys(store.entries);
83
- if (keys.length === 0)
84
- break;
85
- let oldestKey = keys[0];
86
- let oldestTime = store.entries[oldestKey].createdAt;
87
- for (let i = 1; i < keys.length; i++) {
88
- if (store.entries[keys[i]].createdAt < oldestTime) {
89
- oldestKey = keys[i];
90
- oldestTime = store.entries[keys[i]].createdAt;
91
- }
92
- }
93
- delete store.entries[oldestKey];
94
- storeJson = JSON.stringify(store);
95
- }
597
+ // Enforce LRU eviction under 5MB
598
+ pruneCacheStore(store);
96
599
  writeCache(workspaceRoot, CACHE_FILE, store);
97
- debugLog(`💾 Investigation cached for future reuse`);
600
+ debugLog(`💾 Investigation cached with topics [${meta.topics.join(', ')}] and signature "${signature}"`);
98
601
  }
602
+ /**
603
+ * Invalidates cache entries that reference any changed files.
604
+ */
99
605
  export async function invalidateFilesFromInvestigationCache(workspaceRoot, changedFiles) {
100
606
  const store = readCache(workspaceRoot, CACHE_FILE);
101
607
  if (!store || !store.entries)
102
608
  return;
609
+ const normalizedChanged = changedFiles.map((f) => f.replace(/\\/g, '/').toLowerCase());
103
610
  let invalidated = 0;
104
611
  for (const hash of Object.keys(store.entries)) {
105
612
  const entry = store.entries[hash];
106
- const dependsOnChange = entry.relevantFiles.some((f) => changedFiles.includes(f));
613
+ const dependsOnChange = entry.relevantFiles.some((f) => {
614
+ const normF = f.replace(/\\/g, '/').toLowerCase();
615
+ return normalizedChanged.some((c) => normF === c || normF.endsWith('/' + c) || c.endsWith('/' + normF));
616
+ });
107
617
  if (dependsOnChange) {
108
618
  delete store.entries[hash];
109
619
  invalidated++;
@@ -114,6 +624,15 @@ export async function invalidateFilesFromInvestigationCache(workspaceRoot, chang
114
624
  debugLog(`[DEBUG] Investigation cache: invalidated ${invalidated} entries referencing changed files`);
115
625
  }
116
626
  }
627
+ /**
628
+ * Clears all entries in the investigation cache.
629
+ */
630
+ export function clearInvestigationCache(workspaceRoot) {
631
+ writeCache(workspaceRoot, CACHE_FILE, { entries: {}, totalHits: 0, totalMisses: 0 });
632
+ }
633
+ /**
634
+ * Returns cache statistics for inspection and telemetry.
635
+ */
117
636
  export function getInvestigationCacheStats(workspaceRoot) {
118
637
  const store = readCache(workspaceRoot, CACHE_FILE);
119
638
  const entriesCount = store?.entries ? Object.keys(store.entries).length : 0;
@@ -126,10 +645,20 @@ export function getInvestigationCacheStats(workspaceRoot) {
126
645
  catch (e) {
127
646
  // ignore
128
647
  }
648
+ const allTopics = new Set();
649
+ if (store?.entries) {
650
+ for (const entry of Object.values(store.entries)) {
651
+ if (Array.isArray(entry.topics)) {
652
+ for (const t of entry.topics)
653
+ allTopics.add(t);
654
+ }
655
+ }
656
+ }
129
657
  return {
130
658
  entries: entriesCount,
131
659
  sizeBytes,
132
- hitCount: 0,
133
- missCount: 0,
660
+ hitCount: store?.totalHits || 0,
661
+ missCount: store?.totalMisses || 0,
662
+ topicsCount: allTopics.size,
134
663
  };
135
664
  }