From f300961b73b1ee867bfc43f0b2925d3f7c055447 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sat, 28 Feb 2026 17:53:42 +0100 Subject: [PATCH 1/3] feat(watcher): chokidar auto-refresh with debounced incremental reindex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a chokidar-based file watcher that runs in MCP server mode. Any source file change in the project root triggers a debounced incremental reindex automatically — no manual refresh_index needed. - src/core/file-watcher.ts: FileWatcherOptions interface + startFileWatcher() with configurable debounce (default 2 s, override via CODEBASE_CONTEXT_DEBOUNCE_MS) - src/index.ts: wires watcher after server.connect(), guards against concurrent reindexes with indexState.status check, cleans up on exit/SIGINT/SIGTERM - tests/file-watcher.test.ts: 3 vitest tests covering trigger, debounce coalescing, and stop() cancellation (real fs writes, no fake timers) - package.json: adds chokidar ^3.6.0 dependency Co-Authored-By: Claude Sonnet 4.6 --- package.json | 1 + pnpm-lock.yaml | 56 +++++++++++++++++++++++++ src/core/file-watcher.ts | 44 ++++++++++++++++++++ src/index.ts | 28 +++++++++++++ tests/file-watcher.test.ts | 83 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 212 insertions(+) create mode 100644 src/core/file-watcher.ts create mode 100644 tests/file-watcher.test.ts diff --git a/package.json b/package.json index 5b23ad5..f4f22be 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "@lancedb/lancedb": "^0.4.0", "@modelcontextprotocol/sdk": "^1.25.2", "@typescript-eslint/typescript-estree": "^7.0.0", + "chokidar": "^3.6.0", "fuse.js": "^7.0.0", "glob": "^10.3.10", "hono": "^4.12.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7894424..fddce61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@typescript-eslint/typescript-estree': specifier: ^7.0.0 version: 7.18.0(typescript@5.9.3) + chokidar: + specifier: ^3.6.0 + version: 3.6.0 fuse.js: specifier: ^7.0.0 version: 7.1.0 @@ -882,6 +885,10 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + apache-arrow@15.0.2: resolution: {integrity: sha512-RvwlFxLRpO405PLGffx4N2PYLiF7FD86Q1hHl6J2XCWiq+tTCzpb9ngFw0apFDcXZBMpCzMuwAvA7hjyL1/73A==} hasBin: true @@ -944,6 +951,10 @@ packages: resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} engines: {node: 20 || >=22} + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -992,6 +1003,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -1534,6 +1549,10 @@ packages: resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} engines: {node: '>= 0.4'} + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + is-boolean-object@1.2.2: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} @@ -1781,6 +1800,10 @@ packages: encoding: optional: true + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1958,6 +1981,10 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -3116,6 +3143,11 @@ snapshots: ansi-styles@6.2.3: {} + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + apache-arrow@15.0.2: dependencies: '@swc/helpers': 0.5.17 @@ -3198,6 +3230,8 @@ snapshots: balanced-match@4.0.3: {} + binary-extensions@2.3.0: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -3254,6 +3288,18 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + chownr@3.0.0: {} color-convert@2.0.1: @@ -3929,6 +3975,10 @@ snapshots: dependencies: has-bigints: 1.1.0 + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + is-boolean-object@1.2.2: dependencies: call-bound: 1.0.4 @@ -4137,6 +4187,8 @@ snapshots: dependencies: whatwg-url: 5.0.0 + normalize-path@3.0.0: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -4322,6 +4374,10 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 diff --git a/src/core/file-watcher.ts b/src/core/file-watcher.ts new file mode 100644 index 0000000..6144990 --- /dev/null +++ b/src/core/file-watcher.ts @@ -0,0 +1,44 @@ +import chokidar from 'chokidar'; + +export interface FileWatcherOptions { + rootPath: string; + /** ms after last change before triggering. Default: 2000 */ + debounceMs?: number; + /** Called once the debounce window expires after the last detected change */ + onChanged: () => void; +} + +/** + * Watch rootPath for source file changes and call onChanged (debounced). + * Returns a stop() function that cancels the debounce timer and closes the watcher. + */ +export function startFileWatcher(opts: FileWatcherOptions): () => void { + const { rootPath, debounceMs = 2000, onChanged } = opts; + let debounceTimer: ReturnType | undefined; + + const trigger = () => { + if (debounceTimer !== undefined) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = undefined; + onChanged(); + }, debounceMs); + }; + + const watcher = chokidar.watch(rootPath, { + ignored: ['**/node_modules/**', '**/.codebase-context/**', '**/.git/**', '**/dist/**'], + persistent: true, + ignoreInitial: true, + awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 100 } + }); + + watcher + .on('add', trigger) + .on('change', trigger) + .on('unlink', trigger) + .on('error', (err: unknown) => console.error('[file-watcher] error:', err)); + + return () => { + if (debounceTimer !== undefined) clearTimeout(debounceTimer); + void watcher.close(); + }; +} diff --git a/src/index.ts b/src/index.ts index 15fda08..dee6e08 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,7 @@ import { } from './constants/codebase-context.js'; import { appendMemoryFile } from './memory/store.js'; import { handleCliCommand } from './cli.js'; +import { startFileWatcher } from './core/file-watcher.js'; import { parseGitLogLineToMemory } from './memory/git-memory.js'; import { isComplementaryPatternCategory, @@ -726,6 +727,33 @@ async function main() { await server.connect(transport); if (process.env.CODEBASE_CONTEXT_DEBUG) console.error('[DEBUG] Server ready'); + + // Auto-refresh: watch for file changes and trigger incremental reindex + const debounceMs = parseInt(process.env.CODEBASE_CONTEXT_DEBOUNCE_MS ?? '', 10) || 2000; + const stopWatcher = startFileWatcher({ + rootPath: ROOT_PATH, + debounceMs, + onChanged: () => { + if (indexState.status === 'indexing') { + if (process.env.CODEBASE_CONTEXT_DEBUG) { + console.error('[file-watcher] Index in progress — skipping auto-refresh'); + } + return; + } + console.error('[file-watcher] Changes detected — incremental reindex starting'); + performIndexing(true); + } + }); + + process.once('exit', stopWatcher); + process.once('SIGINT', () => { + stopWatcher(); + process.exit(0); + }); + process.once('SIGTERM', () => { + stopWatcher(); + process.exit(0); + }); } // Export server components for programmatic use diff --git a/tests/file-watcher.test.ts b/tests/file-watcher.test.ts new file mode 100644 index 0000000..01f4259 --- /dev/null +++ b/tests/file-watcher.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { startFileWatcher } from '../src/core/file-watcher.js'; + +describe('FileWatcher', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'file-watcher-test-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('triggers onChanged after debounce window', async () => { + const debounceMs = 400; + let callCount = 0; + + const stop = startFileWatcher({ + rootPath: tempDir, + debounceMs, + onChanged: () => { callCount++; }, + }); + + try { + // Give chokidar a moment to finish initializing before the first write + await new Promise((resolve) => setTimeout(resolve, 100)); + await fs.writeFile(path.join(tempDir, 'test.ts'), 'export const x = 1;'); + // Wait for chokidar to pick up the event (including awaitWriteFinish stabilityThreshold) + // + debounce window + OS scheduling slack + await new Promise((resolve) => setTimeout(resolve, debounceMs + 1000)); + expect(callCount).toBe(1); + } finally { + stop(); + } + }, 8000); + + it('debounces rapid changes into a single callback', async () => { + const debounceMs = 300; + let callCount = 0; + + const stop = startFileWatcher({ + rootPath: tempDir, + debounceMs, + onChanged: () => { callCount++; }, + }); + + try { + // Write 5 files in quick succession — all within the debounce window + for (let i = 0; i < 5; i++) { + await fs.writeFile(path.join(tempDir, `file${i}.ts`), `export const x${i} = ${i};`); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + // Wait for debounce to settle + await new Promise((resolve) => setTimeout(resolve, debounceMs + 400)); + expect(callCount).toBe(1); + } finally { + stop(); + } + }, 8000); + + it('stop() cancels a pending callback', async () => { + const debounceMs = 500; + let callCount = 0; + + const stop = startFileWatcher({ + rootPath: tempDir, + debounceMs, + onChanged: () => { callCount++; }, + }); + + await fs.writeFile(path.join(tempDir, 'cancel.ts'), 'export const y = 99;'); + // Let chokidar detect the event but stop before debounce fires + await new Promise((resolve) => setTimeout(resolve, 150)); + stop(); + // Wait past where debounce would have fired + await new Promise((resolve) => setTimeout(resolve, debounceMs + 200)); + expect(callCount).toBe(0); + }, 5000); +}); From 2d781105f9d56e3b5644abe90ae88978e4d7b0d0 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sat, 28 Feb 2026 18:12:35 +0100 Subject: [PATCH 2/3] fix(watcher): queue refresh during indexing --- src/index.ts | 29 +++++++++++++++++++++++++---- tests/file-watcher.test.ts | 4 ++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index dee6e08..ee22013 100644 --- a/src/index.ts +++ b/src/index.ts @@ -252,6 +252,8 @@ const indexState: IndexState = { status: 'idle' }; +let autoRefreshQueued = false; + const server: Server = new Server( { name: 'codebase-context', @@ -512,7 +514,7 @@ async function extractGitMemories(): Promise { return added; } -async function performIndexing(incrementalOnly?: boolean): Promise { +async function performIndexingOnce(incrementalOnly?: boolean): Promise { indexState.status = 'indexing'; const mode = incrementalOnly ? 'incremental' : 'full'; console.error(`Indexing (${mode}): ${ROOT_PATH}`); @@ -566,6 +568,22 @@ async function performIndexing(incrementalOnly?: boolean): Promise { } } +async function performIndexing(incrementalOnly?: boolean): Promise { + let nextMode = incrementalOnly; + for (;;) { + await performIndexingOnce(nextMode); + + const shouldRunQueuedRefresh = autoRefreshQueued && indexState.status === 'ready'; + autoRefreshQueued = false; + if (!shouldRunQueuedRefresh) return; + + if (process.env.CODEBASE_CONTEXT_DEBUG) { + console.error('[file-watcher] Running queued auto-refresh'); + } + nextMode = true; + } +} + async function shouldReindex(): Promise { const indexPath = PATHS.keywordIndex; try { @@ -735,13 +753,16 @@ async function main() { debounceMs, onChanged: () => { if (indexState.status === 'indexing') { + autoRefreshQueued = true; if (process.env.CODEBASE_CONTEXT_DEBUG) { - console.error('[file-watcher] Index in progress — skipping auto-refresh'); + console.error('[file-watcher] Index in progress — queueing auto-refresh'); } return; } - console.error('[file-watcher] Changes detected — incremental reindex starting'); - performIndexing(true); + if (process.env.CODEBASE_CONTEXT_DEBUG) { + console.error('[file-watcher] Changes detected — incremental reindex starting'); + } + void performIndexing(true); } }); diff --git a/tests/file-watcher.test.ts b/tests/file-watcher.test.ts index 01f4259..b3ca98e 100644 --- a/tests/file-watcher.test.ts +++ b/tests/file-watcher.test.ts @@ -49,6 +49,8 @@ describe('FileWatcher', () => { }); try { + // Give chokidar a moment to finish initializing before the first write + await new Promise((resolve) => setTimeout(resolve, 100)); // Write 5 files in quick succession — all within the debounce window for (let i = 0; i < 5; i++) { await fs.writeFile(path.join(tempDir, `file${i}.ts`), `export const x${i} = ${i};`); @@ -72,6 +74,8 @@ describe('FileWatcher', () => { onChanged: () => { callCount++; }, }); + // Give chokidar a moment to finish initializing before the first write + await new Promise((resolve) => setTimeout(resolve, 100)); await fs.writeFile(path.join(tempDir, 'cancel.ts'), 'export const y = 99;'); // Let chokidar detect the event but stop before debounce fires await new Promise((resolve) => setTimeout(resolve, 150)); From 070433cf79dace7420c26284ceeca7fea41dc8a1 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Sat, 28 Feb 2026 18:16:59 +0100 Subject: [PATCH 3/3] fix(watcher): allow debounce 0 and harden test --- src/index.ts | 3 ++- tests/file-watcher.test.ts | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index ee22013..e653834 100644 --- a/src/index.ts +++ b/src/index.ts @@ -747,7 +747,8 @@ async function main() { if (process.env.CODEBASE_CONTEXT_DEBUG) console.error('[DEBUG] Server ready'); // Auto-refresh: watch for file changes and trigger incremental reindex - const debounceMs = parseInt(process.env.CODEBASE_CONTEXT_DEBOUNCE_MS ?? '', 10) || 2000; + const debounceEnv = Number.parseInt(process.env.CODEBASE_CONTEXT_DEBOUNCE_MS ?? '', 10); + const debounceMs = Number.isFinite(debounceEnv) && debounceEnv >= 0 ? debounceEnv : 2000; const stopWatcher = startFileWatcher({ rootPath: ROOT_PATH, debounceMs, diff --git a/tests/file-watcher.test.ts b/tests/file-watcher.test.ts index b3ca98e..a1a6bc3 100644 --- a/tests/file-watcher.test.ts +++ b/tests/file-watcher.test.ts @@ -77,8 +77,9 @@ describe('FileWatcher', () => { // Give chokidar a moment to finish initializing before the first write await new Promise((resolve) => setTimeout(resolve, 100)); await fs.writeFile(path.join(tempDir, 'cancel.ts'), 'export const y = 99;'); - // Let chokidar detect the event but stop before debounce fires - await new Promise((resolve) => setTimeout(resolve, 150)); + // Let chokidar detect the event (including awaitWriteFinish stabilityThreshold) + // but stop before the debounce window expires. + await new Promise((resolve) => setTimeout(resolve, 350)); stop(); // Wait past where debounce would have fired await new Promise((resolve) => setTimeout(resolve, debounceMs + 200));