minovative-mind-cli 2.14.1 → 2.14.3

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 (47) hide show
  1. package/README.md +40 -59
  2. package/dist/services/agent/slashCommands.js +144 -13
  3. package/dist/services/agent/toolLoop.js +11 -2
  4. package/dist/services/agent/types.d.ts +9 -2
  5. package/dist/services/agent-tools.d.ts +20 -1
  6. package/dist/services/agent-tools.js +272 -47
  7. package/dist/services/agent.js +43 -18
  8. package/dist/services/ai.d.ts +9 -0
  9. package/dist/services/ai.js +71 -21
  10. package/dist/services/chatHistoryService.d.ts +5 -0
  11. package/dist/services/contextAgent.d.ts +14 -6
  12. package/dist/services/contextAgent.js +36 -10
  13. package/dist/services/orchestration/investigationAgent.js +31 -7
  14. package/dist/services/orchestration/investigationCache.js +19 -9
  15. package/dist/services/orchestration/readCache.d.ts +1 -0
  16. package/dist/services/orchestration/readCache.js +5 -2
  17. package/dist/services/orchestration/scopedTools.js +37 -10
  18. package/dist/services/orchestration/subAgent.js +16 -2
  19. package/dist/services/proxyClient.d.ts +6 -0
  20. package/dist/services/proxyClient.js +24 -10
  21. package/dist/services/sessionSettings.d.ts +66 -0
  22. package/dist/services/sessionSettings.js +126 -0
  23. package/dist/services/userProfileService.d.ts +14 -0
  24. package/dist/services/userProfileService.js +105 -3
  25. package/dist/services/verificationService.js +24 -2
  26. package/dist/utils/analysisRunner.d.ts +120 -8
  27. package/dist/utils/analysisRunner.js +946 -125
  28. package/dist/utils/antiCheatingGuard.d.ts +21 -0
  29. package/dist/utils/antiCheatingGuard.js +554 -0
  30. package/dist/utils/contextPrompts.d.ts +39 -0
  31. package/dist/utils/contextPrompts.js +81 -9
  32. package/dist/utils/contextRanker.d.ts +216 -0
  33. package/dist/utils/contextRanker.js +603 -0
  34. package/dist/utils/dependencyTracer/modules/graph.d.ts +4 -1
  35. package/dist/utils/dependencyTracer/modules/graph.js +11 -0
  36. package/dist/utils/dependencyTracer/modules/types.d.ts +10 -0
  37. package/dist/utils/dependencyTracer.d.ts +40 -2
  38. package/dist/utils/dependencyTracer.js +95 -3
  39. package/dist/utils/fileReadCache.d.ts +58 -0
  40. package/dist/utils/fileReadCache.js +162 -0
  41. package/dist/utils/projectStorage.js +2 -1
  42. package/dist/utils/symbolExtractor.d.ts +12 -0
  43. package/dist/utils/symbolExtractor.js +111 -15
  44. package/dist/utils/systemPrompts.d.ts +4 -3
  45. package/dist/utils/systemPrompts.js +66 -8
  46. package/oclif.manifest.json +1 -1
  47. package/package.json +1 -1
@@ -1,9 +1,28 @@
1
+ export declare function invalidateDependencyGraph(workspaceRoot?: string): void;
2
+ export declare function clearDependencyGraphCache(): void;
3
+ export declare function getDependencyGraphCacheStats(): Readonly<{
4
+ hits: number;
5
+ misses: number;
6
+ invalidations: number;
7
+ cachedWorkspaces: number;
8
+ }>;
9
+ export declare function resetDependencyGraphCacheStats(): void;
1
10
  export interface DependencyNode {
2
11
  /** Files this file directly imports (forward/downstream) */
3
12
  imports: Set<string>;
4
13
  /** Files that directly import this file (reverse/upstream) */
5
14
  importedBy: Set<string>;
6
15
  }
16
+ export interface CentralityMetrics {
17
+ /** Number of files that directly import this file (hub dependency) */
18
+ inDegree: number;
19
+ /** Number of files this file directly imports */
20
+ outDegree: number;
21
+ /** Total direct connections (inDegree + outDegree) */
22
+ totalDegree: number;
23
+ /** Centrality score (weighted: inDegree * 1.5 + outDegree * 0.5) */
24
+ score: number;
25
+ }
7
26
  export interface DependencyGraph {
8
27
  /** What files does `filePath` directly import? */
9
28
  getImports(filePath: string): string[];
@@ -13,6 +32,10 @@ export interface DependencyGraph {
13
32
  getReverseDependencyTree(filePath: string, maxDepth?: number): string[];
14
33
  /** Transitive: all files reachable via import chains, up to maxDepth */
15
34
  getForwardDependencyTree(filePath: string, maxDepth?: number): string[];
35
+ /** Centrality metrics for a specific file */
36
+ getCentrality(filePath: string): CentralityMetrics;
37
+ /** Centrality metrics across all indexed files in the graph */
38
+ getAllCentrality(): Map<string, CentralityMetrics>;
16
39
  /** The full raw graph for inspection */
17
40
  readonly nodes: ReadonlyMap<string, DependencyNode>;
18
41
  }
@@ -21,9 +44,13 @@ export interface DependencyGraph {
21
44
  *
22
45
  * Performance: This is pure regex + filesystem walking — no AST parsing.
23
46
  * For a typical project (<5,000 source files), this completes in <1s.
24
- * The graph is ephemeral: built once per `gatherContext` call and discarded.
47
+ * The graph is cached in-memory per workspaceRoot and automatically reused across
48
+ * subsequent queries until invalidated by file modifications.
49
+ *
50
+ * @param workspaceRoot - Root directory of the target project workspace
51
+ * @param forceRebuild - If true, bypasses the in-memory cache and forces a full rescan
25
52
  */
26
- export declare function buildDependencyGraph(workspaceRoot: string): Promise<DependencyGraph>;
53
+ export declare function buildDependencyGraph(workspaceRoot: string, forceRebuild?: boolean): Promise<DependencyGraph>;
27
54
  export interface FindDependenciesResult {
28
55
  filePath: string;
29
56
  forwardDeps: string[];
@@ -42,6 +69,17 @@ export declare function findDependencies(workspaceRoot: string, filePath: string
42
69
  * suitable for feeding back to the LLM.
43
70
  */
44
71
  export declare function formatDependencyResult(result: FindDependenciesResult): string;
72
+ /**
73
+ * Calculates graph centrality metrics for all nodes in a dependency graph.
74
+ */
75
+ export declare function computeGraphCentrality(nodes: ReadonlyMap<string, DependencyNode> | Map<string, DependencyNode>): Map<string, CentralityMetrics>;
76
+ /**
77
+ * Returns top dependency hub files sorted descending by centrality score.
78
+ */
79
+ export declare function getHubFiles(graph: DependencyGraph, limit?: number): Array<{
80
+ filePath: string;
81
+ metrics: CentralityMetrics;
82
+ }>;
45
83
  /**
46
84
  * Resets the tsconfig alias cache (useful between workspace changes).
47
85
  */
@@ -1,6 +1,47 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { EXCLUDED_EXTENSIONS } from './excludedExtensions.js';
4
+ import { debugLog } from './logger.js';
5
+ // ─── Dependency Graph Cache ──────────────────────────────────────────
6
+ const dependencyGraphCache = new Map();
7
+ const dependencyGraphCacheStats = {
8
+ hits: 0,
9
+ misses: 0,
10
+ invalidations: 0,
11
+ };
12
+ function normalizeWorkspaceKey(workspaceRoot) {
13
+ const resolved = path.resolve(workspaceRoot).replace(/\\/g, '/');
14
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
15
+ }
16
+ export function invalidateDependencyGraph(workspaceRoot) {
17
+ if (workspaceRoot) {
18
+ const key = normalizeWorkspaceKey(workspaceRoot);
19
+ if (dependencyGraphCache.delete(key)) {
20
+ dependencyGraphCacheStats.invalidations++;
21
+ debugLog(`[DependencyTracer] Invalidated graph cache for workspace: ${workspaceRoot}`);
22
+ }
23
+ }
24
+ else {
25
+ const count = dependencyGraphCache.size;
26
+ dependencyGraphCache.clear();
27
+ dependencyGraphCacheStats.invalidations += count;
28
+ debugLog(`[DependencyTracer] Cleared graph cache for all workspaces (${count})`);
29
+ }
30
+ }
31
+ export function clearDependencyGraphCache() {
32
+ invalidateDependencyGraph();
33
+ }
34
+ export function getDependencyGraphCacheStats() {
35
+ return {
36
+ ...dependencyGraphCacheStats,
37
+ cachedWorkspaces: dependencyGraphCache.size,
38
+ };
39
+ }
40
+ export function resetDependencyGraphCacheStats() {
41
+ dependencyGraphCacheStats.hits = 0;
42
+ dependencyGraphCacheStats.misses = 0;
43
+ dependencyGraphCacheStats.invalidations = 0;
44
+ }
4
45
  // ─── Language Profile Registry ───────────────────────────────────────
5
46
  //
6
47
  // Each profile defines regex patterns that extract import specifiers for
@@ -503,9 +544,24 @@ async function walkWorkspace(workspaceRoot) {
503
544
  *
504
545
  * Performance: This is pure regex + filesystem walking — no AST parsing.
505
546
  * For a typical project (<5,000 source files), this completes in <1s.
506
- * The graph is ephemeral: built once per `gatherContext` call and discarded.
547
+ * The graph is cached in-memory per workspaceRoot and automatically reused across
548
+ * subsequent queries until invalidated by file modifications.
549
+ *
550
+ * @param workspaceRoot - Root directory of the target project workspace
551
+ * @param forceRebuild - If true, bypasses the in-memory cache and forces a full rescan
507
552
  */
508
- export async function buildDependencyGraph(workspaceRoot) {
553
+ export async function buildDependencyGraph(workspaceRoot, forceRebuild = false) {
554
+ const key = normalizeWorkspaceKey(workspaceRoot);
555
+ if (!forceRebuild) {
556
+ const cached = dependencyGraphCache.get(key);
557
+ if (cached) {
558
+ dependencyGraphCacheStats.hits++;
559
+ debugLog(`[DependencyTracer] Graph cache HIT for ${workspaceRoot} (${cached.graph.nodes.size} nodes)`);
560
+ return cached.graph;
561
+ }
562
+ }
563
+ dependencyGraphCacheStats.misses++;
564
+ debugLog(`[DependencyTracer] Graph cache MISS, building graph for ${workspaceRoot}`);
509
565
  const nodes = new Map();
510
566
  function getOrCreate(filePath) {
511
567
  let node = nodes.get(filePath);
@@ -540,7 +596,7 @@ export async function buildDependencyGraph(workspaceRoot) {
540
596
  }
541
597
  }));
542
598
  // 3. Return the graph with query methods
543
- return {
599
+ const graph = {
544
600
  nodes,
545
601
  getImports(filePath) {
546
602
  return Array.from(nodes.get(filePath)?.imports || []);
@@ -554,7 +610,20 @@ export async function buildDependencyGraph(workspaceRoot) {
554
610
  getForwardDependencyTree(filePath, maxDepth = 3) {
555
611
  return bfsTraverse(nodes, filePath, 'imports', maxDepth);
556
612
  },
613
+ getCentrality(filePath) {
614
+ const node = nodes.get(filePath);
615
+ const inDegree = node?.importedBy.size ?? 0;
616
+ const outDegree = node?.imports.size ?? 0;
617
+ const totalDegree = inDegree + outDegree;
618
+ const score = inDegree * 1.5 + outDegree * 0.5;
619
+ return { inDegree, outDegree, totalDegree, score };
620
+ },
621
+ getAllCentrality() {
622
+ return computeGraphCentrality(nodes);
623
+ },
557
624
  };
625
+ dependencyGraphCache.set(key, { graph, timestamp: Date.now() });
626
+ return graph;
558
627
  }
559
628
  /**
560
629
  * BFS traversal of the dependency graph in a given direction.
@@ -652,6 +721,29 @@ export function formatDependencyResult(result) {
652
721
  lines.push(`Total impact radius: ${totalImpact} file(s)`);
653
722
  return lines.join('\n');
654
723
  }
724
+ /**
725
+ * Calculates graph centrality metrics for all nodes in a dependency graph.
726
+ */
727
+ export function computeGraphCentrality(nodes) {
728
+ const result = new Map();
729
+ for (const [file, node] of nodes.entries()) {
730
+ const inDegree = node.importedBy.size;
731
+ const outDegree = node.imports.size;
732
+ const totalDegree = inDegree + outDegree;
733
+ const score = inDegree * 1.5 + outDegree * 0.5;
734
+ result.set(file, { inDegree, outDegree, totalDegree, score });
735
+ }
736
+ return result;
737
+ }
738
+ /**
739
+ * Returns top dependency hub files sorted descending by centrality score.
740
+ */
741
+ export function getHubFiles(graph, limit) {
742
+ const allCentrality = graph.getAllCentrality();
743
+ const entries = Array.from(allCentrality.entries()).map(([filePath, metrics]) => ({ filePath, metrics }));
744
+ entries.sort((a, b) => b.metrics.score - a.metrics.score || b.metrics.inDegree - a.metrics.inDegree);
745
+ return typeof limit === 'number' && limit > 0 ? entries.slice(0, limit) : entries;
746
+ }
655
747
  /**
656
748
  * Resets the tsconfig alias cache (useful between workspace changes).
657
749
  */
@@ -0,0 +1,58 @@
1
+ export interface CachedFileEntry {
2
+ content: string;
3
+ mtimeMs: number;
4
+ size: number;
5
+ lastAccessed: number;
6
+ byteLength: number;
7
+ }
8
+ export interface FileReadCacheStats {
9
+ hits: number;
10
+ misses: number;
11
+ invalidations: number;
12
+ currentEntries: number;
13
+ currentBytes: number;
14
+ }
15
+ /**
16
+ * Normalizes a file path to create a deterministic, cross-platform cache key.
17
+ * Resolves relative segments, standardizes to forward slashes, and on Windows
18
+ * lowercases the path to guarantee case-insensitive parity.
19
+ *
20
+ * @param filePath - The absolute or relative file path to normalize.
21
+ * @returns Standardized cache key string.
22
+ */
23
+ export declare function normalizeCacheKey(filePath: string): string;
24
+ /**
25
+ * Retrieves file content with in-turn caching and strict mtime validation.
26
+ *
27
+ * Behavior:
28
+ * 1. Checks `fs.stat(absPath)`.
29
+ * 2. If file exceeds 1MB, reads from disk directly without polluting memory cache.
30
+ * 3. If cached with identical `mtimeMs` and `size`, returns the cached string ($O(1)$ disk avoidance).
31
+ * 4. If modified or uncached, reads from disk, stores in cache with LRU eviction, and returns.
32
+ *
33
+ * @param absPath - Absolute path to the file on disk.
34
+ * @returns The string content of the file.
35
+ */
36
+ export declare function getCachedFileContent(absPath: string): Promise<string>;
37
+ /**
38
+ * Invalidates the cached entry for a specific file path.
39
+ * Must be invoked whenever `modify_file`, `write_file`, `delete_file`, or `rename_file`
40
+ * modifies the target on disk.
41
+ *
42
+ * @param filePath - The file path that was modified.
43
+ */
44
+ export declare function invalidateFileReadCache(filePath: string): void;
45
+ /**
46
+ * Completely clears the in-memory file read cache.
47
+ * Must be invoked when arbitrary shell commands run (e.g. `npm run build`, `git checkout`),
48
+ * or when `/revert` restores previous file trees.
49
+ */
50
+ export declare function clearFileReadCache(): void;
51
+ /**
52
+ * Returns diagnostic statistics for the file read cache.
53
+ */
54
+ export declare function getFileReadCacheStats(): Readonly<FileReadCacheStats>;
55
+ /**
56
+ * Resets the telemetry counters (useful for unit tests).
57
+ */
58
+ export declare function resetFileReadCacheStats(): void;
@@ -0,0 +1,162 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { debugLog } from './logger.js';
4
+ /**
5
+ * Maximum number of distinct files to keep in the in-memory read cache.
6
+ */
7
+ const MAX_CACHED_FILES = 200;
8
+ /**
9
+ * Maximum aggregate memory size (in bytes) allowed for cached file contents (15MB).
10
+ */
11
+ const MAX_TOTAL_BYTES = 15 * 1024 * 1024;
12
+ /**
13
+ * Single file size ceiling (1MB). Files larger than this bypass the in-memory
14
+ * cache to prevent memory bloat from huge generated assets.
15
+ */
16
+ const MAX_SINGLE_FILE_BYTES = 1 * 1024 * 1024;
17
+ const cache = new Map();
18
+ let currentTotalBytes = 0;
19
+ const stats = {
20
+ hits: 0,
21
+ misses: 0,
22
+ invalidations: 0,
23
+ currentEntries: 0,
24
+ currentBytes: 0,
25
+ };
26
+ /**
27
+ * Normalizes a file path to create a deterministic, cross-platform cache key.
28
+ * Resolves relative segments, standardizes to forward slashes, and on Windows
29
+ * lowercases the path to guarantee case-insensitive parity.
30
+ *
31
+ * @param filePath - The absolute or relative file path to normalize.
32
+ * @returns Standardized cache key string.
33
+ */
34
+ export function normalizeCacheKey(filePath) {
35
+ const resolved = path.resolve(filePath).replace(/\\/g, '/');
36
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
37
+ }
38
+ /**
39
+ * Evicts the least recently accessed cache entries until the cache satisfies
40
+ * both the max file count and max byte size constraints.
41
+ */
42
+ function evictOldestEntries(extraBytesNeeded = 0) {
43
+ while ((cache.size >= MAX_CACHED_FILES || currentTotalBytes + extraBytesNeeded > MAX_TOTAL_BYTES) && cache.size > 0) {
44
+ let oldestKey = null;
45
+ let oldestTime = Infinity;
46
+ for (const [key, entry] of cache.entries()) {
47
+ if (entry.lastAccessed < oldestTime) {
48
+ oldestTime = entry.lastAccessed;
49
+ oldestKey = key;
50
+ }
51
+ }
52
+ if (!oldestKey)
53
+ break;
54
+ const evicted = cache.get(oldestKey);
55
+ if (evicted) {
56
+ currentTotalBytes -= evicted.byteLength;
57
+ }
58
+ cache.delete(oldestKey);
59
+ debugLog(`[FileReadCache] Evicted LRU entry: ${oldestKey}`);
60
+ }
61
+ stats.currentEntries = cache.size;
62
+ stats.currentBytes = currentTotalBytes;
63
+ }
64
+ /**
65
+ * Retrieves file content with in-turn caching and strict mtime validation.
66
+ *
67
+ * Behavior:
68
+ * 1. Checks `fs.stat(absPath)`.
69
+ * 2. If file exceeds 1MB, reads from disk directly without polluting memory cache.
70
+ * 3. If cached with identical `mtimeMs` and `size`, returns the cached string ($O(1)$ disk avoidance).
71
+ * 4. If modified or uncached, reads from disk, stores in cache with LRU eviction, and returns.
72
+ *
73
+ * @param absPath - Absolute path to the file on disk.
74
+ * @returns The string content of the file.
75
+ */
76
+ export async function getCachedFileContent(absPath) {
77
+ const stat = await fs.stat(absPath);
78
+ if (stat.size > MAX_SINGLE_FILE_BYTES) {
79
+ debugLog(`[FileReadCache] Bypassing cache for large file (${stat.size} bytes > 1MB): ${absPath}`);
80
+ stats.misses++;
81
+ return fs.readFile(absPath, 'utf-8');
82
+ }
83
+ const key = normalizeCacheKey(absPath);
84
+ const existing = cache.get(key);
85
+ if (existing && existing.mtimeMs === stat.mtimeMs && existing.size === stat.size) {
86
+ existing.lastAccessed = Date.now();
87
+ stats.hits++;
88
+ debugLog(`[FileReadCache] HIT (${stat.size} bytes): ${absPath}`);
89
+ return existing.content;
90
+ }
91
+ stats.misses++;
92
+ debugLog(`[FileReadCache] MISS (reading disk): ${absPath}`);
93
+ const content = await fs.readFile(absPath, 'utf-8');
94
+ const byteLength = Buffer.byteLength(content, 'utf8');
95
+ if (existing) {
96
+ currentTotalBytes -= existing.byteLength;
97
+ }
98
+ evictOldestEntries(byteLength);
99
+ cache.set(key, {
100
+ content,
101
+ mtimeMs: stat.mtimeMs,
102
+ size: stat.size,
103
+ lastAccessed: Date.now(),
104
+ byteLength,
105
+ });
106
+ currentTotalBytes += byteLength;
107
+ stats.currentEntries = cache.size;
108
+ stats.currentBytes = currentTotalBytes;
109
+ return content;
110
+ }
111
+ /**
112
+ * Invalidates the cached entry for a specific file path.
113
+ * Must be invoked whenever `modify_file`, `write_file`, `delete_file`, or `rename_file`
114
+ * modifies the target on disk.
115
+ *
116
+ * @param filePath - The file path that was modified.
117
+ */
118
+ export function invalidateFileReadCache(filePath) {
119
+ const key = normalizeCacheKey(filePath);
120
+ const existing = cache.get(key);
121
+ if (existing) {
122
+ currentTotalBytes -= existing.byteLength;
123
+ cache.delete(key);
124
+ stats.invalidations++;
125
+ stats.currentEntries = cache.size;
126
+ stats.currentBytes = currentTotalBytes;
127
+ debugLog(`[FileReadCache] INVALIDATED entry: ${filePath}`);
128
+ }
129
+ }
130
+ /**
131
+ * Completely clears the in-memory file read cache.
132
+ * Must be invoked when arbitrary shell commands run (e.g. `npm run build`, `git checkout`),
133
+ * or when `/revert` restores previous file trees.
134
+ */
135
+ export function clearFileReadCache() {
136
+ const count = cache.size;
137
+ cache.clear();
138
+ currentTotalBytes = 0;
139
+ stats.currentEntries = 0;
140
+ stats.currentBytes = 0;
141
+ debugLog(`[FileReadCache] CLEARED ${count} entries from cache`);
142
+ }
143
+ /**
144
+ * Returns diagnostic statistics for the file read cache.
145
+ */
146
+ export function getFileReadCacheStats() {
147
+ return {
148
+ ...stats,
149
+ currentEntries: cache.size,
150
+ currentBytes: currentTotalBytes,
151
+ };
152
+ }
153
+ /**
154
+ * Resets the telemetry counters (useful for unit tests).
155
+ */
156
+ export function resetFileReadCacheStats() {
157
+ stats.hits = 0;
158
+ stats.misses = 0;
159
+ stats.invalidations = 0;
160
+ stats.currentEntries = cache.size;
161
+ stats.currentBytes = currentTotalBytes;
162
+ }
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import * as pc from 'picocolors';
4
- import { buildDependencyGraph } from './dependencyTracer.js';
4
+ import { buildDependencyGraph, invalidateDependencyGraph } from './dependencyTracer.js';
5
5
  /**
6
6
  * Gets the path to the project's .minovativemind storage directory.
7
7
  */
@@ -236,6 +236,7 @@ export async function invalidateCacheForDependents(workspaceRoot, changedFiles)
236
236
  if (updated) {
237
237
  writeCache(workspaceRoot, 'context_cache.json', cachedContext);
238
238
  }
239
+ invalidateDependencyGraph(workspaceRoot);
239
240
  try {
240
241
  const { invalidateFilesFromInvestigationCache } = await import('../services/orchestration/investigationCache.js');
241
242
  await invalidateFilesFromInvestigationCache(workspaceRoot, changedFiles);
@@ -1,3 +1,15 @@
1
+ export interface SymbolExtractorCacheStats {
2
+ outlineHits: number;
3
+ outlineMisses: number;
4
+ symbolsHits: number;
5
+ symbolsMisses: number;
6
+ indexHits: number;
7
+ indexMisses: number;
8
+ size: number;
9
+ }
10
+ export declare function clearSymbolExtractorCache(): void;
11
+ export declare function getSymbolExtractorCacheStats(): Readonly<SymbolExtractorCacheStats>;
12
+ export declare function resetSymbolExtractorCacheStats(): void;
1
13
  /**
2
14
  * Supported classification kinds for extracted AST symbols and definition chunks.
3
15
  */
@@ -1,4 +1,57 @@
1
1
  import * as path from 'node:path';
2
+ import crypto from 'node:crypto';
3
+ // ─── AST Symbol Cache ────────────────────────────────────────────────
4
+ const MAX_SYMBOL_CACHE_ENTRIES = 1000;
5
+ const symbolCache = new Map();
6
+ const symbolCacheStats = {
7
+ outlineHits: 0,
8
+ outlineMisses: 0,
9
+ symbolsHits: 0,
10
+ symbolsMisses: 0,
11
+ indexHits: 0,
12
+ indexMisses: 0,
13
+ };
14
+ function hashContent(content) {
15
+ return crypto.createHash('sha256').update(content).digest('hex').substring(0, 16);
16
+ }
17
+ function getFromSymbolCache(key) {
18
+ if (!symbolCache.has(key))
19
+ return undefined;
20
+ const value = symbolCache.get(key);
21
+ // Refresh LRU order
22
+ symbolCache.delete(key);
23
+ symbolCache.set(key, value);
24
+ return value;
25
+ }
26
+ function setToSymbolCache(key, value) {
27
+ if (symbolCache.has(key)) {
28
+ symbolCache.delete(key);
29
+ }
30
+ else if (symbolCache.size >= MAX_SYMBOL_CACHE_ENTRIES) {
31
+ const oldestKey = symbolCache.keys().next().value;
32
+ if (oldestKey !== undefined) {
33
+ symbolCache.delete(oldestKey);
34
+ }
35
+ }
36
+ symbolCache.set(key, value);
37
+ }
38
+ export function clearSymbolExtractorCache() {
39
+ symbolCache.clear();
40
+ }
41
+ export function getSymbolExtractorCacheStats() {
42
+ return {
43
+ ...symbolCacheStats,
44
+ size: symbolCache.size,
45
+ };
46
+ }
47
+ export function resetSymbolExtractorCacheStats() {
48
+ symbolCacheStats.outlineHits = 0;
49
+ symbolCacheStats.outlineMisses = 0;
50
+ symbolCacheStats.symbolsHits = 0;
51
+ symbolCacheStats.symbolsMisses = 0;
52
+ symbolCacheStats.indexHits = 0;
53
+ symbolCacheStats.indexMisses = 0;
54
+ }
2
55
  /**
3
56
  * Estimates token consumption for a given string using a standard ~3.8 characters per token heuristic.
4
57
  *
@@ -204,8 +257,14 @@ function findPrecedingContext(lines, declarationIndex, ext) {
204
257
  const isDecorator = prevLine.startsWith('@');
205
258
  const isPythonComment = (ext === '.py' || ext === '.pyi') && prevLine.startsWith('#');
206
259
  const isLineComment = prevLine.startsWith('//');
207
- const isRustDocOrAttr = (ext === '.rs') && (prevLine.startsWith('///') || prevLine.startsWith('//!') || prevLine.startsWith('#['));
208
- if (isDecorator || isPythonComment || isLineComment || isJsDocEnd || isJsDocLine || isJsDocStart || isRustDocOrAttr) {
260
+ const isRustDocOrAttr = ext === '.rs' && (prevLine.startsWith('///') || prevLine.startsWith('//!') || prevLine.startsWith('#['));
261
+ if (isDecorator ||
262
+ isPythonComment ||
263
+ isLineComment ||
264
+ isJsDocEnd ||
265
+ isJsDocLine ||
266
+ isJsDocStart ||
267
+ isRustDocOrAttr) {
209
268
  start--;
210
269
  if (isJsDocStart) {
211
270
  insideJsDoc = false;
@@ -227,7 +286,8 @@ function findPrecedingContext(lines, declarationIndex, ext) {
227
286
  * @returns True if the line contains valid code characters outside comments/strings
228
287
  */
229
288
  function scanLineValidity(line, state) {
230
- let j = 0, lineHasValidCode = false;
289
+ let j = 0;
290
+ let lineHasValidCode = false;
231
291
  while (j < line.length) {
232
292
  if (!state.inMultiLineComment && state.inMultiLineString) {
233
293
  if (line[j] === '\\') {
@@ -357,7 +417,11 @@ function findBlockEnd(lines, i, ext) {
357
417
  processLineChars(lines[j], parseState);
358
418
  if (parseState.foundOpen && parseState.braces === 0 && parseState.brackets === 0 && parseState.parens === 0)
359
419
  break;
360
- if (!parseState.foundOpen && j > i && parseState.braces === 0 && parseState.brackets === 0 && parseState.parens === 0) {
420
+ if (!parseState.foundOpen &&
421
+ j > i &&
422
+ parseState.braces === 0 &&
423
+ parseState.brackets === 0 &&
424
+ parseState.parens === 0) {
361
425
  const prevLine = lines[j - 1].trim();
362
426
  if (!prevLine.endsWith(',') && !prevLine.endsWith('.')) {
363
427
  endIndex = j - 1;
@@ -421,7 +485,8 @@ export function extractSymbolMetadata(content, filePath, targetElements) {
421
485
  for (let i = 0; i < lines.length; i++) {
422
486
  const startsInMulti = scanState.inMultiLineComment || scanState.inMultiLineString !== null;
423
487
  const lineHasValidCode = scanLineValidity(lines[i], scanState);
424
- if (startsInMulti || (!lineHasValidCode && (scanState.inMultiLineComment || scanState.inMultiLineString !== null))) {
488
+ if (startsInMulti ||
489
+ (!lineHasValidCode && (scanState.inMultiLineComment || scanState.inMultiLineString !== null))) {
425
490
  validLines[i] = false;
426
491
  }
427
492
  }
@@ -447,7 +512,6 @@ export function extractSymbolMetadata(content, filePath, targetElements) {
447
512
  // 1. If target specified a parent (e.g. Class.method)
448
513
  if (symTarget.parentName) {
449
514
  if (symTarget.regex.test(line)) {
450
- const parentStartIndex = findPrecedingContext(lines, i, ext);
451
515
  const parentEndIndex = findBlockEnd(lines, i, ext);
452
516
  // Scan inside the parent body for the member
453
517
  for (let m = i + 1; m <= parentEndIndex; m++) {
@@ -513,6 +577,13 @@ export function extractSymbolMetadata(content, filePath, targetElements) {
513
577
  export function extractSymbolIndex(content, filePath) {
514
578
  if (!content || !content.trim())
515
579
  return [];
580
+ const key = `index:${filePath}:${hashContent(content)}`;
581
+ const cached = getFromSymbolCache(key);
582
+ if (cached !== undefined) {
583
+ symbolCacheStats.indexHits++;
584
+ return cached;
585
+ }
586
+ symbolCacheStats.indexMisses++;
516
587
  const ext = path.extname(filePath).toLowerCase();
517
588
  const lines = content.split('\n');
518
589
  const symbols = [];
@@ -826,6 +897,7 @@ export function extractSymbolIndex(content, filePath) {
826
897
  }
827
898
  i++;
828
899
  }
900
+ setToSymbolCache(key, symbols);
829
901
  return symbols;
830
902
  }
831
903
  /**
@@ -895,12 +967,19 @@ export function chunkDefinitions(content, filePath, options) {
895
967
  export function extractSymbols(content, filePath, targetElements, options) {
896
968
  if (!targetElements || targetElements.length === 0)
897
969
  return content;
898
- const ext = path.extname(filePath).toLowerCase();
970
+ const sortedTargets = targetElements.slice().sort().join(',');
971
+ const key = `symbols:${filePath}:${hashContent(content)}:${sortedTargets}:${options?.maxLinesPerSymbol ?? ''}`;
972
+ const cached = getFromSymbolCache(key);
973
+ if (cached !== undefined) {
974
+ symbolCacheStats.symbolsHits++;
975
+ return cached;
976
+ }
977
+ symbolCacheStats.symbolsMisses++;
899
978
  const lines = content.split('\n');
900
979
  const linesToKeep = new Set();
901
980
  const metadata = extractSymbolMetadata(content, filePath, targetElements);
902
981
  for (const meta of metadata) {
903
- let start = meta.startLine;
982
+ const start = meta.startLine;
904
983
  let end = meta.endLine;
905
984
  if (options?.maxLinesPerSymbol && end - start + 1 > options.maxLinesPerSymbol) {
906
985
  end = start + options.maxLinesPerSymbol - 1;
@@ -919,7 +998,9 @@ export function extractSymbols(content, filePath, targetElements, options) {
919
998
  output.push(lines[lineNum]);
920
999
  previousLineNum = lineNum;
921
1000
  }
922
- return output.join('\n');
1001
+ const result = output.join('\n');
1002
+ setToSymbolCache(key, result);
1003
+ return result;
923
1004
  }
924
1005
  /**
925
1006
  * Extracts a compact declarations outline for TypeScript/JavaScript source files.
@@ -1312,7 +1393,7 @@ function extractPythonOutline(lines) {
1312
1393
  if (cTrim.startsWith('"""') || cTrim.startsWith("'''")) {
1313
1394
  const delim = cTrim.startsWith('"""') ? '"""' : "'''";
1314
1395
  output.push(cLine);
1315
- if (cTrim.length > 3 && cTrim.endsWith(delim) && cTrim.indexOf(delim, 3) !== -1) {
1396
+ if (cTrim.length > 3 && cTrim.endsWith(delim) && cTrim.includes(delim, 3)) {
1316
1397
  // single line docstring
1317
1398
  }
1318
1399
  else {
@@ -1844,8 +1925,16 @@ function extractRustOutline(lines) {
1844
1925
  export function extractDeclarationsOutline(content, filePath) {
1845
1926
  if (!content || !content.trim())
1846
1927
  return '';
1928
+ const key = `outline:${filePath}:${hashContent(content)}`;
1929
+ const cached = getFromSymbolCache(key);
1930
+ if (cached !== undefined) {
1931
+ symbolCacheStats.outlineHits++;
1932
+ return cached;
1933
+ }
1934
+ symbolCacheStats.outlineMisses++;
1847
1935
  const ext = path.extname(filePath).toLowerCase();
1848
1936
  const lines = content.split('\n');
1937
+ let result = '';
1849
1938
  switch (ext) {
1850
1939
  case '.ts':
1851
1940
  case '.tsx':
@@ -1855,15 +1944,22 @@ export function extractDeclarationsOutline(content, filePath) {
1855
1944
  case '.cjs':
1856
1945
  case '.mts':
1857
1946
  case '.cts':
1858
- return extractTsJsOutline(lines);
1947
+ result = extractTsJsOutline(lines);
1948
+ break;
1859
1949
  case '.py':
1860
1950
  case '.pyi':
1861
- return extractPythonOutline(lines);
1951
+ result = extractPythonOutline(lines);
1952
+ break;
1862
1953
  case '.go':
1863
- return extractGoOutline(lines);
1954
+ result = extractGoOutline(lines);
1955
+ break;
1864
1956
  case '.rs':
1865
- return extractRustOutline(lines);
1957
+ result = extractRustOutline(lines);
1958
+ break;
1866
1959
  default:
1867
- return content.trim();
1960
+ result = content.trim();
1961
+ break;
1868
1962
  }
1963
+ setToSymbolCache(key, result);
1964
+ return result;
1869
1965
  }