UNPKG

@probelabs/probe-chat

Version:

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

492 lines (428 loc) 17.8 kB
// Import tool generators and instances from @probelabs/probe package import { searchTool, queryTool, extractTool, DEFAULT_SYSTEM_MESSAGE, listFilesToolInstance as packageListFilesToolInstance, searchFilesToolInstance as packageSearchFilesToolInstance } from '@probelabs/probe'; import { spawn } from 'child_process'; import { randomUUID } from 'crypto'; import { EventEmitter } from 'events'; // Import the new pluggable implementation tool import { createImplementTool } from './implement/core/ImplementTool.js'; // Create an event emitter for tool calls export const toolCallEmitter = new EventEmitter(); // Map to track active tool executions by session ID const activeToolExecutions = new Map(); // Function to check if a session has been cancelled export function isSessionCancelled(sessionId) { return activeToolExecutions.get(sessionId)?.cancelled || false; } // Function to cancel all tool executions for a session export function cancelToolExecutions(sessionId) { // Only log if not in non-interactive mode or if in debug mode if (process.env.PROBE_NON_INTERACTIVE !== '1' || process.env.DEBUG_CHAT === '1') { console.log(`Cancelling tool executions for session: ${sessionId}`); } const sessionData = activeToolExecutions.get(sessionId); if (sessionData) { sessionData.cancelled = true; // Only log if not in non-interactive mode or if in debug mode if (process.env.PROBE_NON_INTERACTIVE !== '1' || process.env.DEBUG_CHAT === '1') { console.log(`Session ${sessionId} marked as cancelled`); } return true; } return false; } // Function to register a new tool execution function registerToolExecution(sessionId) { if (!sessionId) return; if (!activeToolExecutions.has(sessionId)) { activeToolExecutions.set(sessionId, { cancelled: false }); } else { // Reset cancelled flag if session already exists for a new execution activeToolExecutions.get(sessionId).cancelled = false; } } // Function to clear tool execution data for a session export function clearToolExecutionData(sessionId) { if (!sessionId) return; if (activeToolExecutions.has(sessionId)) { activeToolExecutions.delete(sessionId); // Only log if not in non-interactive mode or if in debug mode if (process.env.PROBE_NON_INTERACTIVE !== '1' || process.env.DEBUG_CHAT === '1') { console.log(`Cleared tool execution data for session: ${sessionId}`); } } } // Generate a default session ID (less relevant now, session is managed per-chat) const defaultSessionId = randomUUID(); // Only log session ID in debug mode if (process.env.DEBUG_CHAT === '1') { console.log(`Generated default session ID (probeTool.js): ${defaultSessionId}`); } // Create configured tools with the session ID // Note: These configOptions are less critical now as sessionId is passed explicitly const configOptions = { sessionId: defaultSessionId, debug: process.env.DEBUG_CHAT === '1' }; // Helper function to truncate long argument values for logging function truncateArgValue(value, maxLength = 200) { if (typeof value !== 'string') { value = JSON.stringify(value); } if (value.length <= maxLength * 2) { return value; } // Show first 200 and last 200 characters return `${value.substring(0, maxLength)}...${value.substring(value.length - maxLength)}`; } // Helper function to format tool arguments for debug logging function formatToolArgs(args) { const formatted = {}; for (const [key, value] of Object.entries(args)) { formatted[key] = truncateArgValue(value); } return formatted; } // Create the base tools using the imported generators const baseSearchTool = searchTool(configOptions); const baseQueryTool = queryTool(configOptions); const baseExtractTool = extractTool(configOptions); // Wrap the tools to emit events and handle cancellation const wrapToolWithEmitter = (tool, toolName, baseExecute) => { return { ...tool, // Spread schema, description etc. execute: async (params) => { // The execute function now receives parsed params const debug = process.env.DEBUG_CHAT === '1'; // Get the session ID from params (passed down from probeChat.js) const toolSessionId = params.sessionId || defaultSessionId; // Fallback, but should always have sessionId if (debug) { console.log(`\n[DEBUG] ========================================`); console.log(`[DEBUG] Tool Call: ${toolName}`); console.log(`[DEBUG] Session: ${toolSessionId}`); console.log(`[DEBUG] Arguments:`); const formattedArgs = formatToolArgs(params); for (const [key, value] of Object.entries(formattedArgs)) { console.log(`[DEBUG] ${key}: ${value}`); } console.log(`[DEBUG] ========================================\n`); } // Register this tool execution (and reset cancel flag if needed) registerToolExecution(toolSessionId); // Check if this session has been cancelled *before* execution if (isSessionCancelled(toolSessionId)) { // Only log if not in non-interactive mode or if in debug mode console.error(`Tool execution cancelled BEFORE starting for session ${toolSessionId}`); throw new Error(`Tool execution cancelled for session ${toolSessionId}`); } // Only log if not in non-interactive mode or if in debug mode console.error(`Executing ${toolName} for session ${toolSessionId}`); // Simplified log // Remove sessionId from params before passing to base tool if it expects only schema params const { sessionId, ...toolParams } = params; try { // Emit a tool call start event const toolCallStartData = { timestamp: new Date().toISOString(), name: toolName, args: toolParams, // Log schema params status: 'started' }; if (debug) { console.log(`[DEBUG] probeTool: Emitting toolCallStart:${toolSessionId}`); } toolCallEmitter.emit(`toolCall:${toolSessionId}`, toolCallStartData); // Execute the original tool's execute function with schema params // Use a promise-based approach with cancellation check let result = null; let executionError = null; const executionPromise = baseExecute(toolParams).catch(err => { executionError = err; // Capture error }); const checkInterval = 50; // Check every 50ms while (result === null && executionError === null) { if (isSessionCancelled(toolSessionId)) { console.error(`Tool execution cancelled DURING execution for session ${toolSessionId}`); // Attempt to signal cancellation if the underlying tool supports it (future enhancement) // For now, just throw the cancellation error throw new Error(`Tool execution cancelled for session ${toolSessionId}`); } // Check if promise is resolved or rejected const status = await Promise.race([ executionPromise.then(() => 'resolved').catch(() => 'rejected'), new Promise(resolve => setTimeout(() => resolve('pending'), checkInterval)) ]); if (status === 'resolved') { result = await executionPromise; // Get the result } else if (status === 'rejected') { // Error already captured by the catch block on executionPromise break; } // If 'pending', continue loop } // If loop exited due to error if (executionError) { throw executionError; } // If loop exited due to cancellation within the loop if (isSessionCancelled(toolSessionId)) { // Only log if not in non-interactive mode or if in debug mode if (process.env.PROBE_NON_INTERACTIVE !== '1' || process.env.DEBUG_CHAT === '1') { console.log(`Tool execution finished but session was cancelled for ${toolSessionId}`); } throw new Error(`Tool execution cancelled for session ${toolSessionId}`); } // Emit the tool call completion event const toolCallData = { timestamp: new Date().toISOString(), name: toolName, args: toolParams, // Safely preview result resultPreview: typeof result === 'string' ? (result.length > 200 ? result.substring(0, 200) + '...' : result) : (result ? JSON.stringify(result).substring(0, 200) + '...' : 'No Result'), status: 'completed' }; if (debug) { console.log(`[DEBUG] probeTool: Emitting toolCall:${toolSessionId} (completed)`); } toolCallEmitter.emit(`toolCall:${toolSessionId}`, toolCallData); return result; } catch (error) { // If it's a cancellation error, re-throw it directly if (error.message.includes('cancelled for session')) { // Only log if not in non-interactive mode or if in debug mode if (process.env.PROBE_NON_INTERACTIVE !== '1' || process.env.DEBUG_CHAT === '1') { console.log(`Caught cancellation error for ${toolName} in session ${toolSessionId}`); } // Emit cancellation event? Or let the caller handle it? Let caller handle. throw error; } // Handle other execution errors if (debug) { console.error(`[DEBUG] probeTool: Error executing ${toolName}:`, error); } // Emit a tool call error event const toolCallErrorData = { timestamp: new Date().toISOString(), name: toolName, args: toolParams, error: error.message || 'Unknown error', status: 'error' }; if (debug) { console.log(`[DEBUG] probeTool: Emitting toolCall:${toolSessionId} (error)`); } toolCallEmitter.emit(`toolCall:${toolSessionId}`, toolCallErrorData); throw error; // Re-throw the error to be caught by probeChat.js loop } } }; }; // Create the implement tool using the new pluggable system const implementToolConfig = { enabled: process.env.ALLOW_EDIT === '1' || process.argv.includes('--allow-edit'), backendConfig: { // Configuration can be extended here } }; const pluggableImplementTool = createImplementTool(implementToolConfig); // Create a compatibility wrapper for the old interface const baseImplementTool = { name: "implement", description: pluggableImplementTool.description, inputSchema: pluggableImplementTool.inputSchema, execute: async ({ task, autoCommits = false, prompt, sessionId }) => { const debug = process.env.DEBUG_CHAT === '1'; if (debug) { console.log(`[DEBUG] Executing implementation with task: ${task}`); console.log(`[DEBUG] Auto-commits: ${autoCommits}`); console.log(`[DEBUG] Session ID: ${sessionId}`); if (prompt) console.log(`[DEBUG] Custom prompt: ${prompt}`); } // Check if the tool is enabled if (!implementToolConfig.enabled) { return { success: false, output: null, error: 'Implementation tool is not enabled. Use --allow-edit flag to enable.', command: null, timestamp: new Date().toISOString(), prompt: prompt || task }; } try { // Use the new pluggable implementation tool const result = await pluggableImplementTool.execute({ task: prompt || task, // Use prompt if provided, otherwise use task autoCommit: autoCommits, sessionId: sessionId, // Pass through any additional options that might be useful context: { workingDirectory: process.cwd() } }); // The result is already in the expected format return result; } catch (error) { // Handle any unexpected errors console.error(`Error in implement tool:`, error); return { success: false, output: null, error: error.message || 'Unknown error in implementation tool', command: null, timestamp: new Date().toISOString(), prompt: prompt || task }; } } }; // Wrapper for listFiles tool with ALLOWED_FOLDERS security const baseListFilesTool = { ...packageListFilesToolInstance, execute: async (params) => { const { directory = '.', sessionId } = params; const debug = process.env.DEBUG_CHAT === '1'; const currentWorkingDir = process.cwd(); // Get allowed folders from environment variable const allowedFoldersEnv = process.env.ALLOWED_FOLDERS; let allowedFolders = []; if (allowedFoldersEnv) { allowedFolders = allowedFoldersEnv.split(',').map(folder => folder.trim()).filter(folder => folder.length > 0); } // Handle default directory behavior when ALLOWED_FOLDERS is set let targetDirectory = directory; if (allowedFolders.length > 0 && (directory === '.' || directory === './')) { // Use the first allowed folder if directory is current directory targetDirectory = allowedFolders[0]; if (debug) { console.log(`[DEBUG] Redirecting from '${directory}' to first allowed folder: ${targetDirectory}`); } } const targetDir = require('path').resolve(currentWorkingDir, targetDirectory); // Validate that the target directory is within allowed folders if (allowedFolders.length > 0) { const isAllowed = allowedFolders.some(allowedFolder => { const resolvedAllowedFolder = require('path').resolve(currentWorkingDir, allowedFolder); return targetDir === resolvedAllowedFolder || targetDir.startsWith(resolvedAllowedFolder + require('path').sep); }); if (!isAllowed) { const error = `Access denied: Directory '${targetDirectory}' is not within allowed folders: ${allowedFolders.join(', ')}`; if (debug) { console.log(`[DEBUG] ${error}`); } return `Error: ${error}`; } } // Call the package tool with workingDirectory parameter return packageListFilesToolInstance.execute({ ...params, directory: targetDirectory, workingDirectory: currentWorkingDir }); } }; // Wrapper for searchFiles tool with ALLOWED_FOLDERS security const baseSearchFilesTool = { ...packageSearchFilesToolInstance, execute: async (params) => { const { pattern, directory = '.', recursive = true, sessionId } = params; const debug = process.env.DEBUG_CHAT === '1'; const currentWorkingDir = process.cwd(); // Get allowed folders from environment variable const allowedFoldersEnv = process.env.ALLOWED_FOLDERS; let allowedFolders = []; if (allowedFoldersEnv) { allowedFolders = allowedFoldersEnv.split(',').map(folder => folder.trim()).filter(folder => folder.length > 0); } // Handle default directory behavior when ALLOWED_FOLDERS is set let targetDirectory = directory; if (allowedFolders.length > 0 && (directory === '.' || directory === './')) { // Use the first allowed folder if directory is current directory targetDirectory = allowedFolders[0]; if (debug) { console.log(`[DEBUG] Redirecting from '${directory}' to first allowed folder: ${targetDirectory}`); } } const targetDir = require('path').resolve(currentWorkingDir, targetDirectory); // Validate that the target directory is within allowed folders if (allowedFolders.length > 0) { const isAllowed = allowedFolders.some(allowedFolder => { const resolvedAllowedFolder = require('path').resolve(currentWorkingDir, allowedFolder); return targetDir === resolvedAllowedFolder || targetDir.startsWith(resolvedAllowedFolder + require('path').sep); }); if (!isAllowed) { const error = `Access denied: Directory '${targetDirectory}' is not within allowed folders: ${allowedFolders.join(', ')}`; if (debug) { console.log(`[DEBUG] ${error}`); } return { success: false, directory: targetDir, pattern: pattern, error: error, timestamp: new Date().toISOString() }; } } // Log execution parameters to stderr for visibility console.error(`Executing searchFiles with params: pattern="${pattern}", directory="${targetDirectory}", recursive=${recursive}`); try { // Call the package tool with workingDirectory parameter const files = await packageSearchFilesToolInstance.execute({ ...params, directory: targetDirectory, recursive, workingDirectory: currentWorkingDir }); if (debug) { console.log(`[DEBUG] Found ${files.length} files matching pattern ${pattern}`); } // Return in the expected format for backward compatibility return { success: true, directory: targetDir, pattern: pattern, recursive: recursive, files: files.map(file => require('path').join(targetDirectory, file)), count: files.length, totalMatches: files.length, limited: false, timestamp: new Date().toISOString() }; } catch (error) { console.error(`Error searching files with pattern "${pattern}" in ${targetDir}:`, error); return { success: false, directory: targetDir, pattern: pattern, error: error.message || 'Unknown error searching files', timestamp: new Date().toISOString() }; } } }; // Export the wrapped tool instances export const searchToolInstance = wrapToolWithEmitter(baseSearchTool, 'search', baseSearchTool.execute); export const queryToolInstance = wrapToolWithEmitter(baseQueryTool, 'query', baseQueryTool.execute); export const extractToolInstance = wrapToolWithEmitter(baseExtractTool, 'extract', baseExtractTool.execute); export const implementToolInstance = wrapToolWithEmitter(baseImplementTool, 'implement', baseImplementTool.execute); export const listFilesToolInstance = wrapToolWithEmitter(baseListFilesTool, 'listFiles', baseListFilesTool.execute); export const searchFilesToolInstance = wrapToolWithEmitter(baseSearchFilesTool, 'searchFiles', baseSearchFilesTool.execute); // Log available tools at startup in debug mode if (process.env.DEBUG_CHAT === '1') { console.log('\n[DEBUG] ========================================'); console.log('[DEBUG] Probe Tools Loaded:'); console.log('[DEBUG] - search: Search for code patterns'); console.log('[DEBUG] - query: Semantic code search'); console.log('[DEBUG] - extract: Extract code snippets'); console.log('[DEBUG] - implement: Generate code implementations'); console.log('[DEBUG] - listFiles: List directory contents'); console.log('[DEBUG] - searchFiles: Search files by pattern'); console.log('[DEBUG] ========================================\n'); }