standalone MCP working

This commit is contained in:
abhigyanpatwari 2026-02-03 04:53:10 +05:30
parent 4357a48fae
commit fcbb6f9e92
21 changed files with 1826 additions and 493 deletions

1
.gitignore vendored
View file

@ -40,3 +40,4 @@ coverage/
.env*.local
.gitnexus

View file

@ -589,3 +589,5 @@ The design cleanly separates concerns across 7 layers, from symbolic math to num

View file

@ -380,3 +380,5 @@ model = pybamm.lithium_ion.DFN(

View file

@ -379,3 +379,5 @@ Expression tree traversal:

View file

@ -1,15 +1,26 @@
/**
* Analyze Command
*
* Indexes a repository and stores the knowledge graph in .gitnexus/
*/
import path from 'path';
import ora from 'ora';
import { runPipelineFromRepo } from '../core/ingestion/pipeline.js';
import { initKuzu, loadGraphToKuzu, getKuzuStats, executeQuery, executeWithReusedStatement } from '../core/kuzu/kuzu-adapter.js';
import { initKuzu, loadGraphToKuzu, getKuzuStats, executeQuery, executeWithReusedStatement, closeKuzu } from '../core/kuzu/kuzu-adapter.js';
import { buildBM25Index, exportBM25Index } from '../core/search/bm25-index.js';
import { runEmbeddingPipeline } from '../core/embeddings/embedding-pipeline.js';
import { ensureRepoBase, getRepoStoragePath, saveMeta, loadMeta } from '../storage/repo-manager.js';
import { getStoragePaths, saveMeta, loadMeta, addToGitignore } from '../storage/repo-manager.js';
import { getCurrentCommit, isGitRepo } from '../storage/git.js';
export interface AnalyzeOptions {
force?: boolean;
skipEmbeddings?: boolean;
}
export const analyzeCommand = async (
inputPath?: string,
options?: { force?: boolean; skipEmbeddings?: boolean }
options?: AnalyzeOptions
) => {
const repoPath = path.resolve(inputPath || '.');
const spinner = ora('Checking repository...').start();
@ -20,42 +31,45 @@ export const analyzeCommand = async (
return;
}
await ensureRepoBase();
const storagePath = getRepoStoragePath(repoPath);
const kuzuPath = path.join(storagePath, 'kuzu');
const bm25Path = path.join(storagePath, 'bm25.json');
const { storagePath, kuzuPath, bm25Path } = getStoragePaths(repoPath);
const currentCommit = getCurrentCommit(repoPath);
const existingMeta = await loadMeta(storagePath);
// Skip if already indexed at same commit
if (existingMeta && !options?.force && existingMeta.lastCommit === currentCommit) {
spinner.succeed('Repository already up to date');
return;
}
// Run ingestion pipeline
spinner.text = 'Running ingestion pipeline...';
const pipelineResult = await runPipelineFromRepo(repoPath, (progress) => {
spinner.text = `${progress.phase}: ${progress.percent}%`;
});
// Load graph into KuzuDB
spinner.text = 'Loading graph into KuzuDB...';
await initKuzu(kuzuPath);
await loadGraphToKuzu(pipelineResult.graph, pipelineResult.fileContents, storagePath);
// Build BM25 search index
spinner.text = 'Building BM25 index...';
buildBM25Index(pipelineResult.fileContents);
await exportBM25Index(bm25Path);
// Generate embeddings
if (!options?.skipEmbeddings) {
spinner.text = 'Generating embeddings...';
await runEmbeddingPipeline(
executeQuery,
executeWithReusedStatement,
(progress) => {
spinner.text = `embeddings: ${progress.percent}%`;
spinner.text = `Embeddings: ${progress.percent}%`;
}
);
}
// Save metadata
const stats = await getKuzuStats();
await saveMeta(storagePath, {
repoPath,
@ -70,7 +84,14 @@ export const analyzeCommand = async (
},
});
spinner.succeed('Repository indexed successfully');
console.log(`Storage: ${storagePath}`);
};
// Add .gitnexus to .gitignore
await addToGitignore(repoPath);
// Close database
await closeKuzu();
spinner.succeed('Repository indexed successfully');
console.log(` Path: ${repoPath}`);
console.log(` Storage: ${storagePath}`);
console.log(` Stats: ${stats.nodes} nodes, ${stats.edges} edges`);
};

View file

@ -1,90 +1,34 @@
/**
* Clean Command
*
* Removes the .gitnexus index from the current repository.
*/
import fs from 'fs/promises';
import { listIndexedRepos, getRepoStoragePath, hashRepoPath } from '../storage/repo-manager.js';
import { findRepo, getStoragePath } from '../storage/repo-manager.js';
export const cleanCommand = async (target?: string, options?: { all?: boolean; force?: boolean }) => {
const repos = await listIndexedRepos();
if (repos.length === 0) {
console.log('No indexed repositories found.');
export const cleanCommand = async (options?: { force?: boolean }) => {
const cwd = process.cwd();
const repo = await findRepo(cwd);
if (!repo) {
console.log('No indexed repository found in this directory.');
return;
}
// Clean all repos
if (options?.all) {
if (!options.force) {
console.log(`⚠️ This will delete ${repos.length} indexed repository(ies):`);
repos.forEach(repo => {
const repoName = repo.meta.repoPath.split(/[/\\]/).pop() || repo.meta.repoPath;
console.log(` - ${repoName} (${repo.id})`);
});
console.log('\nRun with --force to confirm deletion.');
return;
}
const repoName = repo.repoPath.split(/[/\\]/).pop() || repo.repoPath;
for (const repo of repos) {
try {
await fs.rm(repo.storagePath, { recursive: true, force: true });
const repoName = repo.meta.repoPath.split(/[/\\]/).pop() || repo.meta.repoPath;
console.log(`🗑️ Deleted: ${repoName} (${repo.id})`);
} catch (err) {
console.error(`Failed to delete ${repo.id}:`, err);
}
}
console.log(`\n✅ Cleaned ${repos.length} indexed repository(ies).`);
if (!options?.force) {
console.log(`⚠️ This will delete the GitNexus index for: ${repoName}`);
console.log(` Path: ${repo.storagePath}`);
console.log('\nRun with --force to confirm deletion.');
return;
}
// Clean specific repo by ID or path
if (target) {
// Try to match by ID first
let repoToDelete = repos.find(r => r.id === target || r.id.startsWith(target));
// If not found by ID, try to match by path
if (!repoToDelete) {
const targetLower = target.toLowerCase();
repoToDelete = repos.find(r => {
const repoPath = r.meta.repoPath.toLowerCase();
const repoName = repoPath.split(/[/\\]/).pop() || '';
return repoPath.includes(targetLower) || repoName === targetLower;
});
}
if (!repoToDelete) {
console.log(`❌ No indexed repository found matching: ${target}`);
console.log('\nAvailable repositories:');
repos.forEach(repo => {
const repoName = repo.meta.repoPath.split(/[/\\]/).pop() || repo.meta.repoPath;
console.log(` 📁 ${repoName} (${repo.id})`);
});
return;
}
const repoName = repoToDelete.meta.repoPath.split(/[/\\]/).pop() || repoToDelete.meta.repoPath;
if (!options?.force) {
console.log(`⚠️ This will delete the index for: ${repoName}`);
console.log(` Path: ${repoToDelete.meta.repoPath}`);
console.log(` ID: ${repoToDelete.id}`);
console.log('\nRun with --force to confirm deletion.');
return;
}
try {
await fs.rm(repoToDelete.storagePath, { recursive: true, force: true });
console.log(`🗑️ Deleted: ${repoName} (${repoToDelete.id})`);
} catch (err) {
console.error(`Failed to delete ${repoToDelete.id}:`, err);
}
return;
try {
await fs.rm(repo.storagePath, { recursive: true, force: true });
console.log(`🗑️ Deleted: ${repo.storagePath}`);
} catch (err) {
console.error('Failed to delete:', err);
}
// No target specified - show usage
console.log('Usage:');
console.log(' gitnexus clean <id-or-name> [--force] Delete a specific repo');
console.log(' gitnexus clean --all [--force] Delete all indexed repos');
console.log('\nIndexed repositories:');
repos.forEach(repo => {
const repoName = repo.meta.repoPath.split(/[/\\]/).pop() || repo.meta.repoPath;
console.log(` 📁 ${repoName} (${repo.id})`);
});
};

View file

@ -1,24 +1,31 @@
import { listIndexedRepos } from '../storage/repo-manager.js';
/**
* List Command
*
* Shows info about the indexed repo in the current directory.
*/
import path from 'path';
import { findRepo } from '../storage/repo-manager.js';
export const listCommand = async () => {
const repos = await listIndexedRepos();
if (repos.length === 0) {
console.log('No indexed repositories found.');
const cwd = process.cwd();
const repo = await findRepo(cwd);
if (!repo) {
console.log('No indexed repository found in this directory.');
console.log('Run `gitnexus analyze` to index your codebase.');
return;
}
repos.forEach((repo, index) => {
const stats = repo.meta.stats || {};
const repoName = repo.meta.repoPath.split(/[/\\]/).pop() || repo.meta.repoPath;
const indexedDate = new Date(repo.meta.indexedAt).toLocaleString();
console.log(`\n📁 ${repoName}`);
console.log(` Path: ${repo.meta.repoPath}`);
console.log(` Indexed: ${indexedDate}`);
console.log(` Stats: ${stats.files ?? 0} files, ${stats.nodes ?? 0} nodes, ${stats.edges ?? 0} edges`);
console.log(` Commit: ${repo.meta.lastCommit?.slice(0, 7) || 'unknown'} (id: ${repo.id})`);
});
const stats = repo.meta.stats || {};
const repoName = repo.repoPath.split(/[/\\]/).pop() || repo.repoPath;
const indexedDate = new Date(repo.meta.indexedAt).toLocaleString();
console.log(`\n📁 ${repoName}`);
console.log(` Path: ${repo.repoPath}`);
console.log(` Indexed: ${indexedDate}`);
console.log(` Stats: ${stats.files ?? 0} files, ${stats.nodes ?? 0} nodes, ${stats.edges ?? 0} edges`);
console.log(` Commit: ${repo.meta.lastCommit?.slice(0, 7) || 'unknown'}`);
if (stats.communities) console.log(` Communities: ${stats.communities}`);
if (stats.processes) console.log(` Processes: ${stats.processes}`);
};

View file

@ -1,28 +1,33 @@
import { detectRepoByCwd } from '../storage/repo-manager.js';
/**
* Status Command
*
* Shows the indexing status of the current repository.
*/
import { findRepo } from '../storage/repo-manager.js';
import { getCurrentCommit, isGitRepo } from '../storage/git.js';
export const statusCommand = async () => {
const cwd = process.cwd();
if (!isGitRepo(cwd)) {
console.log('Not a git repository.');
return;
}
const repo = await detectRepoByCwd(cwd);
const repo = await findRepo(cwd);
if (!repo) {
console.log('Repository not indexed. Run: gitnexus analyze');
console.log('Repository not indexed.');
console.log('Run: gitnexus analyze');
return;
}
const current = getCurrentCommit(repo.meta.repoPath);
const upToDate = current && current === repo.meta.lastCommit;
const currentCommit = getCurrentCommit(repo.repoPath);
const isUpToDate = currentCommit === repo.meta.lastCommit;
console.log(`Repo: ${repo.meta.repoPath}`);
console.log(`Indexed at: ${repo.meta.indexedAt}`);
console.log(`Last commit indexed: ${repo.meta.lastCommit}`);
console.log(`Current commit: ${current}`);
console.log(`Status: ${upToDate ? 'up-to-date' : 'stale'}`);
console.log(`Repository: ${repo.repoPath}`);
console.log(`Indexed: ${new Date(repo.meta.indexedAt).toLocaleString()}`);
console.log(`Indexed commit: ${repo.meta.lastCommit?.slice(0, 7)}`);
console.log(`Current commit: ${currentCommit?.slice(0, 7)}`);
console.log(`Status: ${isUpToDate ? '✅ up-to-date' : '⚠️ stale (re-run gitnexus analyze)'}`);
};

View file

@ -1,3 +1,9 @@
/**
* CLI MCP Server
*
* Standalone MCP server that uses local .gitnexus/ index.
*/
import path from 'path';
import fs from 'fs/promises';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
@ -9,7 +15,7 @@ import {
ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { GITNEXUS_TOOLS } from './tools.js';
import { detectRepoByCwd, loadMeta } from '../storage/repo-manager.js';
import { findRepo } from '../storage/repo-manager.js';
import { initKuzu, executeQuery } from '../core/kuzu/kuzu-adapter.js';
import { loadBM25Index, isBM25Ready, searchBM25 } from '../core/search/bm25-index.js';
import { hybridSearch } from '../core/search/hybrid-search.js';
@ -51,7 +57,7 @@ export const startMCPServer = async () => {
);
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const repo = await detectRepoByCwd(process.cwd());
const repo = await findRepo(process.cwd());
if (!repo) return { resources: [] };
return {
resources: [
@ -69,7 +75,7 @@ export const startMCPServer = async () => {
if (request.params.uri !== 'gitnexus://context') {
throw new Error(`Unknown resource: ${request.params.uri}`);
}
const repo = await detectRepoByCwd(process.cwd());
const repo = await findRepo(process.cwd());
if (!repo) {
return {
contents: [
@ -101,7 +107,7 @@ export const startMCPServer = async () => {
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const repo = await detectRepoByCwd(process.cwd());
const repo = await findRepo(process.cwd());
if (!repo) {
return {
content: [{ type: 'text', text: notIndexedMessage(process.cwd()) }],
@ -147,14 +153,7 @@ export const startMCPServer = async () => {
isError: true,
};
}
const meta = await loadMeta(repo.storagePath);
if (!meta) {
return {
content: [{ type: 'text', text: notIndexedMessage(process.cwd()) }],
isError: true,
};
}
const fullPath = path.join(meta.repoPath, String(filePath));
const fullPath = path.join(repo.repoPath, String(filePath));
const content = await fs.readFile(fullPath, 'utf-8');
return { content: [{ type: 'text', text: content }] };
}
@ -174,5 +173,3 @@ export const startMCPServer = async () => {
const transport = new StdioServerTransport();
await server.connect(transport);
};

View file

@ -1,8 +1,14 @@
/**
* HTTP API Server
*
* REST API for browser-based clients to query the local .gitnexus/ index.
*/
import express from 'express';
import cors from 'cors';
import path from 'path';
import fs from 'fs/promises';
import { listIndexedRepos, loadMeta } from '../storage/repo-manager.js';
import { findRepo, loadMeta } from '../storage/repo-manager.js';
import { initKuzu, executeQuery } from '../core/kuzu/kuzu-adapter.js';
import { NODE_TABLES } from '../core/kuzu/schema.js';
import { GraphNode, GraphRelationship } from '../core/graph/types.js';
@ -10,7 +16,6 @@ import { loadBM25Index, searchBM25, isBM25Ready } from '../core/search/bm25-inde
import { hybridSearch } from '../core/search/hybrid-search.js';
import { semanticSearch } from '../core/embeddings/embedding-pipeline.js';
import { isEmbedderReady } from '../core/embeddings/embedder.js';
import { getRepoStoragePath } from '../storage/repo-manager.js';
const buildGraph = async (): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => {
const nodes: GraphNode[] = [];
@ -31,38 +36,23 @@ const buildGraph = async (): Promise<{ nodes: GraphNode[]; relationships: GraphR
const rows = await executeQuery(query);
for (const row of rows) {
const id = row.id ?? row[0];
const name = row.name ?? row.label ?? row[1];
const filePath = row.filePath ?? row[2];
const startLine = row.startLine ?? row[3];
const endLine = row.endLine ?? row[4];
const content = row.content ?? row[5];
const heuristicLabel = row.heuristicLabel ?? row[2];
const cohesion = row.cohesion ?? row[3];
const symbolCount = row.symbolCount ?? row[4];
const processType = row.processType ?? row[3];
const stepCount = row.stepCount ?? row[4];
const communities = row.communities ?? row[5];
const entryPointId = row.entryPointId ?? row[6];
const terminalId = row.terminalId ?? row[7];
nodes.push({
id,
id: row.id ?? row[0],
label: table as GraphNode['label'],
properties: {
name,
filePath,
startLine,
endLine,
content,
heuristicLabel,
cohesion,
symbolCount,
processType,
stepCount,
communities,
entryPointId,
terminalId,
name: row.name ?? row.label ?? row[1],
filePath: row.filePath ?? row[2],
startLine: row.startLine,
endLine: row.endLine,
content: row.content,
heuristicLabel: row.heuristicLabel,
cohesion: row.cohesion,
symbolCount: row.symbolCount,
processType: row.processType,
stepCount: row.stepCount,
communities: row.communities,
entryPointId: row.entryPointId,
terminalId: row.terminalId,
} as GraphNode['properties'],
});
}
@ -76,20 +66,14 @@ const buildGraph = async (): Promise<{ nodes: GraphNode[]; relationships: GraphR
`MATCH (a)-[r:CodeRelation]->(b) RETURN a.id AS sourceId, b.id AS targetId, r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step`
);
for (const row of relRows) {
const sourceId = row.sourceId ?? row[0];
const targetId = row.targetId ?? row[1];
const type = row.type ?? row[2];
const confidence = row.confidence ?? row[3];
const reason = row.reason ?? row[4];
const step = row.step ?? row[5];
relationships.push({
id: `${sourceId}_${type}_${targetId}`,
type,
sourceId,
targetId,
confidence,
reason,
step,
id: `${row.sourceId}_${row.type}_${row.targetId}`,
type: row.type,
sourceId: row.sourceId,
targetId: row.targetId,
confidence: row.confidence,
reason: row.reason,
step: row.step,
});
}
@ -101,77 +85,53 @@ export const createServer = async (port: number) => {
app.use(cors());
app.use(express.json({ limit: '10mb' }));
app.get('/api/repos', async (_req, res) => {
const repos = await listIndexedRepos();
// Get repo info
app.get('/api/repo', async (_req, res) => {
const repo = await findRepo(process.cwd());
if (!repo) {
res.status(404).json({ error: 'Repository not indexed. Run: gitnexus analyze' });
return;
}
res.json({
repos: repos.map((r) => ({
id: r.id,
repoPath: r.meta.repoPath,
indexedAt: r.meta.indexedAt,
stats: r.meta.stats || {},
})),
repoPath: repo.repoPath,
indexedAt: repo.meta.indexedAt,
stats: repo.meta.stats || {},
});
});
app.get('/api/repos/:id/graph', async (req, res) => {
const storagePath = getRepoStoragePath(req.params.id);
const meta = await loadMeta(storagePath);
if (!meta) {
// Get full graph
app.get('/api/graph', async (_req, res) => {
const repo = await findRepo(process.cwd());
if (!repo) {
res.status(404).json({ error: 'Repository not indexed' });
return;
}
await initKuzu(path.join(storagePath, 'kuzu'));
await initKuzu(repo.kuzuPath);
const graph = await buildGraph();
res.json(graph);
});
app.get('/api/repos/:id/serialized', async (req, res) => {
const storagePath = getRepoStoragePath(req.params.id);
const meta = await loadMeta(storagePath);
if (!meta) {
// Execute Cypher query
app.post('/api/query', async (req, res) => {
const repo = await findRepo(process.cwd());
if (!repo) {
res.status(404).json({ error: 'Repository not indexed' });
return;
}
await initKuzu(path.join(storagePath, 'kuzu'));
const graph = await buildGraph();
const fileRows = await executeQuery(`MATCH (f:File) RETURN f.filePath AS path`);
const fileContents: Record<string, string> = {};
for (const row of fileRows) {
const relPath = row.path ?? row[0];
try {
const fullPath = path.join(meta.repoPath, relPath);
const content = await fs.readFile(fullPath, 'utf-8');
fileContents[relPath] = content;
} catch {
// ignore missing
}
}
res.json({ nodes: graph.nodes, relationships: graph.relationships, fileContents });
});
app.post('/api/repos/:id/query', async (req, res) => {
const storagePath = getRepoStoragePath(req.params.id);
const meta = await loadMeta(storagePath);
if (!meta) {
res.status(404).json({ error: 'Repository not indexed' });
return;
}
await initKuzu(path.join(storagePath, 'kuzu'));
await initKuzu(repo.kuzuPath);
const result = await executeQuery(req.body.cypher);
res.json({ result });
});
app.post('/api/repos/:id/search', async (req, res) => {
const storagePath = getRepoStoragePath(req.params.id);
const meta = await loadMeta(storagePath);
if (!meta) {
// Search
app.post('/api/search', async (req, res) => {
const repo = await findRepo(process.cwd());
if (!repo) {
res.status(404).json({ error: 'Repository not indexed' });
return;
}
await initKuzu(path.join(storagePath, 'kuzu'));
await loadBM25Index(path.join(storagePath, 'bm25.json'));
await initKuzu(repo.kuzuPath);
await loadBM25Index(repo.bm25Path);
const query = req.body.query ?? '';
const limit = req.body.limit ?? 10;
@ -183,24 +143,22 @@ export const createServer = async (port: number) => {
}
if (isBM25Ready()) {
const results = searchBM25(query, limit);
res.json({ results });
res.json({ results: searchBM25(query, limit) });
return;
}
if (isEmbedderReady()) {
const results = await semanticSearch(executeQuery, query, limit);
res.json({ results });
res.json({ results: await semanticSearch(executeQuery, query, limit) });
return;
}
res.json({ results: [] });
});
app.get('/api/repos/:id/file', async (req, res) => {
const storagePath = getRepoStoragePath(req.params.id);
const meta = await loadMeta(storagePath);
if (!meta) {
// Read file
app.get('/api/file', async (req, res) => {
const repo = await findRepo(process.cwd());
if (!repo) {
res.status(404).json({ error: 'Repository not indexed' });
return;
}
@ -209,7 +167,7 @@ export const createServer = async (port: number) => {
res.status(400).json({ error: 'Missing path' });
return;
}
const fullPath = path.join(meta.repoPath, filePath);
const fullPath = path.join(repo.repoPath, filePath);
const content = await fs.readFile(fullPath, 'utf-8');
res.json({ content });
});
@ -218,4 +176,3 @@ export const createServer = async (port: number) => {
console.log(`GitNexus server running on http://localhost:${port}`);
});
};

View file

@ -1,7 +1,11 @@
/**
* Repository Manager
*
* Manages GitNexus index storage in .gitnexus/ at repo root.
*/
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import crypto from 'crypto';
export interface RepoMeta {
repoPath: string;
@ -17,7 +21,7 @@ export interface RepoMeta {
}
export interface IndexedRepo {
id: string;
repoPath: string;
storagePath: string;
kuzuPath: string;
bm25Path: string;
@ -25,23 +29,31 @@ export interface IndexedRepo {
meta: RepoMeta;
}
const getHomeDir = (): string => path.join(os.homedir(), '.gitnexus');
const getReposDir = (): string => path.join(getHomeDir(), 'repos');
const GITNEXUS_DIR = '.gitnexus';
export const ensureRepoBase = async (): Promise<void> => {
await fs.mkdir(getReposDir(), { recursive: true });
/**
* Get the .gitnexus storage path for a repository
*/
export const getStoragePath = (repoPath: string): string => {
return path.join(path.resolve(repoPath), GITNEXUS_DIR);
};
export const hashRepoPath = (repoPath: string): string => {
const resolved = path.resolve(repoPath);
return crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 12);
};
export const getRepoStoragePath = (repoPathOrHash: string): string => {
const hash = repoPathOrHash.length === 12 ? repoPathOrHash : hashRepoPath(repoPathOrHash);
return path.join(getReposDir(), hash);
/**
* Get paths to key storage files
*/
export const getStoragePaths = (repoPath: string) => {
const storagePath = getStoragePath(repoPath);
return {
storagePath,
kuzuPath: path.join(storagePath, 'kuzu'),
bm25Path: path.join(storagePath, 'bm25.json'),
metaPath: path.join(storagePath, 'meta.json'),
};
};
/**
* Load metadata from an indexed repo
*/
export const loadMeta = async (storagePath: string): Promise<RepoMeta | null> => {
try {
const metaPath = path.join(storagePath, 'meta.json');
@ -52,50 +64,75 @@ export const loadMeta = async (storagePath: string): Promise<RepoMeta | null> =>
}
};
/**
* Save metadata to storage
*/
export const saveMeta = async (storagePath: string, meta: RepoMeta): Promise<void> => {
await fs.mkdir(storagePath, { recursive: true });
const metaPath = path.join(storagePath, 'meta.json');
await fs.writeFile(metaPath, JSON.stringify(meta, null, 2), 'utf-8');
};
export const listIndexedRepos = async (): Promise<IndexedRepo[]> => {
await ensureRepoBase();
const dirs = await fs.readdir(getReposDir(), { withFileTypes: true });
const repos: IndexedRepo[] = [];
for (const dir of dirs) {
if (!dir.isDirectory()) continue;
const id = dir.name;
const storagePath = path.join(getReposDir(), id);
const meta = await loadMeta(storagePath);
if (!meta) continue;
repos.push({
id,
storagePath,
kuzuPath: path.join(storagePath, 'kuzu'),
bm25Path: path.join(storagePath, 'bm25.json'),
metaPath: path.join(storagePath, 'meta.json'),
meta,
});
/**
* Check if a path has a GitNexus index
*/
export const hasIndex = async (repoPath: string): Promise<boolean> => {
const { metaPath } = getStoragePaths(repoPath);
try {
await fs.access(metaPath);
return true;
} catch {
return false;
}
return repos;
};
export const detectRepoByCwd = async (cwd: string): Promise<IndexedRepo | null> => {
const repos = await listIndexedRepos();
const cwdResolved = path.resolve(cwd);
const cwdLower = cwdResolved.toLowerCase();
/**
* Load an indexed repo from a path
*/
export const loadRepo = async (repoPath: string): Promise<IndexedRepo | null> => {
const paths = getStoragePaths(repoPath);
const meta = await loadMeta(paths.storagePath);
if (!meta) return null;
return {
repoPath: path.resolve(repoPath),
...paths,
meta,
};
};
for (const repo of repos) {
const repoPath = path.resolve(repo.meta.repoPath);
const repoLower = repoPath.toLowerCase();
if (cwdLower.startsWith(repoLower) || repoLower.startsWith(cwdLower)) {
return repo;
}
/**
* Find .gitnexus by walking up from a starting path
*/
export const findRepo = async (startPath: string): Promise<IndexedRepo | null> => {
let current = path.resolve(startPath);
const root = path.parse(current).root;
while (current !== root) {
const repo = await loadRepo(current);
if (repo) return repo;
current = path.dirname(current);
}
return null;
};
/**
* Add .gitnexus to .gitignore if not already present
*/
export const addToGitignore = async (repoPath: string): Promise<void> => {
const gitignorePath = path.join(repoPath, '.gitignore');
try {
const content = await fs.readFile(gitignorePath, 'utf-8');
if (content.includes(GITNEXUS_DIR)) return;
const newContent = content.endsWith('\n')
? `${content}${GITNEXUS_DIR}\n`
: `${content}\n${GITNEXUS_DIR}\n`;
await fs.writeFile(gitignorePath, newContent, 'utf-8');
} catch {
// .gitignore doesn't exist, create it
await fs.writeFile(gitignorePath, `${GITNEXUS_DIR}\n`, 'utf-8');
}
};

View file

@ -1,15 +1,17 @@
{
"name": "gitnexus-mcp",
"version": "0.1.1",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus-mcp",
"version": "0.1.1",
"version": "0.2.0",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"kuzu": "^0.11.0",
"minisearch": "^7.1.0",
"uuid": "^13.0.0",
"ws": "^8.16.0"
},
@ -593,6 +595,67 @@
}
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aproba": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
"integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
"license": "ISC"
},
"node_modules/are-we-there-yet": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz",
"integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"dependencies": {
"delegates": "^1.0.0",
"readable-stream": "^3.6.0"
},
"engines": {
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@ -655,6 +718,100 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/chownr": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/cmake-js": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-7.4.0.tgz",
"integrity": "sha512-Lw0JxEHrmk+qNj1n9W9d4IvkDdYTBn7l2BW6XmtLj7WPpIo2shvxUy+YokfjMxAAOELNonQwX3stkPhM5xSC2Q==",
"license": "MIT",
"dependencies": {
"axios": "^1.6.5",
"debug": "^4",
"fs-extra": "^11.2.0",
"memory-stream": "^1.0.0",
"node-api-headers": "^1.1.0",
"npmlog": "^6.0.2",
"rc": "^1.2.7",
"semver": "^7.5.4",
"tar": "^6.2.0",
"url-join": "^4.0.1",
"which": "^2.0.2",
"yargs": "^17.7.2"
},
"bin": {
"cmake-js": "bin/cmake-js"
},
"engines": {
"node": ">= 14.15.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
"license": "ISC",
"bin": {
"color-support": "bin.js"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
"license": "ISC"
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@ -739,6 +896,30 @@
}
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/delegates": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
"license": "MIT"
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -768,6 +949,12 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@ -807,6 +994,21 @@
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@ -849,6 +1051,15 @@
"@esbuild/win32-x64": "0.27.2"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@ -986,6 +1197,63 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -1004,6 +1272,44 @@
"node": ">= 0.8"
}
},
"node_modules/fs-extra": {
"version": "11.3.3",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz",
"integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/fs-minipass": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
"license": "ISC",
"dependencies": {
"minipass": "^3.0.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/fs-minipass/node_modules/minipass": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -1028,6 +1334,35 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gauge": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz",
"integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"dependencies": {
"aproba": "^1.0.3 || ^2.0.0",
"color-support": "^1.1.3",
"console-control-strings": "^1.1.0",
"has-unicode": "^2.0.1",
"signal-exit": "^3.0.7",
"string-width": "^4.2.3",
"strip-ansi": "^6.0.1",
"wide-align": "^1.1.5"
},
"engines": {
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@ -1090,6 +1425,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@ -1102,6 +1443,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-unicode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
"license": "ISC"
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@ -1166,6 +1528,12 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -1175,6 +1543,15 @@
"node": ">= 0.10"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@ -1208,6 +1585,30 @@
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause"
},
"node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/kuzu": {
"version": "0.11.3",
"resolved": "https://registry.npmjs.org/kuzu/-/kuzu-0.11.3.tgz",
"integrity": "sha512-4+hD3Y+YMV3e0uiqTv1/GUal47D04l8qluw1WFWg8Nx3k7rLsHG1Pmq9WHIOlf1742svxQvTYQiuY6oS1qxAZA==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"cmake-js": "^7.3.0",
"node-addon-api": "^6.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -1226,6 +1627,15 @@
"node": ">= 0.8"
}
},
"node_modules/memory-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/memory-stream/-/memory-stream-1.0.0.tgz",
"integrity": "sha512-Wm13VcsPIMdG96dzILfij09PvuS3APtcKNh7M28FsCA/w6+1mjR7hhPmfFNoilX9xU7wTdhsH5lJAm6XNzdtww==",
"license": "MIT",
"dependencies": {
"readable-stream": "^3.4.0"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
@ -1263,6 +1673,67 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
"license": "ISC",
"engines": {
"node": ">=8"
}
},
"node_modules/minisearch": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz",
"integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==",
"license": "MIT"
},
"node_modules/minizlib": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
"license": "MIT",
"dependencies": {
"minipass": "^3.0.0",
"yallist": "^4.0.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/minizlib/node_modules/minipass": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
"license": "MIT",
"bin": {
"mkdirp": "bin/cmd.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -1278,6 +1749,34 @@
"node": ">= 0.6"
}
},
"node_modules/node-addon-api": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
"integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==",
"license": "MIT"
},
"node_modules/node-api-headers": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/node-api-headers/-/node-api-headers-1.8.0.tgz",
"integrity": "sha512-jfnmiKWjRAGbdD1yQS28bknFM1tbHC1oucyuMPjmkEs+kpiu76aRs40WlTmBmyEgzDM76ge1DQ7XJ3R5deiVjQ==",
"license": "MIT"
},
"node_modules/npmlog": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz",
"integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"dependencies": {
"are-we-there-yet": "^3.0.0",
"console-control-strings": "^1.1.0",
"gauge": "^4.0.3",
"set-blocking": "^2.0.0"
},
"engines": {
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -1370,6 +1869,12 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
@ -1409,6 +1914,44 @@
"node": ">= 0.10"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -1444,12 +1987,44 @@
"node": ">= 18"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
@ -1495,6 +2070,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@ -1594,6 +2175,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@ -1603,6 +2190,68 @@
"node": ">= 0.8"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
"deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me",
"license": "ISC",
"dependencies": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
"minipass": "^5.0.0",
"minizlib": "^2.1.1",
"mkdirp": "^1.0.3",
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@ -1667,6 +2316,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@ -1676,6 +2334,18 @@
"node": ">= 0.8"
}
},
"node_modules/url-join": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz",
"integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==",
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
@ -1713,6 +2383,32 @@
"node": ">= 8"
}
},
"node_modules/wide-align": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
"integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
"license": "ISC",
"dependencies": {
"string-width": "^1.0.2 || 2 || 3 || 4"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@ -1740,6 +2436,48 @@
}
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/zod": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",

View file

@ -31,6 +31,8 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"kuzu": "^0.11.0",
"minisearch": "^7.1.0",
"uuid": "^13.0.0",
"ws": "^8.16.0"
},

View file

@ -1,13 +1,14 @@
/**
* Serve Command
*
* Starts the MCP server that bridges external AI agents to GitNexus.
* - Listens on stdio for MCP protocol (from AI tools)
* - Hosts a local WebSocket bridge for the GitNexus browser app
* Starts the MCP server with hybrid mode:
* 1. First tries local .gitnexus/ index (standalone mode)
* 2. Falls back to WebSocket bridge if browser is running
*/
import { startMCPServer } from '../mcp/server.js';
import { WebSocketBridge } from '../bridge/websocket-server.js';
import { LocalBackend } from '../local/local-backend.js';
interface ServeOptions {
port: string;
@ -15,17 +16,30 @@ interface ServeOptions {
export async function serveCommand(options: ServeOptions) {
const port = parseInt(options.port, 10);
// Use GITNEXUS_CWD env var if set, otherwise use process.cwd()
const cwd = process.env.GITNEXUS_CWD || process.cwd();
// Start local WebSocket bridge (browser connects to ws://localhost:<port>)
const client = new WebSocketBridge(port);
const started = await client.start();
// Try local backend first (standalone mode)
const local = new LocalBackend();
const hasLocalIndex = await local.init(cwd);
if (hasLocalIndex) {
console.error(`GitNexus: Using local index at ${local.storagePath}`);
await startMCPServer(local);
return;
}
// No local index - fall back to browser bridge
console.error('GitNexus: No local .gitnexus/ found, starting browser bridge...');
const bridge = new WebSocketBridge(port);
const started = await bridge.start();
if (!started) {
console.error(`Failed to start GitNexus browser bridge on port ${port}.`);
console.error('Another process is already using this port.');
console.error('Run "gitnexus analyze" to index this repository for standalone mode.');
process.exit(1);
}
// Start MCP server on stdio (AI tools connect here)
await startMCPServer(client);
await startMCPServer(bridge);
}

View file

@ -0,0 +1,120 @@
/**
* BM25 Full-Text Search Index (Read-Only)
*
* Uses MiniSearch for fast keyword-based search with BM25 ranking.
* For MCP, we only load and search - not build.
*/
import MiniSearch from 'minisearch';
import fs from 'fs/promises';
export interface BM25Document {
id: string; // File path
content: string; // File content
name: string; // File name (boosted in search)
}
export interface BM25SearchResult {
filePath: string;
score: number;
rank: number;
}
let searchIndex: MiniSearch<BM25Document> | null = null;
let indexedDocCount = 0;
/**
* Common stop words to filter out
*/
const STOP_WORDS = new Set([
'const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while',
'class', 'new', 'this', 'import', 'export', 'from', 'default', 'async', 'await',
'try', 'catch', 'throw', 'typeof', 'instanceof', 'true', 'false', 'null', 'undefined',
'the', 'is', 'at', 'which', 'on', 'a', 'an', 'and', 'or', 'but', 'in', 'with',
'to', 'of', 'it', 'be', 'as', 'by', 'that', 'for', 'are', 'was', 'were',
]);
/**
* Tokenizer for BM25 search
*/
const tokenize = (text: string): string[] => {
const tokens = text.toLowerCase().split(/[\s\-_./\\(){}[\]<>:;,!?'"]+/);
const expanded: string[] = [];
for (const token of tokens) {
if (token.length === 0) continue;
const camelParts = token.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase().split(' ');
expanded.push(...camelParts);
if (camelParts.length > 1) {
expanded.push(token);
}
}
return expanded.filter(t => t.length > 1 && !STOP_WORDS.has(t));
};
/**
* Load a BM25 index from disk
*/
export const loadBM25Index = async (filePath: string): Promise<boolean> => {
try {
const json = await fs.readFile(filePath, 'utf-8');
// MiniSearch.loadJSON expects the raw JSON string, not a parsed object
searchIndex = MiniSearch.loadJSON(json, {
fields: ['content', 'name'],
storeFields: ['id'],
tokenize,
});
indexedDocCount = searchIndex.documentCount;
return true;
} catch {
return false;
}
};
/**
* Search the BM25 index
*/
export const searchBM25 = (query: string, limit: number = 20): BM25SearchResult[] => {
if (!searchIndex) {
return [];
}
const results = searchIndex.search(query, {
fuzzy: 0.2,
prefix: true,
boost: { name: 2 },
});
return results.slice(0, limit).map((r, index) => ({
filePath: r.id,
score: r.score,
rank: index + 1,
}));
};
/**
* Check if the BM25 index is ready
*/
export const isBM25Ready = (): boolean => {
return searchIndex !== null && indexedDocCount > 0;
};
/**
* Get index statistics
*/
export const getBM25Stats = (): { documentCount: number; termCount: number } => {
if (!searchIndex) {
return { documentCount: 0, termCount: 0 };
}
return {
documentCount: indexedDocCount,
termCount: searchIndex.termCount,
};
};
/**
* Clear the index
*/
export const clearBM25Index = (): void => {
searchIndex = null;
indexedDocCount = 0;
};

View file

@ -0,0 +1,54 @@
/**
* KuzuDB Adapter (Read-Only)
*
* Simplified adapter for MCP that only reads from existing .gitnexus/ database.
*/
import fs from 'fs/promises';
import path from 'path';
import kuzu from 'kuzu';
let db: kuzu.Database | null = null;
let conn: kuzu.Connection | null = null;
export const initKuzu = async (dbPath: string): Promise<void> => {
if (conn) return;
// Check if database exists
try {
await fs.stat(dbPath);
} catch {
throw new Error(`KuzuDB not found at ${dbPath}. Run: gitnexus analyze`);
}
db = new kuzu.Database(dbPath);
conn = new kuzu.Connection(db);
};
export const executeQuery = async (cypher: string): Promise<any[]> => {
if (!conn) {
throw new Error('KuzuDB not initialized. Call initKuzu first.');
}
const queryResult = await conn.query(cypher);
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
const rows = await result.getAll();
return rows;
};
export const closeKuzu = async (): Promise<void> => {
if (conn) {
try {
await conn.close();
} catch {}
conn = null;
}
if (db) {
try {
await db.close();
} catch {}
db = null;
}
};
export const isKuzuReady = (): boolean => conn !== null && db !== null;

View file

@ -0,0 +1,589 @@
/**
* Local Backend
*
* Provides tool implementations using local .gitnexus/ index.
* This enables MCP to work without the browser.
*/
import fs from 'fs/promises';
import path from 'path';
import { initKuzu, executeQuery, closeKuzu, isKuzuReady } from '../core/kuzu-adapter.js';
import { loadBM25Index, searchBM25, isBM25Ready } from '../core/bm25-index.js';
export interface RepoMeta {
repoPath: string;
lastCommit: string;
indexedAt: string;
stats?: {
files?: number;
nodes?: number;
edges?: number;
communities?: number;
processes?: number;
};
}
export interface IndexedRepo {
repoPath: string;
storagePath: string;
kuzuPath: string;
bm25Path: string;
metaPath: string;
meta: RepoMeta;
}
const GITNEXUS_DIR = '.gitnexus';
function getStoragePaths(repoPath: string) {
const storagePath = path.join(path.resolve(repoPath), GITNEXUS_DIR);
return {
storagePath,
kuzuPath: path.join(storagePath, 'kuzu'),
bm25Path: path.join(storagePath, 'bm25.json'),
metaPath: path.join(storagePath, 'meta.json'),
};
}
async function loadMeta(storagePath: string): Promise<RepoMeta | null> {
try {
const metaPath = path.join(storagePath, 'meta.json');
const raw = await fs.readFile(metaPath, 'utf-8');
return JSON.parse(raw) as RepoMeta;
} catch {
return null;
}
}
async function loadRepo(repoPath: string): Promise<IndexedRepo | null> {
const paths = getStoragePaths(repoPath);
const meta = await loadMeta(paths.storagePath);
if (!meta) return null;
return {
repoPath: path.resolve(repoPath),
...paths,
meta,
};
}
export async function findRepo(startPath: string): Promise<IndexedRepo | null> {
let current = path.resolve(startPath);
const root = path.parse(current).root;
while (current !== root) {
const repo = await loadRepo(current);
if (repo) return repo;
current = path.dirname(current);
}
return null;
}
export interface CodebaseContext {
projectName: string;
stats: {
fileCount: number;
functionCount: number;
classCount: number;
interfaceCount: number;
methodCount: number;
communityCount: number;
processCount: number;
};
hotspots: Array<{
name: string;
type: string;
filePath: string;
connections: number;
}>;
folderTree: string;
}
export class LocalBackend {
private repo: IndexedRepo | null = null;
private _context: CodebaseContext | null = null;
private initialized = false;
async init(cwd: string): Promise<boolean> {
this.repo = await findRepo(cwd);
if (!this.repo) return false;
const stats = this.repo.meta.stats || {};
this._context = {
projectName: path.basename(this.repo.repoPath),
stats: {
fileCount: stats.files || 0,
functionCount: stats.nodes || 0,
classCount: 0,
interfaceCount: 0,
methodCount: 0,
communityCount: stats.communities || 0,
processCount: stats.processes || 0,
},
hotspots: [],
folderTree: '',
};
return true;
}
private async ensureInitialized(): Promise<void> {
if (this.initialized || !this.repo) return;
await initKuzu(this.repo.kuzuPath);
await loadBM25Index(this.repo.bm25Path);
this.initialized = true;
}
get context(): CodebaseContext | null {
return this._context;
}
get isReady(): boolean {
return this.repo !== null;
}
get repoPath(): string | null {
return this.repo?.repoPath || null;
}
get storagePath(): string | null {
return this.repo?.storagePath || null;
}
async callTool(method: string, params: any): Promise<any> {
if (!this.repo) {
throw new Error('Repository not indexed. Run: gitnexus analyze');
}
switch (method) {
case 'context':
return this.getContext();
case 'search':
return this.search(params);
case 'cypher':
return this.cypher(params);
case 'overview':
return this.overview(params);
case 'explore':
return this.explore(params);
case 'impact':
return this.impact(params);
case 'analyze':
return this.analyze(params);
default:
throw new Error(`Unknown tool: ${method}`);
}
}
private async getContext(): Promise<string> {
if (!this._context || !this.repo) {
return 'Repository not indexed. Run: gitnexus analyze';
}
const stats = this.repo.meta.stats || {};
return [
`# GitNexus: ${this._context.projectName}`,
'',
'## Stats',
`- Files: ${stats.files || 0}`,
`- Nodes: ${stats.nodes || 0}`,
`- Edges: ${stats.edges || 0}`,
`- Communities: ${stats.communities || 0}`,
`- Processes: ${stats.processes || 0}`,
'',
`Indexed: ${this.repo.meta.indexedAt}`,
`Commit: ${this.repo.meta.lastCommit?.slice(0, 7)}`,
'',
'## Available Tools',
'- **analyze**: Index/re-index repository',
'- **search**: Hybrid semantic + keyword search',
'- **cypher**: Graph queries (Cypher)',
'- **overview**: List communities and processes',
'- **explore**: Deep dive on symbol/cluster/process',
'- **impact**: Change impact analysis',
].join('\n');
}
private async search(params: { query: string; limit?: number; depth?: string }): Promise<any> {
await this.ensureInitialized();
const limit = params.limit || 10;
const query = params.query;
const depth = params.depth || 'definitions';
// BM25 keyword search
const bm25Results = isBM25Ready() ? searchBM25(query, limit * 2) : [];
if (bm25Results.length === 0) {
return { message: 'No results found', query, bm25Ready: isBM25Ready() };
}
// Get node details from kuzu for top results
const results: any[] = [];
for (const bm25Result of bm25Results.slice(0, limit)) {
try {
// Use CONTAINS to match file paths (handles relative vs full paths)
const fileName = bm25Result.filePath.split('/').pop() || bm25Result.filePath;
const symbolQuery = `
MATCH (n)
WHERE n.filePath CONTAINS '${fileName.replace(/'/g, "''")}'
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
LIMIT 5
`;
const symbols = await executeQuery(symbolQuery);
if (symbols.length > 0) {
for (const sym of symbols) {
const result: any = {
name: sym.name || sym[1],
type: sym.type || sym[2],
filePath: sym.filePath || sym[3],
startLine: sym.startLine || sym[4],
endLine: sym.endLine || sym[5],
score: bm25Result.score,
};
// Add relationships if depth is 'full'
if (depth === 'full') {
const relQuery = `
MATCH (n {id: '${(sym.id || sym[0]).replace(/'/g, "''")}' })-[r:CodeRelation]->(m)
RETURN r.type AS type, m.name AS targetName, m.filePath AS targetPath
LIMIT 5
`;
try {
const rels = await executeQuery(relQuery);
result.connections = rels.map((rel: any) => ({
type: rel.type || rel[0],
name: rel.targetName || rel[1],
path: rel.targetPath || rel[2],
}));
} catch {
result.connections = [];
}
}
results.push(result);
}
} else {
// No symbols found in kuzu, return file info from BM25
results.push({
name: fileName,
type: 'File',
filePath: bm25Result.filePath,
score: bm25Result.score,
});
}
} catch {
// On kuzu error, still return BM25 result
results.push({
name: bm25Result.filePath.split('/').pop(),
type: 'File',
filePath: bm25Result.filePath,
score: bm25Result.score,
});
}
}
return results.slice(0, limit);
}
private async cypher(params: { query: string }): Promise<any> {
await this.ensureInitialized();
if (!isKuzuReady()) {
return { error: 'KuzuDB not ready. Index may be corrupted.' };
}
try {
const result = await executeQuery(params.query);
return result;
} catch (err: any) {
return { error: err.message || 'Query failed' };
}
}
private async overview(params: { showClusters?: boolean; showProcesses?: boolean; limit?: number }): Promise<any> {
await this.ensureInitialized();
const limit = params.limit || 20;
const result: any = {
repoPath: this.repo!.repoPath,
stats: this.repo!.meta.stats,
indexedAt: this.repo!.meta.indexedAt,
lastCommit: this.repo!.meta.lastCommit,
};
if (params.showClusters !== false) {
try {
const clusters = await executeQuery(`
MATCH (c:Community)
RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
ORDER BY c.symbolCount DESC
LIMIT ${limit}
`);
result.clusters = clusters.map((c: any) => ({
id: c.id || c[0],
label: c.label || c[1],
heuristicLabel: c.heuristicLabel || c[2],
cohesion: c.cohesion || c[3],
symbolCount: c.symbolCount || c[4],
}));
} catch {
result.clusters = [];
}
}
if (params.showProcesses !== false) {
try {
const processes = await executeQuery(`
MATCH (p:Process)
RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
ORDER BY p.stepCount DESC
LIMIT ${limit}
`);
result.processes = processes.map((p: any) => ({
id: p.id || p[0],
label: p.label || p[1],
heuristicLabel: p.heuristicLabel || p[2],
processType: p.processType || p[3],
stepCount: p.stepCount || p[4],
}));
} catch {
result.processes = [];
}
}
return result;
}
private async explore(params: { name: string; type: 'symbol' | 'cluster' | 'process' }): Promise<any> {
await this.ensureInitialized();
const { name, type } = params;
if (type === 'symbol') {
// Find symbol and its context
const symbolQuery = `
MATCH (n)
WHERE n.name = '${name.replace(/'/g, "''")}'
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
LIMIT 1
`;
const symbols = await executeQuery(symbolQuery);
if (symbols.length === 0) return { error: `Symbol '${name}' not found` };
const sym = symbols[0];
const symId = sym.id || sym[0];
// Get callers
const callersQuery = `
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(n {id: '${symId}'})
RETURN caller.name AS name, caller.filePath AS filePath
LIMIT 10
`;
const callers = await executeQuery(callersQuery);
// Get callees
const calleesQuery = `
MATCH (n {id: '${symId}'})-[:CodeRelation {type: 'CALLS'}]->(callee)
RETURN callee.name AS name, callee.filePath AS filePath
LIMIT 10
`;
const callees = await executeQuery(calleesQuery);
// Get community
const communityQuery = `
MATCH (n {id: '${symId}'})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
RETURN c.label AS label, c.heuristicLabel AS heuristicLabel
LIMIT 1
`;
const communities = await executeQuery(communityQuery);
return {
symbol: {
id: symId,
name: sym.name || sym[1],
type: sym.type || sym[2],
filePath: sym.filePath || sym[3],
startLine: sym.startLine || sym[4],
endLine: sym.endLine || sym[5],
},
callers: callers.map((c: any) => ({ name: c.name || c[0], filePath: c.filePath || c[1] })),
callees: callees.map((c: any) => ({ name: c.name || c[0], filePath: c.filePath || c[1] })),
community: communities.length > 0 ? {
label: communities[0].label || communities[0][0],
heuristicLabel: communities[0].heuristicLabel || communities[0][1],
} : null,
};
}
if (type === 'cluster') {
const clusterQuery = `
MATCH (c:Community)
WHERE c.label = '${name.replace(/'/g, "''")}' OR c.heuristicLabel = '${name.replace(/'/g, "''")}'
RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
LIMIT 1
`;
const clusters = await executeQuery(clusterQuery);
if (clusters.length === 0) return { error: `Cluster '${name}' not found` };
const cluster = clusters[0];
const clusterId = cluster.id || cluster[0];
const membersQuery = `
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c {id: '${clusterId}'})
RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
LIMIT 20
`;
const members = await executeQuery(membersQuery);
return {
cluster: {
id: clusterId,
label: cluster.label || cluster[1],
heuristicLabel: cluster.heuristicLabel || cluster[2],
cohesion: cluster.cohesion || cluster[3],
symbolCount: cluster.symbolCount || cluster[4],
},
members: members.map((m: any) => ({
name: m.name || m[0],
type: m.type || m[1],
filePath: m.filePath || m[2],
})),
};
}
if (type === 'process') {
const processQuery = `
MATCH (p:Process)
WHERE p.label = '${name.replace(/'/g, "''")}' OR p.heuristicLabel = '${name.replace(/'/g, "''")}'
RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount, p.entryPointId AS entryPointId, p.terminalId AS terminalId
LIMIT 1
`;
const processes = await executeQuery(processQuery);
if (processes.length === 0) return { error: `Process '${name}' not found` };
const proc = processes[0];
const procId = proc.id || proc[0];
const stepsQuery = `
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: '${procId}'})
RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, r.step AS step
ORDER BY r.step
`;
const steps = await executeQuery(stepsQuery);
return {
process: {
id: procId,
label: proc.label || proc[1],
heuristicLabel: proc.heuristicLabel || proc[2],
processType: proc.processType || proc[3],
stepCount: proc.stepCount || proc[4],
},
steps: steps.map((s: any) => ({
step: s.step || s[3],
name: s.name || s[0],
type: s.type || s[1],
filePath: s.filePath || s[2],
})),
};
}
return { error: 'Invalid type. Use: symbol, cluster, or process' };
}
private async impact(params: { target: string; direction: 'upstream' | 'downstream'; maxDepth?: number }): Promise<any> {
await this.ensureInitialized();
const { target, direction } = params;
const maxDepth = params.maxDepth || 3;
// Find target symbol
const targetQuery = `
MATCH (n)
WHERE n.name = '${target.replace(/'/g, "''")}'
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
LIMIT 1
`;
const targets = await executeQuery(targetQuery);
if (targets.length === 0) return { error: `Target '${target}' not found` };
const sym = targets[0];
const symId = sym.id || sym[0];
// BFS to find impacted nodes
const impacted: any[] = [];
const visited = new Set<string>([symId]);
let frontier = [symId];
for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
const nextFrontier: string[] = [];
for (const nodeId of frontier) {
const query = direction === 'upstream'
? `MATCH (caller)-[r:CodeRelation]->(n {id: '${nodeId}'}) WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'] RETURN caller.id AS id, caller.name AS name, labels(caller)[0] AS type, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence`
: `MATCH (n {id: '${nodeId}'})-[r:CodeRelation]->(callee) WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'] RETURN callee.id AS id, callee.name AS name, labels(callee)[0] AS type, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence`;
const related = await executeQuery(query);
for (const rel of related) {
const relId = rel.id || rel[0];
if (!visited.has(relId)) {
visited.add(relId);
nextFrontier.push(relId);
impacted.push({
depth,
id: relId,
name: rel.name || rel[1],
type: rel.type || rel[2],
filePath: rel.filePath || rel[3],
relationType: rel.relType || rel[4],
confidence: rel.confidence || rel[5] || 1.0,
});
}
}
}
frontier = nextFrontier;
}
// Group by depth
const grouped: Record<number, any[]> = {};
for (const item of impacted) {
if (!grouped[item.depth]) grouped[item.depth] = [];
grouped[item.depth].push(item);
}
return {
target: {
id: symId,
name: sym.name || sym[1],
type: sym.type || sym[2],
filePath: sym.filePath || sym[3],
},
direction,
impactedCount: impacted.length,
byDepth: grouped,
};
}
private async analyze(params: { path?: string; force?: boolean }): Promise<any> {
const targetPath = params.path ? path.resolve(params.path) : process.cwd();
return {
action: 'analyze',
targetPath,
message: `To index this repository, run:\n\n cd ${targetPath}\n gitnexus analyze${params.force ? ' --force' : ''}\n\nThis will create a .gitnexus/ folder with the knowledge graph.`,
};
}
disconnect(): void {
closeKuzu();
this.repo = null;
this._context = null;
this.initialized = false;
}
}

View file

@ -2,7 +2,7 @@
* MCP Tool Definitions
*
* Defines the tools that GitNexus exposes to external AI agents.
* Each tool has a rich description with examples to help agents use them correctly.
* Only includes tools that provide unique value over native IDE capabilities.
*/
export interface ToolDefinition {
@ -15,12 +15,37 @@ export interface ToolDefinition {
description?: string;
default?: any;
items?: { type: string };
enum?: string[];
}>;
required: string[];
};
}
export const GITNEXUS_TOOLS: ToolDefinition[] = [
{
name: 'analyze',
description: `Index or re-index the current repository.
Creates .gitnexus/ in repo root with:
- Knowledge graph (functions, classes, calls, imports)
- BM25 search index
- Community detection (Leiden)
- Process tracing
Run this when:
- First time using GitNexus on a repo
- After major code changes
- When 'not indexed' error appears`,
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Repo path (default: current directory)' },
force: { type: 'boolean', description: 'Re-index even if exists', default: false },
skipEmbeddings: { type: 'boolean', description: 'Skip embedding generation (faster)', default: false },
},
required: [],
},
},
{
name: 'context',
description: `Get GitNexus codebase context. CALL THIS FIRST before using other tools.
@ -28,7 +53,7 @@ export const GITNEXUS_TOOLS: ToolDefinition[] = [
Returns:
- Project name and stats (files, functions, classes)
- Hotspots (most connected/important nodes)
- Directory structure (TOON format for token efficiency)
- Communities and processes count
- Tool usage guidance
ALWAYS call this first to understand the codebase before searching or querying.`,
@ -43,10 +68,10 @@ ALWAYS call this first to understand the codebase before searching or querying.`
description: `Hybrid search (keyword + semantic) across the codebase.
Returns code nodes with their graph connections, grouped by process.
WHEN TO USE:
- Finding implementations ("where is auth handled?")
- Understanding code flow ("what calls UserService?")
- Locating patterns ("find all API endpoints")
BETTER THAN IDE search because:
- Process-aware grouping (shows execution flows)
- Cluster context (which functional area)
- Relationship data (callers/callees)
RETURNS: Array of {name, type, filePath, code, connections[], cluster, processes[]}`,
inputSchema: {
@ -54,6 +79,7 @@ RETURNS: Array of {name, type, filePath, code, connections[], cluster, processes
properties: {
query: { type: 'string', description: 'Natural language or keyword search query' },
limit: { type: 'number', description: 'Max results to return', default: 10 },
depth: { type: 'string', description: 'Result detail: "definitions" (symbols only) or "full" (with relationships)', enum: ['definitions', 'full'], default: 'definitions' },
groupByProcess: { type: 'boolean', description: 'Group results by process', default: true },
},
required: ['query'],
@ -89,50 +115,6 @@ TIPS:
required: ['query'],
},
},
{
name: 'grep',
description: `Regex search for exact patterns in file contents.
WHEN TO USE:
- Finding exact strings: error codes, TODOs, specific API keys
- Pattern matching: all console.log, all fetch calls
- Finding imports of specific modules
BETTER THAN search for: exact matches, regex patterns, case-sensitive
RETURNS: Array of {filePath, line, lineNumber, match}`,
inputSchema: {
type: 'object',
properties: {
pattern: { type: 'string', description: 'Regex pattern to search for' },
caseSensitive: { type: 'boolean', description: 'Case-sensitive search', default: false },
maxResults: { type: 'number', description: 'Max results to return', default: 50 },
},
required: ['pattern'],
},
},
{
name: 'read',
description: `Read file content from the codebase.
WHEN TO USE:
- After search/grep to see full context
- To understand implementation details
- Before making changes
ALWAYS read before concluding - don't guess from names alone.
RETURNS: {filePath, content, language, lines}`,
inputSchema: {
type: 'object',
properties: {
filePath: { type: 'string', description: 'Path to file to read' },
startLine: { type: 'number', description: 'Start line (optional)' },
endLine: { type: 'number', description: 'End line (optional)' },
},
required: ['filePath'],
},
},
{
name: 'explore',
description: `Deep dive on a symbol, cluster, or process.
@ -206,20 +188,4 @@ Depth groups:
required: ['target', 'direction'],
},
},
{
name: 'highlight',
description: `Highlight nodes in the GitNexus graph visualization.
Use after search/analysis to show the user what you found.
The user will see the nodes glow in the graph view.
Great for visual confirmation of your findings.`,
inputSchema: {
type: 'object',
properties: {
nodeIds: { type: 'array', items: { type: 'string' }, description: 'Array of node IDs to highlight' },
color: { type: 'string', description: 'Highlight color (optional, default: cyan)' },
},
required: ['nodeIds'],
},
},
];

View file

@ -26,7 +26,6 @@ const AppContent = () => {
isRightPanelOpen,
runPipeline,
runPipelineFromFiles,
loadSerializedGraph,
isSettingsPanelOpen,
setSettingsPanelOpen,
refreshLLMSettings,
@ -43,8 +42,6 @@ const AppContent = () => {
} = useAppState();
const [showClusteringModal, setShowClusteringModal] = useState(false);
const [localRepos, setLocalRepos] = useState<Array<{ id: string; repoPath: string; indexedAt: string }>>([]);
const [localAvailable, setLocalAvailable] = useState(false);
// Trigger clustering modal after ingestion if not seen yet
// DISABLED: Clustering is now in the upload flow
@ -191,64 +188,6 @@ const AppContent = () => {
}
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineFromFiles, startEmbeddings, initializeAgent, runClusterEnrichment]);
useEffect(() => {
fetch('http://localhost:4747/api/repos')
.then((res) => res.json())
.then((data) => {
if (data?.repos?.length) {
setLocalAvailable(true);
setLocalRepos(data.repos);
}
})
.catch(() => {
setLocalAvailable(false);
});
}, []);
const handleOpenLocalRepo = useCallback(async (repoId: string, repoPath: string) => {
const project = repoPath.split('/').pop() || repoPath.split('\\').pop() || 'repository';
setProjectName(project);
setProgress({ phase: 'extracting', percent: 0, message: 'Loading local repository...' });
setViewMode('loading');
try {
const res = await fetch(`http://localhost:4747/api/repos/${repoId}/serialized`);
if (!res.ok) {
throw new Error(`Failed to fetch local repo: ${res.status}`);
}
const serialized = await res.json();
const result = await loadSerializedGraph(serialized);
setGraph(result.graph);
setFileContents(result.fileContents);
setViewMode('exploring');
if (getActiveProviderConfig()) {
initializeAgent(project);
}
startEmbeddings().catch((err) => {
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
startEmbeddings('wasm').catch(console.warn);
} else {
console.warn('Embeddings auto-start failed:', err);
}
});
} catch (error) {
console.error('Local repo load error:', error);
setProgress({
phase: 'error',
percent: 0,
message: 'Error loading local repository',
detail: error instanceof Error ? error.message : 'Unknown error',
});
setTimeout(() => {
setViewMode('onboarding');
setProgress(null);
}, 3000);
}
}, [setProjectName, setProgress, setViewMode, loadSerializedGraph, setGraph, setFileContents, initializeAgent, startEmbeddings]);
const handleFocusNode = useCallback((nodeId: string) => {
graphCanvasRef.current?.focusNode(nodeId);
}, []);
@ -262,34 +201,7 @@ const AppContent = () => {
// Render based on view mode
if (viewMode === 'onboarding') {
return (
<div className="flex h-screen flex-col">
{localAvailable && localRepos.length > 0 && (
<div className="mx-auto mt-8 w-full max-w-4xl rounded-lg border border-zinc-700 bg-zinc-900 p-4">
<div className="text-sm font-medium text-zinc-200">Local GitNexus server detected</div>
<div className="mt-2 space-y-2">
{localRepos.map((repo) => (
<div key={repo.id} className="flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2">
<div>
<div className="text-sm text-zinc-200">{repo.repoPath}</div>
<div className="text-xs text-zinc-500">Indexed: {repo.indexedAt}</div>
</div>
<button
className="rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-500"
onClick={() => handleOpenLocalRepo(repo.id, repo.repoPath)}
>
Open
</button>
</div>
))}
</div>
</div>
)}
<div className="flex-1">
<DropZone onFileSelect={handleFileSelect} onGitClone={handleGitClone} />
</div>
</div>
);
return <DropZone onFileSelect={handleFileSelect} onGitClone={handleGitClone} />;
}
if (viewMode === 'loading' && progress) {

View file

@ -1,7 +1,7 @@
import { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react';
import * as Comlink from 'comlink';
import { KnowledgeGraph, GraphNode, NodeLabel } from '../core/graph/types';
import { PipelineProgress, PipelineResult, SerializablePipelineResult, deserializePipelineResult } from '../types/pipeline';
import { PipelineProgress, PipelineResult, deserializePipelineResult } from '../types/pipeline';
import { createKnowledgeGraph } from '../core/graph/graph';
import { DEFAULT_VISIBLE_LABELS } from '../lib/constants';
import type { IngestionWorkerApi } from '../workers/ingestion.worker';
@ -114,7 +114,6 @@ interface AppState {
// Worker API (shared across app)
runPipeline: (file: File, onProgress: (p: PipelineProgress) => void, clusteringConfig?: ProviderConfig) => Promise<PipelineResult>;
runPipelineFromFiles: (files: FileEntry[], onProgress: (p: PipelineProgress) => void, clusteringConfig?: ProviderConfig) => Promise<PipelineResult>;
loadSerializedGraph: (serialized: SerializablePipelineResult) => Promise<PipelineResult>;
runQuery: (cypher: string) => Promise<any[]>;
isDatabaseReady: () => Promise<boolean>;
@ -461,15 +460,6 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
return deserializePipelineResult(serializedResult, createKnowledgeGraph);
}, []);
const loadSerializedGraph = useCallback(async (
serialized: SerializablePipelineResult
): Promise<PipelineResult> => {
const api = apiRef.current;
if (!api) throw new Error('Worker not initialized');
await api.loadSerializedGraph(serialized);
return deserializePipelineResult(serialized, createKnowledgeGraph);
}, []);
const runQuery = useCallback(async (cypher: string): Promise<any[]> => {
const api = apiRef.current;
if (!api) throw new Error('Worker not initialized');
@ -1206,7 +1196,6 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
setProjectName,
runPipeline,
runPipelineFromFiles,
loadSerializedGraph,
runQuery,
isDatabaseReady,
// Embedding state and methods

View file

@ -1,6 +1,6 @@
import * as Comlink from 'comlink';
import { runIngestionPipeline, runPipelineFromFiles } from '../core/ingestion/pipeline';
import { PipelineProgress, SerializablePipelineResult, serializePipelineResult, deserializePipelineResult } from '../types/pipeline';
import { PipelineProgress, SerializablePipelineResult, serializePipelineResult } from '../types/pipeline';
import { FileEntry } from '../services/zip';
import {
runEmbeddingPipeline,
@ -25,7 +25,6 @@ import {
mergeWithRRF,
type HybridSearchResult,
} from '../core/search';
import { createKnowledgeGraph } from '../core/graph/graph';
// Lazy import for Kuzu to avoid breaking worker if SharedArrayBuffer unavailable
let kuzuAdapter: typeof import('../core/kuzu/kuzu-adapter') | null = null;
@ -224,31 +223,6 @@ const workerApi = {
return serializePipelineResult(result);
},
/**
* Load a serialized graph result into the worker (for local CLI integration)
*/
async loadSerializedGraph(serialized: SerializablePipelineResult): Promise<void> {
const result = deserializePipelineResult(serialized, createKnowledgeGraph);
currentGraphResult = result;
storedFileContents = result.fileContents;
const bm25DocCount = buildBM25Index(storedFileContents);
if (import.meta.env.DEV) {
console.log(`🔍 BM25 index built: ${bm25DocCount} documents`);
}
try {
const kuzu = await getKuzuAdapter();
await kuzu.loadGraphToKuzu(result.graph, result.fileContents);
if (import.meta.env.DEV) {
const stats = await kuzu.getKuzuStats();
console.log('KuzuDB loaded from serialized graph:', stats);
}
} catch {
// KuzuDB is optional
}
},
// ============================================================
// Embedding Pipeline Methods
// ============================================================