Skip to main content
Glama
portel-dev

NCP - Natural Context Provider

by portel-dev
file-watcher.ts13.3 kB
/** * File Watcher Service - Monitor skills and photons directories for changes * * Watches ~/.ncp/skills/ and ~/.ncp/photons/ directories and triggers * incremental index updates when files are added, modified, or deleted. * Uses chokidar for cross-platform support (Windows, macOS, Linux). */ import chokidar, { FSWatcher } from 'chokidar'; import * as path from 'path'; import { logger } from '../utils/logger.js'; import { getNcpBaseDirectory } from '../utils/ncp-paths.js'; export interface FileWatchEvent { type: 'add' | 'change' | 'unlink'; filePath: string; fileName: string; directory: 'skills' | 'photons'; } export interface FileWatcherCallbacks { onSkillAdded?: (fileName: string, filePath: string) => Promise<void>; onSkillModified?: (fileName: string, filePath: string) => Promise<void>; onSkillRemoved?: (fileName: string) => Promise<void>; onPhotonAdded?: (fileName: string, filePath: string) => Promise<void>; onPhotonModified?: (fileName: string, filePath: string) => Promise<void>; onPhotonRemoved?: (fileName: string) => Promise<void>; onError?: (error: Error) => void; } export class FileWatcher { private skillsWatcher: FSWatcher | null = null; private photonsWatcher: FSWatcher | null = null; private callbacks: FileWatcherCallbacks; private isInitialized = false; private debounceTimers: Map<string, NodeJS.Timeout> = new Map(); private debounceMs: number; // Batch operation detection private batchOperations: Map<string, number> = new Map(); // Track file changes in batch private batchStartTime: number | null = null; private batchCompletionTimer: NodeJS.Timeout | null = null; private readonly BATCH_COMPLETION_WINDOW_MS = 1000; // Consider batch complete if no changes for 1 second private readonly BATCH_SIZE_THRESHOLD = 10; // Treat as batch if >=10 files // Metrics tracking private metrics = { skillsAdded: 0, skillsModified: 0, skillsRemoved: 0, photonsAdded: 0, photonsModified: 0, photonsRemoved: 0, totalOperations: 0, lastBatchSize: 0, lastBatchDuration: 0 }; // Patterns to ignore (temp files, editor backups, system files) private static IGNORE_PATTERNS = [ /~$/, // Vim/Emacs backup files /\.swp$/, // Vim swap files /\.swo$/, // Vim swap files /\.bak$/, // Generic backups /\.tmp$/, // Temp files /\.orig$/, // Original copies /^\._/, // macOS resource forks /^\.DS_Store$/, // macOS folder metadata /Thumbs\.db$/, // Windows thumbnails /~\d+$/, // Word/Office backups ]; constructor(callbacks: FileWatcherCallbacks = {}, debounceMs?: number) { this.callbacks = callbacks; // Default 300ms debounce, configurable via NCP_FILE_WATCHER_DEBOUNCE_MS this.debounceMs = debounceMs || parseInt(process.env.NCP_FILE_WATCHER_DEBOUNCE_MS || '300', 10); } /** * Check if a file should be ignored */ private shouldIgnoreFile(fileName: string): boolean { return FileWatcher.IGNORE_PATTERNS.some(pattern => pattern.test(fileName)); } /** * Debounce file event processing */ private debounceEvent(key: string, callback: () => Promise<void>): void { // Clear existing timer for this key const existingTimer = this.debounceTimers.get(key); if (existingTimer) { clearTimeout(existingTimer); } // Set new debounced timer const newTimer = setTimeout(() => { this.debounceTimers.delete(key); callback().catch(error => { logger.error(`Debounced event callback failed: ${error.message}`); }); }, this.debounceMs); this.debounceTimers.set(key, newTimer); } /** * Track file operation for batch detection */ private trackBatchOperation(resourceType: string, operationType: string, resourceName: string): void { // Start batch if this is the first operation if (this.batchOperations.size === 0) { this.batchStartTime = Date.now(); logger.debug(`📦 Batch operation started`); } // Add to batch operations map const operationKey = `${resourceType}:${operationType}:${resourceName}`; this.batchOperations.set(operationKey, Date.now()); // Clear existing batch completion timer if (this.batchCompletionTimer) { clearTimeout(this.batchCompletionTimer); } // Set new batch completion timer this.batchCompletionTimer = setTimeout(() => { this.completeBatch(); }, this.BATCH_COMPLETION_WINDOW_MS); // Update metrics based on operation type this.metrics.totalOperations++; if (resourceType === 'skill') { if (operationType === 'add') this.metrics.skillsAdded++; else if (operationType === 'modify') this.metrics.skillsModified++; else if (operationType === 'remove') this.metrics.skillsRemoved++; } else if (resourceType === 'photon') { if (operationType === 'add') this.metrics.photonsAdded++; else if (operationType === 'modify') this.metrics.photonsModified++; else if (operationType === 'remove') this.metrics.photonsRemoved++; } } /** * Complete batch and log metrics */ private completeBatch(): void { if (this.batchOperations.size === 0) return; const batchSize = this.batchOperations.size; const batchDuration = this.batchStartTime ? Date.now() - this.batchStartTime : 0; const isBatch = batchSize >= this.BATCH_SIZE_THRESHOLD; // Update metrics this.metrics.lastBatchSize = batchSize; this.metrics.lastBatchDuration = batchDuration; // Log batch completion if (isBatch) { logger.info(`📦 Batch operation completed: ${batchSize} files in ${batchDuration}ms`); logger.debug(` Skills: +${this.metrics.skillsAdded}/-${this.metrics.skillsRemoved}/${this.metrics.skillsModified}m`); logger.debug(` Photons: +${this.metrics.photonsAdded}/-${this.metrics.photonsRemoved}/${this.metrics.photonsModified}m`); } // Reset batch state this.batchOperations.clear(); this.batchStartTime = null; this.batchCompletionTimer = null; } /** * Get FileWatcher metrics */ getMetrics(): typeof this.metrics { return { ...this.metrics }; } /** * Start watching for file changes in skills and photons directories */ async start(): Promise<void> { if (this.isInitialized) { logger.debug('FileWatcher already started'); return; } const ncpDir = getNcpBaseDirectory(); const skillsDir = path.join(ncpDir, 'skills'); const photonsDir = path.join(ncpDir, 'photons'); try { // Watch skills directory this.skillsWatcher = chokidar.watch(skillsDir, { persistent: true, ignored: /(^|[/\\])\.|node_modules|\.git/, awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 100 } }); this.skillsWatcher.on('add', (filePath) => this.handleSkillEvent('add', filePath)); this.skillsWatcher.on('change', (filePath) => this.handleSkillEvent('change', filePath)); this.skillsWatcher.on('unlink', (filePath) => this.handleSkillEvent('unlink', filePath)); this.skillsWatcher.on('error', (error: any) => { logger.error(`Skills watcher error: ${error?.message || error}`); if (error instanceof Error) { this.callbacks.onError?.(error); } }); // Watch photons directory this.photonsWatcher = chokidar.watch(photonsDir, { persistent: true, ignored: /(^|[/\\])\.|node_modules|\.git/, awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 100 } }); this.photonsWatcher.on('add', (filePath) => this.handlePhotonEvent('add', filePath)); this.photonsWatcher.on('change', (filePath) => this.handlePhotonEvent('change', filePath)); this.photonsWatcher.on('unlink', (filePath) => this.handlePhotonEvent('unlink', filePath)); this.photonsWatcher.on('error', (error: any) => { logger.error(`Photons watcher error: ${error?.message || error}`); if (error instanceof Error) { this.callbacks.onError?.(error); } }); this.isInitialized = true; logger.info(`📁 File watcher started for skills and photons directories (debounce: ${this.debounceMs}ms)`); } catch (error: any) { logger.error(`Failed to start file watcher: ${error.message}`); this.callbacks.onError?.(error); throw error; } } /** * Stop watching for file changes */ async stop(): Promise<void> { if (!this.isInitialized) { logger.debug('FileWatcher not started'); return; } try { // Clear all pending debounce timers for (const timer of this.debounceTimers.values()) { clearTimeout(timer); } this.debounceTimers.clear(); // Clear batch completion timer and complete any pending batch if (this.batchCompletionTimer) { clearTimeout(this.batchCompletionTimer); this.completeBatch(); // Finalize any pending batch } await this.skillsWatcher?.close(); await this.photonsWatcher?.close(); this.isInitialized = false; logger.info('📁 File watcher stopped'); } catch (error: any) { logger.error(`Failed to stop file watcher: ${error.message}`); this.callbacks.onError?.(error); throw error; } } /** * Handle skill directory events */ private async handleSkillEvent(type: 'add' | 'change' | 'unlink', filePath: string) { try { // Only process SKILL.md files or skill directories const fileName = path.basename(filePath); // Ignore temp/system files if (this.shouldIgnoreFile(fileName)) { logger.debug(`Ignoring temp file: ${fileName}`); return; } const parentDir = path.basename(path.dirname(filePath)); const ncpDir = getNcpBaseDirectory(); const skillsDir = path.join(ncpDir, 'skills'); // For SKILL.md files or when parent is a skill directory if (fileName === 'SKILL.md' || (filePath.startsWith(skillsDir) && filePath !== skillsDir)) { const skillName = fileName === 'SKILL.md' ? parentDir : fileName.replace(/\.(ts|js|md)$/, ''); logger.debug(`Skill ${type}: ${skillName} (${filePath})`); // Track for batch operation detection const operationType = type === 'change' ? 'modify' : type; this.trackBatchOperation('skill', operationType, skillName); // Debounce the event to prevent duplicate processing const debounceKey = `skill:${skillName}:${type}`; this.debounceEvent(debounceKey, async () => { if (type === 'add' && this.callbacks.onSkillAdded) { await this.callbacks.onSkillAdded(skillName, filePath); } else if (type === 'change' && this.callbacks.onSkillModified) { await this.callbacks.onSkillModified(skillName, filePath); } else if (type === 'unlink' && this.callbacks.onSkillRemoved) { await this.callbacks.onSkillRemoved(skillName); } }); } } catch (error: any) { logger.error(`Error handling skill event: ${error.message}`); this.callbacks.onError?.(error); } } /** * Handle photon directory events */ private async handlePhotonEvent(type: 'add' | 'change' | 'unlink', filePath: string) { try { // Only process .photon.ts files const fileName = path.basename(filePath); // Ignore temp/system files if (this.shouldIgnoreFile(fileName)) { logger.debug(`Ignoring temp file: ${fileName}`); return; } // Match photon files ending in .photon.ts or .photon.js if (fileName.match(/\.photon\.(ts|js)$/)) { const photonName = fileName.replace(/\.photon\.(ts|js)$/, ''); logger.debug(`Photon ${type}: ${photonName} (${filePath})`); // Track for batch operation detection const operationType = type === 'change' ? 'modify' : type; this.trackBatchOperation('photon', operationType, photonName); // Debounce the event to prevent duplicate processing const debounceKey = `photon:${photonName}:${type}`; this.debounceEvent(debounceKey, async () => { if (type === 'add' && this.callbacks.onPhotonAdded) { await this.callbacks.onPhotonAdded(photonName, filePath); } else if (type === 'change' && this.callbacks.onPhotonModified) { await this.callbacks.onPhotonModified(photonName, filePath); } else if (type === 'unlink' && this.callbacks.onPhotonRemoved) { await this.callbacks.onPhotonRemoved(photonName); } }); } } catch (error: any) { logger.error(`Error handling photon event: ${error.message}`); this.callbacks.onError?.(error); } } /** * Check if watcher is initialized */ isRunning(): boolean { return this.isInitialized; } } // Singleton instance for application-wide use let fileWatcherInstance: FileWatcher | null = null; export function getFileWatcher(callbacks?: FileWatcherCallbacks): FileWatcher { if (!fileWatcherInstance) { fileWatcherInstance = new FileWatcher(callbacks); } else if (callbacks) { // Update callbacks if provided fileWatcherInstance['callbacks'] = callbacks; } return fileWatcherInstance; }

Latest Blog Posts

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/portel-dev/ncp'

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