| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338 |
- /**
- * 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(/<Section-(\w+)\s+"[^"]+"/g)) {
- const target = this.ctx.getFilesByType('section').find(f => f.name === m[1]);
- if (target) edges.push({ from: file.path, to: target.path, type: 'hosts', weight: 1 });
- }
- for (const m of content.matchAll(/<Component-(\w+)\s+"[^"]+"/g)) {
- const target = this.ctx.getFilesByType('component').find(f => 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(/<ServiceDomain-(\w+)>/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(/<Component-(\w+)\s+"[^"]+"/g)) {
- const target = this.ctx.getFilesByType('component').find(f => 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 `<system-reminder>
- Auto-loaded relevant VL files (${blocks.length} files):
- ${blocks.join('\n\n')}
- </system-reminder>`;
- }
- /** 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');
- }
- }
|