UNPKG

agent-world

Version:

World-mediated agent management system with clean API surface

1,090 lines (1,089 loc) 49.2 kB
#!/usr/bin/env node /* * cli/index.ts * Summary: CLI entrypoint and interactive world subscription logic with event-driven prompt display. * Implementation: Uses subscribeWorld to obtain a managed WorldSubscription, then subscribes directly to world.eventEmitter for CLI-specific handling. * Architecture: Event-driven prompt display using world activity events instead of timers. */ // Load environment variables from .env file import dotenv from 'dotenv'; dotenv.config({ quiet: true }); /** * Agent World CLI Entry Point - Dual-Mode Console Interface * * Provides pipeline and interactive modes with unified subscription system, * real-time streaming, and comprehensive world management. * * FEATURES: * - Pipeline Mode: Execute commands and exit with pure event-driven completion tracking * - Uses world activity events (response-start, response-end, idle) for completion detection * - No visible activity progress (clean output for scripting) * - Extended timeout (120s) with quick exit on no activity (2s) * - Silent timeout handling (no error messages for clean pipelines) * - Interactive Mode: Real-time console interface with streaming responses * - Full activity progress display with world events, tool execution, and streaming * - Event-driven prompt display using world idle events * - Unified Subscription: Both modes use subscribeWorld for consistent event handling * - World Management: Auto-discovery and interactive selection * - Real-time Streaming: Live agent responses via stream.ts module (interactive only) * - Color Helpers: Consistent styling with simplified color functions * - Debug Logging: Configurable log levels using core logger module * - Environment Variables: Automatically loads .env file for API keys and configuration * * ARCHITECTURE: * - Uses commander.js for argument parsing and mode detection * - Uses subscribeWorld for all world management in both modes * - Implements ClientConnection interface for console-based event handling * - Uses readline for interactive input with proper cleanup * - Delegates streaming display to stream.ts module for real-time chunk accumulation * - Uses core logger for structured debug logging with configurable levels * - Event-driven prompt display: listens to world 'idle' events instead of using timers * - WorldActivityMonitor tracks agent processing and signals when world becomes idle * * USAGE: * Pipeline: cli --root /data/worlds --world myworld --command "/clear agent1" * Pipeline: cli --root /data/worlds --world myworld "Hello, world!" * Pipeline: echo "Hello, world!" | cli --root /data/worlds --world myworld * Interactive: cli --root /data/worlds --world myworld * Debug Mode: cli --root /data/worlds --world myworld --logLevel debug */ import path from 'path'; import { fileURLToPath } from 'url'; import { program } from 'commander'; import readline from 'readline'; import { listWorlds, subscribeWorld, createCategoryLogger, LLMProvider, enableStreaming, disableStreaming } from '../core/index.js'; import { EventType } from '../core/types.js'; import { getDefaultRootPath } from '../core/storage/storage-factory.js'; import { processCLIInput, displayChatMessages } from './commands.js'; import { createStreamingState, handleWorldEventWithStreaming, handleToolEvents, handleActivityEvents, } from './stream.js'; import { configureLLMProvider } from '../core/llm-config.js'; // Create CLI category logger after logger auto-initialization const logger = createCategoryLogger('cli'); function createGlobalState() { return { awaitingResponse: false }; } // Color helpers - consolidated styling API const red = (text) => `\x1b[31m${text}\x1b[0m`; const green = (text) => `\x1b[32m${text}\x1b[0m`; const yellow = (text) => `\x1b[33m${text}\x1b[0m`; const blue = (text) => `\x1b[34m${text}\x1b[0m`; const magenta = (text) => `\x1b[35m${text}\x1b[0m`; const cyan = (text) => `\x1b[36m${text}\x1b[0m`; const gray = (text) => `\x1b[90m${text}\x1b[0m`; const bold = (text) => `\x1b[1m${text}\x1b[0m`; const boldRed = (text) => `\x1b[1m\x1b[31m${text}\x1b[0m`; const boldGreen = (text) => `\x1b[1m\x1b[32m${text}\x1b[0m`; const boldYellow = (text) => `\x1b[1m\x1b[33m${text}\x1b[0m`; const boldBlue = (text) => `\x1b[1m\x1b[34m${text}\x1b[0m`; const boldMagenta = (text) => `\x1b[1m\x1b[35m${text}\x1b[0m`; const boldCyan = (text) => `\x1b[1m\x1b[36m${text}\x1b[0m`; const success = (text) => `${boldGreen('✓')} ${text}`; const error = (text) => `${boldRed('✗')} ${text}`; const bullet = (text) => `${gray('•')} ${text}`; class WorldActivityMonitor { lastEvent = null; waiters = new Set(); captureSnapshot() { return { activityId: this.lastEvent?.activityId ?? 0, type: this.lastEvent?.type ?? null }; } handle(event) { // Check for valid event types if (!event || (event.type !== 'response-start' && event.type !== 'response-end' && event.type !== 'idle')) { return; } const timestampMsRaw = event.timestamp ? Date.parse(event.timestamp) : Date.now(); const timestampMs = Number.isFinite(timestampMsRaw) ? timestampMsRaw : Date.now(); this.lastEvent = { ...event, timestampMs }; for (const waiter of Array.from(this.waiters)) { // Track when we see response-start after the target activity if (event.type === 'response-start' && event.activityId > waiter.activityId) { waiter.seenProcessing = true; if (waiter.noActivityTimeoutId) { clearTimeout(waiter.noActivityTimeoutId); waiter.noActivityTimeoutId = undefined; } } // Resolve waiter on idle event if conditions are met if (event.type === 'idle') { const shouldResolve = event.activityId > waiter.activityId || (event.activityId === waiter.activityId && waiter.seenProcessing); if (shouldResolve) { this.finishWaiter(waiter, true); } } } } async waitForIdle(options = {}) { const { snapshot = this.captureSnapshot(), timeoutMs = 120_000, // Default 2 minutes for complex operations noActivityTimeoutMs = 2_000 // Default 2 seconds for quick exit } = options; const targetActivityId = snapshot.activityId; // Already idle after target activity if (this.lastEvent && this.lastEvent.type === 'idle' && this.lastEvent.activityId > targetActivityId) { return; } return new Promise((resolve, reject) => { const waiter = { activityId: targetActivityId, resolveCallback: () => { cleanup(); resolve(); }, rejectCallback: (error) => { cleanup(); reject(error); }, seenProcessing: false }; const cleanup = () => { if (waiter.timeoutId) { clearTimeout(waiter.timeoutId); waiter.timeoutId = undefined; } if (waiter.noActivityTimeoutId) { clearTimeout(waiter.noActivityTimeoutId); waiter.noActivityTimeoutId = undefined; } this.waiters.delete(waiter); }; const last = this.lastEvent; if (!last) { waiter.noActivityTimeoutId = setTimeout(() => this.finishWaiter(waiter, true), noActivityTimeoutMs); } else { // Track if we've seen response-start after target activity if (last.type === 'response-start' && last.activityId > targetActivityId) { waiter.seenProcessing = true; } // If already idle at target activity, set short timeout if (last.type === 'idle' && last.activityId === targetActivityId) { waiter.noActivityTimeoutId = setTimeout(() => this.finishWaiter(waiter, true), noActivityTimeoutMs); } // If already idle after target activity, resolve immediately if (last.type === 'idle' && last.activityId > targetActivityId) { resolve(); return; } } waiter.timeoutId = setTimeout(() => { this.finishWaiter(waiter, false, new Error('Timed out waiting for world to become idle')); }, timeoutMs); this.waiters.add(waiter); }); } reset(reason = 'World subscription reset') { const error = new Error(reason); for (const waiter of Array.from(this.waiters)) { this.finishWaiter(waiter, false, error); } this.lastEvent = null; } getActiveSources() { return this.lastEvent?.activeSources ?? []; } finishWaiter(waiter, resolve, error) { if (!this.waiters.has(waiter)) { return; } if (waiter.timeoutId) { clearTimeout(waiter.timeoutId); waiter.timeoutId = undefined; } if (waiter.noActivityTimeoutId) { clearTimeout(waiter.noActivityTimeoutId); waiter.noActivityTimeoutId = undefined; } this.waiters.delete(waiter); if (resolve) { waiter.resolveCallback(); } else { waiter.rejectCallback(error ?? new Error('World activity waiter cancelled')); } } } function parseActivitySource(source) { if (!source) return null; if (source.startsWith('agent:')) { return { type: 'agent', name: source.slice('agent:'.length) }; } if (source.startsWith('message:')) { return { type: 'message', name: source.slice('message:'.length) }; } return null; } class ActivityProgressRenderer { activeAgents = new Set(); handle(event) { if (!event) return; // Reset on idle if (event.type === 'idle') { this.reset(); return; } const details = parseActivitySource(event.source); if (!details || details.type !== 'agent') { return; } // Track agent start on response-start if (event.type === 'response-start' && !this.activeAgents.has(details.name)) { this.activeAgents.add(details.name); } // Track agent end on response-end if (event.type === 'response-end' && this.activeAgents.has(details.name)) { this.activeAgents.delete(details.name); } } reset() { if (this.activeAgents.size > 0) { this.activeAgents.clear(); // console.log(gray('All agents finished.')); } } } // LLM Provider configuration from environment variables function configureLLMProvidersFromEnv() { // OpenAI if (process.env.OPENAI_API_KEY) { configureLLMProvider(LLMProvider.OPENAI, { apiKey: process.env.OPENAI_API_KEY }); logger.debug('Configured OpenAI provider from environment'); } // Anthropic if (process.env.ANTHROPIC_API_KEY) { configureLLMProvider(LLMProvider.ANTHROPIC, { apiKey: process.env.ANTHROPIC_API_KEY }); logger.debug('Configured Anthropic provider from environment'); } // Google if (process.env.GOOGLE_API_KEY) { configureLLMProvider(LLMProvider.GOOGLE, { apiKey: process.env.GOOGLE_API_KEY }); logger.debug('Configured Google provider from environment'); } // Azure if (process.env.AZURE_OPENAI_API_KEY && process.env.AZURE_RESOURCE_NAME && process.env.AZURE_DEPLOYMENT) { configureLLMProvider(LLMProvider.AZURE, { apiKey: process.env.AZURE_OPENAI_API_KEY, resourceName: process.env.AZURE_RESOURCE_NAME, deployment: process.env.AZURE_DEPLOYMENT, apiVersion: process.env.AZURE_API_VERSION || '2024-10-21-preview' }); logger.debug('Configured Azure provider from environment'); } // XAI if (process.env.XAI_API_KEY) { configureLLMProvider(LLMProvider.XAI, { apiKey: process.env.XAI_API_KEY }); logger.debug('Configured XAI provider from environment'); } // OpenAI Compatible if (process.env.OPENAI_COMPATIBLE_API_KEY && process.env.OPENAI_COMPATIBLE_BASE_URL) { configureLLMProvider(LLMProvider.OPENAI_COMPATIBLE, { apiKey: process.env.OPENAI_COMPATIBLE_API_KEY, baseUrl: process.env.OPENAI_COMPATIBLE_BASE_URL }); logger.debug('Configured OpenAI-Compatible provider from environment'); } // Ollama (OpenAI-compatible endpoint) if (process.env.OLLAMA_BASE_URL) { configureLLMProvider(LLMProvider.OLLAMA, { baseUrl: process.env.OLLAMA_BASE_URL }); logger.debug('Configured Ollama provider (OpenAI-compatible) from environment'); } else { // Configure Ollama with default OpenAI-compatible URL if not specified configureLLMProvider(LLMProvider.OLLAMA, { baseUrl: 'http://localhost:11434/v1' }); logger.debug('Configured Ollama provider (OpenAI-compatible) with default URL'); } } // Get default root path from storage-factory (no local defaults) const DEFAULT_ROOT_PATH = getDefaultRootPath(); // Helper to print CLI results in a user-friendly way function printCLIResult(result) { if (result.success) { if (result.message) console.log(success(result.message)); if (result.data && typeof result.data === 'string') console.log(result.data); } else { if (result.message) console.log(error(result.message)); if (result.error && result.error !== result.message) console.log(error(result.error)); } } /** * Attach CLI event listeners to world EventEmitter * * @param world - World instance to attach listeners to * @param streaming - Streaming state for interactive mode (null for pipeline mode) * @param globalState - Global state for interactive mode (null for pipeline mode) * @param activityMonitor - Activity monitor for tracking world events * @param progressRenderer - Progress renderer for displaying activity * @param rl - Readline interface for interactive mode (undefined for pipeline mode) * @returns Map of event types to listener functions for cleanup */ function attachCLIListeners(world, streaming, globalState, activityMonitor, progressRenderer, rl) { const listeners = new Map(); // World activity events const worldListener = (eventData) => { activityMonitor.handle(eventData); // Only render activity progress in interactive mode if (streaming && globalState && rl) { progressRenderer.handle(eventData); handleWorldEvent(EventType.WORLD, eventData, streaming, globalState, activityMonitor, progressRenderer, rl); } // Pipeline mode: silently track events for completion detection }; world.eventEmitter.on(EventType.WORLD, worldListener); listeners.set(EventType.WORLD, worldListener); // Message events const messageListener = (eventData) => { if (eventData.content && eventData.content.includes('Success message sent')) return; if (streaming && globalState && rl) { handleWorldEvent(EventType.MESSAGE, eventData, streaming, globalState, activityMonitor, progressRenderer, rl); } else { // Pipeline mode: simple console output if (eventData.sender === 'system') { console.log(`${boldRed('● system:')} ${eventData.content}`); } if (eventData.content) { console.log(`${boldGreen('● ' + (eventData.sender || 'agent') + ':')} ${eventData.content}`); } } }; world.eventEmitter.on(EventType.MESSAGE, messageListener); listeners.set(EventType.MESSAGE, messageListener); // SSE events (interactive mode only - pipeline mode uses non-streaming LLM calls) if (streaming && globalState && rl) { const sseListener = (eventData) => { handleWorldEvent(EventType.SSE, eventData, streaming, globalState, activityMonitor, progressRenderer, rl); }; world.eventEmitter.on(EventType.SSE, sseListener); listeners.set(EventType.SSE, sseListener); } // System events const systemListener = (eventData) => { if (eventData.content && eventData.content.includes('Success message sent')) return; if (streaming && globalState && rl) { handleWorldEvent(EventType.SYSTEM, eventData, streaming, globalState, activityMonitor, progressRenderer, rl); } else if (eventData.message || eventData.content) { // Pipeline mode: system messages are handled by message listener } }; world.eventEmitter.on(EventType.SYSTEM, systemListener); listeners.set(EventType.SYSTEM, systemListener); return listeners; } /** * Cleanup CLI event listeners from world EventEmitter * * @param world - World instance to remove listeners from * @param listeners - Map of event types to listener functions */ function detachCLIListeners(world, listeners) { for (const [eventType, listener] of listeners.entries()) { world.eventEmitter.removeListener(eventType, listener); } listeners.clear(); } // Pipeline mode execution with event-driven completion tracking async function runPipelineMode(options, messageFromArgs) { disableStreaming(); let world = null; let worldSubscription = null; let cliListeners = null; const activityMonitor = new WorldActivityMonitor(); const progressRenderer = new ActivityProgressRenderer(); try { if (options.world) { // Subscribe to world lifecycle but do not request forwarding callbacks worldSubscription = await subscribeWorld(options.world, { isOpen: true }); if (!worldSubscription) { console.error(boldRed(`Error: World '${options.world}' not found`)); process.exit(1); } world = worldSubscription.world; // Attach direct listeners to the world.eventEmitter for pipeline handling // Note: Pipeline mode uses non-streaming LLM calls, so SSE events are not needed cliListeners = attachCLIListeners(world, null, null, activityMonitor, progressRenderer); } // Execute command from --command option if (options.command) { if (!options.command.startsWith('/') && !world) { console.error(boldRed('Error: World must be specified to send user messages')); process.exit(1); } const snapshot = activityMonitor.captureSnapshot(); const result = await processCLIInput(options.command, world, 'human'); printCLIResult(result); if (!options.command.startsWith('/') && world) { try { // Event-driven completion: wait for world idle state await activityMonitor.waitForIdle({ snapshot, timeoutMs: 120_000, // Extended timeout for complex operations noActivityTimeoutMs: 2_000 // Quick exit if no activity detected }); } catch (error) { // Silent exit on timeout - events may have completed logger.debug('Pipeline mode completion timeout', { error: error.message }); } } else { if (worldSubscription) worldSubscription.unsubscribe(); process.exit(result.success ? 0 : 1); } if (!result.success) { setTimeout(() => process.exit(1), 100); return; } } // Execute message from args if (messageFromArgs) { if (!world) { console.error(boldRed('Error: World must be specified to send user messages')); process.exit(1); } const snapshot = activityMonitor.captureSnapshot(); const result = await processCLIInput(messageFromArgs, world, 'human'); printCLIResult(result); try { // Event-driven completion: wait for world idle state await activityMonitor.waitForIdle({ snapshot, timeoutMs: 120_000, noActivityTimeoutMs: 2_000 }); } catch (error) { // Silent exit on timeout - events may have completed logger.debug('Pipeline mode completion timeout', { error: error.message }); } if (!result.success) { setTimeout(() => process.exit(1), 100); return; } } // Handle stdin input if (!process.stdin.isTTY) { let input = ''; process.stdin.setEncoding('utf8'); for await (const chunk of process.stdin) input += chunk; if (input.trim()) { if (!world) { console.error(boldRed('Error: World must be specified to send user messages')); process.exit(1); } const snapshot = activityMonitor.captureSnapshot(); const result = await processCLIInput(input.trim(), world, 'HUMAN'); printCLIResult(result); try { // Event-driven completion: wait for world idle state await activityMonitor.waitForIdle({ snapshot, timeoutMs: 120_000, noActivityTimeoutMs: 2_000 }); } catch (error) { // Silent exit on timeout - events may have completed logger.debug('Pipeline mode completion timeout', { error: error.message }); } if (!result.success) { setTimeout(() => process.exit(1), 100); return; } return; } } if (!options.command && !messageFromArgs) { program.help(); } if (worldSubscription) { if (cliListeners && world) { detachCLIListeners(world, cliListeners); } await worldSubscription.unsubscribe(); } process.exit(0); } catch (error) { console.error(boldRed('Error:'), error instanceof Error ? error.message : error); if (worldSubscription) { if (cliListeners && world) { detachCLIListeners(world, cliListeners); } await worldSubscription.unsubscribe(); } process.exit(1); } } function cleanupWorldSubscription(worldState) { if (worldState?.subscription) { worldState.subscription.unsubscribe(); } } /** * Subscribe to world and attach CLI event listeners for interactive mode * * @param rootPath - Root path for world storage (unused, kept for compatibility) * @param worldName - Name of the world to subscribe to * @param streaming - Streaming state for real-time response display * @param globalState - Global state for timer management * @param activityMonitor - Activity monitor for tracking world events * @param progressRenderer - Progress renderer for displaying activity * @param rl - Readline interface for interactive input * @returns WorldState with subscription and world instance */ async function handleSubscribe(rootPath, worldName, streaming, globalState, activityMonitor, progressRenderer, rl) { // Subscribe to world lifecycle but do not request forwarding callbacks const subscription = await subscribeWorld(worldName, { isOpen: true }); if (!subscription) throw new Error('Failed to load world'); const world = subscription.world; // Attach direct listeners to the world.eventEmitter for CLI handling // Interactive mode needs all event types including SSE for streaming responses attachCLIListeners(world, streaming, globalState, activityMonitor, progressRenderer, rl); return { subscription, world }; } // Handle world events with streaming support function handleWorldEvent(eventType, eventData, streaming, globalState, activityMonitor, progressRenderer, rl) { if (eventType === 'world') { const payload = eventData; // Handle activity events (new format: type = 'response-start' | 'response-end' | 'idle') if (payload.type === 'response-start' || payload.type === 'response-end' || payload.type === 'idle') { activityMonitor.handle(payload); progressRenderer.handle(payload); // Call handleActivityEvents for verbose activity logging handleActivityEvents(payload); if (payload.type === 'idle' && rl && globalState.awaitingResponse) { globalState.awaitingResponse = false; console.log(); // Empty line before prompt rl.prompt(); } } // Handle tool events (migrated from sse channel) else if (payload.type === 'tool-start' || payload.type === 'tool-result' || payload.type === 'tool-error' || payload.type === 'tool-progress') { handleToolEvents(payload); } return; } if (handleWorldEventWithStreaming(eventType, eventData, streaming)) { return; } if (eventData.content && eventData.content.includes('Success message sent')) return; // Handle regular message events from agents (non-streaming or after streaming ends) if (eventType === 'message' && eventData.sender && eventData.content) { // Skip user messages to prevent echo if (eventData.sender === 'human' || eventData.sender.startsWith('user')) { return; } // Skip if this message was already displayed via streaming if (streaming.messageId === eventData.messageId) { return; } // Display system messages if (eventData.sender === 'system') { console.log(`${boldRed('● system:')} ${eventData.content}`); return; } // Display agent messages (fallback for non-streaming or missed messages) console.log(`\n${boldGreen(`● ${eventData.sender}:`)} ${eventData.content}\n`); return; } if ((eventType === 'system' || eventType === 'world') && (eventData.message || eventData.content)) { // existing logic } } // World discovery and selection async function getAvailableWorldNames(rootPath) { try { const worldInfos = await listWorlds(); return worldInfos.map(info => info.id); } catch (error) { console.error('Error listing worlds:', error); return []; } } async function selectWorld(rootPath, rl) { const worlds = await getAvailableWorldNames(rootPath); if (worlds.length === 0) { console.log(boldRed(`No worlds found in ${rootPath}`)); return null; } if (worlds.length === 1) { console.log(`${boldGreen('Auto-selecting the only available world:')} ${cyan(worlds[0])}`); return worlds[0]; } console.log(`\n${boldMagenta('Available worlds:')}`); console.log(` ${yellow('0.')} ${cyan('Exit')}`); worlds.forEach((world, index) => { console.log(` ${yellow(`${index + 1}.`)} ${cyan(world)}`); }); return new Promise((resolve) => { function askForSelection() { rl.question(`\n${boldMagenta('Select a world (number or name), or 0 to exit:')} `, (answer) => { const trimmed = answer.trim(); const num = parseInt(trimmed); if (num === 0) { resolve(null); return; } if (!isNaN(num) && num >= 1 && num <= worlds.length) { resolve(worlds[num - 1]); return; } const found = worlds.find(world => world.toLowerCase() === trimmed.toLowerCase() || world.toLowerCase().includes(trimmed.toLowerCase())); if (found) { resolve(found); return; } console.log(boldRed('Invalid selection. Please try again.')); askForSelection(); }); } askForSelection(); }); } // Chat discovery and selection async function selectChat(world, chats, currentChatId, rl) { if (chats.length === 0) { console.log(boldRed(`No chats found in world '${world.name}'`)); return null; } if (chats.length === 1) { console.log(`${boldGreen('Auto-selecting the only available chat:')} ${cyan(chats[0].name)}`); return chats[0].id; } console.log(`\n${boldMagenta('Available chats:')}`); console.log(` ${yellow('0.')} ${cyan('Cancel')}`); chats.forEach((chat, index) => { const isCurrent = currentChatId && chat.id === currentChatId; const currentIndicator = isCurrent ? ' (current)' : ''; const msgCount = chat.messageCount || 0; console.log(` ${yellow(`${index + 1}.`)} ${cyan(`${chat.name}${currentIndicator} - (${msgCount}`)}`); }); return new Promise((resolve) => { function askForSelection() { rl.question(`\n${boldMagenta('Select a chat (number or name), or 0 to cancel:')} `, (answer) => { const trimmed = answer.trim(); const num = parseInt(trimmed); if (num === 0) { resolve(null); return; } if (!isNaN(num) && num >= 1 && num <= chats.length) { resolve(chats[num - 1].id); return; } const found = chats.find(chat => chat.name.toLowerCase() === trimmed.toLowerCase() || chat.name.toLowerCase().includes(trimmed.toLowerCase()) || chat.id === trimmed); if (found) { resolve(found.id); return; } console.log(boldRed('Invalid selection. Please try again.')); askForSelection(); }); } askForSelection(); }); } // Interactive mode: console-based interface async function runInteractiveMode(options) { const rootPath = options.root || DEFAULT_ROOT_PATH; enableStreaming(); const globalState = createGlobalState(); const streaming = createStreamingState(); const activityMonitor = new WorldActivityMonitor(); const progressRenderer = new ActivityProgressRenderer(); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' }); console.log(boldCyan('Agent World CLI (Interactive Mode)')); console.log(cyan('====================================')); let worldState = null; let currentWorldName = ''; let isExiting = false; try { // Load initial world or prompt for selection if (options.world) { logger.debug(`Loading world: ${options.world}`); try { activityMonitor.reset(); progressRenderer.reset(); worldState = await handleSubscribe(rootPath, options.world, streaming, globalState, activityMonitor, progressRenderer, rl); currentWorldName = options.world; console.log(success(`Connected to world: ${currentWorldName}`)); if (worldState?.world) { console.log(`${gray('Agents:')} ${yellow(String(worldState.world.agents?.size || 0))} ${gray('| Turn Limit:')} ${yellow(String(worldState.world.turnLimit || 'N/A'))}`); } } catch (err) { console.error(error(`Error loading world: ${err instanceof Error ? err.message : 'Unknown error'}`)); process.exit(1); } } else { console.log(`\n${boldBlue('Discovering available worlds...')}`); const selectedWorld = await selectWorld(rootPath, rl); if (!selectedWorld) { console.log(error('No world selected. Exiting.')); rl.close(); return; } logger.debug(`Loading world: ${selectedWorld}`); try { activityMonitor.reset(); progressRenderer.reset(); worldState = await handleSubscribe(rootPath, selectedWorld, streaming, globalState, activityMonitor, progressRenderer, rl); currentWorldName = selectedWorld; console.log(success(`Connected to world: ${currentWorldName}`)); if (worldState?.world) { console.log(`${gray('Agents:')} ${yellow(String(worldState.world.agents?.size || 0))} ${gray('| Turn Limit:')} ${yellow(String(worldState.world.turnLimit || 'N/A'))}`); } } catch (err) { console.error(error(`Error loading world: ${err instanceof Error ? err.message : 'Unknown error'}`)); rl.close(); return; } } // Show usage tips console.log(`\n${gray('Quick Start:')}`); console.log(` ${bullet(gray('World commands:'))} ${cyan('/world list')}, ${cyan('/world create')}, ${cyan('/world select')}`); console.log(` ${bullet(gray('Agent commands:'))} ${cyan('/agent list')}, ${cyan('/agent create Ava')}, ${cyan('/agent update Ava')}`); console.log(` ${bullet(gray('Chat commands:'))} ${cyan('/chat list')}, ${cyan('/chat select')}, ${cyan('/chat create')}, ${cyan('/chat export')}`); console.log(` ${bullet(gray('Need help?'))} ${cyan('/help world')}, ${cyan('/help agent')}, ${cyan('/help chat')}`); console.log(` ${bullet(gray('Type messages to talk with the world'))}`); console.log(` ${bullet(gray('Exit with'))} ${cyan('/quit')} ${gray('or')} ${cyan('/exit')} ${gray('or press')} ${boldYellow('Ctrl+C')}`); console.log(` ${bullet(gray('Enable debug logs via'))} ${cyan('--logLevel debug')}`); console.log(''); // Display current chat messages after Quick Start tips if (worldState?.world) { await displayChatMessages(worldState.world.id, worldState.world.currentChatId); } console.log(); // Empty line before prompt rl.prompt(); rl.on('line', async (input) => { const trimmedInput = input.trim(); if (!trimmedInput) { console.log(); // Empty line before prompt rl.prompt(); return; } // Check for exit commands before anything else const isExitCommand = trimmedInput.toLowerCase() === '/exit' || trimmedInput.toLowerCase() === '/quit'; if (isExitCommand) { if (isExiting) return; isExiting = true; console.log(`\n${boldCyan('Goodbye!')}`); if (worldState) cleanupWorldSubscription(worldState); rl.close(); process.exit(0); } console.log(`\n${boldYellow('● you:')} ${trimmedInput}`); const isCommand = trimmedInput.startsWith('/'); let snapshot = null; if (!isCommand) { globalState.awaitingResponse = true; snapshot = activityMonitor.captureSnapshot(); } try { const result = await processCLIInput(trimmedInput, worldState?.world || null, 'HUMAN'); // Handle exit commands from result (redundant, but keep for safety) if (result.data?.exit) { if (isExiting) return; // Prevent duplicate exit handling isExiting = true; console.log(`\n${boldCyan('Goodbye!')}`); if (worldState) cleanupWorldSubscription(worldState); rl.close(); process.exit(0); } // Handle world selection command if (result.data?.selectWorld) { console.log(`\n${boldBlue('Discovering available worlds...')}`); const selectedWorld = await selectWorld(rootPath, rl); if (!selectedWorld) { console.log(error('No world selected.')); console.log(); // Empty line before prompt rl.prompt(); return; } logger.debug(`Loading world: ${selectedWorld}`); try { // Clean up existing world subscription first if (worldState) { logger.debug('Cleaning up previous world subscription...'); cleanupWorldSubscription(worldState); worldState = null; // Small delay to ensure cleanup is complete await new Promise(resolve => setTimeout(resolve, 100)); } // Subscribe to the new world logger.debug(`Subscribing to world: ${selectedWorld}...`); activityMonitor.reset(); progressRenderer.reset(); worldState = await handleSubscribe(rootPath, selectedWorld, streaming, globalState, activityMonitor, progressRenderer, rl); currentWorldName = selectedWorld; console.log(success(`Connected to world: ${currentWorldName}`)); if (worldState?.world) { console.log(`${gray('Agents:')} ${yellow(String(worldState.world.agents?.size || 0))} ${gray('| Turn Limit:')} ${yellow(String(worldState.world.turnLimit || 'N/A'))}`); // Display current chat messages await displayChatMessages(worldState.world.id, worldState.world.currentChatId); } } catch (err) { console.error(error(`Error loading world: ${err instanceof Error ? err.message : 'Unknown error'}`)); } // Show prompt immediately after world selection console.log(); // Empty line before prompt rl.prompt(); return; } // Handle chat selection command if (result.data?.selectChat && worldState?.world) { const { chats, currentChatId } = result.data; const selectedChatId = await selectChat(worldState.world, chats, currentChatId, rl); if (!selectedChatId) { console.log(error('No chat selected.')); console.log(); // Empty line before prompt rl.prompt(); return; } try { // Restore the selected chat const { restoreChat } = await import('../core/index.js'); const restored = await restoreChat(worldState.world.id, selectedChatId); if (!restored) { console.log(error(`Failed to restore chat '${selectedChatId}'`)); } else { const selectedChat = chats.find((c) => c.id === selectedChatId); const chatName = selectedChat?.name || selectedChatId; console.log(success(`Chat '${chatName}' selected and loaded`)); // Display chat messages await displayChatMessages(worldState.world.id, selectedChatId); // Refresh world state // console.log(boldBlue('Refreshing world state...')); const refreshedWorld = await worldState.subscription.refresh(rootPath); worldState.world = refreshedWorld; console.log(success('World state refreshed')); } } catch (err) { console.error(error(`Error loading chat: ${err instanceof Error ? err.message : 'Unknown error'}`)); } // Show prompt immediately after chat selection console.log(); // Empty line before prompt rl.prompt(); return; } if (result.success === false) { console.log(error(`Error: ${result.error || result.message || 'Command failed'}`)); } else if (result.message && !result.message.includes('Success message sent') && !result.message.includes('Message sent to world')) { console.log(success(result.message)); } // if (result.data && !(result.data.sender === 'human')) { // // Print a concise summary of result.data if present and not already in message // if (result.data) { // if (typeof result.data === 'string') { // console.log(`${boldMagenta('Data:')} ${result.data}`); // } else if (result.data.name) { // // If it's an agent or world object // console.log(`${boldMagenta('Data:')} ${result.data.name}`); // } else if (Array.isArray(result.data)) { // console.log(`${boldMagenta('Data:')} ${result.data.length} items`); // } else { // // Fallback: print keys // console.log(`${boldMagenta('Data:')} ${Object.keys(result.data).join(', ')}`); // } // } // } // Refresh world if needed if (result.refreshWorld && currentWorldName && worldState) { try { // console.log(boldBlue('Refreshing world state...')); // Use the subscription's refresh method to properly destroy old world and create new const refreshedWorld = await worldState.subscription.refresh(rootPath); worldState.world = refreshedWorld; console.log(success('World state refreshed')); } catch (err) { console.error(error(`Error refreshing world: ${err instanceof Error ? err.message : 'Unknown error'}`)); } } } catch (err) { console.error(error(`Command error: ${err instanceof Error ? err.message : 'Unknown error'}`)); if (!isCommand && globalState.awaitingResponse) { globalState.awaitingResponse = false; rl.prompt(); } snapshot = null; } if (!isCommand && snapshot) { try { await activityMonitor.waitForIdle({ snapshot }); } catch (error) { console.error(red(`Timed out waiting for responses: ${error.message}`)); } finally { if (globalState.awaitingResponse) { globalState.awaitingResponse = false; console.log(); // Empty line before prompt rl.prompt(); } } return; } // For commands, show prompt immediately. For messages, world events will trigger the prompt const isSelectCommand = trimmedInput.toLowerCase() === '/select' || trimmedInput.toLowerCase() === '/world select'; if (isSelectCommand) { // For world select command, prompt is already shown in the handler return; } else if (isCommand) { // For other commands, show prompt immediately console.log(); // Empty line before prompt rl.prompt(); } // For messages, waitForIdle() above will handle prompt display via world 'idle' event }); rl.on('close', () => { if (isExiting) return; // Prevent duplicate cleanup isExiting = true; console.log(`\n${boldCyan('Goodbye!')}`); if (worldState) cleanupWorldSubscription(worldState); process.exit(0); }); rl.on('SIGINT', () => { if (isExiting) return; // Prevent duplicate cleanup isExiting = true; console.log(`\n${boldCyan('Shutting down...')}`); console.log(`\n${boldCyan('Goodbye!')}`); if (worldState) cleanupWorldSubscription(worldState); rl.close(); process.exit(0); }); } catch (err) { console.error(boldRed('Error starting interactive mode:'), err instanceof Error ? err.message : err); rl.close(); process.exit(1); } } // Main CLI entry point async function main() { // Configure LLM providers from environment variables at startup configureLLMProvidersFromEnv(); // Import help generator from commands.ts // (import at top: import { generateHelpMessage } from './commands.js';) const { generateHelpMessage } = await import('./commands.js'); program .name('cli') .description('Agent World CLI') .version('1.0.0') .option('-r, --root <path>', 'Root path for worlds data', DEFAULT_ROOT_PATH) .option('-w, --world <name>', 'World name to connect to') .option('-c, --command <cmd>', 'Command to execute in pipeline mode') .option('-l, --logLevel <level>', 'Set log level (trace, debug, info, warn, error)', 'error') .option('-s, --server', 'Launch the server before running CLI') .allowUnknownOption() .allowExcessArguments() .helpOption('-h, --help', 'Display help for command') .addHelpText('beforeAll', () => generateHelpMessage()) .parse(); const options = program.opts(); // If --server is specified, launch the server first if (options.server) { const { spawnSync } = await import('child_process'); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const serverPath = path.resolve(__dirname, '../server/index.js'); const serverProcess = spawnSync('node', [serverPath], { stdio: 'inherit', cwd: path.dirname(serverPath), env: process.env }); if (serverProcess.error) { console.error(boldRed('Failed to launch server:'), serverProcess.error); process.exit(1); } // If server exits, exit CLI as well process.exit(serverProcess.status || 0); } const args = program.args; const messageFromArgs = args.length > 0 ? args.join(' ') : null; const isPipelineMode = !!(options.command || messageFromArgs || !process.stdin.isTTY); if (isPipelineMode) { await runPipelineMode(options, messageFromArgs); } else { await runInteractiveMode(options); } } // Global error handling function setupErrorHandlers() { process.on('unhandledRejection', (error) => { console.error(boldRed('Unhandled rejection:'), error); process.exit(1); }); process.on('uncaughtException', (error) => { console.error(boldRed('Uncaught exception:'), error); process.exit(1); }); } setupErrorHandlers(); main().catch((error) => { console.error(boldRed('CLI error:'), error); process.exit(1); });