← back to Dw Boardroom V2
src/engine/meetingEngine.ts
377 lines
// ═══════════════════════════════════════════════
// DW Boardroom V2 — Meeting Engine
// 8-phase orchestrator with mutex and auto-advance
// Ported from boardroom meeting-engine.js
// ═══════════════════════════════════════════════
import { v4 as uuid } from 'uuid';
import * as db from '../db/sqlite';
import { broadcast } from '../ws/broadcast';
import { generateDialogue } from './agentDialogue';
import { TEAM_AGENTS, EXECUTIVES, BOSS_PERSONA, getAgentsByDept, DEPARTMENT_GROUPS } from '../agents/registry';
import {
MEETING_PHASES, PHASE_DURATIONS, PHASE_LABELS,
type MeetingPhase, type MeetingType, type Meeting, type MeetingMessage, type AgendaItem
} from '../types';
let activeMeeting: string | null = null;
let phaseTimer: NodeJS.Timeout | null = null;
let meetingMutex = false;
// ─── Public API ────────────────────────────────
export function getActiveMeeting(): Meeting | undefined {
if (!activeMeeting) return undefined;
return db.queryOne<Meeting>('SELECT * FROM br2_meetings WHERE id = ?', [activeMeeting]);
}
export function getMeeting(id: string): Meeting | undefined {
return db.queryOne<Meeting>('SELECT * FROM br2_meetings WHERE id = ?', [id]);
}
export function listMeetings(limit = 50): Meeting[] {
return db.query<Meeting>('SELECT * FROM br2_meetings ORDER BY created_at DESC LIMIT ?', [limit]);
}
export function getMeetingMessages(meetingId: string, limit = 500): MeetingMessage[] {
return db.query<MeetingMessage>(
'SELECT * FROM br2_messages WHERE meeting_id = ? ORDER BY created_at ASC LIMIT ?',
[meetingId, limit]
);
}
export function getAgendaItems(meetingId: string): AgendaItem[] {
return db.query<AgendaItem>(
'SELECT * FROM br2_agenda WHERE meeting_id = ? ORDER BY position ASC',
[meetingId]
);
}
export async function startMeeting(type: MeetingType): Promise<Meeting> {
if (meetingMutex) {
throw new Error('Meeting engine is busy');
}
if (activeMeeting) {
throw new Error(`Meeting ${activeMeeting} is already in progress`);
}
meetingMutex = true;
try {
const id = uuid();
const attendees = JSON.stringify([
...EXECUTIVES.map(e => e.id),
...TEAM_AGENTS.map(a => a.id),
]);
db.run(
`INSERT INTO br2_meetings (id, meeting_type, phase, status, started_at, attendees)
VALUES (?, ?, 'caucus', 'in_progress', datetime('now'), ?)`,
[id, type, attendees]
);
activeMeeting = id;
const meeting = getMeeting(id)!;
// President Lincoln takes the podium
addMessage(id, 'lincoln', BOSS_PERSONA.name, `${type.charAt(0).toUpperCase() + type.slice(1)} meeting initiated. ${BOSS_PERSONA.name} takes the podium.`, 'system');
broadcast({ type: 'meeting_started', payload: meeting, timestamp: new Date().toISOString() });
// Start phase orchestration
await runPhase(id, 'caucus');
return meeting;
} finally {
meetingMutex = false;
}
}
export function haltMeeting(meetingId: string): Meeting {
const meeting = getMeeting(meetingId);
if (!meeting || meeting.status !== 'in_progress') {
throw new Error('No active meeting to halt');
}
clearPhaseTimer();
db.run(
`UPDATE br2_meetings SET status = 'halted', ended_at = datetime('now') WHERE id = ?`,
[meetingId]
);
activeMeeting = null;
addMessage(meetingId, 'system', 'Boardroom', 'Meeting halted by directive.', 'system');
const updated = getMeeting(meetingId)!;
broadcast({ type: 'meeting_halted', payload: updated, timestamp: new Date().toISOString() });
return updated;
}
export async function advancePhase(meetingId: string): Promise<Meeting> {
const meeting = getMeeting(meetingId);
if (!meeting || meeting.status !== 'in_progress') {
throw new Error('No active meeting to advance');
}
const currentIdx = MEETING_PHASES.indexOf(meeting.phase as MeetingPhase);
if (currentIdx >= MEETING_PHASES.length - 1) {
return completeMeeting(meetingId);
}
clearPhaseTimer();
const nextPhase = MEETING_PHASES[currentIdx + 1];
await runPhase(meetingId, nextPhase);
return getMeeting(meetingId)!;
}
export function addMessage(
meetingId: string,
agentId: string,
agentName: string,
message: string,
messageType: string = 'dialogue'
): MeetingMessage {
const meeting = getMeeting(meetingId);
db.run(
`INSERT INTO br2_messages (meeting_id, agent_id, agent_name, message, message_type, phase)
VALUES (?, ?, ?, ?, ?, ?)`,
[meetingId, agentId, agentName, message, messageType, meeting?.phase || 'unknown']
);
const msg = db.queryOne<MeetingMessage>(
'SELECT * FROM br2_messages WHERE meeting_id = ? ORDER BY id DESC LIMIT 1',
[meetingId]
)!;
broadcast({ type: 'meeting_message', payload: msg, timestamp: new Date().toISOString() });
return msg;
}
// ─── Agenda CRUD ───────────────────────────────
export function addAgendaItem(
meetingId: string,
topic: string,
recommendation?: string,
decisionType?: string,
owner?: string
): AgendaItem {
const items = getAgendaItems(meetingId);
if (items.length >= 5) {
throw new Error('Maximum 5 agenda items per meeting');
}
db.run(
`INSERT INTO br2_agenda (meeting_id, topic, recommendation, decision_type, owner, position)
VALUES (?, ?, ?, ?, ?, ?)`,
[meetingId, topic, recommendation || null, decisionType || 'B', owner || null, items.length]
);
const item = db.queryOne<AgendaItem>(
'SELECT * FROM br2_agenda WHERE meeting_id = ? ORDER BY id DESC LIMIT 1',
[meetingId]
)!;
broadcast({ type: 'agenda_updated', payload: { meetingId, items: getAgendaItems(meetingId) }, timestamp: new Date().toISOString() });
return item;
}
export function updateAgendaItem(meetingId: string, itemId: number, updates: Partial<AgendaItem>): AgendaItem {
const sets: string[] = [];
const params: any[] = [];
if (updates.topic !== undefined) { sets.push('topic = ?'); params.push(updates.topic); }
if (updates.recommendation !== undefined) { sets.push('recommendation = ?'); params.push(updates.recommendation); }
if (updates.decision_type !== undefined) { sets.push('decision_type = ?'); params.push(updates.decision_type); }
if (updates.owner !== undefined) { sets.push('owner = ?'); params.push(updates.owner); }
if (updates.position !== undefined) { sets.push('position = ?'); params.push(updates.position); }
if (updates.status !== undefined) { sets.push('status = ?'); params.push(updates.status); }
if (sets.length > 0) {
params.push(itemId, meetingId);
db.run(`UPDATE br2_agenda SET ${sets.join(', ')} WHERE id = ? AND meeting_id = ?`, params);
}
return db.queryOne<AgendaItem>('SELECT * FROM br2_agenda WHERE id = ?', [itemId])!;
}
export function deleteAgendaItem(meetingId: string, itemId: number): void {
db.run('DELETE FROM br2_agenda WHERE id = ? AND meeting_id = ?', [itemId, meetingId]);
}
// ─── Phase Orchestration ───────────────────────
async function runPhase(meetingId: string, phase: MeetingPhase): Promise<void> {
db.run('UPDATE br2_meetings SET phase = ? WHERE id = ?', [phase, meetingId]);
const label = PHASE_LABELS[phase];
addMessage(meetingId, 'system', 'Boardroom', `--- Phase: ${label} ---`, 'phase');
broadcast({
type: 'meeting_phase',
payload: { meetingId, phase, label },
timestamp: new Date().toISOString(),
});
// Run phase-specific logic
try {
await executePhaseLogic(meetingId, phase);
} catch (err: any) {
console.error(`[meetingEngine] Phase ${phase} error:`, err.message);
addMessage(meetingId, 'system', 'Boardroom', `Phase error: ${err.message}`, 'system');
}
// Schedule auto-advance
const duration = PHASE_DURATIONS[phase] * 1000;
phaseTimer = setTimeout(async () => {
try {
const meeting = getMeeting(meetingId);
if (meeting && meeting.status === 'in_progress') {
const idx = MEETING_PHASES.indexOf(phase);
if (idx < MEETING_PHASES.length - 1) {
await runPhase(meetingId, MEETING_PHASES[idx + 1]);
} else {
completeMeeting(meetingId);
}
}
} catch (err: any) {
console.error(`[meetingEngine] Auto-advance error:`, err.message);
}
}, duration);
}
async function executePhaseLogic(meetingId: string, phase: MeetingPhase): Promise<void> {
const meeting = getMeeting(meetingId)!;
const type = meeting.meeting_type;
const agenda = getAgendaItems(meetingId);
switch (phase) {
case 'caucus': {
// President Lincoln opens from the podium
const agentCount = TEAM_AGENTS.length;
const execCount = EXECUTIVES.length;
addMessage(meetingId, 'lincoln', BOSS_PERSONA.name, `Good day. This ${type} session is now convened with ${agentCount} agents and ${execCount} executives present. Let us proceed with focus and purpose.`, 'dialogue');
// CEO acknowledges
addMessage(meetingId, 'ceo', 'Curtis', `Thank you, ${BOSS_PERSONA.title}. All departments are standing by.`, 'dialogue');
break;
}
case 'call_to_order': {
// Roll call — pick 3-5 key agents based on meeting type
const keyAgents = getKeyAgentsForType(type);
for (const agent of keyAgents.slice(0, 3)) {
const dialogue = await generateDialogue(agent.id, agent.name, agent.role, phase, `Acknowledge presence at ${type} meeting`, type);
addMessage(meetingId, agent.id, agent.name, dialogue, 'dialogue');
}
break;
}
case 'old_business': {
// Review previous action items — COO reports
const dialogue = await generateDialogue('coo', 'Conrad', 'Chief Operations Officer', phase, 'Report on outstanding action items from previous meeting', type);
addMessage(meetingId, 'coo', 'Conrad', dialogue, 'dialogue');
break;
}
case 'current_business': {
// Discuss agenda items
if (agenda.length === 0) {
addMessage(meetingId, 'system', 'Boardroom', 'No agenda items to discuss.', 'system');
} else {
for (const item of agenda.slice(0, 3)) {
const owner = item.owner || 'ceo';
const ownerAgent = TEAM_AGENTS.find(a => a.id === owner) || EXECUTIVES.find(e => e.id === owner);
const name = ownerAgent ? ('name' in ownerAgent ? ownerAgent.name : owner) : owner;
const role = ownerAgent ? ('role' in ownerAgent ? ownerAgent.role : '') : '';
const dialogue = await generateDialogue(owner, name, role, phase, `Present agenda item: ${item.topic}. Recommendation: ${item.recommendation || 'None'}`, type);
addMessage(meetingId, owner, name, dialogue, 'dialogue');
updateAgendaItem(meetingId, item.id, { status: 'discussed' });
}
}
break;
}
case 'new_business': {
// Remaining agenda items + department heads report
const remaining = agenda.filter(a => a.status === 'pending');
for (const item of remaining.slice(0, 2)) {
const owner = item.owner || 'vp-ops';
const ownerAgent = TEAM_AGENTS.find(a => a.id === owner) || EXECUTIVES.find(e => e.id === owner);
const name = ownerAgent ? ('name' in ownerAgent ? ownerAgent.name : owner) : owner;
const role = ownerAgent ? ('role' in ownerAgent ? ownerAgent.role : '') : '';
const dialogue = await generateDialogue(owner, name, role, phase, `Raise new business: ${item.topic}`, type);
addMessage(meetingId, owner, name, dialogue, 'dialogue');
updateAgendaItem(meetingId, item.id, { status: 'discussed' });
}
break;
}
case 'breakout': {
// Brief departmental breakout summaries
const deptLeaders = ['max', 'grant', 'shane', 'atlas'];
for (const leaderId of deptLeaders.slice(0, 2)) {
const agent = TEAM_AGENTS.find(a => a.id === leaderId);
if (agent) {
const dialogue = await generateDialogue(agent.id, agent.name, agent.role, phase, `Brief departmental summary for ${agent.dept}`, type);
addMessage(meetingId, agent.id, agent.name, dialogue, 'dialogue');
}
}
break;
}
case 'minutes': {
// Note taker summarizes
const dialogue = await generateDialogue('nate', 'Nate', 'Note Taker & Records', phase, `Summarize key points and action items from this ${type} meeting`, type);
addMessage(meetingId, 'nate', 'Nate', dialogue, 'dialogue');
break;
}
case 'adjournment': {
// CEO delivers closing remarks to President Lincoln
const dialogue = await generateDialogue('ceo', 'Curtis', 'Chief Executive', phase, `Deliver closing remarks to President Lincoln for this ${type} meeting. Address him as Mr. President.`, type);
addMessage(meetingId, 'ceo', 'Curtis', dialogue, 'dialogue');
// President Lincoln adjourns
addMessage(meetingId, 'lincoln', BOSS_PERSONA.name, `This ${type} session stands adjourned. Thank you all for your contributions. Dismissed.`, 'dialogue');
break;
}
}
}
function completeMeeting(meetingId: string): Meeting {
clearPhaseTimer();
db.run(
`UPDATE br2_meetings SET status = 'completed', ended_at = datetime('now'), phase = 'adjournment' WHERE id = ?`,
[meetingId]
);
activeMeeting = null;
addMessage(meetingId, 'system', 'Boardroom', 'Meeting adjourned.', 'system');
const meeting = getMeeting(meetingId)!;
broadcast({ type: 'meeting_completed', payload: meeting, timestamp: new Date().toISOString() });
return meeting;
}
function clearPhaseTimer(): void {
if (phaseTimer) {
clearTimeout(phaseTimer);
phaseTimer = null;
}
}
function getKeyAgentsForType(type: string): { id: string; name: string; role: string }[] {
const all = [...EXECUTIVES.map(e => ({ id: e.id, name: e.name, role: e.role }))];
switch (type) {
case 'strategic':
return [...all, { id: 'frank', name: 'Lance', role: 'Legal Compliance' }, { id: 'grant', name: 'Finn', role: 'Finance' }];
case 'initiative':
return [...all, { id: 'atlas', name: 'Porter', role: 'Product Master' }, { id: 'bob', name: 'Craig', role: 'Content Creator' }];
case 'ops':
return [...all, { id: 'max', name: 'Ford', role: 'Fleet Commander' }, { id: 'hawk', name: 'Sven', role: 'System Monitor' }];
case 'emergency':
return [...all, { id: 'max', name: 'Ford', role: 'Fleet Commander' }, { id: 'vince', name: 'Vince', role: 'Site Monitor' }];
default:
return all;
}
}