/** * Smart Context Loader – auto-discovers and loads related files * * When the user asks about or wants to edit a VL file, this module * uses the dependency graph to find all relevant files and pre-loads * them into context. * * Strategy: * 1. Parse user message for file references (@file, path mentions) * 2. Walk dependency graph: upstream (who depends on me) + downstream (who I depend on) * 3. Rank files by relevance (direct dep > transitive dep > same type) * 4. Load files with smart truncation (Read with offset/limit for large files) * 5. Return as system-reminder block for context injection */ import fs from 'fs/promises'; import path from 'path'; export class SmartContextLoader { constructor(projectContext) { this.ctx = projectContext; this.depGraph = null; // {nodes, edges} this.adjacency = null; // nodeId → [connected nodes] this.reverseAdj = null; // nodeId → [nodes that point to me] this.recentEdits = []; // [{filePath, timestamp}] — tracks recently edited files this.compileErrorFiles = new Set(); // Files mentioned in compile errors this.contextManager = null; // Wired by orchestrator for conversation history analysis } /** Record a file edit for recency boosting */ recordEdit(filePath) { this.recentEdits.push({ filePath, timestamp: Date.now() }); // Keep last 50 edits if (this.recentEdits.length > 50) this.recentEdits.shift(); } /** Update compile error file set from last-compile.json */ updateCompileErrors(errList) { this.compileErrorFiles.clear(); if (!errList) return; for (const err of errList) { if (err.file) this.compileErrorFiles.add(err.file); if (err.filePath) this.compileErrorFiles.add(err.filePath); } } /** Build dependency graph from project files */ async buildGraph() { if (!this.ctx.isVLProject()) return; const nodes = new Map(); const edges = []; for (const file of this.ctx.getAllFiles()) { nodes.set(file.path, { id: file.path, type: file.type, name: file.name }); try { const content = await fs.readFile(file.fullPath, 'utf-8'); // App → Section/Component references if (file.type === 'app') { for (const m of content.matchAll(/ f.name === m[1]); if (target) edges.push({ from: file.path, to: target.path, type: 'hosts', weight: 1 }); } for (const m of content.matchAll(/ f.name === m[1]); if (target) edges.push({ from: file.path, to: target.path, type: 'uses', weight: 2 }); } } // Section → Service/Component references if (file.type === 'section') { for (const m of content.matchAll(//g)) { const target = this.ctx.getFilesByType('service').find(f => f.name === m[1]); if (target) edges.push({ from: file.path, to: target.path, type: 'calls', weight: 1 }); } for (const m of content.matchAll(/ f.name === m[1]); if (target) edges.push({ from: file.path, to: target.path, type: 'uses', weight: 2 }); } } // Service → Database (by sourceTable references) if (file.type === 'service') { for (const m of content.matchAll(/sourceTable:(\w+)/g)) { const target = this.ctx.getFilesByType('database').find(() => true); if (target) edges.push({ from: file.path, to: target.path, type: 'queries', weight: 1 }); } } } catch { continue; } } // Deduplicate edges const edgeSet = new Set(); const uniqueEdges = edges.filter(e => { const key = `${e.from}→${e.to}`; if (edgeSet.has(key)) return false; edgeSet.add(key); return true; }); this.depGraph = { nodes: [...nodes.values()], edges: uniqueEdges }; // Build adjacency lists this.adjacency = new Map(); this.reverseAdj = new Map(); for (const node of nodes.values()) { this.adjacency.set(node.id, []); this.reverseAdj.set(node.id, []); } for (const edge of uniqueEdges) { this.adjacency.get(edge.from)?.push({ target: edge.to, weight: edge.weight, type: edge.type }); this.reverseAdj.get(edge.to)?.push({ target: edge.from, weight: edge.weight, type: edge.type }); } } /** * Given user message, discover relevant files to load * Returns: [{path, type, relevance, reason}] */ discoverRelevantFiles(userMessage) { if (!this.depGraph) return []; const relevant = new Map(); // path → {relevance, reason} // 1. Direct @mentions: "@HeaderSection" or "HeaderSection.sc" for (const file of this.ctx.getAllFiles()) { if (userMessage.includes(`@${file.name}`) || userMessage.includes(path.basename(file.path)) || userMessage.includes(file.name)) { relevant.set(file.path, { relevance: 10, reason: 'directly mentioned' }); } } // 2. Keyword matching by file type const keywords = { section: ['section', 'page', 'layout', 'ui', 'frontend', 'view'], component: ['component', 'widget', 'button', 'card', 'input'], service: ['service', 'api', 'backend', 'query', 'database', 'crud'], app: ['app', 'route', 'navigation', 'routing'], database: ['database', 'table', 'schema', 'field', 'entity', 'db'], theme: ['theme', 'style', 'color', 'token', 'design'], }; const msgLower = userMessage.toLowerCase(); for (const [type, words] of Object.entries(keywords)) { if (words.some(w => msgLower.includes(w))) { for (const file of this.ctx.getFilesByType(type)) { if (!relevant.has(file.path)) { relevant.set(file.path, { relevance: 3, reason: `type match: ${type}` }); } } } } // 3. Walk dependency graph for directly mentioned files for (const [filePath, info] of relevant) { if (info.relevance >= 8) { // Downstream dependencies (what this file uses) const downstream = this.adjacency.get(filePath) || []; for (const dep of downstream) { if (!relevant.has(dep.target) || relevant.get(dep.target).relevance < 6) { relevant.set(dep.target, { relevance: 6, reason: `dependency of ${path.basename(filePath)}` }); } } // Upstream dependents (who uses this file) const upstream = this.reverseAdj.get(filePath) || []; for (const dep of upstream) { if (!relevant.has(dep.target) || relevant.get(dep.target).relevance < 5) { relevant.set(dep.target, { relevance: 5, reason: `depends on ${path.basename(filePath)}` }); } } } } // 4. Recently edited files: boost relevance (+5 within last 5 min, +3 within 15 min) const now = Date.now(); for (const edit of this.recentEdits) { const age = now - edit.timestamp; const boost = age < 5 * 60 * 1000 ? 5 : age < 15 * 60 * 1000 ? 3 : 0; if (boost > 0) { const existing = relevant.get(edit.filePath); if (existing) { existing.relevance += boost; existing.reason += ', recently edited'; } else { relevant.set(edit.filePath, { relevance: boost, reason: 'recently edited' }); } } } // 5. Compile error files: high relevance boost (+8) for (const errFile of this.compileErrorFiles) { const existing = relevant.get(errFile); if (existing) { existing.relevance += 8; existing.reason += ', has compile errors'; } else { relevant.set(errFile, { relevance: 8, reason: 'has compile errors' }); } } // 6. Conversation history: extract recently discussed files from context if (this.contextManager) { const recentFiles = this._extractFilesFromConversation(); for (const filePath of recentFiles) { const existing = relevant.get(filePath); if (existing) { existing.relevance += 2; } else { relevant.set(filePath, { relevance: 2, reason: 'discussed in conversation' }); } } } // Sort by relevance (highest first), limit to top 8 return [...relevant.entries()] .map(([p, info]) => ({ path: p, ...info, type: this.ctx.getFile(p)?.type })) .sort((a, b) => b.relevance - a.relevance) .slice(0, 8); } /** Extract file paths mentioned in recent conversation messages */ _extractFilesFromConversation() { if (!this.contextManager) return []; const messages = this.contextManager.messages || []; const recentMsgs = messages.slice(-10); // Only last 10 messages const mentioned = new Set(); const allFiles = this.ctx.getAllFiles(); for (const msg of recentMsgs) { const text = typeof msg.content === 'string' ? msg.content : Array.isArray(msg.content) ? msg.content.map(b => b.text || b.content || '').join(' ') : ''; for (const file of allFiles) { if (text.includes(file.name) || text.includes(path.basename(file.path))) { mentioned.add(file.path); } } } return [...mentioned].slice(0, 5); } /** * Load discovered files into a context block * Smart truncation: key sections only for large files */ async loadContext(relevantFiles, userMessageLength) { if (relevantFiles.length === 0) return null; const blocks = []; let totalChars = 0; // Dynamic token budget: short user messages get more context, long messages less const msgLen = userMessageLength || 200; const MAX_CHARS = msgLen < 100 ? 20000 : msgLen > 500 ? 8000 : 15000; for (const file of relevantFiles) { if (totalChars > MAX_CHARS) break; try { const fullPath = path.resolve(this.ctx.workDir, file.path); const content = await fs.readFile(fullPath, 'utf-8'); const lines = content.split('\n'); let loaded; if (lines.length <= 80) { // Small file: load fully loaded = content; } else { // Large file: load header + section markers + public interface loaded = this.extractKeySections(lines, file.type); } const budget = MAX_CHARS - totalChars; const truncated = loaded.length > budget ? loaded.substring(0, budget) + '\n... (truncated)' : loaded; totalChars += truncated.length; blocks.push(`--- ${file.path} (${file.reason}) ---\n${truncated}`); } catch { continue; } } if (blocks.length === 0) return null; return ` Auto-loaded relevant VL files (${blocks.length} files): ${blocks.join('\n\n')} `; } /** Extract key sections from a large VL file */ extractKeySections(lines, fileType) { const result = []; let inPublicSection = false; let inTreeSection = false; let treeLineCount = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Always include: version, root component, section headers if (i < 3 || line.startsWith('//') || line.match(/^<\w+-\w+/) || line.match(/^#\s/)) { result.push(line); inPublicSection = line.includes('Public'); inTreeSection = line.includes('Tree'); treeLineCount = 0; continue; } // Include full public interface sections if (inPublicSection && !line.startsWith('#')) { result.push(line); if (line.trim() === '') inPublicSection = false; continue; } // Include first 20 lines of tree if (inTreeSection && !line.startsWith('#')) { treeLineCount++; if (treeLineCount <= 20) { result.push(line); } else if (treeLineCount === 21) { result.push(`... (${lines.length - i} more lines in tree)`); inTreeSection = false; } continue; } // Include SERVICE/METHOD/EVENT declarations if (line.match(/^(SERVICE|PUBLIC_SERVICE|METHOD|METHOD_PUB|EVENT|TRANSACTION|PIPE)\s/)) { result.push(line); } } return result.join('\n'); } }