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.
- package/README.md +14 -6
- package/dist/services/agent/inputHandler.d.ts +18 -3
- package/dist/services/agent/inputHandler.js +54 -29
- package/dist/services/agent/slashCommands.js +12 -2
- package/dist/services/agent.js +3 -0
- package/dist/services/ai.d.ts +2 -1
- package/dist/services/ai.js +130 -12
- package/dist/services/contextAgent.js +35 -4
- package/dist/services/investigationComplexity.d.ts +1 -1
- package/dist/services/investigationComplexity.js +1 -1
- package/dist/services/orchestration/investigationCache.d.ts +80 -5
- package/dist/services/orchestration/investigationCache.js +570 -41
- package/dist/services/orchestration/investigationOrchestrator.js +11 -2
- package/dist/utils/contextPrompts.d.ts +1 -1
- package/dist/utils/contextPrompts.js +18 -1
- package/dist/utils/systemPrompts.d.ts +2 -1
- package/dist/utils/systemPrompts.js +36 -7
- package/oclif.manifest.json +1 -1
- package/package.json +2 -2
|
@@ -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 = [
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 ||
|
|
392
|
+
if (!store || !store.entries || Object.keys(store.entries).length === 0) {
|
|
53
393
|
return null;
|
|
54
394
|
}
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
return
|
|
550
|
+
store.totalMisses = (store.totalMisses || 0) + 1;
|
|
551
|
+
writeCache(workspaceRoot, CACHE_FILE, store);
|
|
552
|
+
return null;
|
|
66
553
|
}
|
|
67
|
-
|
|
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
|
|
80
|
-
|
|
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
|
|
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) =>
|
|
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
|
}
|