@buger/probe-chat
Version:
CLI and web interface for Probe code search (formerly @buger/probe-web and @buger/probe-chat)
1,072 lines (922 loc) • 77.6 kB
JavaScript
import 'dotenv/config';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createOpenAI } from '@ai-sdk/openai';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { streamText } from 'ai'; // Removed 'tool' import as it's not used directly here
import { randomUUID } from 'crypto';
import { TokenCounter } from './tokenCounter.js';
import { TokenUsageDisplay } from './tokenUsageDisplay.js';
import { writeFileSync, existsSync } from 'fs';
import { join } from 'path';
import { TelemetryConfig } from './telemetry.js';
import { trace } from '@opentelemetry/api';
import { appTracer } from './appTracer.js';
// Import the tools that emit events and the listFilesByLevel utility
import { listFilesByLevel } from '@buger/probe';
// Import schemas and parser from common (assuming tools.js)
import {
searchSchema, querySchema, extractSchema, attemptCompletionSchema,
searchToolDefinition, queryToolDefinition, extractToolDefinition, attemptCompletionToolDefinition, implementToolDefinition,
listFilesToolDefinition, searchFilesToolDefinition,
parseXmlToolCallWithThinking
} from './tools.js'; // Assuming common.js is moved to tools/
// Import tool *instances* for execution
import { searchToolInstance, queryToolInstance, extractToolInstance, implementToolInstance, listFilesToolInstance, searchFilesToolInstance } from './probeTool.js'; // Added new tool instances
// Maximum number of messages to keep in history
const MAX_HISTORY_MESSAGES = 100;
// Maximum iterations for the tool loop - configurable via MAX_TOOL_ITERATIONS env var
const MAX_TOOL_ITERATIONS = parseInt(process.env.MAX_TOOL_ITERATIONS || '30', 10);
// Parse and validate allowed folders from environment variable
const allowedFolders = process.env.ALLOWED_FOLDERS
? process.env.ALLOWED_FOLDERS.split(',').map(folder => folder.trim()).filter(Boolean)
: [];
// Validate folders exist on startup - will be handled by index.js in non-interactive mode
// This is kept for backward compatibility with direct ProbeChat usage
const validateFolders = () => {
if (allowedFolders.length > 0) {
for (const folder of allowedFolders) {
const exists = existsSync(folder);
// 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(`- ${folder} ${exists ? '✓' : '✗ (not found)'}`);
if (!exists) {
console.warn(`Warning: Folder "${folder}" does not exist or is not accessible`);
}
}
}
} else {
// 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.warn('No folders configured via ALLOWED_FOLDERS. Tools might default to current directory or require explicit paths.');
}
}
};
// Only validate folders on startup if not in non-interactive mode
if (typeof process !== 'undefined' && !process.env.PROBE_CHAT_SKIP_FOLDER_VALIDATION) {
validateFolders();
}
/**
* Extract image URLs from message text
* @param {string} message - The message text to analyze
* @param {boolean} debug - Whether to log debug information
* @returns {Array} Array of { url: string, cleanedMessage: string }
*/
function extractImageUrls(message, debug = false) {
// This function should be called within the session context, so it will inherit the trace ID
const tracer = trace.getTracer('probe-chat', '1.0.0');
return tracer.startActiveSpan('content.image.extract', (span) => {
try {
// Pattern to match image URLs and base64 data:
// 1. GitHub private-user-images URLs (always images, regardless of extension)
// 2. GitHub user-attachments/assets URLs (always images, regardless of extension)
// 3. URLs with common image extensions (PNG, JPG, JPEG, WebP, GIF)
// 4. Base64 data URLs (data:image/...)
// Updated to stop at quotes, spaces, or common HTML/XML delimiters
const imageUrlPattern = /(?:data:image\/[a-zA-Z]*;base64,[A-Za-z0-9+/=]+|https?:\/\/(?:(?:private-user-images\.githubusercontent\.com|github\.com\/user-attachments\/assets)\/[^\s"'<>]+|[^\s"'<>]+\.(?:png|jpg|jpeg|webp|gif)(?:\?[^\s"'<>]*)?))/gi;
span.setAttributes({
'message.length': message.length,
'debug.enabled': debug
});
if (debug) {
console.log(`[DEBUG] Scanning message for image URLs. Message length: ${message.length}`);
console.log(`[DEBUG] Image URL pattern: ${imageUrlPattern.toString()}`);
}
const urls = [];
let match;
while ((match = imageUrlPattern.exec(message)) !== null) {
urls.push(match[0]);
if (debug) {
console.log(`[DEBUG] Found image URL: ${match[0]}`);
}
}
// Remove image URLs from message text
const cleanedMessage = message.replace(imageUrlPattern, '').trim();
span.setAttributes({
'images.found': urls.length,
'message.cleaned_length': cleanedMessage.length
});
if (debug) {
console.log(`[DEBUG] Total image URLs found: ${urls.length}`);
if (urls.length > 0) {
console.log(`[DEBUG] Original message length: ${message.length}, cleaned message length: ${cleanedMessage.length}`);
}
}
const result = {
imageUrls: urls,
cleanedMessage: cleanedMessage
};
span.setStatus({ code: 1 }); // SUCCESS
return result;
} catch (error) {
span.recordException(error);
span.setStatus({ code: 2, message: error.message }); // ERROR
throw error;
} finally {
span.end();
}
});
}
/**
* Validate image URLs by checking if they're accessible, handling redirects
* @param {string[]} imageUrls - Array of image URLs to validate
* @param {boolean} debug - Whether to log debug messages
* @returns {Promise<string[]>} Array of valid final image URLs (after redirects)
*/
async function validateImageUrls(imageUrls, debug = false) {
const validUrls = [];
for (const url of imageUrls) {
try {
// Check if it's a base64 data URL
if (url.startsWith('data:image/')) {
// Validate base64 data URL format
const dataUrlMatch = url.match(/^data:image\/([a-zA-Z]*);base64,([A-Za-z0-9+/=]+)$/);
if (dataUrlMatch) {
const [, imageType, base64Data] = dataUrlMatch;
// Basic validation of base64 data
if (base64Data.length > 0 && imageType) {
// Estimate file size from base64 (rough approximation: base64 is ~1.33x original size)
const estimatedSize = (base64Data.length * 3) / 4;
// Check size limit (10MB)
if (estimatedSize <= 10 * 1024 * 1024) {
validUrls.push(url);
if (debug) {
console.log(`[DEBUG] Valid base64 image: ${imageType} (~${(estimatedSize / 1024).toFixed(1)}KB)`);
}
} else {
if (debug) {
console.log(`[DEBUG] Base64 image too large: ~${(estimatedSize / 1024 / 1024).toFixed(1)}MB (max 10MB)`);
}
}
} else {
if (debug) {
console.log(`[DEBUG] Invalid base64 data URL format: ${url.substring(0, 50)}...`);
}
}
} else {
if (debug) {
console.log(`[DEBUG] Invalid data URL format: ${url.substring(0, 50)}...`);
}
}
} else {
// Handle regular HTTP/HTTPS URLs
// Always use GET request with Range header to validate and get content type
// This works better than HEAD for GitHub URLs and other services
const response = await fetch(url, {
method: 'GET',
headers: {
'Range': 'bytes=0-1023' // Only fetch first 1KB to check content type and minimize data transfer
},
timeout: 10000, // TIMEOUTS.HTTP_REQUEST - 10 second timeout for GitHub URLs which can be slower
redirect: 'follow'
});
if (response.ok || response.status === 206) { // 206 = Partial Content (from Range header)
// Check if the response has image content type
const contentType = response.headers.get('content-type');
if (contentType && contentType.startsWith('image/')) {
// Use the final URL after following redirects
const finalUrl = response.url;
validUrls.push(finalUrl);
if (debug) {
if (finalUrl !== url) {
console.log(`[DEBUG] Valid image URL after redirect: ${url} -> ${finalUrl} (${contentType})`);
} else {
console.log(`[DEBUG] Valid image URL: ${finalUrl} (${contentType})`);
}
}
} else {
if (debug) {
console.log(`[DEBUG] URL not an image: ${url} (${contentType || 'unknown type'})`);
}
}
} else {
if (debug) {
console.log(`[DEBUG] URL not accessible: ${url} (status: ${response.status})`);
}
}
}
} catch (error) {
if (debug) {
console.log(`[DEBUG] Error validating image URL ${url}: ${error.message}`);
}
}
}
return validUrls;
}
/**
* ProbeChat class to handle chat interactions with AI models
*/
export class ProbeChat {
/**
* Create a new ProbeChat instance
* @param {Object} options - Configuration options
* @param {string} [options.sessionId] - Optional session ID
* @param {boolean} [options.isNonInteractive=false] - Suppress internal logs if true
* @param {Function} [options.toolCallCallback] - Callback function for tool calls (sessionId, toolCallData) - *Note: Callback may need adjustment for XML flow*
* @param {string} [options.customPrompt] - Custom prompt to replace the default system message
* @param {string} [options.promptType] - Predefined prompt type (architect, code-review, support)
* @param {boolean} [options.allowEdit=false] - Allow the use of the 'implement' tool
*/
constructor(options = {}) {
// Suppress internal logs if in non-interactive mode
this.isNonInteractive = !!options.isNonInteractive;
// Flag to track if a request has been cancelled
this.cancelled = false;
// AbortController for cancelling fetch requests
this.abortController = null;
// Make allowedFolders accessible as a property of the class
this.allowedFolders = allowedFolders;
// Store custom prompt or prompt type if provided
this.customPrompt = options.customPrompt || process.env.CUSTOM_PROMPT || null;
this.promptType = options.promptType || process.env.PROMPT_TYPE || null;
// Store allowEdit flag - enable if allow_edit is set or if allow_suggestions is set via environment
// Note: ALLOW_SUGGESTIONS also enables allowEdit because the implement tool is needed to generate
// code changes that reviewdog can then convert into PR review suggestions
this.allowEdit = !!options.allowEdit || process.env.ALLOW_EDIT === '1' || process.env.ALLOW_SUGGESTIONS === '1';
// Store client-provided API credentials if available
this.clientApiProvider = options.apiProvider;
this.clientApiKey = options.apiKey;
this.clientApiUrl = options.apiUrl;
// Initialize token counter and display
this.tokenCounter = new TokenCounter();
this.tokenDisplay = new TokenUsageDisplay({
maxTokens: 8192 // Will be updated based on model
});
// Use provided session ID or generate a unique one
this.sessionId = options.sessionId || randomUUID();
// Get debug mode
this.debug = process.env.DEBUG_CHAT === '1';
if (this.debug) {
console.log(`[DEBUG] Generated session ID for chat: ${this.sessionId}`);
console.log(`[DEBUG] Maximum tool iterations configured: ${MAX_TOOL_ITERATIONS}`);
console.log(`[DEBUG] Allow Edit (implement tool): ${this.allowEdit}`);
}
// Store tool instances for execution
// These are the actual functions/objects that perform the actions
this.toolImplementations = {
search: searchToolInstance,
query: queryToolInstance,
extract: extractToolInstance,
listFiles: listFilesToolInstance,
searchFiles: searchFilesToolInstance,
// attempt_completion is handled specially in the loop, no direct implementation needed here
};
// Conditionally add the implement tool if allowed
if (this.allowEdit) {
this.toolImplementations.implement = implementToolInstance;
}
// Initialize the chat model
this.initializeModel();
// Initialize telemetry
this.initializeTelemetry();
// Initialize chat history
this.history = [];
// Initialize display history - tracks what users actually see
this.displayHistory = [];
// Store persistent storage instance if provided
this.storage = options.storage || null;
}
/**
* Initialize the AI model based on available API keys and forced provider setting
*/
initializeModel() {
// Get API keys from environment variables or client-provided values
const anthropicApiKey = this.clientApiKey && this.clientApiProvider === 'anthropic' ?
this.clientApiKey : process.env.ANTHROPIC_API_KEY;
const openaiApiKey = this.clientApiKey && this.clientApiProvider === 'openai' ?
this.clientApiKey : process.env.OPENAI_API_KEY;
const googleApiKey = this.clientApiKey && this.clientApiProvider === 'google' ?
this.clientApiKey : process.env.GOOGLE_API_KEY;
// Get custom API URLs if provided (client URL takes precedence over environment variables)
const llmBaseUrl = process.env.LLM_BASE_URL; // Generic base URL for all providers
// For each provider, use client URL if available and matches the provider
const anthropicApiUrl = (this.clientApiUrl && this.clientApiProvider === 'anthropic') ?
this.clientApiUrl : (process.env.ANTHROPIC_API_URL || llmBaseUrl);
const openaiApiUrl = (this.clientApiUrl && this.clientApiProvider === 'openai') ?
this.clientApiUrl : (process.env.OPENAI_API_URL || llmBaseUrl);
const googleApiUrl = (this.clientApiUrl && this.clientApiProvider === 'google') ?
this.clientApiUrl : (process.env.GOOGLE_API_URL || llmBaseUrl);
// Get model override if provided
const modelName = process.env.MODEL_NAME;
// Check if client has specified a provider that should be forced
const clientForceProvider = this.clientApiProvider && this.clientApiKey ? this.clientApiProvider : null;
// Use client-forced provider or environment variable
const forceProvider = clientForceProvider || (process.env.FORCE_PROVIDER ? process.env.FORCE_PROVIDER.toLowerCase() : null);
if (this.debug) {
console.log(`[DEBUG] Available API keys: Anthropic=${!!anthropicApiKey}, OpenAI=${!!openaiApiKey}, Google=${!!googleApiKey}`);
console.log(`[DEBUG] Force provider: ${forceProvider || '(not set)'}`);
if (llmBaseUrl) console.log(`[DEBUG] Generic LLM Base URL: ${llmBaseUrl}`);
if (process.env.ANTHROPIC_API_URL) console.log(`[DEBUG] Custom Anthropic URL: ${anthropicApiUrl}`);
if (process.env.OPENAI_API_URL) console.log(`[DEBUG] Custom OpenAI URL: ${openaiApiUrl}`);
if (process.env.GOOGLE_API_URL) console.log(`[DEBUG] Custom Google URL: ${googleApiUrl}`);
if (modelName) console.log(`[DEBUG] Model override: ${modelName}`);
}
// Check if a specific provider is forced
if (forceProvider) {
if (!this.isNonInteractive || this.debug) {
console.log(`Provider forced to: ${forceProvider}`);
}
if (forceProvider === 'anthropic' && anthropicApiKey) {
this.initializeAnthropicModel(anthropicApiKey, anthropicApiUrl, modelName);
return;
} else if (forceProvider === 'openai' && openaiApiKey) {
this.initializeOpenAIModel(openaiApiKey, openaiApiUrl, modelName);
return;
} else if (forceProvider === 'google' && googleApiKey) {
this.initializeGoogleModel(googleApiKey, googleApiUrl, modelName);
return;
}
console.warn(`WARNING: Forced provider "${forceProvider}" selected but required API key is missing or invalid! Falling back to auto-detection.`);
}
// If no provider is forced or forced provider failed, use the first available API key
if (anthropicApiKey) {
this.initializeAnthropicModel(anthropicApiKey, anthropicApiUrl, modelName);
} else if (openaiApiKey) {
this.initializeOpenAIModel(openaiApiKey, openaiApiUrl, modelName);
} else if (googleApiKey) {
this.initializeGoogleModel(googleApiKey, googleApiUrl, modelName);
} else {
console.error('FATAL: No API key provided. Please set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY environment variable.');
this.noApiKeysMode = true; // Use flag for potential UI handling
this.model = 'none';
this.apiType = 'none';
console.log('ProbeChat cannot function without an API key.');
// Consider throwing an error here in a real application to prevent execution
// throw new Error('No API key configured for AI provider.');
}
}
/**
* Initialize Anthropic model
* @param {string} apiKey - Anthropic API key
* @param {string} [apiUrl] - Optional Anthropic API URL override
* @param {string} [modelName] - Optional model name override
*/
initializeAnthropicModel(apiKey, apiUrl, modelName) {
this.provider = createAnthropic({
apiKey: apiKey,
...(apiUrl && { baseURL: apiUrl }), // Conditionally add baseURL
});
this.model = modelName || 'claude-3-7-sonnet-20250219';
this.apiType = 'anthropic';
if (!this.isNonInteractive || this.debug) {
const urlSource = process.env.ANTHROPIC_API_URL ? 'ANTHROPIC_API_URL' :
(process.env.LLM_BASE_URL ? 'LLM_BASE_URL' : 'default');
console.log(`Using Anthropic API with model: ${this.model}${apiUrl ? ` (URL: ${apiUrl}, from: ${urlSource})` : ''}`);
}
}
/**
* Initialize OpenAI model
* @param {string} apiKey - OpenAI API key
* @param {string} [apiUrl] - Optional OpenAI API URL override
* @param {string} [modelName] - Optional model name override
*/
initializeOpenAIModel(apiKey, apiUrl, modelName) {
this.provider = createOpenAI({
compatibility: 'strict',
apiKey: apiKey,
...(apiUrl && { baseURL: apiUrl }), // Conditionally add baseURL
});
this.model = modelName || 'gpt-4o';
this.apiType = 'openai';
if (!this.isNonInteractive || this.debug) {
const urlSource = process.env.OPENAI_API_URL ? 'OPENAI_API_URL' :
(process.env.LLM_BASE_URL ? 'LLM_BASE_URL' : 'default');
console.log(`Using OpenAI API with model: ${this.model}${apiUrl ? ` (URL: ${apiUrl}, from: ${urlSource})` : ''}`);
}
}
/**
* Initialize Google model
* @param {string} apiKey - Google API key
* @param {string} [apiUrl] - Optional Google API URL override
* @param {string} [modelName] - Optional model name override
*/
initializeGoogleModel(apiKey, apiUrl, modelName) {
this.provider = createGoogleGenerativeAI({
apiKey: apiKey,
...(apiUrl && { baseURL: apiUrl }), // Conditionally add baseURL
});
this.model = modelName || 'gemini-2.0-flash';
this.apiType = 'google';
if (!this.isNonInteractive || this.debug) {
const urlSource = process.env.GOOGLE_API_URL ? 'GOOGLE_API_URL' :
(process.env.LLM_BASE_URL ? 'LLM_BASE_URL' : 'default');
console.log(`Using Google API with model: ${this.model}${apiUrl ? ` (URL: ${apiUrl}, from: ${urlSource})` : ''}`);
}
// Note: Google's tool support might differ. Ensure XML approach works reliably.
}
/**
* Initialize telemetry configuration
*/
initializeTelemetry() {
try {
// Check if telemetry is enabled via environment variables
const fileEnabled = process.env.OTEL_ENABLE_FILE === 'true';
const remoteEnabled = process.env.OTEL_ENABLE_REMOTE === 'true';
const consoleEnabled = process.env.OTEL_ENABLE_CONSOLE === 'true';
if (fileEnabled || remoteEnabled || consoleEnabled) {
this.telemetryConfig = new TelemetryConfig({
enableFile: fileEnabled,
enableRemote: remoteEnabled,
enableConsole: consoleEnabled,
filePath: process.env.OTEL_FILE_PATH || './traces.jsonl',
remoteEndpoint: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT || 'http://localhost:4318/v1/traces'
});
this.telemetryConfig.initialize();
if (this.debug) {
console.log('[DEBUG] Telemetry initialized successfully');
}
} else {
if (this.debug) {
console.log('[DEBUG] Telemetry disabled - no exporters configured');
}
}
} catch (error) {
console.error('Failed to initialize telemetry:', error.message);
this.telemetryConfig = null;
}
}
/**
* Get the system message with instructions for the AI (XML Tool Format)
* @returns {Promise<string>} - The system message
*/
async getSystemMessage() {
// --- Dynamically build Tool Definitions ---
let toolDefinitions = `
${searchToolDefinition}
${queryToolDefinition}
${extractToolDefinition}
${listFilesToolDefinition}
${searchFilesToolDefinition}
${attemptCompletionToolDefinition}
`;
if (this.allowEdit) {
toolDefinitions += `${implementToolDefinition}\n`;
}
// --- Dynamically build Tool Guidelines ---
let xmlToolGuidelines = `
# Tool Use Formatting
Tool use MUST be formatted using XML-style tags. The tool name is enclosed in opening and closing tags, and each parameter is similarly enclosed within its own set of tags. You MUST use exactly ONE tool call per message until you are ready to complete the task.
Structure:
<tool_name>
<parameter1_name>value1</parameter1_name>
<parameter2_name>value2</parameter2_name>
...
</tool_name>
Example:
<search>
<query>error handling</query>
<path>src/search</path>
</search>
# Thinking Process
Before using a tool, analyze the situation within <thinking></thinking> tags. This helps you organize your thoughts and make better decisions. Your thinking process should include:
1. Analyze what information you already have and what information you need to proceed with the task.
2. Determine which of the available tools would be most effective for gathering this information or accomplishing the current step.
3. Check if all required parameters for the tool are available or can be inferred from the context.
4. If all parameters are available, proceed with the tool use.
5. If parameters are missing, explain what's missing and why it's needed.
Example:
<thinking>
I need to find code related to error handling in the search module. The most appropriate tool for this is the search tool, which requires a query parameter and a path parameter. I have both the query ("error handling") and the path ("src/search"), so I can proceed with the search.
</thinking>
# Tool Use Guidelines
1. Think step-by-step about how to achieve the user's goal.
2. Use <thinking></thinking> tags to analyze the situation and determine the appropriate tool.
3. Choose **one** tool that helps achieve the current step.
4. Format the tool call using the specified XML format. Ensure all required parameters are included.
5. **You MUST respond with exactly one tool call in the specified XML format in each turn.**
6. Wait for the tool execution result, which will be provided in the next message (within a <tool_result> block).
7. Analyze the tool result and decide the next step. If more tool calls are needed, repeat steps 2-6.
8. If the task is fully complete and all previous steps were successful, use the \`<attempt_completion>\` tool to provide the final answer. This is the ONLY way to finish the task.
9. If you cannot proceed (e.g., missing information, invalid request), explain the issue clearly before using \`<attempt_completion>\` with an appropriate message in the \`<result>\` tag.
10. Do not be lazy and dig to the topic as deep as possible, until you see full picture.
Available Tools:
- search: Search code using keyword queries.
- query: Search code using structural AST patterns.
- extract: Extract specific code blocks or lines from files.
- listFiles: List files and directories in a specified location.
- searchFiles: Find files matching a glob pattern with recursive search capability.
${this.allowEdit ? '- implement: Implement a feature or fix a bug using aider.\n' : ''}
- attempt_completion: Finalize the task and provide the result to the user.
`;
// Common instructions that will be added to all prompts
const commonInstructions = `<instructions>
Follow these instructions carefully:
1. Analyze the user's request.
2. Use <thinking></thinking> tags to analyze the situation and determine the appropriate tool for each step.
3. Use the available tools step-by-step to fulfill the request.
4. You should always prefer the \`search\` tool for code-related questions. Read full files only if really necessary.
4. Ensure to get really deep and understand the full picture before answering. Ensure to check dependencies where required.
5. You MUST respond with exactly ONE tool call per message, using the specified XML format, until the task is complete.
6. Wait for the tool execution result (provided in the next user message in a <tool_result> block) before proceeding to the next step.
7. Once the task is fully completed, and you have confirmed the success of all steps, use the '<attempt_completion>' tool to provide the final result. This is the ONLY way to signal completion.
8. Prefer concise and focused search queries. Use specific keywords and phrases to narrow down results. Avoid reading files in full, only when absolutely necessary.
9. Show mermaid diagrams to illustrate complex code structures or workflows. In diagrams, content inside ["..."] always should be in quotes.</instructions>
`;
// Define predefined prompts (without the common instructions)
const predefinedPrompts = {
'code-explorer': `You are ProbeChat Code Explorer, a specialized AI assistant focused on helping developers, product managers, and QAs understand and navigate codebases. Your primary function is to answer questions based on code, explain how systems work, and provide insights into code functionality using the provided code analysis tools.
When exploring code:
- Provide clear, concise explanations based on user request
- Find and highlight the most relevant code snippets, if required
- Trace function calls and data flow through the system
- Use diagrams to illustrate code structure and relationships when helpful
- Try to understand the user's intent and provide relevant information
- Understand high level picture
- Balance detail with clarity in your explanations`,
'architect': `You are ProbeChat Architect, a specialized AI assistant focused on software architecture and design. Your primary function is to help users understand, analyze, and design software systems using the provided code analysis tools. You excel at identifying architectural patterns, suggesting improvements, and creating high-level design documentation. You provide detailed and accurate responses to user queries about system architecture, component relationships, and code organization.
When analyzing code:
- Focus on high-level design patterns and system organization
- Identify architectural patterns and component relationships
- Evaluate system structure and suggest architectural improvements
- Create diagrams to illustrate system architecture and workflows
- Consider scalability, maintainability, and extensibility in your analysis`,
'code-review': `You are ProbeChat Code Reviewer, a specialized AI assistant focused on code quality and best practices. Your primary function is to help users identify issues, suggest improvements, and ensure code follows best practices using the provided code analysis tools. You excel at spotting bugs, performance issues, security vulnerabilities, and style inconsistencies. You provide detailed and constructive feedback on code quality.
When reviewing code:
- Look for bugs, edge cases, and potential issues
- Identify performance bottlenecks and optimization opportunities
- Check for security vulnerabilities and best practices
- Evaluate code style and consistency
- Is the backward compatibility can be broken?
- Organize feedback by severity (critical, major, minor) and type (bug, performance, security, style)
- Provide specific, actionable suggestions with code examples where appropriate
## Failure Detection
If you detect critical issues that should prevent the code from being merged, include <fail> in your response:
- Security vulnerabilities that could be exploited
- Breaking changes without proper documentation or migration path
- Critical bugs that would cause system failures
- Severe violations of project standards that must be addressed
The <fail> tag will cause the GitHub check to fail, drawing immediate attention to these critical issues.`,
'engineer': `You are senior engineer focused on software architecture and design.
Before jumping on the task you first, in details analyse user request, and try to provide elegant and concise solution.
If solution is clear, you can jump to implementation right away, if not, you can ask user a clarification question, by calling attempt_completion tool, with required details.
You are allowed to use search tool with allow_tests argument, in order to find the tests.
Before jumping to implementation:
- Focus on high-level design patterns and system organization
- Identify architectural patterns and component relationships
- Evaluate system structure and suggest architectural improvements
- Focus on backward compatibility.
- Respond with diagrams to illustrate system architecture and workflows, if required.
- Consider scalability, maintainability, and extensibility in your analysis
During the implementation:
- Avoid implementing special cases
- Do not forget to add the tests`,
'support': `You are ProbeChat Support, a specialized AI assistant focused on helping developers troubleshoot issues and solve problems. Your primary function is to help users diagnose errors, understand unexpected behaviors, and find solutions using the provided code analysis tools. You excel at debugging, explaining complex concepts, and providing step-by-step guidance. You provide detailed and patient support to help users overcome technical challenges.
When troubleshooting:
- Focus on finding root causes, not just symptoms
- Explain concepts clearly with appropriate context
- Provide step-by-step guidance to solve problems
- Suggest diagnostic steps to verify solutions
- Consider edge cases and potential complications
- Be empathetic and patient in your explanations`
};
let systemMessage = '';
// Use custom prompt if provided
if (this.customPrompt) {
// For custom prompts, use the entire content as is
systemMessage = "<role>" + this.customPrompt + "</role>";
if (this.debug) {
console.log(`[DEBUG] Using custom prompt`);
}
}
// Use predefined prompt if specified
else if (this.promptType && predefinedPrompts[this.promptType]) {
systemMessage = "<role>" + predefinedPrompts[this.promptType] + "</role>";
if (this.debug) {
console.log(`[DEBUG] Using predefined prompt: ${this.promptType}`);
}
// Add common instructions to predefined prompts
systemMessage += commonInstructions;
} else {
// Use the default prompt (code explorer) if no prompt type is specified
systemMessage = "<role>" + predefinedPrompts['code-explorer'] + "</role>";
if (this.debug) {
console.log(`[DEBUG] Using default prompt: code explorer`);
}
// Add common instructions to the default prompt
systemMessage += commonInstructions;
}
// Add XML Tool Guidelines
systemMessage += `\n${xmlToolGuidelines}\n`;
// Add Tool Definitions
systemMessage += `\n# Tools Available\n${toolDefinitions}\n`;
// Add special emphasis for image handling
systemMessage += `\n# CRITICAL: XML Tool Format Required\n\nEven when processing images or visual content, you MUST respond using the XML tool format. Do not provide direct answers about images - instead use the appropriate tool (usually <attempt_completion>) with your analysis inside the <result> tag.\n\nExample when analyzing an image:\n<attempt_completion>\n<result>\nI can see this is a promotional image from Tyk showing... [your analysis here]\n</result>\n</attempt_completion>\n`;
const searchDirectory = this.allowedFolders.length > 0 ? this.allowedFolders[0] : process.cwd();
if (this.debug) {
console.log(`[DEBUG] Generating file list for base directory: ${searchDirectory}...`);
}
// Add folder information
if (this.allowedFolders.length > 0) {
const folderList = this.allowedFolders.map(f => `"${f}"`).join(', ');
systemMessage += `\n\nYou are configured to primarily operate within these folders: ${folderList}. When using tools like 'search' or 'query', the 'path' parameter should generally refer to these folders or subpaths within them. The root for relative paths is considered the project base.`;
} else {
systemMessage += `\n\nCurrent path: ${searchDirectory}. When using tools, specify paths like '.' for the current directory, 'src/utils', etc., within the 'path' parameter. Dependencies are located in /dep folder: "/dep/go/github.com/user/repo", "/dep/js/<package>", "/dep/rust/crate_name".`;
}
// Add Rules/Capabilities section
systemMessage += `\n\n# Capabilities & Rules\n- Search given folder using keywords (\`search\`) or structural patterns (\`query\`).\n- Extract specific code blocks or full files using (\`extract\`).\n- File paths are relative to the project base unless using dependency syntax.\n- Always wait for tool results (\`<tool_result>...\`) before proceeding.\n- Use \`attempt_completion\` ONLY when the entire task is finished.\n- Be direct and technical. Use exactly ONE tool call per response in the specified XML format. Prefer using search tool.\n`;
if (this.debug) {
console.log(`[DEBUG] Base system message length (pre-file list): ${systemMessage.length}`);
}
// Add file list information if available
try {
let files = await listFilesByLevel({
directory: searchDirectory, // Use the determined search directory
maxFiles: 100, // Keep it reasonable
respectGitignore: true
});
// Exclude debug file(s) and common large directories
files = files.filter((file) => {
const lower = file.toLowerCase();
return !lower.includes('probe-debug.txt') && !lower.includes('node_modules') && !lower.includes('/.git/');
});
if (files.length > 0) {
const fileListHeader = `\n\n# Project Files (Sample of up to ${files.length} files in ${searchDirectory}):\n`;
const fileListContent = files.map(file => `- ${file}`).join('\n');
systemMessage += fileListHeader + fileListContent;
if (this.debug) {
console.log(`[DEBUG] Added ${files.length} files to system message. Total length: ${systemMessage.length}`);
}
} else {
if (this.debug) {
console.log(`[DEBUG] No files found or listed for the project directory: ${searchDirectory}.`);
}
systemMessage += `\n\n# Project Files\nNo files listed for the primary directory (${searchDirectory}). You may need to use tools like 'search' or 'query' with broad paths initially if the user's request requires file exploration.`;
}
} catch (error) {
console.warn(`Warning: Could not generate file list for directory "${searchDirectory}": ${error.message}`);
systemMessage += `\n\n# Project Files\nCould not retrieve file listing. Proceed based on user instructions and tool capabilities.`;
}
if (this.debug) {
console.log(`[DEBUG] Final system message length: ${systemMessage.length}`);
// Log first/last parts for verification
const debugFilePath = join(process.cwd(), 'probe-debug-system-prompt.txt');
try {
writeFileSync(debugFilePath, systemMessage);
console.log(`[DEBUG] Full system prompt saved to ${debugFilePath}`);
} catch (e) {
console.error(`[DEBUG] Failed to write full system prompt: ${e.message}`);
console.log(`[DEBUG] System message START:\n${systemMessage.substring(0, 300)}...`);
console.log(`[DEBUG] System message END:\n...${systemMessage.substring(systemMessage.length - 300)}`);
}
}
return systemMessage;
}
/**
* Abort the current chat request
*/
abort() {
if (!this.isNonInteractive || this.debug) {
console.log(`Aborting chat for session: ${this.sessionId}`);
}
this.cancelled = true;
// Abort any fetch requests
if (this.abortController) {
try {
this.abortController.abort('User cancelled request'); // Pass reason
} catch (error) {
// Ignore errors if already aborted or controller is in an unexpected state
if (error.name !== 'AbortError') {
console.error('Error aborting fetch request:', error);
}
}
}
}
/**
* Process a user message and get a response
* @param {string} message - The user message
* @param {string} [sessionId] - Optional session ID to use for this chat (overrides the default)
* @param {Object} [apiCredentials] - Optional API credentials for this call
* @param {string[]} [images] - Optional array of base64 image URLs
* @returns {Promise<string>} - The AI response
*/
async chat(message, sessionId, apiCredentials = null, images = []) {
// Use our custom app tracer for granular tracing
const effectiveSessionId = sessionId || this.sessionId;
// Start the chat session span first, then execute the entire chat flow within the session context
const chatSessionSpan = appTracer.startChatSession(effectiveSessionId, message, this.apiType, this.model);
// Execute the entire chat flow within the session context
return await appTracer.withSessionContext(effectiveSessionId, async () => {
try {
// Update client credentials if provided in this call
if (apiCredentials) {
this.clientApiProvider = apiCredentials.apiProvider || this.clientApiProvider;
this.clientApiKey = apiCredentials.apiKey || this.clientApiKey;
this.clientApiUrl = apiCredentials.apiUrl || this.clientApiUrl;
// Re-initialize the model with the new credentials
if (apiCredentials.apiKey && apiCredentials.apiProvider) {
this.initializeModel();
}
}
// Handle no API keys mode gracefully
if (this.noApiKeysMode) {
console.error("Cannot process chat: No API keys configured.");
appTracer.endChatSession(effectiveSessionId, false, 0);
// Return structured response even for API key errors
return {
response: "Error: ProbeChat is not configured with an AI provider API key. Please set the appropriate environment variable (e.g., ANTHROPIC_API_KEY, OPENAI_API_KEY) or provide an API key in the browser.",
tokenUsage: { contextWindow: 0, current: {}, total: {} }
};
}
// Reset cancelled flag for the new request
this.cancelled = false;
// Create a new AbortController for this specific request
// This ensures previous cancellations don't affect new requests
this.abortController = new AbortController();
// If a session ID is provided and it's different from the current one, update it
if (sessionId && sessionId !== this.sessionId) {
if (this.debug) {
console.log(`[DEBUG] Switching session ID from ${this.sessionId} to ${sessionId}`);
}
// Update the session ID for this instance
this.sessionId = sessionId;
// NOTE: History is NOT cleared automatically when session ID changes this way.
// Call clearHistory() explicitly if a new session should start fresh.
}
// Process the message using the potentially updated session ID
const result = await this._processChat(message, effectiveSessionId, images);
appTracer.endChatSession(effectiveSessionId, true, result.tokenUsage?.total?.total || 0);
// CRITICAL FIX: Ensure all spans are properly exported before returning
if (this.telemetryConfig) {
try {
// First, ensure the session span is ended within its context
await appTracer.withSessionContext(effectiveSessionId, async () => {
// Small delay to ensure all child spans are ended
await new Promise(resolve => setTimeout(resolve, 50));
});
// Give BatchSpanProcessor time to process the ended spans
// BatchSpanProcessor has a scheduledDelayMillis of 500ms (reduced from default 5000ms)
await new Promise(resolve => setTimeout(resolve, 600));
// Force flush all pending spans
await this.telemetryConfig.forceFlush();
// Additional delay to ensure file writes complete
await new Promise(resolve => setTimeout(resolve, 100));
} catch (flushError) {
if (this.debug) console.log('[DEBUG] Telemetry flush warning:', flushError.message);
}
}
return result;
} catch (error) {
appTracer.endChatSession(effectiveSessionId, false, 0);
// CRITICAL FIX: Ensure all spans are properly exported even on error
if (this.telemetryConfig) {
try {
// First, ensure the session span is ended within its context
await appTracer.withSessionContext(effectiveSessionId, async () => {
// Small delay to ensure all child spans are ended
await new Promise(resolve => setTimeout(resolve, 50));
});
// Give BatchSpanProcessor time to process the ended spans
// BatchSpanProcessor has a scheduledDelayMillis of 500ms (reduced from default 5000ms)
await new Promise(resolve => setTimeout(resolve, 600));
// Force flush all pending spans
await this.telemetryConfig.forceFlush();
// Additional delay to ensure file writes complete
await new Promise(resolve => setTimeout(resolve, 100));
} catch (flushError) {
if (this.debug) console.log('[DEBUG] Telemetry flush warning:', flushError.message);
}
}
throw error;
}
}); // End withSessionContext
}
/**
* Internal method to process a chat message using the XML tool loop
* @param {string} message - The user message
* @param {string} sessionId - The session ID for tracing
* @param {string[]} images - Array of base64 image URLs
* @returns {Promise<string>} - The final AI response after loop completion
* @private
*/
async _processChat(message, sessionId, images = []) {
let currentIteration = 0;
let completionAttempted = false;
let finalResult = `Error: Max tool iterations (${MAX_TOOL_ITERATIONS}) reached without completion. You can increase this limit using the MAX_TOOL_ITERATIONS environment variable or --max-iterations flag.`; // Default error
this.abortController = new AbortController();
const debugFilePath = join(process.cwd(), 'probe-debug.txt');
try {
if (this.debug) {
console.log(`[DEBUG] ===== Starting XML Tool Chat Loop (Session: ${this.sessionId}) =====`);
console.log(`[DEBUG] Received user message: ${message}`);
console.log(`[DEBUG] Initial history length: ${this.history.length}`);
}
this.tokenCounter.startNewTurn();
this.tokenCounter.addRequestTokens(this.tokenCounter.countTokens(message));
if (this.history.length > MAX_HISTORY_MESSAGES) {
const removedCount = this.history.length - MAX_HISTORY_MESSAGES;
this.history = this.history.slice(removedCount);
if (this.debug) console.log(`[DEBUG] Trimmed history to ${this.history.length} messages (removed ${removedCount}).`);
}
const isFirstMessage = this.history.length === 0;
// Start user message processing trace
const messageId = `msg_${Date.now()}`;
appTracer.startUserMessageProcessing(sessionId, messageId, message);
// Extract image URLs from the message within the processing context
const { imageUrls, cleanedMessage } = appTracer.withUserProcessingContext(sessionId, () =>
extractImageUrls(message, this.debug)
);
// Start image processing trace if images are found
if (imageUrls.length > 0) {
appTracer.startImageProcessing(sessionId, messageId, imageUrls, cleanedMessage.length);
if (this.debug) console.log(`[DEBUG] Found ${imageUrls.length} image URLs in message`);
}
// Log image detection only in interactive mode or debug mode
if (imageUrls.length > 0) {
if (process.env.PROBE_NON_INTERACTIVE !== '1' || process.env.DEBUG_CHAT === '1') {
console.log(`Detected ${imageUrls.length} image URL(s) in message.`);
}
if (this.debug) {
console.log(`[DEBUG] Extracted image URLs:`, imageUrls);
}
}
// Validate image URLs and filter out broken ones
let validImageUrls = [];
let validationResults = null;
if (imageUrls.length > 0) {
const validationStartTime = Date.now();
validImageUrls = await validateImageUrls(imageUrls, this.debug);
const validationEndTime = Date.now();
// Record validation results in trace
validationResults = {
totalUrls: imageUrls.length,
validUrls: validImageUrls.length,
invalidUrls: imageUrls.length - validImageUrls.length,
redirectedUrls: 0, // TODO: capture from validateImageUrls if needed
timeoutUrls: 0, // TODO: capture from validateImageUrls if needed
networkErrors: 0, // TODO: capture from validateImageUrls if needed
durationMs: validationEndTime - validationStartTime
};
appTracer.recordImageValidation(sessionId, validationResults);
appTracer.endImageProcessing(sessionId, validImageUrls.length > 0, validImageUrls.length);
} else {
validImageUrls = await validateImageUrls(imageUrls, this.debug);
}
// Start the agent loop trace within user processing context
appTracer.withUserProcessingContext(sessionId, () => {
appTracer.startAgentLoop(sessionId, MAX_TOOL_ITERATIONS);
});
// Log validation results only in interactive mode or debug mode
if (imageUrls.length > 0) {
const invalidCount = imageUrls.length - validImageUrls.length;
if (process.env.PROBE_NON_INTERACTIVE !== '1' || process.env.DEBUG_CHAT === '1') {
if (validImageUrls.length > 0) {
console.log(`Image validation: ${validImageUrls.length} valid, ${invalidCount} invalid/inaccessible.`);
} else {
console.log(`Image validation: All ${imageUrls.length} image URLs failed validation.`);
}
}
if (this.debug && validImageUrls.length > 0) {
console.log(`[DEBUG] Valid image URLs:`, validImageUrls);
}
}
const wrappedMessage = isFirstMessage ? `<task>\n${cleanedMessage}\n</task>` : cleanedMessage;
// Combine extracted URL images with uploaded base64 images
const allImages = [...validImageUrls, ...images];
// Create the user message with potential image attachments
const userMessage = { role: 'user', content: wrappedMessage };
// Store user message in display history (always visible to users)
const displayUserMessage = {
role: 'user',
content: message, // Store original unwrapped message
visible: true,
displayType: 'user',
timestamp: new Date().toISOString()
};
// Add image attachments if any images are present
if (allImages.length > 0) {
userMessage.content = [
{ type: 'text', text: wrappedMessage },
...allImages.map(imageUrl => ({
type: 'image',
image: imageUrl
}))
];
// Add images to display message as well
displayUserMessage.images = allImages;
if (this.debug) {
console.log(`[DEBUG] Created message with ${allImages.length} images (${validImageUrls.length} from URLs, ${images.length} uploaded)`);
}
}
// Add user message to display history
if (!this.displayHistory) {
this.displayHistory = [];
}
this.displayHistory.push(displayUserMessage);
// Save user message to persistent storage
if (this.storage) {
this.storage.saveMessage(this.sessionId, {
role: 'user',
content: message, // Original message
timestamp: Date.now(),
displayType: 'user',
visible: 1,
images: allImages,
metadata: {}
}).catch(err => {
console.error('Failed to save user message to persistent storage:', err);
});
}
let currentMessages = [
...this.history,
userMessage
];
const promptGenerationStart =