UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

455 lines (397 loc) 15 kB
/** * AIWG Messaging Hub — main entry point. * * Wires together the EventBus, MessageFormatter, AdapterRegistry, * and CommandRouter into a cohesive messaging subsystem that can be * embedded in the daemon or used standalone. * * @implements @.aiwg/architecture/adrs/ADR-messaging-bot-mode.md */ import path from 'path'; import { EventBus } from './event-bus.mjs'; import { formatEvent } from './message-formatter.mjs'; import { initializeRegistry, loadEnabledAdapters, getRegistryStatus, shutdownAll, } from './adapter-registry.mjs'; import { CommandRouter } from './command-router.mjs'; import { ChatHandler } from './chat-handler.mjs'; import { COMMANDS, EventTopic } from './types.mjs'; /** * @typedef {Object} MessagingHub * @property {EventBus} bus - Internal event bus * @property {CommandRouter} router - Command dispatcher * @property {number} adapterCount - Number of loaded adapters * @property {(event: import('./event-bus.mjs').AiwgEvent) => void} publish * @property {() => Promise<void>} shutdown * @property {() => Object} getStatus */ /** * Create and initialize the messaging hub. * * Discovers enabled adapters from environment variables, loads them, * wires event subscriptions, and registers default command handlers. * * @param {Object} [options] * @param {Object} [options.adapterConfigs] - Per-adapter config overrides * @param {string[]} [options.writeUsers] - User IDs with write permission * @param {import('./chat-handler.mjs').ChatHandlerOptions} [options.chatHandler] - Chat handler config (enables 2-way AI chat) * @param {import('./room-manager.mjs').RoomManager} [options.roomManager] - Room manager for multi-room routing * @param {Object} [options.messagingConfig] - Messaging config from daemon.yaml * @returns {Promise<MessagingHub|null>} null if no adapters enabled */ export async function createMessagingHub(options = {}) { const { adapterConfigs = {}, writeUsers = [], chatHandler: chatHandlerConfig, roomManager, messagingConfig } = options; // Discover and load adapters initializeRegistry(); const adapters = await loadEnabledAdapters(adapterConfigs); if (adapters.size === 0) { console.log('[messaging] No adapters enabled — messaging hub not started'); return null; } const projectName = path.basename(process.cwd()); const bus = new EventBus(); const router = new CommandRouter(); // Grant write permissions for (const userId of writeUsers) { router.grantPermission(userId, 'write'); } // Register default command handlers registerDefaultHandlers(router, bus, roomManager); // Wire adapters: EventBus → format → send to all adapters bus.subscribe('*', async (event) => { const message = formatEvent(event); for (const [name, adapter] of adapters) { try { await adapter.send(message); } catch (error) { console.error(`[messaging] Failed to send to ${name}: ${error.message}`); } } }, 'messaging-hub-broadcaster'); // Wire inbound commands from adapters → router → response for (const [name, adapter] of adapters) { adapter.onCommand(async (command, args, context) => { const rawInput = [command, ...args].join(' '); const result = await router.dispatch(rawInput, context); // Send response back to the originating platform const responseMessage = { title: result.success ? 'Command Result' : 'Command Failed', body: result.message || result.error || 'No response', severity: result.success ? 'info' : 'warning', fields: [], project: projectName, timestamp: new Date().toISOString(), }; try { await adapter.send(responseMessage, context.channelId || context.chatId); } catch (error) { console.error(`[messaging] Failed to send command response to ${name}: ${error.message}`); } }); } // Wire 2-way AI chat if chatHandler config is provided let chatHandler = null; if (chatHandlerConfig !== false) { chatHandler = new ChatHandler(chatHandlerConfig || {}); // Register /ask command → forwards to chat handler router.registerHandler('ask', async (args, context) => { const question = args.join(' '); if (!question) { return { success: false, error: 'Usage: /ask <question>' }; } const result = await chatHandler.processMessage(question, { chatId: context.chatId, platform: context.platform, from: context.from, }); return { success: true, message: result.response }; }); // Wire free-text messages from adapters → chat handler → response for (const [name, adapter] of adapters) { adapter.onMessage(async (text, context) => { bus.publish({ topic: EventTopic.CHAT_MESSAGE, source: `${context.platform}-adapter`, severity: 'info', summary: `Chat message from ${context.from?.username || 'unknown'}`, details: { text: text.slice(0, 100), platform: context.platform }, project: projectName, timestamp: new Date().toISOString(), }); const result = await chatHandler.processMessage(text, { chatId: context.chatId, platform: context.platform, from: context.from, }); // Send AI response back to the originating platform const responseMessage = { title: 'AI Response', body: result.response, severity: 'info', fields: [], project: projectName, timestamp: new Date().toISOString(), }; try { await adapter.send(responseMessage, context.chatId); } catch (error) { console.error(`[messaging] Failed to send chat response to ${name}: ${error.message}`); bus.publish({ topic: EventTopic.CHAT_ERROR, source: 'messaging-hub', severity: 'warning', summary: `Failed to send chat response: ${error.message}`, project: projectName, timestamp: new Date().toISOString(), }); } }); } console.log('[messaging] 2-way AI chat enabled'); } console.log(`[messaging] Hub started with ${adapters.size} adapter(s): ${[...adapters.keys()].join(', ')}`); return { bus, router, chatHandler, roomManager, adapterCount: adapters.size, /** * Publish an event to all connected messaging platforms. * * @param {import('./event-bus.mjs').AiwgEvent} event */ publish(event) { if (!event.project) { event.project = projectName; } bus.publish(event); }, /** * Shutdown all adapters and clean up the event bus. * * @returns {Promise<void>} */ async shutdown() { bus.destroy(); await shutdownAll(); console.log('[messaging] Hub shut down'); }, /** * Get combined status of all components. * * @returns {Object} */ getStatus() { return { adapters: getRegistryStatus(), commands: router.handlerCount, subscribers: bus.subscriberCount, deadLetters: bus.getDeadLetters().length, }; }, }; } /** * Register built-in command handlers. * * @param {CommandRouter} router * @param {EventBus} bus * @param {import('./room-manager.mjs').RoomManager} [roomManager] */ function registerDefaultHandlers(router, bus, roomManager) { // /help — list available commands router.registerHandler('help', async (_args, _context) => { return { success: true, message: router.getHelpText(), }; }); // /status — show project status router.registerHandler('status', async (_args, _context) => { const projectName = path.basename(process.cwd()); const statusParts = [`Project: ${projectName}`]; // Check for .aiwg directory const fs = await import('fs'); if (fs.existsSync('.aiwg/planning/current-phase.txt')) { const phase = fs.readFileSync('.aiwg/planning/current-phase.txt', 'utf8').trim(); statusParts.push(`Phase: ${phase}`); } // Check daemon state if (fs.existsSync('.aiwg/daemon/state.json')) { try { const state = JSON.parse(fs.readFileSync('.aiwg/daemon/state.json', 'utf8')); statusParts.push(`Daemon uptime: ${state.uptime_seconds}s`); statusParts.push(`Health: ${state.health?.status || 'unknown'}`); } catch { statusParts.push('Daemon: state unreadable'); } } return { success: true, message: statusParts.join('\n'), }; }); // /ralph-status — show Ralph loop status router.registerHandler('ralph-status', async (_args, _context) => { const fs = await import('fs'); const ralphDir = '.aiwg/ralph-external'; if (!fs.existsSync(ralphDir)) { return { success: true, message: 'No Ralph loop active' }; } try { const files = fs.readdirSync(ralphDir).filter(f => f.endsWith('.json')); if (files.length === 0) { return { success: true, message: 'No Ralph loop state files found' }; } // Read the most recent state file const latest = files.sort().pop(); const state = JSON.parse(fs.readFileSync(path.join(ralphDir, latest), 'utf8')); const parts = [ `Loop: ${state.id || latest}`, `Status: ${state.status || 'unknown'}`, `Iteration: ${state.iteration || 'N/A'}/${state.maxIterations || 'N/A'}`, ]; if (state.objective) { parts.push(`Objective: ${state.objective}`); } return { success: true, message: parts.join('\n') }; } catch (error) { return { success: true, message: `Ralph state error: ${error.message}` }; } }); // /health — run health check router.registerHandler('health', async (_args, _context) => { const checks = []; const fs = await import('fs'); // Check daemon heartbeat if (fs.existsSync('.aiwg/daemon/heartbeat')) { try { const hb = JSON.parse(fs.readFileSync('.aiwg/daemon/heartbeat', 'utf8')); const age = (Date.now() - new Date(hb.timestamp).getTime()) / 1000; checks.push(age < 120 ? 'Daemon: healthy' : `Daemon: stale heartbeat (${Math.round(age)}s ago)`); } catch { checks.push('Daemon: heartbeat unreadable'); } } else { checks.push('Daemon: not running'); } // Check adapter status const adapterStatuses = getRegistryStatus(); for (const adapter of adapterStatuses) { const status = adapter.status === 'ready' ? 'connected' : adapter.status; checks.push(`${adapter.name}: ${status}${adapter.error ? ` (${adapter.error})` : ''}`); } return { success: true, message: `Health Check\n${checks.join('\n')}`, }; }); // /approve — approve a pending HITL gate router.registerHandler('approve', async (args, context) => { const gateId = args[0]; if (!gateId) { return { success: false, error: 'Usage: /approve <gate-id>' }; } bus.publish({ topic: EventTopic.GATE_APPROVED, source: 'messaging-command', gateId, severity: 'info', summary: `Gate ${gateId} approved via ${context.platform}`, details: { approvedBy: context.userId || context.from?.username || 'unknown', platform: context.platform, }, project: path.basename(process.cwd()), timestamp: new Date().toISOString(), }); return { success: true, message: `Gate ${gateId} approved` }; }); // /reject — reject a pending HITL gate router.registerHandler('reject', async (args, context) => { const gateId = args[0]; if (!gateId) { return { success: false, error: 'Usage: /reject <gate-id> [reason]' }; } const reason = args.slice(1).join(' ') || 'Rejected via messaging'; bus.publish({ topic: EventTopic.GATE_REJECTED, source: 'messaging-command', gateId, severity: 'warning', summary: `Gate ${gateId} rejected via ${context.platform}: ${reason}`, details: { rejectedBy: context.userId || context.from?.username || 'unknown', reason, platform: context.platform, }, project: path.basename(process.cwd()), timestamp: new Date().toISOString(), }); return { success: true, message: `Gate ${gateId} rejected: ${reason}` }; }); // Room management commands (require roomManager) if (roomManager) { // /join — dynamically add this chat as a room router.registerHandler('join', async (_args, context) => { const chatId = context.chatId || context.channelId; if (!chatId) { return { success: false, error: 'Could not determine chat ID' }; } const room = roomManager.addRoom(context.platform, chatId, { label: context.chatTitle || chatId, purpose: 'interactive', isDefault: false, source: 'dynamic', }); return { success: true, message: `Room "${room.label}" joined (${room.id})` }; }); // /leave — remove this chat from rooms router.registerHandler('leave', async (_args, context) => { const chatId = context.chatId || context.channelId; if (!chatId) { return { success: false, error: 'Could not determine chat ID' }; } const removed = roomManager.removeRoom(context.platform, chatId); return { success: true, message: removed ? 'Left room' : 'Room not found' }; }); // /rooms — list all active rooms router.registerHandler('rooms', async (_args, _context) => { const allRooms = roomManager.getRooms(); if (allRooms.length === 0) { return { success: true, message: 'No rooms configured' }; } const lines = allRooms.map(r => `${r.platform}:${r.platformRoomId}${r.label} (${r.purpose}${r.isDefault ? ', default' : ''})` ); return { success: true, message: `Rooms (${allRooms.length}):\n${lines.join('\n')}` }; }); // /subscribe — receive task updates in this room router.registerHandler('subscribe', async (args, context) => { const taskId = args[0]; if (!taskId) { return { success: false, error: 'Usage: /subscribe <taskId>' }; } const chatId = context.chatId || context.channelId; if (!chatId) { return { success: false, error: 'Could not determine chat ID' }; } roomManager.bindTask(taskId, context.platform, chatId); return { success: true, message: `Subscribed to task ${taskId}` }; }); // /unsubscribe — stop receiving task updates router.registerHandler('unsubscribe', async (args, _context) => { const taskId = args[0]; if (!taskId) { return { success: false, error: 'Usage: /unsubscribe <taskId>' }; } roomManager.unbindTask(taskId); return { success: true, message: `Unsubscribed from task ${taskId}` }; }); } } export default createMessagingHub;