UNPKG

@probelabs/probe-chat

Version:

CLI and web interface for Probe code search (formerly @probelabs/probe-web and @probelabs/probe-chat)

428 lines (372 loc) 12.3 kB
/** * Configuration management for the implementation tool * @module config */ import fs from 'fs'; import path from 'path'; import { promisify } from 'util'; import { fileURLToPath } from 'url'; import { TIMEOUTS, getDefaultTimeoutMs, secondsToMs, isValidTimeout } from './timeouts.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const readFile = promisify(fs.readFile); const writeFile = promisify(fs.writeFile); const exists = promisify(fs.exists); /** * Configuration manager for implementation backends * @class */ class ConfigManager { constructor() { this.config = null; this.configPath = null; this.watchers = new Map(); this.changeCallbacks = []; } /** * Initialize configuration * @param {string} [configPath] - Path to configuration file * @returns {Promise<void>} */ async initialize(configPath = null) { // Determine config path this.configPath = this.resolveConfigPath(configPath); // Load configuration await this.loadConfig(); // Apply environment overrides this.applyEnvironmentOverrides(); // Set up file watching if (this.configPath && fs.existsSync(this.configPath)) { this.setupWatcher(); } } /** * Resolve configuration file path * @param {string} [providedPath] - User-provided path * @returns {string|null} * @private */ resolveConfigPath(providedPath) { // Priority order: // 1. Provided path // 2. Environment variable // 3. Local config file // 4. Default config file if (providedPath && fs.existsSync(providedPath)) { return providedPath; } if (process.env.IMPLEMENT_TOOL_CONFIG_PATH) { const envPath = process.env.IMPLEMENT_TOOL_CONFIG_PATH; if (fs.existsSync(envPath)) { return envPath; } } // Check for local config in current directory const localConfig = path.join(process.cwd(), 'implement-config.json'); if (fs.existsSync(localConfig)) { return localConfig; } // Fall back to default config const defaultConfig = path.join(__dirname, '..', 'config', 'default.json'); if (fs.existsSync(defaultConfig)) { return defaultConfig; } return null; } /** * Load configuration from file * @returns {Promise<void>} * @private */ async loadConfig() { if (this.configPath && fs.existsSync(this.configPath)) { try { const configData = await readFile(this.configPath, 'utf8'); this.config = JSON.parse(configData); console.error(`Loaded configuration from: ${this.configPath}`); } catch (error) { console.error(`Failed to load configuration from ${this.configPath}:`, error.message); this.config = this.getDefaultConfig(); } } else { console.error('Using default configuration'); this.config = this.getDefaultConfig(); } } /** * Get default configuration * @returns {Object} * @private */ getDefaultConfig() { return { implement: { defaultBackend: 'aider', selectionStrategy: 'auto', maxConcurrentSessions: 3, timeout: getDefaultTimeoutMs(), // Use centralized default (20 minutes) retryAttempts: 2, retryDelay: 5000 }, backends: { aider: { command: 'aider', timeout: getDefaultTimeoutMs(), // Use centralized default (20 minutes) maxOutputSize: 10485760, additionalArgs: [], environment: {}, autoCommit: false, modelSelection: 'auto' }, 'claude-code': { timeout: getDefaultTimeoutMs(), // Use centralized default (20 minutes) maxTokens: 8000, temperature: 0.3, model: 'claude-3-5-sonnet-20241022', systemPrompt: null, tools: ['edit', 'search', 'bash'], maxTurns: 100 } } }; } /** * Apply environment variable overrides * @private */ applyEnvironmentOverrides() { // Backend selection if (process.env.IMPLEMENT_TOOL_BACKEND) { this.config.implement.defaultBackend = process.env.IMPLEMENT_TOOL_BACKEND; console.error(`[ImplementConfig] Setting default backend from env: ${process.env.IMPLEMENT_TOOL_BACKEND}`); } if (process.env.IMPLEMENT_TOOL_FALLBACKS) { this.config.implement.fallbackBackends = process.env.IMPLEMENT_TOOL_FALLBACKS .split(',') .map(s => s.trim()) .filter(Boolean); } if (process.env.IMPLEMENT_TOOL_SELECTION_STRATEGY) { this.config.implement.selectionStrategy = process.env.IMPLEMENT_TOOL_SELECTION_STRATEGY; } if (process.env.IMPLEMENT_TOOL_TIMEOUT) { // Convert seconds to milliseconds for backend compatibility const timeoutSeconds = parseInt(process.env.IMPLEMENT_TOOL_TIMEOUT, 10); if (isNaN(timeoutSeconds)) { console.warn(`[Config] Invalid IMPLEMENT_TOOL_TIMEOUT value: ${process.env.IMPLEMENT_TOOL_TIMEOUT}. Using default: ${TIMEOUTS.IMPLEMENT_DEFAULT}s`); } else if (!isValidTimeout(timeoutSeconds)) { console.warn(`[Config] IMPLEMENT_TOOL_TIMEOUT ${timeoutSeconds}s outside valid range ${TIMEOUTS.IMPLEMENT_MINIMUM}-${TIMEOUTS.IMPLEMENT_MAXIMUM}s. Using default: ${TIMEOUTS.IMPLEMENT_DEFAULT}s`); } else { this.config.implement.timeout = secondsToMs(timeoutSeconds); // Log message removed to prevent stdout pollution } } // Aider backend configuration if (process.env.AIDER_MODEL) { this.config.backends.aider = this.config.backends.aider || {}; this.config.backends.aider.model = process.env.AIDER_MODEL; } if (process.env.AIDER_TIMEOUT) { this.config.backends.aider = this.config.backends.aider || {}; this.config.backends.aider.timeout = parseInt(process.env.AIDER_TIMEOUT, 10); } if (process.env.AIDER_AUTO_COMMIT) { this.config.backends.aider = this.config.backends.aider || {}; this.config.backends.aider.autoCommit = process.env.AIDER_AUTO_COMMIT === 'true'; } if (process.env.AIDER_ADDITIONAL_ARGS) { this.config.backends.aider = this.config.backends.aider || {}; this.config.backends.aider.additionalArgs = process.env.AIDER_ADDITIONAL_ARGS .split(',') .map(s => s.trim()) .filter(Boolean); } // Claude Code backend configuration if (process.env.CLAUDE_CODE_MODEL) { this.config.backends['claude-code'] = this.config.backends['claude-code'] || {}; this.config.backends['claude-code'].model = process.env.CLAUDE_CODE_MODEL; } if (process.env.CLAUDE_CODE_MAX_TOKENS) { this.config.backends['claude-code'] = this.config.backends['claude-code'] || {}; this.config.backends['claude-code'].maxTokens = parseInt(process.env.CLAUDE_CODE_MAX_TOKENS, 10); } if (process.env.CLAUDE_CODE_TEMPERATURE) { this.config.backends['claude-code'] = this.config.backends['claude-code'] || {}; this.config.backends['claude-code'].temperature = parseFloat(process.env.CLAUDE_CODE_TEMPERATURE); } if (process.env.CLAUDE_CODE_MAX_TURNS) { this.config.backends['claude-code'] = this.config.backends['claude-code'] || {}; this.config.backends['claude-code'].maxTurns = parseInt(process.env.CLAUDE_CODE_MAX_TURNS, 10); } } /** * Set up file watcher for configuration changes * @private */ setupWatcher() { if (!this.configPath) return; fs.watchFile(this.configPath, { interval: 2000 }, async (curr, prev) => { if (curr.mtime !== prev.mtime) { console.error('Configuration file changed, reloading...'); await this.reloadConfig(); } }); } /** * Reload configuration from file * @returns {Promise<void>} */ async reloadConfig() { try { const oldConfig = JSON.stringify(this.config); await this.loadConfig(); this.applyEnvironmentOverrides(); const newConfig = JSON.stringify(this.config); if (oldConfig !== newConfig) { this.notifyChangeCallbacks(); } } catch (error) { console.error('Failed to reload configuration:', error); } } /** * Register a callback for configuration changes * @param {Function} callback - Callback function */ onChange(callback) { this.changeCallbacks.push(callback); } /** * Notify all change callbacks * @private */ notifyChangeCallbacks() { for (const callback of this.changeCallbacks) { try { callback(this.config); } catch (error) { console.error('Error in configuration change callback:', error); } } } /** * Get configuration value by path * @param {string} [path] - Dot-separated path (e.g., 'implement.defaultBackend') * @returns {*} */ get(path = null) { if (!path) { return this.config; } const parts = path.split('.'); let value = this.config; for (const part of parts) { if (value && typeof value === 'object' && part in value) { value = value[part]; } else { return undefined; } } return value; } /** * Set configuration value by path * @param {string} path - Dot-separated path * @param {*} value - Value to set */ set(path, value) { const parts = path.split('.'); const lastPart = parts.pop(); let target = this.config; for (const part of parts) { if (!(part in target) || typeof target[part] !== 'object') { target[part] = {}; } target = target[part]; } target[lastPart] = value; } /** * Save configuration to file * @param {string} [path] - Path to save to (defaults to current config path) * @returns {Promise<void>} */ async save(path = null) { const savePath = path || this.configPath; if (!savePath) { throw new Error('No configuration file path specified'); } try { const configData = JSON.stringify(this.config, null, 2); await writeFile(savePath, configData, 'utf8'); console.error(`Configuration saved to: ${savePath}`); } catch (error) { throw new Error(`Failed to save configuration: ${error.message}`); } } /** * Get backend-specific configuration * @param {string} backendName - Backend name * @returns {Object} */ getBackendConfig(backendName) { return this.config.backends?.[backendName] || {}; } /** * Get implementation tool configuration * @returns {Object} */ getImplementConfig() { return this.config.implement || {}; } /** * Validate configuration * @returns {Object} Validation result */ validate() { const errors = []; const warnings = []; // Check required fields if (!this.config.implement?.defaultBackend) { errors.push('implement.defaultBackend is required'); } // Check backend configurations exist const defaultBackend = this.config.implement?.defaultBackend; if (defaultBackend && !this.config.backends?.[defaultBackend]) { warnings.push(`Configuration for default backend '${defaultBackend}' not found`); } // Check fallback backends const fallbackBackends = this.config.implement?.fallbackBackends || []; for (const backend of fallbackBackends) { if (!this.config.backends?.[backend]) { warnings.push(`Configuration for fallback backend '${backend}' not found`); } } // Validate selection strategy const validStrategies = ['auto', 'preference', 'capability']; const strategy = this.config.implement?.selectionStrategy; if (strategy && !validStrategies.includes(strategy)) { errors.push(`Invalid selection strategy: ${strategy}. Must be one of: ${validStrategies.join(', ')}`); } return { valid: errors.length === 0, errors, warnings }; } /** * Clean up resources */ cleanup() { if (this.configPath) { fs.unwatchFile(this.configPath); } this.changeCallbacks = []; this.watchers.clear(); } } // Create singleton instance const configManager = new ConfigManager(); export { ConfigManager, configManager };