Skip to main content
Glama

In Memoria

file-watcher.ts10.1 kB
import * as chokidar from 'chokidar'; import { EventEmitter } from 'eventemitter3'; import { createHash } from 'crypto'; import { readFileSync, statSync } from 'fs'; import { join, extname } from 'path'; export interface FileChange { type: 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'; path: string; stats?: { size: number; mtime: Date; isDirectory: boolean; }; content?: string; hash?: string; language?: string; } export interface WatcherOptions { patterns: string[]; ignored?: string[]; debounceMs?: number; includeContent?: boolean; persistent?: boolean; } export class FileWatcher extends EventEmitter { private watcher: chokidar.FSWatcher | null = null; private debounceTimers: Map<string, NodeJS.Timeout> = new Map(); private fileHashes: Map<string, string> = new Map(); private options: Required<WatcherOptions>; constructor(options: WatcherOptions) { super(); this.options = { patterns: options.patterns, ignored: options.ignored || [ '**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**', '**/.next/**', '**/target/**', '**/*.log' ], debounceMs: options.debounceMs || 500, includeContent: options.includeContent ?? true, persistent: options.persistent ?? true }; } startWatching(): void { if (this.watcher) { this.stopWatching(); } this.watcher = chokidar.watch(this.options.patterns, { ignored: this.options.ignored, ignoreInitial: false, persistent: this.options.persistent, followSymlinks: false, atomic: true, alwaysStat: true, depth: undefined, interval: 100, binaryInterval: 300, usePolling: false, awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 100 } }); this.setupEventHandlers(); this.emit('watcher:started', { patterns: this.options.patterns }); } stopWatching(): void { if (this.watcher) { this.watcher.close(); this.watcher = null; } // Clear all debounce timers this.debounceTimers.forEach(timer => clearTimeout(timer)); this.debounceTimers.clear(); this.emit('watcher:stopped'); } isWatching(): boolean { return this.watcher !== null; } getWatchedPaths(): string[] { return this.watcher ? this.watcher.getWatched() as any : []; } addPath(path: string): void { if (this.watcher) { this.watcher.add(path); } } removePath(path: string): void { if (this.watcher) { this.watcher.unwatch(path); } } private setupEventHandlers(): void { if (!this.watcher) return; this.watcher.on('add', (path, stats) => { this.handleFileChange('add', path, stats); }); this.watcher.on('change', (path, stats) => { this.handleFileChange('change', path, stats); }); this.watcher.on('unlink', (path) => { this.handleFileChange('unlink', path); this.fileHashes.delete(path); }); this.watcher.on('addDir', (path, stats) => { this.handleFileChange('addDir', path, stats); }); this.watcher.on('unlinkDir', (path) => { this.handleFileChange('unlinkDir', path); }); this.watcher.on('error', (error) => { this.emit('watcher:error', error); }); this.watcher.on('ready', () => { this.emit('watcher:ready'); }); } private handleFileChange( type: FileChange['type'], path: string, stats?: any ): void { // Clear existing debounce timer for this path const existingTimer = this.debounceTimers.get(path); if (existingTimer) { clearTimeout(existingTimer); } // Set new debounce timer const timer = setTimeout(async () => { this.debounceTimers.delete(path); try { const change = await this.buildFileChange(type, path, stats); // Skip if content hasn't actually changed (for change events) if (type === 'change' && !this.hasContentChanged(path, change.hash)) { return; } // Update hash for file content tracking if (change.hash && !change.stats?.isDirectory) { this.fileHashes.set(path, change.hash); } this.emit('file:change', change); this.emit(`file:${type}`, change); // Emit specific events for different file types if (change.language) { this.emit(`file:${change.language}:${type}`, change); } } catch (error) { this.emit('watcher:error', { message: `Failed to process file change: ${path}`, error, path, type }); } }, this.options.debounceMs); this.debounceTimers.set(path, timer); } private async buildFileChange( type: FileChange['type'], path: string, stats?: any ): Promise<FileChange> { const change: FileChange = { type, path }; // Add stats if available if (stats) { change.stats = { size: stats.size, mtime: stats.mtime, isDirectory: stats.isDir() }; } // For file operations (not directories), add content and metadata if (type !== 'unlinkDir' && type !== 'addDir') { try { const actualStats = stats || statSync(path); if (!actualStats.isDirectory()) { change.language = this.detectLanguage(path); if (this.options.includeContent && this.isTextFile(path)) { change.content = readFileSync(path, 'utf-8'); change.hash = this.calculateHash(change.content); } else if (!this.options.includeContent) { // Calculate hash from file stats for binary files or when content is disabled const statsString = `${actualStats.size}-${actualStats.mtime.getTime()}`; change.hash = this.calculateHash(statsString); } } } catch (error) { // File might have been deleted between stat and read if (type !== 'unlink') { throw error; } } } return change; } private hasContentChanged(path: string, newHash?: string): boolean { if (!newHash) return true; const oldHash = this.fileHashes.get(path); return oldHash !== newHash; } private detectLanguage(filePath: string): string { const ext = extname(filePath).toLowerCase(); const languageMap: Record<string, string> = { '.ts': 'typescript', '.tsx': 'typescript', '.js': 'javascript', '.jsx': 'javascript', '.py': 'python', '.rs': 'rust', '.go': 'go', '.java': 'java', '.cpp': 'cpp', '.cc': 'cpp', '.cxx': 'cpp', '.c': 'c', '.h': 'c', '.hpp': 'cpp', '.cs': 'csharp', '.php': 'php', '.rb': 'ruby', '.swift': 'swift', '.kt': 'kotlin', '.scala': 'scala', '.clj': 'clojure', '.hs': 'haskell', '.ml': 'ocaml', '.fs': 'fsharp', '.elm': 'elm', '.dart': 'dart', '.r': 'r', '.jl': 'julia', '.lua': 'lua', '.pl': 'perl', '.sh': 'bash', '.ps1': 'powershell', '.sql': 'sql', '.html': 'html', '.css': 'css', '.scss': 'scss', '.sass': 'sass', '.less': 'less', '.json': 'json', '.xml': 'xml', '.yaml': 'yaml', '.yml': 'yaml', '.toml': 'toml', '.ini': 'ini', '.cfg': 'ini', '.conf': 'conf', '.md': 'markdown', '.rst': 'rst', '.tex': 'latex' }; return languageMap[ext] || 'unknown'; } private isTextFile(filePath: string): boolean { const textExtensions = new Set([ '.ts', '.tsx', '.js', '.jsx', '.py', '.rs', '.go', '.java', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cs', '.php', '.rb', '.swift', '.kt', '.scala', '.clj', '.hs', '.ml', '.fs', '.elm', '.dart', '.r', '.jl', '.lua', '.pl', '.sh', '.ps1', '.sql', '.html', '.css', '.scss', '.sass', '.less', '.json', '.xml', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf', '.md', '.rst', '.tex', '.txt', '.log', '.gitignore', '.dockerignore', '.editorconfig' ]); const ext = extname(filePath).toLowerCase(); return textExtensions.has(ext); } private calculateHash(content: string): string { return createHash('sha256').update(content).digest('hex'); } // Utility methods for advanced filtering addIgnorePattern(pattern: string): void { if (this.watcher) { // Note: chokidar doesn't support dynamic ignore pattern updates // This would require restarting the watcher console.warn('Dynamic ignore pattern updates require restarting the watcher'); } } getFileStats(): { totalWatched: number; byLanguage: Record<string, number>; byType: Record<string, number>; } { const stats = { totalWatched: this.options.patterns.length, byLanguage: {} as Record<string, number>, byType: {} as Record<string, number> }; // Analyze patterns to determine file types and languages this.options.patterns.forEach(pattern => { if (pattern.includes('*.ts')) { stats.byLanguage['typescript'] = (stats.byLanguage['typescript'] || 0) + 1; stats.byType['source'] = (stats.byType['source'] || 0) + 1; } else if (pattern.includes('*.js')) { stats.byLanguage['javascript'] = (stats.byLanguage['javascript'] || 0) + 1; stats.byType['source'] = (stats.byType['source'] || 0) + 1; } else if (pattern.includes('*.py')) { stats.byLanguage['python'] = (stats.byLanguage['python'] || 0) + 1; stats.byType['source'] = (stats.byType['source'] || 0) + 1; } else if (pattern.includes('*.rs')) { stats.byLanguage['rust'] = (stats.byLanguage['rust'] || 0) + 1; stats.byType['source'] = (stats.byType['source'] || 0) + 1; } else { stats.byType['other'] = (stats.byType['other'] || 0) + 1; } }); return stats; } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/pi22by7/In-Memoria'

If you have feedback or need assistance with the MCP directory API, please join our Discord server