Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
521 changes: 351 additions & 170 deletions src/index.ts

Large diffs are not rendered by default.

93 changes: 93 additions & 0 deletions src/project-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import path from 'path';
import {
CODEBASE_CONTEXT_DIRNAME,
MEMORY_FILENAME,
INTELLIGENCE_FILENAME,
KEYWORD_INDEX_FILENAME,
VECTOR_DB_DIRNAME
} from './constants/codebase-context.js';
import { createAutoRefreshController } from './core/auto-refresh.js';
import type { AutoRefreshController } from './core/auto-refresh.js';
import type { ToolPaths, IndexState } from './tools/types.js';

export interface ProjectState {
rootPath: string;
paths: ToolPaths;
indexState: IndexState;
autoRefresh: AutoRefreshController;
stopWatcher?: () => void;
}

export function makePaths(rootPath: string): ToolPaths {
return {
baseDir: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME),
memory: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME),
intelligence: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, INTELLIGENCE_FILENAME),
keywordIndex: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, KEYWORD_INDEX_FILENAME),
vectorDb: path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, VECTOR_DB_DIRNAME)
};
}

export function makeLegacyPaths(rootPath: string) {
return {
intelligence: path.join(rootPath, '.codebase-intelligence.json'),
keywordIndex: path.join(rootPath, '.codebase-index.json'),
vectorDb: path.join(rootPath, '.codebase-index')
};
}

export function normalizeRootKey(rootPath: string): string {
let normalized = path.resolve(rootPath);
// Strip trailing separator
while (normalized.length > 1 && (normalized.endsWith('/') || normalized.endsWith('\\'))) {
normalized = normalized.slice(0, -1);
}
// Case-insensitive on Windows
if (process.platform === 'win32') {
normalized = normalized.toLowerCase();
}
return normalized;
}

const projects = new Map<string, ProjectState>();

export function createProjectState(rootPath: string): ProjectState {
return {
rootPath,
paths: makePaths(rootPath),
indexState: { status: 'idle' },
autoRefresh: createAutoRefreshController()
};
}

export function getOrCreateProject(rootPath: string): ProjectState {
const key = normalizeRootKey(rootPath);
let project = projects.get(key);
if (!project) {
project = createProjectState(rootPath);
projects.set(key, project);
}
return project;
}

export function getProject(rootPath: string): ProjectState | undefined {
return projects.get(normalizeRootKey(rootPath));
}

export function getAllProjects(): ProjectState[] {
return Array.from(projects.values());
}

export function removeProject(rootPath: string): void {
const key = normalizeRootKey(rootPath);
const project = projects.get(key);
project?.stopWatcher?.();
projects.delete(key);
}

export function clearProjects(): void {
for (const project of projects.values()) {
project.stopWatcher?.();
}
projects.clear();
}
31 changes: 30 additions & 1 deletion src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,36 @@ import { definition as d10, handle as h10 } from './get-memory.js';

import type { ToolContext, ToolResponse } from './types.js';

export const TOOLS: Tool[] = [d1, d2, d3, d4, d5, d6, d7, d8, d9, d10];
const PROJECT_DIRECTORY_PROPERTY: Record<string, string> = {
type: 'string',
description:
'Optional absolute path or file:// URI for the project root to use when multiple roots are available.'
};

function withProjectDirectory(definition: Tool): Tool {
const schema = definition.inputSchema;
if (!schema || schema.type !== 'object') {
return definition;
}

const properties = { ...(schema.properties ?? {}) };
if ('project_directory' in properties) {
return definition;
}

return {
...definition,
inputSchema: {
...schema,
properties: {
...properties,
project_directory: PROJECT_DIRECTORY_PROPERTY
}
}
};
}

export const TOOLS: Tool[] = [d1, d2, d3, d4, d5, d6, d7, d8, d9, d10].map(withProjectDirectory);

export async function dispatchTool(
name: string,
Expand Down
66 changes: 17 additions & 49 deletions src/tools/search-codebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,57 +177,25 @@ export async function handle(
});
} catch (error) {
if (error instanceof IndexCorruptedError) {
console.error('[Auto-Heal] Index corrupted. Triggering full re-index...');

await ctx.performIndexing();

if (ctx.indexState.status === 'ready') {
console.error('[Auto-Heal] Success. Retrying search...');
const freshSearcher = new CodebaseSearcher(ctx.rootPath);
try {
results = await freshSearcher.search(queryStr, limit || 5, filters, {
profile: searchProfile
});
} catch (retryError) {
return {
content: [
console.error('[Auto-Heal] Index corrupted. Triggering background re-index...');
void ctx.performIndexing();
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
type: 'text',
text: JSON.stringify(
{
status: 'error',
message: `Auto-heal retry failed: ${
retryError instanceof Error ? retryError.message : String(retryError)
}`
},
null,
2
)
}
]
};
}
} else {
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
status: 'error',
message: `Auto-heal failed: Indexing ended with status '${ctx.indexState.status}'`,
error: ctx.indexState.error
},
null,
2
)
}
]
};
}
} else {
throw error; // Propagate unexpected errors
status: 'indexing',
message: 'Index was corrupt. Rebuild started — retry shortly.'
},
null,
2
)
}
]
};
}
throw error;
}

// Load memories for keyword matching, enriched with confidence
Expand Down
68 changes: 55 additions & 13 deletions tests/index-versioning-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,17 +206,21 @@ describe('index versioning migration (MIGR-01)', () => {
});

afterEach(async () => {
const { clearProjects } = await import('../src/project-state.js');
clearProjects();

if (originalArgv) process.argv = originalArgv;
if (originalEnvRoot === undefined) delete process.env.CODEBASE_ROOT;
else process.env.CODEBASE_ROOT = originalEnvRoot;

if (tempRoot) {
await fs.rm(tempRoot, { recursive: true, force: true });
// Background indexing (fire-and-forget) may still be writing — retry on ENOTEMPTY
await fs.rm(tempRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
tempRoot = null;
}
});

it('refuses legacy indexes without index-meta.json and triggers auto-heal rebuild', async () => {
it('refuses legacy indexes without index-meta.json and triggers background rebuild', async () => {
if (!tempRoot) throw new Error('tempRoot not initialized');

const ctxDir = path.join(tempRoot, CODEBASE_CONTEXT_DIRNAME);
Expand All @@ -241,14 +245,14 @@ describe('index versioning migration (MIGR-01)', () => {
});

const payload = JSON.parse(response.content[0].text);
expect(payload.status).toBe('success');
expect(payload.status).toBe('indexing');
expect(payload.message).toContain('retry');
expect(payload.index).toBeTruthy();
expect(payload.index.action).toBe('rebuilt-and-served');
expect(payload.index.action).toBe('rebuild-started');
expect(String(payload.index.reason || '')).toContain('Index meta');
expect(indexerMocks.index).toHaveBeenCalledTimes(1);
});

it('detects keyword index header mismatch and triggers rebuild (no silent empty results)', async () => {
it('detects keyword index header mismatch and triggers background rebuild', async () => {
if (!tempRoot) throw new Error('tempRoot not initialized');

const ctxDir = path.join(tempRoot, CODEBASE_CONTEXT_DIRNAME);
Expand Down Expand Up @@ -302,13 +306,13 @@ describe('index versioning migration (MIGR-01)', () => {
});

const payload = JSON.parse(response.content[0].text);
expect(payload.status).toBe('success');
expect(payload.index.action).toBe('rebuilt-and-served');
expect(payload.status).toBe('indexing');
expect(payload.message).toContain('retry');
expect(payload.index.action).toBe('rebuild-started');
expect(String(payload.index.reason || '')).toContain('Keyword index');
expect(indexerMocks.index).toHaveBeenCalledTimes(1);
});

it('detects vector DB build marker mismatch and triggers rebuild', async () => {
it('detects vector DB build marker mismatch and triggers background rebuild', async () => {
if (!tempRoot) throw new Error('tempRoot not initialized');

const ctxDir = path.join(tempRoot, CODEBASE_CONTEXT_DIRNAME);
Expand Down Expand Up @@ -362,10 +366,10 @@ describe('index versioning migration (MIGR-01)', () => {
});

const payload = JSON.parse(response.content[0].text);
expect(payload.status).toBe('success');
expect(payload.index.action).toBe('rebuilt-and-served');
expect(payload.status).toBe('indexing');
expect(payload.message).toContain('retry');
expect(payload.index.action).toBe('rebuild-started');
expect(String(payload.index.reason || '')).toContain('Vector DB');
expect(indexerMocks.index).toHaveBeenCalledTimes(1);
});
});

Expand All @@ -382,9 +386,47 @@ describe('index-consuming allowlist enforcement', () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'index-versioning-allowlist-'));
process.env.CODEBASE_ROOT = tempRoot;
process.argv[2] = tempRoot;

const ctxDir = path.join(tempRoot, CODEBASE_CONTEXT_DIRNAME);
const buildId = 'allowlist-build';
const generatedAt = new Date().toISOString();

await fs.mkdir(path.join(ctxDir, VECTOR_DB_DIRNAME), { recursive: true });
await fs.writeFile(
path.join(ctxDir, VECTOR_DB_DIRNAME, 'index-build.json'),
JSON.stringify({ buildId, formatVersion: INDEX_FORMAT_VERSION }),
'utf-8'
);
await fs.writeFile(
path.join(ctxDir, KEYWORD_INDEX_FILENAME),
JSON.stringify({ header: { buildId, formatVersion: INDEX_FORMAT_VERSION }, chunks: [] }),
'utf-8'
);
await fs.writeFile(
path.join(ctxDir, INDEX_META_FILENAME),
JSON.stringify(
{
metaVersion: INDEX_META_VERSION,
formatVersion: INDEX_FORMAT_VERSION,
buildId,
generatedAt,
toolVersion: 'test',
artifacts: {
keywordIndex: { path: KEYWORD_INDEX_FILENAME },
vectorDb: { path: VECTOR_DB_DIRNAME, provider: 'lancedb' }
}
},
null,
2
),
'utf-8'
);
});

afterEach(async () => {
const { clearProjects } = await import('../src/project-state.js');
clearProjects();

if (originalArgv) process.argv = originalArgv;
if (originalEnvRoot === undefined) delete process.env.CODEBASE_ROOT;
else process.env.CODEBASE_ROOT = originalEnvRoot;
Expand Down
Loading
Loading