/** * CommentWatcher - Watches for file changes and detects AI comments. * * This class provides a higher-level abstraction over file watching that: * 1. Watches files using a Chokidar-compatible watcher * 2. Parses files for AI comments * 3. Manages pending comments across files * 4. Emits callbacks for AI comments, edit triggers (AI!), and question triggers (AI?) * * Behavior: * - AI comments (without ! or ?) are collected as pending changes * - AI! comments trigger the sending of all pending comments as edit instructions * - AI? comments trigger the sending of all pending comments as a question * - AI- comments ignore the whole file * - Event processing is paused while agent is editing files to avoid duplicates * * Usage: * const watcher = new CommentWatcher(chokidarInstance, callbacks, options); * watcher.watch("/path/to/watch"); */ import { DEFAULT_IGNORED_PATTERNS, hasAIIgnoreFileMarker, hasQuestionComment, hasTriggerComment, matchesPiWatchIgnore, readFileAndParseComments, readPiWatchIgnorePatterns, removeAICommentsFromFiles, shouldIgnorePath, } from "./core.js"; import type { CommentWatcherCallbacks, CommentWatcherOptions, FSWatcherLike, ParsedComment, WatcherFactory, } from "./types.js"; const DEFAULT_OPTIONS: Required = { ignoredPatterns: DEFAULT_IGNORED_PATTERNS, cwd: process.cwd(), ignoreInitial: true, stabilityThreshold: 500, pollInterval: 50, }; export class CommentWatcher { private fsWatcher: FSWatcherLike | null = null; private pendingComments: Map = new Map(); private callbacks: Required; private options: Required; private isWatching = false; private isPaused = false; private watchPaths: string[] = []; constructor( private watcherFactory: WatcherFactory, callbacks: CommentWatcherCallbacks = {}, options: CommentWatcherOptions = {} ) { this.options = { ...DEFAULT_OPTIONS, ...options }; this.callbacks = { onAIComment: callbacks.onAIComment ?? (() => {}), onAITrigger: callbacks.onAITrigger ?? (() => {}), onAIQuestion: callbacks.onAIQuestion ?? (() => {}), onReady: callbacks.onReady ?? (() => {}), onError: callbacks.onError ?? (() => {}), }; } /** * Start watching one or more paths for AI comments. */ watch(watchPath: string | string[], preservePending = false): void { const pendingComments = preservePending ? new Map(this.pendingComments) : null; if (this.isWatching) { this.close(); } if (pendingComments) { this.pendingComments = pendingComments; } this.watchPaths = Array.isArray(watchPath) ? [...watchPath] : [watchPath]; this.fsWatcher = this.watcherFactory(this.watchPaths, { ignored: this.options.ignoredPatterns, persistent: true, ignoreInitial: this.options.ignoreInitial, awaitWriteFinish: { stabilityThreshold: this.options.stabilityThreshold, pollInterval: this.options.pollInterval, }, }); this.isWatching = true; this.fsWatcher .on("add", this.handleChange.bind(this)) .on("change", this.handleChange.bind(this)) .on("unlink", this.handleChange.bind(this)) .on("ready", () => this.callbacks.onReady()) .on("error", (error: Error) => this.callbacks.onError(error)); } /** * Stop watching and clean up resources. */ close(): void { if (this.fsWatcher) { void this.fsWatcher.close(); this.fsWatcher = null; } this.isWatching = false; this.watchPaths = []; this.clearPending(); } /** * Pause processing of file change events. * Useful for pausing while agent is editing files. */ pause(): void { this.isPaused = true; } /** * Resume processing of file change events. */ resume(): void { this.isPaused = false; } /** * Check if the watcher is currently paused. */ isWatcherPaused(): boolean { return this.isPaused; } /** * Clear all pending comments. */ clearPending(): void { this.pendingComments.clear(); } /** * Get all currently pending comments. */ getPendingComments(): ParsedComment[] { const allComments: ParsedComment[] = []; for (const fileComments of this.pendingComments.values()) { allComments.push(...fileComments); } return allComments; } /** * Return a serializable snapshot of watcher state. */ getStatus(): { isWatching: boolean; isPaused: boolean; watchPaths: string[]; pendingCount: number; pendingFiles: string[]; ignoredPatterns: string[]; } { return { isWatching: this.isWatching, isPaused: this.isPaused, watchPaths: [...this.watchPaths], pendingCount: this.getPendingComments().length, pendingFiles: [...this.pendingComments.keys()], ignoredPatterns: this.options.ignoredPatterns.map((pattern) => pattern.source), }; } /** * Add a path to the active watch list. */ addWatchPath(watchPath: string): void { const paths = new Set(this.watchPaths); paths.add(watchPath); this.watch([...paths], true); } /** * Remove a path from the active watch list. */ removeWatchPath(watchPath: string): void { const paths = this.watchPaths.filter((path) => path !== watchPath); if (paths.length === 0) { this.close(); return; } this.watch(paths, true); } /** * Add an ignore pattern at runtime and restart the watcher if active. */ addIgnorePattern(pattern: RegExp): void { this.options.ignoredPatterns = [...this.options.ignoredPatterns, pattern]; if (this.isWatching) { this.watch(this.watchPaths, true); } } /** * Handle file change events from the file system watcher. */ private handleChange(filePath: string): void { // Skip processing if paused (e.g., while agent is editing files) if (this.isPaused) { return; } if ( shouldIgnorePath(filePath, this.options.ignoredPatterns) || matchesPiWatchIgnore(filePath, this.options.cwd, readPiWatchIgnorePatterns(this.options.cwd)) ) { this.pendingComments.delete(filePath); return; } const result = readFileAndParseComments(filePath); if (!result) { // File not readable - remove any pending comments for this file this.pendingComments.delete(filePath); return; } const { content, comments } = result; if (hasAIIgnoreFileMarker(content)) { this.pendingComments.delete(filePath); return; } if (comments.length === 0) { // No AI comments - remove any pending for this file this.pendingComments.delete(filePath); return; } // Update pending comments for this file this.pendingComments.set(filePath, comments); // Check if any comment has an edit trigger (AI!) if (hasTriggerComment(comments)) { const allComments = this.getPendingComments(); this.callbacks.onAITrigger(allComments); try { removeAICommentsFromFiles(allComments); } catch (error) { this.callbacks.onError(error instanceof Error ? error : new Error(String(error))); } this.clearPending(); return; } // Check if any comment has a question trigger (AI?) if (hasQuestionComment(comments)) { const allComments = this.getPendingComments(); this.callbacks.onAIQuestion(allComments); try { removeAICommentsFromFiles(allComments); } catch (error) { this.callbacks.onError(error instanceof Error ? error : new Error(String(error))); } this.clearPending(); return; } // Emit callbacks for each collect-only AI comment for (const comment of comments) { this.callbacks.onAIComment(comment, this.getPendingComments()); } } }