git-contextor
Version:
A code context tool with vector search and real-time monitoring, with optional Git integration.
485 lines (409 loc) • 17.7 kB
JavaScript
const fs = require('fs').promises;
const path = require('path');
const { getEmbedding } = require('../utils/embeddings');
const { countTokens } = require('../utils/tokenizer');
const logger = require('../cli/utils/logger');
const { kmeans } = require('ml-kmeans');
const { generateText, generateTextStream } = require('../utils/llm');
const COLLECTION_SUMMARY_PATH = 'gitcontextor://system/collection-summary.md';
const MODEL_CONTEXT_WINDOWS = {
// OpenAI
'gpt-4': 8192,
'gpt-4-32k': 32768,
'gpt-4-turbo': 128000,
'gpt-4-vision-preview': 128000,
'gpt-4o': 128000,
'gpt-3.5-turbo': 4096,
'gpt-3.5-turbo-16k': 16385,
// Anthropic
'claude-3-opus-20240229': 200000,
'claude-3-sonnet-20240229': 200000,
'claude-3-haiku-20240307': 200000,
'claude-sonnet': 200000, // For simple config
'claude-opus': 200000, // For simple config
'claude-haiku': 200000, // For simple config
// Google
'gemini-1.5-pro-latest': 1000000,
'gemini-1.0-pro': 30720,
'gemini-pro': 30720,
};
// Ein konservatives Verhältnis, um Platz für die Benutzeranfrage, Anweisungen und die Antwort des Modells zu lassen.
const CONTEXT_FILL_RATIO = 0.75;
// Ein großzügiger Standardwert für Modelle, die nicht in unserer Karte sind.
const DEFAULT_MAX_TOKENS = 8192;
/**
* Optimizes context from search results to fit within LLM token limits.
*/
class ContextOptimizer {
/**
* @param {VectorStore} vectorStore - An instance of the VectorStore class.
* @param {object} config - The application configuration object.
*/
constructor(vectorStore, config) {
this.vectorStore = vectorStore;
this.config = config;
}
/**
* Optimizes context from search results to fit within token limits.
* @param {Array} searchResults - Array of search result objects.
* @param {string} query - The search query.
* @param {number} maxTokens - Maximum tokens allowed.
* @returns {Promise<object>} Optimized context object.
*/
async optimizeContext(searchResults, query, maxTokens) {
if (!searchResults || searchResults.length === 0) {
return {
optimizedContext: '',
tokenCount: 0,
chunkCount: 0
};
}
// Sort results by score (highest first)
const sortedResults = searchResults.sort((a, b) => (b.score || 0) - (a.score || 0));
let context = '';
let tokenCount = 0;
let chunkCount = 0;
let clusteredGroups = null;
// Check if clustering is enabled
if (this.config.optimization?.clustering?.enabled) {
// Simple clustering implementation - group by file extension or keyword similarity
clusteredGroups = this._clusterResults(sortedResults);
}
for (const result of sortedResults) {
const chunk = result.payload || result;
const chunkContext = `File: ${chunk.filePath}\nLines ${chunk.start_line}-${chunk.end_line}:\n${chunk.content}\n---\n\n`;
const chunkTokens = this.countTokens(chunkContext);
if (tokenCount + chunkTokens <= maxTokens) {
context += chunkContext;
tokenCount += chunkTokens;
chunkCount++;
} else {
break;
}
}
const result = {
optimizedContext: context,
tokenCount,
chunkCount
};
if (clusteredGroups) {
result.clusteredGroups = clusteredGroups;
}
return result;
}
/**
* Simple clustering implementation
* @param {Array} results - Search results to cluster
* @returns {Array} Clustered groups
* @private
*/
_clusterResults(results) {
// Simple clustering by file extension
const clusters = {};
results.forEach(result => {
const chunk = result.payload || result;
const ext = chunk.filePath.split('.').pop() || 'unknown';
if (!clusters[ext]) {
clusters[ext] = [];
}
clusters[ext].push(result);
});
return Object.entries(clusters).map(([ext, items]) => ({
type: ext,
items: items.length,
files: items.map(item => (item.payload || item).filePath)
}));
}
/**
* Counts tokens in text (simple approximation).
* @param {string} text - Text to count tokens in.
* @returns {number} Estimated token count.
*/
countTokens(text) {
if (!text) return 0;
// Simple approximation: ~4 characters per token
return Math.ceil(text.length / 4);
}
/**
* Performs a search and optimizes the results for a given token limit.
* @param {string} query - The search query.
* @param {object} [options={}] - Search options.
* @param {number} [options.maxTokens=2048] - The maximum number of tokens for the context.
* @param {object} [options.filter=null] - A filter to apply to the search.
* @param {string} [options.llmType='claude-sonnet'] - The LLM type for token counting.
* @returns {Promise<object>} The search results and optimized context.
*/
async search(query, options = {}) {
// 1. Modell und Filter bestimmen
const llmType = options.llmType || this.config.llm?.model || 'claude-sonnet';
const filter = options.filter || null;
let filePathToPrioritize = options.filePath || null;
const includeSummary = options.includeSummary || false;
// 2. maxTokens für den Kontext dynamisch bestimmen
let maxTokens = options.maxTokens;
if (!maxTokens) {
const modelMax = MODEL_CONTEXT_WINDOWS[llmType];
if (modelMax) {
maxTokens = Math.floor(modelMax * CONTEXT_FILL_RATIO);
logger.info(`Dynamically set max context tokens for "${llmType}" to ${maxTokens} (${CONTEXT_FILL_RATIO * 100}% of ${modelMax}).`);
} else {
maxTokens = DEFAULT_MAX_TOKENS;
logger.warn(`Model "${llmType}" not in context window map. Using default max tokens: ${maxTokens}.`);
}
}
logger.info(`Performing search for query: "${query}" with maxTokens: ${maxTokens}, prioritizing file: ${filePathToPrioritize || 'None'}, including summary: ${includeSummary}`);
let allResults = [];
if (includeSummary) {
try {
const summaryDoc = await this.vectorStore.search(
await getEmbedding('repository overview summary', this.config.embedding),
1,
{ must: [{ key: 'filePath', match: { value: COLLECTION_SUMMARY_PATH } }] }
);
if (summaryDoc && summaryDoc.length > 0) {
summaryDoc[0].score = 1.1; // Highest score to prioritize
allResults.push(...summaryDoc);
logger.info(`Added collection summary to context candidates.`);
}
} catch (error) {
logger.warn('Could not retrieve collection summary.', error);
}
}
if (filePathToPrioritize) {
const absoluteFilePath = path.isAbsolute(filePathToPrioritize) ? filePathToPrioritize : path.join(this.config.repository.path, filePathToPrioritize);
try {
const fileContent = await fs.readFile(absoluteFilePath, 'utf8');
const fileTokens = countTokens(fileContent, llmType);
if (fileTokens < maxTokens) {
const prioritizedChunk = {
score: 1.0, // High priority, but lower than summary
payload: {
content: fileContent,
filePath: filePathToPrioritize,
source: 'file-priority'
}
};
allResults.push(prioritizedChunk);
logger.info(`Added prioritized file ${filePathToPrioritize} to context candidates.`);
} else {
logger.warn(`File ${filePathToPrioritize} (${fileTokens} tokens) is too large for context window of ${maxTokens} tokens. Skipping file content prioritization.`);
filePathToPrioritize = null;
}
} catch (error) {
logger.error(`Could not read prioritized file ${filePathToPrioritize}:`, error);
}
}
const queryVector = await getEmbedding(query, this.config.embedding);
if (!queryVector) {
logger.error('Could not generate query embedding.');
return { error: 'Could not generate query embedding.' };
}
const searchLimit = 50;
const searchResults = await this.vectorStore.search(queryVector, searchLimit, filter);
if (searchResults && searchResults.length > 0) {
const uniqueResults = searchResults.filter(r =>
r.payload.filePath !== COLLECTION_SUMMARY_PATH &&
r.payload.filePath !== filePathToPrioritize
);
allResults.push(...uniqueResults);
}
if (allResults.length === 0) {
logger.warn('No results found for query.');
return { query, optimizedContext: '', results: [] };
}
const { optimizedContext, includedResults } = this.packContext(allResults, maxTokens, llmType);
const finalTokenCount = countTokens(optimizedContext, llmType);
logger.info(`Returning ${includedResults.length} results with ${finalTokenCount} tokens.`);
return {
query,
optimizedContext,
results: includedResults,
tokenCount: finalTokenCount
};
}
async chat(query, options = {}) {
// This logic is moved from api/routes/chat.js
const searchResult = await this.search(query, options);
const config = this.config;
const chatConfig = config.chat || {};
let llmConfig = chatConfig.llm || config.llm;
if ((!llmConfig || !llmConfig.provider) && chatConfig.provider) {
llmConfig = chatConfig;
}
if (!llmConfig || !llmConfig.provider) {
throw new Error('LLM configuration is missing or incomplete. Please set your provider and API key.');
}
const aiResponse = await this._generateConversationalResponse(
query,
searchResult.optimizedContext,
options.context_type || 'general',
llmConfig
);
return {
query: query,
response: aiResponse,
context_chunks: searchResult.results // Fix: use context_chunks for UI
};
}
async* generateConversationalResponseStream(query, context, contextType, llmConfig) {
const systemPrompt = `You are an AI assistant that helps developers understand codebases.
You have access to relevant code context and should provide helpful, accurate responses about the repository.
Context type: ${contextType}
Available code context: ${context ? 'Yes' : 'No'}
Guidelines:
- Be concise but thorough
- Focus on code patterns and architecture
- If you don't know the answer, say so. Do not invent information.
- When referencing code, mention the file path.`;
const userPrompt = `Based on the provided context, answer the following query: "${query}"
--- Context ---
${context || 'No context available.'}
--- End Context ---`;
yield* generateTextStream(userPrompt, { systemPrompt }, { llm: llmConfig });
}
async _generateConversationalResponse(query, context, contextType, llmConfig) {
const systemPrompt = `You are an AI assistant that helps developers understand codebases.
You have access to relevant code context and should provide helpful, accurate responses about the repository.
Context type: ${contextType}
Available code context: ${context ? 'Yes' : 'No'}
Guidelines:
- Be concise but thorough
- Focus on code patterns and architecture
- If you don't know the answer, say so. Do not invent information.
- When referencing code, mention the file path.`;
const userPrompt = `Based on the provided context, answer the following query: "${query}"
--- Context ---
${context || 'No context available.'}
--- End Context ---`;
return generateText(
userPrompt,
{ systemPrompt },
{ llm: llmConfig }
);
}
async summarizeCollection(options = {}) {
// Determine the correct LLM configuration, preferring chat settings.
const config = this.config;
const chatConfig = config.chat || {};
let llmConfig = chatConfig.llm || config.llm;
if ((!llmConfig || !llmConfig.provider) && chatConfig.provider) {
llmConfig = chatConfig;
}
if (!llmConfig || !llmConfig.provider) {
throw new Error('LLM configuration is missing or incomplete. Please set your provider and API key, e.g., by running `npx git-contextor config set llm.provider openai` and `npx git-contextor config set llm.apiKey YOUR_OPENAI_KEY`.');
}
const numClusters = options.numClusters || 10;
const pointsPerCluster = options.pointsPerCluster || 5;
logger.info(`Starting collection summary generation with ${numClusters} clusters.`);
const allPoints = await this.vectorStore.getAllPoints();
if (allPoints.length < numClusters) {
logger.warn('Not enough data points to generate a meaningful summary. Aborting.');
return { success: false, message: 'Not enough data points.' };
}
const vectors = allPoints.map(p => p.vector);
const ans = kmeans(vectors, numClusters, { maxIterations: 100 });
const clusters = Array.from({ length: numClusters }, () => []);
ans.clusters.forEach((clusterId, pointIndex) => {
clusters[clusterId].push(pointIndex);
});
let summaryPrompt = 'You are an expert software architect. Below are clustered chunks of code and text from a repository. For each cluster, summarize its core topic or theme in a single, concise headline. Then, list the key technologies, patterns, or concepts found within that cluster. Format the output in Markdown.\n\n';
for (let i = 0; i < clusters.length; i++) {
const clusterPoints = clusters[i].map(pointIndex => allPoints[pointIndex]);
const samplePoints = clusterPoints.slice(0, pointsPerCluster);
if (samplePoints.length === 0) continue;
summaryPrompt += `--- Cluster ${i + 1} ---\n`;
samplePoints.forEach(point => {
summaryPrompt += `File: ${point.payload.filePath}\n\`\`\`\n${point.payload.content}\n\`\`\`\n\n`;
});
}
const summaryContent = await generateText(
summaryPrompt,
{
systemPrompt: 'Generate a summary based on the provided text clusters.'
},
{ llm: llmConfig }
);
logger.info('Generated collection summary. Now indexing it.');
await this.vectorStore.removeFile(COLLECTION_SUMMARY_PATH);
await this.vectorStore.upsertChunks([
{
content: summaryContent,
metadata: {
filePath: COLLECTION_SUMMARY_PATH,
startLine: 1,
endLine: summaryContent.split('\n').length,
},
},
]);
logger.info('Collection summary updated successfully.');
return { success: true, message: 'Collection summary updated.' };
}
/**
* Retrieves the collection summary, creating it if it doesn't exist.
* @returns {Promise<string|null>} The summary content as a string, or null on failure.
*/
async getOrCreateSummary() {
try {
const filter = {
must: [{ key: 'filePath', match: { value: COLLECTION_SUMMARY_PATH } }]
};
let points = await this.vectorStore.getPoints(filter);
if (points && points.length > 0) {
logger.info('Retrieved existing collection summary.');
// The summary might be chunked, but for now it's treated as a single doc.
return points.map(p => p.payload.content).join('\n\n');
}
// If not found, create it.
logger.info('No collection summary found, generating a new one.');
const summaryResult = await this.summarizeCollection();
if (!summaryResult || !summaryResult.success) {
logger.error('Failed to generate collection summary during getOrCreateSummary.');
return null;
}
// Fetch it again.
points = await this.vectorStore.getPoints(filter);
if (points && points.length > 0) {
logger.info('Retrieved newly generated collection summary.');
return points.map(p => p.payload.content).join('\n\n');
} else {
logger.warn('Could not retrieve collection summary even after generating it.');
return null;
}
} catch (error) {
logger.error('Error in getOrCreateSummary:', error);
return null;
}
}
/**
* Packs the most relevant context into a string that respects the token limit.
* @param {Array<object>} results - Search results from the vector store.
* @param {number} maxTokens - The maximum number of tokens allowed.
* @param {string} llmType - The LLM type for tokenization.
* @returns {{optimizedContext: string, includedResults: Array<object>}}
* @private
*/
packContext(results, maxTokens, llmType) {
let currentTokens = 0;
let combinedContext = '';
const includedResults = [];
for (const result of results) {
const chunkContent = result.payload.content;
const contextHeader = `--- File: ${result.payload.filePath} (Score: ${result.score.toFixed(2)}) ---\n`;
const fullChunk = contextHeader + chunkContent + '\n\n';
const chunkTokens = countTokens(fullChunk, llmType);
if (currentTokens + chunkTokens <= maxTokens) {
combinedContext += fullChunk;
currentTokens += chunkTokens;
includedResults.push({
...result.payload,
filePath: result.payload.filePath,
score: result.score,
});
} else {
break;
}
}
return { optimizedContext: combinedContext.trim(), includedResults };
}
}
module.exports = ContextOptimizer;