gitnexus wal cleanup preventing reindexing issue fixed

This commit is contained in:
abhigyanpatwari 2026-02-06 06:35:15 +05:30
parent 4bc4b68786
commit 0943df9feb
50 changed files with 1855 additions and 1917 deletions

View file

@ -7,6 +7,7 @@ description: Trace bugs through call chains using knowledge graph
## Quick Start
```
0. If "Index is stale" → gitnexus_analyze({})
1. gitnexus_search({query}) → Find code related to error
2. gitnexus_explore({name, type: "symbol"}) → Get callers and callees
3. READ gitnexus://process/{name} → Trace execution flow

View file

@ -7,6 +7,7 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
## Quick Start
```
0. If "Index is stale" → gitnexus_analyze({})
1. READ gitnexus://context → Get codebase overview (~150 tokens)
2. READ gitnexus://clusters → See all functional clusters
3. READ gitnexus://cluster/{name} → Deep dive on specific cluster

View file

@ -7,6 +7,7 @@ description: Analyze blast radius before making code changes
## Quick Start
```
0. If "Index is stale" → gitnexus_analyze({})
1. gitnexus_impact({target, direction: "upstream"}) → What depends on this
2. READ gitnexus://clusters → Check affected areas
3. READ gitnexus://processes → Affected execution flows

View file

@ -7,6 +7,7 @@ description: Plan safe refactors using blast radius and dependency mapping
## Quick Start
```
0. If "Index is stale" → gitnexus_analyze({})
1. gitnexus_impact({target, direction: "upstream"}) → Map all dependents
2. READ gitnexus://schema → Understand graph structure
3. gitnexus_cypher → Find all references

View file

@ -13,10 +13,10 @@ This project is indexed by GitNexus, providing AI agents with deep code intellig
| Metric | Count |
|--------|-------|
| Files | 150 |
| Symbols | 930 |
| Relationships | 2411 |
| Communities | 280 |
| Files | 146 |
| Symbols | 907 |
| Relationships | 2306 |
| Communities | 278 |
| Processes | 75 |
## Quick Start

View file

@ -7,10 +7,10 @@ This project is indexed by GitNexus, providing AI agents with deep code intellig
| Metric | Count |
|--------|-------|
| Files | 150 |
| Symbols | 930 |
| Relationships | 2411 |
| Communities | 280 |
| Files | 146 |
| Symbols | 907 |
| Relationships | 2306 |
| Communities | 278 |
| Processes | 75 |
## Quick Start

View file

@ -521,7 +521,7 @@ ORDER BY distance
| **Visualization** | Sigma.js, Graphology, ForceAtlas2 (WebGL) |
| **Parsing** | Tree-sitter WASM (9 languages) |
| **Database** | KuzuDB WASM (graph + vector HNSW) |
| **Clustering** | Graphology + Leiden (Louvain) |
| **Clustering** | Graphology + Leiden algorithm |
| **Embeddings** | transformers.js, snowflake-arctic-embed-xs (22M) |
| **AI** | LangChain ReAct agent, streaming |
| **Concurrency** | Web Workers + Comlink |
@ -537,12 +537,6 @@ ORDER BY distance
---
## 📝 License
MIT License
---
## 🙏 Acknowledgments
- [Tree-sitter](https://tree-sitter.github.io/) - AST parsing

2
gitnexus-web/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.vercel
.env*.local

View file

@ -1,204 +0,0 @@
/**
* Activity Feed Component
*
* Shows real-time log of external AI agent tool calls.
* Used in RightPanel as an alternative to the Chat tab.
*/
import { useState, useEffect, useRef } from 'react';
import { Activity, Search, Database, Terminal, Loader2, CheckCircle, XCircle, Clock, FileText, Zap, Map, Compass } from 'lucide-react';
import { getMCPClient, type ActivityEvent } from '../core/mcp/mcp-client';
// Tool icons
const TOOL_ICONS: Record<string, typeof Search> = {
context: Zap,
search: Search,
cypher: Database,
grep: Terminal,
read: FileText,
impact: Activity,
overview: Map,
explore: Compass,
};
// Tool colors
const TOOL_COLORS: Record<string, string> = {
context: 'text-amber-400',
search: 'text-cyan-400',
cypher: 'text-purple-400',
grep: 'text-green-400',
read: 'text-blue-400',
impact: 'text-rose-400',
overview: 'text-indigo-400',
explore: 'text-teal-400',
};
export function ActivityFeed() {
const [events, setEvents] = useState<ActivityEvent[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const client = getMCPClient();
// Subscribe to activity events
const unsubscribe = client.onActivity((event) => {
setEvents(prev => {
// Keep max 100 events
const next = [...prev, event];
if (next.length > 100) {
next.shift();
}
return next;
});
});
// Get existing events
setEvents(client.getActivityLog());
return () => {
unsubscribe();
};
}, []);
// Auto-scroll to bottom
useEffect(() => {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [events]);
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
};
const formatParams = (params: any): string => {
if (!params) return '';
// Show first key-value pairs, truncated
const entries = Object.entries(params).slice(0, 2);
return entries.map(([k, v]) => {
const val = typeof v === 'string' ? v.slice(0, 30) : JSON.stringify(v).slice(0, 30);
return `${k}: ${val}${val.length >= 30 ? '...' : ''}`;
}).join(', ');
};
const formatResult = (event: ActivityEvent): string => {
if (event.status === 'running') return 'Running...';
if (event.status === 'error') return `Error: ${event.error?.slice(0, 50) || 'Unknown'}`;
// Format result based on type
if (Array.isArray(event.result)) {
return `${event.result.length} results`;
}
if (typeof event.result === 'object' && event.result) {
const keys = Object.keys(event.result);
if (keys.includes('content')) return `${event.result.content?.length || 0} chars`;
if (keys.includes('projectName')) return event.result.projectName;
return `{${keys.slice(0, 3).join(', ')}${keys.length > 3 ? '...' : ''}}`;
}
return String(event.result || 'Done');
};
if (events.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-center px-8">
<div className="w-14 h-14 mb-4 flex items-center justify-center bg-gradient-to-br from-accent to-node-class rounded-xl shadow-glow text-2xl">
📡
</div>
<h3 className="text-base font-medium mb-2">
No Agent Activity
</h3>
<p className="text-sm text-text-secondary leading-relaxed">
When external AI agents (Cursor, Claude Code) call GitNexus tools,
their activity will appear here in real-time.
</p>
<p className="text-xs text-text-muted mt-4">
Make sure MCP toggle is enabled in the header
</p>
</div>
);
}
return (
<div ref={containerRef} className="flex-1 overflow-y-auto p-4 scrollbar-thin">
<div className="space-y-3">
{events.map((event) => {
const Icon = TOOL_ICONS[event.tool] || Activity;
const color = TOOL_COLORS[event.tool] || 'text-text-muted';
return (
<div
key={event.id}
className="bg-elevated border border-border-subtle rounded-lg p-3 animate-fade-in"
>
{/* Header row */}
<div className="flex items-center gap-2 mb-2">
{/* Agent color indicator */}
{event.agentColor && (
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: event.agentColor }}
title={event.agentName || 'Unknown agent'}
/>
)}
<Icon className={`w-4 h-4 ${color}`} />
<span className="font-medium text-sm">{event.tool}</span>
{event.agentName && event.agentName !== 'Unknown' && (
<span
className="text-[10px] px-1.5 py-0.5 rounded-full"
style={{
backgroundColor: `${event.agentColor}20`,
color: event.agentColor
}}
>
{event.agentName}
</span>
)}
<span className="text-xs text-text-muted ml-auto flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatTime(event.timestamp)}
</span>
</div>
{/* Params preview */}
{event.params && Object.keys(event.params).length > 0 && (
<div className="text-xs text-text-secondary font-mono bg-deep rounded px-2 py-1 mb-2 truncate">
{formatParams(event.params)}
</div>
)}
{/* Status/Result */}
<div className="flex items-center gap-2 text-xs">
{event.status === 'running' && (
<>
<Loader2 className="w-3 h-3 animate-spin text-accent" />
<span className="text-accent">Running...</span>
</>
)}
{event.status === 'complete' && (
<>
<CheckCircle className="w-3 h-3 text-green-400" />
<span className="text-text-secondary">{formatResult(event)}</span>
{event.duration && (
<span className="text-text-muted ml-auto">{event.duration}ms</span>
)}
</>
)}
{event.status === 'error' && (
<>
<XCircle className="w-3 h-3 text-rose-400" />
<span className="text-rose-300">{formatResult(event)}</span>
</>
)}
</div>
</div>
);
})}
</div>
</div>
);
}

View file

@ -3,8 +3,6 @@ import { useAppState } from '../hooks/useAppState';
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import { GraphNode } from '../core/graph/types';
import { EmbeddingStatus } from './EmbeddingStatus';
import { MCPToggle } from './MCPToggle';
import { buildCodebaseContext } from '../core/llm/context-builder';
// Color mapping for node types in search results
const NODE_TYPE_COLORS: Record<string, string> = {
@ -31,11 +29,6 @@ export const Header = ({ onFocusNode }: HeaderProps) => {
isRightPanelOpen,
rightPanelTab,
setSettingsPanelOpen,
runQuery,
semanticSearch,
setHighlightedNodeIds,
fileContents,
triggerNodeAnimation,
} = useAppState();
const [searchQuery, setSearchQuery] = useState('');
const [isSearchOpen, setIsSearchOpen] = useState(false);
@ -220,177 +213,6 @@ export const Header = ({ onFocusNode }: HeaderProps) => {
{/* Embedding Status */}
<EmbeddingStatus />
{/* MCP Toggle for external AI agents */}
<MCPToggle
showOnboardingTip={!!graph}
onSearch={async (query, limit = 10) => {
// Use semantic search from the app
const results = await semanticSearch(query, limit);
// Trigger pulse animation on search results
const nodeIds = results.map((r: any) => r.id).filter(Boolean);
if (nodeIds.length > 0) {
triggerNodeAnimation(nodeIds, 'pulse');
}
return results;
}}
onCypher={async (query) => {
// Execute Cypher query
const results = await runQuery(query);
return results;
}}
onImpact={async (nodeId: string, hops = 2) => {
// Run impact analysis query
const query = `
MATCH (start)-[*1..${hops}]-(connected)
WHERE start.id = '${nodeId}' OR start.name = '${nodeId}'
RETURN DISTINCT connected.id AS id, connected.name AS name, labels(connected) AS labels
`;
const results = await runQuery(query);
// Trigger ripple animation on impact results
const nodeIds = results.map((r: any) => r.id).filter(Boolean);
if (nodeIds.length > 0) {
triggerNodeAnimation(nodeIds, 'ripple');
}
return results;
}}
onOverview={async () => {
// Return codebase overview: clusters + processes
const clustersQuery = `
MATCH (c:Community)
OPTIONAL MATCH (c)<-[:CodeRelation {type: 'MEMBER_OF'}]-(m)
RETURN c.id AS id, c.label AS label, c.cohesion AS cohesion, c.description AS description, count(m) AS memberCount
ORDER BY memberCount DESC
LIMIT 50
`;
const processesQuery = `
MATCH (p:Process)
RETURN p.id AS id, p.label AS label, p.processType AS type, p.stepCount AS steps
ORDER BY p.stepCount DESC
LIMIT 50
`;
const [clusters, processes] = await Promise.all([
runQuery(clustersQuery),
runQuery(processesQuery),
]);
return { clusters, processes };
}}
onExplore={async (target: string, type?: 'symbol' | 'cluster' | 'process') => {
// Explore a specific target
if (type === 'cluster' || target.startsWith('comm_')) {
const query = `
MATCH (c:Community)
WHERE c.id = '${target}' OR c.label CONTAINS '${target}'
OPTIONAL MATCH (c)<-[:CodeRelation {type: 'MEMBER_OF'}]-(m)
RETURN c.id AS id, c.label AS label, c.description AS description, collect(m.name)[0..10] AS members
LIMIT 1
`;
return await runQuery(query);
} else if (type === 'process' || target.startsWith('proc_')) {
const query = `
MATCH (p:Process)
WHERE p.id = '${target}' OR p.label CONTAINS '${target}'
OPTIONAL MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p)
RETURN p.id AS id, p.label AS label, p.stepCount AS steps, collect({name: s.name, step: r.step})[0..20] AS trace
LIMIT 1
`;
return await runQuery(query);
} else {
// Symbol exploration
const query = `
MATCH (n)
WHERE n.name = '${target}' OR n.id ENDS WITH ':${target}'
OPTIONAL MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
OPTIONAL MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, label(n) AS nodeType,
c.label AS cluster, collect({process: p.label, step: r.step}) AS processes
LIMIT 1
`;
return await runQuery(query);
}
}}
getContext={async () => {
// Build codebase context for external AI agents
if (!projectName) return null;
const context = await buildCodebaseContext(runQuery, projectName);
// Reshape to match MCP CodebaseContext format
return {
projectName: context.stats.projectName,
stats: {
fileCount: context.stats.fileCount,
functionCount: context.stats.functionCount,
classCount: context.stats.classCount,
interfaceCount: context.stats.interfaceCount,
methodCount: context.stats.methodCount,
},
hotspots: context.hotspots,
folderTree: context.folderTree,
};
}}
onGrep={async (pattern, caseSensitive = false, maxResults = 50) => {
// Grep across file contents
const results: Array<{ filePath: string; line: string; lineNumber: number; match: string }> = [];
const regex = new RegExp(pattern, caseSensitive ? 'g' : 'gi');
for (const [filePath, content] of fileContents.entries()) {
const lines = content.split('\n');
for (let i = 0; i < lines.length && results.length < maxResults; i++) {
const line = lines[i];
const match = line.match(regex);
if (match) {
results.push({
filePath,
line: line.trim(),
lineNumber: i + 1,
match: match[0],
});
}
}
if (results.length >= maxResults) break;
}
return results;
}}
onRead={async (filePath, startLine, endLine) => {
// Read file content
let content = fileContents.get(filePath);
// Try normalized path if not found
if (!content) {
const normalizedPath = filePath.replace(/\\/g, '/');
for (const [path, c] of fileContents.entries()) {
if (path.endsWith(normalizedPath) || normalizedPath.endsWith(path)) {
content = c;
break;
}
}
}
if (!content) {
return { error: `File not found: ${filePath}` };
}
const lines = content.split('\n');
const language = filePath.split('.').pop() || 'text';
// If line range specified, return only those lines
if (startLine !== undefined && endLine !== undefined) {
const slice = lines.slice(startLine - 1, endLine);
return {
filePath,
content: slice.join('\n'),
language,
lines: slice.length,
};
}
return {
filePath,
content,
language,
lines: lines.length,
};
}}
/>
{/* Icon buttons */}
<button
onClick={() => setSettingsPanelOpen(true)}

View file

@ -1,140 +0,0 @@
import { Brain, Sparkles, X, Settings } from 'lucide-react';
interface IntelligentClusteringModalProps {
isOpen: boolean;
onClose: () => void;
onEnable: () => void;
onConfigure: () => void;
}
export const IntelligentClusteringModal = ({
isOpen,
onClose,
onEnable,
onConfigure
}: IntelligentClusteringModalProps) => {
if (!isOpen) return null;
// Parent handles all state updates via onEnable/onConfigure/onClose
const handleEnable = () => {
onEnable();
};
const handleSkip = () => {
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={handleSkip}
/>
{/* Modal Content */}
<div className="relative bg-surface border border-border-subtle rounded-2xl shadow-2xl max-w-md w-full overflow-hidden animate-in fade-in zoom-in-95 duration-200">
{/* Header with cool gradient background */}
<div className="bg-gradient-to-br from-accent/20 to-surface p-6 pb-8 border-b border-border-subtle/50 relative overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-10">
<Brain className="w-32 h-32 rotate-12" />
</div>
<button
onClick={handleSkip}
className="absolute top-4 right-4 p-2 text-text-muted hover:text-text-primary rounded-full hover:bg-black/10 transition-colors"
>
<X className="w-5 h-5" />
</button>
<div className="flex items-center gap-3 mb-2">
<div className="p-3 bg-accent text-white rounded-xl shadow-lg shadow-accent/20">
<Sparkles className="w-6 h-6" />
</div>
</div>
<h2 className="text-xl font-bold text-text-primary mt-4">
Upgrade to Intelligent Clustering?
</h2>
<p className="text-text-secondary mt-1 text-sm leading-relaxed">
Your clusters are ready, but they could be smarter! Right now they're just named after folders.
</p>
</div>
{/* Body */}
<div className="p-6 space-y-6">
<div className="space-y-3">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<Brain className="w-4 h-4 text-accent" />
What you get:
</h3>
<ul className="space-y-2 text-sm text-text-secondary">
<li className="flex items-start gap-2">
<span className="text-green-400 mt-1">✓</span>
Semantic names (e.g., "Auth System" vs "utils")
</li>
<li className="flex items-start gap-2">
<span className="text-green-400 mt-1">✓</span>
Search keywords for better agent context
</li>
<li className="flex items-start gap-2">
<span className="text-green-400 mt-1">✓</span>
Descriptions of what the code actually does
</li>
</ul>
</div>
{/* How it works */}
<div className="p-4 bg-elevated/50 border border-border-subtle rounded-xl">
<div className="flex items-start gap-3">
<div className="p-2 bg-blue-500/10 rounded-lg text-blue-400">
<Brain className="w-4 h-4" />
</div>
<div>
<h4 className="text-sm font-medium text-text-primary">Uses Your Configured LLM</h4>
<p className="text-xs text-text-muted mt-1 leading-relaxed">
Runs on your own API key. Very low token usage (~$0.01 for most codebases).
<br />
<span className="text-amber-400">💡 Tip:</span> Use a cheaper model like GPT-4o-mini in settings!
</p>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="p-6 pt-2 bg-surface flex flex-col gap-3">
<button
onClick={handleEnable}
className="w-full py-3 px-4 bg-accent text-white font-medium rounded-xl hover:bg-accent-dim shadow-lg shadow-accent/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2"
>
<Sparkles className="w-4 h-4" />
Enable Smart Clustering
</button>
<div className="flex bg-elevated rounded-xl p-1 gap-1">
<button
onClick={onConfigure}
className="flex-1 py-2 px-3 text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-hover rounded-lg transition-colors flex items-center justify-center gap-2"
>
<Settings className="w-3 h-3" />
Configure Model
</button>
<div className="w-px bg-border-subtle my-2" />
<button
onClick={handleSkip}
className="flex-1 py-2 px-3 text-sm font-medium text-text-muted hover:text-text-primary hover:bg-hover rounded-lg transition-colors"
>
No thanks (Free)
</button>
</div>
</div>
</div>
</div>
);
};

View file

@ -1,300 +0,0 @@
/**
* MCP Toggle Component
*
* Toggle for enabling MCP exposure to external AI agents (Cursor, Claude, etc.)
* Shows MCP config for setup and connection status.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { Copy, Check, X, Sparkles, Zap, ExternalLink } from 'lucide-react';
import { getMCPClient, type CodebaseContext } from '../core/mcp/mcp-client';
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error';
interface MCPToggleProps {
onSearch?: (query: string, limit?: number) => Promise<any>;
onCypher?: (query: string) => Promise<any>;
onImpact?: (nodeId: string, hops?: number) => Promise<any>;
onGrep?: (pattern: string, caseSensitive?: boolean, maxResults?: number) => Promise<any>;
onRead?: (filePath: string, startLine?: number, endLine?: number) => Promise<any>;
onOverview?: () => Promise<any>;
onExplore?: (target: string, type?: 'symbol' | 'cluster' | 'process') => Promise<any>;
showOnboardingTip?: boolean;
getContext?: () => Promise<CodebaseContext | null>;
}
const MCP_TIP_DISMISSED_KEY = 'gitnexus-mcp-tip-dismissed';
// MCP config that users copy to their AI agent
const MCP_CONFIG = `{
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["--prefer-online", "-y", "gitnexus-mcp@latest"]
}
}
}`;
export function MCPToggle({
onSearch,
onCypher,
onImpact,
onGrep,
onRead,
onOverview,
onExplore,
showOnboardingTip = false,
getContext,
}: MCPToggleProps = {}) {
const [status, setStatus] = useState<ConnectionState>('disconnected');
const [copied, setCopied] = useState(false);
const [showPopup, setShowPopup] = useState(false);
const popupRef = useRef<HTMLDivElement | null>(null);
const [showTip, setShowTip] = useState(false);
const isConnected = status === 'connected';
const isConnecting = status === 'connecting';
// Show tip when graph becomes ready
useEffect(() => {
if (showOnboardingTip) {
const dismissed = localStorage.getItem(MCP_TIP_DISMISSED_KEY);
if (!dismissed) {
const timer = setTimeout(() => setShowTip(true), 1500);
return () => clearTimeout(timer);
}
}
}, [showOnboardingTip]);
// Close popup when clicking outside
useEffect(() => {
if (!showPopup) return;
const handleClickOutside = (event: MouseEvent) => {
if (popupRef.current && !popupRef.current.contains(event.target as Node)) {
setShowPopup(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showPopup]);
const dismissTip = () => {
setShowTip(false);
localStorage.setItem(MCP_TIP_DISMISSED_KEY, 'true');
};
const connect = useCallback(async () => {
const client = getMCPClient();
setStatus('connecting');
setShowTip(false);
try {
await client.connect();
// Register tool handlers
if (onSearch) client.registerHandler('search', async (params) => onSearch(params.query, params.limit));
if (onCypher) client.registerHandler('cypher', async (params) => onCypher(params.query));
if (onImpact) client.registerHandler('impact', async (params) => onImpact(params.nodeId, params.hops));
if (onGrep) client.registerHandler('grep', async (params) => onGrep(params.pattern, params.caseSensitive, params.maxResults));
if (onRead) client.registerHandler('read', async (params) => onRead(params.filePath, params.startLine, params.endLine));
if (onOverview) client.registerHandler('overview', async () => onOverview());
if (onExplore) client.registerHandler('explore', async (params) => onExplore(params.target, params.type));
if (getContext) client.registerHandler('context', async () => getContext());
setStatus('connected');
setShowPopup(false);
localStorage.setItem(MCP_TIP_DISMISSED_KEY, 'true');
// Send context after connecting
if (getContext) {
try {
const context = await getContext();
if (context) client.sendContext(context);
} catch (e) {
console.error('[MCP] Failed to send context:', e);
}
}
} catch {
setStatus('error');
}
}, [onSearch, onCypher, onImpact, onGrep, onRead, onOverview, onExplore, getContext]);
const disconnect = useCallback(() => {
const client = getMCPClient();
client.disconnect();
setStatus('disconnected');
}, []);
const toggle = useCallback(() => {
if (isConnected) {
disconnect();
} else if (!isConnecting) {
connect();
}
}, [isConnected, isConnecting, connect, disconnect]);
const copyConfig = () => {
navigator.clipboard.writeText(MCP_CONFIG);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
// Listen for connection changes
useEffect(() => {
const client = getMCPClient();
const unsubscribe = client.onConnectionChange((connected) => {
setStatus(connected ? 'connected' : 'disconnected');
if (connected) setShowPopup(false);
});
return () => { unsubscribe(); };
}, []);
return (
<div className="relative flex items-center gap-2">
{/* MCP Button */}
<button
onClick={() => setShowPopup(!showPopup)}
className={`
flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium
transition-all duration-200
${isConnected
? 'bg-green-500/15 text-green-400 border border-green-500/30 hover:bg-green-500/25'
: 'bg-surface hover:bg-hover text-text-secondary hover:text-text-primary border border-border-subtle'
}
`}
>
<Zap className={`w-3.5 h-3.5 ${isConnected ? 'text-green-400' : ''}`} />
<span>MCP</span>
{isConnected && <span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" />}
</button>
{/* Popup */}
{showPopup && (
<div
ref={popupRef}
className="absolute top-full right-0 mt-2 w-[380px] bg-surface/95 backdrop-blur-xl border border-border-subtle rounded-xl shadow-2xl z-50 overflow-hidden"
>
{/* Header */}
<div className="px-4 py-3 bg-gradient-to-r from-accent/10 to-transparent border-b border-border-subtle">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="p-1.5 bg-accent/20 rounded-lg">
<Sparkles className="w-4 h-4 text-accent" />
</div>
<div>
<h3 className="text-sm font-semibold text-text-primary">Connect AI Agents</h3>
<p className="text-[10px] text-text-muted">Cursor, Claude Code, Antigravity</p>
</div>
</div>
<button
onClick={() => setShowPopup(false)}
className="p-1 text-text-muted hover:text-text-primary rounded transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Step 1: Config */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="flex items-center justify-center w-5 h-5 rounded-full bg-accent/20 text-accent text-[10px] font-bold">1</span>
<span className="text-xs text-text-secondary">Add to your AI agent's MCP config</span>
</div>
<div className="relative group">
<pre className="p-3 bg-deep rounded-lg text-[11px] font-mono text-text-primary overflow-x-auto border border-border-subtle">
{MCP_CONFIG}
</pre>
<button
onClick={copyConfig}
className="absolute top-2 right-2 p-1.5 bg-surface/80 hover:bg-hover rounded-md transition-colors opacity-0 group-hover:opacity-100"
title="Copy config"
>
{copied ? (
<Check className="w-3.5 h-3.5 text-green-400" />
) : (
<Copy className="w-3.5 h-3.5 text-text-muted" />
)}
</button>
</div>
</div>
{/* Step 2: Connect */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="flex items-center justify-center w-5 h-5 rounded-full bg-accent/20 text-accent text-[10px] font-bold">2</span>
<span className="text-xs text-text-secondary">Connect browser to daemon</span>
</div>
<button
onClick={toggle}
disabled={isConnecting}
className={`
w-full py-2.5 rounded-lg text-sm font-medium transition-all duration-200
${isConnected
? 'bg-green-500/15 text-green-400 border border-green-500/30 hover:bg-green-500/25'
: isConnecting
? 'bg-surface text-text-muted cursor-wait'
: 'bg-accent text-white hover:bg-accent-dim'
}
`}
>
{isConnected ? '✓ Connected' : isConnecting ? 'Connecting...' : 'Connect'}
</button>
</div>
{/* Status message */}
{status === 'error' && (
<p className="text-[11px] text-amber-400 text-center">
Daemon not running. Make sure your AI agent has started gitnexus-mcp.
</p>
)}
{/* Help link */}
<a
href="https://github.com/abhigyanpatwari/GitNexus#mcp-integration"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 text-[11px] text-text-muted hover:text-accent transition-colors"
>
<span>Learn more</span>
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
)}
{/* Onboarding Tip */}
{showTip && !isConnected && !showPopup && (
<div className="absolute top-full right-0 mt-3 w-72 p-4 bg-surface/95 backdrop-blur-xl border border-accent/30 rounded-xl shadow-2xl z-50 animate-in fade-in slide-in-from-top-2">
<button
onClick={dismissTip}
className="absolute top-2 right-2 p-1 text-text-muted hover:text-text-primary rounded transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
<div className="flex items-start gap-3">
<div className="p-2 bg-accent/20 rounded-lg flex-shrink-0">
<Sparkles className="w-4 h-4 text-accent" />
</div>
<div>
<h4 className="text-sm font-semibold text-text-primary mb-1">
Connect your AI tools
</h4>
<p className="text-xs text-text-secondary leading-relaxed mb-3">
Let Cursor or Claude access GitNexus code intelligence.
</p>
<button
onClick={() => { dismissTip(); setShowPopup(true); }}
className="px-3 py-1.5 text-xs font-medium bg-accent text-white rounded-lg hover:bg-accent-dim transition-colors"
>
Set up MCP
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -1,12 +1,11 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import {
Send, Square, Sparkles, User,
PanelRightClose, Loader2, AlertTriangle, Activity, GitBranch
PanelRightClose, Loader2, AlertTriangle, GitBranch
} from 'lucide-react';
import { useAppState } from '../hooks/useAppState';
import { ToolCallCard } from './ToolCallCard';
import { isProviderConfigured } from '../core/llm/settings-service';
import { ActivityFeed } from './ActivityFeed';
import { MarkdownRenderer } from './MarkdownRenderer';
import { ProcessesPanel } from './ProcessesPanel';
export const RightPanel = () => {
@ -29,7 +28,7 @@ export const RightPanel = () => {
} = useAppState();
const [chatInput, setChatInput] = useState('');
const [activeTab, setActiveTab] = useState<'chat' | 'activity' | 'processes'>('chat');
const [activeTab, setActiveTab] = useState<'chat' | 'processes'>('chat');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
@ -225,18 +224,6 @@ export const RightPanel = () => {
<span>Nexus AI</span>
</button>
{/* Activity Tab */}
<button
onClick={() => setActiveTab('activity')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${activeTab === 'activity'
? 'bg-accent/15 text-accent'
: 'text-text-muted hover:text-text-primary hover:bg-hover'
}`}
>
<Activity className="w-3.5 h-3.5" />
<span>Activity</span>
</button>
{/* Processes Tab */}
<button
onClick={() => setActiveTab('processes')}
@ -263,13 +250,6 @@ export const RightPanel = () => {
</button>
</div>
{/* Activity Feed Tab */}
{activeTab === 'activity' && (
<div className="flex-1 flex flex-col overflow-hidden">
<ActivityFeed />
</div>
)}
{/* Processes Tab */}
{activeTab === 'processes' && (
<div className="flex-1 flex flex-col overflow-hidden">

View file

@ -18,12 +18,17 @@ import { NODE_TABLES, NodeTableName } from './schema';
// ============================================================================
/**
* Sanitize string to ensure valid UTF-8
* Removes or replaces invalid characters that would break CSV parsing
* Sanitize string to ensure valid UTF-8 and safe CSV content for KuzuDB
* Removes or replaces invalid characters that would break CSV parsing.
*
* Critical: KuzuDB's CSV parser can misinterpret \r\n inside quoted fields.
* We normalize all line endings to \n only.
*/
const sanitizeUTF8 = (str: string): string => {
return str
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') // Remove control chars except \t \n \r
.replace(/\r\n/g, '\n') // Normalize Windows line endings first
.replace(/\r/g, '\n') // Normalize remaining \r to \n
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') // Remove control chars except \t \n
.replace(/[\uD800-\uDFFF]/g, '') // Remove surrogate pairs (invalid standalone)
.replace(/[\uFFFE\uFFFF]/g, ''); // Remove BOM and special chars
};

View file

@ -113,70 +113,61 @@ export const loadGraphToKuzu = async (
}
// 5. INSERT relations one by one (COPY doesn't work with multi-pair REL tables)
// Parse CSV format: "from","to","type",confidence,"reason"
// Build a set of valid table names for fast lookup
const validTables = new Set<string>(NODE_TABLES as readonly string[]);
const getNodeLabel = (nodeId: string): string => {
if (nodeId.startsWith('comm_')) return 'Community';
if (nodeId.startsWith('proc_')) return 'Process';
return nodeId.split(':')[0];
};
// All multi-language tables are created with backticks - must always reference them with backticks
const escapeLabel = (label: string): string => {
return BACKTICK_TABLES.has(label) ? `\`${label}\`` : label;
};
let insertedRels = 0;
let skippedRels = 0;
const skippedRelStats = new Map<string, number>();
for (const line of relLines) {
try {
// Parse CSV - handle quoted fields and numeric confidence
// Parse CSV - handle quoted fields and numeric confidence
// Format: "from","to","type",confidence,"reason",step
// Note: step is unquoted numeric
const match = line.match(/"([^"]*)","([^"]*)","([^"]*)",([0-9.]+),"([^"]*)",([0-9-]+)/);
if (!match) continue;
const [, fromId, toId, relType, confidenceStr, reason, stepStr] = match;
const fromLabel = getNodeLabel(fromId);
const toLabel = getNodeLabel(toId);
// Skip relationships where either node's label doesn't have a table in KuzuDB
// Querying a non-existent table causes a fatal native crash
if (!validTables.has(fromLabel) || !validTables.has(toLabel)) {
skippedRels++;
continue;
}
const confidence = parseFloat(confidenceStr) || 1.0;
const step = parseInt(stepStr) || 0;
// Extract labels from node IDs
// Community nodes have IDs like "comm_14" (no colon)
// Other nodes have IDs like "Label:path:name"
const getNodeLabel = (nodeId: string): string => {
if (nodeId.startsWith('comm_')) {
return 'Community';
}
if (nodeId.startsWith('proc_')) {
return 'Process';
}
return nodeId.split(':')[0];
};
// Reserved Cypher keywords need backtick escaping
const RESERVED_LABELS = ['Macro', 'Enum', 'Union', 'Const', 'Module', 'Struct'];
const escapeLabel = (label: string): string => {
return RESERVED_LABELS.includes(label) ? `\`${label}\`` : label;
};
const fromLabel = escapeLabel(getNodeLabel(fromId));
const toLabel = escapeLabel(getNodeLabel(toId));
// INSERT with explicit node matching (including confidence and reason)
const insertQuery = `
MATCH (a:${fromLabel} {id: '${fromId.replace(/'/g, "''")}'}),
(b:${toLabel} {id: '${toId.replace(/'/g, "''")}'})
MATCH (a:${escapeLabel(fromLabel)} {id: '${fromId.replace(/'/g, "''")}'}),
(b:${escapeLabel(toLabel)} {id: '${toId.replace(/'/g, "''")}'})
CREATE (a)-[:${REL_TABLE_NAME} {type: '${relType}', confidence: ${confidence}, reason: '${reason.replace(/'/g, "''")}', step: ${step}}]->(b)
`;
await conn.query(insertQuery);
insertedRels++;
} catch (err) {
// Skip failed insertions (nodes might not exist, or relation pair not allowed by schema)
skippedRels++;
const match = line.match(/"([^"]*)","([^"]*)","([^"]*)",([0-9.]+),"([^"]*)"/);
if (match) {
const [, fromId, toId, relType] = match;
const getNodeLabel = (nodeId: string): string => {
if (nodeId.startsWith('comm_')) return 'Community';
if (nodeId.startsWith('proc_')) return 'Process';
return nodeId.split(':')[0];
};
const fromLabel = getNodeLabel(fromId);
const toLabel = getNodeLabel(toId);
const key = `${relType}:${fromLabel}->` + toLabel;
skippedRelStats.set(key, (skippedRelStats.get(key) || 0) + 1);
// Log each skipped relation
if (import.meta.env.DEV) {
console.warn(`⚠️ Skipped: ${key} | "${fromId}" → "${toId}" | ${err instanceof Error ? err.message : String(err)}`);
}
@ -222,25 +213,41 @@ export const loadGraphToKuzu = async (
}
};
// KuzuDB default ESCAPE is '\' (backslash), but our CSV uses RFC 4180 escaping ("" for literal quotes).
// Source code content is full of backslashes which confuse the auto-detection.
// We MUST explicitly set ESCAPE='"' and disable auto_detect.
const COPY_CSV_OPTS = `(HEADER=true, ESCAPE='"', DELIM=',', QUOTE='"', PARALLEL=false, auto_detect=false)`;
// Multi-language table names created with backticks in CODE_ELEMENT_BASE
const BACKTICK_TABLES = new Set([
'Struct', 'Enum', 'Macro', 'Typedef', 'Union', 'Namespace', 'Trait', 'Impl',
'TypeAlias', 'Const', 'Static', 'Property', 'Record', 'Delegate', 'Annotation',
'Constructor', 'Template', 'Module',
]);
const escapeTableName = (table: string): string => {
return BACKTICK_TABLES.has(table) ? `\`${table}\`` : table;
};
/**
* Get the COPY query for a node table with correct column mapping
*/
const getCopyQuery = (table: NodeTableName, path: string): string => {
// File and Folder have different columns than code elements
const t = escapeTableName(table);
if (table === 'File') {
return `COPY File(id, name, filePath, content) FROM "${path}" (HEADER=true, PARALLEL=false)`;
return `COPY ${t}(id, name, filePath, content) FROM "${path}" ${COPY_CSV_OPTS}`;
}
if (table === 'Folder') {
return `COPY Folder(id, name, filePath) FROM "${path}" (HEADER=true, PARALLEL=false)`;
return `COPY ${t}(id, name, filePath) FROM "${path}" ${COPY_CSV_OPTS}`;
}
if (table === 'Community') {
return `COPY Community(id, label, heuristicLabel, keywords, description, enrichedBy, cohesion, symbolCount) FROM "${path}" (HEADER=true, PARALLEL=false)`;
return `COPY ${t}(id, label, heuristicLabel, keywords, description, enrichedBy, cohesion, symbolCount) FROM "${path}" ${COPY_CSV_OPTS}`;
}
if (table === 'Process') {
return `COPY Process(id, label, heuristicLabel, processType, stepCount, communities, entryPointId, terminalId) FROM "${path}" (HEADER=true, PARALLEL=false)`;
return `COPY ${t}(id, label, heuristicLabel, processType, stepCount, communities, entryPointId, terminalId) FROM "${path}" ${COPY_CSV_OPTS}`;
}
// All code element tables: Function, Class, Interface, Method, CodeElement
return `COPY ${table}(id, name, filePath, startLine, endLine, isExported, content) FROM "${path}" (HEADER=true, PARALLEL=false)`;
// Code element tables (Function, Class, Interface, Method, CodeElement, and multi-language)
return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content) FROM "${path}" ${COPY_CSV_OPTS}`;
};
/**

View file

@ -201,20 +201,20 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM File TO \`Struct\`,
FROM File TO \`Enum\`,
FROM File TO \`Macro\`,
FROM File TO Typedef,
FROM File TO \`Typedef\`,
FROM File TO \`Union\`,
FROM File TO Namespace,
FROM File TO Trait,
FROM File TO Impl,
FROM File TO TypeAlias,
FROM File TO \`Namespace\`,
FROM File TO \`Trait\`,
FROM File TO \`Impl\`,
FROM File TO \`TypeAlias\`,
FROM File TO \`Const\`,
FROM File TO Static,
FROM File TO Property,
FROM File TO Record,
FROM File TO Delegate,
FROM File TO Annotation,
FROM File TO Constructor,
FROM File TO Template,
FROM File TO \`Static\`,
FROM File TO \`Property\`,
FROM File TO \`Record\`,
FROM File TO \`Delegate\`,
FROM File TO \`Annotation\`,
FROM File TO \`Constructor\`,
FROM File TO \`Template\`,
FROM File TO \`Module\`,
FROM Folder TO Folder,
FROM Folder TO File,
@ -224,48 +224,48 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM Function TO Community,
FROM Function TO \`Macro\`,
FROM Function TO \`Struct\`,
FROM Function TO Template,
FROM Function TO \`Template\`,
FROM Function TO \`Enum\`,
FROM Function TO Namespace,
FROM Function TO TypeAlias,
FROM Function TO \`Namespace\`,
FROM Function TO \`TypeAlias\`,
FROM Function TO \`Module\`,
FROM Function TO Impl,
FROM Function TO \`Impl\`,
FROM Function TO Interface,
FROM Function TO Constructor,
FROM Function TO \`Constructor\`,
FROM Class TO Method,
FROM Class TO Function,
FROM Class TO Class,
FROM Class TO Interface,
FROM Class TO Community,
FROM Class TO Template,
FROM Class TO TypeAlias,
FROM Class TO \`Template\`,
FROM Class TO \`TypeAlias\`,
FROM Class TO \`Struct\`,
FROM Class TO \`Enum\`,
FROM Class TO Constructor,
FROM Class TO \`Constructor\`,
FROM Method TO Function,
FROM Method TO Method,
FROM Method TO Class,
FROM Method TO Community,
FROM Method TO Template,
FROM Method TO \`Template\`,
FROM Method TO \`Struct\`,
FROM Method TO TypeAlias,
FROM Method TO \`TypeAlias\`,
FROM Method TO \`Enum\`,
FROM Method TO \`Macro\`,
FROM Method TO Namespace,
FROM Method TO \`Namespace\`,
FROM Method TO \`Module\`,
FROM Method TO Impl,
FROM Method TO \`Impl\`,
FROM Method TO Interface,
FROM Method TO Constructor,
FROM Template TO Template,
FROM Template TO Function,
FROM Template TO Method,
FROM Template TO Class,
FROM Template TO \`Struct\`,
FROM Template TO TypeAlias,
FROM Template TO \`Enum\`,
FROM Template TO \`Macro\`,
FROM Template TO Interface,
FROM Template TO Constructor,
FROM Method TO \`Constructor\`,
FROM \`Template\` TO \`Template\`,
FROM \`Template\` TO Function,
FROM \`Template\` TO Method,
FROM \`Template\` TO Class,
FROM \`Template\` TO \`Struct\`,
FROM \`Template\` TO \`TypeAlias\`,
FROM \`Template\` TO \`Enum\`,
FROM \`Template\` TO \`Macro\`,
FROM \`Template\` TO Interface,
FROM \`Template\` TO \`Constructor\`,
FROM \`Module\` TO \`Module\`,
FROM CodeElement TO Community,
FROM Interface TO Community,
@ -273,11 +273,11 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM Interface TO Method,
FROM Interface TO Class,
FROM Interface TO Interface,
FROM Interface TO TypeAlias,
FROM Interface TO \`TypeAlias\`,
FROM Interface TO \`Struct\`,
FROM Interface TO Constructor,
FROM Interface TO \`Constructor\`,
FROM \`Struct\` TO Community,
FROM \`Struct\` TO Trait,
FROM \`Struct\` TO \`Trait\`,
FROM \`Struct\` TO Function,
FROM \`Struct\` TO Method,
FROM \`Enum\` TO Community,
@ -286,56 +286,56 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM \`Macro\` TO Method,
FROM \`Module\` TO Function,
FROM \`Module\` TO Method,
FROM Typedef TO Community,
FROM \`Typedef\` TO Community,
FROM \`Union\` TO Community,
FROM Namespace TO Community,
FROM Trait TO Community,
FROM Impl TO Community,
FROM Impl TO Trait,
FROM TypeAlias TO Community,
FROM \`Namespace\` TO Community,
FROM \`Trait\` TO Community,
FROM \`Impl\` TO Community,
FROM \`Impl\` TO \`Trait\`,
FROM \`TypeAlias\` TO Community,
FROM \`Const\` TO Community,
FROM Static TO Community,
FROM Property TO Community,
FROM Record TO Community,
FROM Delegate TO Community,
FROM Annotation TO Community,
FROM Constructor TO Community,
FROM Constructor TO Interface,
FROM Constructor TO Class,
FROM Constructor TO Method,
FROM Constructor TO Function,
FROM Constructor TO Constructor,
FROM Constructor TO \`Struct\`,
FROM Constructor TO \`Macro\`,
FROM Constructor TO Template,
FROM Constructor TO TypeAlias,
FROM Constructor TO \`Enum\`,
FROM Constructor TO Impl,
FROM Constructor TO Namespace,
FROM Template TO Community,
FROM \`Static\` TO Community,
FROM \`Property\` TO Community,
FROM \`Record\` TO Community,
FROM \`Delegate\` TO Community,
FROM \`Annotation\` TO Community,
FROM \`Constructor\` TO Community,
FROM \`Constructor\` TO Interface,
FROM \`Constructor\` TO Class,
FROM \`Constructor\` TO Method,
FROM \`Constructor\` TO Function,
FROM \`Constructor\` TO \`Constructor\`,
FROM \`Constructor\` TO \`Struct\`,
FROM \`Constructor\` TO \`Macro\`,
FROM \`Constructor\` TO \`Template\`,
FROM \`Constructor\` TO \`TypeAlias\`,
FROM \`Constructor\` TO \`Enum\`,
FROM \`Constructor\` TO \`Impl\`,
FROM \`Constructor\` TO \`Namespace\`,
FROM \`Template\` TO Community,
FROM \`Module\` TO Community,
FROM Function TO Process,
FROM Method TO Process,
FROM Class TO Process,
FROM Interface TO Process,
FROM \`Struct\` TO Process,
FROM Constructor TO Process,
FROM \`Constructor\` TO Process,
FROM \`Module\` TO Process,
FROM \`Macro\` TO Process,
FROM Impl TO Process,
FROM Typedef TO Process,
FROM TypeAlias TO Process,
FROM \`Impl\` TO Process,
FROM \`Typedef\` TO Process,
FROM \`TypeAlias\` TO Process,
FROM \`Enum\` TO Process,
FROM \`Union\` TO Process,
FROM Namespace TO Process,
FROM Trait TO Process,
FROM \`Namespace\` TO Process,
FROM \`Trait\` TO Process,
FROM \`Const\` TO Process,
FROM Static TO Process,
FROM Property TO Process,
FROM Record TO Process,
FROM Delegate TO Process,
FROM Annotation TO Process,
FROM Template TO Process,
FROM \`Static\` TO Process,
FROM \`Property\` TO Process,
FROM \`Record\` TO Process,
FROM \`Delegate\` TO Process,
FROM \`Annotation\` TO Process,
FROM \`Template\` TO Process,
FROM CodeElement TO Process,
type STRING,
confidence DOUBLE,

View file

@ -1,341 +0,0 @@
/**
* MCP Browser Client
*
* WebSocket client that connects to the gitnexus-mcp daemon.
* - Sends codebase context (stats, hotspots, folder tree) on connect
* - Receives tool calls from external AI agents and executes them
* - Emits activity events for real-time monitoring
*/
/**
* Agent color mapping for multi-agent support
*/
export const AGENT_COLORS: Record<string, string> = {
'cursor': '#a855f7', // Purple
'claude': '#3b82f6', // Blue
'claude-code': '#3b82f6', // Blue
'windsurf': '#22c55e', // Green
'codeium': '#22c55e', // Green (Windsurf)
'unknown': '#6b7280', // Gray
};
export function getAgentColor(agentName: string): string {
const normalized = agentName.toLowerCase().trim();
for (const [key, color] of Object.entries(AGENT_COLORS)) {
if (normalized.includes(key)) {
return color;
}
}
return AGENT_COLORS.unknown;
}
export interface ConnectedAgent {
name: string;
color: string;
}
export interface MCPMessage {
id: string;
type?: 'context' | 'tool_call' | 'tool_result' | 'agent_info';
method?: string;
params?: Record<string, any>;
result?: any;
error?: { message: string };
agentName?: string;
}
/**
* Codebase context to send to daemon
*/
export interface CodebaseContext {
projectName: string;
stats: {
fileCount: number;
functionCount: number;
classCount: number;
interfaceCount: number;
methodCount: number;
};
hotspots: Array<{
name: string;
type: string;
filePath: string;
connections: number;
}>;
folderTree: string;
}
/**
* Activity event for real-time monitoring
*/
export interface ActivityEvent {
id: string;
tool: string;
params: Record<string, any>;
status: 'running' | 'complete' | 'error';
result?: any;
error?: string;
timestamp: number;
duration?: number;
agentName?: string;
agentColor?: string;
}
type ToolHandler = (params: Record<string, any>) => Promise<any>;
type ActivityListener = (event: ActivityEvent) => void;
export class MCPBrowserClient {
private ws: WebSocket | null = null;
private handlers: Map<string, ToolHandler> = new Map();
private connectionListeners: Set<(connected: boolean) => void> = new Set();
private activityListeners: Set<ActivityListener> = new Set();
private activityLog: ActivityEvent[] = [];
private pendingContext: CodebaseContext | null = null;
private _connectedAgent: ConnectedAgent | null = null;
constructor(private port = 54319) {}
/**
* Connect to the MCP daemon
*/
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(`ws://localhost:${this.port}`);
this.ws.onopen = () => {
console.log('[MCP] Connected to daemon');
this.notifyConnectionListeners(true);
// Send pending context if available
if (this.pendingContext) {
this.sendContext(this.pendingContext);
}
resolve();
};
this.ws.onerror = () => {
this.notifyConnectionListeners(false);
reject(new Error('Failed to connect to MCP bridge'));
};
this.ws.onmessage = (event) => {
try {
const msg: MCPMessage = JSON.parse(event.data);
this.handleMessage(msg);
} catch (error) {
console.error('[MCP] Failed to parse message:', error);
}
};
this.ws.onclose = () => {
this.ws = null;
this.notifyConnectionListeners(false);
};
} catch (error) {
reject(error);
}
});
}
/**
* Send codebase context to daemon
* Call this whenever context changes (new repo loaded, etc.)
*/
sendContext(context: CodebaseContext) {
this.pendingContext = context;
if (this.ws?.readyState === WebSocket.OPEN) {
const msg = {
id: `ctx_${Date.now()}`,
type: 'context',
params: context,
};
this.ws.send(JSON.stringify(msg));
console.log('[MCP] Sent context:', context.projectName);
}
}
/**
* Handle incoming messages from daemon
*/
private async handleMessage(msg: MCPMessage) {
// Handle agent info updates
if (msg.type === 'agent_info' && msg.agentName) {
this._connectedAgent = {
name: msg.agentName,
color: getAgentColor(msg.agentName),
};
console.log('[MCP] Agent connected:', this._connectedAgent);
return;
}
// This is a tool call request from an external agent
if (msg.method && msg.id) {
const handler = this.handlers.get(msg.method);
const startTime = Date.now();
// Get agent info from message or use connected agent
const agentName = msg.agentName || this._connectedAgent?.name || 'Unknown';
const agentColor = getAgentColor(agentName);
// Create activity event with agent info
const activityEvent: ActivityEvent = {
id: msg.id,
tool: msg.method,
params: msg.params || {},
status: 'running',
timestamp: startTime,
agentName,
agentColor,
};
this.logActivity(activityEvent);
if (handler) {
try {
const result = await handler(msg.params || {});
this.send({ id: msg.id, result });
// Update activity with success
this.updateActivity(msg.id, {
status: 'complete',
result,
duration: Date.now() - startTime,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.send({ id: msg.id, error: { message } });
// Update activity with error
this.updateActivity(msg.id, {
status: 'error',
error: message,
duration: Date.now() - startTime,
});
}
} else {
this.send({
id: msg.id,
error: { message: `Unknown tool: ${msg.method}` }
});
// Update activity with error
this.updateActivity(msg.id, {
status: 'error',
error: `Unknown tool: ${msg.method}`,
duration: Date.now() - startTime,
});
}
}
}
/**
* Log an activity event
*/
private logActivity(event: ActivityEvent) {
this.activityLog.push(event);
// Keep max 100 events
if (this.activityLog.length > 100) {
this.activityLog.shift();
}
this.notifyActivityListeners(event);
}
/**
* Update an existing activity event
*/
private updateActivity(id: string, updates: Partial<ActivityEvent>) {
const event = this.activityLog.find(e => e.id === id);
if (event) {
Object.assign(event, updates);
this.notifyActivityListeners(event);
}
}
/**
* Send a message to the daemon
*/
private send(msg: MCPMessage) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
/**
* Register a handler for a tool
*/
registerHandler(method: string, handler: ToolHandler) {
this.handlers.set(method, handler);
}
/**
* Listen for connection state changes
*/
onConnectionChange(listener: (connected: boolean) => void) {
this.connectionListeners.add(listener);
return () => this.connectionListeners.delete(listener);
}
private notifyConnectionListeners(connected: boolean) {
this.connectionListeners.forEach(listener => listener(connected));
}
/**
* Listen for activity events
*/
onActivity(listener: ActivityListener) {
this.activityListeners.add(listener);
return () => this.activityListeners.delete(listener);
}
private notifyActivityListeners(event: ActivityEvent) {
this.activityListeners.forEach(listener => listener(event));
}
/**
* Get the activity log
*/
getActivityLog(): ActivityEvent[] {
return [...this.activityLog];
}
/**
* Clear the activity log
*/
clearActivityLog() {
this.activityLog = [];
}
/**
* Check if connected
*/
get isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
/**
* Get the connected agent info
*/
get connectedAgent(): ConnectedAgent | null {
return this._connectedAgent;
}
/**
* Disconnect from daemon
*/
disconnect() {
this.ws?.close();
this.ws = null;
}
}
// Singleton instance
let mcpClientInstance: MCPBrowserClient | null = null;
export function getMCPClient(): MCPBrowserClient {
if (!mcpClientInstance) {
mcpClientInstance = new MCPBrowserClient();
}
return mcpClientInstance;
}

View file

@ -7,7 +7,7 @@
"": {
"name": "gitnexus",
"version": "0.1.0",
"license": "MIT",
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
"@huggingface/transformers": "^3.0.0",
"@modelcontextprotocol/sdk": "^1.0.0",
@ -16,23 +16,25 @@
"express": "^4.19.2",
"glob": "^11.0.0",
"graphology": "^0.25.4",
"graphology-communities-louvain": "^2.0.1",
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.3.0",
"kuzu": "^0.11.3",
"lru-cache": "^11.0.0",
"minisearch": "^7.2.0",
"mnemonist": "^0.39.0",
"ora": "^8.0.0",
"pandemonium": "^2.4.0",
"tree-sitter": "^0.21.0",
"tree-sitter-c": "^0.21.0",
"tree-sitter-c-sharp": "^0.21.0",
"tree-sitter-cpp": "^0.22.0",
"tree-sitter-go": "^0.21.0",
"tree-sitter-java": "^0.20.0",
"tree-sitter-java": "^0.21.0",
"tree-sitter-javascript": "^0.21.0",
"tree-sitter-python": "^0.21.0",
"tree-sitter-rust": "^0.21.0",
"tree-sitter-typescript": "^0.21.0",
"uuid": "^13.0.0",
"ws": "^8.16.0"
"typescript": "^5.4.5",
"uuid": "^13.0.0"
},
"bin": {
"gitnexus": "dist/cli/index.js"
@ -42,9 +44,7 @@
"@types/express": "^4.17.21",
"@types/node": "^20.0.0",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.5.10",
"tsx": "^4.0.0",
"typescript": "^5.4.5"
"tsx": "^4.0.0"
},
"engines": {
"node": ">=18.0.0"
@ -1572,16 +1572,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@ -2849,21 +2839,6 @@
"graphology-types": ">=0.24.0"
}
},
"node_modules/graphology-communities-louvain": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/graphology-communities-louvain/-/graphology-communities-louvain-2.0.2.tgz",
"integrity": "sha512-zt+2hHVPYxjEquyecxWXoUoIuN/UvYzsvI7boDdMNz0rRvpESQ7+e+Ejv6wK7AThycbZXuQ6DkG8NPMCq6XwoA==",
"license": "MIT",
"dependencies": {
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.4.4",
"mnemonist": "^0.39.0",
"pandemonium": "^2.4.1"
},
"peerDependencies": {
"graphology-types": ">=0.19.0"
}
},
"node_modules/graphology-indices": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/graphology-indices/-/graphology-indices-0.17.0.tgz",
@ -3308,12 +3283,6 @@
"node": ">=16 || 14 >=14.17"
}
},
"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",
@ -3366,12 +3335,6 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/nan": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz",
"integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@ -4500,13 +4463,31 @@
}
},
"node_modules/tree-sitter-java": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/tree-sitter-java/-/tree-sitter-java-0.20.2.tgz",
"integrity": "sha512-jc6RCnM+JE2ns1AkpErOp2Dp1jOADPbljsrWup0Vj2qTmG8KGYMSTD7HcrVRyZUC6pRLFySPMOh8x7Dn12aynw==",
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/tree-sitter-java/-/tree-sitter-java-0.21.0.tgz",
"integrity": "sha512-CKJiTo1uc3SUsgEcaZgufGx8my6dzihy8JR/JsJH40Tj3uSe2/eFLk+0q+fpbosGAyY4YiXJtEoFB2O4bS2yOw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"nan": "^2.14.1"
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0"
},
"peerDependencies": {
"tree-sitter": "^0.21.0"
},
"peerDependenciesMeta": {
"tree_sitter": {
"optional": true
}
}
},
"node_modules/tree-sitter-java/node_modules/node-addon-api": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/tree-sitter-javascript": {
@ -4680,7 +4661,6 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -4919,27 +4899,6 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View file

@ -10,7 +10,8 @@
},
"files": [
"dist",
"skills"
"skills",
"vendor"
],
"scripts": {
"build": "tsc",
@ -25,17 +26,19 @@
"express": "^4.19.2",
"glob": "^11.0.0",
"graphology": "^0.25.4",
"graphology-communities-louvain": "^2.0.1",
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.3.0",
"kuzu": "^0.11.3",
"lru-cache": "^11.0.0",
"minisearch": "^7.2.0",
"mnemonist": "^0.39.0",
"ora": "^8.0.0",
"pandemonium": "^2.4.0",
"tree-sitter": "^0.21.0",
"tree-sitter-c": "^0.21.0",
"tree-sitter-c-sharp": "^0.21.0",
"tree-sitter-cpp": "^0.22.0",
"tree-sitter-go": "^0.21.0",
"tree-sitter-java": "^0.20.0",
"tree-sitter-java": "^0.21.0",
"tree-sitter-javascript": "^0.21.0",
"tree-sitter-python": "^0.21.0",
"tree-sitter-rust": "^0.21.0",
@ -53,4 +56,4 @@
"engines": {
"node": ">=18.0.0"
}
}
}

View file

@ -7,6 +7,7 @@ description: Trace bugs through call chains using knowledge graph
## Quick Start
```
0. If "Index is stale" → gitnexus_analyze({})
1. gitnexus_search({query}) → Find code related to error
2. gitnexus_explore({name, type: "symbol"}) → Get callers and callees
3. READ gitnexus://process/{name} → Trace execution flow

View file

@ -7,6 +7,7 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
## Quick Start
```
0. If "Index is stale" → gitnexus_analyze({})
1. READ gitnexus://context → Get codebase overview (~150 tokens)
2. READ gitnexus://clusters → See all functional clusters
3. READ gitnexus://cluster/{name} → Deep dive on specific cluster

View file

@ -7,6 +7,7 @@ description: Analyze blast radius before making code changes
## Quick Start
```
0. If "Index is stale" → gitnexus_analyze({})
1. gitnexus_impact({target, direction: "upstream"}) → What depends on this
2. READ gitnexus://clusters → Check affected areas
3. READ gitnexus://processes → Affected execution flows

View file

@ -7,6 +7,7 @@ description: Plan safe refactors using blast radius and dependency mapping
## Quick Start
```
0. If "Index is stale" → gitnexus_analyze({})
1. gitnexus_impact({target, direction: "upstream"}) → Map all dependents
2. READ gitnexus://schema → Understand graph structure
3. gitnexus_cypher → Find all references

View file

@ -1,13 +0,0 @@
/**
* FTS Live Update Test File
*
* This file contains the unique identifier: GITNEXUS_FTS_LIVETEST_2026_ALPHA
*
* If you can find this file via MCP search after the watcher picks up the commit,
* then the KuzuDB FTS live update system is working correctly!
*/
export function ftsLiveTestFunction() {
// GITNEXUS_FTS_LIVETEST_2026_ALPHA marker for search
return "Live FTS test successful!";
}

View file

@ -7,8 +7,7 @@
import path from 'path';
import ora from 'ora';
import { runPipelineFromRepo } from '../core/ingestion/pipeline.js';
import { initKuzu, loadGraphToKuzu, getKuzuStats, executeQuery, executeWithReusedStatement, closeKuzu } from '../core/kuzu/kuzu-adapter.js';
import { buildBM25Index, exportBM25Index } from '../core/search/bm25-index.js';
import { initKuzu, loadGraphToKuzu, getKuzuStats, executeQuery, executeWithReusedStatement, closeKuzu, createFTSIndex } from '../core/kuzu/kuzu-adapter.js';
import { runEmbeddingPipeline } from '../core/embeddings/embedding-pipeline.js';
import { getStoragePaths, saveMeta, loadMeta, addToGitignore } from '../storage/repo-manager.js';
import { getCurrentCommit, isGitRepo, getGitRoot } from '../storage/git.js';
@ -45,7 +44,7 @@ export const analyzeCommand = async (
return;
}
const { storagePath, kuzuPath, bm25Path } = getStoragePaths(repoPath);
const { storagePath, kuzuPath } = getStoragePaths(repoPath);
const currentCommit = getCurrentCommit(repoPath);
const existingMeta = await loadMeta(storagePath);
@ -62,14 +61,33 @@ export const analyzeCommand = async (
});
// Load graph into KuzuDB
// Always start fresh - remove existing kuzu DB to avoid stale/corrupt data
spinner.text = 'Loading graph into KuzuDB...';
await closeKuzu();
// Kuzu 0.11 stores databases as: <name> (main file) + <name>.wal (WAL file)
// BOTH must be deleted or kuzu will find the orphaned WAL and corrupt the database
const fsClean = await import('fs/promises');
const kuzuFiles = [kuzuPath, `${kuzuPath}.wal`, `${kuzuPath}.lock`];
for (const f of kuzuFiles) {
try { await fsClean.rm(f, { recursive: true, force: true }); } catch { /* may not exist */ }
}
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);
// Create FTS indexes for keyword search
// Indexes searchable content on: File, Function, Class, Method
spinner.text = 'Creating FTS indexes...';
try {
await createFTSIndex('File', 'file_fts', ['name', 'content']);
await createFTSIndex('Function', 'function_fts', ['name', 'content']);
await createFTSIndex('Class', 'class_fts', ['name', 'content']);
await createFTSIndex('Method', 'method_fts', ['name', 'content']);
} catch (e: any) {
// FTS index creation may fail if tables are empty (no data for that type)
console.error('Note: Some FTS indexes may not have been created:', e.message);
}
// Generate embeddings
if (!options?.skipEmbeddings) {

View file

@ -6,7 +6,6 @@ import { listCommand } from './list.js';
import { statusCommand } from './status.js';
import { mcpCommand } from './mcp.js';
import { cleanCommand } from './clean.js';
const program = new Command();
program

View file

@ -61,6 +61,8 @@ export const mcpCommand = async () => {
const local = new LocalBackend();
await local.init(candidate);
console.error(`GitNexus: Found index at ${repo.storagePath}`);
// Start MCP server (no watcher daemon - staleness hints guide LLM to call analyze)
await startMCPServer(local);
return;
}

View file

@ -16,6 +16,37 @@ export const createKnowledgeGraph = (): KnowledgeGraph => {
}
};
/**
* Remove a single node and all relationships involving it
*/
const removeNode = (nodeId: string): boolean => {
if (!nodeMap.has(nodeId)) return false;
nodeMap.delete(nodeId);
// Remove all relationships involving this node
for (const [relId, rel] of relationshipMap) {
if (rel.sourceId === nodeId || rel.targetId === nodeId) {
relationshipMap.delete(relId);
}
}
return true;
};
/**
* Remove all nodes (and their relationships) belonging to a file
*/
const removeNodesByFile = (filePath: string): number => {
let removed = 0;
for (const [nodeId, node] of nodeMap) {
if (node.properties?.filePath === filePath) {
removeNode(nodeId);
removed++;
}
}
return removed;
};
return{
get nodes(){
return Array.from(nodeMap.values())
@ -36,6 +67,8 @@ export const createKnowledgeGraph = (): KnowledgeGraph => {
addNode,
addRelationship,
removeNode,
removeNodesByFile,
};
};
};

View file

@ -83,4 +83,6 @@ export interface KnowledgeGraph {
relationshipCount: number,
addNode: (node: GraphNode) => void,
addRelationship: (relationship: GraphRelationship) => void,
removeNode: (nodeId: string) => boolean,
removeNodesByFile: (filePath: string) => number,
}

View file

@ -6,7 +6,7 @@ import Parser from 'tree-sitter';
import { loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
import { generateId } from '../../lib/utils.js';
import { getLanguageFromFilename } from './utils.js';
import { getLanguageFromFilename, yieldToEventLoop } from './utils.js';
/**
* Node types that represent function/method definitions across languages.
@ -140,6 +140,7 @@ export const processCalls = async (
for (let i = 0; i < files.length; i++) {
const file = files[i];
onProgress?.(i + 1, files.length);
if (i % 20 === 0) await yieldToEventLoop();
// 1. Check language support first
const language = getLanguageFromFilename(file.path);

View file

@ -1,19 +1,29 @@
/**
* Community Detection Processor
*
* Uses the Leiden algorithm (via graphology-communities-louvain) to detect
* Uses the Leiden algorithm (via graphology-communities-leiden) to detect
* communities/clusters in the code graph based on CALLS relationships.
*
* Communities represent groups of code that work together frequently,
* helping agents navigate the codebase by functional area rather than file structure.
*/
// NOTE: graphology + louvain typings are a bit inconsistent across versions.
// Keep these as `any` to avoid blocking the CLI build.
// NOTE: The Leiden algorithm source is vendored from graphology's repo
// (src/communities-leiden) because it was never published to npm.
// We use createRequire to load the CommonJS vendored files in ESM context.
import Graph from 'graphology';
import louvain from 'graphology-communities-louvain';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { KnowledgeGraph, NodeLabel } from '../graph/types.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Navigate to package root (works from both src/ and dist/)
const leidenPath = resolve(__dirname, '..', '..', '..', 'vendor', 'leiden', 'index.cjs');
const _require = createRequire(import.meta.url);
const leiden = _require(leidenPath);
// ============================================================================
// TYPES
// ============================================================================
@ -95,8 +105,8 @@ export const processCommunities = async (
onProgress?.(`Running Leiden algorithm on ${graph.order} nodes...`, 30);
// Step 2: Run Leiden (via Louvain implementation with refinement)
const details = (louvain as any).detailed(graph, {
// Step 2: Run Leiden algorithm for community detection
const details = (leiden as any).detailed(graph, {
resolution: 1.0, // Default resolution, can be tuned
randomWalk: true,
});

View file

@ -13,7 +13,7 @@ import Parser from 'tree-sitter';
import { loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
import { generateId } from '../../lib/utils.js';
import { getLanguageFromFilename } from './utils.js';
import { getLanguageFromFilename, yieldToEventLoop } from './utils.js';
export const processHeritage = async (
graph: KnowledgeGraph,
@ -27,6 +27,7 @@ export const processHeritage = async (
for (let i = 0; i < files.length; i++) {
const file = files[i];
onProgress?.(i + 1, files.length);
if (i % 20 === 0) await yieldToEventLoop();
// 1. Check language support
const language = getLanguageFromFilename(file.path);

View file

@ -4,7 +4,7 @@ import Parser from 'tree-sitter';
import { loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
import { generateId } from '../../lib/utils.js';
import { getLanguageFromFilename } from './utils.js';
import { getLanguageFromFilename, yieldToEventLoop } from './utils.js';
const isDev = process.env.NODE_ENV !== 'production';
@ -127,6 +127,7 @@ export const processImports = async (
for (let i = 0; i < files.length; i++) {
const file = files[i];
onProgress?.(i + 1, files.length);
if (i % 20 === 0) await yieldToEventLoop();
// 1. Check language support first
const language = getLanguageFromFilename(file.path);

View file

@ -5,7 +5,7 @@ import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
import { generateId } from '../../lib/utils.js';
import { SymbolTable } from './symbol-table.js';
import { ASTCache } from './ast-cache.js';
import { getLanguageFromFilename } from './utils.js';
import { getLanguageFromFilename, yieldToEventLoop } from './utils.js';
export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
@ -128,6 +128,9 @@ export const processParsing = async (
// Report progress for each file
onFileProgress?.(i + 1, total, file.path);
// Yield to event loop periodically so spinner can update
if (i % 20 === 0) await yieldToEventLoop();
const language = getLanguageFromFilename(file.path);
if (!language) continue;

View file

@ -1,5 +1,11 @@
import { SupportedLanguages } from '../../config/supported-languages.js';
/**
* Yield control to the event loop so spinners/progress can render.
* Call periodically in hot loops to prevent UI freezes.
*/
export const yieldToEventLoop = (): Promise<void> => new Promise(resolve => setImmediate(resolve));
/**
* Map file extension to SupportedLanguage enum
*/

View file

@ -18,12 +18,17 @@ import { NODE_TABLES, NodeTableName } from './schema.js';
// ============================================================================
/**
* Sanitize string to ensure valid UTF-8
* Removes or replaces invalid characters that would break CSV parsing
* Sanitize string to ensure valid UTF-8 and safe CSV content for KuzuDB
* Removes or replaces invalid characters that would break CSV parsing.
*
* Critical: KuzuDB's native CSV parser on Windows can misinterpret \r\n
* inside quoted fields. We normalize all line endings to \n only.
*/
const sanitizeUTF8 = (str: string): string => {
return str
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') // Remove control chars except \t \n \r
.replace(/\r\n/g, '\n') // Normalize Windows line endings first
.replace(/\r/g, '\n') // Normalize remaining \r to \n
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') // Remove control chars except \t \n
.replace(/[\uD800-\uDFFF]/g, '') // Remove surrogate pairs (invalid standalone)
.replace(/[\uFFFE\uFFFF]/g, ''); // Remove BOM and special chars
};
@ -175,14 +180,14 @@ const generateFolderCSV = (nodes: GraphNode[]): string => {
/**
* Generate CSV for code element nodes (Function, Class, Interface, Method, CodeElement)
* Headers: id,name,filePath,startLine,endLine,content
* Headers: id,name,filePath,startLine,endLine,isExported,content
*/
const generateCodeElementCSV = (
nodes: GraphNode[],
label: NodeLabel,
fileContents: Map<string, string>
): string => {
const headers = ['id', 'name', 'filePath', 'startLine', 'endLine', 'content'];
const headers = ['id', 'name', 'filePath', 'startLine', 'endLine', 'isExported', 'content'];
const rows: string[] = [headers.join(',')];
for (const node of nodes) {
@ -194,6 +199,7 @@ const generateCodeElementCSV = (
escapeCSVField(node.properties.filePath || ''),
escapeCSVNumber(node.properties.startLine, -1),
escapeCSVNumber(node.properties.endLine, -1),
node.properties.isExported ? 'true' : 'false',
escapeCSVField(content),
].join(','));
}

View file

@ -19,21 +19,24 @@ const normalizeCopyPath = (filePath: string): string => filePath.replace(/\\/g,
export const initKuzu = async (dbPath: string) => {
if (conn) return { db, conn };
// kuzu v0.11 expects the database path to NOT exist (it will create it)
// or to be an existing valid kuzu database
// If an empty directory exists from a previous clean, remove it
// kuzu v0.11 stores the database as a single file (not a directory).
// If the path already exists, it must be a valid kuzu database file.
// Remove stale empty directories or files from older versions.
try {
const stat = await fs.stat(dbPath);
if (stat.isDirectory()) {
// Check if it's an empty directory
// Old-style directory database or empty leftover - remove it
const files = await fs.readdir(dbPath);
if (files.length === 0) {
// Empty directory - remove it so kuzu can create fresh
await fs.rmdir(dbPath);
} else {
// Non-empty directory from older kuzu version - remove entire directory
await fs.rm(dbPath, { recursive: true, force: true });
}
}
// If it's a file, assume it's an existing kuzu database - kuzu will open it
} catch {
// Path doesn't exist, which is what kuzu v0.11 wants for a new database
// Path doesn't exist, which is what kuzu wants for a new database
}
// Ensure parent directory exists
@ -46,8 +49,12 @@ export const initKuzu = async (dbPath: string) => {
for (const schemaQuery of SCHEMA_QUERIES) {
try {
await conn.query(schemaQuery);
} catch {
// Schema may already exist
} catch (err) {
// Only ignore "already exists" errors - log everything else
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes('already exists')) {
console.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`);
}
}
}
@ -78,10 +85,50 @@ export const loadGraphToKuzu = async (
const relLines = csvData.relCSV.split('\n').slice(1).filter(line => line.trim());
for (const { table, path: filePath } of nodeFiles) {
const copyQuery = getCopyQuery(table, normalizeCopyPath(filePath));
await conn.query(copyQuery);
const normalizedPath = normalizeCopyPath(filePath);
const copyQuery = getCopyQuery(table, normalizedPath);
// Log CSV stats for diagnostics
const csvContent = await fs.readFile(filePath, 'utf-8');
const csvLines = csvContent.split('\n').length;
console.log(` COPY ${table}: ${csvLines - 1} rows`);
try {
await conn.query(copyQuery);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
console.warn(`⚠️ COPY failed for ${table} (${csvLines - 1} rows): ${errMsg}`);
// Retry with IGNORE_ERRORS=true to skip malformed rows
console.log(` Retrying ${table} with IGNORE_ERRORS=true...`);
try {
const retryQuery = copyQuery.replace('auto_detect=false)', 'auto_detect=false, IGNORE_ERRORS=true)');
await conn.query(retryQuery);
console.log(` ✅ ${table} loaded with IGNORE_ERRORS (some rows may have been skipped)`);
} catch (retryErr) {
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
console.error(`❌ COPY failed for ${table} even with IGNORE_ERRORS: ${retryMsg}`);
throw retryErr;
}
}
}
console.log('✅ All COPY commands succeeded. Starting relationship insertion...');
// Build a set of valid table names for fast lookup
const validTables = new Set<string>(NODE_TABLES as readonly string[]);
const getNodeLabel = (nodeId: string): string => {
if (nodeId.startsWith('comm_')) return 'Community';
if (nodeId.startsWith('proc_')) return 'Process';
return nodeId.split(':')[0];
};
// All multi-language tables are created with backticks - must always reference them with backticks
const escapeLabel = (label: string): string => {
return BACKTICK_TABLES.has(label) ? `\`${label}\`` : label;
};
let insertedRels = 0;
let skippedRels = 0;
for (const line of relLines) {
@ -89,26 +136,24 @@ export const loadGraphToKuzu = async (
const match = line.match(/"([^"]*)","([^"]*)","([^"]*)",([0-9.]+),"([^"]*)",([0-9-]+)/);
if (!match) continue;
const [, fromId, toId, relType, confidenceStr, reason, stepStr] = match;
const fromLabel = getNodeLabel(fromId);
const toLabel = getNodeLabel(toId);
// Skip relationships where either node's label doesn't have a table in KuzuDB
// (e.g. Variable, Import, Type nodes that aren't in the schema)
// Querying a non-existent table causes a fatal native crash
if (!validTables.has(fromLabel) || !validTables.has(toLabel)) {
skippedRels++;
continue;
}
const confidence = parseFloat(confidenceStr) || 1.0;
const step = parseInt(stepStr) || 0;
const getNodeLabel = (nodeId: string): string => {
if (nodeId.startsWith('comm_')) return 'Community';
if (nodeId.startsWith('proc_')) return 'Process';
return nodeId.split(':')[0];
};
const RESERVED_LABELS = ['Macro', 'Enum', 'Union', 'Const', 'Module', 'Struct'];
const escapeLabel = (label: string): string => {
return RESERVED_LABELS.includes(label) ? `\`${label}\`` : label;
};
const fromLabel = escapeLabel(getNodeLabel(fromId));
const toLabel = escapeLabel(getNodeLabel(toId));
const insertQuery = `
MATCH (a:${fromLabel} {id: '${fromId.replace(/'/g, "''")}' }),
(b:${toLabel} {id: '${toId.replace(/'/g, "''")}' })
MATCH (a:${escapeLabel(fromLabel)} {id: '${fromId.replace(/'/g, "''")}' }),
(b:${escapeLabel(toLabel)} {id: '${toId.replace(/'/g, "''")}' })
CREATE (a)-[:${REL_TABLE_NAME} {type: '${relType}', confidence: ${confidence}, reason: '${reason.replace(/'/g, "''")}', step: ${step}}]->(b)
`;
await conn.query(insertQuery);
@ -137,20 +182,157 @@ export const loadGraphToKuzu = async (
return { success: true, insertedRels, skippedRels };
};
// KuzuDB default ESCAPE is '\' (backslash), but our CSV uses RFC 4180 escaping ("" for literal quotes).
// Source code content is full of backslashes which confuse the auto-detection.
// We MUST explicitly set ESCAPE='"' to use RFC 4180 escaping, and disable auto_detect to prevent
// KuzuDB from overriding our settings based on sample rows.
const COPY_CSV_OPTS = `(HEADER=true, ESCAPE='"', DELIM=',', QUOTE='"', PARALLEL=false, auto_detect=false)`;
// Multi-language table names that were created with backticks in CODE_ELEMENT_BASE
// and must always be referenced with backticks in queries
const BACKTICK_TABLES = new Set([
'Struct', 'Enum', 'Macro', 'Typedef', 'Union', 'Namespace', 'Trait', 'Impl',
'TypeAlias', 'Const', 'Static', 'Property', 'Record', 'Delegate', 'Annotation',
'Constructor', 'Template', 'Module',
]);
const escapeTableName = (table: string): string => {
return BACKTICK_TABLES.has(table) ? `\`${table}\`` : table;
};
const getCopyQuery = (table: NodeTableName, filePath: string): string => {
const t = escapeTableName(table);
if (table === 'File') {
return `COPY File(id, name, filePath, content) FROM "${filePath}" (HEADER=true, PARALLEL=false)`;
return `COPY ${t}(id, name, filePath, content) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'Folder') {
return `COPY Folder(id, name, filePath) FROM "${filePath}" (HEADER=true, PARALLEL=false)`;
return `COPY ${t}(id, name, filePath) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'Community') {
return `COPY Community(id, label, heuristicLabel, keywords, description, enrichedBy, cohesion, symbolCount) FROM "${filePath}" (HEADER=true, PARALLEL=false)`;
return `COPY ${t}(id, label, heuristicLabel, keywords, description, enrichedBy, cohesion, symbolCount) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'Process') {
return `COPY Process(id, label, heuristicLabel, processType, stepCount, communities, entryPointId, terminalId) FROM "${filePath}" (HEADER=true, PARALLEL=false)`;
return `COPY ${t}(id, label, heuristicLabel, processType, stepCount, communities, entryPointId, terminalId) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
return `COPY ${table}(id, name, filePath, startLine, endLine, content) FROM "${filePath}" (HEADER=true, PARALLEL=false)`;
// Code element tables (Function, Class, Interface, Method, CodeElement, and multi-language)
return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content) FROM "${filePath}" ${COPY_CSV_OPTS}`;
};
/**
* Insert a single node to KuzuDB
* @param label - Node type (File, Function, Class, etc.)
* @param properties - Node properties
* @param dbPath - Path to KuzuDB database (optional if already initialized)
*/
export const insertNodeToKuzu = async (
label: string,
properties: Record<string, any>,
dbPath?: string
): Promise<boolean> => {
// Use provided dbPath or fall back to module-level db
const targetDbPath = dbPath || (db ? undefined : null);
if (!targetDbPath && !db) {
throw new Error('KuzuDB not initialized. Provide dbPath or call initKuzu first.');
}
try {
const escapeValue = (v: any): string => {
if (v === null || v === undefined) return 'NULL';
if (typeof v === 'number') return String(v);
// Escape backslashes first (for Windows paths), then single quotes
return `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`;
};
// Build INSERT query based on node type
let query: string;
if (label === 'File') {
query = `CREATE (n:File {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, content: ${escapeValue(properties.content || '')}})`;
} else if (label === 'Folder') {
query = `CREATE (n:Folder {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}})`;
} else {
// Function, Class, Method, Interface, etc. - standard code element schema
query = `CREATE (n:${label} {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, content: ${escapeValue(properties.content || '')}})`;
}
// Use per-query connection if dbPath provided (avoids lock conflicts)
if (targetDbPath) {
const tempDb = new kuzu.Database(targetDbPath);
const tempConn = new kuzu.Connection(tempDb);
try {
await tempConn.query(query);
return true;
} finally {
try { await tempConn.close(); } catch {}
try { await tempDb.close(); } catch {}
}
} else if (conn) {
// Use existing persistent connection (when called from analyze)
await conn.query(query);
return true;
}
return false;
} catch (e: any) {
// Node may already exist or other error
console.error(`Failed to insert ${label} node:`, e.message);
return false;
}
};
/**
* Batch insert multiple nodes to KuzuDB using a single connection
* @param nodes - Array of {label, properties} to insert
* @param dbPath - Path to KuzuDB database
* @returns Object with success count and error count
*/
export const batchInsertNodesToKuzu = async (
nodes: Array<{ label: string; properties: Record<string, any> }>,
dbPath: string
): Promise<{ inserted: number; failed: number }> => {
if (nodes.length === 0) return { inserted: 0, failed: 0 };
const escapeValue = (v: any): string => {
if (v === null || v === undefined) return 'NULL';
if (typeof v === 'number') return String(v);
// Escape backslashes first (for Windows paths), then single quotes
return `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`;
};
// Open a single connection for all inserts
const tempDb = new kuzu.Database(dbPath);
const tempConn = new kuzu.Connection(tempDb);
let inserted = 0;
let failed = 0;
try {
for (const { label, properties } of nodes) {
try {
let query: string;
// Use MERGE instead of CREATE for upsert behavior (handles duplicates gracefully)
if (label === 'File') {
query = `MERGE (n:File {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.content = ${escapeValue(properties.content || '')}`;
} else if (label === 'Folder') {
query = `MERGE (n:Folder {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}`;
} else {
query = `MERGE (n:${label} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.content = ${escapeValue(properties.content || '')}`;
}
await tempConn.query(query);
inserted++;
} catch (e: any) {
// Don't console.error here - it corrupts MCP JSON-RPC on stderr
failed++;
}
}
} finally {
try { await tempConn.close(); } catch {}
try { await tempDb.close(); } catch {}
}
return { inserted, failed };
};
export const executeQuery = async (cypher: string): Promise<any[]> => {
@ -244,7 +426,199 @@ export const closeKuzu = async (): Promise<void> => {
export const isKuzuReady = (): boolean => conn !== null && db !== null;
/**
* Delete all nodes (and their relationships) for a specific file from KuzuDB
* @param filePath - The file path to delete nodes for
* @param dbPath - Optional path to KuzuDB for per-query connection
* @returns Object with counts of deleted nodes
*/
export const deleteNodesForFile = async (filePath: string, dbPath?: string): Promise<{ deletedNodes: number }> => {
const usePerQuery = !!dbPath;
// Set up connection (either use existing or create per-query)
let tempDb: kuzu.Database | null = null;
let tempConn: kuzu.Connection | null = null;
let targetConn: kuzu.Connection | null = conn;
if (usePerQuery) {
tempDb = new kuzu.Database(dbPath);
tempConn = new kuzu.Connection(tempDb);
targetConn = tempConn;
} else if (!conn) {
throw new Error('KuzuDB not initialized. Provide dbPath or call initKuzu first.');
}
try {
let deletedNodes = 0;
const escapedPath = filePath.replace(/'/g, "''");
// Delete nodes from each table that has filePath
// DETACH DELETE removes the node and all its relationships
for (const tableName of NODE_TABLES) {
// Skip tables that don't have filePath (Community, Process)
if (tableName === 'Community' || tableName === 'Process') continue;
try {
// First count how many we'll delete
const countResult = await targetConn!.query(
`MATCH (n:${tableName}) WHERE n.filePath = '${escapedPath}' RETURN count(n) AS cnt`
);
const result = Array.isArray(countResult) ? countResult[0] : countResult;
const rows = await result.getAll();
const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0);
if (count > 0) {
// Delete nodes (and implicitly their relationships via DETACH)
await targetConn!.query(
`MATCH (n:${tableName}) WHERE n.filePath = '${escapedPath}' DETACH DELETE n`
);
deletedNodes += count;
}
} catch (e) {
// Some tables may not support this query, skip
}
}
// Also delete any embeddings for nodes in this file
try {
await targetConn!.query(
`MATCH (e:${EMBEDDING_TABLE_NAME}) WHERE e.nodeId STARTS WITH '${escapedPath}' DELETE e`
);
} catch {
// Embedding table may not exist or nodeId format may differ
}
return { deletedNodes };
} finally {
// Close per-query connection if used
if (tempConn) {
try { await tempConn.close(); } catch {}
}
if (tempDb) {
try { await tempDb.close(); } catch {}
}
}
};
export const getEmbeddingTableName = (): string => EMBEDDING_TABLE_NAME;
// ============================================================================
// Full-Text Search (FTS) Functions
// ============================================================================
/**
* Load the FTS extension (required before using FTS functions)
*/
export const loadFTSExtension = async (): Promise<void> => {
if (!conn) {
throw new Error('KuzuDB not initialized. Call initKuzu first.');
}
try {
await conn.query('INSTALL fts');
await conn.query('LOAD EXTENSION fts');
} catch {
// Extension may already be loaded
}
};
/**
* Create a full-text search index on a table
* @param tableName - The node table name (e.g., 'File', 'CodeSymbol')
* @param indexName - Name for the FTS index
* @param properties - List of properties to index (e.g., ['name', 'code'])
* @param stemmer - Stemming algorithm (default: 'porter')
*/
export const createFTSIndex = async (
tableName: string,
indexName: string,
properties: string[],
stemmer: string = 'porter'
): Promise<void> => {
if (!conn) {
throw new Error('KuzuDB not initialized. Call initKuzu first.');
}
await loadFTSExtension();
const propList = properties.map(p => `'${p}'`).join(', ');
const query = `CALL CREATE_FTS_INDEX('${tableName}', '${indexName}', [${propList}], stemmer := '${stemmer}')`;
try {
await conn.query(query);
} catch (e: any) {
// Index may already exist
if (!e.message?.includes('already exists')) {
throw e;
}
}
};
/**
* Query a full-text search index
* @param tableName - The node table name
* @param indexName - FTS index name
* @param query - Search query string
* @param limit - Maximum results
* @param conjunctive - If true, all terms must match (AND); if false, any term matches (OR)
* @returns Array of { node properties, score }
*/
export const queryFTS = async (
tableName: string,
indexName: string,
query: string,
limit: number = 20,
conjunctive: boolean = false
): Promise<Array<{ nodeId: string; name: string; filePath: string; score: number; [key: string]: any }>> => {
if (!conn) {
throw new Error('KuzuDB not initialized. Call initKuzu first.');
}
// Escape single quotes in query
const escapedQuery = query.replace(/'/g, "''");
const cypher = `
CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', '${escapedQuery}', conjunctive := ${conjunctive})
RETURN node, score
ORDER BY score DESC
LIMIT ${limit}
`;
try {
const queryResult = await conn.query(cypher);
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
const rows = await result.getAll();
return rows.map((row: any) => {
const node = row.node || row[0] || {};
const score = row.score ?? row[1] ?? 0;
return {
nodeId: node.nodeId || node.id || '',
name: node.name || '',
filePath: node.filePath || '',
score: typeof score === 'number' ? score : parseFloat(score) || 0,
...node,
};
});
} catch (e: any) {
// Return empty if index doesn't exist yet
if (e.message?.includes('does not exist')) {
return [];
}
throw e;
}
};
/**
* Drop an FTS index
*/
export const dropFTSIndex = async (tableName: string, indexName: string): Promise<void> => {
if (!conn) {
throw new Error('KuzuDB not initialized. Call initKuzu first.');
}
try {
await conn.query(`CALL DROP_FTS_INDEX('${tableName}', '${indexName}')`);
} catch {
// Index may not exist
}
};

View file

@ -62,6 +62,7 @@ CREATE NODE TABLE Function (
filePath STRING,
startLine INT64,
endLine INT64,
isExported BOOLEAN,
content STRING,
PRIMARY KEY (id)
)`;
@ -73,6 +74,7 @@ CREATE NODE TABLE Class (
filePath STRING,
startLine INT64,
endLine INT64,
isExported BOOLEAN,
content STRING,
PRIMARY KEY (id)
)`;
@ -84,6 +86,7 @@ CREATE NODE TABLE Interface (
filePath STRING,
startLine INT64,
endLine INT64,
isExported BOOLEAN,
content STRING,
PRIMARY KEY (id)
)`;
@ -95,6 +98,7 @@ CREATE NODE TABLE Method (
filePath STRING,
startLine INT64,
endLine INT64,
isExported BOOLEAN,
content STRING,
PRIMARY KEY (id)
)`;
@ -106,6 +110,7 @@ CREATE NODE TABLE CodeElement (
filePath STRING,
startLine INT64,
endLine INT64,
isExported BOOLEAN,
content STRING,
PRIMARY KEY (id)
)`;
@ -196,20 +201,20 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM File TO \`Struct\`,
FROM File TO \`Enum\`,
FROM File TO \`Macro\`,
FROM File TO Typedef,
FROM File TO \`Typedef\`,
FROM File TO \`Union\`,
FROM File TO Namespace,
FROM File TO Trait,
FROM File TO Impl,
FROM File TO TypeAlias,
FROM File TO \`Namespace\`,
FROM File TO \`Trait\`,
FROM File TO \`Impl\`,
FROM File TO \`TypeAlias\`,
FROM File TO \`Const\`,
FROM File TO Static,
FROM File TO Property,
FROM File TO Record,
FROM File TO Delegate,
FROM File TO Annotation,
FROM File TO Constructor,
FROM File TO Template,
FROM File TO \`Static\`,
FROM File TO \`Property\`,
FROM File TO \`Record\`,
FROM File TO \`Delegate\`,
FROM File TO \`Annotation\`,
FROM File TO \`Constructor\`,
FROM File TO \`Template\`,
FROM File TO \`Module\`,
FROM Folder TO Folder,
FROM Folder TO File,
@ -219,48 +224,48 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM Function TO Community,
FROM Function TO \`Macro\`,
FROM Function TO \`Struct\`,
FROM Function TO Template,
FROM Function TO \`Template\`,
FROM Function TO \`Enum\`,
FROM Function TO Namespace,
FROM Function TO TypeAlias,
FROM Function TO \`Namespace\`,
FROM Function TO \`TypeAlias\`,
FROM Function TO \`Module\`,
FROM Function TO Impl,
FROM Function TO \`Impl\`,
FROM Function TO Interface,
FROM Function TO Constructor,
FROM Function TO \`Constructor\`,
FROM Class TO Method,
FROM Class TO Function,
FROM Class TO Class,
FROM Class TO Interface,
FROM Class TO Community,
FROM Class TO Template,
FROM Class TO TypeAlias,
FROM Class TO \`Template\`,
FROM Class TO \`TypeAlias\`,
FROM Class TO \`Struct\`,
FROM Class TO \`Enum\`,
FROM Class TO Constructor,
FROM Class TO \`Constructor\`,
FROM Method TO Function,
FROM Method TO Method,
FROM Method TO Class,
FROM Method TO Community,
FROM Method TO Template,
FROM Method TO \`Template\`,
FROM Method TO \`Struct\`,
FROM Method TO TypeAlias,
FROM Method TO \`TypeAlias\`,
FROM Method TO \`Enum\`,
FROM Method TO \`Macro\`,
FROM Method TO Namespace,
FROM Method TO \`Namespace\`,
FROM Method TO \`Module\`,
FROM Method TO Impl,
FROM Method TO \`Impl\`,
FROM Method TO Interface,
FROM Method TO Constructor,
FROM Template TO Template,
FROM Template TO Function,
FROM Template TO Method,
FROM Template TO Class,
FROM Template TO \`Struct\`,
FROM Template TO TypeAlias,
FROM Template TO \`Enum\`,
FROM Template TO \`Macro\`,
FROM Template TO Interface,
FROM Template TO Constructor,
FROM Method TO \`Constructor\`,
FROM \`Template\` TO \`Template\`,
FROM \`Template\` TO Function,
FROM \`Template\` TO Method,
FROM \`Template\` TO Class,
FROM \`Template\` TO \`Struct\`,
FROM \`Template\` TO \`TypeAlias\`,
FROM \`Template\` TO \`Enum\`,
FROM \`Template\` TO \`Macro\`,
FROM \`Template\` TO Interface,
FROM \`Template\` TO \`Constructor\`,
FROM \`Module\` TO \`Module\`,
FROM CodeElement TO Community,
FROM Interface TO Community,
@ -268,11 +273,11 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM Interface TO Method,
FROM Interface TO Class,
FROM Interface TO Interface,
FROM Interface TO TypeAlias,
FROM Interface TO \`TypeAlias\`,
FROM Interface TO \`Struct\`,
FROM Interface TO Constructor,
FROM Interface TO \`Constructor\`,
FROM \`Struct\` TO Community,
FROM \`Struct\` TO Trait,
FROM \`Struct\` TO \`Trait\`,
FROM \`Struct\` TO Function,
FROM \`Struct\` TO Method,
FROM \`Enum\` TO Community,
@ -281,56 +286,56 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM \`Macro\` TO Method,
FROM \`Module\` TO Function,
FROM \`Module\` TO Method,
FROM Typedef TO Community,
FROM \`Typedef\` TO Community,
FROM \`Union\` TO Community,
FROM Namespace TO Community,
FROM Trait TO Community,
FROM Impl TO Community,
FROM Impl TO Trait,
FROM TypeAlias TO Community,
FROM \`Namespace\` TO Community,
FROM \`Trait\` TO Community,
FROM \`Impl\` TO Community,
FROM \`Impl\` TO \`Trait\`,
FROM \`TypeAlias\` TO Community,
FROM \`Const\` TO Community,
FROM Static TO Community,
FROM Property TO Community,
FROM Record TO Community,
FROM Delegate TO Community,
FROM Annotation TO Community,
FROM Constructor TO Community,
FROM Constructor TO Interface,
FROM Constructor TO Class,
FROM Constructor TO Method,
FROM Constructor TO Function,
FROM Constructor TO Constructor,
FROM Constructor TO \`Struct\`,
FROM Constructor TO \`Macro\`,
FROM Constructor TO Template,
FROM Constructor TO TypeAlias,
FROM Constructor TO \`Enum\`,
FROM Constructor TO Impl,
FROM Constructor TO Namespace,
FROM Template TO Community,
FROM \`Static\` TO Community,
FROM \`Property\` TO Community,
FROM \`Record\` TO Community,
FROM \`Delegate\` TO Community,
FROM \`Annotation\` TO Community,
FROM \`Constructor\` TO Community,
FROM \`Constructor\` TO Interface,
FROM \`Constructor\` TO Class,
FROM \`Constructor\` TO Method,
FROM \`Constructor\` TO Function,
FROM \`Constructor\` TO \`Constructor\`,
FROM \`Constructor\` TO \`Struct\`,
FROM \`Constructor\` TO \`Macro\`,
FROM \`Constructor\` TO \`Template\`,
FROM \`Constructor\` TO \`TypeAlias\`,
FROM \`Constructor\` TO \`Enum\`,
FROM \`Constructor\` TO \`Impl\`,
FROM \`Constructor\` TO \`Namespace\`,
FROM \`Template\` TO Community,
FROM \`Module\` TO Community,
FROM Function TO Process,
FROM Method TO Process,
FROM Class TO Process,
FROM Interface TO Process,
FROM \`Struct\` TO Process,
FROM Constructor TO Process,
FROM \`Constructor\` TO Process,
FROM \`Module\` TO Process,
FROM \`Macro\` TO Process,
FROM Impl TO Process,
FROM Typedef TO Process,
FROM TypeAlias TO Process,
FROM \`Impl\` TO Process,
FROM \`Typedef\` TO Process,
FROM \`TypeAlias\` TO Process,
FROM \`Enum\` TO Process,
FROM \`Union\` TO Process,
FROM Namespace TO Process,
FROM Trait TO Process,
FROM \`Namespace\` TO Process,
FROM \`Trait\` TO Process,
FROM \`Const\` TO Process,
FROM Static TO Process,
FROM Property TO Process,
FROM Record TO Process,
FROM Delegate TO Process,
FROM Annotation TO Process,
FROM Template TO Process,
FROM \`Static\` TO Process,
FROM \`Property\` TO Process,
FROM \`Record\` TO Process,
FROM \`Delegate\` TO Process,
FROM \`Annotation\` TO Process,
FROM \`Template\` TO Process,
FROM CodeElement TO Process,
type STRING,
confidence DOUBLE,

View file

@ -1,18 +1,11 @@
/**
* BM25 Full-Text Search Index
* Full-Text Search via KuzuDB FTS
*
* Uses MiniSearch for fast keyword-based search with BM25 ranking.
* Complements semantic search - BM25 finds exact terms, semantic finds concepts.
* Uses KuzuDB's built-in full-text search indexes for keyword-based search.
* Always reads from the database (no cached state to drift).
*/
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)
}
import { queryFTS } from '../kuzu/kuzu-adapter.js';
export interface BM25SearchResult {
filePath: string;
@ -21,183 +14,51 @@ export interface BM25SearchResult {
}
/**
* BM25 Index singleton
* Stores the MiniSearch instance and provides search methods
*/
let searchIndex: MiniSearch<BM25Document> | null = null;
let indexedDocCount = 0;
/**
* Build the BM25 index from file contents
* Should be called after ingestion completes
* Search using KuzuDB's built-in FTS (always fresh, reads from disk)
*
* @param fileContents - Map of file path to content
* @returns Number of documents indexed
* Queries multiple node tables (File, Function, Class, Method) in parallel
* and merges results by filePath, summing scores for the same file.
*
* @param query - Search query string
* @param limit - Maximum results
* @returns Ranked search results from FTS indexes
*/
export const buildBM25Index = (fileContents: Map<string, string>): number => {
// Create new MiniSearch instance with BM25-like scoring
searchIndex = new MiniSearch<BM25Document>({
fields: ['content', 'name'], // Fields to index
storeFields: ['id'], // Fields to return in results
// Tokenizer: split on non-alphanumeric, camelCase, snake_case
tokenize: (text: string) => {
// Split on whitespace and punctuation
const tokens = text.toLowerCase().split(/[\s\-_./\\(){}[\]<>:;,!?'"]+/);
// Also split camelCase: "getUserById" -> ["get", "user", "by", "id"]
const expanded: string[] = [];
for (const token of tokens) {
if (token.length === 0) continue;
// Split camelCase
const camelParts = token.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase().split(' ');
expanded.push(...camelParts);
// Also keep original token for exact matches
if (camelParts.length > 1) {
expanded.push(token);
}
export const searchFTSFromKuzu = async (query: string, limit: number = 20): Promise<BM25SearchResult[]> => {
// Search multiple tables with searchable content
const [fileResults, functionResults, classResults, methodResults] = await Promise.all([
queryFTS('File', 'file_fts', query, limit, false).catch(() => []),
queryFTS('Function', 'function_fts', query, limit, false).catch(() => []),
queryFTS('Class', 'class_fts', query, limit, false).catch(() => []),
queryFTS('Method', 'method_fts', query, limit, false).catch(() => []),
]);
// Merge results by filePath, summing scores for same file
const merged = new Map<string, { filePath: string; score: number }>();
const addResults = (results: any[]) => {
for (const r of results) {
const existing = merged.get(r.filePath);
if (existing) {
existing.score += r.score;
} else {
merged.set(r.filePath, { filePath: r.filePath, score: r.score });
}
// Filter out very short tokens and common noise
return expanded.filter(t => t.length > 1 && !STOP_WORDS.has(t));
},
});
}
};
// Index all files
const documents: BM25Document[] = [];
addResults(fileResults);
addResults(functionResults);
addResults(classResults);
addResults(methodResults);
for (const [filePath, content] of fileContents.entries()) {
// Extract filename from path
const name = filePath.split('/').pop() || filePath;
documents.push({
id: filePath,
content: content,
name: name,
});
}
// Sort by score descending and add rank
const sorted = Array.from(merged.values())
.sort((a, b) => b.score - a.score)
.slice(0, limit);
// Batch add for efficiency
searchIndex.addAll(documents);
indexedDocCount = documents.length;
const isDev = process.env.NODE_ENV !== 'production';
if (isDev) {
console.log(`📚 BM25 index built: ${indexedDocCount} documents`);
}
return indexedDocCount;
};
/**
* Search the BM25 index
*
* @param query - Search query (keywords)
* @param limit - Maximum results to return
* @returns Ranked search results with file paths and scores
*/
export const searchBM25 = (query: string, limit: number = 20): BM25SearchResult[] => {
if (!searchIndex) {
return [];
}
// Search with fuzzy matching and prefix support
const results = searchIndex.search(query, {
fuzzy: 0.2,
prefix: true,
boost: { name: 2 }, // Boost file name matches
});
// Limit results and add rank
return results.slice(0, limit).map((r, index) => ({
filePath: r.id,
return sorted.map((r, index) => ({
filePath: r.filePath,
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 (for cleanup or re-indexing)
*/
export const clearBM25Index = (): void => {
searchIndex = null;
indexedDocCount = 0;
};
/**
* Export the BM25 index to disk
*/
export const exportBM25Index = async (filePath: string): Promise<void> => {
if (!searchIndex) return;
const json = JSON.stringify(searchIndex.toJSON());
await fs.writeFile(filePath, json, 'utf-8');
};
/**
* Load a BM25 index from disk
*/
export const loadBM25Index = async (filePath: string): Promise<boolean> => {
try {
const json = await fs.readFile(filePath, 'utf-8');
const data = JSON.parse(json);
searchIndex = MiniSearch.loadJSON(data, {
fields: ['content', 'name'],
storeFields: ['id'],
tokenize: (text: 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));
},
});
indexedDocCount = searchIndex.documentCount;
return true;
} catch {
return false;
}
};
/**
* Common stop words to filter out (too common to be useful)
*/
const STOP_WORDS = new Set([
// JavaScript/TypeScript keywords
'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',
// Common English stop words
'the', 'is', 'at', 'which', 'on', 'a', 'an', 'and', 'or', 'but', 'in', 'with',
'to', 'of', 'it', 'be', 'as', 'by', 'that', 'for', 'are', 'was', 'were',
]);

View file

@ -8,7 +8,7 @@
* production search systems.
*/
import { searchBM25, isBM25Ready, type BM25SearchResult } from './bm25-index.js';
import { searchFTSFromKuzu, type BM25SearchResult } from './bm25-index.js';
import type { SemanticSearchResult } from '../embeddings/types.js';
/**
@ -114,11 +114,11 @@ export const mergeWithRRF = (
/**
* Check if hybrid search is available
* Requires BM25 index to be built
* Note: Semantic search is optional - hybrid works with just BM25 if embeddings aren't ready
* KuzuDB FTS is always available once the database is initialized.
* Semantic search is optional - hybrid works with just FTS if embeddings aren't ready.
*/
export const isHybridSearchReady = (): boolean => {
return isBM25Ready();
return true; // FTS is always available via KuzuDB when DB is open
};
/**
@ -146,6 +146,7 @@ export const formatHybridResults = (results: HybridSearchResult[]): string => {
/**
* Execute BM25 + semantic search and merge with RRF.
* Uses KuzuDB FTS for always-fresh BM25 results (no cached data).
* The semanticSearch function is injected to keep this module environment-agnostic.
*/
export const hybridSearch = async (
@ -154,11 +155,8 @@ export const hybridSearch = async (
executeQuery: (cypher: string) => Promise<any[]>,
semanticSearch: (executeQuery: (cypher: string) => Promise<any[]>, query: string, k?: number) => Promise<SemanticSearchResult[]>
): Promise<HybridSearchResult[]> => {
const bm25Results = isBM25Ready() ? searchBM25(query, limit) : [];
// Use KuzuDB FTS for always-fresh BM25 results
const bm25Results = await searchFTSFromKuzu(query, limit);
const semanticResults = await semanticSearch(executeQuery, query, limit);
return mergeWithRRF(bm25Results, semanticResults, limit);
};

View file

@ -1,120 +0,0 @@
/**
* 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

@ -1,30 +1,37 @@
/**
* KuzuDB Adapter (Read-Only)
* KuzuDB Adapter (Persistent Connection)
*
* Simplified adapter for MCP that only reads from existing .gitnexus/ database.
* Holds a single database connection for the lifetime of the MCP session.
* This is safe since the watcher has been removed -- only one process
* accesses the database at a time.
*/
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;
/**
* Initialize with a persistent connection to the database
*/
export const initKuzu = async (path: string): Promise<void> => {
if (conn) return; // Already initialized
// Check if database exists
try {
await fs.stat(dbPath);
await fs.stat(path);
} catch {
throw new Error(`KuzuDB not found at ${dbPath}. Run: gitnexus analyze`);
throw new Error(`KuzuDB not found at ${path}. Run: gitnexus analyze`);
}
db = new kuzu.Database(dbPath);
db = new kuzu.Database(path);
conn = new kuzu.Connection(db);
};
/**
* Execute a query using the persistent connection
*/
export const executeQuery = async (cypher: string): Promise<any[]> => {
if (!conn) {
throw new Error('KuzuDB not initialized. Call initKuzu first.');
@ -36,19 +43,21 @@ export const executeQuery = async (cypher: string): Promise<any[]> => {
return rows;
};
/**
* Close the persistent connection
*/
export const closeKuzu = async (): Promise<void> => {
if (conn) {
try {
await conn.close();
} catch {}
try { await conn.close(); } catch {}
conn = null;
}
if (db) {
try {
await db.close();
} catch {}
try { await db.close(); } catch {}
db = null;
}
};
/**
* Check if the database connection is active
*/
export const isKuzuReady = (): boolean => conn !== null && db !== null;

View file

@ -8,8 +8,15 @@
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';
import { embedQuery, getEmbeddingDims, disposeEmbedder } from '../core/embedder.js';
import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js';
import {
getStoragePaths as getRepoStoragePaths,
saveMeta as saveRepoMeta,
loadMeta as loadRepoMeta,
addToGitignore,
} from '../../storage/repo-manager.js';
import { generateAIContextFiles } from '../../cli/ai-context.js';
export interface RepoMeta {
repoPath: string;
@ -28,7 +35,6 @@ export interface IndexedRepo {
repoPath: string;
storagePath: string;
kuzuPath: string;
bm25Path: string;
metaPath: string;
meta: RepoMeta;
}
@ -40,7 +46,6 @@ function getStoragePaths(repoPath: string) {
return {
storagePath,
kuzuPath: path.join(storagePath, 'kuzu'),
bm25Path: path.join(storagePath, 'bm25.json'),
metaPath: path.join(storagePath, 'meta.json'),
};
}
@ -143,7 +148,6 @@ export class LocalBackend {
if (this.initialized || !this.repo) return;
await initKuzu(this.repo.kuzuPath);
await loadBM25Index(this.repo.bm25Path);
this.initialized = true;
}
@ -163,6 +167,10 @@ export class LocalBackend {
return this.repo?.storagePath || null;
}
get meta(): any {
return this.repo?.meta || null;
}
async callTool(method: string, params: any): Promise<any> {
if (!this.repo) {
throw new Error('Repository not indexed. Run: gitnexus analyze');
@ -269,12 +277,13 @@ export class LocalBackend {
}
/**
* BM25 keyword search helper
* BM25 keyword search helper - uses KuzuDB FTS for always-fresh results
*/
private async bm25Search(query: string, limit: number): Promise<any[]> {
if (!isBM25Ready()) return [];
// Import dynamically to avoid circular dependency
const { searchFTSFromKuzu } = await import('../../core/search/bm25-index.js');
const bm25Results = await searchFTSFromKuzu(query, limit);
const bm25Results = searchBM25(query, limit);
const results: any[] = [];
for (const bm25Result of bm25Results) {
@ -667,14 +676,153 @@ export class LocalBackend {
};
}
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.`,
};
private async analyze(params: { path?: string; force?: boolean; skipEmbeddings?: boolean }): Promise<any> {
// Determine target repo path
let repoPath: string;
if (params.path) {
repoPath = path.resolve(params.path);
} else if (this.repo) {
repoPath = this.repo.repoPath;
} else {
const gitRoot = getGitRoot(process.cwd());
if (!gitRoot) {
return { error: 'Not inside a git repository' };
}
repoPath = gitRoot;
}
if (!isGitRepo(repoPath)) {
return { error: 'Not a git repository' };
}
const { storagePath, kuzuPath } = getRepoStoragePaths(repoPath);
const currentCommit = getCurrentCommit(repoPath);
const existingMeta = await loadRepoMeta(storagePath);
// Skip if already indexed at same commit (unless force)
if (existingMeta && !params.force && existingMeta.lastCommit === currentCommit) {
return { status: 'up_to_date', message: 'Repository already up to date.' };
}
// Close MCP's persistent connection before pipeline takes over
await closeKuzu();
this.initialized = false;
try {
// Import pipeline modules dynamically to avoid circular deps
const { runPipelineFromRepo } = await import('../../core/ingestion/pipeline.js');
const coreKuzu = await import('../../core/kuzu/kuzu-adapter.js');
// Run ingestion pipeline
console.error('GitNexus: Running indexing pipeline...');
const pipelineResult = await runPipelineFromRepo(repoPath, (progress) => {
if (progress.percent % 20 === 0) {
console.error(`GitNexus: ${progress.phase} ${progress.percent}%`);
}
});
// Load graph into KuzuDB
console.error('GitNexus: Loading graph into KuzuDB...');
await coreKuzu.initKuzu(kuzuPath);
await coreKuzu.loadGraphToKuzu(pipelineResult.graph, pipelineResult.fileContents, storagePath);
// Create FTS indexes
console.error('GitNexus: Creating FTS indexes...');
try {
await coreKuzu.createFTSIndex('File', 'file_fts', ['name', 'content']);
await coreKuzu.createFTSIndex('Function', 'function_fts', ['name', 'content']);
await coreKuzu.createFTSIndex('Class', 'class_fts', ['name', 'content']);
await coreKuzu.createFTSIndex('Method', 'method_fts', ['name', 'content']);
} catch (e: any) {
console.error('GitNexus: Some FTS indexes may not have been created:', e.message);
}
// Generate embeddings (unless skipped)
if (!params.skipEmbeddings) {
try {
console.error('GitNexus: Generating embeddings...');
const { runEmbeddingPipeline } = await import('../../core/embeddings/embedding-pipeline.js');
await runEmbeddingPipeline(
coreKuzu.executeQuery,
coreKuzu.executeWithReusedStatement,
(progress) => {
if (progress.percent % 25 === 0) {
console.error(`GitNexus: Embeddings ${progress.percent}%`);
}
}
);
} catch (e: any) {
console.error('GitNexus: Embedding generation failed (non-fatal):', e.message);
}
}
// Save metadata
const stats = await coreKuzu.getKuzuStats();
await saveRepoMeta(storagePath, {
repoPath,
lastCommit: currentCommit,
indexedAt: new Date().toISOString(),
stats: {
files: pipelineResult.fileContents.size,
nodes: stats.nodes,
edges: stats.edges,
communities: pipelineResult.communityResult?.stats.totalCommunities,
processes: pipelineResult.processResult?.stats.totalProcesses,
},
});
// Add .gitnexus to .gitignore
await addToGitignore(repoPath);
// Generate AI context files
const projectName = path.basename(repoPath);
await generateAIContextFiles(repoPath, storagePath, projectName, {
files: pipelineResult.fileContents.size,
nodes: stats.nodes,
edges: stats.edges,
communities: pipelineResult.communityResult?.stats.totalCommunities,
processes: pipelineResult.processResult?.stats.totalProcesses,
});
// Close core kuzu connection (pipeline is done)
await coreKuzu.closeKuzu();
// Re-init MCP state so next tool call reconnects
this.repo = await loadRepo(repoPath);
if (this.repo) {
const repoStats = this.repo.meta.stats || {};
this._context = {
projectName: path.basename(this.repo.repoPath),
stats: {
fileCount: repoStats.files || 0,
functionCount: repoStats.nodes || 0,
classCount: 0,
interfaceCount: 0,
methodCount: 0,
communityCount: repoStats.communities || 0,
processCount: repoStats.processes || 0,
},
hotspots: [],
folderTree: '',
};
}
console.error('GitNexus: Indexing complete!');
return {
status: 'success',
message: `Repository indexed successfully.`,
stats: {
files: pipelineResult.fileContents.size,
nodes: stats.nodes,
edges: stats.edges,
communities: pipelineResult.communityResult?.stats.totalCommunities,
processes: pipelineResult.processResult?.stats.totalProcesses,
},
};
} catch (e: any) {
console.error('GitNexus: Indexing failed:', e.message);
return { error: `Indexing failed: ${e.message}` };
}
}
async disconnect(): Promise<void> {

View file

@ -6,6 +6,7 @@
*/
import type { LocalBackend } from './local/local-backend.js';
import { checkStaleness } from './staleness.js';
export interface ResourceDefinition {
uri: string;
@ -113,28 +114,42 @@ async function getContextResource(backend: LocalBackend): Promise<string> {
return 'error: No codebase loaded. Run: gitnexus analyze';
}
// Check staleness
const repoPath = backend.repoPath;
const lastCommit = backend.meta?.lastCommit || 'HEAD';
const staleness = repoPath ? checkStaleness(repoPath, lastCommit) : { isStale: false, commitsBehind: 0 };
const lines: string[] = [
`project: ${context.projectName}`,
'stats:',
` files: ${context.stats.fileCount}`,
` symbols: ${context.stats.functionCount}`,
` clusters: ${context.stats.communityCount}`,
` processes: ${context.stats.processCount}`,
'',
'tools_available:',
' - search: Hybrid semantic + keyword search',
' - explore: Deep dive on symbol/cluster/process',
' - impact: Blast radius analysis',
' - overview: List all clusters and processes',
' - cypher: Raw graph queries',
'',
'resources_available:',
' - gitnexus://clusters: All clusters',
' - gitnexus://processes: All processes',
' - gitnexus://cluster/{name}: Cluster details',
' - gitnexus://process/{name}: Process trace',
];
// Add staleness warning if index is behind
if (staleness.isStale && staleness.hint) {
lines.push('');
lines.push(`staleness: "${staleness.hint}"`);
}
lines.push('');
lines.push('stats:');
lines.push(` files: ${context.stats.fileCount}`);
lines.push(` symbols: ${context.stats.functionCount}`);
lines.push(` clusters: ${context.stats.communityCount}`);
lines.push(` processes: ${context.stats.processCount}`);
lines.push('');
lines.push('tools_available:');
lines.push(' - search: Hybrid semantic + keyword search');
lines.push(' - explore: Deep dive on symbol/cluster/process');
lines.push(' - impact: Blast radius analysis');
lines.push(' - overview: List all clusters and processes');
lines.push(' - cypher: Raw graph queries');
lines.push(' - analyze: Re-index to update stale data');
lines.push('');
lines.push('resources_available:');
lines.push(' - gitnexus://clusters: All clusters');
lines.push(' - gitnexus://processes: All processes');
lines.push(' - gitnexus://cluster/{name}: Cluster details');
lines.push(' - gitnexus://process/{name}: Process trace');
return lines.join('\n');
}

View file

@ -0,0 +1,43 @@
/**
* Staleness Check
*
* Checks if the GitNexus index is behind the current git HEAD.
* Returns a hint for the LLM to call analyze if stale.
*/
import { execSync } from 'child_process';
import path from 'path';
export interface StalenessInfo {
isStale: boolean;
commitsBehind: number;
hint?: string;
}
/**
* Check how many commits the index is behind HEAD
*/
export function checkStaleness(repoPath: string, lastCommit: string): StalenessInfo {
try {
// Get count of commits between lastCommit and HEAD
const result = execSync(
`git rev-list --count ${lastCommit}..HEAD`,
{ cwd: repoPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
).trim();
const commitsBehind = parseInt(result, 10) || 0;
if (commitsBehind > 0) {
return {
isStale: true,
commitsBehind,
hint: `⚠️ Index is ${commitsBehind} commit${commitsBehind > 1 ? 's' : ''} behind HEAD. Run analyze tool to update.`,
};
}
return { isStale: false, commitsBehind: 0 };
} catch {
// If git command fails, assume not stale (fail open)
return { isStale: false, commitsBehind: 0 };
}
}

View file

@ -24,18 +24,22 @@ export interface ToolDefinition {
export const GITNEXUS_TOOLS: ToolDefinition[] = [
{
name: 'analyze',
description: `Index or re-index the current repository.
description: `Index or re-index the current repository. Runs the full pipeline in-process.
Creates .gitnexus/ in repo root with:
- Knowledge graph (functions, classes, calls, imports)
- BM25 search index
- Full-text search indexes
- Community detection (Leiden)
- Process tracing
- Embeddings for semantic search
Run this when:
- First time using GitNexus on a repo
- After major code changes
- When 'not indexed' error appears`,
- When staleness warning appears in gitnexus://context
- When 'not indexed' error appears
Note: This may take 30-120 seconds for large repos.`,
inputSchema: {
type: 'object',
properties: {

View file

@ -12,7 +12,7 @@ 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';
import { loadBM25Index, searchBM25, isBM25Ready } from '../core/search/bm25-index.js';
import { searchFTSFromKuzu } from '../core/search/bm25-index.js';
import { hybridSearch } from '../core/search/hybrid-search.js';
import { semanticSearch } from '../core/embeddings/embedding-pipeline.js';
import { isEmbedderReady } from '../core/embeddings/embedder.js';
@ -131,28 +131,19 @@ export const createServer = async (port: number) => {
return;
}
await initKuzu(repo.kuzuPath);
await loadBM25Index(repo.bm25Path);
const query = req.body.query ?? '';
const limit = req.body.limit ?? 10;
if (isBM25Ready() && isEmbedderReady()) {
if (isEmbedderReady()) {
const results = await hybridSearch(query, limit, executeQuery, semanticSearch);
res.json({ results });
return;
}
if (isBM25Ready()) {
res.json({ results: searchBM25(query, limit) });
return;
}
if (isEmbedderReady()) {
res.json({ results: await semanticSearch(executeQuery, query, limit) });
return;
}
res.json({ results: [] });
// FTS-only fallback when embeddings aren't loaded
const results = await searchFTSFromKuzu(query, limit);
res.json({ results });
});
// Read file

View file

@ -17,6 +17,7 @@ export interface RepoMeta {
edges?: number;
communities?: number;
processes?: number;
embeddings?: number;
};
}
@ -24,7 +25,6 @@ export interface IndexedRepo {
repoPath: string;
storagePath: string;
kuzuPath: string;
bm25Path: string;
metaPath: string;
meta: RepoMeta;
}
@ -46,7 +46,6 @@ export const getStoragePaths = (repoPath: string) => {
return {
storagePath,
kuzuPath: path.join(storagePath, 'kuzu'),
bm25Path: path.join(storagePath, 'bm25.json'),
metaPath: path.join(storagePath, 'meta.json'),
};
};

355
gitnexus/vendor/leiden/index.cjs vendored Normal file
View file

@ -0,0 +1,355 @@
/**
* Graphology Leiden Algorithm
* ============================
*
* JavaScript implementation of the Leiden community detection
* algorithm for graphology.
*
* Vendored from: https://github.com/graphology/graphology/tree/master/src/communities-leiden
* License: MIT
*
* [Reference]
* Traag, V. A., et al. "From Louvain to Leiden: Guaranteeing Well-Connected
* Communities". Scientific Reports, vol. 9, no 1, 2019, p. 5233.
* https://arxiv.org/abs/1810.08473
*/
var resolveDefaults = require('graphology-utils/defaults');
var isGraph = require('graphology-utils/is-graph');
var inferType = require('graphology-utils/infer-type');
var SparseMap = require('mnemonist/sparse-map');
var SparseQueueSet = require('mnemonist/sparse-queue-set');
var createRandomIndex = require('pandemonium/random-index').createRandomIndex;
var utils = require('./utils.cjs');
var indices = require('graphology-indices/louvain');
var addWeightToCommunity = utils.addWeightToCommunity;
var UndirectedLouvainIndex = indices.UndirectedLouvainIndex;
var UndirectedLeidenAddenda = utils.UndirectedLeidenAddenda;
var DEFAULTS = {
attributes: {
community: 'community',
weight: 'weight'
},
randomness: 0.01,
randomWalk: true,
resolution: 1,
rng: Math.random,
weighted: false
};
var EPSILON = 1e-10;
function tieBreaker(
bestCommunity,
currentCommunity,
targetCommunity,
delta,
bestDelta
) {
if (Math.abs(delta - bestDelta) < EPSILON) {
if (bestCommunity === currentCommunity) {
return false;
} else {
return targetCommunity > bestCommunity;
}
} else if (delta > bestDelta) {
return true;
}
return false;
}
function undirectedLeiden(detailed, graph, options) {
var index = new UndirectedLouvainIndex(graph, {
attributes: {
weight: options.attributes.weight
},
keepDendrogram: detailed,
resolution: options.resolution,
weighted: options.weighted
});
var addenda = new UndirectedLeidenAddenda(index, {
randomness: options.randomness,
rng: options.rng
});
var randomIndex = createRandomIndex(options.rng);
// Communities
var currentCommunity, targetCommunity;
var communities = new SparseMap(Float64Array, index.C);
// Traversal
var queue = new SparseQueueSet(index.C),
start,
end,
weight,
ci,
ri,
s,
i,
j,
l;
// Metrics
var degree, targetCommunityDegree;
// Moves
var bestCommunity, bestDelta, deltaIsBetter, delta;
// Details
var deltaComputations = 0,
nodesVisited = 0,
moves = [],
currentMoves;
while (true) {
l = index.C;
currentMoves = 0;
// Traversal of the graph
ri = options.randomWalk ? randomIndex(l) : 0;
for (s = 0; s < l; s++, ri++) {
i = ri % l;
queue.enqueue(i);
}
while (queue.size !== 0) {
i = queue.dequeue();
nodesVisited++;
degree = 0;
communities.clear();
currentCommunity = index.belongings[i];
start = index.starts[i];
end = index.starts[i + 1];
// Traversing neighbors
for (; start < end; start++) {
j = index.neighborhood[start];
weight = index.weights[start];
targetCommunity = index.belongings[j];
// Incrementing metrics
degree += weight;
addWeightToCommunity(communities, targetCommunity, weight);
}
// Finding best community to move to
bestDelta = index.fastDeltaWithOwnCommunity(
i,
degree,
communities.get(currentCommunity) || 0,
currentCommunity
);
bestCommunity = currentCommunity;
for (ci = 0; ci < communities.size; ci++) {
targetCommunity = communities.dense[ci];
if (targetCommunity === currentCommunity) continue;
targetCommunityDegree = communities.vals[ci];
deltaComputations++;
delta = index.fastDelta(
i,
degree,
targetCommunityDegree,
targetCommunity
);
deltaIsBetter = tieBreaker(
bestCommunity,
currentCommunity,
targetCommunity,
delta,
bestDelta
);
if (deltaIsBetter) {
bestDelta = delta;
bestCommunity = targetCommunity;
}
}
if (bestDelta < 0) {
bestCommunity = index.isolate(i, degree);
if (bestCommunity === currentCommunity) continue;
} else {
if (bestCommunity === currentCommunity) {
continue;
} else {
index.move(i, degree, bestCommunity);
}
}
currentMoves++;
// Adding neighbors from other communities to the queue
start = index.starts[i];
end = index.starts[i + 1];
for (; start < end; start++) {
j = index.neighborhood[start];
targetCommunity = index.belongings[j];
if (targetCommunity !== bestCommunity) queue.enqueue(j);
}
}
moves.push(currentMoves);
if (currentMoves === 0) {
index.zoomOut();
break;
}
if (!addenda.onlySingletons()) {
// We continue working on the induced graph
addenda.zoomOut();
continue;
}
break;
}
var results = {
index: index,
deltaComputations: deltaComputations,
nodesVisited: nodesVisited,
moves: moves
};
return results;
}
/**
* Function returning the communities mapping of the graph.
*
* @param {boolean} assign - Assign communities to nodes attributes?
* @param {boolean} detailed - Whether to return detailed information.
* @param {Graph} graph - Target graph.
* @param {object} options - Options:
* @param {object} attributes - Attribute names:
* @param {string} community - Community node attribute name.
* @param {string} weight - Weight edge attribute name.
* @param {number} randomness - Randomness parameter.
* @param {boolean} randomWalk - Whether to traverse the graph in random order.
* @param {number} resolution - Resolution parameter.
* @param {function} rng - RNG function to use.
* @param {boolean} weighted - Whether to compute the weighted version.
* @return {object}
*/
function leiden(assign, detailed, graph, options) {
if (!isGraph(graph))
throw new Error(
'graphology-communities-leiden: the given graph is not a valid graphology instance.'
);
var type = inferType(graph);
if (type === 'mixed')
throw new Error(
'graphology-communities-leiden: cannot run the algorithm on a true mixed graph.'
);
if (type === 'directed')
throw new Error(
'graphology-communities-leiden: not yet implemented for directed graphs.'
);
// Attributes name
options = resolveDefaults(options, DEFAULTS);
// Empty graph case
var c = 0;
if (graph.size === 0) {
if (assign) {
graph.forEachNode(function (node) {
graph.setNodeAttribute(node, options.attributes.communities, c++);
});
return;
}
var communities = {};
graph.forEachNode(function (node) {
communities[node] = c++;
});
if (!detailed) return communities;
return {
communities: communities,
count: graph.order,
deltaComputations: 0,
dendrogram: null,
level: 0,
modularity: NaN,
moves: null,
nodesVisited: 0,
resolution: options.resolution
};
}
var fn = undirectedLeiden;
var results = fn(detailed, graph, options);
var index = results.index;
// Standard output
if (!detailed) {
if (assign) {
index.assign(options.attributes.community);
return;
}
return index.collect();
}
// Detailed output
var output = {
count: index.C,
deltaComputations: results.deltaComputations,
dendrogram: index.dendrogram,
level: index.level,
modularity: index.modularity(),
moves: results.moves,
nodesVisited: results.nodesVisited,
resolution: options.resolution
};
if (assign) {
index.assign(options.attributes.community);
return output;
}
output.communities = index.collect();
return output;
}
/**
* Exporting.
*/
var fn = leiden.bind(null, false, false);
fn.assign = leiden.bind(null, true, false);
fn.detailed = leiden.bind(null, false, true);
fn.defaults = DEFAULTS;
module.exports = fn;

392
gitnexus/vendor/leiden/utils.cjs vendored Normal file
View file

@ -0,0 +1,392 @@
/**
* Graphology Leiden Utils
* ========================
*
* Miscellaneous utilities used by the Leiden algorithm.
*
* Vendored from: https://github.com/graphology/graphology/tree/master/src/communities-leiden
* License: MIT
*/
var SparseMap = require('mnemonist/sparse-map');
var createRandom = require('pandemonium/random').createRandom;
function addWeightToCommunity(map, community, weight) {
var currentWeight = map.get(community);
if (typeof currentWeight === 'undefined') currentWeight = 0;
currentWeight += weight;
map.set(community, currentWeight);
}
function UndirectedLeidenAddenda(index, options) {
options = options || {};
var rng = options.rng || Math.random;
var randomness = 'randomness' in options ? options.randomness : 0.01;
this.index = index;
this.random = createRandom(rng);
this.randomness = randomness;
this.rng = rng;
var NodesPointerArray = index.counts.constructor;
var WeightsArray = index.weights.constructor;
var order = index.C;
this.resolution = index.resolution;
// Used to group nodes by communities
this.B = index.C;
this.C = 0;
this.communitiesOffsets = new NodesPointerArray(order);
this.nodesSortedByCommunities = new NodesPointerArray(order);
this.communitiesBounds = new NodesPointerArray(order + 1);
// Used to merge nodes subsets
this.communityWeights = new WeightsArray(order);
this.degrees = new WeightsArray(order);
this.nonSingleton = new Uint8Array(order);
this.externalEdgeWeightPerCommunity = new WeightsArray(order);
this.belongings = new NodesPointerArray(order);
this.neighboringCommunities = new SparseMap(WeightsArray, order);
this.cumulativeIncrement = new Float64Array(order);
this.macroCommunities = null;
}
UndirectedLeidenAddenda.prototype.groupByCommunities = function () {
var index = this.index;
var n, i, c, b, o;
n = 0;
o = 0;
for (i = 0; i < index.C; i++) {
c = index.counts[i];
if (c !== 0) {
this.communitiesBounds[o++] = n;
n += c;
this.communitiesOffsets[i] = n;
}
}
this.communitiesBounds[o] = n;
o = 0;
for (i = 0; i < index.C; i++) {
b = index.belongings[i];
o = --this.communitiesOffsets[b];
this.nodesSortedByCommunities[o] = i;
}
this.B = index.C - index.U;
this.C = index.C;
};
UndirectedLeidenAddenda.prototype.communities = function () {
var communities = new Array(this.B);
var i, j, community, start, stop;
for (i = 0; i < this.B; i++) {
start = this.communitiesBounds[i];
stop = this.communitiesBounds[i + 1];
community = [];
for (j = start; j < stop; j++) {
community.push(j);
}
communities[i] = community;
}
return communities;
};
UndirectedLeidenAddenda.prototype.mergeNodesSubset = function (start, stop) {
var index = this.index;
var currentMacroCommunity =
index.belongings[this.nodesSortedByCommunities[start]];
var neighboringCommunities = this.neighboringCommunities;
var totalNodeWeight = 0;
var i, j, w;
var ei, el, et;
// Initializing singletons
for (j = start; j < stop; j++) {
i = this.nodesSortedByCommunities[j];
this.belongings[i] = i;
this.nonSingleton[i] = 0;
this.degrees[i] = 0;
totalNodeWeight += index.loops[i] / 2;
this.communityWeights[i] = index.loops[i];
this.externalEdgeWeightPerCommunity[i] = 0;
ei = index.starts[i];
el = index.starts[i + 1];
for (; ei < el; ei++) {
et = index.neighborhood[ei];
w = index.weights[ei];
this.degrees[i] += w;
if (index.belongings[et] !== currentMacroCommunity) continue;
totalNodeWeight += w;
this.externalEdgeWeightPerCommunity[i] += w;
this.communityWeights[i] += w;
}
}
var microDegrees = this.externalEdgeWeightPerCommunity.slice();
var s, ri, ci;
var order = stop - start;
var degree,
bestCommunity,
qualityValueIncrement,
maxQualityValueIncrement,
totalTransformedQualityValueIncrement,
targetCommunity,
targetCommunityDegree,
targetCommunityWeight;
var r, lo, hi, mid, chosenCommunity;
ri = this.random(start, stop - 1);
for (s = start; s < stop; s++, ri++) {
j = start + (ri % order);
i = this.nodesSortedByCommunities[j];
if (this.nonSingleton[i] === 1) {
continue;
}
if (
this.externalEdgeWeightPerCommunity[i] <
this.communityWeights[i] *
(totalNodeWeight / 2 - this.communityWeights[i]) *
this.resolution
) {
continue;
}
this.communityWeights[i] = 0;
this.externalEdgeWeightPerCommunity[i] = 0;
neighboringCommunities.clear();
neighboringCommunities.set(i, 0);
degree = 0;
ei = index.starts[i];
el = index.starts[i + 1];
for (; ei < el; ei++) {
et = index.neighborhood[ei];
if (index.belongings[et] !== currentMacroCommunity) continue;
w = index.weights[ei];
degree += w;
addWeightToCommunity(neighboringCommunities, this.belongings[et], w);
}
bestCommunity = i;
maxQualityValueIncrement = 0;
totalTransformedQualityValueIncrement = 0;
for (ci = 0; ci < neighboringCommunities.size; ci++) {
targetCommunity = neighboringCommunities.dense[ci];
targetCommunityDegree = neighboringCommunities.vals[ci];
targetCommunityWeight = this.communityWeights[targetCommunity];
if (
this.externalEdgeWeightPerCommunity[targetCommunity] >=
targetCommunityWeight *
(totalNodeWeight / 2 - targetCommunityWeight) *
this.resolution
) {
qualityValueIncrement =
targetCommunityDegree -
((degree + index.loops[i]) *
targetCommunityWeight *
this.resolution) /
totalNodeWeight;
if (qualityValueIncrement > maxQualityValueIncrement) {
bestCommunity = targetCommunity;
maxQualityValueIncrement = qualityValueIncrement;
}
if (qualityValueIncrement >= 0)
totalTransformedQualityValueIncrement += Math.exp(
qualityValueIncrement / this.randomness
);
}
this.cumulativeIncrement[ci] = totalTransformedQualityValueIncrement;
}
if (
totalTransformedQualityValueIncrement < Number.MAX_VALUE &&
totalTransformedQualityValueIncrement < Infinity
) {
r = totalTransformedQualityValueIncrement * this.rng();
lo = -1;
hi = neighboringCommunities.size + 1;
while (lo < hi - 1) {
mid = (lo + hi) >>> 1;
if (this.cumulativeIncrement[mid] >= r) hi = mid;
else lo = mid;
}
chosenCommunity = neighboringCommunities.dense[hi];
} else {
chosenCommunity = bestCommunity;
}
this.communityWeights[chosenCommunity] += degree + index.loops[i];
ei = index.starts[i];
el = index.starts[i + 1];
for (; ei < el; ei++) {
et = index.neighborhood[ei];
if (index.belongings[et] !== currentMacroCommunity) continue;
targetCommunity = this.belongings[et];
if (targetCommunity === chosenCommunity) {
this.externalEdgeWeightPerCommunity[chosenCommunity] -=
microDegrees[et];
} else {
this.externalEdgeWeightPerCommunity[chosenCommunity] +=
microDegrees[et];
}
}
if (chosenCommunity !== i) {
this.belongings[i] = chosenCommunity;
this.nonSingleton[chosenCommunity] = 1;
this.C--;
}
}
var microCommunities = this.neighboringCommunities;
microCommunities.clear();
for (j = start; j < stop; j++) {
i = this.nodesSortedByCommunities[j];
microCommunities.set(this.belongings[i], 1);
}
return microCommunities.dense.slice(0, microCommunities.size);
};
UndirectedLeidenAddenda.prototype.refinePartition = function () {
this.groupByCommunities();
this.macroCommunities = new Array(this.B);
var i, start, stop, mapping;
var bounds = this.communitiesBounds;
for (i = 0; i < this.B; i++) {
start = bounds[i];
stop = bounds[i + 1];
mapping = this.mergeNodesSubset(start, stop);
this.macroCommunities[i] = mapping;
}
};
UndirectedLeidenAddenda.prototype.split = function () {
var index = this.index;
var isolates = this.neighboringCommunities;
isolates.clear();
var i, community, isolated;
for (i = 0; i < index.C; i++) {
community = this.belongings[i];
if (i !== community) continue;
isolated = index.isolate(i, this.degrees[i]);
isolates.set(community, isolated);
}
for (i = 0; i < index.C; i++) {
community = this.belongings[i];
if (i === community) continue;
isolated = isolates.get(community);
index.move(i, this.degrees[i], isolated);
}
var j, macro;
for (i = 0; i < this.macroCommunities.length; i++) {
macro = this.macroCommunities[i];
for (j = 0; j < macro.length; j++) macro[j] = isolates.get(macro[j]);
}
};
UndirectedLeidenAddenda.prototype.zoomOut = function () {
var index = this.index;
this.refinePartition();
this.split();
var newLabels = index.zoomOut();
var macro, leader, follower;
var i, j;
for (i = 0; i < this.macroCommunities.length; i++) {
macro = this.macroCommunities[i];
leader = newLabels[macro[0]];
for (j = 1; j < macro.length; j++) {
follower = newLabels[macro[j]];
index.expensiveMove(follower, leader);
}
}
};
UndirectedLeidenAddenda.prototype.onlySingletons = function () {
var index = this.index;
var i;
for (i = 0; i < index.C; i++) {
if (index.counts[i] > 1) return false;
}
return true;
};
exports.addWeightToCommunity = addWeightToCommunity;
exports.UndirectedLeidenAddenda = UndirectedLeidenAddenda;