Merge pull request #18 from abhigyanpatwari/Schema_Experiments

Agent UI improved. Prompt injected with repo directory structure, hot…
This commit is contained in:
Abhigyan Patwari 2026-01-11 19:43:45 +05:30 • committed by GitHub
commit 16c50deea1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 639 additions and 241 deletions

View file

@ -1,7 +1,7 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import {
import {
Send, Sparkles, User,
PanelRightClose, Loader2, Settings, AlertTriangle
PanelRightClose, Loader2, Settings, AlertTriangle
} from 'lucide-react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
@ -29,7 +29,7 @@ const customTheme = {
};
export const RightPanel = () => {
const {
const {
isRightPanelOpen,
setRightPanelOpen,
fileContents,
@ -46,7 +46,7 @@ export const RightPanel = () => {
sendChatMessage,
clearChat,
} = useAppState();
const [chatInput, setChatInput] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -177,12 +177,12 @@ export const RightPanel = () => {
return null;
}, [extractTextFromChildren, isLikelyFileRefHref]);
// Auto-resize textarea as user types
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return;
// Reset height to get accurate scrollHeight
textarea.style.height = 'auto';
// Set to scrollHeight, capped at max
@ -192,7 +192,7 @@ export const RightPanel = () => {
// Show scrollbar if content exceeds max
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden';
}, []);
// Adjust height when input changes
useEffect(() => {
adjustTextareaHeight();
@ -235,7 +235,7 @@ export const RightPanel = () => {
<span className="font-medium text-sm">Nexus AI</span>
<span className="text-xs text-text-muted">• Ask about the codebase</span>
</div>
{/* Close button */}
<button
onClick={() => setRightPanelOpen(false)}
@ -271,87 +271,79 @@ export const RightPanel = () => {
</div>
</div>
{/* Status / errors */}
{agentError && (
<div className="px-4 py-3 bg-rose-500/10 border-b border-rose-500/30 text-rose-100 text-sm flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
<span>{agentError}</span>
</div>
)}
{/* Status / errors */}
{agentError && (
<div className="px-4 py-3 bg-rose-500/10 border-b border-rose-500/30 text-rose-100 text-sm flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
<span>{agentError}</span>
</div>
)}
{/* Active tool calls - shown at top during execution */}
{currentToolCalls.length > 0 && (
<div className="px-4 py-3 bg-elevated/60 border-b border-border-subtle space-y-2">
<div className="text-[10px] uppercase tracking-wider text-text-muted flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
<span>Working...</span>
</div>
{currentToolCalls.map(tc => (
<ToolCallCard key={tc.id} toolCall={tc} defaultExpanded={false} />
))}
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 scrollbar-thin">
{chatMessages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center px-4">
<div className="w-14 h-14 mb-4 flex items-center justify-center bg-gradient-to-br from-accent to-node-interface rounded-xl shadow-glow text-2xl">
🧠
</div>
<h3 className="text-base font-medium mb-2">
Ask me anything
</h3>
<p className="text-sm text-text-secondary leading-relaxed mb-5">
I can help you understand the architecture, find functions, or explain connections.
</p>
<div className="flex flex-wrap gap-2 justify-center">
{chatSuggestions.map((suggestion) => (
<button
key={suggestion}
onClick={() => setChatInput(suggestion)}
className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-full text-xs text-text-secondary hover:border-accent hover:text-text-primary transition-colors"
>
{suggestion}
</button>
))}
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 scrollbar-thin">
{chatMessages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center px-4">
<div className="w-14 h-14 mb-4 flex items-center justify-center bg-gradient-to-br from-accent to-node-interface rounded-xl shadow-glow text-2xl">
🧠
</div>
) : (
<div className="flex flex-col gap-5">
{chatMessages.map((message) => (
<div
key={message.id}
className={`flex gap-3 ${message.role === 'user' ? 'flex-row-reverse' : ''} animate-fade-in`}
<h3 className="text-base font-medium mb-2">
Ask me anything
</h3>
<p className="text-sm text-text-secondary leading-relaxed mb-5">
I can help you understand the architecture, find functions, or explain connections.
</p>
<div className="flex flex-wrap gap-2 justify-center">
{chatSuggestions.map((suggestion) => (
<button
key={suggestion}
onClick={() => setChatInput(suggestion)}
className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-full text-xs text-text-secondary hover:border-accent hover:text-text-primary transition-colors"
>
<div className={`
w-7 h-7 flex-shrink-0 flex items-center justify-center rounded-md text-sm
${message.role === 'assistant'
? 'bg-gradient-to-br from-accent to-node-interface text-white'
: 'bg-elevated border border-border-subtle text-text-secondary'
}
`}>
{message.role === 'assistant' ? (
<Sparkles className="w-3.5 h-3.5" />
) : (
<User className="w-3.5 h-3.5" />
)}
{suggestion}
</button>
))}
</div>
</div>
) : (
<div className="flex flex-col gap-6">
{chatMessages.map((message) => (
<div
key={message.id}
className="animate-fade-in"
>
{/* User message - compact label style */}
{message.role === 'user' && (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<User className="w-4 h-4 text-text-muted" />
<span className="text-xs font-medium text-text-muted uppercase tracking-wide">You</span>
</div>
<div className="pl-6 text-sm text-text-primary">
{message.content}
</div>
</div>
<div className={`
max-w-[90%] rounded-xl
${message.role === 'assistant'
? 'px-4 py-3 bg-elevated border border-border-subtle text-text-primary chat-prose'
: 'px-3.5 py-2.5 bg-accent text-white text-sm'
}
`}>
{message.role === 'assistant' ? (
// Render steps in order (reasoning, tool calls, content interleaved)
message.steps && message.steps.length > 0 ? (
)}
{/* Assistant message - copilot style */}
{message.role === 'assistant' && (
<div>
<div className="flex items-center gap-2 mb-3">
<Sparkles className="w-4 h-4 text-accent" />
<span className="text-xs font-medium text-text-muted uppercase tracking-wide">Nexus AI</span>
{isChatLoading && message === chatMessages[chatMessages.length - 1] && (
<Loader2 className="w-3 h-3 animate-spin text-accent" />
)}
</div>
<div className="pl-6 chat-prose">
{/* Render steps in order (reasoning, tool calls, content interleaved) */}
{message.steps && message.steps.length > 0 ? (
<div className="space-y-4">
{message.steps.map((step) => (
<div key={step.id}>
{step.type === 'reasoning' && step.content && (
<div className="text-text-primary text-sm chat-prose">
<div className="text-text-secondary text-sm italic border-l-2 border-text-muted/30 pl-3 mb-3">
<ReactMarkdown
components={{
a: ({ href, children, ...props }) => {
@ -439,100 +431,104 @@ export const RightPanel = () => {
</div>
)}
{step.type === 'tool_call' && step.toolCall && (
<ToolCallCard toolCall={step.toolCall} defaultExpanded={false} />
<div className="mb-3">
<ToolCallCard toolCall={step.toolCall} defaultExpanded={false} />
</div>
)}
{step.type === 'content' && step.content && (
<ReactMarkdown
components={{
a: ({ href, children, ...props }) => {
if (href && href.startsWith('code-ref:')) {
const inner = decodeURIComponent(href.slice('code-ref:'.length));
const label = formatRefChipLabel(inner);
<div className="text-text-primary text-sm">
<ReactMarkdown
components={{
a: ({ href, children, ...props }) => {
if (href && href.startsWith('code-ref:')) {
const inner = decodeURIComponent(href.slice('code-ref:'.length));
const label = formatRefChipLabel(inner);
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
handleGroundingClick(inner);
}}
className="inline-flex items-center px-2 py-0.5 rounded-md border border-cyan-300/55 bg-cyan-400/10 !text-cyan-200 visited:!text-cyan-200 font-mono text-[12px] !no-underline hover:!no-underline hover:bg-cyan-400/15 hover:border-cyan-200/70 transition-colors"
title={`Open in Code panel • ${inner}`}
{...props}
>
<span className="text-inherit">{label || children}</span>
</a>
);
}
const internalRef = getInternalRefFromLink(href, children);
if (internalRef) {
const label = formatRefChipLabel(internalRef);
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
handleGroundingClick(internalRef);
}}
className="inline-flex items-center px-2 py-0.5 rounded-md border border-cyan-300/55 bg-cyan-400/10 !text-cyan-200 visited:!text-cyan-200 font-mono text-[12px] !no-underline hover:!no-underline hover:bg-cyan-400/15 hover:border-cyan-200/70 transition-colors"
title={`Open in Code panel • ${internalRef}`}
{...props}
>
<span className="text-inherit">{label || children}</span>
</a>
);
}
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
handleGroundingClick(inner);
}}
className="inline-flex items-center px-2 py-0.5 rounded-md border border-cyan-300/55 bg-cyan-400/10 !text-cyan-200 visited:!text-cyan-200 font-mono text-[12px] !no-underline hover:!no-underline hover:bg-cyan-400/15 hover:border-cyan-200/70 transition-colors"
title={`Open in Code panel • ${inner}`}
className="text-accent underline underline-offset-2 hover:text-purple-300"
target="_blank"
rel="noopener noreferrer"
{...props}
>
<span className="text-inherit">{label || children}</span>
{children}
</a>
);
}
const internalRef = getInternalRefFromLink(href, children);
if (internalRef) {
const label = formatRefChipLabel(internalRef);
},
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '');
const isInline = !className && !match;
const codeContent = String(children).replace(/\n$/, '');
if (isInline) {
return <code {...props}>{children}</code>;
}
const language = match ? match[1] : 'text';
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
handleGroundingClick(internalRef);
<SyntaxHighlighter
style={customTheme}
language={language}
PreTag="div"
customStyle={{
margin: 0,
padding: '14px 16px',
borderRadius: '8px',
fontSize: '13px',
background: '#0a0a10',
border: '1px solid #1e1e2a',
}}
className="inline-flex items-center px-2 py-0.5 rounded-md border border-cyan-300/55 bg-cyan-400/10 !text-cyan-200 visited:!text-cyan-200 font-mono text-[12px] !no-underline hover:!no-underline hover:bg-cyan-400/15 hover:border-cyan-200/70 transition-colors"
title={`Open in Code panel • ${internalRef}`}
{...props}
>
<span className="text-inherit">{label || children}</span>
</a>
{codeContent}
</SyntaxHighlighter>
);
}
return (
<a
href={href}
className="text-accent underline underline-offset-2 hover:text-purple-300"
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
);
},
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '');
const isInline = !className && !match;
const codeContent = String(children).replace(/\n$/, '');
if (isInline) {
return <code {...props}>{children}</code>;
}
const language = match ? match[1] : 'text';
return (
<SyntaxHighlighter
style={customTheme}
language={language}
PreTag="div"
customStyle={{
margin: 0,
padding: '14px 16px',
borderRadius: '8px',
fontSize: '13px',
background: '#0a0a10',
border: '1px solid #1e1e2a',
}}
>
{codeContent}
</SyntaxHighlighter>
);
},
pre: ({ children }) => <>{children}</>,
}}
>
{formatMarkdownForDisplay(step.content)}
</ReactMarkdown>
},
pre: ({ children }) => <>{children}</>,
}}
>
{formatMarkdownForDisplay(step.content)}
</ReactMarkdown>
</div>
)}
</div>
))}
</div>
) : (
// Fallback: render content + toolCalls separately (old format)
<>
<div className="text-text-primary text-sm">
<ReactMarkdown
components={{
a: ({ href, children, ...props }) => {
@ -588,11 +584,11 @@ export const RightPanel = () => {
const match = /language-(\w+)/.exec(className || '');
const isInline = !className && !match;
const codeContent = String(children).replace(/\n$/, '');
if (isInline) {
return <code {...props}>{children}</code>;
}
const language = match ? match[1] : 'text';
return (
<SyntaxHighlighter
@ -624,58 +620,59 @@ export const RightPanel = () => {
))}
</div>
)}
</>
)
) : (
message.content
)}
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
))}
{/* Input */}
<div className="p-3 bg-surface border-t border-border-subtle">
<div className="flex items-end gap-2 px-3 py-2 bg-elevated border border-border-subtle rounded-xl transition-all focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
<textarea
ref={textareaRef}
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about the codebase..."
rows={1}
className="flex-1 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted resize-none min-h-[36px] scrollbar-thin"
style={{ height: '36px', overflowY: 'hidden' }}
/>
<button
onClick={clearChat}
className="px-2 py-1 text-xs text-text-muted hover:text-text-primary transition-colors"
title="Clear chat"
>
Clear
</button>
<button
onClick={handleSendMessage}
disabled={!chatInput.trim() || isChatLoading || isAgentInitializing}
className="w-9 h-9 flex items-center justify-center bg-accent rounded-md text-white transition-all hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
>
{isChatLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-3.5 h-3.5" />}
</button>
</div>
{!isAgentReady && !isAgentInitializing && (
<div className="mt-2 text-xs text-amber-200 flex items-center gap-2">
<AlertTriangle className="w-3.5 h-3.5" />
<span>
{isProviderConfigured()
? 'Initializing AI agent...'
: 'Configure an LLM provider to enable chat.'}
</span>
</div>
)}
</div>
)}
</div>
{/* Input */}
<div className="p-3 bg-surface border-t border-border-subtle">
<div className="flex items-end gap-2 px-3 py-2 bg-elevated border border-border-subtle rounded-xl transition-all focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
<textarea
ref={textareaRef}
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about the codebase..."
rows={1}
className="flex-1 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted resize-none min-h-[36px] scrollbar-thin"
style={{ height: '36px', overflowY: 'hidden' }}
/>
<button
onClick={clearChat}
className="px-2 py-1 text-xs text-text-muted hover:text-text-primary transition-colors"
title="Clear chat"
>
Clear
</button>
<button
onClick={handleSendMessage}
disabled={!chatInput.trim() || isChatLoading || isAgentInitializing}
className="w-9 h-9 flex items-center justify-center bg-accent rounded-md text-white transition-all hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
>
{isChatLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-3.5 h-3.5" />}
</button>
</div>
{!isAgentReady && !isAgentInitializing && (
<div className="mt-2 text-xs text-amber-200 flex items-center gap-2">
<AlertTriangle className="w-3.5 h-3.5" />
<span>
{isProviderConfigured()
? 'Initializing AI agent...'
: 'Configure an LLM provider to enable chat.'}
</span>
</div>
)}
</div>
</div>
</aside>
);
};

View file

@ -19,6 +19,10 @@ import type {
AnthropicConfig,
AgentStreamChunk,
} from './types';
import {
type CodebaseContext,
buildDynamicSystemPrompt,
} from './context-builder';
/**
* System prompt for the Graph RAG agent
@ -30,44 +34,55 @@ import type {
* - Explicit progress reporting requirement
* - Anti-laziness directives
*/
const SYSTEM_PROMPT = `You are Nexus, an elite Code Analysis Agent powered by a Knowledge Graph. You have access to tools, use them to get project context.
Your mission is to answer user questions with precision by exploring the codebase, verifying facts, and visualizing your findings.
Ground your answers with \`[[file:line]]\` citations.
/**
* Base system prompt - exported so it can be used with dynamic context injection
*
* Structure (optimized for instruction following):
* 1. Identity + GROUNDING mandate (most important)
* 2. Core protocol (how to work)
* 3. Tools reference
* 4. Output format & rules
* 5. [Dynamic context appended at end]
*/
export const BASE_SYSTEM_PROMPT = `You are Nexus, a Code Analysis Agent with access to a Knowledge Graph. Your responses MUST be grounded.
### 🧠 CORE PROTOCOL
You are an investigator, not a one-shot query engine:
1. **Plan:** State what you're looking for.
2. **Execute:** Run tools to gather evidence.
3. **Analyze:** Did it answer the question? Did it reveal new leads? Loop back if needed.
4. **Visualize:** Use \`highlight\` to show relevant nodes in the graph.
5. **Ground:** Cite with \`[[file:line]]\` format.
## ⚠️ MANDATORY: GROUNDING
Every factual claim MUST include a citation: \`[[filename:line]]\`
- When you mention a function, class, or file → cite where you found it
- When you describe behavior → cite the code that proves it
- NO citation = NO claim. If you can't cite it, say "I didn't find evidence for this."
### 🛠️ TOOLS (5 total)
- **\`search\`** — Find code by keywords/concepts. Returns matches + their graph connections.
- **\`cypher\`** — Run Cypher queries for structural analysis. Include \`{{QUERY_VECTOR}}\` for semantic+graph queries.
- **\`grep\`** — Regex pattern search across files. Use for exact strings, TODOs, error codes.
- **\`read\`** — Read file content. Use after finding files via search/grep.
Examples:
- "The \`AuthService\` handles login [[src/services/auth.ts:45]]."
- "This function calls \`validateToken\` [[src/utils.ts:12]] which throws on invalid tokens [[src/utils.ts:28]]."
## 🧠 CORE PROTOCOL
You are an investigator. For each question:
1. **Search** → Use \`search\` or \`grep\` to find relevant code
2. **Read** → Use \`read\` to see the actual source
3. **Trace** → Use \`cypher\` to follow connections in the graph
4. **Cite** → Ground every finding with \`[[file:line]]\`
5. **Highlight** → Visualize key nodes with \`highlight\`
## 🛠️ TOOLS
- **\`search\`** — Hybrid search (keyword + semantic). Returns code matches with graph connections.
- **\`cypher\`** — Cypher queries against the graph. Use \`{{QUERY_VECTOR}}\` for vector search.
- **\`grep\`** — Regex search. Best for exact strings, TODOs, error codes.
- **\`read\`** — Read file content. Always use after search/grep to see full code.
- **\`highlight\`** — Highlight nodes in the visual graph.
### 📊 GRAPH SCHEMA
**Node Tables:** File, Folder, Function, Class, Interface, Method, CodeElement
**Relation:** CodeRelation (single table with 'type' property: CONTAINS, DEFINES, IMPORTS, CALLS)
## 📊 GRAPH SCHEMA
Nodes: File, Folder, Function, Class, Interface, Method, CodeElement
Relation: \`CodeRelation\` with \`type\` property: CONTAINS, DEFINES, IMPORTS, CALLS
**Cypher Examples:**
- All functions: \`MATCH (f:Function) RETURN f.name LIMIT 10\`
- What file defines: \`MATCH (f:File)-[r:CodeRelation {type: 'DEFINES'}]->(fn:Function) WHERE f.name = 'utils.ts' RETURN fn.name\`
- Get all connections: \`MATCH (f:File)-[r:CodeRelation]-(m) WHERE f.name = 'main.ts' RETURN m.name, r.type\`
- Semantic+graph: \`CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', {{QUERY_VECTOR}}, 10) YIELD node AS emb, distance WITH emb, distance WHERE distance < 0.5 MATCH (n:Function {id: emb.nodeId}) RETURN n\`
Cypher examples:
- \`MATCH (f:Function) RETURN f.name LIMIT 10\`
- \`MATCH (f:File)-[:CodeRelation {type: 'IMPORTS'}]->(g:File) RETURN f.name, g.name\`
### 📝 OUTPUT
1. **Citations:** \`[[file:line]]\`
2. **Diagrams:** Mermaid when useful
3. **Highlight:** Always highlight nodes you discuss
### 🚫 RULES
- **Iterate:** Don't stop at surface. Trace logic to source.
- **Verify:** Don't guess. Read the code.
- **Retry:** If a tool fails, fix input and retry.`;
## <EFBFBD> RULES
- **Cite or retract.** Never state something you can't ground.
- **Read before concluding.** Don't guess from names alone.
- **Retry on failure.** If a tool fails, fix the input and try again.`;
/**
* Create a chat model instance from provider configuration
@ -144,7 +159,8 @@ export const createGraphRAGAgent = (
hybridSearch: (query: string, k?: number) => Promise<any[]>,
isEmbeddingReady: () => boolean,
isBM25Ready: () => boolean,
fileContents: Map<string, string>
fileContents: Map<string, string>,
codebaseContext?: CodebaseContext
) => {
const model = createChatModel(config);
const tools = createGraphRAGTools(
@ -157,10 +173,20 @@ export const createGraphRAGAgent = (
fileContents
);
// Use dynamic prompt if context is provided, otherwise use base prompt
const systemPrompt = codebaseContext
? buildDynamicSystemPrompt(BASE_SYSTEM_PROMPT, codebaseContext)
: BASE_SYSTEM_PROMPT;
// Log the full prompt for debugging
if (import.meta.env?.DEV) {
console.log('📝 Full System Prompt:\n', systemPrompt);
}
const agent = createReactAgent({
llm: model as any,
tools: tools as any,
messageModifier: new SystemMessage(SYSTEM_PROMPT) as any,
messageModifier: new SystemMessage(systemPrompt) as any,
});
return agent;

View file

@ -0,0 +1,344 @@
/**
* Context Builder for Graph RAG Agent
*
* Generates dynamic context about the loaded codebase to inject into the system prompt.
* This helps the LLM understand the project structure, scale, and key entry points
* without needing to explore from scratch.
*/
/**
* Codebase statistics
*/
export interface CodebaseStats {
projectName: string;
fileCount: number;
functionCount: number;
classCount: number;
interfaceCount: number;
methodCount: number;
}
/**
* Hotspot - highly connected node
*/
export interface Hotspot {
name: string;
type: string;
filePath: string;
connections: number;
}
/**
* Folder info for tree rendering
*/
interface FolderInfo {
path: string;
name: string;
depth: number;
fileCount: number;
children: FolderInfo[];
}
/**
* Complete codebase context for prompt injection
* Simplified: stats + hotspots + folder tree (no entry points or language detection)
*/
export interface CodebaseContext {
stats: CodebaseStats;
hotspots: Hotspot[];
folderTree: string;
}
/**
* Get codebase statistics via Cypher queries
*/
export async function getCodebaseStats(
executeQuery: (cypher: string) => Promise<any[]>,
projectName: string
): Promise<CodebaseStats> {
try {
// Count each node type
const countQueries = [
{ type: 'files', query: 'MATCH (n:File) RETURN COUNT(n) AS count' },
{ type: 'functions', query: 'MATCH (n:Function) RETURN COUNT(n) AS count' },
{ type: 'classes', query: 'MATCH (n:Class) RETURN COUNT(n) AS count' },
{ type: 'interfaces', query: 'MATCH (n:Interface) RETURN COUNT(n) AS count' },
{ type: 'methods', query: 'MATCH (n:Method) RETURN COUNT(n) AS count' },
];
const counts: Record<string, number> = {};
for (const { type, query } of countQueries) {
try {
const result = await executeQuery(query);
// Handle both array and object result formats
const row = result[0];
counts[type] = Array.isArray(row) ? (row[0] ?? 0) : (row?.count ?? 0);
} catch {
counts[type] = 0;
}
}
return {
projectName,
fileCount: counts.files,
functionCount: counts.functions,
classCount: counts.classes,
interfaceCount: counts.interfaces,
methodCount: counts.methods,
};
} catch (error) {
console.error('Failed to get codebase stats:', error);
return {
projectName,
fileCount: 0,
functionCount: 0,
classCount: 0,
interfaceCount: 0,
methodCount: 0,
};
}
}
/**
* Find hotspots - nodes with the most connections
*/
export async function getHotspots(
executeQuery: (cypher: string) => Promise<any[]>,
limit: number = 8
): Promise<Hotspot[]> {
try {
// Find nodes with most edges (both directions)
const query = `
MATCH (n)-[r:CodeRelation]-(m)
WHERE n.name IS NOT NULL
WITH n, COUNT(r) AS connections
ORDER BY connections DESC
LIMIT ${limit}
RETURN n.name AS name, LABEL(n) AS type, n.filePath AS filePath, connections
`;
const results = await executeQuery(query);
return results.map(row => {
if (Array.isArray(row)) {
return {
name: row[0],
type: row[1],
filePath: row[2],
connections: row[3],
};
}
return {
name: row.name,
type: row.type,
filePath: row.filePath,
connections: row.connections,
};
}).filter(h => h.name && h.type);
} catch (error) {
console.error('Failed to get hotspots:', error);
return [];
}
}
/**
* Build folder tree structure from file paths
*/
export async function getFolderTree(
executeQuery: (cypher: string) => Promise<any[]>,
maxDepth: number = 3
): Promise<string> {
try {
// Get all file paths
const query = 'MATCH (f:File) RETURN f.filePath AS path ORDER BY path';
const results = await executeQuery(query);
const paths = results.map(row => {
if (Array.isArray(row)) return row[0];
return row.path;
}).filter(Boolean);
if (paths.length === 0) return '';
// Build tree structure from paths
const tree = buildTreeFromPaths(paths, maxDepth);
return formatTreeAsAscii(tree, '');
} catch (error) {
console.error('Failed to get folder tree:', error);
return '';
}
}
/**
* Build a tree structure from file paths
*/
function buildTreeFromPaths(paths: string[], maxDepth: number): Map<string, any> {
const root = new Map<string, any>();
for (const fullPath of paths) {
// Normalize path separators
const normalizedPath = fullPath.replace(/\\/g, '/');
const parts = normalizedPath.split('/').filter(Boolean);
let current = root;
const depth = Math.min(parts.length, maxDepth + 1); // +1 to include files at maxDepth
for (let i = 0; i < depth; i++) {
const part = parts[i];
const isFile = i === parts.length - 1;
if (!current.has(part)) {
current.set(part, isFile ? null : new Map<string, any>());
}
const next = current.get(part);
if (next instanceof Map) {
current = next;
} else {
break;
}
}
}
return root;
}
/**
* Format tree as ASCII (like VS Code sidebar)
*/
function formatTreeAsAscii(
tree: Map<string, any>,
prefix: string,
isLast: boolean = true
): string {
const lines: string[] = [];
const entries = Array.from(tree.entries());
// Sort: folders first, then files, alphabetically
entries.sort(([a, aVal], [b, bVal]) => {
const aIsDir = aVal instanceof Map;
const bIsDir = bVal instanceof Map;
if (aIsDir !== bIsDir) return bIsDir ? 1 : -1;
return a.localeCompare(b);
});
entries.forEach(([name, subtree], index) => {
const isLastItem = index === entries.length - 1;
const connector = isLastItem ? '└── ' : '├── ';
const childPrefix = prefix + (isLastItem ? ' ' : '│ ');
if (subtree instanceof Map && subtree.size > 0) {
// Folder with children
const childCount = countItems(subtree);
const annotation = childCount > 3 ? ` (${childCount} items)` : '';
lines.push(`${prefix}${connector}${name}/${annotation}`);
lines.push(formatTreeAsAscii(subtree, childPrefix, isLastItem));
} else if (subtree instanceof Map) {
// Empty folder
lines.push(`${prefix}${connector}${name}/`);
} else {
// File
lines.push(`${prefix}${connector}${name}`);
}
});
return lines.filter(Boolean).join('\n');
}
/**
* Count items in a tree node
*/
function countItems(tree: Map<string, any>): number {
let count = 0;
for (const [, value] of tree) {
if (value instanceof Map) {
count += 1 + countItems(value);
} else {
count += 1;
}
}
return count;
}
/**
* Build complete codebase context
*/
export async function buildCodebaseContext(
executeQuery: (cypher: string) => Promise<any[]>,
projectName: string
): Promise<CodebaseContext> {
// Run all queries in parallel for speed
const [stats, hotspots, folderTree] = await Promise.all([
getCodebaseStats(executeQuery, projectName),
getHotspots(executeQuery),
getFolderTree(executeQuery),
]);
return {
stats,
hotspots,
folderTree,
};
}
/**
* Format context as markdown for prompt injection
*/
export function formatContextForPrompt(context: CodebaseContext): string {
const { stats, hotspots, folderTree } = context;
const lines: string[] = [];
// Project header with stats
lines.push(`### 📊 CODEBASE: ${stats.projectName}`);
const statParts = [
`Files: ${stats.fileCount}`,
`Functions: ${stats.functionCount}`,
stats.classCount > 0 ? `Classes: ${stats.classCount}` : null,
stats.interfaceCount > 0 ? `Interfaces: ${stats.interfaceCount}` : null,
].filter(Boolean);
lines.push(statParts.join(' | '));
lines.push('');
// Hotspots
if (hotspots.length > 0) {
lines.push('**Hotspots** (most connected):');
hotspots.slice(0, 5).forEach(h => {
lines.push(`- \`${h.name}\` (${h.type}) — ${h.connections} edges`);
});
lines.push('');
}
// Folder tree
if (folderTree) {
lines.push('### 📁 STRUCTURE');
lines.push('```');
lines.push(stats.projectName + '/');
lines.push(folderTree);
lines.push('```');
}
return lines.join('\n');
}
/**
* Build the complete dynamic system prompt
* Context is appended at the END so core instructions remain at the top
*/
export function buildDynamicSystemPrompt(
basePrompt: string,
context: CodebaseContext
): string {
const contextSection = formatContextForPrompt(context);
// Append context at the END - keeps core instructions at top for better adherence
return `${basePrompt}
---
## 📦 CURRENT CODEBASE
${contextSection}`;
}

View file

@ -23,12 +23,22 @@ export {
// Tools
export { createGraphRAGTools } from './tools';
// Context Builder
export {
buildCodebaseContext,
formatContextForPrompt,
buildDynamicSystemPrompt,
type CodebaseContext,
type CodebaseStats,
type Hotspot,
} from './context-builder';
// Agent
export {
createChatModel,
createGraphRAGAgent,
streamAgentResponse,
invokeAgent,
BASE_SYSTEM_PROMPT,
type AgentMessage,
} from './agent';

View file

@ -12,6 +12,7 @@ import { isEmbedderReady, disposeEmbedder } from '../core/embeddings/embedder';
import type { EmbeddingProgress, SemanticSearchResult } from '../core/embeddings/types';
import type { ProviderConfig, AgentStreamChunk } from '../core/llm/types';
import { createGraphRAGAgent, streamAgentResponse, type AgentMessage } from '../core/llm/agent';
import { buildCodebaseContext } from '../core/llm/context-builder';
import {
buildBM25Index,
searchBM25,
@ -423,6 +424,25 @@ 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';
let codebaseContext;
try {
codebaseContext = await buildCodebaseContext(kuzu.executeQuery, projectName);
if (import.meta.env.DEV) {
console.log('📊 Codebase context built:', {
files: codebaseContext.stats.fileCount,
functions: codebaseContext.stats.functionCount,
hotspots: codebaseContext.hotspots.length,
});
}
} catch (err) {
console.warn('Failed to build codebase context, proceeding without:', err);
}
currentAgent = createGraphRAGAgent(
config,
kuzu.executeQuery,
@ -431,7 +451,8 @@ const workerApi = {
hybridSearchWrapper,
() => isEmbeddingComplete,
() => isBM25Ready(),
storedFileContents
storedFileContents,
codebaseContext
);
currentProviderConfig = config;