UNPKG

ollama-code-qwen

Version:

Un assistant IA en ligne de commande utilisant Ollama et le modèle qwen2.5-coder pour aider au développement, avec des capacités MCP améliorées et détection d'intentions en français et anglais

651 lines (545 loc) 22 kB
import fs from 'fs/promises'; import path from 'path'; import { promisify } from 'util'; import { exec as execCallback } from 'child_process'; const exec = promisify(execCallback); /** * Gère l'extraction de contexte du projet actuel de manière avancée */ export class ContextManager { /** * Initialise le gestionnaire de contexte * @param {string} basePath - Chemin de base du projet * @param {Object} options - Options supplémentaires */ constructor(basePath, options = {}) { this.basePath = basePath; this.maxFiles = options.maxFiles || 30; // Augmenté de 5 à 30 this.maxFileSize = options.maxFileSize || 30000; // Augmenté de 10KB à 30KB this.maxDepth = options.maxDepth || 5; // Profondeur maximale de récursion accrue this.config = options.config; this.maxContextSize = options.maxContextSize || (this.config ? this.config.get('maxContextSize') : 100000); this.smartPrioritization = options.smartPrioritization || (this.config ? this.config.get('smartContextPrioritization') : true); // Système de cache pour les performances this.cacheService = options.cacheService; // Garder trace des fichiers récemment modifiés ou consultés this.recentFiles = new Map(); // fileName -> { lastAccessed, relevanceScore } // Nouveau système de scoring pour hiérarchiser les fichiers this.fileScores = new Map(); this.lastAccessedFiles = []; // Répertoires à ignorer par défaut this.ignoreDirs = options.ignoreDirs || new Set([ '.git', '.github', 'node_modules', 'venv', '.venv', '__pycache__', 'dist', 'build', '.idea', '.vscode' ]); // Modèles de fichiers à ignorer par défaut this.ignorePatterns = options.ignorePatterns || [ /\.pyc$/, /\.pyo$/, /\.mo$/, /\.db$/, /\.css\.map$/, /\.egg-info$/, /\.egg$/, /\.DS_Store$/, /\.git/, /\.png$/, /\.jpg$/, /\.jpeg$/, /\.gif$/, /\.pdf$/, /\.zip$/, /\.tar\.gz$/ ]; } /** * Récupère le contexte du projet actuel * @returns {Promise<string>} - Chaîne contenant la structure du projet et le contenu des fichiers pertinents */ async getContext() { // Vérifier le cache si disponible if (this.cacheService) { const cacheKey = { path: this.basePath, maxFiles: this.maxFiles, maxFileSize: this.maxFileSize, maxDepth: this.maxDepth }; const cachedContext = await this.cacheService.get('projectContext', cacheKey); if (cachedContext) { return cachedContext; } } // Si pas en cache ou pas de service de cache, générer le contexte normalement const context = []; // Ajouter une vue d'ensemble de la structure du projet context.push('# Structure du Projet\n'); context.push(await this._getProjectStructure()); context.push('\n\n'); // Ajouter le contenu des fichiers pertinents context.push('# Fichiers Pertinents\n'); const relevantFiles = await this._findRelevantFiles(); for (const filePath of relevantFiles.slice(0, this.maxFiles)) { try { const content = await fs.readFile(filePath, 'utf-8'); const trimmedContent = content.length > this.maxFileSize ? content.substring(0, this.maxFileSize) + '...[tronqué]' : content; const relPath = path.relative(this.basePath, filePath); context.push(`## ${relPath}\n`); context.push('```\n'); context.push(trimmedContent); context.push('\n```\n\n'); } catch (error) { // Ignorer les fichiers qui ne peuvent pas être lus } } const fullContext = context.join(''); // Mettre en cache si le service est disponible if (this.cacheService) { const cacheKey = { path: this.basePath, maxFiles: this.maxFiles, maxFileSize: this.maxFileSize, maxDepth: this.maxDepth }; // Cache valide pendant 5 minutes par défaut await this.cacheService.set('projectContext', cacheKey, fullContext, 5 * 60 * 1000); } return fullContext; } /** * Récupère le contexte pour un répertoire spécifique (mode focus) * @param {string} dirPath - Chemin du répertoire * @returns {Promise<string>} - Contexte du répertoire */ async getContextForDirectory(dirPath) { const context = []; // Obtenir le chemin relatif const relPath = path.relative(this.basePath, dirPath); context.push(`# Focus sur le répertoire: ${relPath || '.'}\n\n`); // Ajouter la structure du répertoire context.push('## Structure\n'); try { const files = await fs.readdir(dirPath, { withFileTypes: true }); for (const file of files) { if (this.ignoreDirs.has(file.name) || this.ignorePatterns.some(pattern => pattern.test(file.name))) { continue; } if (file.isDirectory()) { context.push(`- 📁 ${file.name}/\n`); } else { context.push(`- 📄 ${file.name}\n`); } } } catch (error) { context.push(`Erreur lors de la lecture du répertoire: ${error.message}\n`); } context.push('\n'); // Ajouter le contenu des fichiers importants dans ce répertoire context.push('## Fichiers dans ce répertoire\n\n'); try { const files = await fs.readdir(dirPath, { withFileTypes: true }); const scoredFiles = []; // Scorer les fichiers de ce répertoire for (const file of files) { if (file.isDirectory() || this.ignorePatterns.some(pattern => pattern.test(file.name))) { continue; } const filePath = path.join(dirPath, file.name); try { const stats = await fs.stat(filePath); if (stats.size > this.maxFileSize) continue; const score = await this._calculateFileRelevance(filePath, stats); scoredFiles.push({ path: filePath, score }); } catch (error) { // Ignorer les erreurs } } // Trier par score et limiter le nombre scoredFiles.sort((a, b) => b.score - a.score); const filesToInclude = scoredFiles.slice(0, 10); // Limiter à 10 fichiers // Ajouter le contenu des fichiers for (const file of filesToInclude) { try { const content = await fs.readFile(file.path, 'utf-8'); const trimmedContent = content.length > this.maxFileSize ? content.substring(0, this.maxFileSize) + '...[tronqué]' : content; context.push(`### ${path.basename(file.path)}\n`); context.push('```\n'); context.push(trimmedContent); context.push('\n```\n\n'); } catch (error) { // Ignorer les erreurs } } } catch (error) { context.push(`Erreur lors de l'analyse des fichiers: ${error.message}\n`); } return context.join(''); } /** * Obtient une vue d'ensemble de la structure du projet * @returns {Promise<string>} - Représentation de la structure du projet */ async _getProjectStructure() { try { // Si ripgrep (rg) est disponible, l'utiliser pour une meilleure performance const hasRipgrep = await this._commandExists('rg'); if (hasRipgrep) { // Utiliser ripgrep pour générer rapidement une liste de fichiers const { stdout } = await exec(`rg --files --no-ignore --hidden ${this.basePath}`, { maxBuffer: 10 * 1024 * 1024 }); const files = stdout.split('\n').filter(Boolean); return this._formatFileList(files); } else { // Fallback vers une implémentation JavaScript native return this._getDirectoryStructure(this.basePath); } } catch (error) { // En cas d'erreur, revenir à l'implémentation JS native return this._getDirectoryStructure(this.basePath); } } /** * Vérifie si une commande existe sur le système * @param {string} command - Nom de la commande * @returns {Promise<boolean>} - True si la commande existe */ async _commandExists(command) { try { const checkCmd = process.platform === 'win32' ? `where ${command}` : `command -v ${command}`; await exec(checkCmd); return true; } catch (error) { return false; } } /** * Formate une liste de fichiers en une représentation arborescente * @param {Array<string>} files - Liste de chemins de fichiers * @returns {string} - Représentation formatée */ _formatFileList(files) { const output = []; const maxFilesPerDir = 10; // Trier les fichiers par chemin files.sort(); // Supprimer le chemin de base et filtrer les fichiers ignorés const relativePaths = files .map(file => path.relative(this.basePath, file)) .filter(file => { const parts = file.split(path.sep); return !parts.some(part => this.ignoreDirs.has(part)) && !this.ignorePatterns.some(pattern => pattern.test(file)); }); // Regrouper par répertoire const dirMap = new Map(); for (const file of relativePaths) { const dir = path.dirname(file); if (!dirMap.has(dir)) { dirMap.set(dir, []); } dirMap.get(dir).push(path.basename(file)); } // Générer la sortie formatée for (const [dir, files] of dirMap.entries()) { const level = dir.split(path.sep).length - 1; const indent = ' '.repeat(4 * level); if (dir !== '.') { output.push(`${indent}${dir}/`); } else { output.push(`${indent}.`); } const subIndent = ' '.repeat(4 * (level + 1)); const displayedFiles = files.slice(0, maxFilesPerDir); for (const file of displayedFiles) { output.push(`${subIndent}${file}`); } if (files.length > maxFilesPerDir) { output.push(`${subIndent}... (${files.length - maxFilesPerDir} autres fichiers)`); } } return output.join('\n'); } /** * Génère la structure d'un répertoire de manière récursive * @param {string} dirPath - Chemin du répertoire * @param {number} level - Niveau d'indentation * @returns {Promise<string>} - Représentation de la structure du répertoire */ async _getDirectoryStructure(dirPath, level = 0) { // Vérifier la profondeur maximale if (level > this.maxDepth) { return `${' '.repeat(4 * level)}... (profondeur maximale atteinte)`; } const output = []; const indent = ' '.repeat(4 * level); try { const files = await fs.readdir(dirPath); // Ajouter le répertoire actuel if (level === 0) { output.push(`${indent}.`); } else { const dirName = path.basename(dirPath); output.push(`${indent}${dirName}/`); } // Limiter le nombre de fichiers affichés par répertoire const maxFilesPerDir = 10; let fileCount = 0; for (const file of files) { const filePath = path.join(dirPath, file); const stats = await fs.stat(filePath); // Ignorer les répertoires et fichiers spécifiés if (this.ignoreDirs.has(file) || this.ignorePatterns.some(pattern => pattern.test(file))) { continue; } if (stats.isDirectory()) { // Récursion pour les sous-répertoires const subStructure = await this._getDirectoryStructure(filePath, level + 1); output.push(subStructure); } else { // Ajouter les fichiers, limitant à maxFilesPerDir if (fileCount < maxFilesPerDir) { output.push(`${indent} ${file}`); fileCount++; } } } // Indiquer s'il y a plus de fichiers que ceux affichés if (fileCount >= maxFilesPerDir) { output.push(`${indent} ... (d'autres fichiers non affichés)`); } return output.join('\n'); } catch (error) { return `${indent}Erreur lors de la lecture du répertoire: ${error.message}`; } } /** * Calcule un score de pertinence pour un fichier * @param {string} filePath - Chemin du fichier * @param {Object} stats - Statistiques du fichier * @returns {number} - Score de pertinence */ async _calculateFileRelevance(filePath, stats) { let score = 0; const fileName = path.basename(filePath); const extension = path.extname(filePath).toLowerCase(); // Facteur 1: Récence de modification (plus c'est récent, plus c'est pertinent) const ageInDays = (Date.now() - stats.mtime) / (1000 * 60 * 60 * 24); score += Math.max(0, 10 - ageInDays); // 10 points max, diminue avec l'âge // Facteur 2: Type de fichier const importantExtensions = new Set(['.js', '.jsx', '.ts', '.tsx', '.py', '.java', '.go', '.cpp', '.c', '.rb']); const configFiles = new Set(['package.json', 'tsconfig.json', '.eslintrc', 'requirements.txt', 'setup.py']); const docFiles = new Set(['README.md', 'CONTRIBUTING.md', 'CHANGELOG.md', 'LICENSE']); if (importantExtensions.has(extension)) { score += 5; } if (configFiles.has(fileName)) { score += 10; } if (docFiles.has(fileName)) { score += 8; } // Facteur 3: Taille du fichier (valorise les fichiers de taille moyenne) if (stats.size > 0 && stats.size < 1000) { score += 2; // Petit fichier } else if (stats.size >= 1000 && stats.size < 10000) { score += 5; // Taille moyenne, probablement plus pertinent } else if (stats.size >= 10000) { score += 1; // Grand fichier, peut être moins spécifique } // Facteur 4: Historique d'accès (valorise les fichiers récemment consultés) if (this.lastAccessedFiles.includes(filePath)) { const index = this.lastAccessedFiles.indexOf(filePath); score += 10 - Math.min(9, index); // 10 points pour le plus récent, diminue progressivement } // Facteur 5: Détection de fichier principal const mainFilePatterns = [ /index\.(js|ts|py|java|go)$/, /main\.(js|ts|py|java|go)$/, /app\.(js|ts|py|java|go)$/ ]; if (mainFilePatterns.some(pattern => pattern.test(fileName))) { score += 15; // Bonus important pour les fichiers principaux } return score; } /** * Trouve les fichiers pertinents pour le contexte * @returns {Promise<Array<string>>} - Liste des chemins de fichiers */ async _findRelevantFiles() { const allFiles = []; const scoredFiles = []; // Trouver tous les fichiers récursivement const findFiles = async (dir, depth = 0) => { if (depth > this.maxDepth) return; try { const files = await fs.readdir(dir, { withFileTypes: true }); for (const file of files) { const filePath = path.join(dir, file.name); // Ignorer les répertoires spécifiés if (file.isDirectory()) { if (!this.ignoreDirs.has(file.name)) { await findFiles(filePath, depth + 1); } continue; } // Ignorer les fichiers correspondant aux modèles d'exclusion if (this.ignorePatterns.some(pattern => pattern.test(file.name))) { continue; } try { const stats = await fs.stat(filePath); // Ignorer les fichiers trop volumineux if (stats.size > this.maxFileSize) { continue; } // Calculer un score de pertinence const score = await this._calculateFileRelevance(filePath, stats); scoredFiles.push({ path: filePath, score, stats }); allFiles.push(filePath); } catch (error) { // Ignorer les erreurs de stat } } } catch (error) { // Ignorer les erreurs de lecture du répertoire } }; await findFiles(this.basePath); // Trier les fichiers par score (décroissant) scoredFiles.sort((a, b) => b.score - a.score); // Mettre à jour le cache de scores scoredFiles.forEach(file => { this.fileScores.set(file.path, file.score); }); // Retourner les chemins, triés par score return scoredFiles.map(file => file.path); } /** * Marque un fichier comme récemment accédé * @param {string} filePath - Chemin du fichier */ trackFileAccess(filePath) { // Retirer le fichier s'il est déjà dans la liste const index = this.lastAccessedFiles.indexOf(filePath); if (index !== -1) { this.lastAccessedFiles.splice(index, 1); } // Ajouter le fichier au début de la liste this.lastAccessedFiles.unshift(filePath); // Garder uniquement les 10 derniers fichiers if (this.lastAccessedFiles.length > 10) { this.lastAccessedFiles.pop(); } } /** * Détecte automatiquement le type de projet et les technologies utilisées * @returns {Promise<Object>} - Informations sur le projet */ async detectProjectType() { const projectInfo = { type: null, language: null, framework: null, buildTools: [], database: null, testing: [] }; // Rechercher des fichiers de configuration courants const configFiles = []; const findConfigFiles = async (dir) => { const files = await fs.readdir(dir, { withFileTypes: true }); for (const file of files) { const fileName = file.name.toLowerCase(); if (file.isFile()) { configFiles.push({ name: fileName, path: path.join(dir, fileName) }); } else if (file.isDirectory() && !this.ignoreDirs.has(file.name)) { // Limiter la profondeur de recherche pour les fichiers de config if (configFiles.length < 100) { // Limite arbitraire await findConfigFiles(path.join(dir, file.name)); } } } }; await findConfigFiles(this.basePath); // Détecter le langage et le framework principal const hasFile = (name) => configFiles.some(f => f.name === name); const hasFilePattern = (pattern) => configFiles.some(f => pattern.test(f.name)); // JavaScript/TypeScript if (hasFile('package.json')) { projectInfo.language = 'javascript'; // Lire package.json pour plus d'informations try { const packageJsonPath = configFiles.find(f => f.name === 'package.json').path; const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')); // Détecter les frameworks const deps = { ...packageJson.dependencies, ...packageJson.devDependencies }; if (deps) { if (deps.react) projectInfo.framework = 'react'; else if (deps.vue) projectInfo.framework = 'vue'; else if (deps.angular) projectInfo.framework = 'angular'; else if (deps.express) projectInfo.framework = 'express'; else if (deps.next) projectInfo.framework = 'nextjs'; else if (deps.gatsby) projectInfo.framework = 'gatsby'; // Outils de build if (deps.webpack) projectInfo.buildTools.push('webpack'); if (deps.babel) projectInfo.buildTools.push('babel'); if (deps.parcel) projectInfo.buildTools.push('parcel'); if (deps.vite) projectInfo.buildTools.push('vite'); // Testing if (deps.jest) projectInfo.testing.push('jest'); if (deps.mocha) projectInfo.testing.push('mocha'); if (deps.chai) projectInfo.testing.push('chai'); if (deps.cypress) projectInfo.testing.push('cypress'); // Database if (deps.mongoose) projectInfo.database = 'mongodb'; if (deps.sequelize) projectInfo.database = 'sql'; if (deps.typeorm) projectInfo.database = 'sql'; } projectInfo.type = packageJson.type === 'module' ? 'esm' : 'commonjs'; // TypeScript if (hasFile('tsconfig.json')) { projectInfo.language = 'typescript'; } } catch (error) { // Ignorer les erreurs de parsing } } // Python else if (hasFile('requirements.txt') || hasFile('setup.py') || hasFile('pyproject.toml')) { projectInfo.language = 'python'; if (hasFile('django')) projectInfo.framework = 'django'; else if (hasFile('flask')) projectInfo.framework = 'flask'; else if (hasFile('fastapi')) projectInfo.framework = 'fastapi'; if (hasFile('pytest.ini')) projectInfo.testing.push('pytest'); if (hasFile('unittest')) projectInfo.testing.push('unittest'); if (hasFilePattern(/migrations/)) projectInfo.database = 'sql'; } // Java else if (hasFile('pom.xml') || hasFile('build.gradle')) { projectInfo.language = 'java'; if (hasFile('pom.xml')) { projectInfo.buildTools.push('maven'); } if (hasFile('build.gradle')) { projectInfo.buildTools.push('gradle'); } // Frameworks if (hasFilePattern(/spring/)) projectInfo.framework = 'spring'; if (hasFilePattern(/jakarta/)) projectInfo.framework = 'jakarta-ee'; } // Go else if (hasFile('go.mod')) { projectInfo.language = 'go'; } // Ruby else if (hasFile('Gemfile')) { projectInfo.language = 'ruby'; if (hasFilePattern(/rails/)) projectInfo.framework = 'rails'; } return projectInfo; } }