minovative-mind-cli 2.3.3 → 2.5.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.
Files changed (38) hide show
  1. package/README.md +5 -0
  2. package/dist/commands/chat.js +2 -1
  3. package/dist/services/agent/slashCommands.js +152 -2
  4. package/dist/services/agent/toolLoop.js +5 -17
  5. package/dist/services/agent-tools.js +26 -14
  6. package/dist/services/agent.js +43 -13
  7. package/dist/services/ai.d.ts +2 -2
  8. package/dist/services/ai.js +131 -14
  9. package/dist/services/changeLogger.js +2 -2
  10. package/dist/services/chatHistoryService.d.ts +8 -0
  11. package/dist/services/chatHistoryService.js +24 -11
  12. package/dist/services/contextAgent.d.ts +1 -0
  13. package/dist/services/contextAgent.js +57 -32
  14. package/dist/services/metrics.d.ts +21 -4
  15. package/dist/services/metrics.js +18 -0
  16. package/dist/services/orchestration/investigationAgent.js +0 -32
  17. package/dist/services/orchestration/investigationCache.d.ts +25 -0
  18. package/dist/services/orchestration/investigationCache.js +135 -0
  19. package/dist/services/orchestration/investigationOrchestrator.js +5 -1
  20. package/dist/services/orchestration/messageBus.d.ts +1 -1
  21. package/dist/services/orchestration/messageBus.js +14 -14
  22. package/dist/services/orchestration/orchestrator.js +1 -1
  23. package/dist/services/orchestration/readCache.js +4 -0
  24. package/dist/services/orchestration/scopedTools.js +2 -1
  25. package/dist/services/orchestration/subAgent.js +10 -26
  26. package/dist/services/proxyClient.d.ts +8 -0
  27. package/dist/services/proxyClient.js +17 -0
  28. package/dist/services/verificationService.js +15 -6
  29. package/dist/utils/config.d.ts +4 -0
  30. package/dist/utils/config.js +8 -0
  31. package/dist/utils/credentialStore.d.ts +7 -0
  32. package/dist/utils/credentialStore.js +8 -0
  33. package/dist/utils/projectStorage.js +7 -0
  34. package/dist/utils/syntaxValidator.js +125 -21
  35. package/dist/utils/systemPrompts.d.ts +2 -2
  36. package/dist/utils/systemPrompts.js +3 -3
  37. package/oclif.manifest.json +2 -2
  38. package/package.json +1 -2
@@ -1,12 +1,14 @@
1
- import { GEMINI_MODELS, DEFAULT_MODEL, MAX_OUTPUT_TOKENS } from '../utils/config.js';
1
+ import { GoogleGenerativeAI } from '@google/generative-ai';
2
+ import { GEMINI_MODELS, DEFAULT_MODEL, MAX_OUTPUT_TOKENS, isByokEnabled } from '../utils/config.js';
2
3
  import { getToolDeclarations } from './agent-tools.js';
3
- import { ProxyClient } from './proxyClient.js';
4
+ import { getMetricCollector } from './metrics.js';
4
5
  import { getAuthorizedIdToken } from './auth.js';
5
6
  import { debugLog } from '../utils/logger.js';
6
7
  import { readCache, writeCache } from '../utils/projectStorage.js';
7
8
  import { GENERAL_CHAT_INSTRUCTION, PLAN_EXECUTION_INSTRUCTION, PLAN_MODE_INSTRUCTION, CONTEXT_SYSTEM_INSTRUCTION, INTENT_ROUTER_SYSTEM_INSTRUCTION, WEB_SEARCH_SYSTEM_INSTRUCTION, EXECUTION_COMPLEXITY_SYSTEM_INSTRUCTION, INVESTIGATION_COMPLEXITY_SYSTEM_INSTRUCTION, } from '../utils/systemPrompts.js';
8
- import { getMetricCollector } from './metrics.js';
9
9
  import { workspaceRegistry } from './workspaceRegistry.js';
10
+ import { loadCredentials } from '../utils/credentialStore.js';
11
+ import { ProxyClient, accumulateTurnUsage } from './proxyClient.js';
10
12
  function getMultiWorkspaceBlock() {
11
13
  const summary = workspaceRegistry.buildPromptSummary();
12
14
  if (!summary)
@@ -206,13 +208,57 @@ export class ProxyChatSession {
206
208
  // Prune old history before sending to keep payload bounded
207
209
  await this.pruneHistory();
208
210
  const effectiveGenerationConfig = { ...this.generationConfig };
209
- const result = await proxyClient.generateFunctionCallViaProxy(idToken, this.modelName, this.history, this.tools, undefined, // toolConfig
210
- this.systemInstruction, effectiveGenerationConfig, onChunk ? { onChunk } : undefined, // streamCallbacks
211
- abortSignal);
211
+ const byokEnabled = await isByokEnabled();
212
+ let result;
213
+ if (byokEnabled) {
214
+ const creds = await loadCredentials();
215
+ const genAI = new GoogleGenerativeAI(creds.geminiApiKey);
216
+ const model = genAI.getGenerativeModel({
217
+ model: this.modelName,
218
+ systemInstruction: this.systemInstruction,
219
+ tools: this.tools,
220
+ });
221
+ const chat = model.startChat({
222
+ history: this.history.slice(0, -1),
223
+ generationConfig: effectiveGenerationConfig,
224
+ });
225
+ const lastMessage = this.history[this.history.length - 1];
226
+ try {
227
+ const response = await chat.sendMessage(lastMessage.parts);
228
+ const responseObj = await response.response;
229
+ result = {
230
+ functionCalls: responseObj.functionCalls(),
231
+ parts: responseObj.candidates?.[0]?.content?.parts,
232
+ usageMetadata: responseObj.usageMetadata,
233
+ };
234
+ if (responseObj.usageMetadata) {
235
+ const collector = getMetricCollector();
236
+ collector?.accumulateUsage(responseObj.usageMetadata);
237
+ }
238
+ }
239
+ catch (error) {
240
+ debugLog(`Failed to send message via BYOK: ${error}`);
241
+ const errorMessage = error?.message || '';
242
+ if (error?.status === 401 ||
243
+ error?.status === 403 ||
244
+ errorMessage.includes('API_KEY_INVALID') ||
245
+ errorMessage.includes('quota') ||
246
+ errorMessage.includes('PERMISSION_DENIED')) {
247
+ throw new Error('AI_BYOK_ERROR: Your API key or quota is invalid. Please run /config-key to update your settings.');
248
+ }
249
+ throw error;
250
+ }
251
+ }
252
+ else {
253
+ result = await proxyClient.generateFunctionCallViaProxy(idToken, this.modelName, this.history, this.tools, undefined, // toolConfig
254
+ this.systemInstruction, effectiveGenerationConfig, onChunk ? { onChunk } : undefined, // streamCallbacks
255
+ abortSignal);
256
+ }
212
257
  this.latestUsageMetadata = result.usageMetadata;
213
258
  // Track token usage metrics
214
259
  if (result.usageMetadata) {
215
260
  const collector = getMetricCollector();
261
+ collector?.accumulateUsage(result.usageMetadata);
216
262
  if (collector) {
217
263
  collector.recordTokenUsage(result.usageMetadata.promptTokens || 0, result.usageMetadata.candidatesTokens || 0, result.usageMetadata.cachedTokens || 0);
218
264
  }
@@ -306,9 +352,9 @@ export function getPlanModeConfig() {
306
352
  * Compresses a large string of text using gemini-3.5-flash-lite.
307
353
  * Used for shrinking context payloads to prevent OOM/choking.
308
354
  */
309
- export async function compressTextUsingFlashLite(text, instruction = "<directives>\nSummarize the following text concisely. Preserve the most critical technical details, function names, and architecture logic. Make sure it's understandable without the fluff.\n</directives>", inlineData) {
310
- if (!text || (text.length < 1000 && !inlineData))
311
- return text; // Don't compress tiny texts
355
+ export async function compressTextUsingFlashLite(text, instruction = "<directives>\nSummarize the following text concisely. Preserve the most critical technical details, function names, and architecture logic. Make sure it's understandable without the fluff.\n</directives>", inlineData, force = false) {
356
+ if (!text || (!force && text.length < 1000 && !inlineData))
357
+ return text; // Don't compress tiny texts unless forced
312
358
  try {
313
359
  const idToken = await getAuthorizedIdToken();
314
360
  if (!idToken)
@@ -321,8 +367,38 @@ export async function compressTextUsingFlashLite(text, instruction = "<directive
321
367
  parts.push({ inlineData });
322
368
  }
323
369
  const contents = [{ role: 'user', parts }];
324
- const result = await proxyClient.generateFunctionCallViaProxy(idToken, model, contents, [], // no tools
325
- undefined, instruction, { temperature: 0.2 });
370
+ const byokEnabled = await isByokEnabled();
371
+ let result;
372
+ if (byokEnabled) {
373
+ try {
374
+ const creds = await loadCredentials();
375
+ const genAI = new GoogleGenerativeAI(creds.geminiApiKey);
376
+ const modelObj = genAI.getGenerativeModel({
377
+ model,
378
+ systemInstruction: instruction,
379
+ });
380
+ const response = await modelObj.generateContent({
381
+ contents: [{ role: 'user', parts }],
382
+ generationConfig: { temperature: 0.2 },
383
+ });
384
+ const responseObj = await response.response;
385
+ result = {
386
+ parts: responseObj.candidates?.[0]?.content?.parts,
387
+ usageMetadata: responseObj.usageMetadata,
388
+ };
389
+ if (responseObj.usageMetadata) {
390
+ accumulateTurnUsage(responseObj.usageMetadata, model);
391
+ }
392
+ }
393
+ catch (error) {
394
+ console.error('BYOK Error:', error.message);
395
+ throw new Error(`BYOK AI Error: ${error.message}`);
396
+ }
397
+ }
398
+ else {
399
+ result = await proxyClient.generateFunctionCallViaProxy(idToken, model, contents, [], // no tools
400
+ undefined, instruction, { temperature: 0.2 });
401
+ }
326
402
  let textPart = '';
327
403
  if (result.parts) {
328
404
  // Use highly optimized local loop instead of array search to avoid callback allocation and naive database regex flags
@@ -338,6 +414,9 @@ export async function compressTextUsingFlashLite(text, instruction = "<directive
338
414
  }
339
415
  catch (error) {
340
416
  debugLog(`Failed to compress text using flash-lite: ${error}`);
417
+ if (error?.status === 401 || error?.status === 403 || error?.message?.includes('API_KEY_INVALID') || error?.message?.includes('quota')) {
418
+ throw new Error('AI_AUTH_ERROR: Your API key or quota is invalid. Please run /config-key or re-login.');
419
+ }
341
420
  return text; // fallback to raw text if compression fails
342
421
  }
343
422
  }
@@ -581,8 +660,38 @@ export async function generateChatTitle(firstMessage) {
581
660
  let model = getGlobalActiveModel();
582
661
  if (model === 'auto')
583
662
  model = GEMINI_MODELS.FLASH_LITE;
584
- const result = await proxyClient.generateFunctionCallViaProxy(idToken, model, contents, [], // no tools
585
- undefined, instruction, { temperature: 0.2 });
663
+ const byokEnabled = await isByokEnabled();
664
+ let result;
665
+ if (byokEnabled) {
666
+ try {
667
+ const creds = await loadCredentials();
668
+ const genAI = new GoogleGenerativeAI(creds.geminiApiKey);
669
+ const modelObj = genAI.getGenerativeModel({
670
+ model,
671
+ systemInstruction: instruction,
672
+ });
673
+ const response = await modelObj.generateContent({
674
+ contents,
675
+ generationConfig: { temperature: 0.2 },
676
+ });
677
+ const responseObj = await response.response;
678
+ result = {
679
+ parts: responseObj.candidates?.[0]?.content?.parts,
680
+ usageMetadata: responseObj.usageMetadata,
681
+ };
682
+ if (responseObj.usageMetadata) {
683
+ accumulateTurnUsage(responseObj.usageMetadata, model);
684
+ }
685
+ }
686
+ catch (error) {
687
+ console.error('BYOK Error:', error.message);
688
+ throw new Error(`BYOK AI Error: ${error.message}`);
689
+ }
690
+ }
691
+ else {
692
+ result = await proxyClient.generateFunctionCallViaProxy(idToken, model, contents, [], // no tools
693
+ undefined, instruction, { temperature: 0.2 });
694
+ }
586
695
  let title = '';
587
696
  if (result.parts) {
588
697
  for (const p of result.parts) {
@@ -597,7 +706,15 @@ export async function generateChatTitle(firstMessage) {
597
706
  return title ? title : firstMessage.substring(0, maxLength);
598
707
  }
599
708
  catch (error) {
600
- debugLog(`Failed to generate chat title: ${error}`);
709
+ debugLog(`Failed to generate chat title via BYOK: ${error}`);
710
+ const errorMessage = error?.message || '';
711
+ if (error?.status === 401 ||
712
+ error?.status === 403 ||
713
+ errorMessage.includes('API_KEY_INVALID') ||
714
+ errorMessage.includes('quota') ||
715
+ errorMessage.includes('PERMISSION_DENIED')) {
716
+ throw new Error('AI_BYOK_ERROR: Your API key or quota is invalid. Please run /config-key to update your settings.');
717
+ }
601
718
  return firstMessage.substring(0, maxLength);
602
719
  }
603
720
  }
@@ -21,7 +21,7 @@ class ChangeLogger {
21
21
  * Maximum number of historical changesets retained in memory and on disk.
22
22
  * Older changesets are discarded on save once this limit is exceeded.
23
23
  */
24
- MAX_HISTORY = 10;
24
+ MAX_HISTORY = 20;
25
25
  /**
26
26
  * Whether the change logger is currently enabled.
27
27
  */
@@ -66,7 +66,7 @@ class ChangeLogger {
66
66
  }
67
67
  writeCache(this.workspaceRoot, 'revert_state.json', {
68
68
  history: this.changeStack,
69
- isEnabled: this.isEnabled
69
+ isEnabled: this.isEnabled,
70
70
  });
71
71
  }
72
72
  /**
@@ -80,6 +80,14 @@ declare class ChatHistoryService {
80
80
  * @returns A promise that resolves when the session and its associated archive have been deleted.
81
81
  */
82
82
  deleteSession(id: string): Promise<void>;
83
+ /**
84
+ * Deletes multiple chat sessions from the local workspace cache and removes their associated archived history files.
85
+ * If any session or its archived history file does not exist, the operation continues for the remaining sessions.
86
+ *
87
+ * @param ids - An array of unique identifiers of the chat sessions to delete.
88
+ * @returns A promise that resolves when the sessions and their associated archives have been deleted.
89
+ */
90
+ bulkDeleteSessions(ids: string[]): Promise<void>;
83
91
  }
84
92
  /**
85
93
  * Singleton instance of the `ChatHistoryService` exported for application-wide use.
@@ -64,23 +64,36 @@ class ChatHistoryService {
64
64
  * @returns A promise that resolves when the session and its associated archive have been deleted.
65
65
  */
66
66
  async deleteSession(id) {
67
- if (!this.workspaceRoot)
67
+ await this.bulkDeleteSessions([id]);
68
+ }
69
+ /**
70
+ * Deletes multiple chat sessions from the local workspace cache and removes their associated archived history files.
71
+ * If any session or its archived history file does not exist, the operation continues for the remaining sessions.
72
+ *
73
+ * @param ids - An array of unique identifiers of the chat sessions to delete.
74
+ * @returns A promise that resolves when the sessions and their associated archives have been deleted.
75
+ */
76
+ async bulkDeleteSessions(ids) {
77
+ if (!this.workspaceRoot || ids.length === 0)
68
78
  return;
69
79
  let sessions = this.getSessions();
70
- sessions = sessions.filter((s) => s.id !== id);
80
+ const idSet = new Set(ids);
81
+ sessions = sessions.filter((s) => !idSet.has(s.id));
71
82
  await writeCache(this.workspaceRoot, 'chat_sessions.json', sessions);
72
- // Also delete the archived history if it exists
83
+ // Also delete the archived histories if they exist
73
84
  const { promises: fs } = await import('node:fs');
74
85
  const path = await import('node:path');
75
86
  const { getProjectStorageDir } = await import('../utils/projectStorage.js');
76
- try {
77
- const storageDir = getProjectStorageDir(this.workspaceRoot);
78
- const archivePath = path.join(storageDir, `archived_history_${id}.json`);
79
- await fs.unlink(archivePath);
80
- }
81
- catch {
82
- // Ignore error if file doesn't exist
83
- }
87
+ const storageDir = getProjectStorageDir(this.workspaceRoot);
88
+ await Promise.all(ids.map(async (id) => {
89
+ try {
90
+ const archivePath = path.join(storageDir, `archived_history_${id}.json`);
91
+ await fs.unlink(archivePath);
92
+ }
93
+ catch {
94
+ // Ignore error if file doesn't exist
95
+ }
96
+ }));
84
97
  }
85
98
  }
86
99
  /**
@@ -7,6 +7,7 @@ export interface ContextAgentResult {
7
7
  }>;
8
8
  summary: string;
9
9
  webSearchSummary?: string;
10
+ fromMemoryBank?: boolean;
10
11
  isParallel?: boolean;
11
12
  }
12
13
  export interface IntentRoute {
@@ -210,6 +210,57 @@ export async function gatherContext(workspaceRoot, userRequest, chatHistory = ''
210
210
  projectTree += `=== ${label} ===\\nProject Type: ${type}\\n${tree}\\n\\n`;
211
211
  }
212
212
  const projectType = primaryProjectType;
213
+ const { lookupInvestigation, saveInvestigation } = await import('./orchestration/investigationCache.js');
214
+ const cacheHit = await lookupInvestigation(workspaceRoot, userRequest);
215
+ if (cacheHit) {
216
+ if (onProgress)
217
+ onProgress(`⚡ Memory Bank HIT — loaded ${cacheHit.entry.relevantFiles.length} files from cache`);
218
+ const cachedFiles = new Map();
219
+ for (const filePath of cacheHit.entry.relevantFiles) {
220
+ const readResult = await executeTool(workspaceRoot, 'read_file', { filePath });
221
+ if (!readResult.error) {
222
+ cachedFiles.set(filePath, { text: readResult.output, inlineData: readResult.inlineData });
223
+ }
224
+ }
225
+ const MAX_TOTAL_FILES = 30;
226
+ try {
227
+ const { resolveAndValidateMultiWorkspacePath } = await import('../utils/pathSecurity.js');
228
+ const autoDiscovered = new Set();
229
+ for (const filePath of cacheHit.entry.relevantFiles) {
230
+ try {
231
+ const resolved = resolveAndValidateMultiWorkspacePath(workspaceRoot, filePath);
232
+ const graph = await buildDependencyGraph(resolved.workspaceRoot);
233
+ const reverseDeps = graph.getImportedBy(resolved.relativePath);
234
+ for (const dep of reverseDeps) {
235
+ if (cachedFiles.has(dep) || autoDiscovered.has(dep))
236
+ continue;
237
+ if (cachedFiles.size + autoDiscovered.size >= MAX_TOTAL_FILES)
238
+ break;
239
+ autoDiscovered.add(dep);
240
+ const depReadRes = await executeTool(resolved.workspaceRoot, 'read_file', { filePath: dep });
241
+ if (!depReadRes.error) {
242
+ cachedFiles.set(dep, { text: depReadRes.output, inlineData: depReadRes.inlineData });
243
+ }
244
+ }
245
+ }
246
+ catch (e) { }
247
+ }
248
+ }
249
+ catch (e) { }
250
+ const { accumulateUsage } = await import('./metrics.js');
251
+ accumulateUsage({ cachedTokens: cacheHit.bytesSaved }); // Rough proxy for saved tokens
252
+ return {
253
+ contextResult: {
254
+ projectTree,
255
+ projectType,
256
+ summary: cacheHit.entry.summary,
257
+ relevantFiles: cachedFiles,
258
+ fromMemoryBank: true,
259
+ },
260
+ targetAgent,
261
+ chainedMessages: [],
262
+ };
263
+ }
213
264
  // ─── Parallel Investigation Gate ─────────────────────────────
214
265
  if (isSubAgentsEnabled()) {
215
266
  // Determine complexity and domain breakdown
@@ -231,9 +282,6 @@ export async function gatherContext(workspaceRoot, userRequest, chatHistory = ''
231
282
  const relevantFiles = new Map();
232
283
  let summary = 'No relevant context found.';
233
284
  let isInvestigationFinished = false;
234
- const visitedToolCalls = new Set();
235
- let consecutiveDuplicates = 0;
236
- const MAX_CONSECUTIVE_DUPLICATES = 3;
237
285
  let chainedMessages = [];
238
286
  let webSearchSummary = '';
239
287
  // Initial prompt
@@ -285,33 +333,6 @@ export async function gatherContext(workspaceRoot, userRequest, chatHistory = ''
285
333
  if (abortSignal.aborted)
286
334
  break;
287
335
  const args = call.args;
288
- // Deduplicate identical tool calls
289
- const callSignature = `${call.name}:${JSON.stringify(args)}`;
290
- if (call.name !== 'finish_investigation' && visitedToolCalls.has(callSignature)) {
291
- consecutiveDuplicates++;
292
- if (consecutiveDuplicates >= MAX_CONSECUTIVE_DUPLICATES) {
293
- // Force-finish: the model is stuck in a loop, finalize with whatever context we have
294
- const forceMsg = `Investigation auto-completed: the model repeated the same tool call ${MAX_CONSECUTIVE_DUPLICATES} times consecutively.`;
295
- debugLog(`[Context Agent] ${forceMsg}`);
296
- if (onProgress)
297
- onProgress(forceMsg);
298
- if (!summary || summary === 'No relevant context found.') {
299
- summary = 'Investigation was auto-completed due to repeated duplicate tool calls. Review the gathered files for context.';
300
- }
301
- isFinished = true;
302
- isInvestigationFinished = relevantFiles.size > 0;
303
- break;
304
- }
305
- functionResponses.push({
306
- functionResponse: {
307
- name: call.name,
308
- response: { error: `DUPLICATE CALL BLOCKED (attempt ${consecutiveDuplicates}/${MAX_CONSECUTIVE_DUPLICATES}): You already executed this exact tool call. Do NOT retry it. Use the results you already have and call finish_investigation now, or try a DIFFERENT tool call with different parameters.` }
309
- }
310
- });
311
- continue;
312
- }
313
- consecutiveDuplicates = 0;
314
- visitedToolCalls.add(callSignature);
315
336
  let logMsg = ` [Context Agent] Executing ${call.name}`;
316
337
  if (call.name === 'finish_investigation') {
317
338
  const filesToRead = args.relevantFiles || [];
@@ -369,7 +390,7 @@ export async function gatherContext(workspaceRoot, userRequest, chatHistory = ''
369
390
  // When the Context Agent finalizes its investigation, we automatically
370
391
  // discover files that DEPEND ON the selected files. This ensures the
371
392
  // Execution Agent won't break imports when modifying/deleting/renaming.
372
- const MAX_TOTAL_FILES = 15;
393
+ const MAX_TOTAL_FILES = 30;
373
394
  try {
374
395
  const { resolveAndValidateMultiWorkspacePath } = await import('../utils/pathSecurity.js');
375
396
  const autoDiscovered = new Set();
@@ -548,9 +569,13 @@ export async function gatherContext(workspaceRoot, userRequest, chatHistory = ''
548
569
  if (collector) {
549
570
  collector.recordContextSelectedFiles(Array.from(relevantFiles.keys()));
550
571
  if (!isInvestigationFinished || relevantFiles.size === 0) {
551
- collector.recordInvestigationFailure();
572
+ collector?.recordInvestigationFailure?.();
552
573
  }
553
574
  }
575
+ if (isInvestigationFinished && relevantFiles.size > 0) {
576
+ const { saveInvestigation } = await import('./orchestration/investigationCache.js');
577
+ await saveInvestigation(workspaceRoot, userRequest, Array.from(relevantFiles.keys()), summary);
578
+ }
554
579
  return {
555
580
  contextResult: { projectTree, projectType, relevantFiles, summary, webSearchSummary, isParallel: false },
556
581
  targetAgent,
@@ -2,6 +2,13 @@ export interface MetricCollector {
2
2
  startTimer(name: string): void;
3
3
  stopTimer(name: string): number;
4
4
  recordTokenUsage(input: number, output: number, cached?: number): void;
5
+ accumulateUsage(usage: any): void;
6
+ resetTurnAccumulator(): void;
7
+ getTurnTotals(): {
8
+ promptTokens: number;
9
+ outputTokens: number;
10
+ cachedTokens: number;
11
+ };
5
12
  recordCompressedContextSize(chars: number): void;
6
13
  recordContextSelectedFiles(files: string[]): void;
7
14
  recordFileModified(filePath: string): void;
@@ -9,10 +16,20 @@ export interface MetricCollector {
9
16
  recordSelfCorrection(): void;
10
17
  recordVerificationResult(passed: boolean): void;
11
18
  recordMatchTier(tier: 'exact' | 'normalized' | 'levenshtein' | 'none'): void;
12
- recordInvestigationFailure(): void;
13
- recordToolFailure(toolName: string): void;
14
- recordModifyFailure(): void;
15
- recordWriteFailure(): void;
19
+ recordCacheHit(cacheType?: 'investigation' | 'read'): void;
20
+ recordCacheMiss(cacheType?: 'investigation' | 'read'): void;
21
+ recordCachePerformance(cacheType: 'investigation' | 'read', durationMs: number): void;
22
+ recordWriteFailure?(): void;
23
+ recordModifyFailure?(): void;
24
+ recordToolFailure?(toolName?: string): void;
25
+ recordInvestigationFailure?(): void;
16
26
  }
17
27
  export declare function setMetricCollector(collector: MetricCollector | null): void;
18
28
  export declare function getMetricCollector(): MetricCollector | null;
29
+ export declare function resetTurnAccumulator(): void;
30
+ export declare function accumulateUsage(usage: any): void;
31
+ export declare function getTurnTotals(): {
32
+ promptTokens: number;
33
+ outputTokens: number;
34
+ cachedTokens: number;
35
+ };
@@ -5,3 +5,21 @@ export function setMetricCollector(collector) {
5
5
  export function getMetricCollector() {
6
6
  return globalCollector;
7
7
  }
8
+ let turnPromptTokens = 0;
9
+ let turnOutputTokens = 0;
10
+ let turnCachedTokens = 0;
11
+ export function resetTurnAccumulator() {
12
+ turnPromptTokens = 0;
13
+ turnOutputTokens = 0;
14
+ turnCachedTokens = 0;
15
+ }
16
+ export function accumulateUsage(usage) {
17
+ if (!usage)
18
+ return;
19
+ turnPromptTokens += usage.promptTokens || 0;
20
+ turnOutputTokens += usage.candidatesTokens || 0;
21
+ turnCachedTokens += usage.cachedTokens || 0;
22
+ }
23
+ export function getTurnTotals() {
24
+ return { promptTokens: turnPromptTokens, outputTokens: turnOutputTokens, cachedTokens: turnCachedTokens };
25
+ }
@@ -113,9 +113,6 @@ export class InvestigationAgentRunner {
113
113
  }
114
114
  currentMessage += `\n\nStart investigating to find relevant files within your assigned domains.`;
115
115
  let isFinished = false;
116
- const visitedToolCalls = new Set();
117
- let consecutiveDuplicates = 0;
118
- const MAX_CONSECUTIVE_DUPLICATES = 3;
119
116
  // Tool loop — mirrors the existing context agent loop in contextAgent.ts
120
117
  while (!crashed && !abortSignal.aborted && !isFinished) {
121
118
  this.pingHeartbeat();
@@ -143,35 +140,6 @@ export class InvestigationAgentRunner {
143
140
  this.pingHeartbeat();
144
141
  const args = call.args;
145
142
  const logPrefix = `[${this.agentLabel}]`;
146
- // Deduplicate identical tool calls
147
- const callSignature = `${call.name}:${JSON.stringify(args)}`;
148
- if (call.name !== 'finish_investigation' && visitedToolCalls.has(callSignature)) {
149
- consecutiveDuplicates++;
150
- if (consecutiveDuplicates >= MAX_CONSECUTIVE_DUPLICATES) {
151
- // Force-finish: the model is stuck in a loop
152
- const forceMsg = `Investigation auto-completed: the model repeated the same tool call ${MAX_CONSECUTIVE_DUPLICATES} times consecutively.`;
153
- debugLog(`InvestigationAgent [${this.agentLabel}]: ${forceMsg}`);
154
- if (onProgress)
155
- onProgress(`[${this.agentLabel}] ${forceMsg}`);
156
- if (!summary || summary === 'No relevant context found.') {
157
- summary = 'Investigation was auto-completed due to repeated duplicate tool calls. Review the gathered files for context.';
158
- }
159
- isFinished = true;
160
- success = relevantFiles.size > 0;
161
- break;
162
- }
163
- functionResponses.push({
164
- functionResponse: {
165
- name: call.name,
166
- response: {
167
- error: `DUPLICATE CALL BLOCKED (attempt ${consecutiveDuplicates}/${MAX_CONSECUTIVE_DUPLICATES}): You already executed this exact tool call. Do NOT retry it. Use the results you already have and call finish_investigation now, or try a DIFFERENT tool call with different parameters.`,
168
- },
169
- },
170
- });
171
- continue;
172
- }
173
- consecutiveDuplicates = 0;
174
- visitedToolCalls.add(callSignature);
175
143
  if (call.name === 'finish_investigation') {
176
144
  summary = args.summary || '';
177
145
  const filesToRead = args.relevantFiles || [];
@@ -0,0 +1,25 @@
1
+ export interface InvestigationCacheEntry {
2
+ promptHash: string;
3
+ relevantFiles: string[];
4
+ summary: string;
5
+ workspaceFingerprint: string;
6
+ createdAt: number;
7
+ }
8
+ export interface InvestigationCacheStore {
9
+ entries: Record<string, InvestigationCacheEntry>;
10
+ }
11
+ export declare function normalizePrompt(prompt: string): string;
12
+ export declare function hashPrompt(normalized: string): string;
13
+ export declare function generateWorkspaceFingerprint(workspaceRoot: string, relevantFiles: string[]): Promise<string>;
14
+ export declare function lookupInvestigation(workspaceRoot: string, userPrompt: string): Promise<{
15
+ entry: InvestigationCacheEntry;
16
+ bytesSaved: number;
17
+ } | null>;
18
+ export declare function saveInvestigation(workspaceRoot: string, userPrompt: string, relevantFiles: string[], summary: string): Promise<void>;
19
+ export declare function invalidateFilesFromInvestigationCache(workspaceRoot: string, changedFiles: string[]): Promise<void>;
20
+ export declare function getInvestigationCacheStats(workspaceRoot: string): {
21
+ entries: number;
22
+ sizeBytes: number;
23
+ hitCount: number;
24
+ missCount: number;
25
+ };