feat(context): Introduce smart context system: analyzer, lazy loader, cache and README/docs improvements
This commit is contained in:
391
ts/context/context-analyzer.ts
Normal file
391
ts/context/context-analyzer.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type {
|
||||
IFileMetadata,
|
||||
IFileDependencies,
|
||||
IFileAnalysis,
|
||||
IAnalysisResult,
|
||||
TaskType,
|
||||
IPrioritizationWeights,
|
||||
ITierConfig,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* ContextAnalyzer provides intelligent file selection and prioritization
|
||||
* based on dependency analysis, task relevance, and configurable weights
|
||||
*/
|
||||
export class ContextAnalyzer {
|
||||
private projectRoot: string;
|
||||
private weights: Required<IPrioritizationWeights>;
|
||||
private tiers: Required<ITierConfig>;
|
||||
|
||||
/**
|
||||
* Creates a new ContextAnalyzer
|
||||
* @param projectRoot - Root directory of the project
|
||||
* @param weights - Prioritization weights
|
||||
* @param tiers - Tier configuration
|
||||
*/
|
||||
constructor(
|
||||
projectRoot: string,
|
||||
weights: Partial<IPrioritizationWeights> = {},
|
||||
tiers: Partial<ITierConfig> = {}
|
||||
) {
|
||||
this.projectRoot = projectRoot;
|
||||
|
||||
// Default weights
|
||||
this.weights = {
|
||||
dependencyWeight: weights.dependencyWeight ?? 0.3,
|
||||
relevanceWeight: weights.relevanceWeight ?? 0.4,
|
||||
efficiencyWeight: weights.efficiencyWeight ?? 0.2,
|
||||
recencyWeight: weights.recencyWeight ?? 0.1,
|
||||
};
|
||||
|
||||
// Default tiers
|
||||
this.tiers = {
|
||||
essential: tiers.essential ?? { minScore: 0.8, trimLevel: 'none' },
|
||||
important: tiers.important ?? { minScore: 0.5, trimLevel: 'light' },
|
||||
optional: tiers.optional ?? { minScore: 0.2, trimLevel: 'aggressive' },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes files for a specific task type
|
||||
* @param metadata - Array of file metadata to analyze
|
||||
* @param taskType - Type of task being performed
|
||||
* @param changedFiles - Optional list of recently changed files (for commits)
|
||||
* @returns Analysis result with scored files
|
||||
*/
|
||||
public async analyze(
|
||||
metadata: IFileMetadata[],
|
||||
taskType: TaskType,
|
||||
changedFiles: string[] = []
|
||||
): Promise<IAnalysisResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Build dependency graph
|
||||
const dependencyGraph = await this.buildDependencyGraph(metadata);
|
||||
|
||||
// Calculate centrality scores
|
||||
this.calculateCentrality(dependencyGraph);
|
||||
|
||||
// Analyze each file
|
||||
const files: IFileAnalysis[] = [];
|
||||
for (const meta of metadata) {
|
||||
const analysis = await this.analyzeFile(
|
||||
meta,
|
||||
taskType,
|
||||
dependencyGraph,
|
||||
changedFiles
|
||||
);
|
||||
files.push(analysis);
|
||||
}
|
||||
|
||||
// Sort by importance score (highest first)
|
||||
files.sort((a, b) => b.importanceScore - a.importanceScore);
|
||||
|
||||
const analysisDuration = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
taskType,
|
||||
files,
|
||||
dependencyGraph,
|
||||
totalFiles: metadata.length,
|
||||
analysisDuration,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a dependency graph from file metadata
|
||||
* @param metadata - Array of file metadata
|
||||
* @returns Dependency graph as a map
|
||||
*/
|
||||
private async buildDependencyGraph(
|
||||
metadata: IFileMetadata[]
|
||||
): Promise<Map<string, IFileDependencies>> {
|
||||
const graph = new Map<string, IFileDependencies>();
|
||||
|
||||
// Initialize graph entries
|
||||
for (const meta of metadata) {
|
||||
graph.set(meta.path, {
|
||||
path: meta.path,
|
||||
imports: [],
|
||||
importedBy: [],
|
||||
centrality: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Parse imports from each file
|
||||
for (const meta of metadata) {
|
||||
try {
|
||||
const contents = await plugins.smartfile.fs.toStringSync(meta.path);
|
||||
const imports = this.extractImports(contents, meta.path);
|
||||
|
||||
const deps = graph.get(meta.path)!;
|
||||
deps.imports = imports;
|
||||
|
||||
// Update importedBy for imported files
|
||||
for (const importPath of imports) {
|
||||
const importedDeps = graph.get(importPath);
|
||||
if (importedDeps) {
|
||||
importedDeps.importedBy.push(meta.path);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse imports from ${meta.path}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts import statements from file contents
|
||||
* @param contents - File contents
|
||||
* @param filePath - Path of the file being analyzed
|
||||
* @returns Array of absolute paths to imported files
|
||||
*/
|
||||
private extractImports(contents: string, filePath: string): string[] {
|
||||
const imports: string[] = [];
|
||||
const fileDir = plugins.path.dirname(filePath);
|
||||
|
||||
// Match various import patterns
|
||||
const importRegex = /(?:import|export).*?from\s+['"](.+?)['"]/g;
|
||||
let match;
|
||||
|
||||
while ((match = importRegex.exec(contents)) !== null) {
|
||||
const importPath = match[1];
|
||||
|
||||
// Skip external modules
|
||||
if (!importPath.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve relative import to absolute path
|
||||
let resolvedPath = plugins.path.resolve(fileDir, importPath);
|
||||
|
||||
// Handle various file extensions
|
||||
const extensions = ['.ts', '.js', '.tsx', '.jsx', '/index.ts', '/index.js'];
|
||||
let found = false;
|
||||
|
||||
for (const ext of extensions) {
|
||||
const testPath = resolvedPath.endsWith(ext) ? resolvedPath : resolvedPath + ext;
|
||||
try {
|
||||
// Use synchronous file check to avoid async in this context
|
||||
const fs = require('fs');
|
||||
const exists = fs.existsSync(testPath);
|
||||
if (exists) {
|
||||
imports.push(testPath);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue trying other extensions
|
||||
}
|
||||
}
|
||||
|
||||
if (!found && !resolvedPath.includes('.')) {
|
||||
// Try with .ts extension as default
|
||||
imports.push(resolvedPath + '.ts');
|
||||
}
|
||||
}
|
||||
|
||||
return imports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates centrality scores for all nodes in the dependency graph
|
||||
* Uses a simplified PageRank-like algorithm
|
||||
* @param graph - Dependency graph
|
||||
*/
|
||||
private calculateCentrality(graph: Map<string, IFileDependencies>): void {
|
||||
const damping = 0.85;
|
||||
const iterations = 10;
|
||||
const nodeCount = graph.size;
|
||||
|
||||
// Initialize scores
|
||||
const scores = new Map<string, number>();
|
||||
for (const path of graph.keys()) {
|
||||
scores.set(path, 1.0 / nodeCount);
|
||||
}
|
||||
|
||||
// Iterative calculation
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const newScores = new Map<string, number>();
|
||||
|
||||
for (const [path, deps] of graph.entries()) {
|
||||
let score = (1 - damping) / nodeCount;
|
||||
|
||||
// Add contributions from nodes that import this file
|
||||
for (const importerPath of deps.importedBy) {
|
||||
const importerDeps = graph.get(importerPath);
|
||||
if (importerDeps) {
|
||||
const importerScore = scores.get(importerPath) ?? 0;
|
||||
const outgoingCount = importerDeps.imports.length || 1;
|
||||
score += damping * (importerScore / outgoingCount);
|
||||
}
|
||||
}
|
||||
|
||||
newScores.set(path, score);
|
||||
}
|
||||
|
||||
// Update scores
|
||||
for (const [path, score] of newScores) {
|
||||
scores.set(path, score);
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize scores to 0-1 range
|
||||
const maxScore = Math.max(...scores.values());
|
||||
if (maxScore > 0) {
|
||||
for (const deps of graph.values()) {
|
||||
const score = scores.get(deps.path) ?? 0;
|
||||
deps.centrality = score / maxScore;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes a single file
|
||||
* @param meta - File metadata
|
||||
* @param taskType - Task being performed
|
||||
* @param graph - Dependency graph
|
||||
* @param changedFiles - Recently changed files
|
||||
* @returns File analysis
|
||||
*/
|
||||
private async analyzeFile(
|
||||
meta: IFileMetadata,
|
||||
taskType: TaskType,
|
||||
graph: Map<string, IFileDependencies>,
|
||||
changedFiles: string[]
|
||||
): Promise<IFileAnalysis> {
|
||||
const deps = graph.get(meta.path);
|
||||
const centralityScore = deps?.centrality ?? 0;
|
||||
|
||||
// Calculate task-specific relevance
|
||||
const relevanceScore = this.calculateRelevance(meta, taskType);
|
||||
|
||||
// Calculate efficiency (information per token)
|
||||
const efficiencyScore = this.calculateEfficiency(meta);
|
||||
|
||||
// Calculate recency (for commit tasks)
|
||||
const recencyScore = this.calculateRecency(meta, changedFiles);
|
||||
|
||||
// Calculate combined importance score
|
||||
const importanceScore =
|
||||
relevanceScore * this.weights.relevanceWeight +
|
||||
centralityScore * this.weights.dependencyWeight +
|
||||
efficiencyScore * this.weights.efficiencyWeight +
|
||||
recencyScore * this.weights.recencyWeight;
|
||||
|
||||
// Assign tier
|
||||
const tier = this.assignTier(importanceScore);
|
||||
|
||||
return {
|
||||
path: meta.path,
|
||||
relevanceScore,
|
||||
centralityScore,
|
||||
efficiencyScore,
|
||||
recencyScore,
|
||||
importanceScore,
|
||||
tier,
|
||||
reason: this.generateReason(meta, taskType, importanceScore, tier),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates task-specific relevance score
|
||||
*/
|
||||
private calculateRelevance(meta: IFileMetadata, taskType: TaskType): number {
|
||||
const relativePath = meta.relativePath.toLowerCase();
|
||||
let score = 0.5; // Base score
|
||||
|
||||
// README generation - prioritize public APIs and main exports
|
||||
if (taskType === 'readme') {
|
||||
if (relativePath.includes('index.ts')) score += 0.3;
|
||||
if (relativePath.match(/^ts\/[^\/]+\.ts$/)) score += 0.2; // Root level exports
|
||||
if (relativePath.includes('test/')) score -= 0.3;
|
||||
if (relativePath.includes('classes/')) score += 0.1;
|
||||
if (relativePath.includes('interfaces/')) score += 0.1;
|
||||
}
|
||||
|
||||
// Commit messages - prioritize changed files and their dependencies
|
||||
if (taskType === 'commit') {
|
||||
if (relativePath.includes('test/')) score -= 0.2;
|
||||
// Recency will handle changed files
|
||||
}
|
||||
|
||||
// Description generation - prioritize main exports and core interfaces
|
||||
if (taskType === 'description') {
|
||||
if (relativePath.includes('index.ts')) score += 0.4;
|
||||
if (relativePath.match(/^ts\/[^\/]+\.ts$/)) score += 0.3;
|
||||
if (relativePath.includes('test/')) score -= 0.4;
|
||||
if (relativePath.includes('interfaces/')) score += 0.2;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(1, score));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates efficiency score (information density)
|
||||
*/
|
||||
private calculateEfficiency(meta: IFileMetadata): number {
|
||||
// Prefer files that are not too large (good signal-to-noise ratio)
|
||||
const optimalSize = 5000; // ~1250 tokens
|
||||
const distance = Math.abs(meta.estimatedTokens - optimalSize);
|
||||
const normalized = Math.max(0, 1 - distance / optimalSize);
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates recency score for changed files
|
||||
*/
|
||||
private calculateRecency(meta: IFileMetadata, changedFiles: string[]): number {
|
||||
if (changedFiles.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Check if this file was changed
|
||||
const isChanged = changedFiles.some((changed) => changed === meta.path);
|
||||
|
||||
return isChanged ? 1.0 : 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns a tier based on importance score
|
||||
*/
|
||||
private assignTier(score: number): 'essential' | 'important' | 'optional' | 'excluded' {
|
||||
if (score >= this.tiers.essential.minScore) return 'essential';
|
||||
if (score >= this.tiers.important.minScore) return 'important';
|
||||
if (score >= this.tiers.optional.minScore) return 'optional';
|
||||
return 'excluded';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a human-readable reason for the score
|
||||
*/
|
||||
private generateReason(
|
||||
meta: IFileMetadata,
|
||||
taskType: TaskType,
|
||||
score: number,
|
||||
tier: string
|
||||
): string {
|
||||
const reasons: string[] = [];
|
||||
|
||||
if (meta.relativePath.includes('index.ts')) {
|
||||
reasons.push('main export file');
|
||||
}
|
||||
|
||||
if (meta.relativePath.includes('test/')) {
|
||||
reasons.push('test file (lower priority)');
|
||||
}
|
||||
|
||||
if (taskType === 'readme' && meta.relativePath.match(/^ts\/[^\/]+\.ts$/)) {
|
||||
reasons.push('root-level module');
|
||||
}
|
||||
|
||||
reasons.push(`score: ${score.toFixed(2)}`);
|
||||
reasons.push(`tier: ${tier}`);
|
||||
|
||||
return reasons.join(', ');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user