UNPKG

@alvinveroy/codecompass

Version:

AI-powered MCP server for codebase navigation and LLM prompt optimization

507 lines (461 loc) 23.4 kB
import { QdrantClient } from "@qdrant/js-client-rest"; import { configService, logger } from "./config-service"; // Added configService // getLLMProvider is not used, so the lint warning was correct. Let's remove it. // import { getLLMProvider } from "./llm-provider"; import { FormattedSearchResult, CapabilitySearchCodeSnippetsParams, CapabilityGetRepositoryOverviewParams, CapabilityGetChangelogParams, CapabilityFetchMoreSearchResultsParams, CapabilityGetFullFileContentParams, CapabilityListDirectoryParams, CapabilityGetAdjacentFileChunksParams, CapabilityGenerateSuggestionWithContextParams, CapabilityAnalyzeCodeProblemWithContextParams } from "./agent"; // Import types from agent.ts import { searchWithRefinement } from "./query-refinement"; // Assuming this is where it is import { processSnippet, getProcessedDiff as getAgentProcessedDiff } from "./agent"; // Import existing helpers from agent.ts // We might move these later if it makes sense. // eslint-disable-next-line @typescript-eslint/no-unused-vars -- CommitInfoPayload and DiffChunkPayload are used for payload type discrimination import { DetailedQdrantSearchResult, FileChunkPayload, CommitInfoPayload, DiffChunkPayload } from "./types"; // Import specific payload types import fs from "fs/promises"; // For getChangelog, getFullFileContent, listDirectory import path from "path"; // For getChangelog, getFullFileContent, listDirectory import { getLLMProvider as getProviderForLLMDependentCaps } from "./llm-provider"; // Alias for LLM-dependent caps // Define the context that will be passed to all capability functions export interface CapabilityContext { qdrantClient: QdrantClient; repoPath: string; suggestionModelAvailable: boolean; } // capability_searchCodeSnippets export async function capability_searchCodeSnippets( context: CapabilityContext, params: CapabilitySearchCodeSnippetsParams ): Promise<FormattedSearchResult[]> { const { qdrantClient, suggestionModelAvailable } = context; // repoPath not directly used if files list is empty const { query } = params; logger.info(`Executing capability_searchCodeSnippets with query: ${params.query}`); // Assuming validateGitRepository and git.listFiles are handled by the orchestrator // or are not strictly needed for the capability if files list is passed or search is global. // For now, let's assume searchWithRefinement can handle an empty files array if repo context isn't pre-filtered. const { results: qdrantResults } = await searchWithRefinement( qdrantClient, query, [] // Pass empty array for files, or orchestrator needs to provide this. ); const formattedResultsPromises = qdrantResults.map(async (r: DetailedQdrantSearchResult) => { const payload = r.payload; let filepathDisplay = "N/A"; let snippetContent = "Content not available"; let isChunked = false; let originalFilepath: string | undefined = undefined; let chunkIndex: number | undefined = undefined; let totalChunks: number | undefined = undefined; let lastModified: string | undefined = undefined; if (payload?.dataType === 'file_chunk') { filepathDisplay = payload.filepath; snippetContent = payload.file_content_chunk; isChunked = true; originalFilepath = payload.filepath; chunkIndex = payload.chunk_index; totalChunks = payload.total_chunks; lastModified = payload.last_modified; if (isChunked) { filepathDisplay = `${payload.filepath} (Chunk ${(chunkIndex ?? 0) + 1}/${totalChunks ?? 'N/A'})`; } } else if (payload) { // Handle other types or log a warning if only file_chunk is expected here logger.warn(`capability_searchCodeSnippets: Received non-file_chunk payload type: ${payload.dataType} for result ID ${r.id}`); // Provide default/fallback values for FormattedSearchResult filepathDisplay = (payload as { filepath?: string }).filepath || `Unknown path (ID: ${r.id})`; snippetContent = `Non-file content (type: ${payload.dataType})`; } const processedSnippetContent = await processSnippet( snippetContent, query, filepathDisplay, suggestionModelAvailable ); return { filepath: filepathDisplay, snippet: processedSnippetContent, last_modified: lastModified, relevance: r.score, is_chunked: isChunked, original_filepath: originalFilepath, chunk_index: chunkIndex, total_chunks: totalChunks, }; }); return Promise.all(formattedResultsPromises); } // capability_getRepositoryOverview export async function capability_getRepositoryOverview( context: CapabilityContext, params: CapabilityGetRepositoryOverviewParams ): Promise<{ refinedQuery: string; diffSummary: string; searchResults: FormattedSearchResult[] }> { const { qdrantClient, repoPath, suggestionModelAvailable } = context; const { query } = params; logger.info(`Executing capability_getRepositoryOverview with query: ${params.query}`); const processedDiff = await getAgentProcessedDiff(repoPath, suggestionModelAvailable); const { results: qdrantResults, refinedQuery } = await searchWithRefinement( qdrantClient, query, [] // Assuming files list is not passed or handled by orchestrator ); const searchResultsPromises = qdrantResults.map(async (r: DetailedQdrantSearchResult) => { const payload = r.payload; let filepathDisplay = "N/A"; let snippetContent = "Content not available"; let isChunked = false; let originalFilepath: string | undefined = undefined; let chunkIndex: number | undefined = undefined; let totalChunks: number | undefined = undefined; let lastModified: string | undefined = undefined; // Populate based on payload type if (payload?.dataType === 'file_chunk') { filepathDisplay = payload.filepath; snippetContent = payload.file_content_chunk; isChunked = true; originalFilepath = payload.filepath; chunkIndex = payload.chunk_index; totalChunks = payload.total_chunks; lastModified = payload.last_modified; if (isChunked) { filepathDisplay = `${payload.filepath} (Chunk ${(chunkIndex ?? 0) + 1}/${totalChunks ?? 'N/A'})`; } } else if (payload?.dataType === 'commit_info') { filepathDisplay = `Commit: ${payload.commit_oid.substring(0, 7)}`; snippetContent = `Message: ${payload.commit_message}`; lastModified = payload.commit_date; } else if (payload?.dataType === 'diff_chunk') { filepathDisplay = `Diff: ${payload.filepath} (Commit: ${payload.commit_oid.substring(0,7)})`; snippetContent = payload.diff_content_chunk; isChunked = true; originalFilepath = payload.filepath; chunkIndex = payload.chunk_index; totalChunks = payload.total_chunks; } else if (payload && typeof payload === 'object' && 'dataType' in payload) { // Check if payload is an object and has dataType const unknownPayload = payload as { dataType?: string; filepath?: string }; // Use a broader type for safe access logger.warn(`capability_getRepositoryOverview: Received payload with unhandled dataType '${unknownPayload.dataType || 'undefined'}' or unexpected structure for result ID ${r.id}`); filepathDisplay = unknownPayload.filepath || `Unknown path (ID: ${r.id})`; snippetContent = `Non-standard content (type: ${unknownPayload.dataType || 'unknown_structure'})`; } else if (payload) { // Payload exists but is not an object or doesn't have dataType logger.warn(`capability_getRepositoryOverview: Received malformed or unexpected payload structure for result ID ${r.id}`, { payload }); filepathDisplay = (payload as { filepath?: string }).filepath || `Unknown path (ID: ${r.id})`; // Keep existing fallback for filepath snippetContent = `Malformed payload (ID: ${r.id})`; } const processedSnippetContent = await processSnippet( snippetContent, query, filepathDisplay, suggestionModelAvailable ); return { filepath: filepathDisplay, snippet: processedSnippetContent, last_modified: lastModified, relevance: r.score, is_chunked: isChunked, original_filepath: originalFilepath, chunk_index: chunkIndex, total_chunks: totalChunks, }; }); const searchResults = await Promise.all(searchResultsPromises); return { refinedQuery, diffSummary: processedDiff, searchResults, }; } // capability_getChangelog export async function capability_getChangelog( context: CapabilityContext, _params: CapabilityGetChangelogParams // No parameters ): Promise<{ changelog: string; error?: string }> { // Return type matches previous refactor const { repoPath } = context; logger.info("Executing capability_getChangelog"); try { const changelogPath = path.join(repoPath, 'CHANGELOG.md'); const changelogContent = await fs.readFile(changelogPath, 'utf8'); return { changelog: changelogContent.substring(0, configService.MAX_FILE_CONTENT_LENGTH_FOR_CAPABILITY || 2000) // Use a config value }; } catch (_error) { if ((_error as NodeJS.ErrnoException)?.code === 'ENOENT') { return { changelog: "No changelog found" }; } return { changelog: "No changelog available", error: "Failed to read changelog" }; } } // capability_fetchMoreSearchResults (similar to capability_searchCodeSnippets) export async function capability_fetchMoreSearchResults( context: CapabilityContext, params: CapabilityFetchMoreSearchResultsParams ): Promise<FormattedSearchResult[]> { const { qdrantClient, suggestionModelAvailable } = context; // repoPath not directly used if files list is empty const { query } = params; logger.info(`Executing capability_fetchMoreSearchResults with query: ${params.query}`); const moreResultsLimit = configService.REQUEST_ADDITIONAL_CONTEXT_MAX_SEARCH_RESULTS; const { results: qdrantResults } = await searchWithRefinement( qdrantClient, query, [], // Assuming files list is not passed or handled by orchestrator moreResultsLimit ); const formattedResultsPromises = qdrantResults.map(async (r: DetailedQdrantSearchResult) => { const payload = r.payload; let filepathDisplay = "N/A"; let snippetContent = "Content not available"; let isChunked = false; let originalFilepath: string | undefined = undefined; let chunkIndex: number | undefined = undefined; let totalChunks: number | undefined = undefined; let lastModified: string | undefined = undefined; if (payload?.dataType === 'file_chunk') { filepathDisplay = payload.filepath; snippetContent = payload.file_content_chunk; isChunked = true; originalFilepath = payload.filepath; chunkIndex = payload.chunk_index; totalChunks = payload.total_chunks; lastModified = payload.last_modified; if (isChunked) { filepathDisplay = `${payload.filepath} (Chunk ${(chunkIndex ?? 0) + 1}/${totalChunks ?? 'N/A'})`; } } else if (payload) { logger.warn(`capability_fetchMoreSearchResults: Received non-file_chunk payload type: ${payload.dataType} for result ID ${r.id}`); filepathDisplay = (payload as { filepath?: string }).filepath || `Unknown path (ID: ${r.id})`; snippetContent = `Non-file content (type: ${payload.dataType})`; } const processedSnippetContent = await processSnippet( snippetContent, query, filepathDisplay, suggestionModelAvailable ); return { filepath: filepathDisplay, snippet: processedSnippetContent, last_modified: lastModified, relevance: r.score, is_chunked: isChunked, original_filepath: originalFilepath, chunk_index: chunkIndex, total_chunks: totalChunks, }; }); return Promise.all(formattedResultsPromises); } // capability_getFullFileContent export async function capability_getFullFileContent( context: CapabilityContext, params: CapabilityGetFullFileContentParams ): Promise<{ filepath: string; content: string }> { // Return type matches previous refactor const { repoPath, suggestionModelAvailable } = context; const { filepath } = params; logger.info(`Executing capability_getFullFileContent for path: ${params.filepath}`); const targetFilePath = path.resolve(repoPath, filepath); if (!targetFilePath.startsWith(path.resolve(repoPath))) { throw new Error(`Access denied: Path "${filepath}" is outside the repository.`); } try { let fileContent = await fs.readFile(targetFilePath, 'utf8'); const MAX_CONTENT_LENGTH = configService.MAX_FILE_CONTENT_LENGTH_FOR_CAPABILITY || 10000; // Use a config value if (fileContent.length > MAX_CONTENT_LENGTH) { if (suggestionModelAvailable) { try { const llmProvider = await getProviderForLLMDependentCaps(); const summaryPrompt = `The user requested the full content of "${filepath}". The content is too long (${fileContent.length} characters). Summarize it concisely, focusing on its main purpose, key functions/classes, and overall structure. Keep the summary informative yet brief.\n\nFile Content (partial):\n${fileContent.substring(0, MAX_CONTENT_LENGTH * 2)}`; // Provide more for summary context fileContent = `Summary of ${filepath}:\n${await llmProvider.generateText(summaryPrompt)}`; logger.info(`Summarized large file content for ${filepath}`); } catch (summaryError) { const sErr = summaryError instanceof Error ? summaryError : new Error(String(summaryError)); logger.warn(`Failed to summarize full file content for ${filepath}. Using truncated content. Error: ${sErr.message}`); fileContent = `Content of ${filepath} is too large. Summary attempt failed. Truncated content:\n${fileContent.substring(0, MAX_CONTENT_LENGTH)}...`; } } else { logger.warn(`Suggestion model not available to summarize large file ${filepath}. Using truncated content.`); fileContent = `Content of ${filepath} is too large. Full content omitted as suggestion model is offline. Truncated content:\n${fileContent.substring(0, MAX_CONTENT_LENGTH)}...`; } } return { filepath, content: fileContent }; } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); logger.error(`Failed to read file "${filepath}": ${err.message}`); throw new Error(`Failed to read file "${filepath}": ${err.message}`); } } // capability_listDirectory export async function capability_listDirectory( context: CapabilityContext, params: CapabilityListDirectoryParams ): Promise<{ path: string; listing: Array<{ name: string; type: 'directory' | 'file' }>; note?: string }> { // Return type matches previous refactor const { repoPath } = context; const { dirPath } = params; logger.info(`Executing capability_listDirectory for path: ${params.dirPath}`); const targetDirPath = path.resolve(repoPath, dirPath); if (!targetDirPath.startsWith(path.resolve(repoPath))) { throw new Error(`Access denied: Path "${dirPath}" is outside the repository.`); } try { const entries = await fs.readdir(targetDirPath, { withFileTypes: true }); const listing = entries.map(entry => ({ name: entry.name, type: entry.isDirectory() ? 'directory' : 'file' as 'directory' | 'file' })); const MAX_DIR_ENTRIES = configService.MAX_DIR_LISTING_ENTRIES_FOR_CAPABILITY || 50; // Use a config value if (listing.length > MAX_DIR_ENTRIES) { return { path: dirPath, listing: listing.slice(0, MAX_DIR_ENTRIES), note: `Listing truncated. Showing first ${MAX_DIR_ENTRIES} of ${listing.length} entries.` }; } return { path: dirPath, listing }; } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); logger.error(`Failed to list directory "${dirPath}": ${err.message}`); throw new Error(`Failed to list directory "${dirPath}": ${err.message}`); } } // capability_getAdjacentFileChunks export interface AdjacentChunkInfo { // This was defined in agent.ts, moved here for co-location filepath: string; chunk_index: number; snippet: string; note?: string; } export async function capability_getAdjacentFileChunks( context: CapabilityContext, params: CapabilityGetAdjacentFileChunksParams ): Promise<{ filepath: string; requested_chunk_index: number; retrieved_chunks: AdjacentChunkInfo[] }> { // Return type matches previous refactor const { qdrantClient } = context; const { filepath, currentChunkIndex } = params; logger.info(`Executing capability_getAdjacentFileChunks for file: "${filepath}", current chunk: ${currentChunkIndex}`); const adjacentChunksResult: AdjacentChunkInfo[] = []; const chunksToFetchIndices = [currentChunkIndex - 1, currentChunkIndex + 1].filter(idx => idx >= 0); for (const targetIndex of chunksToFetchIndices) { try { const scrollResponse = await qdrantClient.scroll(configService.COLLECTION_NAME, { filter: { must: [ { key: "payload.dataType", match: { value: "file_chunk" } }, // Ensure we only get file_chunks { key: "payload.filepath", match: { value: filepath } }, { key: "payload.chunk_index", match: { value: targetIndex } } ] }, limit: 1, with_payload: true, with_vector: false, }); if (scrollResponse.points.length > 0 && scrollResponse.points[0].payload) { const pointPayload = scrollResponse.points[0].payload; // Type is already narrowed by filter if Qdrant respects it fully // Or, we can cast if confident, but better to check dataType if it could be mixed. // Given the filter, it *should* be FileChunkPayload. if (pointPayload.dataType === 'file_chunk') { // Explicit check for safety const fileChunkPayload = pointPayload as unknown as FileChunkPayload; // Cast via unknown adjacentChunksResult.push({ filepath: fileChunkPayload.filepath, chunk_index: fileChunkPayload.chunk_index, snippet: fileChunkPayload.file_content_chunk, }); } else { // This case should ideally not be hit due to the Qdrant filter const actualDataType = (pointPayload && typeof pointPayload === 'object' && 'dataType' in pointPayload) ? (pointPayload as {dataType: string}).dataType : 'unknown'; logger.warn(`capability_getAdjacentFileChunks: Expected file_chunk, got ${actualDataType} for ${filepath} chunk ${targetIndex}`); adjacentChunksResult.push({ filepath: filepath, chunk_index: targetIndex, snippet: "", note: `Chunk ${targetIndex} for file ${filepath} had unexpected data type ${actualDataType}.` }); } } else { adjacentChunksResult.push({ filepath: filepath, chunk_index: targetIndex, snippet: "", // Correct: No payload to access here, so snippet is empty note: `Chunk ${targetIndex} not found for file ${filepath}.` }); } } catch (searchError) { const sErr = searchError instanceof Error ? searchError : new Error(String(searchError)); logger.warn(`Failed to fetch chunk ${targetIndex} for ${filepath}: ${sErr.message}`); adjacentChunksResult.push({ filepath: filepath, chunk_index: targetIndex, snippet: "", note: `Error fetching chunk ${targetIndex} for file ${filepath}: ${sErr.message}` }); } } return { filepath: filepath, requested_chunk_index: currentChunkIndex, retrieved_chunks: adjacentChunksResult }; } // capability_generateSuggestionWithContext export async function capability_generateSuggestionWithContext( context: CapabilityContext, // _context was used, now context is used for suggestionModelAvailable params: CapabilityGenerateSuggestionWithContextParams ): Promise<{ suggestion: string }> { // Return type matches previous refactor const { query, repoPathName, filesContextString, diffSummary, recentQueriesStrings, relevantSnippets } = params; logger.info(`Executing capability_generateSuggestionWithContext for query: ${params.query}`); if (!context.suggestionModelAvailable) { return { suggestion: "Suggestion generation capability requires an LLM, which is currently not available." }; } const prompt = ` **Context**: Repository: ${repoPathName} Files: ${filesContextString} Recent Changes: ${diffSummary ? diffSummary.substring(0, 1000) : "Not available"}${diffSummary && diffSummary.length > 1000 ? "..." : ""} ${recentQueriesStrings.length > 0 ? `Recent Queries: ${recentQueriesStrings.join(", ")}` : ''} **Relevant Snippets**: ${relevantSnippets.map(c => `File: ${c.filepath} (Last modified: ${c.last_modified || 'N/A'}, Relevance: ${(c.relevance || 0).toFixed(2)})${c.is_chunked ? ` [Chunk ${(c.chunk_index ?? 0) + 1}/${c.total_chunks ?? 'N/A'} of ${c.original_filepath}]` : ''}\n${(c.snippet || "").substring(0, 500)}${(c.snippet || "").length > 500 ? "..." : ""}`).join("\n\n")} **Instruction**: Based on the provided context and snippets, generate a detailed code suggestion for "${query}". Include: - A suggested code implementation or improvement. - An explanation of how it addresses the query. - References to the provided snippets or context where applicable. `; const llmProvider = await getProviderForLLMDependentCaps(); const suggestion = await llmProvider.generateText(prompt); return { suggestion: suggestion || "No suggestion generated." }; } // capability_analyzeCodeProblemWithContext export async function capability_analyzeCodeProblemWithContext( context: CapabilityContext, // _context was used, now context is used params: CapabilityAnalyzeCodeProblemWithContextParams ): Promise<{ analysis: string }> { // Return type matches previous refactor const { problemQuery, relevantSnippets } = params; logger.info(`Executing capability_analyzeCodeProblemWithContext for problem: ${params.problemQuery}`); if (!context.suggestionModelAvailable) { return { analysis: "Code problem analysis capability requires an LLM, which is currently not available." }; } const analysisPrompt = ` **Code Problem Analysis** Problem: ${problemQuery} **Relevant Code**: ${relevantSnippets.map(c => `File: ${c.filepath}${c.is_chunked ? ` [Chunk ${(c.chunk_index ?? 0) + 1}/${c.total_chunks ?? 'N/A'} of ${c.original_filepath}]` : ''}\n\`\`\`\n${(c.snippet || "").substring(0, 500)}${(c.snippet || "").length > 500 ? "..." : ""}\n\`\`\``).join("\n\n")} **Instructions**: 1. Analyze the problem described above. 2. Identify potential causes based on the code snippets. 3. List possible solutions. 4. Recommend the best approach. Structure your analysis with these sections: - Problem Understanding - Root Cause Analysis - Potential Solutions - Recommended Approach `; const llmProvider = await getProviderForLLMDependentCaps(); const analysis = await llmProvider.generateText(analysisPrompt); return { analysis }; }