smart-context.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. /**
  2. * Smart Context Loader – auto-discovers and loads related files
  3. *
  4. * When the user asks about or wants to edit a VL file, this module
  5. * uses the dependency graph to find all relevant files and pre-loads
  6. * them into context.
  7. *
  8. * Strategy:
  9. * 1. Parse user message for file references (@file, path mentions)
  10. * 2. Walk dependency graph: upstream (who depends on me) + downstream (who I depend on)
  11. * 3. Rank files by relevance (direct dep > transitive dep > same type)
  12. * 4. Load files with smart truncation (Read with offset/limit for large files)
  13. * 5. Return as system-reminder block for context injection
  14. */
  15. import fs from 'fs/promises';
  16. import path from 'path';
  17. export class SmartContextLoader {
  18. constructor(projectContext) {
  19. this.ctx = projectContext;
  20. this.depGraph = null; // {nodes, edges}
  21. this.adjacency = null; // nodeId → [connected nodes]
  22. this.reverseAdj = null; // nodeId → [nodes that point to me]
  23. this.recentEdits = []; // [{filePath, timestamp}] — tracks recently edited files
  24. this.compileErrorFiles = new Set(); // Files mentioned in compile errors
  25. this.contextManager = null; // Wired by orchestrator for conversation history analysis
  26. }
  27. /** Record a file edit for recency boosting */
  28. recordEdit(filePath) {
  29. this.recentEdits.push({ filePath, timestamp: Date.now() });
  30. // Keep last 50 edits
  31. if (this.recentEdits.length > 50) this.recentEdits.shift();
  32. }
  33. /** Update compile error file set from last-compile.json */
  34. updateCompileErrors(errList) {
  35. this.compileErrorFiles.clear();
  36. if (!errList) return;
  37. for (const err of errList) {
  38. if (err.file) this.compileErrorFiles.add(err.file);
  39. if (err.filePath) this.compileErrorFiles.add(err.filePath);
  40. }
  41. }
  42. /** Build dependency graph from project files */
  43. async buildGraph() {
  44. if (!this.ctx.isVLProject()) return;
  45. const nodes = new Map();
  46. const edges = [];
  47. for (const file of this.ctx.getAllFiles()) {
  48. nodes.set(file.path, { id: file.path, type: file.type, name: file.name });
  49. try {
  50. const content = await fs.readFile(file.fullPath, 'utf-8');
  51. // App → Section/Component references
  52. if (file.type === 'app') {
  53. for (const m of content.matchAll(/<Section-(\w+)\s+"[^"]+"/g)) {
  54. const target = this.ctx.getFilesByType('section').find(f => f.name === m[1]);
  55. if (target) edges.push({ from: file.path, to: target.path, type: 'hosts', weight: 1 });
  56. }
  57. for (const m of content.matchAll(/<Component-(\w+)\s+"[^"]+"/g)) {
  58. const target = this.ctx.getFilesByType('component').find(f => f.name === m[1]);
  59. if (target) edges.push({ from: file.path, to: target.path, type: 'uses', weight: 2 });
  60. }
  61. }
  62. // Section → Service/Component references
  63. if (file.type === 'section') {
  64. for (const m of content.matchAll(/<ServiceDomain-(\w+)>/g)) {
  65. const target = this.ctx.getFilesByType('service').find(f => f.name === m[1]);
  66. if (target) edges.push({ from: file.path, to: target.path, type: 'calls', weight: 1 });
  67. }
  68. for (const m of content.matchAll(/<Component-(\w+)\s+"[^"]+"/g)) {
  69. const target = this.ctx.getFilesByType('component').find(f => f.name === m[1]);
  70. if (target) edges.push({ from: file.path, to: target.path, type: 'uses', weight: 2 });
  71. }
  72. }
  73. // Service → Database (by sourceTable references)
  74. if (file.type === 'service') {
  75. for (const m of content.matchAll(/sourceTable:(\w+)/g)) {
  76. const target = this.ctx.getFilesByType('database').find(() => true);
  77. if (target) edges.push({ from: file.path, to: target.path, type: 'queries', weight: 1 });
  78. }
  79. }
  80. } catch {
  81. continue;
  82. }
  83. }
  84. // Deduplicate edges
  85. const edgeSet = new Set();
  86. const uniqueEdges = edges.filter(e => {
  87. const key = `${e.from}→${e.to}`;
  88. if (edgeSet.has(key)) return false;
  89. edgeSet.add(key);
  90. return true;
  91. });
  92. this.depGraph = { nodes: [...nodes.values()], edges: uniqueEdges };
  93. // Build adjacency lists
  94. this.adjacency = new Map();
  95. this.reverseAdj = new Map();
  96. for (const node of nodes.values()) {
  97. this.adjacency.set(node.id, []);
  98. this.reverseAdj.set(node.id, []);
  99. }
  100. for (const edge of uniqueEdges) {
  101. this.adjacency.get(edge.from)?.push({ target: edge.to, weight: edge.weight, type: edge.type });
  102. this.reverseAdj.get(edge.to)?.push({ target: edge.from, weight: edge.weight, type: edge.type });
  103. }
  104. }
  105. /**
  106. * Given user message, discover relevant files to load
  107. * Returns: [{path, type, relevance, reason}]
  108. */
  109. discoverRelevantFiles(userMessage) {
  110. if (!this.depGraph) return [];
  111. const relevant = new Map(); // path → {relevance, reason}
  112. // 1. Direct @mentions: "@HeaderSection" or "HeaderSection.sc"
  113. for (const file of this.ctx.getAllFiles()) {
  114. if (userMessage.includes(`@${file.name}`) ||
  115. userMessage.includes(path.basename(file.path)) ||
  116. userMessage.includes(file.name)) {
  117. relevant.set(file.path, { relevance: 10, reason: 'directly mentioned' });
  118. }
  119. }
  120. // 2. Keyword matching by file type
  121. const keywords = {
  122. section: ['section', 'page', 'layout', 'ui', 'frontend', 'view'],
  123. component: ['component', 'widget', 'button', 'card', 'input'],
  124. service: ['service', 'api', 'backend', 'query', 'database', 'crud'],
  125. app: ['app', 'route', 'navigation', 'routing'],
  126. database: ['database', 'table', 'schema', 'field', 'entity', 'db'],
  127. theme: ['theme', 'style', 'color', 'token', 'design'],
  128. };
  129. const msgLower = userMessage.toLowerCase();
  130. for (const [type, words] of Object.entries(keywords)) {
  131. if (words.some(w => msgLower.includes(w))) {
  132. for (const file of this.ctx.getFilesByType(type)) {
  133. if (!relevant.has(file.path)) {
  134. relevant.set(file.path, { relevance: 3, reason: `type match: ${type}` });
  135. }
  136. }
  137. }
  138. }
  139. // 3. Walk dependency graph for directly mentioned files
  140. for (const [filePath, info] of relevant) {
  141. if (info.relevance >= 8) {
  142. // Downstream dependencies (what this file uses)
  143. const downstream = this.adjacency.get(filePath) || [];
  144. for (const dep of downstream) {
  145. if (!relevant.has(dep.target) || relevant.get(dep.target).relevance < 6) {
  146. relevant.set(dep.target, { relevance: 6, reason: `dependency of ${path.basename(filePath)}` });
  147. }
  148. }
  149. // Upstream dependents (who uses this file)
  150. const upstream = this.reverseAdj.get(filePath) || [];
  151. for (const dep of upstream) {
  152. if (!relevant.has(dep.target) || relevant.get(dep.target).relevance < 5) {
  153. relevant.set(dep.target, { relevance: 5, reason: `depends on ${path.basename(filePath)}` });
  154. }
  155. }
  156. }
  157. }
  158. // 4. Recently edited files: boost relevance (+5 within last 5 min, +3 within 15 min)
  159. const now = Date.now();
  160. for (const edit of this.recentEdits) {
  161. const age = now - edit.timestamp;
  162. const boost = age < 5 * 60 * 1000 ? 5 : age < 15 * 60 * 1000 ? 3 : 0;
  163. if (boost > 0) {
  164. const existing = relevant.get(edit.filePath);
  165. if (existing) {
  166. existing.relevance += boost;
  167. existing.reason += ', recently edited';
  168. } else {
  169. relevant.set(edit.filePath, { relevance: boost, reason: 'recently edited' });
  170. }
  171. }
  172. }
  173. // 5. Compile error files: high relevance boost (+8)
  174. for (const errFile of this.compileErrorFiles) {
  175. const existing = relevant.get(errFile);
  176. if (existing) {
  177. existing.relevance += 8;
  178. existing.reason += ', has compile errors';
  179. } else {
  180. relevant.set(errFile, { relevance: 8, reason: 'has compile errors' });
  181. }
  182. }
  183. // 6. Conversation history: extract recently discussed files from context
  184. if (this.contextManager) {
  185. const recentFiles = this._extractFilesFromConversation();
  186. for (const filePath of recentFiles) {
  187. const existing = relevant.get(filePath);
  188. if (existing) {
  189. existing.relevance += 2;
  190. } else {
  191. relevant.set(filePath, { relevance: 2, reason: 'discussed in conversation' });
  192. }
  193. }
  194. }
  195. // Sort by relevance (highest first), limit to top 8
  196. return [...relevant.entries()]
  197. .map(([p, info]) => ({ path: p, ...info, type: this.ctx.getFile(p)?.type }))
  198. .sort((a, b) => b.relevance - a.relevance)
  199. .slice(0, 8);
  200. }
  201. /** Extract file paths mentioned in recent conversation messages */
  202. _extractFilesFromConversation() {
  203. if (!this.contextManager) return [];
  204. const messages = this.contextManager.messages || [];
  205. const recentMsgs = messages.slice(-10); // Only last 10 messages
  206. const mentioned = new Set();
  207. const allFiles = this.ctx.getAllFiles();
  208. for (const msg of recentMsgs) {
  209. const text = typeof msg.content === 'string' ? msg.content :
  210. Array.isArray(msg.content) ? msg.content.map(b => b.text || b.content || '').join(' ') : '';
  211. for (const file of allFiles) {
  212. if (text.includes(file.name) || text.includes(path.basename(file.path))) {
  213. mentioned.add(file.path);
  214. }
  215. }
  216. }
  217. return [...mentioned].slice(0, 5);
  218. }
  219. /**
  220. * Load discovered files into a context block
  221. * Smart truncation: key sections only for large files
  222. */
  223. async loadContext(relevantFiles, userMessageLength) {
  224. if (relevantFiles.length === 0) return null;
  225. const blocks = [];
  226. let totalChars = 0;
  227. // Dynamic token budget: short user messages get more context, long messages less
  228. const msgLen = userMessageLength || 200;
  229. const MAX_CHARS = msgLen < 100 ? 20000 : msgLen > 500 ? 8000 : 15000;
  230. for (const file of relevantFiles) {
  231. if (totalChars > MAX_CHARS) break;
  232. try {
  233. const fullPath = path.resolve(this.ctx.workDir, file.path);
  234. const content = await fs.readFile(fullPath, 'utf-8');
  235. const lines = content.split('\n');
  236. let loaded;
  237. if (lines.length <= 80) {
  238. // Small file: load fully
  239. loaded = content;
  240. } else {
  241. // Large file: load header + section markers + public interface
  242. loaded = this.extractKeySections(lines, file.type);
  243. }
  244. const budget = MAX_CHARS - totalChars;
  245. const truncated = loaded.length > budget ? loaded.substring(0, budget) + '\n... (truncated)' : loaded;
  246. totalChars += truncated.length;
  247. blocks.push(`--- ${file.path} (${file.reason}) ---\n${truncated}`);
  248. } catch {
  249. continue;
  250. }
  251. }
  252. if (blocks.length === 0) return null;
  253. return `<system-reminder>
  254. Auto-loaded relevant VL files (${blocks.length} files):
  255. ${blocks.join('\n\n')}
  256. </system-reminder>`;
  257. }
  258. /** Extract key sections from a large VL file */
  259. extractKeySections(lines, fileType) {
  260. const result = [];
  261. let inPublicSection = false;
  262. let inTreeSection = false;
  263. let treeLineCount = 0;
  264. for (let i = 0; i < lines.length; i++) {
  265. const line = lines[i];
  266. // Always include: version, root component, section headers
  267. if (i < 3 || line.startsWith('//') || line.match(/^<\w+-\w+/) || line.match(/^#\s/)) {
  268. result.push(line);
  269. inPublicSection = line.includes('Public');
  270. inTreeSection = line.includes('Tree');
  271. treeLineCount = 0;
  272. continue;
  273. }
  274. // Include full public interface sections
  275. if (inPublicSection && !line.startsWith('#')) {
  276. result.push(line);
  277. if (line.trim() === '') inPublicSection = false;
  278. continue;
  279. }
  280. // Include first 20 lines of tree
  281. if (inTreeSection && !line.startsWith('#')) {
  282. treeLineCount++;
  283. if (treeLineCount <= 20) {
  284. result.push(line);
  285. } else if (treeLineCount === 21) {
  286. result.push(`... (${lines.length - i} more lines in tree)`);
  287. inTreeSection = false;
  288. }
  289. continue;
  290. }
  291. // Include SERVICE/METHOD/EVENT declarations
  292. if (line.match(/^(SERVICE|PUBLIC_SERVICE|METHOD|METHOD_PUB|EVENT|TRANSACTION|PIPE)\s/)) {
  293. result.push(line);
  294. }
  295. }
  296. return result.join('\n');
  297. }
  298. }