aiwg
Version:
Cognitive architecture for AI-augmented software development with structured memory, ensemble validation, and closed-loop correction. FAIR-aligned artifacts, 84% cost reduction via human-in-the-loop, standards adopted by 100+ organizations.
387 lines (334 loc) • 12.2 kB
JavaScript
/**
* 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)
* @returns {Promise<MessagingHub|null>} null if no adapters enabled
*/
export async function createMessagingHub(options = {}) {
const { adapterConfigs = {}, writeUsers = [], chatHandler: chatHandlerConfig } = 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);
// 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,
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
*/
function registerDefaultHandlers(router, bus) {
// /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}` };
});
}
export default createMessagingHub;