diff --git a/src/agent/AgentBackend.ts b/src/agent/AgentBackend.ts index 33a32814..414e8666 100644 --- a/src/agent/AgentBackend.ts +++ b/src/agent/AgentBackend.ts @@ -48,7 +48,7 @@ export interface McpServerConfig { export type AgentTransport = 'native-claude' | 'mcp-codex' | 'acp'; /** Agent identifier */ -export type AgentId = 'claude' | 'codex' | 'gemini' | 'opencode' | 'claude-acp' | 'codex-acp'; +export type AgentId = 'claude' | 'codex' | 'gemini' | 'opencode' | 'claude-acp' | 'codex-acp' | 'codebuddy' | 'codebuddy-acp'; /** * Configuration for creating an agent backend diff --git a/src/agent/acp/codebuddy.ts b/src/agent/acp/codebuddy.ts new file mode 100644 index 00000000..b049371f --- /dev/null +++ b/src/agent/acp/codebuddy.ts @@ -0,0 +1,130 @@ +/** + * CodeBuddy ACP Backend - CodeBuddy Code agent via ACP + * + * This module provides a factory function for creating a CodeBuddy backend + * that communicates using the Agent Client Protocol (ACP). + * + * CodeBuddy Code supports ACP for external tool integration and communication. + */ + +import { AcpSdkBackend, type AcpSdkBackendOptions, type AcpPermissionHandler } from './AcpSdkBackend'; +import type { AgentBackend, McpServerConfig } from '../AgentBackend'; +import { agentRegistry, type AgentFactoryOptions } from '../AgentRegistry'; +import { logger } from '@/ui/logger'; +import { + CODEBUDDY_API_KEY_ENV, + CODEBUDDY_MODEL_ENV, + DEFAULT_CODEBUDDY_MODEL, + CODEBUDDY_CLI_COMMAND +} from '@/codebuddy/constants'; +import { + readCodebuddyLocalConfig, + determineCodebuddyModel, + getCodebuddyModelSource +} from '@/codebuddy/utils/config'; + +/** + * Options for creating a CodeBuddy ACP backend + */ +export interface CodebuddyBackendOptions extends AgentFactoryOptions { + /** API key for CodeBuddy (defaults to CODEBUDDY_API_KEY env var) */ + apiKey?: string; + + /** OAuth token from Happy cloud - highest priority */ + cloudToken?: string; + + /** Model to use. If undefined, will use local config, env var, or default. + * If explicitly set to null, will use default (skip local config). + * (defaults to CODEBUDDY_MODEL env var or 'claude-sonnet-4-20250514') */ + model?: string | null; + + /** MCP servers to make available to the agent */ + mcpServers?: Record; + + /** Optional permission handler for tool approval */ + permissionHandler?: AcpPermissionHandler; +} + +/** + * Create a CodeBuddy backend using ACP (official SDK). + * + * The CodeBuddy CLI must be installed and available in PATH. + * Uses ACP mode for communication. + * + * @param options - Configuration options + * @returns AgentBackend instance for CodeBuddy + */ +export function createCodebuddyBackend(options: CodebuddyBackendOptions): AgentBackend { + + // Resolve API key from multiple sources (in priority order): + // 1. Happy cloud OAuth token + // 2. Local CodeBuddy config files (~/.codebuddy/) + // 3. CODEBUDDY_API_KEY environment variable + // 4. Explicit apiKey option + + // Try reading from local CodeBuddy config (token and model) + const localConfig = readCodebuddyLocalConfig(); + + let apiKey = options.cloudToken // 1. Happy cloud token (passed from runCodebuddy) + || localConfig.token // 2. Local config (~/.codebuddy/) + || process.env[CODEBUDDY_API_KEY_ENV] // 3. CODEBUDDY_API_KEY env var + || options.apiKey; // 4. Explicit apiKey option (fallback) + + if (!apiKey) { + logger.warn(`[CodeBuddy] No API key found. Set ${CODEBUDDY_API_KEY_ENV} environment variable or configure authentication.`); + } + + // Command to run codebuddy + const codebuddyCommand = CODEBUDDY_CLI_COMMAND; + + // Get model from options, local config, system environment, or use default + const model = determineCodebuddyModel(options.model, localConfig); + + // Build args - use ACP mode flag + // Note: The actual flag might need to be adjusted based on CodeBuddy CLI implementation + const codebuddyArgs = ['--acp']; + + const backendOptions: AcpSdkBackendOptions = { + agentName: 'codebuddy', + cwd: options.cwd, + command: codebuddyCommand, + args: codebuddyArgs, + env: { + ...options.env, + ...(apiKey ? { [CODEBUDDY_API_KEY_ENV]: apiKey } : {}), + // Pass model via env var + [CODEBUDDY_MODEL_ENV]: model, + // Suppress debug output to avoid stdout pollution + NODE_ENV: 'production', + DEBUG: '', + }, + mcpServers: options.mcpServers, + permissionHandler: options.permissionHandler, + }; + + // Determine model source for logging + const modelSource = getCodebuddyModelSource(options.model, localConfig); + + logger.debug('[CodeBuddy] Creating ACP SDK backend with options:', { + cwd: backendOptions.cwd, + command: backendOptions.command, + args: backendOptions.args, + hasApiKey: !!apiKey, + model: model, + modelSource: modelSource, + mcpServerCount: options.mcpServers ? Object.keys(options.mcpServers).length : 0, + }); + + return new AcpSdkBackend(backendOptions); +} + +/** + * Register CodeBuddy backend with the global agent registry. + * + * This function should be called during application initialization + * to make the CodeBuddy agent available for use. + */ +export function registerCodebuddyAgent(): void { + agentRegistry.register('codebuddy', (opts) => createCodebuddyBackend(opts)); + logger.debug('[CodeBuddy] Registered with agent registry'); +} diff --git a/src/agent/acp/index.ts b/src/agent/acp/index.ts index 1b44cd26..f12892d6 100644 --- a/src/agent/acp/index.ts +++ b/src/agent/acp/index.ts @@ -9,4 +9,5 @@ export { AcpSdkBackend, type AcpSdkBackendOptions } from './AcpSdkBackend'; export { createGeminiBackend, registerGeminiAgent, type GeminiBackendOptions } from './gemini'; +export { createCodebuddyBackend, registerCodebuddyAgent, type CodebuddyBackendOptions } from './codebuddy'; diff --git a/src/api/api.ts b/src/api/api.ts index fc381180..f367169c 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -320,7 +320,7 @@ export class ApiClient { * Get vendor API token from the server * Returns the token if it exists, null otherwise */ - async getVendorToken(vendor: 'openai' | 'anthropic' | 'gemini'): Promise { + async getVendorToken(vendor: 'openai' | 'anthropic' | 'gemini' | 'codebuddy'): Promise { try { const response = await axios.get( `${configuration.serverUrl}/v1/connect/${vendor}/token`, diff --git a/src/codebuddy/constants.ts b/src/codebuddy/constants.ts new file mode 100644 index 00000000..86a437a9 --- /dev/null +++ b/src/codebuddy/constants.ts @@ -0,0 +1,67 @@ +/** + * CodeBuddy Constants + * + * Centralized constants for CodeBuddy Code integration including environment variable names, + * default values, and directory/file path patterns. + */ + +import { trimIdent } from '@/utils/trimIdent'; + +/** Environment variable name for CodeBuddy API key */ +export const CODEBUDDY_API_KEY_ENV = 'CODEBUDDY_API_KEY'; + +/** Environment variable name for CodeBuddy model selection */ +export const CODEBUDDY_MODEL_ENV = 'CODEBUDDY_MODEL'; + +/** Default CodeBuddy model */ +export const DEFAULT_CODEBUDDY_MODEL = 'claude-sonnet-4-20250514'; + +/** CodeBuddy CLI command */ +export const CODEBUDDY_CLI_COMMAND = 'codebuddy'; + +/** + * Directory names for CodeBuddy configuration + * Based on CodeBuddy Code documentation + */ +export const CODEBUDDY_DIR = '.codebuddy'; + +/** User-level CodeBuddy directory */ +export const CODEBUDDY_USER_DIR = '~/.codebuddy'; + +/** Memory file name */ +export const CODEBUDDY_MEMORY_FILE = 'CODEBUDDY.md'; + +/** Local memory file name (not committed to git) */ +export const CODEBUDDY_LOCAL_MEMORY_FILE = 'CODEBUDDY.local.md'; + +/** Settings file name */ +export const CODEBUDDY_SETTINGS_FILE = 'settings.json'; + +/** Local settings file name */ +export const CODEBUDDY_LOCAL_SETTINGS_FILE = 'settings.local.json'; + +/** Rules directory name */ +export const CODEBUDDY_RULES_DIR = 'rules'; + +/** Agents directory name */ +export const CODEBUDDY_AGENTS_DIR = 'agents'; + +/** + * Instruction for changing chat title + * Used in system prompts to instruct agents to call change_title function + */ +export const CHANGE_TITLE_INSTRUCTION = trimIdent( + `Based on this message, call functions.happy__change_title to change chat session title that would represent the current task. If chat idea would change dramatically - call this function again to update the title.` +); + +/** + * Available CodeBuddy models + */ +export const AVAILABLE_CODEBUDDY_MODELS = [ + 'claude-sonnet-4-20250514', + 'claude-opus-4-20250514', + 'claude-3-5-sonnet-20241022', + 'claude-3-5-haiku-20241022', +] as const; + +export type CodebuddyModel = typeof AVAILABLE_CODEBUDDY_MODELS[number]; diff --git a/src/codebuddy/index.ts b/src/codebuddy/index.ts new file mode 100644 index 00000000..4379a495 --- /dev/null +++ b/src/codebuddy/index.ts @@ -0,0 +1,77 @@ +/** + * CodeBuddy Module + * + * Main entry point for CodeBuddy Code integration. + * Exports all public types, constants, and utilities. + */ + +// Types +export type { + CodebuddyMode, + CodexMessagePayload, + CodebuddyLocalConfig, + CodebuddySettings, + CodebuddyMemory, + CodebuddyRule +} from './types'; + +// Constants +export { + CODEBUDDY_API_KEY_ENV, + CODEBUDDY_MODEL_ENV, + DEFAULT_CODEBUDDY_MODEL, + CODEBUDDY_CLI_COMMAND, + CODEBUDDY_DIR, + CODEBUDDY_USER_DIR, + CODEBUDDY_MEMORY_FILE, + CODEBUDDY_LOCAL_MEMORY_FILE, + CODEBUDDY_SETTINGS_FILE, + CODEBUDDY_LOCAL_SETTINGS_FILE, + CODEBUDDY_RULES_DIR, + CODEBUDDY_AGENTS_DIR, + CHANGE_TITLE_INSTRUCTION, + AVAILABLE_CODEBUDDY_MODELS, + type CodebuddyModel +} from './constants'; + +// Configuration utilities +export { + getUserCodebuddyDir, + getProjectCodebuddyDir, + readCodebuddyLocalConfig, + readCodebuddySettings, + readCodebuddyMemory, + readCodebuddyRules, + determineCodebuddyModel, + saveCodebuddyModelToConfig, + getInitialCodebuddyModel, + getCodebuddyModelSource, + buildSystemPrompt +} from './utils/config'; + +// Permission handler +export { CodebuddyPermissionHandler } from './utils/permissionHandler'; +export type { PermissionResult, PendingRequest } from './utils/permissionHandler'; + +// Reasoning processor +export { CodebuddyReasoningProcessor } from './utils/reasoningProcessor'; +export type { + ReasoningToolCall, + ReasoningToolResult, + ReasoningMessage, + ReasoningOutput +} from './utils/reasoningProcessor'; + +// Diff processor +export { CodebuddyDiffProcessor } from './utils/diffProcessor'; +export type { DiffToolCall, DiffToolResult } from './utils/diffProcessor'; + +// Options parser +export { + hasIncompleteOptions, + parseOptionsFromText, + formatOptionsXml +} from './utils/optionsParser'; + +// Main entry point +export { runCodebuddy } from './runCodebuddy'; diff --git a/src/codebuddy/runCodebuddy.ts b/src/codebuddy/runCodebuddy.ts new file mode 100644 index 00000000..a4d2da81 --- /dev/null +++ b/src/codebuddy/runCodebuddy.ts @@ -0,0 +1,875 @@ +/** + * CodeBuddy CLI Entry Point + * + * This module provides the main entry point for running the CodeBuddy agent + * through Happy CLI. It manages the agent lifecycle, session state, and + * communication with the Happy server and mobile app. + */ + +import { render } from 'ink'; +import React from 'react'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; + +import { ApiClient } from '@/api/api'; +import { logger } from '@/ui/logger'; +import { Credentials, readSettings } from '@/persistence'; +import { createSessionMetadata } from '@/utils/createSessionMetadata'; +import { initialMachineMetadata } from '@/daemon/run'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { hashObject } from '@/utils/deterministicJson'; +import { projectPath } from '@/projectPath'; +import { startHappyServer } from '@/claude/utils/startHappyServer'; +import { MessageBuffer } from '@/ui/ink/messageBuffer'; +import { notifyDaemonSessionStarted } from '@/daemon/controlClient'; +import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; +import { stopCaffeinate } from '@/utils/caffeinate'; +import { connectionState } from '@/utils/serverConnectionErrors'; +import { setupOfflineReconnection } from '@/utils/setupOfflineReconnection'; +import type { ApiSessionClient } from '@/api/apiSession'; + +import { createCodebuddyBackend } from '@/agent/acp/codebuddy'; +import type { AgentBackend, AgentMessage } from '@/agent/AgentBackend'; +import { CodebuddyDisplay } from '@/ui/ink/CodebuddyDisplay'; +import { CodebuddyPermissionHandler } from '@/codebuddy/utils/permissionHandler'; +import { CodebuddyReasoningProcessor } from '@/codebuddy/utils/reasoningProcessor'; +import { CodebuddyDiffProcessor } from '@/codebuddy/utils/diffProcessor'; +import type { CodebuddyMode, CodexMessagePayload } from '@/codebuddy/types'; +import type { PermissionMode } from '@/api/types'; +import { CODEBUDDY_MODEL_ENV, DEFAULT_CODEBUDDY_MODEL, CHANGE_TITLE_INSTRUCTION } from '@/codebuddy/constants'; +import { + readCodebuddyLocalConfig, + determineCodebuddyModel, + saveCodebuddyModelToConfig, + getInitialCodebuddyModel +} from '@/codebuddy/utils/config'; +import { + parseOptionsFromText, + hasIncompleteOptions, + formatOptionsXml, +} from '@/codebuddy/utils/optionsParser'; + + +/** + * Main entry point for the codebuddy command with ink UI + */ +export async function runCodebuddy(opts: { + credentials: Credentials; + startedBy?: 'daemon' | 'terminal'; +}): Promise { + // + // Define session + // + + const sessionTag = randomUUID(); + + // Set backend for offline warnings (before any API calls) + connectionState.setBackend('CodeBuddy'); + + const api = await ApiClient.create(opts.credentials); + + + // + // Machine + // + + const settings = await readSettings(); + const machineId = settings?.machineId; + if (!machineId) { + console.error(`[START] No machine ID found in settings, which is unexpected since authAndSetupMachineIfNeeded should have created it. Please report this issue on https://github.com/slopus/happy-cli/issues`); + process.exit(1); + } + logger.debug(`Using machineId: ${machineId}`); + await api.getOrCreateMachine({ + machineId, + metadata: initialMachineMetadata + }); + + // + // Fetch CodeBuddy cloud token (if available) + // + let cloudToken: string | undefined = undefined; + try { + const vendorToken = await api.getVendorToken('codebuddy'); + if (vendorToken?.oauth?.access_token) { + cloudToken = vendorToken.oauth.access_token; + logger.debug('[CodeBuddy] Using OAuth token from Happy cloud'); + } + } catch (error) { + logger.debug('[CodeBuddy] Failed to fetch cloud token:', error); + } + + // + // Create session + // + + const { state, metadata } = createSessionMetadata({ + flavor: 'codebuddy', + machineId, + startedBy: opts.startedBy + }); + const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); + + // Handle server unreachable case - create offline stub with hot reconnection + let session: ApiSessionClient; + let permissionHandler: CodebuddyPermissionHandler; + const { session: initialSession, reconnectionHandle } = setupOfflineReconnection({ + api, + sessionTag, + metadata, + state, + response, + onSessionSwap: (newSession) => { + session = newSession; + if (permissionHandler) { + permissionHandler.updateSession(newSession); + } + } + }); + session = initialSession; + + // Report to daemon (only if we have a real session) + if (response) { + try { + logger.debug(`[START] Reporting session ${response.id} to daemon`); + const result = await notifyDaemonSessionStarted(response.id, metadata); + if (result.error) { + logger.debug(`[START] Failed to report to daemon (may not be running):`, result.error); + } else { + logger.debug(`[START] Reported session ${response.id} to daemon`); + } + } catch (error) { + logger.debug('[START] Failed to report to daemon (may not be running):', error); + } + } + + const messageQueue = new MessageQueue2((mode) => hashObject({ + permissionMode: mode.permissionMode, + model: mode.model, + })); + + // Track current overrides to apply per message + let currentPermissionMode: PermissionMode | undefined = undefined; + let currentModel: string | undefined = undefined; + + session.onUserMessage((message) => { + // Resolve permission mode (validate) + let messagePermissionMode = currentPermissionMode; + if (message.meta?.permissionMode) { + const validModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; + if (validModes.includes(message.meta.permissionMode as PermissionMode)) { + messagePermissionMode = message.meta.permissionMode as PermissionMode; + currentPermissionMode = messagePermissionMode; + updatePermissionMode(messagePermissionMode); + logger.debug(`[CodeBuddy] Permission mode updated from user message to: ${currentPermissionMode}`); + } else { + logger.debug(`[CodeBuddy] Invalid permission mode received: ${message.meta.permissionMode}`); + } + } else { + logger.debug(`[CodeBuddy] User message received with no permission mode override, using current: ${currentPermissionMode ?? 'default (effective)'}`); + } + + // Initialize permission mode if not set yet + if (currentPermissionMode === undefined) { + currentPermissionMode = 'default'; + updatePermissionMode('default'); + } + + // Resolve model + let messageModel = currentModel; + if (message.meta?.hasOwnProperty('model')) { + if (message.meta.model === null) { + messageModel = undefined; + currentModel = undefined; + } else if (message.meta.model) { + messageModel = message.meta.model; + currentModel = messageModel; + updateDisplayedModel(messageModel, true); + messageBuffer.addMessage(`Model changed to: ${messageModel}`, 'system'); + } + } + + // Build the full prompt with appendSystemPrompt if provided + const originalUserMessage = message.content.text; + let fullPrompt = originalUserMessage; + if (isFirstMessage && message.meta?.appendSystemPrompt) { + fullPrompt = message.meta.appendSystemPrompt + '\n\n' + originalUserMessage + '\n\n' + CHANGE_TITLE_INSTRUCTION; + isFirstMessage = false; + } + + const mode: CodebuddyMode = { + permissionMode: messagePermissionMode || 'default', + model: messageModel, + originalUserMessage, + }; + messageQueue.push(fullPrompt, mode); + }); + + let thinking = false; + session.keepAlive(thinking, 'remote'); + const keepAliveInterval = setInterval(() => { + session.keepAlive(thinking, 'remote'); + }, 2000); + + let isFirstMessage = true; + + const sendReady = () => { + session.sendSessionEvent({ type: 'ready' }); + try { + api.push().sendToAllDevices( + "It's ready!", + 'CodeBuddy is waiting for your command', + { sessionId: session.sessionId } + ); + } catch (pushError) { + logger.debug('[CodeBuddy] Failed to send ready push', pushError); + } + }; + + const emitReadyIfIdle = (): boolean => { + if (shouldExit) return false; + if (thinking) return false; + if (isResponseInProgress) return false; + if (messageQueue.size() > 0) return false; + + sendReady(); + return true; + }; + + // + // Abort handling + // + + let abortController = new AbortController(); + let shouldExit = false; + let codebuddyBackend: AgentBackend | null = null; + let acpSessionId: string | null = null; + let wasSessionCreated = false; + + async function handleAbort() { + logger.debug('[CodeBuddy] Abort requested - stopping current task'); + + session.sendCodexMessage({ + type: 'turn_aborted', + id: randomUUID(), + }); + + reasoningProcessor.abort(); + diffProcessor.reset(); + + try { + abortController.abort(); + messageQueue.reset(); + if (codebuddyBackend && acpSessionId) { + await codebuddyBackend.cancel(acpSessionId); + } + logger.debug('[CodeBuddy] Abort completed - session remains active'); + } catch (error) { + logger.debug('[CodeBuddy] Error during abort:', error); + } finally { + abortController = new AbortController(); + } + } + + const handleKillSession = async () => { + logger.debug('[CodeBuddy] Kill session requested - terminating process'); + await handleAbort(); + logger.debug('[CodeBuddy] Abort completed, proceeding with termination'); + + try { + if (session) { + session.updateMetadata((currentMetadata) => ({ + ...currentMetadata, + lifecycleState: 'archived', + lifecycleStateSince: Date.now(), + archivedBy: 'cli', + archiveReason: 'User terminated' + })); + + session.sendSessionDeath(); + await session.flush(); + await session.close(); + } + + stopCaffeinate(); + happyServer.stop(); + + if (codebuddyBackend) { + await codebuddyBackend.dispose(); + } + + logger.debug('[CodeBuddy] Session termination complete, exiting'); + process.exit(0); + } catch (error) { + logger.debug('[CodeBuddy] Error during session termination:', error); + process.exit(1); + } + }; + + session.rpcHandlerManager.registerHandler('abort', handleAbort); + registerKillSessionHandler(session.rpcHandlerManager, handleKillSession); + + // + // Initialize Ink UI + // + + const messageBuffer = new MessageBuffer(); + const hasTTY = process.stdout.isTTY && process.stdin.isTTY; + let inkInstance: ReturnType | null = null; + + let displayedModel: string | undefined = getInitialCodebuddyModel(); + + const localConfig = readCodebuddyLocalConfig(); + logger.debug(`[codebuddy] Initial model setup: env[CODEBUDDY_MODEL]=${process.env[CODEBUDDY_MODEL_ENV] || 'not set'}, localConfig=${localConfig.model || 'not set'}, displayedModel=${displayedModel}`); + + const updateDisplayedModel = (model: string | undefined, saveToConfig: boolean = false) => { + if (model === undefined) { + logger.debug(`[codebuddy] updateDisplayedModel called with undefined, skipping update`); + return; + } + + const oldModel = displayedModel; + displayedModel = model; + logger.debug(`[codebuddy] updateDisplayedModel called: oldModel=${oldModel}, newModel=${model}, saveToConfig=${saveToConfig}`); + + if (saveToConfig) { + saveCodebuddyModelToConfig(model); + } + + if (hasTTY && oldModel !== model) { + logger.debug(`[codebuddy] Adding model update message to buffer: [MODEL:${model}]`); + messageBuffer.addMessage(`[MODEL:${model}]`, 'system'); + } else if (hasTTY) { + logger.debug(`[codebuddy] Model unchanged, skipping update message`); + } + }; + + if (hasTTY) { + console.clear(); + const DisplayComponent = () => { + const currentModelValue = displayedModel || DEFAULT_CODEBUDDY_MODEL; + return React.createElement(CodebuddyDisplay, { + messageBuffer, + logPath: process.env.DEBUG ? logger.getLogPath() : undefined, + currentModel: currentModelValue, + onExit: async () => { + logger.debug('[codebuddy]: Exiting agent via Ctrl-C'); + shouldExit = true; + await handleAbort(); + } + }); + }; + + inkInstance = render(React.createElement(DisplayComponent), { + exitOnCtrlC: false, + patchConsole: false + }); + + const initialModelName = displayedModel || DEFAULT_CODEBUDDY_MODEL; + logger.debug(`[codebuddy] Sending initial model to UI: ${initialModelName}`); + messageBuffer.addMessage(`[MODEL:${initialModelName}]`, 'system'); + } + + if (hasTTY) { + process.stdin.resume(); + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.setEncoding('utf8'); + } + + // + // Start Happy MCP server and create CodeBuddy backend + // + + const happyServer = await startHappyServer(session); + const bridgeCommand = join(projectPath(), 'bin', 'happy-mcp.mjs'); + const mcpServers = { + happy: { + command: bridgeCommand, + args: ['--url', happyServer.url] + } + }; + + permissionHandler = new CodebuddyPermissionHandler(session); + + const reasoningProcessor = new CodebuddyReasoningProcessor((message) => { + session.sendCodexMessage(message); + }); + + const diffProcessor = new CodebuddyDiffProcessor((message) => { + session.sendCodexMessage(message); + }); + + const updatePermissionMode = (mode: PermissionMode) => { + permissionHandler.setPermissionMode(mode); + }; + + let accumulatedResponse = ''; + let isResponseInProgress = false; + let currentResponseMessageId: string | null = null; + + function setupCodebuddyMessageHandler(backend: AgentBackend): void { + backend.onMessage((msg: AgentMessage) => { + + switch (msg.type) { + case 'model-output': + if (msg.textDelta) { + if (!isResponseInProgress) { + messageBuffer.removeLastMessage('system'); + messageBuffer.addMessage(msg.textDelta, 'assistant'); + isResponseInProgress = true; + logger.debug(`[codebuddy] Started new response, first chunk length: ${msg.textDelta.length}`); + } else { + messageBuffer.updateLastMessage(msg.textDelta, 'assistant'); + logger.debug(`[codebuddy] Updated response, chunk length: ${msg.textDelta.length}, total accumulated: ${accumulatedResponse.length + msg.textDelta.length}`); + } + accumulatedResponse += msg.textDelta; + } + break; + + case 'status': + logger.debug(`[codebuddy] Status changed: ${msg.status}${msg.detail ? ` - ${msg.detail}` : ''}`); + + if (msg.status === 'error') { + logger.debug(`[codebuddy] Error status received: ${msg.detail || 'Unknown error'}`); + + session.sendCodexMessage({ + type: 'turn_aborted', + id: randomUUID(), + }); + } + + if (msg.status === 'running') { + thinking = true; + session.keepAlive(thinking, 'remote'); + + session.sendCodexMessage({ + type: 'task_started', + id: randomUUID(), + }); + + messageBuffer.addMessage('Thinking...', 'system'); + } else if (msg.status === 'idle' || msg.status === 'stopped') { + if (thinking) { + thinking = false; + } + thinking = false; + session.keepAlive(thinking, 'remote'); + + const reasoningCompleted = reasoningProcessor.complete(); + + if (reasoningCompleted || isResponseInProgress) { + session.sendCodexMessage({ + type: 'task_complete', + id: randomUUID(), + }); + } + + if (isResponseInProgress && accumulatedResponse.trim()) { + const { text: messageText, options } = parseOptionsFromText(accumulatedResponse); + + let finalMessageText = messageText; + if (options.length > 0) { + const optionsXml = formatOptionsXml(options); + finalMessageText = messageText + optionsXml; + logger.debug(`[codebuddy] Found ${options.length} options in response:`, options); + } else if (hasIncompleteOptions(accumulatedResponse)) { + logger.debug(`[codebuddy] Warning: Incomplete options block detected but sending message anyway`); + } + + const messageId = randomUUID(); + + const messagePayload: CodexMessagePayload = { + type: 'message', + message: finalMessageText, + id: messageId, + ...(options.length > 0 && { options }), + }; + + logger.debug(`[codebuddy] Sending complete message to mobile (length: ${finalMessageText.length})`); + session.sendCodexMessage(messagePayload); + accumulatedResponse = ''; + isResponseInProgress = false; + } + } else if (msg.status === 'error') { + thinking = false; + session.keepAlive(thinking, 'remote'); + accumulatedResponse = ''; + isResponseInProgress = false; + currentResponseMessageId = null; + + const errorMessage = msg.detail || 'Unknown error'; + messageBuffer.addMessage(`Error: ${errorMessage}`, 'status'); + + session.sendCodexMessage({ + type: 'message', + message: `Error: ${errorMessage}`, + id: randomUUID(), + }); + } + break; + + case 'tool-call': + const toolArgs = msg.args ? JSON.stringify(msg.args).substring(0, 100) : ''; + + logger.debug(`[codebuddy] Tool call received: ${msg.toolName} (${msg.callId})`); + + messageBuffer.addMessage(`Executing: ${msg.toolName}${toolArgs ? ` ${toolArgs}${toolArgs.length >= 100 ? '...' : ''}` : ''}`, 'tool'); + session.sendCodexMessage({ + type: 'tool-call', + name: msg.toolName, + callId: msg.callId, + input: msg.args, + id: randomUUID(), + }); + break; + + case 'tool-result': + const isError = msg.result && typeof msg.result === 'object' && 'error' in msg.result; + const resultText = typeof msg.result === 'string' + ? msg.result.substring(0, 200) + : JSON.stringify(msg.result).substring(0, 200); + const truncatedResult = resultText + (typeof msg.result === 'string' && msg.result.length > 200 ? '...' : ''); + + const resultSize = typeof msg.result === 'string' + ? msg.result.length + : JSON.stringify(msg.result).length; + + logger.debug(`[codebuddy] ${isError ? 'Error' : 'Success'} Tool result: ${msg.toolName} (${msg.callId}) - Size: ${resultSize} bytes`); + + if (!isError) { + diffProcessor.processToolResult(msg.toolName, msg.result, msg.callId); + } + + if (isError) { + const errorMsg = (msg.result as any).error || 'Tool call failed'; + logger.debug(`[codebuddy] Tool call error: ${errorMsg.substring(0, 300)}`); + messageBuffer.addMessage(`Error: ${errorMsg}`, 'status'); + } else { + if (resultSize > 1000) { + logger.debug(`[codebuddy] Large tool result (${resultSize} bytes) - first 200 chars: ${truncatedResult}`); + } + messageBuffer.addMessage(`Result: ${truncatedResult}`, 'result'); + } + + session.sendCodexMessage({ + type: 'tool-call-result', + callId: msg.callId, + output: msg.result, + id: randomUUID(), + }); + break; + + case 'fs-edit': + messageBuffer.addMessage(`File edit: ${msg.description}`, 'tool'); + + diffProcessor.processFsEdit(msg.path || '', msg.description, msg.diff); + + session.sendCodexMessage({ + type: 'file-edit', + description: msg.description, + diff: msg.diff, + path: msg.path, + id: randomUUID(), + }); + break; + + case 'terminal-output': + messageBuffer.addMessage(msg.data, 'result'); + session.sendCodexMessage({ + type: 'terminal-output', + data: msg.data, + id: randomUUID(), + }); + break; + + case 'permission-request': + session.sendCodexMessage({ + type: 'permission-request', + permissionId: msg.id, + reason: msg.reason, + payload: msg.payload, + id: randomUUID(), + }); + break; + + case 'event': + if (msg.name === 'thinking') { + const thinkingPayload = msg.payload as { text?: string } | undefined; + const thinkingText = (thinkingPayload && typeof thinkingPayload === 'object' && 'text' in thinkingPayload) + ? String(thinkingPayload.text || '') + : ''; + if (thinkingText) { + reasoningProcessor.processChunk(thinkingText); + + logger.debug(`[codebuddy] Thinking chunk received: ${thinkingText.length} chars`); + + if (!thinkingText.startsWith('**')) { + const thinkingPreview = thinkingText.substring(0, 100); + messageBuffer.updateLastMessage(`[Thinking] ${thinkingPreview}...`, 'system'); + } + } + session.sendCodexMessage({ + type: 'thinking', + text: thinkingText, + id: randomUUID(), + }); + } + break; + + default: + if ((msg as any).type === 'token-count') { + session.sendCodexMessage({ + type: 'token_count', + ...(msg as any), + id: randomUUID(), + }); + } + break; + } + }); + } + + let first = true; + + try { + let currentModeHash: string | null = null; + let pending: { message: string; mode: CodebuddyMode; isolate: boolean; hash: string } | null = null; + + while (!shouldExit) { + let message: { message: string; mode: CodebuddyMode; isolate: boolean; hash: string } | null = pending; + pending = null; + + if (!message) { + logger.debug('[codebuddy] Main loop: waiting for messages from queue...'); + const waitSignal = abortController.signal; + const batch = await messageQueue.waitForMessagesAndGetAsString(waitSignal); + if (!batch) { + if (waitSignal.aborted && !shouldExit) { + logger.debug('[codebuddy] Main loop: wait aborted, continuing...'); + continue; + } + logger.debug('[codebuddy] Main loop: no batch received, breaking...'); + break; + } + logger.debug(`[codebuddy] Main loop: received message from queue (length: ${batch.message.length})`); + message = batch; + } + + if (!message) { + break; + } + + // Handle mode change - restart session if permission mode or model changed + if (wasSessionCreated && currentModeHash && message.hash !== currentModeHash) { + logger.debug('[CodeBuddy] Mode changed – restarting CodeBuddy session'); + messageBuffer.addMessage('═'.repeat(40), 'status'); + messageBuffer.addMessage('Starting new CodeBuddy session (mode changed)...', 'status'); + + permissionHandler.reset(); + reasoningProcessor.abort(); + + if (codebuddyBackend) { + await codebuddyBackend.dispose(); + codebuddyBackend = null; + } + + const modelToUse = message.mode?.model === undefined ? undefined : (message.mode.model || null); + codebuddyBackend = createCodebuddyBackend({ + cwd: process.cwd(), + mcpServers, + permissionHandler, + cloudToken, + model: modelToUse, + }); + + setupCodebuddyMessageHandler(codebuddyBackend); + + const localConfigForModel = readCodebuddyLocalConfig(); + const actualModel = determineCodebuddyModel(modelToUse, localConfigForModel); + logger.debug(`[codebuddy] Model change - modelToUse=${modelToUse}, actualModel=${actualModel}`); + + logger.debug('[codebuddy] Starting new ACP session with model:', actualModel); + const { sessionId } = await codebuddyBackend.startSession(); + acpSessionId = sessionId; + logger.debug(`[codebuddy] New ACP session started: ${acpSessionId}`); + + logger.debug(`[codebuddy] Calling updateDisplayedModel with: ${actualModel}`); + updateDisplayedModel(actualModel, false); + + updatePermissionMode(message.mode.permissionMode); + + wasSessionCreated = true; + currentModeHash = message.hash; + first = false; + } + + currentModeHash = message.hash; + const userMessageToShow = message.mode?.originalUserMessage || message.message; + messageBuffer.addMessage(userMessageToShow, 'user'); + + try { + if (first || !wasSessionCreated) { + if (!codebuddyBackend) { + const modelToUse = message.mode?.model === undefined ? undefined : (message.mode.model || null); + codebuddyBackend = createCodebuddyBackend({ + cwd: process.cwd(), + mcpServers, + permissionHandler, + cloudToken, + model: modelToUse, + }); + + setupCodebuddyMessageHandler(codebuddyBackend); + + const localConfigForModel = readCodebuddyLocalConfig(); + const actualModel = determineCodebuddyModel(modelToUse, localConfigForModel); + + const modelSource = modelToUse !== undefined + ? 'message' + : process.env[CODEBUDDY_MODEL_ENV] + ? 'env-var' + : localConfigForModel.model + ? 'local-config' + : 'default'; + + logger.debug(`[codebuddy] Backend created, model will be: ${actualModel} (from ${modelSource})`); + logger.debug(`[codebuddy] Calling updateDisplayedModel with: ${actualModel}`); + updateDisplayedModel(actualModel, false); + } + + if (!acpSessionId) { + logger.debug('[codebuddy] Starting ACP session...'); + updatePermissionMode(message.mode.permissionMode); + const { sessionId } = await codebuddyBackend.startSession(); + acpSessionId = sessionId; + logger.debug(`[codebuddy] ACP session started: ${acpSessionId}`); + wasSessionCreated = true; + currentModeHash = message.hash; + + logger.debug(`[codebuddy] Displaying model in UI: ${displayedModel || DEFAULT_CODEBUDDY_MODEL}`); + } + } + + if (!acpSessionId) { + throw new Error('ACP session not started'); + } + + accumulatedResponse = ''; + isResponseInProgress = false; + + if (!codebuddyBackend || !acpSessionId) { + throw new Error('CodeBuddy backend or session not initialized'); + } + + const promptToSend = message.message; + + logger.debug(`[codebuddy] Sending prompt to CodeBuddy (length: ${promptToSend.length})`); + await codebuddyBackend.sendPrompt(acpSessionId, promptToSend); + logger.debug('[codebuddy] Prompt sent successfully'); + + if (first) { + first = false; + } + } catch (error) { + logger.debug('[codebuddy] Error in codebuddy session:', error); + const isAbortError = error instanceof Error && error.name === 'AbortError'; + + if (isAbortError) { + messageBuffer.addMessage('Aborted by user', 'status'); + session.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); + } else { + let errorMsg = 'Process error occurred'; + + if (typeof error === 'object' && error !== null) { + const errObj = error as any; + + const errorDetails = errObj.data?.details || errObj.details || ''; + const errorCode = errObj.code || errObj.status || (errObj.response?.status); + const errorMessage = errObj.message || errObj.error?.message || ''; + const errorString = String(error); + + if (errorCode === 404 || errorDetails.includes('notFound') || errorDetails.includes('404') || + errorMessage.includes('not found') || errorMessage.includes('404')) { + const currentModel = displayedModel || DEFAULT_CODEBUDDY_MODEL; + errorMsg = `Model "${currentModel}" not found.`; + } + else if (errorCode === 429 || + errorDetails.includes('429') || errorMessage.includes('429') || errorString.includes('429') || + errorDetails.includes('rateLimitExceeded') || errorDetails.includes('RESOURCE_EXHAUSTED') || + errorMessage.includes('Rate limit exceeded') || errorMessage.includes('Resource exhausted')) { + errorMsg = 'API rate limit exceeded. Please wait a moment and try again.'; + } + else if (errorDetails.includes('quota') || errorMessage.includes('quota') || errorString.includes('quota')) { + errorMsg = 'API daily quota exceeded. Please wait until quota resets.'; + } + else if (Object.keys(error).length === 0) { + errorMsg = 'Failed to start CodeBuddy. Is "codebuddy" CLI installed?'; + } + else if (errObj.message || errorMessage) { + errorMsg = errorDetails || errorMessage || errObj.message; + } + } else if (error instanceof Error) { + errorMsg = error.message; + } + + messageBuffer.addMessage(errorMsg, 'status'); + session.sendCodexMessage({ + type: 'message', + message: errorMsg, + id: randomUUID(), + }); + } + } finally { + permissionHandler.reset(); + reasoningProcessor.abort(); + diffProcessor.reset(); + + thinking = false; + session.keepAlive(thinking, 'remote'); + + emitReadyIfIdle(); + + logger.debug(`[codebuddy] Main loop: turn completed, continuing to next iteration (queue size: ${messageQueue.size()})`); + } + } + + } finally { + logger.debug('[codebuddy]: Final cleanup start'); + + if (reconnectionHandle) { + logger.debug('[codebuddy]: Cancelling offline reconnection'); + reconnectionHandle.cancel(); + } + + try { + session.sendSessionDeath(); + await session.flush(); + await session.close(); + } catch (e) { + logger.debug('[codebuddy]: Error while closing session', e); + } + + if (codebuddyBackend) { + await codebuddyBackend.dispose(); + } + + happyServer.stop(); + + if (process.stdin.isTTY) { + try { process.stdin.setRawMode(false); } catch { /* ignore */ } + } + if (hasTTY) { + try { process.stdin.pause(); } catch { /* ignore */ } + } + + clearInterval(keepAliveInterval); + if (inkInstance) { + inkInstance.unmount(); + } + messageBuffer.clear(); + + logger.debug('[codebuddy]: Final cleanup completed'); + } +} diff --git a/src/codebuddy/types.ts b/src/codebuddy/types.ts new file mode 100644 index 00000000..eecc90fd --- /dev/null +++ b/src/codebuddy/types.ts @@ -0,0 +1,83 @@ +/** + * CodeBuddy Types + * + * Centralized type definitions for CodeBuddy Code integration. + * Based on CodeBuddy Code documentation and ACP protocol. + */ + +import type { PermissionMode } from '@/api/types'; + +/** + * Mode configuration for CodeBuddy messages + */ +export interface CodebuddyMode { + permissionMode: PermissionMode; + model?: string; + originalUserMessage?: string; // Original user message without system prompt +} + +/** + * Codex message payload for sending messages to mobile app + * (Compatible with existing mobile app protocol) + */ +export interface CodexMessagePayload { + type: 'message'; + message: string; + id: string; + options?: string[]; +} + +/** + * CodeBuddy local configuration structure + */ +export interface CodebuddyLocalConfig { + token: string | null; + model: string | null; +} + +/** + * CodeBuddy settings configuration (from .codebuddy/settings.json) + */ +export interface CodebuddySettings { + permissions?: { + allow?: string[]; + ask?: string[]; + deny?: string[]; + additionalDirectories?: string[]; + defaultMode?: string; + }; + env?: Record; + model?: string; + cleanupPeriodDays?: number; + includeCoAuthoredBy?: boolean; + hooks?: Record; +} + +/** + * CodeBuddy memory file structure + */ +export interface CodebuddyMemory { + /** Raw content of CODEBUDDY.md */ + content: string; + /** Parsed sections */ + sections?: { + title: string; + items: string[]; + }[]; +} + +/** + * Rule file with YAML frontmatter + */ +export interface CodebuddyRule { + /** Whether the rule is enabled */ + enabled: boolean; + /** Whether to always apply this rule */ + alwaysApply: boolean; + /** Glob pattern for triggering the rule */ + paths?: string; + /** Rule content (markdown) */ + content: string; + /** File path of the rule */ + filePath: string; +} diff --git a/src/codebuddy/utils/config.ts b/src/codebuddy/utils/config.ts new file mode 100644 index 00000000..657c003f --- /dev/null +++ b/src/codebuddy/utils/config.ts @@ -0,0 +1,452 @@ +/** + * CodeBuddy Configuration Utilities + * + * Utilities for reading and writing CodeBuddy Code configuration files, + * including settings, memory files, and model settings. + * + * Configuration hierarchy (from CodeBuddy Code docs): + * 1. User settings: ~/.codebuddy/settings.json + * 2. Project shared: .codebuddy/settings.json + * 3. Project local: .codebuddy/settings.local.json + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import { logger } from '@/ui/logger'; +import { + CODEBUDDY_MODEL_ENV, + DEFAULT_CODEBUDDY_MODEL, + CODEBUDDY_DIR, + CODEBUDDY_MEMORY_FILE, + CODEBUDDY_LOCAL_MEMORY_FILE, + CODEBUDDY_SETTINGS_FILE, + CODEBUDDY_LOCAL_SETTINGS_FILE, + CODEBUDDY_RULES_DIR +} from '../constants'; +import type { CodebuddyLocalConfig, CodebuddySettings, CodebuddyMemory, CodebuddyRule } from '../types'; + +/** + * Get user-level CodeBuddy directory path + */ +export function getUserCodebuddyDir(): string { + return join(homedir(), CODEBUDDY_DIR); +} + +/** + * Get project-level CodeBuddy directory path + */ +export function getProjectCodebuddyDir(cwd: string = process.cwd()): string { + return join(cwd, CODEBUDDY_DIR); +} + +/** + * Read CodeBuddy local configuration (token and model) + */ +export function readCodebuddyLocalConfig(): CodebuddyLocalConfig { + let token: string | null = null; + let model: string | null = null; + + // Try common CodeBuddy config locations + const possiblePaths = [ + join(getUserCodebuddyDir(), 'config.json'), + join(getUserCodebuddyDir(), 'auth.json'), + join(getUserCodebuddyDir(), CODEBUDDY_SETTINGS_FILE), + ]; + + for (const configPath of possiblePaths) { + if (existsSync(configPath)) { + try { + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + + // Try different possible token field names + if (!token) { + const foundToken = config.access_token || config.token || config.apiKey || config.CODEBUDDY_API_KEY; + if (foundToken && typeof foundToken === 'string') { + token = foundToken; + logger.debug(`[CodeBuddy] Found token in ${configPath}`); + } + } + + // Try to read model from config + if (!model) { + const foundModel = config.model || config.CODEBUDDY_MODEL; + if (foundModel && typeof foundModel === 'string') { + model = foundModel; + logger.debug(`[CodeBuddy] Found model in ${configPath}: ${model}`); + } + } + } catch (error) { + logger.debug(`[CodeBuddy] Failed to read config from ${configPath}:`, error); + } + } + } + + return { token, model }; +} + +/** + * Read CodeBuddy settings from .codebuddy/settings.json + * Merges user, project shared, and project local settings + */ +export function readCodebuddySettings(cwd: string = process.cwd()): CodebuddySettings { + const settings: CodebuddySettings = {}; + + // Order of loading (later overrides earlier): + // 1. User settings + // 2. Project shared settings + // 3. Project local settings + const settingsPaths = [ + join(getUserCodebuddyDir(), CODEBUDDY_SETTINGS_FILE), + join(getProjectCodebuddyDir(cwd), CODEBUDDY_SETTINGS_FILE), + join(getProjectCodebuddyDir(cwd), CODEBUDDY_LOCAL_SETTINGS_FILE), + ]; + + for (const settingsPath of settingsPaths) { + if (existsSync(settingsPath)) { + try { + const fileSettings = JSON.parse(readFileSync(settingsPath, 'utf-8')); + // Deep merge settings + Object.assign(settings, fileSettings); + logger.debug(`[CodeBuddy] Loaded settings from ${settingsPath}`); + } catch (error) { + logger.debug(`[CodeBuddy] Failed to read settings from ${settingsPath}:`, error); + } + } + } + + return settings; +} + +/** + * Read CODEBUDDY.md memory file + * Searches in multiple locations as per CodeBuddy Code documentation + */ +export function readCodebuddyMemory(cwd: string = process.cwd()): CodebuddyMemory | null { + // Search order: + // 1. User level: ~/.codebuddy/CODEBUDDY.md + // 2. Project root: ./CODEBUDDY.md + // 3. Project .codebuddy: ./.codebuddy/CODEBUDDY.md + // Also check for local variants (CODEBUDDY.local.md) + + const memoryPaths = [ + join(getUserCodebuddyDir(), CODEBUDDY_MEMORY_FILE), + join(cwd, CODEBUDDY_MEMORY_FILE), + join(getProjectCodebuddyDir(cwd), CODEBUDDY_MEMORY_FILE), + ]; + + const localMemoryPaths = [ + join(cwd, CODEBUDDY_LOCAL_MEMORY_FILE), + join(getProjectCodebuddyDir(cwd), CODEBUDDY_LOCAL_MEMORY_FILE), + ]; + + let combinedContent = ''; + + // Read main memory files + for (const memoryPath of memoryPaths) { + if (existsSync(memoryPath)) { + try { + const content = readFileSync(memoryPath, 'utf-8'); + if (content.trim()) { + combinedContent += content + '\n\n'; + logger.debug(`[CodeBuddy] Loaded memory from ${memoryPath}`); + } + } catch (error) { + logger.debug(`[CodeBuddy] Failed to read memory from ${memoryPath}:`, error); + } + } + } + + // Read local memory files + for (const localPath of localMemoryPaths) { + if (existsSync(localPath)) { + try { + const content = readFileSync(localPath, 'utf-8'); + if (content.trim()) { + combinedContent += content + '\n\n'; + logger.debug(`[CodeBuddy] Loaded local memory from ${localPath}`); + } + } catch (error) { + logger.debug(`[CodeBuddy] Failed to read local memory from ${localPath}:`, error); + } + } + } + + if (!combinedContent.trim()) { + return null; + } + + return { + content: combinedContent.trim(), + sections: parseMemorySections(combinedContent), + }; +} + +/** + * Parse memory content into sections + */ +function parseMemorySections(content: string): { title: string; items: string[] }[] { + const sections: { title: string; items: string[] }[] = []; + const lines = content.split('\n'); + let currentSection: { title: string; items: string[] } | null = null; + + for (const line of lines) { + // Check for markdown headers (## or ###) + const headerMatch = line.match(/^#{2,3}\s+(.+)$/); + if (headerMatch) { + if (currentSection) { + sections.push(currentSection); + } + currentSection = { title: headerMatch[1], items: [] }; + } else if (currentSection) { + // Check for list items (- or *) + const itemMatch = line.match(/^[-*]\s+(.+)$/); + if (itemMatch) { + currentSection.items.push(itemMatch[1]); + } + } + } + + if (currentSection) { + sections.push(currentSection); + } + + return sections; +} + +/** + * Read rules from .codebuddy/rules/ directory + */ +export function readCodebuddyRules(cwd: string = process.cwd()): CodebuddyRule[] { + const rules: CodebuddyRule[] = []; + + // Check both user and project rules directories + const rulesDirs = [ + join(getUserCodebuddyDir(), CODEBUDDY_RULES_DIR), + join(getProjectCodebuddyDir(cwd), CODEBUDDY_RULES_DIR), + ]; + + for (const rulesDir of rulesDirs) { + if (!existsSync(rulesDir)) { + continue; + } + + try { + const { readdirSync, statSync } = require('fs'); + const files = readdirSync(rulesDir); + + for (const file of files) { + const filePath = join(rulesDir, file); + const stat = statSync(filePath); + + if (stat.isFile() && file.endsWith('.md')) { + const rule = parseRuleFile(filePath); + if (rule) { + rules.push(rule); + } + } else if (stat.isDirectory()) { + // Recursively read rules from subdirectories + const subRules = readRulesFromDir(filePath); + rules.push(...subRules); + } + } + } catch (error) { + logger.debug(`[CodeBuddy] Failed to read rules from ${rulesDir}:`, error); + } + } + + return rules; +} + +/** + * Read rules from a directory recursively + */ +function readRulesFromDir(dir: string): CodebuddyRule[] { + const rules: CodebuddyRule[] = []; + + try { + const { readdirSync, statSync } = require('fs'); + const files = readdirSync(dir); + + for (const file of files) { + const filePath = join(dir, file); + const stat = statSync(filePath); + + if (stat.isFile() && file.endsWith('.md')) { + const rule = parseRuleFile(filePath); + if (rule) { + rules.push(rule); + } + } else if (stat.isDirectory()) { + const subRules = readRulesFromDir(filePath); + rules.push(...subRules); + } + } + } catch (error) { + logger.debug(`[CodeBuddy] Failed to read rules from ${dir}:`, error); + } + + return rules; +} + +/** + * Parse a rule file with YAML frontmatter + */ +function parseRuleFile(filePath: string): CodebuddyRule | null { + try { + const content = readFileSync(filePath, 'utf-8'); + + // Default values + let enabled = true; + let alwaysApply = true; + let paths: string | undefined; + let ruleContent = content; + + // Check for YAML frontmatter + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (frontmatterMatch) { + const frontmatter = frontmatterMatch[1]; + ruleContent = frontmatterMatch[2]; + + // Parse simple YAML frontmatter + const enabledMatch = frontmatter.match(/enabled:\s*(true|false)/); + if (enabledMatch) { + enabled = enabledMatch[1] === 'true'; + } + + const alwaysApplyMatch = frontmatter.match(/alwaysApply:\s*(true|false)/); + if (alwaysApplyMatch) { + alwaysApply = alwaysApplyMatch[1] === 'true'; + } + + const pathsMatch = frontmatter.match(/paths:\s*(.+)/); + if (pathsMatch) { + paths = pathsMatch[1].trim(); + } + } + + return { + enabled, + alwaysApply, + paths, + content: ruleContent.trim(), + filePath, + }; + } catch (error) { + logger.debug(`[CodeBuddy] Failed to parse rule file ${filePath}:`, error); + return null; + } +} + +/** + * Determine the model to use based on priority: + * 1. Explicit model parameter (if provided) + * 2. Environment variable (CODEBUDDY_MODEL) + * 3. Local config file + * 4. Default model + */ +export function determineCodebuddyModel( + explicitModel: string | null | undefined, + localConfig: CodebuddyLocalConfig +): string { + if (explicitModel !== undefined) { + if (explicitModel === null) { + // Explicitly null - use env or default, skip local config + return process.env[CODEBUDDY_MODEL_ENV] || DEFAULT_CODEBUDDY_MODEL; + } else { + // Model explicitly provided - use it + return explicitModel; + } + } else { + // No explicit model - check env var first, then local config, then default + const envModel = process.env[CODEBUDDY_MODEL_ENV]; + logger.debug(`[CodeBuddy] Model selection: env[CODEBUDDY_MODEL]=${envModel}, localConfig.model=${localConfig.model}, DEFAULT=${DEFAULT_CODEBUDDY_MODEL}`); + const model = envModel || localConfig.model || DEFAULT_CODEBUDDY_MODEL; + logger.debug(`[CodeBuddy] Selected model: ${model}`); + return model; + } +} + +/** + * Save model to CodeBuddy config file + */ +export function saveCodebuddyModelToConfig(model: string): void { + try { + const configDir = getUserCodebuddyDir(); + const configPath = join(configDir, 'config.json'); + + // Create directory if it doesn't exist + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }); + } + + // Read existing config or create new one + let config: Record = {}; + if (existsSync(configPath)) { + try { + config = JSON.parse(readFileSync(configPath, 'utf-8')); + } catch (error) { + logger.debug(`[CodeBuddy] Failed to read existing config, creating new one`); + config = {}; + } + } + + // Update model in config + config.model = model; + + // Write config back + writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); + logger.debug(`[CodeBuddy] Saved model "${model}" to ${configPath}`); + } catch (error) { + logger.debug(`[CodeBuddy] Failed to save model to config:`, error); + } +} + +/** + * Get the initial model value for UI display + */ +export function getInitialCodebuddyModel(): string { + const localConfig = readCodebuddyLocalConfig(); + return process.env[CODEBUDDY_MODEL_ENV] || localConfig.model || DEFAULT_CODEBUDDY_MODEL; +} + +/** + * Determine the source of the model for logging purposes + */ +export function getCodebuddyModelSource( + explicitModel: string | null | undefined, + localConfig: CodebuddyLocalConfig +): 'explicit' | 'env-var' | 'local-config' | 'default' { + if (explicitModel !== undefined && explicitModel !== null) { + return 'explicit'; + } else if (process.env[CODEBUDDY_MODEL_ENV]) { + return 'env-var'; + } else if (localConfig.model) { + return 'local-config'; + } else { + return 'default'; + } +} + +/** + * Build system prompt from memory and rules + */ +export function buildSystemPrompt(cwd: string = process.cwd()): string { + const parts: string[] = []; + + // Add memory content + const memory = readCodebuddyMemory(cwd); + if (memory?.content) { + parts.push('# Project Context from CODEBUDDY.md\n\n' + memory.content); + } + + // Add always-applied rules + const rules = readCodebuddyRules(cwd); + const alwaysApplyRules = rules.filter(r => r.enabled && r.alwaysApply); + + if (alwaysApplyRules.length > 0) { + parts.push('# Project Rules\n\n' + alwaysApplyRules.map(r => r.content).join('\n\n---\n\n')); + } + + return parts.join('\n\n---\n\n'); +} diff --git a/src/codebuddy/utils/diffProcessor.ts b/src/codebuddy/utils/diffProcessor.ts new file mode 100644 index 00000000..91968aca --- /dev/null +++ b/src/codebuddy/utils/diffProcessor.ts @@ -0,0 +1,157 @@ +/** + * Diff Processor for CodeBuddy - Handles file edit events and tracks unified_diff changes + * + * This processor tracks changes from fs-edit events and tool_call results that contain + * file modification information, converting them to CodebuddyDiff tool calls similar to Codex. + * + * Note: CodeBuddy ACP may track file changes through fs-edit events and tool results. + */ + +import { randomUUID } from 'node:crypto'; +import { logger } from '@/ui/logger'; + +export interface DiffToolCall { + type: 'tool-call'; + name: 'CodebuddyDiff'; + callId: string; + input: { + unified_diff?: string; + path?: string; + description?: string; + }; + id: string; +} + +export interface DiffToolResult { + type: 'tool-call-result'; + callId: string; + output: { + status: 'completed'; + }; + id: string; +} + +export class CodebuddyDiffProcessor { + private previousDiffs = new Map(); // Track diffs per file path + private onMessage: ((message: any) => void) | null = null; + + constructor(onMessage?: (message: any) => void) { + this.onMessage = onMessage || null; + } + + /** + * Process an fs-edit event and check if it contains diff information + */ + processFsEdit(path: string, description?: string, diff?: string): void { + logger.debug(`[CodebuddyDiffProcessor] Processing fs-edit for path: ${path}`); + + // If we have a diff, process it + if (diff) { + this.processDiff(path, diff, description); + } else { + // Even without diff, we can track that a file was edited + // Generate a simple diff representation + const simpleDiff = `File edited: ${path}${description ? ` - ${description}` : ''}`; + this.processDiff(path, simpleDiff, description); + } + } + + /** + * Process a tool result that may contain diff information + */ + processToolResult(toolName: string, result: any, callId: string): void { + // Check if result contains diff information + if (result && typeof result === 'object') { + // Look for common diff fields + const diff = result.diff || result.unified_diff || result.patch; + const path = result.path || result.file; + + if (diff && path) { + logger.debug(`[CodebuddyDiffProcessor] Found diff in tool result: ${toolName} (${callId})`); + this.processDiff(path, diff, result.description); + } else if (result.changes && typeof result.changes === 'object') { + // Handle multiple file changes (like patch operations) + for (const [filePath, change] of Object.entries(result.changes)) { + const changeDiff = (change as any).diff || (change as any).unified_diff || + JSON.stringify(change); + this.processDiff(filePath, changeDiff, (change as any).description); + } + } + } + } + + /** + * Process a unified diff and check if it has changed from the previous value + */ + private processDiff(path: string, unifiedDiff: string, description?: string): void { + const previousDiff = this.previousDiffs.get(path); + + // Check if the diff has changed from the previous value + if (previousDiff !== unifiedDiff) { + logger.debug(`[CodebuddyDiffProcessor] Unified diff changed for ${path}, sending CodebuddyDiff tool call`); + + // Generate a unique call ID for this diff + const callId = randomUUID(); + + // Send tool call for the diff change + const toolCall: DiffToolCall = { + type: 'tool-call', + name: 'CodebuddyDiff', + callId: callId, + input: { + unified_diff: unifiedDiff, + path: path, + description: description + }, + id: randomUUID() + }; + + this.onMessage?.(toolCall); + + // Immediately send the tool result to mark it as completed + const toolResult: DiffToolResult = { + type: 'tool-call-result', + callId: callId, + output: { + status: 'completed' + }, + id: randomUUID() + }; + + this.onMessage?.(toolResult); + } + + // Update the stored diff value + this.previousDiffs.set(path, unifiedDiff); + logger.debug(`[CodebuddyDiffProcessor] Updated stored diff for ${path}`); + } + + /** + * Reset the processor state (called on task_complete or turn_aborted) + */ + reset(): void { + logger.debug('[CodebuddyDiffProcessor] Resetting diff state'); + this.previousDiffs.clear(); + } + + /** + * Set the message callback for sending messages directly + */ + setMessageCallback(callback: (message: any) => void): void { + this.onMessage = callback; + } + + /** + * Get the current diff value for a specific path + */ + getCurrentDiff(path: string): string | null { + return this.previousDiffs.get(path) || null; + } + + /** + * Get all tracked diffs + */ + getAllDiffs(): Map { + return new Map(this.previousDiffs); + } +} diff --git a/src/codebuddy/utils/optionsParser.ts b/src/codebuddy/utils/optionsParser.ts new file mode 100644 index 00000000..d7a47005 --- /dev/null +++ b/src/codebuddy/utils/optionsParser.ts @@ -0,0 +1,69 @@ +/** + * Options Parser Utilities for CodeBuddy + * + * Utilities for parsing and formatting XML options blocks from agent responses. + * Used for extracting and formatting blocks. + */ + +/** + * Check if text has an incomplete options block (opening tag but no closing tag) + * + * @param text - The text to check + * @returns true if there's an opening tag without a closing tag + */ +export function hasIncompleteOptions(text: string): boolean { + const hasOpeningTag = //i.test(text); + const hasClosingTag = /<\/options>/i.test(text); + return hasOpeningTag && !hasClosingTag; +} + +/** + * Parse XML options from text + * Extracts blocks and returns + * the text without options and the parsed options array + * + * @param text - The text containing options XML + * @returns Object with text (without options) and options array + */ +export function parseOptionsFromText(text: string): { text: string; options: string[] } { + // Match ... block (multiline, non-greedy) + const optionsRegex = /\s*([\s\S]*?)\s*<\/options>/i; + const match = text.match(optionsRegex); + + if (!match) { + return { text: text.trim(), options: [] }; + } + + // Extract options block content + const optionsBlock = match[1]; + + // Parse individual