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
16 changes: 13 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { fileURLToPath } from "url";

import { parseConfig } from "./config/schema.js";
import { loadMergedConfig } from "./config/merger.js";
import { Indexer } from "./indexer/index.js";
import { createWatcherWithIndexer } from "./watcher/index.js";
import {
codebase_search,
Expand All @@ -20,10 +19,19 @@ import {
add_knowledge_base,
list_knowledge_bases,
remove_knowledge_base,
getSharedIndexer,
initializeTools,
} from "./tools/index.js";
import { loadCommandsFromDirectory } from "./commands/loader.js";
import { hasProjectMarker } from "./utils/files.js";
import type { CombinedWatcher } from "./watcher/index.js";

let activeWatcher: CombinedWatcher | null = null;

function replaceActiveWatcher(nextWatcher: CombinedWatcher | null): void {
activeWatcher?.stop();
activeWatcher = nextWatcher;
}

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

initializeTools(projectRoot, config);

const indexer = new Indexer(projectRoot, config);
const indexer = getSharedIndexer();

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

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

if (config.indexing.watchFiles && isValidProject) {
createWatcherWithIndexer(indexer, projectRoot, config);
replaceActiveWatcher(createWatcherWithIndexer(getSharedIndexer, projectRoot, config));
} else {
replaceActiveWatcher(null);
}

return {
Expand Down
16 changes: 15 additions & 1 deletion src/tools/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin";

import { parseConfig, type ParsedCodebaseIndexConfig } from "../config/schema.js";
import { Indexer } from "../indexer/index.js";
import { ParsedCodebaseIndexConfig } from "../config/schema.js";
import { formatCostEstimate } from "../utils/cost.js";
import type { LogLevel } from "../config/schema.js";
import type { LogEntry } from "../utils/logger.js";
Expand Down Expand Up @@ -30,6 +30,18 @@ export function initializeTools(projectRoot: string, config: ParsedCodebaseIndex
sharedIndexer = new Indexer(projectRoot, config);
}

export function getSharedIndexer(): Indexer {
return getIndexer();
}

function refreshIndexerFromConfig(): void {
if (!sharedProjectRoot) {
throw new Error("Codebase index tools not initialized. Plugin may not be loaded correctly.");
}

sharedIndexer = new Indexer(sharedProjectRoot, parseConfig(loadConfig()));
}

function getIndexer(): Indexer {
if (!sharedIndexer) {
throw new Error("Codebase index tools not initialized. Plugin may not be loaded correctly.");
Expand Down Expand Up @@ -372,6 +384,7 @@ export const add_knowledge_base: ToolDefinition = tool({
knowledgeBases.push(resolvedPath);
config.knowledgeBases = knowledgeBases;
saveConfig(config);
refreshIndexerFromConfig();

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

let result = `Removed: ${removed}\n\n`;
result += `Remaining knowledge bases: ${knowledgeBases.length}\n`;
Expand Down
11 changes: 10 additions & 1 deletion src/utils/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,16 @@ export function shouldIncludeFile(
}

function matchGlob(filePath: string, pattern: string): boolean {
let regexPattern = pattern
if (pattern.startsWith("**/")) {
const withoutPrefix = pattern.slice(3);
if (withoutPrefix && matchGlob(filePath, withoutPrefix)) {
return true;
}
}

const escapedPattern = pattern.replace(/[.+^$()|[\]\\]/g, "\\$&");

let regexPattern = escapedPattern
.replace(/\*\*/g, "<<<DOUBLESTAR>>>")
.replace(/\*/g, "[^/]*")
.replace(/<<<DOUBLESTAR>>>/g, ".*")
Expand Down
10 changes: 5 additions & 5 deletions src/watcher/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import chokidar, { FSWatcher } from "chokidar";
import * as path from "path";

import { CodebaseIndexConfig } from "../config/schema.js";
import type { CodebaseIndexConfig } from "../config/schema.js";
import { createIgnoreFilter, shouldIncludeFile } from "../utils/files.js";
import { Indexer } from "../indexer/index.js";
import type { Indexer } from "../indexer/index.js";
import { isGitRepo, getHeadPath, getCurrentBranch } from "../git/index.js";

export type FileChangeType = "add" | "change" | "unlink";
Expand Down Expand Up @@ -243,7 +243,7 @@ export interface CombinedWatcher {
}

export function createWatcherWithIndexer(
indexer: Indexer,
getIndexer: () => Indexer,
projectRoot: string,
config: CodebaseIndexConfig
): CombinedWatcher {
Expand All @@ -256,7 +256,7 @@ export function createWatcherWithIndexer(
const hasDelete = changes.some((c) => c.type === "unlink");

if (hasAddOrChange || hasDelete) {
await indexer.index();
await getIndexer().index();
}
});

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

Expand Down
16 changes: 16 additions & 0 deletions tests/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,22 @@ describe("files utilities", () => {
)
).toBe(false);
});

it("should include root-level files with dots in their names", () => {
const filter = createIgnoreFilter(tempDir);
const includePatterns = ["**/*.{ts,tsx,js,jsx,mjs,cjs}"];
const excludePatterns = ["**/.*"];

expect(
shouldIncludeFile(
path.join(tempDir, "watcher.probe.ts"),
tempDir,
includePatterns,
excludePatterns,
filter
)
).toBe(true);
});
});

describe("collectFiles", () => {
Expand Down
116 changes: 116 additions & 0 deletions tests/tools-knowledge-bases.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import * as fs from "fs";
import * as os from "os";
import * as path from "path";

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const { indexerInstances, MockIndexer } = vi.hoisted(() => {
const indexerInstances: Array<{ projectRoot: string; config: Record<string, unknown> }> = [];

class MockIndexer {
public readonly projectRoot: string;
public readonly config: Record<string, unknown>;

public constructor(projectRoot: string, config: Record<string, unknown>) {
this.projectRoot = projectRoot;
this.config = config;
indexerInstances.push({ projectRoot, config });
}

public estimateCost = vi.fn().mockResolvedValue({
filesCount: 0,
totalSizeBytes: 0,
estimatedChunks: 0,
estimatedTokens: 0,
estimatedCost: 0,
isFree: true,
provider: "ollama",
model: "nomic-embed-text",
});

public clearIndex = vi.fn().mockResolvedValue(undefined);
public index = vi.fn().mockResolvedValue({
totalFiles: 0,
totalChunks: 0,
indexedChunks: 0,
failedChunks: 0,
tokensUsed: 0,
durationMs: 0,
existingChunks: 0,
removedChunks: 0,
skippedFiles: [],
parseFailures: [],
});

public getStatus = vi.fn().mockResolvedValue({
indexed: true,
vectorCount: 0,
provider: "ollama",
model: "nomic-embed-text",
indexPath: "/tmp/index",
currentBranch: "main",
baseBranch: "main",
});

public healthCheck = vi.fn().mockResolvedValue({
removed: 0,
gcOrphanEmbeddings: 0,
gcOrphanChunks: 0,
gcOrphanSymbols: 0,
gcOrphanCallEdges: 0,
filePaths: [],
});

public getLogger = vi.fn().mockReturnValue({
isEnabled: vi.fn().mockReturnValue(false),
isMetricsEnabled: vi.fn().mockReturnValue(false),
getLogs: vi.fn().mockReturnValue([]),
getLogsByCategory: vi.fn().mockReturnValue([]),
getLogsByLevel: vi.fn().mockReturnValue([]),
formatMetrics: vi.fn().mockReturnValue(""),
});
}

return { indexerInstances, MockIndexer };
});

vi.mock("../src/indexer/index.js", () => ({
Indexer: MockIndexer,
}));

import { parseConfig } from "../src/config/schema.js";
import { add_knowledge_base, initializeTools, remove_knowledge_base } from "../src/tools/index.js";

describe("knowledge base tool config refresh", () => {
let tempDir: string;
let kbDir: string;

beforeEach(() => {
indexerInstances.length = 0;
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "kb-tools-test-"));
kbDir = fs.mkdtempSync(path.join(os.tmpdir(), "kb-source-"));
initializeTools(tempDir, parseConfig({ indexing: { watchFiles: false } }));
});

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
fs.rmSync(kbDir, { recursive: true, force: true });
});

it("rebuilds the shared indexer after adding a knowledge base", async () => {
await add_knowledge_base.execute({ path: kbDir });

expect(indexerInstances).toHaveLength(2);
expect(indexerInstances[1]?.projectRoot).toBe(tempDir);
expect(indexerInstances[1]?.config.knowledgeBases).toEqual([path.normalize(kbDir)]);
});

it("rebuilds the shared indexer after removing a knowledge base", async () => {
await add_knowledge_base.execute({ path: kbDir });
await remove_knowledge_base.execute({ path: kbDir });

expect(indexerInstances).toHaveLength(3);
expect(indexerInstances[2]?.projectRoot).toBe(tempDir);
expect(indexerInstances[2]?.config.knowledgeBases).toEqual([]);
});
});
69 changes: 68 additions & 1 deletion tests/watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import { FileWatcher, GitHeadWatcher, FileChange } from "../src/watcher/index.js";
import { FileWatcher, GitHeadWatcher, FileChange, createWatcherWithIndexer } from "../src/watcher/index.js";
import { ParsedCodebaseIndexConfig } from "../src/config/schema.js";

const createTestConfig = (overrides: Partial<ParsedCodebaseIndexConfig> = {}): ParsedCodebaseIndexConfig => ({
Expand Down Expand Up @@ -119,6 +119,73 @@ describe("FileWatcher", () => {
expect(tsChanges.length).toBeGreaterThanOrEqual(0);
expect(mdChanges.length).toBe(0);
});

it("should include matching root-level files", async () => {
const changes: FileChange[] = [];
watcher = new FileWatcher(tempDir, createTestConfig({ include: ["**/*.ts"] }));

watcher.start(async (c) => {
changes.push(...c);
});

await new Promise((r) => setTimeout(r, 100));

fs.writeFileSync(path.join(tempDir, "root.ts"), "export const root = 1;");

await new Promise((r) => setTimeout(r, 1500));

expect(changes.some((c) => c.path.endsWith("root.ts"))).toBe(true);
});
});

describe("createWatcherWithIndexer", () => {
it("uses the latest indexer instance for file-triggered reindexing", async () => {
const staleIndexer = {
index: vi.fn().mockResolvedValue(undefined),
};
const refreshedIndexer = {
index: vi.fn().mockResolvedValue(undefined),
};

let currentIndexer = staleIndexer;
const combinedWatcher = createWatcherWithIndexer(
() => currentIndexer,
tempDir,
createTestConfig()
);

await new Promise((r) => setTimeout(r, 100));
currentIndexer = refreshedIndexer;

fs.writeFileSync(path.join(tempDir, "src", "reindex-me.ts"), "export const value = 1;");

await new Promise((r) => setTimeout(r, 1500));

expect(refreshedIndexer.index).toHaveBeenCalledTimes(1);
expect(staleIndexer.index).not.toHaveBeenCalled();

combinedWatcher.stop();
});

it("stops the watcher cleanly after start", () => {
const indexer = {
index: vi.fn().mockResolvedValue(undefined),
};

const combinedWatcher = createWatcherWithIndexer(
() => indexer,
tempDir,
createTestConfig()
);

expect(combinedWatcher.fileWatcher.isRunning()).toBe(true);
expect(combinedWatcher.gitWatcher?.isRunning() ?? false).toBe(false);

combinedWatcher.stop();

expect(combinedWatcher.fileWatcher.isRunning()).toBe(false);
expect(combinedWatcher.gitWatcher?.isRunning() ?? false).toBe(false);
});
});
});

Expand Down
Loading