Skip to content

Commit d29373d

Browse files
authored
Merge pull request #53 from Helweg/fix/knowledge-base-indexer-refresh
fix: refresh shared indexer after knowledge base updates
2 parents a77e26c + 89f86e7 commit d29373d

File tree

7 files changed

+243
-11
lines changed

7 files changed

+243
-11
lines changed

src/index.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { fileURLToPath } from "url";
44

55
import { parseConfig } from "./config/schema.js";
66
import { loadMergedConfig } from "./config/merger.js";
7-
import { Indexer } from "./indexer/index.js";
87
import { createWatcherWithIndexer } from "./watcher/index.js";
98
import {
109
codebase_search,
@@ -20,10 +19,19 @@ import {
2019
add_knowledge_base,
2120
list_knowledge_bases,
2221
remove_knowledge_base,
22+
getSharedIndexer,
2323
initializeTools,
2424
} from "./tools/index.js";
2525
import { loadCommandsFromDirectory } from "./commands/loader.js";
2626
import { hasProjectMarker } from "./utils/files.js";
27+
import type { CombinedWatcher } from "./watcher/index.js";
28+
29+
let activeWatcher: CombinedWatcher | null = null;
30+
31+
function replaceActiveWatcher(nextWatcher: CombinedWatcher | null): void {
32+
activeWatcher?.stop();
33+
activeWatcher = nextWatcher;
34+
}
2735

2836
function getCommandsDir(): string {
2937
let currentDir = process.cwd();
@@ -43,7 +51,7 @@ const plugin: Plugin = async ({ directory }) => {
4351

4452
initializeTools(projectRoot, config);
4553

46-
const indexer = new Indexer(projectRoot, config);
54+
const indexer = getSharedIndexer();
4755

4856
const isValidProject = !config.indexing.requireProjectMarker || hasProjectMarker(projectRoot);
4957

@@ -61,7 +69,9 @@ const plugin: Plugin = async ({ directory }) => {
6169
}
6270

6371
if (config.indexing.watchFiles && isValidProject) {
64-
createWatcherWithIndexer(indexer, projectRoot, config);
72+
replaceActiveWatcher(createWatcherWithIndexer(getSharedIndexer, projectRoot, config));
73+
} else {
74+
replaceActiveWatcher(null);
6575
}
6676

6777
return {

src/tools/index.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { tool, type ToolDefinition } from "@opencode-ai/plugin";
22

3+
import { parseConfig, type ParsedCodebaseIndexConfig } from "../config/schema.js";
34
import { Indexer } from "../indexer/index.js";
4-
import { ParsedCodebaseIndexConfig } from "../config/schema.js";
55
import { formatCostEstimate } from "../utils/cost.js";
66
import type { LogLevel } from "../config/schema.js";
77
import type { LogEntry } from "../utils/logger.js";
@@ -30,6 +30,18 @@ export function initializeTools(projectRoot: string, config: ParsedCodebaseIndex
3030
sharedIndexer = new Indexer(projectRoot, config);
3131
}
3232

33+
export function getSharedIndexer(): Indexer {
34+
return getIndexer();
35+
}
36+
37+
function refreshIndexerFromConfig(): void {
38+
if (!sharedProjectRoot) {
39+
throw new Error("Codebase index tools not initialized. Plugin may not be loaded correctly.");
40+
}
41+
42+
sharedIndexer = new Indexer(sharedProjectRoot, parseConfig(loadConfig()));
43+
}
44+
3345
function getIndexer(): Indexer {
3446
if (!sharedIndexer) {
3547
throw new Error("Codebase index tools not initialized. Plugin may not be loaded correctly.");
@@ -372,6 +384,7 @@ export const add_knowledge_base: ToolDefinition = tool({
372384
knowledgeBases.push(resolvedPath);
373385
config.knowledgeBases = knowledgeBases;
374386
saveConfig(config);
387+
refreshIndexerFromConfig();
375388

376389
let result = `${resolvedPath}\n`;
377390
result += `Total knowledge bases: ${knowledgeBases.length}\n`;
@@ -458,6 +471,7 @@ export const remove_knowledge_base: ToolDefinition = tool({
458471
const removed = knowledgeBases.splice(index, 1)[0];
459472
config.knowledgeBases = knowledgeBases;
460473
saveConfig(config);
474+
refreshIndexerFromConfig();
461475

462476
let result = `Removed: ${removed}\n\n`;
463477
result += `Remaining knowledge bases: ${knowledgeBases.length}\n`;

src/utils/files.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,16 @@ export function shouldIncludeFile(
111111
}
112112

113113
function matchGlob(filePath: string, pattern: string): boolean {
114-
let regexPattern = pattern
114+
if (pattern.startsWith("**/")) {
115+
const withoutPrefix = pattern.slice(3);
116+
if (withoutPrefix && matchGlob(filePath, withoutPrefix)) {
117+
return true;
118+
}
119+
}
120+
121+
const escapedPattern = pattern.replace(/[.+^$()|[\]\\]/g, "\\$&");
122+
123+
let regexPattern = escapedPattern
115124
.replace(/\*\*/g, "<<<DOUBLESTAR>>>")
116125
.replace(/\*/g, "[^/]*")
117126
.replace(/<<<DOUBLESTAR>>>/g, ".*")

src/watcher/index.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import chokidar, { FSWatcher } from "chokidar";
22
import * as path from "path";
33

4-
import { CodebaseIndexConfig } from "../config/schema.js";
4+
import type { CodebaseIndexConfig } from "../config/schema.js";
55
import { createIgnoreFilter, shouldIncludeFile } from "../utils/files.js";
6-
import { Indexer } from "../indexer/index.js";
6+
import type { Indexer } from "../indexer/index.js";
77
import { isGitRepo, getHeadPath, getCurrentBranch } from "../git/index.js";
88

99
export type FileChangeType = "add" | "change" | "unlink";
@@ -243,7 +243,7 @@ export interface CombinedWatcher {
243243
}
244244

245245
export function createWatcherWithIndexer(
246-
indexer: Indexer,
246+
getIndexer: () => Indexer,
247247
projectRoot: string,
248248
config: CodebaseIndexConfig
249249
): CombinedWatcher {
@@ -256,7 +256,7 @@ export function createWatcherWithIndexer(
256256
const hasDelete = changes.some((c) => c.type === "unlink");
257257

258258
if (hasAddOrChange || hasDelete) {
259-
await indexer.index();
259+
await getIndexer().index();
260260
}
261261
});
262262

@@ -266,7 +266,7 @@ export function createWatcherWithIndexer(
266266
gitWatcher = new GitHeadWatcher(projectRoot);
267267
gitWatcher.start(async (oldBranch, newBranch) => {
268268
console.log(`Branch changed: ${oldBranch ?? "(none)"} -> ${newBranch}`);
269-
await indexer.index();
269+
await getIndexer().index();
270270
});
271271
}
272272

tests/files.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,22 @@ describe("files utilities", () => {
8282
)
8383
).toBe(false);
8484
});
85+
86+
it("should include root-level files with dots in their names", () => {
87+
const filter = createIgnoreFilter(tempDir);
88+
const includePatterns = ["**/*.{ts,tsx,js,jsx,mjs,cjs}"];
89+
const excludePatterns = ["**/.*"];
90+
91+
expect(
92+
shouldIncludeFile(
93+
path.join(tempDir, "watcher.probe.ts"),
94+
tempDir,
95+
includePatterns,
96+
excludePatterns,
97+
filter
98+
)
99+
).toBe(true);
100+
});
85101
});
86102

87103
describe("collectFiles", () => {
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import * as fs from "fs";
2+
import * as os from "os";
3+
import * as path from "path";
4+
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6+
7+
const { indexerInstances, MockIndexer } = vi.hoisted(() => {
8+
const indexerInstances: Array<{ projectRoot: string; config: Record<string, unknown> }> = [];
9+
10+
class MockIndexer {
11+
public readonly projectRoot: string;
12+
public readonly config: Record<string, unknown>;
13+
14+
public constructor(projectRoot: string, config: Record<string, unknown>) {
15+
this.projectRoot = projectRoot;
16+
this.config = config;
17+
indexerInstances.push({ projectRoot, config });
18+
}
19+
20+
public estimateCost = vi.fn().mockResolvedValue({
21+
filesCount: 0,
22+
totalSizeBytes: 0,
23+
estimatedChunks: 0,
24+
estimatedTokens: 0,
25+
estimatedCost: 0,
26+
isFree: true,
27+
provider: "ollama",
28+
model: "nomic-embed-text",
29+
});
30+
31+
public clearIndex = vi.fn().mockResolvedValue(undefined);
32+
public index = vi.fn().mockResolvedValue({
33+
totalFiles: 0,
34+
totalChunks: 0,
35+
indexedChunks: 0,
36+
failedChunks: 0,
37+
tokensUsed: 0,
38+
durationMs: 0,
39+
existingChunks: 0,
40+
removedChunks: 0,
41+
skippedFiles: [],
42+
parseFailures: [],
43+
});
44+
45+
public getStatus = vi.fn().mockResolvedValue({
46+
indexed: true,
47+
vectorCount: 0,
48+
provider: "ollama",
49+
model: "nomic-embed-text",
50+
indexPath: "/tmp/index",
51+
currentBranch: "main",
52+
baseBranch: "main",
53+
});
54+
55+
public healthCheck = vi.fn().mockResolvedValue({
56+
removed: 0,
57+
gcOrphanEmbeddings: 0,
58+
gcOrphanChunks: 0,
59+
gcOrphanSymbols: 0,
60+
gcOrphanCallEdges: 0,
61+
filePaths: [],
62+
});
63+
64+
public getLogger = vi.fn().mockReturnValue({
65+
isEnabled: vi.fn().mockReturnValue(false),
66+
isMetricsEnabled: vi.fn().mockReturnValue(false),
67+
getLogs: vi.fn().mockReturnValue([]),
68+
getLogsByCategory: vi.fn().mockReturnValue([]),
69+
getLogsByLevel: vi.fn().mockReturnValue([]),
70+
formatMetrics: vi.fn().mockReturnValue(""),
71+
});
72+
}
73+
74+
return { indexerInstances, MockIndexer };
75+
});
76+
77+
vi.mock("../src/indexer/index.js", () => ({
78+
Indexer: MockIndexer,
79+
}));
80+
81+
import { parseConfig } from "../src/config/schema.js";
82+
import { add_knowledge_base, initializeTools, remove_knowledge_base } from "../src/tools/index.js";
83+
84+
describe("knowledge base tool config refresh", () => {
85+
let tempDir: string;
86+
let kbDir: string;
87+
88+
beforeEach(() => {
89+
indexerInstances.length = 0;
90+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "kb-tools-test-"));
91+
kbDir = fs.mkdtempSync(path.join(os.tmpdir(), "kb-source-"));
92+
initializeTools(tempDir, parseConfig({ indexing: { watchFiles: false } }));
93+
});
94+
95+
afterEach(() => {
96+
fs.rmSync(tempDir, { recursive: true, force: true });
97+
fs.rmSync(kbDir, { recursive: true, force: true });
98+
});
99+
100+
it("rebuilds the shared indexer after adding a knowledge base", async () => {
101+
await add_knowledge_base.execute({ path: kbDir });
102+
103+
expect(indexerInstances).toHaveLength(2);
104+
expect(indexerInstances[1]?.projectRoot).toBe(tempDir);
105+
expect(indexerInstances[1]?.config.knowledgeBases).toEqual([path.normalize(kbDir)]);
106+
});
107+
108+
it("rebuilds the shared indexer after removing a knowledge base", async () => {
109+
await add_knowledge_base.execute({ path: kbDir });
110+
await remove_knowledge_base.execute({ path: kbDir });
111+
112+
expect(indexerInstances).toHaveLength(3);
113+
expect(indexerInstances[2]?.projectRoot).toBe(tempDir);
114+
expect(indexerInstances[2]?.config.knowledgeBases).toEqual([]);
115+
});
116+
});

tests/watcher.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
22
import * as fs from "fs";
33
import * as path from "path";
44
import * as os from "os";
5-
import { FileWatcher, GitHeadWatcher, FileChange } from "../src/watcher/index.js";
5+
import { FileWatcher, GitHeadWatcher, FileChange, createWatcherWithIndexer } from "../src/watcher/index.js";
66
import { ParsedCodebaseIndexConfig } from "../src/config/schema.js";
77

88
const createTestConfig = (overrides: Partial<ParsedCodebaseIndexConfig> = {}): ParsedCodebaseIndexConfig => ({
@@ -119,6 +119,73 @@ describe("FileWatcher", () => {
119119
expect(tsChanges.length).toBeGreaterThanOrEqual(0);
120120
expect(mdChanges.length).toBe(0);
121121
});
122+
123+
it("should include matching root-level files", async () => {
124+
const changes: FileChange[] = [];
125+
watcher = new FileWatcher(tempDir, createTestConfig({ include: ["**/*.ts"] }));
126+
127+
watcher.start(async (c) => {
128+
changes.push(...c);
129+
});
130+
131+
await new Promise((r) => setTimeout(r, 100));
132+
133+
fs.writeFileSync(path.join(tempDir, "root.ts"), "export const root = 1;");
134+
135+
await new Promise((r) => setTimeout(r, 1500));
136+
137+
expect(changes.some((c) => c.path.endsWith("root.ts"))).toBe(true);
138+
});
139+
});
140+
141+
describe("createWatcherWithIndexer", () => {
142+
it("uses the latest indexer instance for file-triggered reindexing", async () => {
143+
const staleIndexer = {
144+
index: vi.fn().mockResolvedValue(undefined),
145+
};
146+
const refreshedIndexer = {
147+
index: vi.fn().mockResolvedValue(undefined),
148+
};
149+
150+
let currentIndexer = staleIndexer;
151+
const combinedWatcher = createWatcherWithIndexer(
152+
() => currentIndexer,
153+
tempDir,
154+
createTestConfig()
155+
);
156+
157+
await new Promise((r) => setTimeout(r, 100));
158+
currentIndexer = refreshedIndexer;
159+
160+
fs.writeFileSync(path.join(tempDir, "src", "reindex-me.ts"), "export const value = 1;");
161+
162+
await new Promise((r) => setTimeout(r, 1500));
163+
164+
expect(refreshedIndexer.index).toHaveBeenCalledTimes(1);
165+
expect(staleIndexer.index).not.toHaveBeenCalled();
166+
167+
combinedWatcher.stop();
168+
});
169+
170+
it("stops the watcher cleanly after start", () => {
171+
const indexer = {
172+
index: vi.fn().mockResolvedValue(undefined),
173+
};
174+
175+
const combinedWatcher = createWatcherWithIndexer(
176+
() => indexer,
177+
tempDir,
178+
createTestConfig()
179+
);
180+
181+
expect(combinedWatcher.fileWatcher.isRunning()).toBe(true);
182+
expect(combinedWatcher.gitWatcher?.isRunning() ?? false).toBe(false);
183+
184+
combinedWatcher.stop();
185+
186+
expect(combinedWatcher.fileWatcher.isRunning()).toBe(false);
187+
expect(combinedWatcher.gitWatcher?.isRunning() ?? false).toBe(false);
188+
});
122189
});
123190
});
124191

0 commit comments

Comments
 (0)