← 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;
  }
}