UNPKG

@nanocollective/nanocoder

Version:

A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter

731 lines (726 loc) 30.6 kB
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import React from 'react'; import { parseInput } from '../../command-parser.js'; import { commandRegistry } from '../../commands.js'; import BashProgress from '../../components/bash-progress.js'; import { ErrorMessage, InfoMessage, SuccessMessage, } from '../../components/message-box.js'; import { DELAY_COMMAND_COMPLETE_MS } from '../../constants.js'; import { getModelContextLimit, getSessionContextLimit, resetSessionContextLimit, setSessionContextLimit, } from '../../models/index.js'; import { CheckpointManager } from '../../services/checkpoint-manager.js'; import { createTokenizer } from '../../tokenization/index.js'; import { executeBashCommand, formatBashResultForLLM } from '../../tools/execute-bash.js'; import { setAutoCompactEnabled, setAutoCompactThreshold, } from '../../utils/auto-compact.js'; import { compressionBackup } from '../../utils/compression-backup.js'; import { compressMessages } from '../../utils/message-compression.js'; import { processPromptTemplate } from '../../utils/prompt-processor.js'; /** Command names that require special handling in the app */ const SPECIAL_COMMANDS = { CLEAR: 'clear', MODEL: 'model', PROVIDER: 'provider', MODEL_DATABASE: 'model-database', SETUP_PROVIDERS: 'setup-providers', SETUP_MCP: 'setup-mcp', SETTINGS: 'settings', STATUS: 'status', CHECKPOINT: 'checkpoint', EXPLORER: 'explorer', IDE: 'ide', SCHEDULE: 'schedule', COMMANDS: 'commands', }; /** Checkpoint subcommands */ const CHECKPOINT_SUBCOMMANDS = { LOAD: 'load', RESTORE: 'restore', }; /** * Extracts error message from an unknown error */ function getErrorMessage(error, fallback = 'Unknown error') { return error instanceof Error ? error.message : fallback; } /** * Handles bash commands prefixed with ! * Uses the unified bash executor service for real-time progress updates */ async function handleBashCommand(bashCommand, options) { const { onAddToChatQueue, setLiveComponent, setIsToolExecuting, onCommandComplete, getNextComponentKey, setMessages, messages, } = options; // Block user input while executing setIsToolExecuting(true); try { // Start execution and get the execution ID const { executionId, promise } = executeBashCommand(bashCommand); // Set as live component for real-time updates (renders outside Static) setLiveComponent(React.createElement(BashProgress, { key: `bash-progress-live-${getNextComponentKey()}`, executionId, command: bashCommand, isLive: true, })); // Wait for execution to complete const result = await promise; // Clear live component and add static completed version to chat queue setLiveComponent(null); onAddToChatQueue(React.createElement(BashProgress, { key: `bash-progress-complete-${getNextComponentKey()}`, executionId, command: bashCommand, completedState: result, })); // Format result for LLM context const llmContext = formatBashResultForLLM(result); // Add the output to the LLM context for future interactions if (llmContext) { const userMessage = { role: 'user', content: `Bash command output:\n\`\`\`\n$ ${bashCommand}\n${llmContext}\n\`\`\``, }; setMessages([...messages, userMessage]); } } catch (error) { // Clear live component on error setLiveComponent(null); // Show error message if command fails onAddToChatQueue(React.createElement(ErrorMessage, { key: `bash-error-${getNextComponentKey()}`, message: `Error executing command: ${getErrorMessage(error, String(error))}`, })); } finally { // Re-enable user input setIsToolExecuting(false); // Signal completion for non-interactive mode onCommandComplete?.(); } } /** * Handles custom user-defined commands * Returns true if a custom command was found and handled */ async function handleCustomCommand(message, commandName, options) { const { customCommandCache, customCommandLoader, customCommandExecutor, onHandleChatMessage, onCommandComplete, } = options; const customCommand = customCommandCache.get(commandName) || customCommandLoader?.getCommand(commandName); if (!customCommand) { return false; } // Execute custom command with any arguments // Slice past '/' + commandName + space to get the arguments const args = message .slice(commandName.length + 2) .trim() .split(/\s+/) .filter(arg => arg); const processedPrompt = customCommandExecutor?.execute(customCommand, args); // Send the processed prompt to the AI if (processedPrompt) { await onHandleChatMessage(processedPrompt); } else { // Custom command didn't generate a prompt, signal completion onCommandComplete?.(); } return true; } /** * Handles special commands that need app state access (/clear, /model, etc.) * Returns true if a special command was handled */ async function handleSpecialCommand(commandName, options) { const { onClearMessages, onEnterModelSelectionMode, onEnterProviderSelectionMode, onEnterModelDatabaseMode, onEnterConfigWizardMode, onEnterSettingsMode, onEnterMcpWizardMode, onEnterExplorerMode, onShowStatus, onCommandComplete, onAddToChatQueue, getNextComponentKey, } = options; switch (commandName) { case SPECIAL_COMMANDS.CLEAR: await onClearMessages(); // Show success message onAddToChatQueue(React.createElement(SuccessMessage, { key: `clear-success-${getNextComponentKey()}`, message: 'Chat cleared.', hideBox: true, })); // Give React time to render before signaling completion setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS); return true; case SPECIAL_COMMANDS.MODEL: onEnterModelSelectionMode(); onCommandComplete?.(); return true; case SPECIAL_COMMANDS.PROVIDER: onEnterProviderSelectionMode(); onCommandComplete?.(); return true; case SPECIAL_COMMANDS.MODEL_DATABASE: onEnterModelDatabaseMode(); onCommandComplete?.(); return true; case SPECIAL_COMMANDS.SETUP_PROVIDERS: onEnterConfigWizardMode(); onCommandComplete?.(); return true; case SPECIAL_COMMANDS.SETUP_MCP: onEnterMcpWizardMode(); onCommandComplete?.(); return true; case SPECIAL_COMMANDS.SETTINGS: onEnterSettingsMode(); onCommandComplete?.(); return true; case SPECIAL_COMMANDS.STATUS: onShowStatus(); // Status adds to queue synchronously, give React time to render setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS); return true; case SPECIAL_COMMANDS.EXPLORER: onEnterExplorerMode(); onCommandComplete?.(); return true; case SPECIAL_COMMANDS.IDE: options.onEnterIdeSelectionMode(); onCommandComplete?.(); return true; default: return false; } } /** * Handles /schedule start as a special case that enters scheduler mode. * Other /schedule subcommands go through the normal command registry. * Returns true if handled. */ async function handleScheduleStart(commandParts, options) { if (commandParts[0] !== SPECIAL_COMMANDS.SCHEDULE || commandParts[1] !== 'start') { return false; } const { onEnterSchedulerMode, onCommandComplete } = options; if (onEnterSchedulerMode) { onEnterSchedulerMode(); onCommandComplete?.(); } else { options.onAddToChatQueue(React.createElement(ErrorMessage, { key: `schedule-error-${options.getNextComponentKey()}`, message: 'Scheduler mode is not available.', })); onCommandComplete?.(); } return true; } /** * Handles /schedule create — creates the schedule file and prompts the AI to help write it. * Returns true if handled. */ async function handleScheduleCreate(commandParts, options) { if (commandParts[0] !== SPECIAL_COMMANDS.SCHEDULE || commandParts[1] !== 'create') { return false; } const { onAddToChatQueue, onHandleChatMessage, onCommandComplete, getNextComponentKey, } = options; const fileName = commandParts[2]; if (!fileName) { onAddToChatQueue(React.createElement(ErrorMessage, { key: `schedule-create-error-${getNextComponentKey()}`, message: 'Usage: /schedule create <name>\nExample: /schedule create deps-update', })); onCommandComplete?.(); return true; } const safeName = fileName.endsWith('.md') ? fileName : `${fileName}.md`; const schedulesDir = join(process.cwd(), '.nanocoder', 'schedules'); const filePath = join(schedulesDir, safeName); if (existsSync(filePath)) { onAddToChatQueue(React.createElement(ErrorMessage, { key: `schedule-create-exists-${getNextComponentKey()}`, message: `Schedule file already exists: .nanocoder/schedules/${safeName}`, })); onCommandComplete?.(); return true; } mkdirSync(schedulesDir, { recursive: true }); const template = `--- description: ${safeName.replace(/\.md$/, '')} scheduled command --- `; writeFileSync(filePath, template, 'utf-8'); onAddToChatQueue(React.createElement(SuccessMessage, { key: `schedule-created-${getNextComponentKey()}`, message: `Created schedule file: .nanocoder/schedules/${safeName}`, hideBox: true, })); // Ask the AI to help write the schedule command content await onHandleChatMessage(`I just created a new schedule command file at .nanocoder/schedules/${safeName}. Help me write the content for this scheduled task. Ask me what I want this scheduled job to do, then write the markdown prompt into the file using the write_file tool. The file should contain a clear prompt that instructs the AI agent what to do when this schedule runs. Keep the YAML frontmatter at the top with the description field.`); return true; } /** * Handles /commands create — creates the command file and prompts the AI to help write it. * Returns true if handled. */ async function handleCommandCreate(commandParts, options) { if ((commandParts[0] !== SPECIAL_COMMANDS.COMMANDS && commandParts[0] !== 'custom-commands') || commandParts[1] !== 'create') { return false; } const { onAddToChatQueue, onHandleChatMessage, onCommandComplete, getNextComponentKey, } = options; const fileName = commandParts[2]; if (!fileName) { onAddToChatQueue(React.createElement(ErrorMessage, { key: `commands-create-error-${getNextComponentKey()}`, message: 'Usage: /commands create <name>\nExample: /commands create review-code', })); onCommandComplete?.(); return true; } const safeName = fileName.endsWith('.md') ? fileName : `${fileName}.md`; const commandsDir = join(process.cwd(), '.nanocoder', 'commands'); const filePath = join(commandsDir, safeName); if (existsSync(filePath)) { onAddToChatQueue(React.createElement(ErrorMessage, { key: `commands-create-exists-${getNextComponentKey()}`, message: `Command file already exists: .nanocoder/commands/${safeName}`, })); onCommandComplete?.(); return true; } mkdirSync(commandsDir, { recursive: true }); const template = `--- description: ${safeName.replace(/\.md$/, '')} custom command --- `; writeFileSync(filePath, template, 'utf-8'); onAddToChatQueue(React.createElement(SuccessMessage, { key: `commands-created-${getNextComponentKey()}`, message: `Created command file: .nanocoder/commands/${safeName}`, hideBox: true, })); // Ask the AI to help write the custom command content const commandBaseName = safeName.replace(/\.md$/, ''); await onHandleChatMessage(`I just created a new custom command file at .nanocoder/commands/${safeName}. Help me write the content for this command. Ask me what I want this command to do, then write the markdown prompt into the file using the write_file tool. The file should contain a clear prompt that instructs the AI what to do when this command is invoked via /${commandBaseName}. Keep the YAML frontmatter at the top. Here is an example of the frontmatter format with all available fields: --- description: Generate unit tests for a file aliases: [test, unittest] parameters: [filename] tags: [testing, quality] triggers: [write tests, unit test] estimated-tokens: 2000 resources: true category: testing version: 1.0.0 author: user examples: - /gen-tests src/utils.ts - /gen-tests lib/parser.ts references: [docs/testing-guide.md] dependencies: [lint] --- Generate comprehensive unit tests for {{filename}}... All fields are optional except description. Use whichever fields are appropriate for the user's needs. Parameters defined here can be used as {{param}} placeholders in the prompt body.`); return true; } // Handles compact command, Returns true if compact command was handled async function handleCompactCommand(commandParts, options) { const { onAddToChatQueue, onCommandComplete, getNextComponentKey, messages, setMessages, provider, model, } = options; // Check if this is a compact command if (commandParts[0] !== 'compact') { return false; } // Parse arguments const args = commandParts.slice(1); let mode = 'default'; let preview = false; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--aggressive') { mode = 'aggressive'; } else if (arg === '--conservative') { mode = 'conservative'; } else if (arg === '--preview') { preview = true; } else if (arg === '--default') { mode = 'default'; } else if (arg === '--restore') { // Restore messages from backup const restored = compressionBackup.restore(); if (restored) { setMessages(restored); onAddToChatQueue(React.createElement(SuccessMessage, { key: `compact-restore-${getNextComponentKey()}`, message: `Restored ${restored.length} messages from backup.`, hideBox: true, })); compressionBackup.clearBackup(); } else { onAddToChatQueue(React.createElement(ErrorMessage, { key: `compact-restore-error-${getNextComponentKey()}`, message: 'No backup available to restore.', hideBox: true, })); } setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS); return true; } else if (arg === '--auto-on') { // Enable auto-compact for current session setAutoCompactEnabled(true); onAddToChatQueue(React.createElement(SuccessMessage, { key: `compact-auto-on-${getNextComponentKey()}`, message: 'Auto-compact enabled for this session.', hideBox: true, })); setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS); return true; } else if (arg === '--auto-off') { // Disable auto compact for current session setAutoCompactEnabled(false); onAddToChatQueue(React.createElement(SuccessMessage, { key: `compact-auto-off-${getNextComponentKey()}`, message: 'Auto-compact disabled for this session.', hideBox: true, })); setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS); return true; } else if (arg === '--threshold' && i + 1 < args.length) { // Set threshold for current session const thresholdValue = Number.parseFloat(args[i + 1]); if (Number.isNaN(thresholdValue) || thresholdValue < 50 || thresholdValue > 95) { onAddToChatQueue(React.createElement(ErrorMessage, { key: `compact-threshold-error-${getNextComponentKey()}`, message: 'Threshold must be a number between 50 and 95.', hideBox: true, })); setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS); return true; } setAutoCompactThreshold(Math.round(thresholdValue)); onAddToChatQueue(React.createElement(SuccessMessage, { key: `compact-threshold-${getNextComponentKey()}`, message: `Auto-compact threshold set to ${Math.round(thresholdValue)}% for this session.`, hideBox: true, })); setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS); return true; } } try { if (messages.length === 0) { onAddToChatQueue(React.createElement(InfoMessage, { key: `compact-info-${getNextComponentKey()}`, message: 'No messages to compact.', hideBox: true, })); onCommandComplete?.(); return true; } // Create tokenizer const tokenizer = createTokenizer(provider, model); // Include system message in token calculations for consistency with /status and auto-compact const systemPrompt = processPromptTemplate(); const systemMessage = { role: 'system', content: systemPrompt }; const allMessages = [systemMessage, ...messages]; // Perform compression (includes system message for accurate token counting) const result = compressMessages(allMessages, tokenizer, { mode }); // Clean up tokenizer if (tokenizer.free) { tokenizer.free(); } if (preview) { // Preview mode: show what would be compressed without applying const message = `Preview: Context would be compacted: ${result.originalTokenCount.toLocaleString()} tokens → ${result.compressedTokenCount.toLocaleString()} tokens (${Math.round(result.reductionPercentage)}% reduction)\n\nPreserved:\n• ${result.preservedInfo.keyDecisions} key decisions\n• ${result.preservedInfo.fileModifications} file modifications\n• ${result.preservedInfo.toolResults} tool results\n• ${result.preservedInfo.recentMessages} recent messages at full detail`; onAddToChatQueue(React.createElement(InfoMessage, { key: `compact-preview-${getNextComponentKey()}`, message, hideBox: false, })); } else { // Apply compression and store backup before compression compressionBackup.storeBackup(messages); // Filter out system messages from compressed result (they're managed separately) const compressedUserMessages = result.compressedMessages.filter(msg => msg.role !== 'system'); setMessages(compressedUserMessages); // Show success message const message = `Context Compacted: ${result.originalTokenCount.toLocaleString()} tokens → ${result.compressedTokenCount.toLocaleString()} tokens (${Math.round(result.reductionPercentage)}% reduction)\n\nPreserved:\n• ${result.preservedInfo.keyDecisions} key decisions\n• ${result.preservedInfo.fileModifications} file modifications\n• ${result.preservedInfo.toolResults} tool results\n• ${result.preservedInfo.recentMessages} recent messages at full detail`; onAddToChatQueue(React.createElement(SuccessMessage, { key: `compact-success-${getNextComponentKey()}`, message, hideBox: false, })); } setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS); return true; } catch (error) { onAddToChatQueue(React.createElement(ErrorMessage, { key: `compact-error-${getNextComponentKey()}`, message: `Failed to compact messages: ${getErrorMessage(error)}`, hideBox: true, })); onCommandComplete?.(); return true; } } /** * Parses a context limit value string, supporting k/K suffix. * e.g. "8192" -> 8192, "128k" -> 128000, "128K" -> 128000 */ export function parseContextLimit(value) { const trimmed = value.trim().toLowerCase(); let multiplier = 1; let numStr = trimmed; if (trimmed.endsWith('k')) { multiplier = 1000; numStr = trimmed.slice(0, -1); } const parsed = Number.parseFloat(numStr); if (Number.isNaN(parsed) || parsed <= 0) { return null; } return Math.round(parsed * multiplier); } // Handles /context-max command. Returns true if handled. async function handleContextMaxCommand(commandParts, options) { const { onAddToChatQueue, onCommandComplete, getNextComponentKey, model } = options; if (commandParts[0] !== 'context-max') { return false; } const args = commandParts.slice(1); // /context-max --reset — clear session override if (args[0] === '--reset') { resetSessionContextLimit(); onAddToChatQueue(React.createElement(SuccessMessage, { key: `context-max-reset-${getNextComponentKey()}`, message: 'Session context limit override cleared.', hideBox: true, })); setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS); return true; } // /context-max <number> — set session context limit if (args.length > 0) { const limit = parseContextLimit(args[0]); if (limit === null) { onAddToChatQueue(React.createElement(ErrorMessage, { key: `context-max-error-${getNextComponentKey()}`, message: 'Invalid context limit. Use a positive number, e.g. /context-max 8192 or /context-max 128k', })); setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS); return true; } setSessionContextLimit(limit); onAddToChatQueue(React.createElement(SuccessMessage, { key: `context-max-set-${getNextComponentKey()}`, message: `Session context limit set to ${limit.toLocaleString()} tokens.`, hideBox: true, })); setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS); return true; } // /context-max (no args) — show current effective context limit const sessionLimit = getSessionContextLimit(); if (sessionLimit !== null) { onAddToChatQueue(React.createElement(InfoMessage, { key: `context-max-info-${getNextComponentKey()}`, message: `Context limit: ${sessionLimit.toLocaleString()} tokens (session override)`, hideBox: true, })); setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS); return true; } const envLimit = process.env.NANOCODER_CONTEXT_LIMIT; if (envLimit) { const parsed = Number.parseInt(envLimit, 10); if (!Number.isNaN(parsed) && parsed > 0) { onAddToChatQueue(React.createElement(InfoMessage, { key: `context-max-info-${getNextComponentKey()}`, message: `Context limit: ${parsed.toLocaleString()} tokens (NANOCODER_CONTEXT_LIMIT env)`, hideBox: true, })); setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS); return true; } } const modelLimit = await getModelContextLimit(model); if (modelLimit !== null) { onAddToChatQueue(React.createElement(InfoMessage, { key: `context-max-info-${getNextComponentKey()}`, message: `Context limit: ${modelLimit.toLocaleString()} tokens (model lookup)`, hideBox: true, })); } else { onAddToChatQueue(React.createElement(InfoMessage, { key: `context-max-info-${getNextComponentKey()}`, message: 'Context limit: Unknown. Use /context-max <number> to set one.', hideBox: true, })); } setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS); return true; } /** * Handles interactive checkpoint load command * Returns true if checkpoint load was handled */ async function handleCheckpointLoad(commandParts, options) { const { onAddToChatQueue, onEnterCheckpointLoadMode, onCommandComplete, getNextComponentKey, messages, } = options; // Check if this is an interactive checkpoint load command const isCheckpointLoad = commandParts[0] === SPECIAL_COMMANDS.CHECKPOINT && (commandParts[1] === CHECKPOINT_SUBCOMMANDS.LOAD || commandParts[1] === CHECKPOINT_SUBCOMMANDS.RESTORE) && commandParts.length === 2; if (!isCheckpointLoad) { return false; } try { const manager = new CheckpointManager(); const checkpoints = await manager.listCheckpoints(); if (checkpoints.length === 0) { onAddToChatQueue(React.createElement(InfoMessage, { key: `checkpoint-info-${getNextComponentKey()}`, message: 'No checkpoints available. Create one with /checkpoint create [name]', hideBox: true, })); onCommandComplete?.(); return true; } onEnterCheckpointLoadMode(checkpoints, messages.length); return true; } catch (error) { onAddToChatQueue(React.createElement(ErrorMessage, { key: `checkpoint-error-${getNextComponentKey()}`, message: `Failed to list checkpoints: ${getErrorMessage(error)}`, hideBox: true, })); onCommandComplete?.(); return true; } } /** * Handles built-in commands via the command registry */ async function handleBuiltInCommand(message, options) { const { onAddToChatQueue, onCommandComplete, getNextComponentKey, messages } = options; const totalTokens = messages.reduce((sum, msg) => sum + options.getMessageTokens(msg), 0); const result = await commandRegistry.execute(message.slice(1), messages, { provider: options.provider, model: options.model, tokens: totalTokens, getMessageTokens: options.getMessageTokens, }); if (!result) { onCommandComplete?.(); return; } // Handle React element result if (React.isValidElement(result)) { // Defer adding to chat queue to avoid "Cannot update a component while rendering" error queueMicrotask(() => { onAddToChatQueue(result); }); // Give React time to render before signaling completion setTimeout(() => { onCommandComplete?.(); }, DELAY_COMMAND_COMPLETE_MS); return; } // Handle string result if (typeof result === 'string' && result.trim()) { queueMicrotask(() => { onAddToChatQueue(React.createElement(InfoMessage, { key: `command-result-${getNextComponentKey()}`, message: result, hideBox: true, })); }); // Give React time to render before signaling completion setTimeout(() => { onCommandComplete?.(); }, DELAY_COMMAND_COMPLETE_MS); return; } // No output to display, signal completion immediately onCommandComplete?.(); } /** * Handles slash commands (prefixed with /) */ async function handleSlashCommand(message, options) { const commandName = message.slice(1).split(/\s+/)[0]; // Try custom command first if (await handleCustomCommand(message, commandName, options)) { return; } // Try compact command const commandParts = message.slice(1).trim().split(/\s+/); if (await handleCompactCommand(commandParts, options)) { return; } // Try context-max command if (await handleContextMaxCommand(commandParts, options)) { return; } // Try /schedule start (enters scheduler mode) if (await handleScheduleStart(commandParts, options)) { return; } // Try /schedule create (creates file + AI assistance) if (await handleScheduleCreate(commandParts, options)) { return; } // Try /commands create (creates file + AI assistance) if (await handleCommandCreate(commandParts, options)) { return; } // Try special command if (await handleSpecialCommand(commandName, options)) { return; } // Try checkpoint load if (await handleCheckpointLoad(commandParts, options)) { return; } // Fall back to built-in command await handleBuiltInCommand(message, options); } /** * Main entry point for handling user message submission. * Routes messages to appropriate handlers based on their type. */ export async function handleMessageSubmission(message, options) { const parsedInput = parseInput(message); // Handle bash commands (prefixed with !) if (parsedInput.isBashCommand && parsedInput.bashCommand) { await handleBashCommand(parsedInput.bashCommand, options); return; } // Handle slash commands (prefixed with /) if (message.startsWith('/')) { await handleSlashCommand(message, options); return; } // Regular chat message - process with AI await options.onHandleChatMessage(message); } export function createClearMessagesHandler(setMessages, client) { return async () => { // Clear message history and client context setMessages([]); if (client) { await client.clearContext(); } }; } //# sourceMappingURL=app-util.js.map