minovative-mind-cli 2.10.0 → 2.11.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.
@@ -9,7 +9,48 @@ import { debugLog } from '../utils/logger.js';
9
9
  import { buildDependencyGraph } from '../utils/dependencyTracer.js';
10
10
  import { runEphemeralScript } from '../utils/analysisRunner.js';
11
11
  import { getMetricCollector } from './metrics.js';
12
+ import { estimateTokenCount, pruneTextToTokenBudget } from '../utils/historyPrompt.js';
12
13
  const metrics = getMetricCollector();
14
+ /** Token budget constants for context optimization */
15
+ const MAX_TREE_TOKENS = 12000;
16
+ const MAX_CHAT_HISTORY_ROUTER_TOKENS = 3000;
17
+ const MAX_CHAT_HISTORY_INVESTIGATION_TOKENS = 4500;
18
+ const MAX_SINGLE_FILE_TOKENS = 25000;
19
+ const MAX_TOTAL_FILE_TOKENS = 150000;
20
+ const MAX_TOTAL_FILES = 30;
21
+ const MAX_TOOL_OUTPUT_TOKENS = 10000;
22
+ /**
23
+ * Bounds individual file content to ensure it does not exceed the single-file token budget.
24
+ *
25
+ * @param filePath The file path.
26
+ * @param content Raw file text.
27
+ * @returns Budget-constrained file text.
28
+ */
29
+ function boundFileContent(filePath, content) {
30
+ const tokens = estimateTokenCount(content);
31
+ if (tokens <= MAX_SINGLE_FILE_TOKENS) {
32
+ return content;
33
+ }
34
+ debugLog(`Truncating large file context for ${filePath}: ${tokens} tokens -> capped at ${MAX_SINGLE_FILE_TOKENS}`);
35
+ return pruneTextToTokenBudget(content, MAX_SINGLE_FILE_TOKENS, {
36
+ truncationMarker: `\n\n... [File content truncated by Context Agent: ${tokens} estimated tokens exceeded the ${MAX_SINGLE_FILE_TOKENS} token limit. Use read_file with line ranges if specific sections are needed.] ...\n\n`,
37
+ });
38
+ }
39
+ /**
40
+ * Bounds tool output text to prevent massive outputs from blowing the context window during investigation turns.
41
+ *
42
+ * @param output Raw tool output string.
43
+ * @returns Budget-constrained tool output.
44
+ */
45
+ function boundToolOutput(output) {
46
+ const tokens = estimateTokenCount(output);
47
+ if (tokens <= MAX_TOOL_OUTPUT_TOKENS) {
48
+ return output;
49
+ }
50
+ return pruneTextToTokenBudget(output, MAX_TOOL_OUTPUT_TOKENS, {
51
+ truncationMarker: `\n\n... [Tool output truncated: exceeded ${MAX_TOOL_OUTPUT_TOKENS} tokens limit] ...\n`,
52
+ });
53
+ }
13
54
  async function detectProjectType(workspaceRoot) {
14
55
  const types = [];
15
56
  const fileExists = async (fileName) => {
@@ -149,7 +190,8 @@ export async function routeIntent(userRequest, chatHistory = '', abortSignal) {
149
190
  const session = createIntentRouterSession();
150
191
  let prompt = `User Request: "${userRequest}"`;
151
192
  if (chatHistory) {
152
- prompt = `Previous Conversation Context:\n${chatHistory}\n\n${prompt}`;
193
+ const prunedHistory = pruneTextToTokenBudget(chatHistory, MAX_CHAT_HISTORY_ROUTER_TOKENS, { fromStart: true });
194
+ prompt = `Previous Conversation Context:\n${prunedHistory}\n\n${prompt}`;
153
195
  }
154
196
  const result = await session.sendMessage(prompt, undefined, abortSignal);
155
197
  const text = result.response.text()?.trim() || '{}';
@@ -183,7 +225,8 @@ export async function evaluateExecutionComplexity(userRequest, investigationSumm
183
225
  Investigation Summary: ${investigationSummary || 'None (0 files needed)'}
184
226
  Number of Relevant Files: ${numRelevantFiles}`;
185
227
  if (chatHistory) {
186
- prompt = `Previous Conversation Context:\n${chatHistory}\n\n${prompt}`;
228
+ const prunedHistory = pruneTextToTokenBudget(chatHistory, MAX_CHAT_HISTORY_ROUTER_TOKENS, { fromStart: true });
229
+ prompt = `Previous Conversation Context:\n${prunedHistory}\n\n${prompt}`;
187
230
  }
188
231
  const result = await session.sendMessage(prompt, undefined, abortSignal);
189
232
  const text = result.response.text()?.trim() || '{}';
@@ -235,8 +278,11 @@ export async function gatherContext(workspaceRoot, userRequest, chatHistory = ''
235
278
  const label = alias ? `@${alias} (${root})` : `Primary Workspace (${root})`;
236
279
  const treeResult = await executeTool(root, 'list_directory', { dirPath: '.', maxDepth: 10 });
237
280
  let tree = treeResult.output;
238
- if (tree.length > 30000) {
239
- tree = tree.substring(0, 30000) + '\n... (Project tree truncated due to size)';
281
+ const treeTokens = estimateTokenCount(tree);
282
+ if (treeTokens > MAX_TREE_TOKENS) {
283
+ tree = pruneTextToTokenBudget(tree, MAX_TREE_TOKENS, {
284
+ truncationMarker: '\n... (Project tree truncated to fit token budget)\n',
285
+ });
240
286
  }
241
287
  const type = await detectProjectType(root);
242
288
  if (!alias) {
@@ -275,15 +321,20 @@ export async function gatherContext(workspaceRoot, userRequest, chatHistory = ''
275
321
  if (onProgress)
276
322
  onProgress(`⚡ Memory Bank HIT — loaded ${cacheHit.entry.relevantFiles.length} files from cache`);
277
323
  const cachedFiles = new Map();
324
+ let cumulativeFileTokens = 0;
278
325
  for (const filePath of cacheHit.entry.relevantFiles) {
279
326
  if (abortSignal.aborted) {
280
327
  const err = new Error('Operation aborted');
281
328
  err.name = 'AbortError';
282
329
  throw err;
283
330
  }
331
+ if (cumulativeFileTokens >= MAX_TOTAL_FILE_TOKENS)
332
+ break;
284
333
  const readResult = await executeTool(workspaceRoot, 'read_file', { filePath });
285
334
  if (!readResult.error) {
286
- cachedFiles.set(filePath, { text: readResult.output, inlineData: readResult.inlineData });
335
+ const boundedText = boundFileContent(filePath, readResult.output);
336
+ cachedFiles.set(filePath, { text: boundedText, inlineData: readResult.inlineData });
337
+ cumulativeFileTokens += estimateTokenCount(boundedText);
287
338
  }
288
339
  }
289
340
  const MAX_TOTAL_FILES = 30;
@@ -304,10 +355,14 @@ export async function gatherContext(workspaceRoot, userRequest, chatHistory = ''
304
355
  continue;
305
356
  if (cachedFiles.size + autoDiscovered.size >= MAX_TOTAL_FILES)
306
357
  break;
358
+ if (cumulativeFileTokens >= MAX_TOTAL_FILE_TOKENS)
359
+ break;
307
360
  autoDiscovered.add(dep);
308
361
  const depReadRes = await executeTool(resolved.workspaceRoot, 'read_file', { filePath: dep });
309
362
  if (!depReadRes.error) {
310
- cachedFiles.set(dep, { text: depReadRes.output, inlineData: depReadRes.inlineData });
363
+ const boundedText = boundFileContent(dep, depReadRes.output);
364
+ cachedFiles.set(dep, { text: boundedText, inlineData: depReadRes.inlineData });
365
+ cumulativeFileTokens += estimateTokenCount(boundedText);
311
366
  }
312
367
  }
313
368
  }
@@ -352,10 +407,12 @@ export async function gatherContext(workspaceRoot, userRequest, chatHistory = ''
352
407
  let isInvestigationFinished = false;
353
408
  let chainedMessages = [];
354
409
  let webSearchSummary = '';
355
- // Initial prompt
410
+ let cumulativeFileTokens = 0;
411
+ // Initial prompt with history budget management
356
412
  let currentMessage = `User Request: "${userRequest}"\n\nProject Type: ${projectType}\n\nProject Structure:\n${projectTree}`;
357
413
  if (chatHistory) {
358
- currentMessage = `Previous Conversation Context:\n${chatHistory}\n\n` + currentMessage;
414
+ const prunedHistory = pruneTextToTokenBudget(chatHistory, MAX_CHAT_HISTORY_INVESTIGATION_TOKENS, { fromStart: true });
415
+ currentMessage = `Previous Conversation Context:\n${prunedHistory}\n\n` + currentMessage;
359
416
  }
360
417
  currentMessage += `\n\nStart investigating to find relevant files.`;
361
418
  const MAX_TURNS = Infinity;
@@ -403,11 +460,11 @@ export async function gatherContext(workspaceRoot, userRequest, chatHistory = ''
403
460
  break;
404
461
  }
405
462
  let isFinished = false;
406
- const functionResponses = [];
463
+ // Log progress for all requested tool calls in this turn
407
464
  for (const call of functionCalls) {
408
465
  if (abortSignal.aborted)
409
466
  break;
410
- const args = call.args;
467
+ const args = (call.args || {});
411
468
  let logMsg = ` [Context Agent] Executing ${call.name}`;
412
469
  if (call.name === 'finish_investigation') {
413
470
  const filesToRead = args.relevantFiles || [];
@@ -434,69 +491,84 @@ export async function gatherContext(workspaceRoot, userRequest, chatHistory = ''
434
491
  logMsg = ` [Context Agent] Looking for recently modified files`;
435
492
  }
436
493
  else if (call.name === 'run_analysis_script') {
437
- const target = args.targetFile ? ` for: ${args.targetFile}` : '';
438
- logMsg = ` [Context Agent] Running analysis script${target}`;
439
- }
440
- const cleanMsg = logMsg.trim().replace(/^\[Context Agent\] /, '');
441
- if (onToolCall) {
442
- onToolCall(cleanMsg, 'Context Agent');
494
+ logMsg = ` [Context Agent] Running analysis script (${args.language})`;
443
495
  }
444
- else if (onProgress) {
445
- onProgress(cleanMsg);
496
+ if (onProgress) {
497
+ onProgress(logMsg.trim());
446
498
  }
447
499
  else {
448
500
  console.log(pc.dim(logMsg));
449
501
  }
502
+ }
503
+ // Execute tool calls concurrently while preserving 1:1 Gemini response positional ordering
504
+ const functionResponses = await Promise.all(functionCalls.map(async (call) => {
505
+ if (abortSignal.aborted) {
506
+ return {
507
+ functionResponse: {
508
+ name: call.name,
509
+ response: { error: 'Operation aborted.' },
510
+ },
511
+ };
512
+ }
513
+ const args = (call.args || {});
450
514
  if (call.name === 'finish_investigation') {
515
+ isInvestigationFinished = true;
516
+ isFinished = true;
451
517
  summary = args.summary || '';
452
518
  const filesToRead = args.relevantFiles || [];
453
- debugLog(`Context Agent finished. Selected files: ${JSON.stringify(filesToRead)}`);
454
- for (const filePath of filesToRead) {
455
- if (!relevantFiles.has(filePath)) {
456
- const readResult = await executeTool(workspaceRoot, 'read_file', { filePath });
457
- if (!readResult.error) {
458
- relevantFiles.set(filePath, { text: readResult.output, inlineData: readResult.inlineData });
459
- }
519
+ const uniqueFilesToRead = filesToRead.filter((fp) => !relevantFiles.has(fp));
520
+ const readResults = await Promise.all(uniqueFilesToRead.map(async (filePath) => {
521
+ const readResult = await executeTool(workspaceRoot, 'read_file', { filePath });
522
+ return { filePath, readResult };
523
+ }));
524
+ for (const { filePath, readResult } of readResults) {
525
+ if (cumulativeFileTokens >= MAX_TOTAL_FILE_TOKENS)
526
+ break;
527
+ if (!readResult.error) {
528
+ const boundedText = boundFileContent(filePath, readResult.output);
529
+ relevantFiles.set(filePath, { text: boundedText, inlineData: readResult.inlineData });
530
+ cumulativeFileTokens += estimateTokenCount(boundedText);
460
531
  }
461
532
  }
462
- isFinished = true;
463
- isInvestigationFinished = true;
464
- // ── Auto-trace reverse dependencies ──
465
- // When the Context Agent finalizes its investigation, we automatically
466
- // discover files that DEPEND ON the selected files. This ensures the
467
- // Execution Agent won't break imports when modifying/deleting/renaming.
468
- const MAX_TOTAL_FILES = 30;
533
+ // Auto-trace reverse dependencies for all selected files to include active importers
469
534
  try {
535
+ const { buildDependencyGraph } = await import('../utils/dependencyTracer.js');
470
536
  const { resolveAndValidateMultiWorkspacePath } = await import('../utils/pathSecurity.js');
471
537
  const autoDiscovered = new Set();
472
- for (const filePath of filesToRead) {
538
+ await Promise.all(filesToRead.map(async (filePath) => {
473
539
  try {
474
540
  const resolved = resolveAndValidateMultiWorkspacePath(workspaceRoot, filePath);
475
541
  const graph = await buildDependencyGraph(resolved.workspaceRoot);
476
542
  const reverseDeps = graph.getImportedBy(resolved.relativePath);
477
543
  for (const dep of reverseDeps) {
478
544
  const aliasedDep = resolved.alias ? `@${resolved.alias}/${dep}` : dep;
479
- if (!filesToRead.includes(aliasedDep) && !autoDiscovered.has(aliasedDep)) {
545
+ if (!filesToRead.includes(aliasedDep)) {
480
546
  autoDiscovered.add(aliasedDep);
481
547
  }
482
548
  }
483
549
  }
484
- catch (e) {
550
+ catch {
485
551
  // Ignore path resolution errors for trace dependencies
486
552
  }
487
- }
488
- // Merge auto-discovered dependents, respecting the file cap
553
+ }));
554
+ // Merge auto-discovered dependents, respecting both file count and token budget caps
489
555
  const remaining = MAX_TOTAL_FILES - relevantFiles.size;
556
+ const depsToRead = Array.from(autoDiscovered)
557
+ .filter((dep) => !relevantFiles.has(dep))
558
+ .slice(0, Math.max(0, remaining));
559
+ const depReadResults = await Promise.all(depsToRead.map(async (dep) => {
560
+ const readResult = await executeTool(workspaceRoot, 'read_file', { filePath: dep });
561
+ return { dep, readResult };
562
+ }));
490
563
  let added = 0;
491
- for (const dep of autoDiscovered) {
492
- if (added >= remaining)
564
+ for (const { dep, readResult } of depReadResults) {
565
+ if (relevantFiles.size >= MAX_TOTAL_FILES || cumulativeFileTokens >= MAX_TOTAL_FILE_TOKENS)
493
566
  break;
494
- if (!relevantFiles.has(dep)) {
495
- const readResult = await executeTool(workspaceRoot, 'read_file', { filePath: dep });
496
- if (!readResult.error) {
497
- relevantFiles.set(dep, { text: readResult.output, inlineData: readResult.inlineData });
498
- added++;
499
- }
567
+ if (!readResult.error) {
568
+ const boundedText = boundFileContent(dep, readResult.output);
569
+ relevantFiles.set(dep, { text: boundedText, inlineData: readResult.inlineData });
570
+ cumulativeFileTokens += estimateTokenCount(boundedText);
571
+ added++;
500
572
  }
501
573
  }
502
574
  if (added > 0) {
@@ -510,46 +582,50 @@ export async function gatherContext(workspaceRoot, userRequest, chatHistory = ''
510
582
  catch {
511
583
  // Dependency tracing is best-effort — don't block investigation
512
584
  }
513
- functionResponses.push({
585
+ return {
514
586
  functionResponse: {
515
587
  name: call.name,
516
588
  response: { output: 'Investigation finished.' },
517
589
  },
518
- });
519
- break;
590
+ };
520
591
  }
521
592
  else if (call.name === 'list_directory') {
522
593
  const listRes = await executeTool(workspaceRoot, 'list_directory', args);
523
- functionResponses.push({
594
+ const output = boundToolOutput(listRes.output);
595
+ return {
524
596
  functionResponse: {
525
597
  name: call.name,
526
598
  response: {
527
- output: listRes.output,
599
+ output,
528
600
  ...(listRes.error ? { error: listRes.error } : {}),
529
601
  },
530
602
  },
531
- });
603
+ };
532
604
  }
533
605
  else if (call.name === 'search_codebase') {
534
606
  const grepRes = await executeTool(workspaceRoot, 'grep_search', { ...args, workspace: 'all' });
535
- functionResponses.push({
607
+ const output = boundToolOutput(grepRes.error ? grepRes.error : grepRes.output);
608
+ return {
536
609
  functionResponse: {
537
610
  name: call.name,
538
- response: { output: grepRes.error ? grepRes.error : grepRes.output },
611
+ response: { output },
539
612
  },
540
- });
613
+ };
541
614
  }
542
615
  else if (call.name === 'read_file') {
543
616
  const readRes = await executeTool(workspaceRoot, 'read_file', args);
544
617
  if (!readRes.error) {
545
- relevantFiles.set(args.filePath, { text: readRes.output, inlineData: readRes.inlineData });
618
+ const boundedText = boundFileContent(args.filePath, readRes.output);
619
+ relevantFiles.set(args.filePath, { text: boundedText, inlineData: readRes.inlineData });
620
+ cumulativeFileTokens += estimateTokenCount(boundedText);
546
621
  }
547
- functionResponses.push({
622
+ const output = boundToolOutput(readRes.error ? readRes.error : readRes.output);
623
+ return {
548
624
  functionResponse: {
549
625
  name: call.name,
550
- response: { output: readRes.error ? readRes.error : readRes.output },
626
+ response: { output },
551
627
  },
552
- });
628
+ };
553
629
  }
554
630
  else if (call.name === 'perform_web_search') {
555
631
  try {
@@ -564,56 +640,64 @@ export async function gatherContext(workspaceRoot, userRequest, chatHistory = ''
564
640
  console.log(pc.dim(` [Web Search] ${webMsg}`));
565
641
  }
566
642
  const searchSummary = webResult.response.text()?.trim() || 'No relevant information found.';
567
- webSearchSummary += `\nQuery: ${args.query}\nFindings:\n${searchSummary}\n`;
568
- functionResponses.push({
643
+ const boundedSummary = boundToolOutput(searchSummary);
644
+ webSearchSummary += `\nQuery: ${args.query}\nFindings:\n${boundedSummary}\n`;
645
+ return {
569
646
  functionResponse: {
570
647
  name: call.name,
571
- response: { output: searchSummary },
648
+ response: { output: boundedSummary },
572
649
  },
573
- });
650
+ };
574
651
  }
575
652
  catch (e) {
576
- functionResponses.push({
653
+ return {
577
654
  functionResponse: {
578
655
  name: call.name,
579
656
  response: { error: e.message || 'Failed to search the web' },
580
657
  },
581
- });
658
+ };
582
659
  }
583
660
  }
584
661
  else if (call.name === 'find_dependencies') {
585
662
  const depResult = await executeTool(workspaceRoot, 'find_dependencies', args);
586
- functionResponses.push({
663
+ const output = boundToolOutput(depResult.error ? depResult.error : depResult.output);
664
+ return {
587
665
  functionResponse: {
588
666
  name: call.name,
589
- response: { output: depResult.error ? depResult.error : depResult.output },
667
+ response: { output },
590
668
  },
591
- });
669
+ };
592
670
  }
593
671
  else if (call.name === 'find_recent_changes') {
594
672
  const recentRes = await executeTool(workspaceRoot, 'find_recent_changes', args);
595
- functionResponses.push({
673
+ const output = boundToolOutput(recentRes.error ? recentRes.error : recentRes.output);
674
+ return {
596
675
  functionResponse: {
597
676
  name: call.name,
598
- response: { output: recentRes.error ? recentRes.error : recentRes.output },
677
+ response: { output },
599
678
  },
600
- });
679
+ };
601
680
  }
602
681
  else if (call.name === 'run_analysis_script') {
603
- const analysisResult = await runEphemeralScript(workspaceRoot, args.language, args.code, {
604
- abortSignal,
605
- });
606
- const output = analysisResult.exitCode === 0
682
+ const analysisResult = await runEphemeralScript(workspaceRoot, args.language, args.code, { abortSignal });
683
+ const rawOutput = analysisResult.exitCode === 0
607
684
  ? analysisResult.stdout || '(script produced no output)'
608
685
  : `Script failed (exit ${analysisResult.exitCode}):\n${analysisResult.stderr}`;
609
- functionResponses.push({
686
+ const output = boundToolOutput(rawOutput);
687
+ return {
610
688
  functionResponse: {
611
689
  name: call.name,
612
690
  response: { output },
613
691
  },
614
- });
692
+ };
615
693
  }
616
- }
694
+ return {
695
+ functionResponse: {
696
+ name: call.name,
697
+ response: { output: `Unknown tool: ${call.name}` },
698
+ },
699
+ };
700
+ }));
617
701
  if (isFinished) {
618
702
  break;
619
703
  }