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
JavaScript
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;
}
}