Project Files
src / documents / fileWatcher.ts
/**
* File Watcher
* Monitors content directories for changes and triggers re-indexing
*/
import chokidar, { FSWatcher } from 'chokidar';
import { DocumentLoader } from './loader';
import { ragLog } from '../utils/ragLogger.js';
export interface FileWatcherConfig {
directories: string[];
onFileAdded?: (path: string) => Promise<void>;
onFileChanged?: (path: string) => Promise<void>;
onFileDeleted?: (path: string) => Promise<void>;
ignoreInitial?: boolean;
changeDebounceMs?: number;
onReady?: () => void;
isSupportedFile?: (path: string) => boolean;
}
export class FileWatcher {
private watcher: FSWatcher | null = null;
private config: FileWatcherConfig;
private isRunning = false;
private readyPromise: Promise<void> | null = null;
private pendingEvents = new Map<string, ReturnType<typeof setTimeout>>();
constructor(config: FileWatcherConfig) {
this.config = config;
}
/**
* Start watching directories
*/
start(): Promise<void> {
if (this.isRunning) {
console.warn('FileWatcher is already running');
return Promise.resolve();
}
if (this.config.directories.length === 0) {
console.warn('No directories to watch');
return Promise.resolve();
}
ragLog('FileWatcher', `Starting with ignoreInitial=${this.config.ignoreInitial ?? false}`);
ragLog('FileWatcher', `Watching directories: ${JSON.stringify(this.config.directories)}`);
this.readyPromise = new Promise((resolve) => {
this.watcher = chokidar.watch(this.config.directories, {
persistent: true,
ignoreInitial: this.config.ignoreInitial ?? false,
awaitWriteFinish: {
stabilityThreshold: this.config.changeDebounceMs ?? 1500,
pollInterval: 100,
},
atomic: true,
// Only watch supported file types
ignored: (path: string) => {
// Ignore node_modules
if (path.includes('/node_modules/')) {
return true;
}
// Ignore hidden files/directories — check only the basename,
// not the full path (which may legitimately pass through ~/.lmstudio/).
const basename = path.split('/').pop() ?? '';
if (basename.startsWith('.') && basename !== '.' && basename !== '..') {
ragLog('FileWatcher', `Ignoring (hidden): ${path}`);
return true;
}
// Check if path has a file extension (is likely a file, not a directory)
const hasExtension = /\.[^/]+$/.test(path);
if (hasExtension) {
// It's a file - check if supported
const supported = this.config.isSupportedFile
? this.config.isSupportedFile(path)
: DocumentLoader.isSupported(path);
if (!supported) {
ragLog('FileWatcher', `Ignoring (unsupported): ${path}`);
} else {
ragLog('FileWatcher', `Accepting: ${path}`);
}
return !supported;
}
// No extension = likely a directory - don't ignore
return false;
},
});
this.watcher
.on('addDir', (path) => {
ragLog('FileWatcher', `Directory: ${path}`);
})
.on('add', async (path) => {
ragLog('FileWatcher', `ADD: ${path}`);
this.schedule('add', path, this.config.onFileAdded);
})
.on('change', async (path) => {
ragLog('FileWatcher', `CHANGE: ${path}`);
this.schedule('change', path, this.config.onFileChanged);
})
.on('unlink', async (path) => {
ragLog('FileWatcher', `DELETE: ${path}`);
this.cancel(path);
if (this.config.onFileDeleted) {
try {
await this.config.onFileDeleted(path);
} catch (error) {
ragLog('FileWatcher', `ERROR on delete ${path}: ${error}`);
}
}
})
.on('error', (error) => {
ragLog('FileWatcher', `ERROR: ${error}`);
})
.on('ready', () => {
ragLog('FileWatcher', `Ready. Watching: ${JSON.stringify(this.config.directories)}`);
this.isRunning = true;
if (this.config.onReady) {
this.config.onReady();
}
resolve();
});
});
return this.readyPromise;
}
/**
* Stop watching
*/
async stop(): Promise<void> {
for (const timer of this.pendingEvents.values()) clearTimeout(timer);
this.pendingEvents.clear();
if (this.watcher) {
await this.watcher.close();
this.watcher = null;
this.isRunning = false;
ragLog('FileWatcher', 'Stopped');
}
}
/**
* Check if watcher is running
*/
getStatus(): boolean {
return this.isRunning;
}
/**
* Get watched paths
*/
getWatchedPaths(): string[] {
if (!this.watcher) return [];
return Object.keys(this.watcher.getWatched());
}
private schedule(eventName: string, filePath: string, handler?: (path: string) => Promise<void>): void {
if (!handler) return;
this.cancel(filePath);
const delayMs = this.config.changeDebounceMs ?? 1500;
const timer = setTimeout(async () => {
this.pendingEvents.delete(filePath);
try {
await handler(filePath);
} catch (error) {
ragLog('FileWatcher', `ERROR on ${eventName} ${filePath}: ${error}`);
}
}, delayMs);
this.pendingEvents.set(filePath, timer);
}
private cancel(filePath: string): void {
const timer = this.pendingEvents.get(filePath);
if (!timer) return;
clearTimeout(timer);
this.pendingEvents.delete(filePath);
}
}