/** * Context Awareness Service * * Automatically discovers and includes relevant files in the conversation * based on: * - Current working directory * - Git repository structure * - File relationships (imports, references) * - Recent modifications */ import * as fs from 'fs/promises'; import * as path from 'path'; import { exec } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); export interface ContextFile { path: string; relativePath: string; content: string; size: number; language: string; relevanceScore: number; } export interface ContextOptions { maxFiles?: number; maxFileSize?: number; // bytes includeGitTracked?: boolean; includeRecentlyModified?: boolean; extensions?: string[]; } export class ContextService { private cwd: string; private options: Required; constructor(cwd: string = process.cwd(), options: ContextOptions = {}) { this.cwd = cwd; this.options = { maxFiles: options.maxFiles ?? 10, maxFileSize: options.maxFileSize ?? 100 * 1024, // 100KB includeGitTracked: options.includeGitTracked ?? true, includeRecentlyModified: options.includeRecentlyModified ?? true, extensions: options.extensions ?? [ '.js', '.ts', '.jsx', '.tsx', '.py', '.java', '.go', '.rs', '.c', '.cpp', '.h', '.hpp', '.cs', '.rb', '.php', '.swift', '.kt', ], }; } /** * Gather context files relevant to the current task */ async gatherContext(query?: string): Promise { const files: ContextFile[] = []; // Get files from various sources const [gitFiles, recentFiles, directoryFiles] = await Promise.all([ this.options.includeGitTracked ? this.getGitTrackedFiles() : Promise.resolve([]), this.options.includeRecentlyModified ? this.getRecentlyModifiedFiles() : Promise.resolve([]), this.getDirectoryFiles(), ]); // Combine and deduplicate const allPaths = [...new Set([...gitFiles, ...recentFiles, ...directoryFiles])]; // Read and score files for (const filePath of allPaths.slice(0, this.options.maxFiles * 2)) { try { const file = await this.readFile(filePath); if (file) { file.relevanceScore = this.calculateRelevance(file, query); files.push(file); } } catch (error) { // Skip unreadable files continue; } } // Sort by relevance and limit return files.sort((a, b) => b.relevanceScore - a.relevanceScore).slice(0, this.options.maxFiles); } /** * Get files tracked by git */ private async getGitTrackedFiles(): Promise { try { const { stdout } = await execAsync('git ls-files', { cwd: this.cwd }); return stdout .trim() .split('\n') .filter(f => this.isRelevantExtension(f)) .map(f => path.join(this.cwd, f)); } catch { return []; } } /** * Get recently modified files */ private async getRecentlyModifiedFiles(): Promise { try { // Get files modified in last 7 days const { stdout } = await execAsync( 'git log --since="7 days ago" --name-only --pretty=format: | sort -u', { cwd: this.cwd } ); return stdout .trim() .split('\n') .filter(f => f && this.isRelevantExtension(f)) .map(f => path.join(this.cwd, f)); } catch { return []; } } /** * Get files from current directory (non-recursive) */ private async getDirectoryFiles(): Promise { try { const entries = await fs.readdir(this.cwd, { withFileTypes: true }); return entries .filter(e => e.isFile() && this.isRelevantExtension(e.name)) .map(e => path.join(this.cwd, e.name)); } catch { return []; } } /** * Read a file and create ContextFile */ private async readFile(filePath: string): Promise { try { const stat = await fs.stat(filePath); // Skip if too large if (stat.size > this.options.maxFileSize) { return null; } const content = await fs.readFile(filePath, 'utf-8'); const relativePath = path.relative(this.cwd, filePath); return { path: filePath, relativePath, content, size: stat.size, language: this.detectLanguage(filePath), relevanceScore: 0, }; } catch { return null; } } /** * Calculate relevance score for a file */ private calculateRelevance(file: ContextFile, query?: string): number { let score = 0; // Prefer smaller files score += Math.max(0, 50 - (file.size / 1024)); // Prefer certain file types if (file.language === 'typescript' || file.language === 'javascript') score += 20; if (file.relativePath.includes('test')) score -= 10; if (file.relativePath.includes('node_modules')) score -= 100; if (file.relativePath.includes('dist') || file.relativePath.includes('build')) score -= 50; // Prefer files in root const depth = file.relativePath.split(path.sep).length; score += Math.max(0, 20 - depth * 5); // Query relevance if (query) { const lowerQuery = query.toLowerCase(); const lowerContent = file.content.toLowerCase(); const lowerPath = file.relativePath.toLowerCase(); if (lowerPath.includes(lowerQuery)) score += 30; if (lowerContent.includes(lowerQuery)) score += 20; // Count occurrences const matches = (lowerContent.match(new RegExp(lowerQuery, 'g')) || []).length; score += Math.min(matches * 5, 50); } return score; } /** * Check if file extension is relevant */ private isRelevantExtension(filePath: string): boolean { const ext = path.extname(filePath); return this.options.extensions.includes(ext); } /** * Detect language from file extension */ private detectLanguage(filePath: string): string { const ext = path.extname(filePath); const languageMap: Record = { '.js': 'javascript', '.jsx': 'javascript', '.ts': 'typescript', '.tsx': 'typescript', '.py': 'python', '.java': 'java', '.go': 'go', '.rs': 'rust', '.c': 'c', '.cpp': 'cpp', '.h': 'c', '.hpp': 'cpp', '.cs': 'csharp', '.rb': 'ruby', '.php': 'php', '.swift': 'swift', '.kt': 'kotlin', }; return languageMap[ext] || 'unknown'; } /** * Format context files for AI prompt */ formatContextForPrompt(files: ContextFile[]): string { if (files.length === 0) return ''; let prompt = '\n**Context Files:**\n\n'; files.forEach(file => { prompt += `\`\`\`${file.language}:${file.relativePath}\n`; prompt += file.content; prompt += '\n```\n\n'; }); return prompt; } }