UNPKG

@context-sync/server

Version:

MCP server for AI context sync with persistent memory, workspace file access, and intelligent code operations

370 lines 12.5 kB
// IDE Workspace Detection and File Reading import { promises as fsAsync } from 'fs'; import * as path from 'path'; import * as chokidar from 'chokidar'; export class WorkspaceDetector { storage; projectDetector; currentWorkspace = null; fileCache = new Map(); fileWatcher = null; // File size limits to prevent OOM crashes MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB - prevents OOM crashes WARN_FILE_SIZE = 1 * 1024 * 1024; // 1MB - warn but still process constructor(storage, projectDetector) { this.storage = storage; this.projectDetector = projectDetector; } /** * Set the current workspace (called when IDE opens a folder) */ setWorkspace(workspacePath) { // Dispose existing watcher if (this.fileWatcher) { this.fileWatcher.close(); } this.currentWorkspace = workspacePath; this.fileCache.clear(); // Set up file watcher to invalidate cache on changes this.setupFileWatcher(workspacePath); // Auto-detect and initialize project (async, but don't block) this.projectDetector.createOrUpdateProject(workspacePath).catch(error => { console.error('Error auto-detecting project:', error); }); console.error(`📂 Workspace set: ${workspacePath}`); } /** * Set up file watcher for cache invalidation */ setupFileWatcher(workspacePath) { const watchPatterns = [ path.join(workspacePath, '**/*.{ts,tsx,js,jsx,json,md}'), ]; this.fileWatcher = chokidar.watch(watchPatterns, { ignored: [ '**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**', '**/.next/**', '**/out/**', '**/coverage/**' ], ignoreInitial: true, persistent: true, awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 } }); this.fileWatcher .on('change', (filePath) => { this.invalidateFileCache(filePath); }) .on('add', (filePath) => { this.invalidateFileCache(filePath); }) .on('unlink', (filePath) => { this.invalidateFileCache(filePath); }) .on('error', (error) => { console.error('File watcher error:', error); }); console.error('📁 File watcher active for cache invalidation'); } /** * Invalidate cached file content */ invalidateFileCache(filePath) { // Remove from file cache if (this.fileCache.has(filePath)) { this.fileCache.delete(filePath); console.error(`🔄 Cache invalidated: ${path.relative(this.currentWorkspace || '', filePath)}`); } // Also remove any related cached files (for relative path variations) const relativePath = this.currentWorkspace ? path.relative(this.currentWorkspace, filePath) : filePath; const fullPath = this.currentWorkspace ? path.join(this.currentWorkspace, relativePath) : filePath; this.fileCache.delete(relativePath); this.fileCache.delete(fullPath); } /** * Get current workspace */ getCurrentWorkspace() { return this.currentWorkspace; } /** * Read a file from the workspace */ async readFile(relativePath) { if (!this.currentWorkspace) { return null; } const fullPath = path.join(this.currentWorkspace, relativePath); // Check cache first if (this.fileCache.has(fullPath)) { return this.fileCache.get(fullPath); } try { await fsAsync.access(fullPath); } catch { return null; } try { // Check file size first to prevent OOM crashes const stats = await fsAsync.stat(fullPath); if (stats.size > this.MAX_FILE_SIZE) { console.error(`⚠️ File too large (${(stats.size / 1024 / 1024).toFixed(1)}MB), skipping: ${relativePath}`); return null; } if (stats.size > this.WARN_FILE_SIZE) { console.error(`⚠️ Large file detected (${(stats.size / 1024 / 1024).toFixed(1)}MB): ${relativePath}`); } const content = await fsAsync.readFile(fullPath, 'utf8'); const language = this.detectLanguage(fullPath); const size = Buffer.byteLength(content); const fileContent = { path: relativePath, content, language, size, }; // Cache it this.fileCache.set(fullPath, fileContent); return fileContent; } catch (error) { console.error(`Error reading file ${fullPath}:`, error); return null; } } /** * Get project structure (file tree) */ async getProjectStructure(maxDepth = 3) { if (!this.currentWorkspace) { return 'No workspace open'; } const structure = []; await this.buildStructure(this.currentWorkspace, '', 0, maxDepth, structure); return structure.join('\n'); } async buildStructure(dirPath, prefix, depth, maxDepth, output) { if (depth > maxDepth) return; try { const entries = await fsAsync.readdir(dirPath, { withFileTypes: true }); // Filter out common ignore patterns const filtered = entries.filter(entry => { const name = entry.name; return !this.shouldIgnore(name); }); for (let index = 0; index < filtered.length; index++) { const entry = filtered[index]; const isLast = index === filtered.length - 1; const marker = isLast ? '└── ' : '├── '; const fullPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { output.push(`${prefix}${marker}📁 ${entry.name}/`); const newPrefix = prefix + (isLast ? ' ' : '│ '); await this.buildStructure(fullPath, newPrefix, depth + 1, maxDepth, output); } else { const icon = this.getFileIcon(entry.name); output.push(`${prefix}${marker}${icon} ${entry.name}`); } } } catch (error) { // Ignore errors (permission denied, etc.) } } /** * Scan important files (main entry points, configs, etc.) */ async scanImportantFiles() { if (!this.currentWorkspace) { return []; } const importantPatterns = [ // Entry points 'src/index.ts', 'src/index.js', 'src/main.ts', 'src/main.js', 'src/app/page.tsx', 'src/app/layout.tsx', 'pages/index.tsx', 'pages/_app.tsx', // Configs 'package.json', 'tsconfig.json', 'next.config.js', 'vite.config.ts', 'tailwind.config.js', 'prisma/schema.prisma', // Docs 'README.md', 'CHANGELOG.md', ]; const files = []; for (const pattern of importantPatterns) { const file = await this.readFile(pattern); if (file) { files.push(file); } } return files; } /** * Create a snapshot of the project for context */ async createSnapshot() { const structure = await this.getProjectStructure(3); const files = await this.scanImportantFiles(); // Create summary const summary = this.generateSummary(files); return { rootPath: this.currentWorkspace || '', files, structure, summary, }; } /** * Generate a summary of the project */ generateSummary(files) { const lines = []; // Count files by type const types = new Map(); files.forEach(f => { const count = types.get(f.language) || 0; types.set(f.language, count + 1); }); lines.push('Project Summary:'); types.forEach((count, lang) => { lines.push(`- ${count} ${lang} files scanned`); }); // Total lines of code (approximate) const totalLines = files.reduce((sum, f) => { return sum + f.content.split('\n').length; }, 0); lines.push(`- ~${totalLines} lines of code`); return lines.join('\n'); } /** * Check if file/folder should be ignored */ shouldIgnore(name) { const ignorePatterns = [ 'node_modules', '.git', '.next', 'dist', 'build', '.turbo', 'coverage', '.cache', '.DS_Store', 'yarn-error.log', 'npm-debug.log', '.env.local', '.env', ]; return ignorePatterns.some(pattern => name === pattern || name.startsWith('.')); } /** * Detect programming language from file extension */ detectLanguage(filePath) { const ext = path.extname(filePath).toLowerCase(); const langMap = { '.ts': 'TypeScript', '.tsx': 'TypeScript React', '.js': 'JavaScript', '.jsx': 'JavaScript React', '.py': 'Python', '.rs': 'Rust', '.go': 'Go', '.java': 'Java', '.c': 'C', '.cpp': 'C++', '.cs': 'C#', '.rb': 'Ruby', '.php': 'PHP', '.swift': 'Swift', '.kt': 'Kotlin', '.json': 'JSON', '.md': 'Markdown', '.yaml': 'YAML', '.yml': 'YAML', '.toml': 'TOML', '.sql': 'SQL', '.css': 'CSS', '.scss': 'SCSS', '.html': 'HTML', }; return langMap[ext] || 'Unknown'; } /** * Get icon for file type */ getFileIcon(filename) { const ext = path.extname(filename).toLowerCase(); const iconMap = { '.ts': '📘', '.tsx': '⚛️', '.js': '📜', '.jsx': '⚛️', '.json': '📋', '.md': '📝', '.css': '🎨', '.html': '🌐', '.py': '🐍', '.rs': '🦀', '.go': '🔷', }; return iconMap[ext] || '📄'; } /** * Search for files matching a pattern */ async searchFiles(pattern, maxResults = 20) { if (!this.currentWorkspace) { return []; } const results = []; await this.searchRecursive(this.currentWorkspace, pattern, results, maxResults); return results; } async searchRecursive(dirPath, pattern, results, maxResults) { if (results.length >= maxResults) return; try { const entries = await fsAsync.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { if (results.length >= maxResults) break; if (this.shouldIgnore(entry.name)) continue; const fullPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { await this.searchRecursive(fullPath, pattern, results, maxResults); } else if (entry.name.toLowerCase().includes(pattern.toLowerCase())) { const relativePath = path.relative(this.currentWorkspace, fullPath); const file = await this.readFile(relativePath); if (file) { results.push(file); } } } } catch (error) { // Ignore errors } } /** * Dispose resources (cleanup file watcher) */ dispose() { if (this.fileWatcher) { this.fileWatcher.close(); this.fileWatcher = null; console.error('📁 File watcher disposed'); } } } //# sourceMappingURL=workspace-detector.js.map