Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 56 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 44 additions & 0 deletions src/core/file-watcher.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout> | 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();
};
}
52 changes: 51 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -251,6 +252,8 @@ const indexState: IndexState = {
status: 'idle'
};

let autoRefreshQueued = false;

const server: Server = new Server(
{
name: 'codebase-context',
Expand Down Expand Up @@ -511,7 +514,7 @@ async function extractGitMemories(): Promise<number> {
return added;
}

async function performIndexing(incrementalOnly?: boolean): Promise<void> {
async function performIndexingOnce(incrementalOnly?: boolean): Promise<void> {
indexState.status = 'indexing';
const mode = incrementalOnly ? 'incremental' : 'full';
console.error(`Indexing (${mode}): ${ROOT_PATH}`);
Expand Down Expand Up @@ -565,6 +568,22 @@ async function performIndexing(incrementalOnly?: boolean): Promise<void> {
}
}

async function performIndexing(incrementalOnly?: boolean): Promise<void> {
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<boolean> {
const indexPath = PATHS.keywordIndex;
try {
Expand Down Expand Up @@ -726,6 +745,37 @@ 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 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,
onChanged: () => {
if (indexState.status === 'indexing') {
autoRefreshQueued = true;
if (process.env.CODEBASE_CONTEXT_DEBUG) {
console.error('[file-watcher] Index in progress — queueing auto-refresh');
}
return;
Comment on lines +756 to +761

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Queue a follow-up refresh when indexing is busy

This branch drops file-change events whenever indexState.status === 'indexing', so edits made during a long-running index (including the startup full index) can be permanently missed if no later change occurs. In that scenario the watcher never schedules another run, leaving the served index stale relative to disk until a manual refresh or another edit happens; this undermines the new auto-refresh guarantee.

Useful? React with 👍 / 👎.

}
Comment on lines +756 to +762
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File changes during initial indexing are silently and permanently dropped

performIndexing() on line 719 is fire-and-forget (not awaited), so the watcher can receive onChanged events while the initial full index is still running. The guard here correctly prevents stacking a second concurrent run, but changes detected during that window are discarded with no mechanism to catch up. Once the initial index completes, those changed files will never be re-examined unless the user manually calls refresh_index.

A simple mitigation is to track whether at least one change was skipped and trigger a follow-up incremental reindex after the initial run finishes:

let pendingChanges = false;

onChanged: () => {
  if (indexState.status === 'indexing') {
    pendingChanges = true;
    if (process.env.CODEBASE_CONTEXT_DEBUG) {
      console.error('[file-watcher] Index in progress — queuing auto-refresh');
    }
    return;
  }
  pendingChanges = false;
  console.error('[file-watcher] Changes detected — incremental reindex starting');
  performIndexing(true);
}

Then, at the end of performIndexing, or via a statuschange hook, check pendingChanges and schedule a follow-up incremental run.

if (process.env.CODEBASE_CONTEXT_DEBUG) {
console.error('[file-watcher] Changes detected — incremental reindex starting');
}
void 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
Expand Down
88 changes: 88 additions & 0 deletions tests/file-watcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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 {
// 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};`);
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++; },
});

// 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 (including awaitWriteFinish stabilityThreshold)
// but stop before the debounce window expires.
await new Promise((resolve) => setTimeout(resolve, 350));
stop();
Comment on lines +79 to +83
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test doesn't actually test debounce cancellation

The test waits 150 ms before calling stop(), but the watcher is configured with awaitWriteFinish: { stabilityThreshold: 200 }. This means chokidar will not emit the 'add' event until at least 200 ms after the write finishes. At the 150 ms mark, trigger() has never been called, no debounce timer has been set, and stop() merely discards a clearTimeout(undefined) no-op before closing the watcher.

callCount stays at 0 because the filesystem event was never delivered — not because the debounce timer was successfully cancelled. The test comment "Let chokidar detect the event but stop before debounce fires" is therefore incorrect; chokidar hasn't detected anything yet.

To actually test cancellation of a live debounce timer, the wait before stop() needs to be longer than stabilityThreshold (200 ms) so that trigger() has been invoked and the timer is running, but shorter than debounceMs (500 ms) so the callback hasn't fired yet. For example, ~300–350 ms would reliably land in that window:

await fs.writeFile(path.join(tempDir, 'cancel.ts'), 'export const y = 99;');
// Wait long enough for chokidar's awaitWriteFinish (200 ms) to fire trigger(),
// but short enough that the debounce (500 ms) hasn't elapsed yet.
await new Promise((resolve) => setTimeout(resolve, 300));
stop();
await new Promise((resolve) => setTimeout(resolve, debounceMs + 200));
expect(callCount).toBe(0);

// Wait past where debounce would have fired
await new Promise((resolve) => setTimeout(resolve, debounceMs + 200));
expect(callCount).toBe(0);
}, 5000);
});
Loading