UI minor fixes

This commit is contained in:
abhigyanpatwari 2026-01-18 06:27:00 +05:30
parent 2e59a894c1
commit 88ecbf5eb4
17 changed files with 705 additions and 233 deletions

View file

@ -41,12 +41,12 @@ const AppContent = () => {
const projectName = file.name.replace('.zip', '');
setProjectName(projectName);
setViewMode('loading');
try {
const result = await runPipeline(file, (progress) => {
setProgress(progress);
});
setGraph(result.graph);
setFileContents(result.fileContents);
setViewMode('exploring');
@ -54,9 +54,9 @@ const AppContent = () => {
// Initialize (or re-initialize) the agent AFTER a repo loads so it captures
// the current codebase context (file contents + graph tools) in the worker.
if (getActiveProviderConfig()) {
initializeAgent();
initializeAgent(projectName);
}
// Auto-start embeddings pipeline in background
// Uses WebGPU if available, falls back to WASM
startEmbeddings().catch((err) => {
@ -86,15 +86,15 @@ const AppContent = () => {
// Extract project name from first file path (e.g., "owner-repo-123/src/..." -> "owner-repo")
const firstPath = files[0]?.path || 'repository';
const projectName = firstPath.split('/')[0].replace(/-\d+$/, '') || 'repository';
setProjectName(projectName);
setViewMode('loading');
try {
const result = await runPipelineFromFiles(files, (progress) => {
setProgress(progress);
});
setGraph(result.graph);
setFileContents(result.fileContents);
setViewMode('exploring');
@ -102,9 +102,9 @@ const AppContent = () => {
// Initialize (or re-initialize) the agent AFTER a repo loads so it captures
// the current codebase context (file contents + graph tools) in the worker.
if (getActiveProviderConfig()) {
initializeAgent();
initializeAgent(projectName);
}
// Auto-start embeddings pipeline in background
// Uses WebGPU if available, falls back to WASM
startEmbeddings().catch((err) => {
@ -154,11 +154,11 @@ const AppContent = () => {
return (
<div className="flex flex-col h-screen bg-void overflow-hidden">
<Header onFocusNode={handleFocusNode} />
<main className="flex-1 flex min-h-0">
{/* Left Panel - File Tree */}
<FileTreePanel onFocusNode={handleFocusNode} />
{/* Graph area - takes remaining space */}
<div className="flex-1 relative min-w-0">
<GraphCanvas ref={graphCanvasRef} />
@ -170,13 +170,13 @@ const AppContent = () => {
</div>
)}
</div>
{/* Right Panel - Code & Chat (tabbed) */}
{isRightPanelOpen && <RightPanel />}
</main>
<StatusBar />
{/* Settings Panel (modal) */}
<SettingsPanel
isOpen={isSettingsPanelOpen}

View file

@ -5,7 +5,7 @@
* Shows connection status and setup instructions if bridge not found.
*/
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Copy, Check, AlertCircle, X, Sparkles } from 'lucide-react';
import { getMCPClient, type CodebaseContext } from '../core/mcp/mcp-client';
@ -45,6 +45,8 @@ export function MCPToggle({
const [status, setStatus] = useState<ConnectionState>('disconnected');
const [copied, setCopied] = useState(false);
const [showSetup, setShowSetup] = useState(false);
const [hasConnectionError, setHasConnectionError] = useState(false);
const setupPopupRef = useRef<HTMLDivElement | null>(null);
const [showTip, setShowTip] = useState(false);
const isConnected = status === 'connected';
@ -62,6 +64,21 @@ export function MCPToggle({
}
}, [showOnboardingTip]);
// Close setup popup when clicking outside
useEffect(() => {
if (!showSetup) return;
const handleClickOutside = (event: MouseEvent) => {
if (!setupPopupRef.current) return;
if (!setupPopupRef.current.contains(event.target as Node)) {
setShowSetup(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showSetup]);
const dismissTip = () => {
setShowTip(false);
localStorage.setItem(MCP_TIP_DISMISSED_KEY, 'true');
@ -71,6 +88,7 @@ export function MCPToggle({
const client = getMCPClient();
setStatus('connecting');
setShowTip(false);
setHasConnectionError(false);
try {
await client.connect();
@ -125,6 +143,7 @@ export function MCPToggle({
setStatus('connected');
setShowSetup(false);
setHasConnectionError(false);
localStorage.setItem(MCP_TIP_DISMISSED_KEY, 'true');
// Send codebase context after connecting
@ -141,6 +160,7 @@ export function MCPToggle({
} catch {
setStatus('error');
setShowSetup(true);
setHasConnectionError(true);
}
}, [onSearch, onCypher, onBlastRadius, onHighlight, onGrep, onRead, getContext]);
@ -148,6 +168,7 @@ export function MCPToggle({
const client = getMCPClient();
client.disconnect();
setStatus('disconnected');
setHasConnectionError(false);
}, []);
const toggle = useCallback(() => {
@ -158,8 +179,8 @@ export function MCPToggle({
}
}, [isConnected, isConnecting, connect, disconnect]);
const copyCommand = () => {
navigator.clipboard.writeText('npx gitnexus-mcp setup');
const copyCommand = (command: string) => {
navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
@ -168,13 +189,19 @@ export function MCPToggle({
useEffect(() => {
const client = getMCPClient();
const unsubscribe = client.onConnectionChange((connected) => {
setStatus(connected ? 'connected' : 'disconnected');
if (connected) {
setStatus('connected');
setShowSetup(false);
setHasConnectionError(false);
} else {
setStatus('disconnected');
}
});
return () => { unsubscribe(); };
}, []);
// Error state - show setup popup
if (status === 'error' && showSetup) {
if (hasConnectionError && showSetup) {
return (
<div className="relative flex items-center gap-2">
<span className="text-xs text-text-primary font-medium">MCP</span>
@ -183,30 +210,74 @@ export function MCPToggle({
className="flex items-center gap-1.5 px-2 py-1 text-xs text-amber-400 hover:bg-amber-400/10 rounded transition-colors"
>
<AlertCircle className="w-3.5 h-3.5" />
<span>Setup</span>
<span>Daemon</span>
</button>
<div className="absolute top-full right-0 mt-2 p-4 bg-surface border border-border rounded-lg shadow-xl z-50 w-80">
<p className="text-sm text-text-secondary mb-3">
Run this command to enable external AI agents:
<div
ref={setupPopupRef}
className="absolute top-full right-0 mt-2 p-5 bg-surface border border-border rounded-lg shadow-xl z-50 w-[28rem]"
>
<p className="text-sm text-text-primary mb-4">
MCP daemon isn't running. Follow the steps below:
</p>
<div className="flex items-center gap-2 bg-background p-2 rounded font-mono text-sm">
<code className="flex-1 text-green-400">npx gitnexus-mcp setup</code>
<button
onClick={copyCommand}
className="p-1 hover:bg-surface-hover rounded"
title="Copy command"
>
{copied ? (
<Check className="w-4 h-4 text-green-400" />
) : (
<Copy className="w-4 h-4 text-text-secondary" />
)}
</button>
<div className="mb-3">
<p className="text-sm text-text-secondary mb-2">1) Install/configure your IDE:</p>
<div className="flex items-center gap-2 bg-background/60 border border-border/60 px-3 py-2 rounded font-mono text-sm">
<code className="flex-1 text-text-primary">npx gitnexus-mcp setup</code>
<button
onClick={() => copyCommand('npx gitnexus-mcp setup')}
className="p-1.5 hover:bg-surface-hover rounded"
title="Copy command"
>
{copied ? (
<Check className="w-4 h-4 text-green-400" />
) : (
<Copy className="w-4 h-4 text-text-secondary" />
)}
</button>
</div>
</div>
<div className="mb-3">
<p className="text-sm text-text-secondary mb-2">2) Start the daemon (recommended):</p>
<div className="flex items-center gap-2 bg-background/60 border border-border/60 px-3 py-2 rounded font-mono text-sm">
<code className="flex-1 text-text-primary">npx gitnexus-mcp daemon</code>
<button
onClick={() => copyCommand('npx gitnexus-mcp daemon')}
className="p-1.5 hover:bg-surface-hover rounded"
title="Copy command"
>
{copied ? (
<Check className="w-4 h-4 text-green-400" />
) : (
<Copy className="w-4 h-4 text-text-secondary" />
)}
</button>
</div>
</div>
<div className="mb-3">
<p className="text-sm text-text-secondary mb-2">Dev mode (from `gitnexus-mcp` dir):</p>
<div className="flex items-center gap-2 bg-background/60 border border-border/60 px-3 py-2 rounded font-mono text-sm">
<code className="flex-1 text-text-primary">npm run build &amp;&amp; node dist/cli.js daemon</code>
<button
onClick={() => copyCommand('npm run build && node dist/cli.js daemon')}
className="p-1.5 hover:bg-surface-hover rounded"
title="Copy command"
>
{copied ? (
<Check className="w-4 h-4 text-green-400" />
) : (
<Copy className="w-4 h-4 text-text-secondary" />
)}
</button>
</div>
</div>
<button
onClick={() => { setShowSetup(false); connect(); }}
className="mt-3 w-full px-3 py-1.5 text-sm bg-green-500/20 text-green-400 rounded hover:bg-green-500/30 transition-colors"
className="mt-2 w-full px-3 py-1.5 text-sm bg-green-500/20 text-green-400 rounded hover:bg-green-500/30 transition-colors"
>
Retry Connection
</button>
@ -282,7 +353,7 @@ export function MCPToggle({
<div className="flex items-center gap-2 bg-background p-2 rounded font-mono text-sm">
<code className="flex-1 text-green-400">npx gitnexus-mcp setup</code>
<button
onClick={copyCommand}
onClick={() => copyCommand('npx gitnexus-mcp setup')}
className="p-1 hover:bg-surface-hover rounded"
title="Copy command"
>

View file

@ -48,6 +48,10 @@ export interface GraphRelationship {
sourceId: string,
targetId: string,
type: RelationshipType,
/** Confidence score 0-1 (1.0 = certain, lower = uncertain resolution) */
confidence: number,
/** Resolution reason: 'import-resolved', 'same-file', 'fuzzy-global', or empty for non-CALLS */
reason: string,
}
export interface KnowledgeGraph {

View file

@ -71,7 +71,8 @@ const findEnclosingFunction = (
if (nodeId) return nodeId;
// Fallback: generate ID based on name and file
return generateId('Function', `${filePath}:${funcName}`);
const fallbackLabel = current.type === 'method_definition' ? 'Method' : 'Function';
return generateId(fallbackLabel, `${filePath}:${funcName}`);
}
// Couldn't determine function name - try parent (might be nested)
@ -143,15 +144,15 @@ export const processCalls = async (
// Skip common built-ins and noise
if (isBuiltInOrNoise(calledName)) return;
// 4. Resolve the target using priority strategy
const targetNodeId = resolveCallTarget(
// 4. Resolve the target using priority strategy (returns confidence)
const resolved = resolveCallTarget(
calledName,
file.path,
symbolTable,
importMap
);
if (!targetNodeId) return;
if (!resolved) return;
// 5. Find the enclosing function (caller)
const callNode = captureMap['call'];
@ -160,13 +161,15 @@ export const processCalls = async (
// Use enclosing function as source, fallback to file for top-level calls
const sourceId = enclosingFuncId || generateId('File', file.path);
const relId = generateId('CALLS', `${sourceId}:${calledName}->${targetNodeId}`);
const relId = generateId('CALLS', `${sourceId}:${calledName}->${resolved.nodeId}`);
graph.addRelationship({
id: relId,
sourceId,
targetId: targetNodeId,
type: 'CALLS'
targetId: resolved.nodeId,
type: 'CALLS',
confidence: resolved.confidence,
reason: resolved.reason,
});
});
@ -177,35 +180,52 @@ export const processCalls = async (
}
};
/**
* Resolution result with confidence scoring
*/
interface ResolveResult {
nodeId: string;
confidence: number; // 0-1: how sure are we?
reason: string; // 'import-resolved' | 'same-file' | 'fuzzy-global'
}
/**
* Resolve a function call to its target node ID using priority strategy:
* A. Check imported files first (highest confidence)
* B. Check local file definitions
* C. Fuzzy global search (lowest confidence)
*
* Returns confidence score so agents know what to trust.
*/
const resolveCallTarget = (
calledName: string,
currentFile: string,
symbolTable: SymbolTable,
importMap: ImportMap
): string | null => {
// Strategy A: Check imported files
): ResolveResult | null => {
// Strategy A: Check imported files (HIGH confidence - we know the import chain)
const importedFiles = importMap.get(currentFile);
if (importedFiles) {
for (const importedFile of importedFiles) {
const nodeId = symbolTable.lookupExact(importedFile, calledName);
if (nodeId) return nodeId;
if (nodeId) {
return { nodeId, confidence: 0.9, reason: 'import-resolved' };
}
}
}
// Strategy B: Check local file (same file definition)
// Strategy B: Check local file (HIGH confidence - same file definition)
const localNodeId = symbolTable.lookupExact(currentFile, calledName);
if (localNodeId) return localNodeId;
if (localNodeId) {
return { nodeId: localNodeId, confidence: 0.85, reason: 'same-file' };
}
// Strategy C: Fuzzy global search (pick first match)
// Strategy C: Fuzzy global search (LOW confidence - just matching by name)
const fuzzyMatches = symbolTable.lookupFuzzy(calledName);
if (fuzzyMatches.length > 0) {
return fuzzyMatches[0].nodeId;
// Lower confidence if multiple matches exist (more ambiguous)
const confidence = fuzzyMatches.length === 1 ? 0.5 : 0.3;
return { nodeId: fuzzyMatches[0].nodeId, confidence, reason: 'fuzzy-global' };
}
return null;

View file

@ -84,7 +84,9 @@ export const processHeritage = async (
id: relId,
sourceId: childId,
targetId: parentId,
type: 'EXTENDS'
type: 'EXTENDS',
confidence: 1.0,
reason: '',
});
}
}
@ -109,7 +111,9 @@ export const processHeritage = async (
id: relId,
sourceId: classId,
targetId: interfaceId,
type: 'IMPLEMENTS'
type: 'IMPLEMENTS',
confidence: 1.0,
reason: '',
});
}
}

View file

@ -125,7 +125,9 @@ export const processImports = async (
id: relId,
sourceId,
targetId,
type: 'IMPORTS'
type: 'IMPORTS',
confidence: 1.0,
reason: '',
});
// B. Update Import Map (For Pass 4)

View file

@ -113,7 +113,9 @@ export const processParsing = async (
id: relId,
sourceId: fileId,
targetId: nodeId,
type: 'DEFINES'
type: 'DEFINES',
confidence: 1.0,
reason: '',
};
graph.addRelationship(relationship);

View file

@ -32,7 +32,9 @@ export const processStructure = ( graph: KnowledgeGraph, paths: string[])=>{
id: relId,
type: 'CONTAINS',
sourceId: parentId,
targetId: nodeId
targetId: nodeId,
confidence: 1.0,
reason: '',
}
graph.addRelationship(relationship)

View file

@ -119,7 +119,7 @@ const extractContent = (
export interface CSVData {
nodes: Map<NodeTableName, string>;
relCSV: string; // Single relation CSV with from,to,type columns
relCSV: string; // Single relation CSV with from,to,type,confidence,reason columns
}
// ============================================================================
@ -202,10 +202,13 @@ const generateCodeElementCSV = (
/**
* Generate CSV for the single CodeRelation table
* Headers: from,to,type
* Headers: from,to,type,confidence,reason
*
* confidence: 0-1 score for CALLS edges (how sure are we about the target?)
* reason: 'import-resolved' | 'same-file' | 'fuzzy-global' (or empty for non-CALLS)
*/
const generateRelationCSV = (graph: KnowledgeGraph): string => {
const headers = ['from', 'to', 'type'];
const headers = ['from', 'to', 'type', 'confidence', 'reason'];
const rows: string[] = [headers.join(',')];
for (const rel of graph.relationships) {
@ -213,6 +216,8 @@ const generateRelationCSV = (graph: KnowledgeGraph): string => {
escapeCSVField(rel.sourceId),
escapeCSVField(rel.targetId),
escapeCSVField(rel.type),
escapeCSVNumber(rel.confidence, 1.0),
escapeCSVField(rel.reason),
].join(','));
}

View file

@ -113,35 +113,56 @@ 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"
// Parse CSV format: "from","to","type",confidence,"reason"
let insertedRels = 0;
let skippedRels = 0;
const skippedRelStats = new Map<string, number>();
for (const line of relLines) {
try {
// Parse CSV - handle quoted fields
const match = line.match(/"([^"]*)","([^"]*)","([^"]*)"/);
// Parse CSV - handle quoted fields and numeric confidence
// Format: "from","to","type",confidence,"reason"
const match = line.match(/"([^"]*)","([^"]*)","([^"]*)",([0-9.]+),"([^"]*)"/);
if (!match) continue;
const [, fromId, toId, relType] = match;
const [, fromId, toId, relType, confidenceStr, reason] = match;
const confidence = parseFloat(confidenceStr) || 1.0;
// Extract labels from node IDs (format: Label:path:name)
const fromLabel = fromId.split(':')[0];
const toLabel = toId.split(':')[0];
// INSERT with explicit node matching
// INSERT with explicit node matching (including confidence and reason)
const insertQuery = `
MATCH (a:${fromLabel} {id: '${fromId.replace(/'/g, "''")}'})
MATCH (b:${toLabel} {id: '${toId.replace(/'/g, "''")}'})
CREATE (a)-[:${REL_TABLE_NAME} {type: '${relType}'}]->(b)
CREATE (a)-[:${REL_TABLE_NAME} {type: '${relType}', confidence: ${confidence}, reason: '${reason.replace(/'/g, "''")}'}]->(b)
`;
await conn.query(insertQuery);
insertedRels++;
} catch {
// Skip failed insertions (nodes might not exist)
// Skip failed insertions (nodes might not exist, or relation pair not allowed by schema)
skippedRels++;
if (import.meta.env.DEV) {
const match = line.match(/"([^"]*)","([^"]*)","([^"]*)",([0-9.]+),"([^"]*)"/);
if (match) {
const [, fromId, toId, relType] = match;
const fromLabel = fromId.split(':')[0];
const toLabel = toId.split(':')[0];
const key = `${relType}:${fromLabel}->${toLabel}`;
skippedRelStats.set(key, (skippedRelStats.get(key) || 0) + 1);
}
}
}
}
if (import.meta.env.DEV) {
console.log(`KuzuDB: Inserted ${insertedRels}/${relCount} relations`);
if (skippedRels > 0) {
const topSkipped = Array.from(skippedRelStats.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
console.warn(`KuzuDB: Skipped ${skippedRels}/${relCount} relations (top by kind/pair):`, topSkipped);
}
}
// 6. Verify results
@ -189,6 +210,7 @@ const getCopyQuery = (table: NodeTableName, path: string): string => {
/**
* Execute a Cypher query against the database
* Returns results as named objects (not tuples) for better usability
*/
export const executeQuery = async (cypher: string): Promise<any[]> => {
if (!conn) {
@ -198,11 +220,45 @@ export const executeQuery = async (cypher: string): Promise<any[]> => {
try {
const result = await conn.query(cypher);
// Extract column names from RETURN clause
const returnMatch = cypher.match(/RETURN\s+(.+?)(?:\s+ORDER|\s+LIMIT|\s+SKIP|\s*$)/is);
let columnNames: string[] = [];
if (returnMatch) {
// Parse RETURN clause to get column names/aliases
// Handles: "a.name, b.filePath AS path, count(x) AS cnt"
const returnClause = returnMatch[1];
columnNames = returnClause.split(',').map(col => {
col = col.trim();
// Check for AS alias
const asMatch = col.match(/\s+AS\s+(\w+)\s*$/i);
if (asMatch) return asMatch[1];
// Check for property access like n.name
const propMatch = col.match(/\.(\w+)\s*$/);
if (propMatch) return propMatch[1];
// Check for function call like count(x)
const funcMatch = col.match(/^(\w+)\s*\(/);
if (funcMatch) return funcMatch[1];
// Just use as-is if simple identifier
return col.replace(/[^a-zA-Z0-9_]/g, '_');
});
}
// Collect all rows
const rows: any[] = [];
while (await result.hasNext()) {
const row = await result.getNext();
rows.push(row);
// Convert tuple to named object if we have column names and row is array
if (Array.isArray(row) && columnNames.length === row.length) {
const namedRow: Record<string, any> = {};
for (let i = 0; i < row.length; i++) {
namedRow[columnNames[i]] = row[i];
}
rows.push(namedRow);
} else {
// Already an object or column count doesn't match
rows.push(row);
}
}
return rows;

View file

@ -123,11 +123,17 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM Folder TO File,
FROM Function TO Function,
FROM Function TO Method,
FROM Function TO Class,
FROM Class TO Method,
FROM Class TO Function,
FROM Class TO Class,
FROM Class TO Interface,
type STRING
FROM Method TO Function,
FROM Method TO Method,
FROM Method TO Class,
type STRING,
confidence DOUBLE,
reason STRING
)`;
// ============================================================================

View file

@ -232,6 +232,11 @@ export const createGraphRAGAgent = (
? buildDynamicSystemPrompt(BASE_SYSTEM_PROMPT, codebaseContext)
: BASE_SYSTEM_PROMPT;
// Log the full prompt for debugging
if (import.meta.env.DEV) {
console.log('🤖 AGENT SYSTEM PROMPT:\n', systemPrompt);
}
const agent = createReactAgent({
llm: model as any,
tools: tools as any,

View file

@ -145,11 +145,11 @@ export async function getHotspots(
/**
* Build folder tree structure from file paths
* Returns TOON format for token efficiency (~50% reduction vs ASCII tree)
* Returns ASCII tree format with smart truncation for readability
*/
export async function getFolderTree(
executeQuery: (cypher: string) => Promise<any[]>,
maxDepth: number = 3
maxDepth: number = 10
): Promise<string> {
try {
// Get all file paths
@ -163,8 +163,8 @@ export async function getFolderTree(
if (paths.length === 0) return '';
// Use TOON format for token efficiency
return formatPathsAsTOON(paths, maxDepth);
// Use hybrid ASCII format: clear hierarchy with smart truncation
return formatAsHybridAscii(paths, maxDepth);
} catch (error) {
console.error('Failed to get folder tree:', error);
return '';
@ -172,75 +172,88 @@ export async function getFolderTree(
}
/**
* Format paths as TOON (Token-Oriented Object Notation)
* Much more token-efficient than ASCII trees
* Format paths as indented tree (TOON-style, no ASCII box chars)
* Uses indentation only for hierarchy - more token efficient than ASCII tree
* Shows complete structure with no truncation
*
* Example output:
* folders:5
* src,src/cli,src/core,src/utils,test
* files:8
* path
* src/index.ts
* src/cli/main.ts
* ...
* src/
* components/ (45 files)
* hooks/
* useAppState.tsx
* useSigma.ts
* core/ (15 files)
* test/ (12 files)
*/
function formatPathsAsTOON(paths: string[], maxDepth: number): string {
const folders = new Set<string>();
const files: string[] = [];
function formatAsHybridAscii(paths: string[], maxDepth: number): string {
// Build tree structure
interface TreeNode {
isFile: boolean;
children: Map<string, TreeNode>;
fileCount: number;
}
const root: TreeNode = { isFile: false, children: new Map(), fileCount: 0 };
for (const path of paths) {
// Normalize path
const normalized = path.replace(/\\/g, '/');
const parts = normalized.split('/').filter(Boolean);
// Add file (truncate to maxDepth)
if (parts.length <= maxDepth + 1) {
files.push(normalized);
} else {
// Show truncated path for deep files
files.push(parts.slice(0, maxDepth + 1).join('/') + '/...');
}
// Add folders
for (let i = 0; i < Math.min(parts.length - 1, maxDepth); i++) {
const folderPath = parts.slice(0, i + 1).join('/');
folders.add(folderPath);
}
}
// Sort folders and files
const sortedFolders = Array.from(folders).sort();
const sortedFiles = [...new Set(files)].sort();
// Build TOON output
const lines: string[] = [];
// Folders section
lines.push(`folders:${sortedFolders.length}`);
if (sortedFolders.length > 0) {
// Compact comma-separated for small lists
if (sortedFolders.length <= 10) {
lines.push(` ${sortedFolders.join(',')}`);
} else {
// Chunked for larger lists
for (let i = 0; i < sortedFolders.length; i += 8) {
lines.push(` ${sortedFolders.slice(i, i + 8).join(',')}`);
let current = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const isFile = i === parts.length - 1;
if (!current.children.has(part)) {
current.children.set(part, { isFile, children: new Map(), fileCount: 0 });
}
current = current.children.get(part)!;
if (isFile) {
// Count files in parent directories
let parent = root;
for (let j = 0; j < i; j++) {
parent = parent.children.get(parts[j])!;
parent.fileCount++;
}
}
}
}
// Files section
lines.push(`files:${sortedFiles.length}`);
lines.push(' path');
// Show first 30 files, then summarize
const maxFiles = 30;
for (let i = 0; i < Math.min(sortedFiles.length, maxFiles); i++) {
lines.push(` ${sortedFiles[i]}`);
}
if (sortedFiles.length > maxFiles) {
lines.push(` ...(${sortedFiles.length - maxFiles} more)`);
// Render tree with indentation only (no ASCII box chars)
const lines: string[] = [];
function renderNode(node: TreeNode, indent: string, depth: number): void {
const entries = [...node.children.entries()];
// Sort: folders first (by file count desc), then files alphabetically
entries.sort(([aName, aNode], [bName, bNode]) => {
if (aNode.isFile !== bNode.isFile) return aNode.isFile ? 1 : -1;
if (!aNode.isFile && !bNode.isFile) return bNode.fileCount - aNode.fileCount;
return aName.localeCompare(bName);
});
for (const [name, childNode] of entries) {
if (childNode.isFile) {
// File
lines.push(`${indent}${name}`);
} else {
// Directory
const childCount = childNode.children.size;
const fileCount = childNode.fileCount;
// Only collapse if beyond maxDepth
if (depth >= maxDepth) {
lines.push(`${indent}${name}/ (${fileCount} files)`);
} else {
lines.push(`${indent}${name}/`);
renderNode(childNode, indent + ' ', depth + 1);
}
}
}
}
renderNode(root, '', 0);
return lines.join('\n');
}

View file

@ -85,8 +85,8 @@ export const createGraphRAGTools = (
OPTIONAL MATCH (n)-[r1:CodeRelation]->(dst)
OPTIONAL MATCH (src)-[r2:CodeRelation]->(n)
RETURN
collect(DISTINCT {name: dst.name, type: r1.type}) AS outgoing,
collect(DISTINCT {name: src.name, type: r2.type}) AS incoming
collect(DISTINCT {name: dst.name, type: r1.type, confidence: r1.confidence}) AS outgoing,
collect(DISTINCT {name: src.name, type: r2.type, confidence: r2.confidence}) AS incoming
LIMIT 1
`;
const connRes = await executeQuery(connectionsQuery);
@ -97,8 +97,16 @@ export const createGraphRAGTools = (
const rawIncoming = Array.isArray(row) ? row[1] : (row.incoming || []);
const outgoing = (rawOutgoing || []).filter((c: any) => c && c.name).slice(0, 3);
const incoming = (rawIncoming || []).filter((c: any) => c && c.name).slice(0, 3);
const outList = outgoing.map((c: any) => `-[${c.type}]-> ${c.name}`);
const inList = incoming.map((c: any) => `<-[${c.type}]- ${c.name}`);
const fmt = (c: any, dir: 'out' | 'in') => {
const conf = c.confidence ? Math.round(c.confidence * 100) : 100;
return dir === 'out'
? `-[${c.type} ${conf}%]-> ${c.name}`
: `<-[${c.type} ${conf}%]- ${c.name}`;
};
const outList = outgoing.map((c: any) => fmt(c, 'out'));
const inList = incoming.map((c: any) => fmt(c, 'in'));
if (outList.length || inList.length) {
connections = `\n Connections: ${[...outList, ...inList].join(', ')}`;
}
@ -168,18 +176,39 @@ export const createGraphRAGTools = (
return 'Query returned no results.';
}
// Format results
// Get column names from first result (now objects from executeQuery)
const firstRow = results[0];
const columnNames = typeof firstRow === 'object' && !Array.isArray(firstRow)
? Object.keys(firstRow)
: [];
// Format as markdown table (more token efficient than JSON per row)
if (columnNames.length > 0) {
const header = `| ${columnNames.join(' | ')} |`;
const separator = `|${columnNames.map(() => '---').join('|')}|`;
const rows = results.slice(0, 50).map(row => {
const values = columnNames.map(col => {
const val = row[col];
if (val === null || val === undefined) return '';
if (typeof val === 'object') return JSON.stringify(val);
// Truncate long values and escape pipe characters
const str = String(val).replace(/\|/g, '\\|');
return str.length > 60 ? str.slice(0, 57) + '...' : str;
});
return `| ${values.join(' | ')} |`;
}).join('\n');
const truncated = results.length > 50 ? `\n\n_(${results.length - 50} more rows)_` : '';
return `**${results.length} results:**\n\n${header}\n${separator}\n${rows}${truncated}`;
}
// Fallback for non-object results
const formatted = results.slice(0, 50).map((row, i) => {
if (Array.isArray(row)) {
return `[${i + 1}] ${row.join(', ')}`;
}
return `[${i + 1}] ${JSON.stringify(row)}`;
});
const resultText = formatted.join('\n');
const truncated = results.length > 50 ? `\n... (${results.length - 50} more results)` : '';
return `Query returned ${results.length} results:\n${resultText}${truncated}`;
const truncated = results.length > 50 ? `\n... (${results.length - 50} more)` : '';
return `${results.length} results:\n${formatted.join('\n')}${truncated}`;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return `Cypher error: ${message}\n\nCheck your query syntax. Node tables: File, Folder, Function, Class, Interface, Method, CodeElement. Relation: CodeRelation with type property (CONTAINS, DEFINES, IMPORTS, CALLS). Example: MATCH (f:File)-[:CodeRelation {type: 'IMPORTS'}]->(g:File) RETURN f, g`;
@ -197,7 +226,8 @@ Example queries:
- Class inheritance: MATCH (child:Class)-[:CodeRelation {type: 'EXTENDS'}]->(parent:Class) RETURN child.name, parent.name
- Classes implementing interface: MATCH (c:Class)-[:CodeRelation {type: 'IMPLEMENTS'}]->(i:Interface) RETURN c.name, i.name
- Files importing a file: MATCH (f:File)-[:CodeRelation {type: 'IMPORTS'}]->(target:File) WHERE target.name = 'utils.ts' RETURN f.name
- All connections: MATCH (n)-[r:CodeRelation]-(m) WHERE n.name = 'MyClass' RETURN m.name, r.type
- All connections (with confidence): MATCH (n)-[r:CodeRelation]-(m) WHERE n.name = 'MyClass' AND r.confidence > 0.8 RETURN m.name, r.type, r.confidence
- Find fuzzy matches: MATCH (n)-[r:CodeRelation]-(m) WHERE r.confidence < 0.8 RETURN n.name, r.reason
For semantic+graph queries, include {{QUERY_VECTOR}} placeholder and provide a 'query' parameter:
CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', {{QUERY_VECTOR}}, 10) YIELD node AS emb, distance
@ -388,23 +418,43 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
// ============================================================================
const blastRadiusTool = tool(
async ({ target, direction, maxDepth }: {
async ({ target, direction, maxDepth, relationTypes, includeTests, minConfidence }: {
target: string;
direction: 'upstream' | 'downstream';
maxDepth?: number;
relationTypes?: string[];
includeTests?: boolean;
minConfidence?: number;
}) => {
const depth = Math.min(maxDepth ?? 3, 10);
const showTests = includeTests ?? false; // Default: exclude test files
const minConf = minConfidence ?? 0.7; // Default: exclude fuzzy matches (<70% confidence)
// Test file patterns
const isTestFile = (path: string): boolean => {
if (!path) return false;
const p = path.toLowerCase();
return p.includes('.test.') || p.includes('.spec.') ||
p.includes('__tests__') || p.includes('__mocks__') ||
p.endsWith('.test.ts') || p.endsWith('.test.tsx') ||
p.endsWith('.spec.ts') || p.endsWith('.spec.tsx');
};
// Default to usage-based relation types (exclude CONTAINS, DEFINES for impact analysis)
const defaultRelTypes = ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
const activeRelTypes = relationTypes && relationTypes.length > 0
? relationTypes
: defaultRelTypes;
const relTypeFilter = activeRelTypes.map(t => `'${t}'`).join(', ');
// Determine the traversal direction
const directionArrow = direction === 'upstream' ? '<-' : '->';
const directionLabel = direction === 'upstream'
? 'what depends on this (callers, importers, child classes)'
: 'what this depends on (callees, imports, parent classes)';
? 'Files that DEPEND ON this (breakage risk)'
: 'Dependencies this RELIES ON';
// Try to find the target node first
const findTargetQuery = `
MATCH (n)
WHERE n.name = '${target.replace(/'/g, "''")}'
WHERE n.name = '${target.replace(/'/g, "''")}'
RETURN n.id AS id, label(n) AS nodeType, n.filePath AS filePath
LIMIT 5
`;
@ -424,34 +474,214 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
const targetNode = targetResults[0];
const targetId = Array.isArray(targetNode) ? targetNode[0] : targetNode.id;
const targetType = Array.isArray(targetNode) ? targetNode[1] : targetNode.nodeType;
const targetFilePath = Array.isArray(targetNode) ? targetNode[2] : targetNode.filePath;
// Note: KuzuDB doesn't support [r IN relationships(path) | r.type] list comprehension
// So we query each depth level separately for accurate depth tracking
// For File targets, find what calls code INSIDE the file (by filePath)
// For code elements (Function, Class, etc.), use the direct id
const isFileTarget = targetType === 'File';
// Get depth info with separate simpler queries for each depth level
// Query each depth level separately (KuzuDB doesn't support list comprehensions on paths)
// For depth 1: direct connections only
// For depth 2+: chain multiple single-hop queries
const depthQueries: Promise<any[]>[] = [];
for (let d = 1; d <= Math.min(depth, 3); d++) {
const dQuery = direction === 'upstream'
// Depth 1 query - direct connections with edge metadata
// For File targets: find callers of any code element with matching filePath
const d1Query = direction === 'upstream'
? isFileTarget
? `
MATCH (target {id: '${targetId.replace(/'/g, "''")}'})
MATCH (affected)-[:CodeRelation*${d}]->(target)
RETURN DISTINCT affected.id AS id, affected.name AS name, label(affected) AS nodeType, affected.filePath AS filePath, ${d} AS depth
MATCH (affected)-[r:CodeRelation]->(callee)
WHERE callee.filePath = '${(targetFilePath || target).replace(/'/g, "''")}'
AND r.type IN [${relTypeFilter}]
AND affected.filePath <> callee.filePath
AND (r.confidence IS NULL OR r.confidence >= ${minConf})
RETURN DISTINCT
affected.id AS id,
affected.name AS name,
label(affected) AS nodeType,
affected.filePath AS filePath,
affected.startLine AS startLine,
1 AS depth,
r.type AS edgeType,
r.confidence AS confidence,
r.reason AS reason
LIMIT 100
`
: `
MATCH (target {id: '${targetId.replace(/'/g, "''")}'})
MATCH (target)-[:CodeRelation*${d}]->(affected)
RETURN DISTINCT affected.id AS id, affected.name AS name, label(affected) AS nodeType, affected.filePath AS filePath, ${d} AS depth
MATCH (affected)-[r:CodeRelation]->(target)
WHERE r.type IN [${relTypeFilter}]
AND (r.confidence IS NULL OR r.confidence >= ${minConf})
RETURN DISTINCT
affected.id AS id,
affected.name AS name,
label(affected) AS nodeType,
affected.filePath AS filePath,
affected.startLine AS startLine,
1 AS depth,
r.type AS edgeType,
r.confidence AS confidence,
r.reason AS reason
LIMIT 100
`
: isFileTarget
? `
MATCH (caller)-[r:CodeRelation]->(affected)
WHERE caller.filePath = '${(targetFilePath || target).replace(/'/g, "''")}'
AND r.type IN [${relTypeFilter}]
AND caller.filePath <> affected.filePath
AND (r.confidence IS NULL OR r.confidence >= ${minConf})
RETURN DISTINCT
affected.id AS id,
affected.name AS name,
label(affected) AS nodeType,
affected.filePath AS filePath,
affected.startLine AS startLine,
1 AS depth,
r.type AS edgeType,
r.confidence AS confidence,
r.reason AS reason
LIMIT 100
`
: `
MATCH (target {id: '${targetId.replace(/'/g, "''")}'})
MATCH (target)-[r:CodeRelation]->(affected)
WHERE r.type IN [${relTypeFilter}]
AND (r.confidence IS NULL OR r.confidence >= ${minConf})
RETURN DISTINCT
affected.id AS id,
affected.name AS name,
label(affected) AS nodeType,
affected.filePath AS filePath,
affected.startLine AS startLine,
1 AS depth,
r.type AS edgeType,
r.confidence AS confidence,
r.reason AS reason
LIMIT 100
`;
depthQueries.push(executeQuery(dQuery).catch(() => []));
depthQueries.push(executeQuery(d1Query).catch(err => {
if (import.meta.env.DEV) console.warn('Blast radius d=1 query failed:', err);
return [];
}));
// Depth 2 query - 2 hops
if (depth >= 2) {
const d2Query = direction === 'upstream'
? `
MATCH (target {id: '${targetId.replace(/'/g, "''")}'})
MATCH (a)-[r1:CodeRelation]->(target)
MATCH (affected)-[r2:CodeRelation]->(a)
WHERE r1.type IN [${relTypeFilter}] AND r2.type IN [${relTypeFilter}]
AND affected.id <> target.id
AND (r1.confidence IS NULL OR r1.confidence >= ${minConf})
AND (r2.confidence IS NULL OR r2.confidence >= ${minConf})
RETURN DISTINCT
affected.id AS id,
affected.name AS name,
label(affected) AS nodeType,
affected.filePath AS filePath,
affected.startLine AS startLine,
2 AS depth,
r2.type AS edgeType,
r2.confidence AS confidence,
r2.reason AS reason
LIMIT 100
`
: `
MATCH (target {id: '${targetId.replace(/'/g, "''")}'})
MATCH (target)-[r1:CodeRelation]->(a)
MATCH (a)-[r2:CodeRelation]->(affected)
WHERE r1.type IN [${relTypeFilter}] AND r2.type IN [${relTypeFilter}]
AND affected.id <> target.id
AND (r1.confidence IS NULL OR r1.confidence >= ${minConf})
AND (r2.confidence IS NULL OR r2.confidence >= ${minConf})
RETURN DISTINCT
affected.id AS id,
affected.name AS name,
label(affected) AS nodeType,
affected.filePath AS filePath,
affected.startLine AS startLine,
2 AS depth,
r2.type AS edgeType,
r2.confidence AS confidence,
r2.reason AS reason
LIMIT 100
`;
depthQueries.push(executeQuery(d2Query).catch(err => {
if (import.meta.env.DEV) console.warn('Blast radius d=2 query failed:', err);
return [];
}));
}
// Depth 3 query - 3 hops
if (depth >= 3) {
const d3Query = direction === 'upstream'
? `
MATCH (target {id: '${targetId.replace(/'/g, "''")}'})
MATCH (a)-[r1:CodeRelation]->(target)
MATCH (b)-[r2:CodeRelation]->(a)
MATCH (affected)-[r3:CodeRelation]->(b)
WHERE r1.type IN [${relTypeFilter}] AND r2.type IN [${relTypeFilter}] AND r3.type IN [${relTypeFilter}]
AND affected.id <> target.id AND affected.id <> a.id
AND (r1.confidence IS NULL OR r1.confidence >= ${minConf})
AND (r2.confidence IS NULL OR r2.confidence >= ${minConf})
AND (r3.confidence IS NULL OR r3.confidence >= ${minConf})
RETURN DISTINCT
affected.id AS id,
affected.name AS name,
label(affected) AS nodeType,
affected.filePath AS filePath,
affected.startLine AS startLine,
3 AS depth,
r3.type AS edgeType,
r3.confidence AS confidence,
r3.reason AS reason
LIMIT 50
`
: `
MATCH (target {id: '${targetId.replace(/'/g, "''")}'})
MATCH (target)-[r1:CodeRelation]->(a)
MATCH (a)-[r2:CodeRelation]->(b)
MATCH (b)-[r3:CodeRelation]->(affected)
WHERE r1.type IN [${relTypeFilter}] AND r2.type IN [${relTypeFilter}] AND r3.type IN [${relTypeFilter}]
AND affected.id <> target.id AND affected.id <> a.id
AND (r1.confidence IS NULL OR r1.confidence >= ${minConf})
AND (r2.confidence IS NULL OR r2.confidence >= ${minConf})
AND (r3.confidence IS NULL OR r3.confidence >= ${minConf})
RETURN DISTINCT
affected.id AS id,
affected.name AS name,
label(affected) AS nodeType,
affected.filePath AS filePath,
affected.startLine AS startLine,
3 AS depth,
r3.type AS edgeType,
r3.confidence AS confidence,
r3.reason AS reason
LIMIT 50
`;
depthQueries.push(executeQuery(d3Query).catch(err => {
if (import.meta.env.DEV) console.warn('Blast radius d=3 query failed:', err);
return [];
}));
}
// Wait for all depth queries
const depthResults = await Promise.all(depthQueries);
// Combine results by depth
const byDepth: Map<number, any[]> = new Map();
interface NodeInfo {
id: string;
name: string;
nodeType: string;
filePath: string;
startLine?: number;
edgeType: string;
confidence: number;
reason: string;
}
const byDepth: Map<number, NodeInfo[]> = new Map();
const allNodeIds: string[] = [];
const seenIds = new Set<string>();
@ -459,11 +689,27 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
const d = idx + 1;
results.forEach((row: any) => {
const nodeId = Array.isArray(row) ? row[0] : row.id;
const filePath = Array.isArray(row) ? row[3] : row.filePath;
// Skip test files if includeTests is false
if (!showTests && isTestFile(filePath)) return;
// Avoid duplicates (a node might appear at multiple depths)
if (nodeId && !seenIds.has(nodeId)) {
seenIds.add(nodeId);
if (!byDepth.has(d)) byDepth.set(d, []);
byDepth.get(d)!.push(row);
const info: NodeInfo = {
id: nodeId,
name: Array.isArray(row) ? row[1] : row.name,
nodeType: Array.isArray(row) ? row[2] : row.nodeType,
filePath: filePath,
startLine: Array.isArray(row) ? row[4] : row.startLine,
edgeType: Array.isArray(row) ? row[5] : row.edgeType || 'CALLS',
confidence: Array.isArray(row) ? row[6] : row.confidence ?? 1.0,
reason: Array.isArray(row) ? row[7] : row.reason || '',
};
byDepth.get(d)!.push(info);
allNodeIds.push(nodeId);
}
});
@ -472,83 +718,97 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
const totalAffected = allNodeIds.length;
if (totalAffected === 0) {
return `No ${direction} dependencies found for "${target}". This code appears to be ${direction === 'upstream' ? 'unused (not called by anything)' : 'self-contained (no outgoing dependencies)'}.`;
return `No ${direction} dependencies found for "${target}" (types: ${activeRelTypes.join(', ')}). This code appears to be ${direction === 'upstream' ? 'unused (not called by anything)' : 'self-contained (no outgoing dependencies)'}.`;
}
// Build tiered output
// ===== COMPACT TABULAR OUTPUT =====
const lines: string[] = [
`🔴 BLAST RADIUS: ${target}`,
``,
`Direction: ${direction} (${directionLabel})`,
`Total affected: ${totalAffected} components`,
`🔴 BLAST RADIUS: ${target} | ${direction} | ${totalAffected} affected`,
``,
];
// Depth 1 - Critical
// Format helper: Type|Name|File:Line|EdgeType|Confidence
const formatNode = (n: NodeInfo): string => {
const fileName = n.filePath?.split('/').pop() || '';
const loc = n.startLine ? `${fileName}:${n.startLine}` : fileName;
const confPct = Math.round((n.confidence ?? 1) * 100);
const fuzzyMarker = confPct < 80 ? '[fuzzy]' : '';
return ` ${n.nodeType}|${n.name}|${loc}|${n.edgeType}|${confPct}%${fuzzyMarker}`;
};
// Helper to get code snippet for a node (call site context)
const getCallSiteSnippet = (n: NodeInfo): string | null => {
if (!n.filePath || !n.startLine) return null;
// Find the file in fileContents (try multiple path formats)
let content: string | undefined;
const normalizedPath = n.filePath.replace(/\\/g, '/');
for (const [path, c] of fileContents.entries()) {
const normalizedKey = path.replace(/\\/g, '/');
if (normalizedKey === normalizedPath ||
normalizedKey.endsWith(normalizedPath) ||
normalizedPath.endsWith(normalizedKey)) {
content = c;
break;
}
}
if (!content) return null;
const lines = content.split('\n');
const lineIdx = n.startLine - 1;
if (lineIdx < 0 || lineIdx >= lines.length) return null;
// Get the line and trim it, max 80 chars
let snippet = lines[lineIdx].trim();
if (snippet.length > 80) snippet = snippet.slice(0, 77) + '...';
return snippet;
};
// Depth 1 - Critical (with call site snippets)
const depth1 = byDepth.get(1) || [];
if (depth1.length > 0) {
lines.push(`═══════════════════════════════════════════════`);
lines.push(`DEPTH 1 — WILL BREAK (${depth1.length} components):`);
lines.push(`═══════════════════════════════════════════════`);
depth1.slice(0, 20).forEach((r: any) => {
const name = Array.isArray(r) ? r[1] : r.name;
const nodeType = Array.isArray(r) ? r[2] : r.nodeType;
const filePath = Array.isArray(r) ? r[3] : r.filePath;
const fileName = filePath?.split('/').pop() || '';
lines.push(`• ${name} (${nodeType}) at ${fileName}`);
const header = direction === 'upstream'
? `d=1 (Directly DEPEND ON ${target}):`
: `d=1 (${target} USES these):`;
lines.push(header);
depth1.slice(0, 15).forEach(n => {
lines.push(formatNode(n));
// Add call site snippet for d=1 results
const snippet = getCallSiteSnippet(n);
if (snippet) {
lines.push(` ↳ "${snippet}"`);
}
});
if (depth1.length > 20) lines.push(` ... and ${depth1.length - 20} more`);
if (depth1.length > 15) lines.push(` ... +${depth1.length - 15} more`);
lines.push(``);
}
// Depth 2 - High impact
const depth2 = byDepth.get(2) || [];
if (depth2.length > 0) {
lines.push(`═══════════════════════════════════════════════`);
lines.push(`DEPTH 2 — LIKELY AFFECTED (${depth2.length} components):`);
lines.push(`═══════════════════════════════════════════════`);
depth2.slice(0, 15).forEach((r: any) => {
const name = Array.isArray(r) ? r[1] : r.name;
const nodeType = Array.isArray(r) ? r[2] : r.nodeType;
const filePath = Array.isArray(r) ? r[3] : r.filePath;
const fileName = filePath?.split('/').pop() || '';
lines.push(`• ${name} (${nodeType}) at ${fileName}`);
});
if (depth2.length > 15) lines.push(` ... and ${depth2.length - 15} more`);
const header = direction === 'upstream'
? `d=2 (Indirectly DEPEND ON ${target}):`
: `d=2 (${target} USES these indirectly):`;
lines.push(header);
depth2.slice(0, 15).forEach(n => lines.push(formatNode(n)));
if (depth2.length > 15) lines.push(` ... +${depth2.length - 15} more`);
lines.push(``);
}
// Depth 3 - Transitive
const depth3 = byDepth.get(3) || [];
if (depth3.length > 0) {
lines.push(`═══════════════════════════════════════════════`);
lines.push(`DEPTH 3 — MAY NEED TESTING (${depth3.length} components):`);
lines.push(`═══════════════════════════════════════════════`);
depth3.slice(0, 5).forEach((r: any) => {
const name = Array.isArray(r) ? r[1] : r.name;
const nodeType = Array.isArray(r) ? r[2] : r.nodeType;
lines.push(`• ${name} (${nodeType})`);
});
if (depth3.length > 5) lines.push(` ... and ${depth3.length - 5} more`);
lines.push(`d=3 (Deep impact/dependency):`);
depth3.slice(0, 5).forEach(n => lines.push(formatNode(n)));
if (depth3.length > 5) lines.push(` ... +${depth3.length - 5} more`);
lines.push(``);
}
// Trusted analysis marker
lines.push(`═══════════════════════════════════════════════`);
// Compact footer
lines.push(`✅ GRAPH ANALYSIS COMPLETE (trusted)`);
lines.push(`═══════════════════════════════════════════════`);
lines.push(`The above results are verified from code graph traversal.`);
lines.push(`No additional validation needed for static dependencies.`);
lines.push(``);
// Optional dynamic detection
lines.push(`═══════════════════════════════════════════════`);
lines.push(`⚠️ OPTIONAL: Dynamic Pattern Check`);
lines.push(`═══════════════════════════════════════════════`);
lines.push(`The graph cannot track: event listeners, DI, dynamic imports.`);
lines.push(`If thoroughness is needed, run:`);
lines.push(`• grep({ pattern: "${target}", fileFilter: "*.ts" })`);
lines.push(`• grep({ pattern: "${target}", fileFilter: "*.json" })`);
lines.push(`⚠️ Optional: grep("${target}") for dynamic patterns`);
lines.push(``);
// Add the marker for UI highlighting
@ -561,24 +821,31 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
name: 'blastRadius',
description: `Analyze the blast radius (impact) of changing a function, class, or file.
Use this when users ask:
Use when users ask:
- "What would break if I changed X?"
- "What depends on X?"
- "Impact analysis for X"
- "Blast radius of X"
Direction:
- upstream: Find what CALLS/IMPORTS/EXTENDS this target (what would break)
- downstream: Find what this target CALLS/IMPORTS/EXTENDS (dependencies)
Results are grouped by depth:
- Depth 1: Direct dependencies (will definitely break)
- Depth 2: Indirect (likely affected)
- Depth 3+: Transitive (may need testing)`,
Output format (compact tabular):
Type|Name|File:Line|EdgeType|Confidence%
EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS
Confidence: 100% = certain, <80% = fuzzy match (may be false positive)
relationTypes filter (optional):
- Default: CALLS, IMPORTS, EXTENDS, IMPLEMENTS (usage-based)
- Can add CONTAINS, DEFINES for structural analysis`,
schema: z.object({
target: z.string().describe('Name of the function, class, or file to analyze'),
direction: z.enum(['upstream', 'downstream']).describe('upstream = what depends on this; downstream = what this depends on'),
maxDepth: z.number().optional().nullable().describe('Max traversal depth (default: 3, max: 10)'),
relationTypes: z.array(z.string()).optional().nullable().describe('Filter by relation types: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, CONTAINS, DEFINES (default: usage-based)'),
includeTests: z.boolean().optional().nullable().describe('Include test files in results (default: false, excludes .test.ts, .spec.ts, __tests__)'),
minConfidence: z.number().optional().nullable().describe('Minimum edge confidence 0-1 (default: 0.7, excludes fuzzy/inferred matches)'),
}),
}
);

View file

@ -145,7 +145,7 @@ interface AppState {
// LLM methods
refreshLLMSettings: () => void;
initializeAgent: () => Promise<void>;
initializeAgent: (overrideProjectName?: string) => Promise<void>;
sendChatMessage: (message: string) => Promise<void>;
clearChat: () => void;
@ -540,7 +540,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
setLLMSettings(loadSettings());
}, []);
const initializeAgent = useCallback(async (): Promise<void> => {
const initializeAgent = useCallback(async (overrideProjectName?: string): Promise<void> => {
const api = apiRef.current;
if (!api) {
setAgentError('Worker not initialized');
@ -557,7 +557,9 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
setAgentError(null);
try {
const result = await api.initializeAgent(config);
// Use override if provided (for fresh loads), fallback to state (for re-init)
const effectiveProjectName = overrideProjectName || projectName || 'project';
const result = await api.initializeAgent(config, effectiveProjectName);
if (result.success) {
setIsAgentReady(true);
setAgentError(null);
@ -575,7 +577,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
} finally {
setIsAgentInitializing(false);
}
}, []);
}, [projectName]);
const sendChatMessage = useCallback(async (message: string): Promise<void> => {
const api = apiRef.current;

View file

@ -396,17 +396,28 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
const currentSelected = selectedNodeRef.current;
const highlighted = highlightedRef.current;
const hasHighlights = highlighted.size > 0;
const blastRadius = blastRadiusRef.current;
const hasHighlights = highlighted.size > 0 || blastRadius.size > 0; // Check BOTH sets
if (hasHighlights && !currentSelected) {
const graph = graphRef.current;
if (graph) {
const [source, target] = graph.extremities(edge);
const bothHighlighted = highlighted.has(source) && highlighted.has(target);
const oneHighlighted = highlighted.has(source) || highlighted.has(target);
// Check if nodes are in EITHER set
const isSourceActive = highlighted.has(source) || blastRadius.has(source);
const isTargetActive = highlighted.has(target) || blastRadius.has(target);
const bothHighlighted = isSourceActive && isTargetActive;
const oneHighlighted = isSourceActive || isTargetActive;
if (bothHighlighted) {
res.color = '#06b6d4';
// If both nodes are in blast radius, use red edge
if (blastRadius.has(source) && blastRadius.has(target)) {
res.color = '#ef4444';
} else {
res.color = '#06b6d4';
}
res.size = Math.max(2, (data.size || 1) * 3);
res.zIndex = 2;
} else if (oneHighlighted) {

View file

@ -382,8 +382,9 @@ const workerApi = {
* Initialize the Graph RAG agent with a provider configuration
* Must be called before using chat methods
* @param config - Provider configuration (Azure OpenAI or Gemini)
* @param projectName - Name of the loaded project/repository
*/
async initializeAgent(config: ProviderConfig): Promise<{ success: boolean; error?: string }> {
async initializeAgent(config: ProviderConfig, projectName?: string): Promise<{ success: boolean; error?: string }> {
try {
const kuzu = await getKuzuAdapter();
if (!kuzu.isKuzuReady()) {
@ -424,14 +425,15 @@ const workerApi = {
return mergeWithRRF(bm25Results, semanticResults, k ?? 10);
};
// Build codebase context for dynamic prompt injection
// Extract project name from first file path
const firstPath = storedFileContents.keys().next().value || '';
const projectName = firstPath.split('/')[0] || firstPath.split('\\')[0] || 'project';
// Use provided projectName, or fallback to 'project' if not provided
const resolvedProjectName = projectName || 'project';
if (import.meta.env.DEV) {
console.log('📛 Project name received:', { provided: projectName, resolved: resolvedProjectName });
}
let codebaseContext;
try {
codebaseContext = await buildCodebaseContext(kuzu.executeQuery, projectName);
codebaseContext = await buildCodebaseContext(kuzu.executeQuery, resolvedProjectName);
if (import.meta.env.DEV) {
console.log('📊 Codebase context built:', {
files: codebaseContext.stats.fileCount,