/ src / components / chatrooms / breakout-command.ts
breakout-command.ts
  1  import type { Chatroom, ChatroomMessage } from '@/types'
  2  import type { StructuredSessionLaunchContext } from '@/components/protocols/structured-session-launcher'
  3  
  4  export const BREAKOUT_COMMAND = '/breakout'
  5  
  6  export type BreakoutCommandParseResult =
  7    | { kind: 'none'; query: ''; topic: '' }
  8    | { kind: 'candidate'; query: string; topic: '' }
  9    | { kind: 'command'; query: 'breakout'; topic: string }
 10  
 11  const MAX_TITLE_LENGTH = 72
 12  const MAX_MESSAGE_SNIPPET_LENGTH = 220
 13  const MAX_KICKOFF_LENGTH = 1100
 14  const MAX_KICKOFF_MESSAGES = 6
 15  
 16  function compactWhitespace(value: string): string {
 17    return value.replace(/\s+/g, ' ').trim()
 18  }
 19  
 20  function truncate(value: string, maxLength: number): string {
 21    if (value.length <= maxLength) return value
 22    return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`
 23  }
 24  
 25  function summarizeMessage(message: ChatroomMessage): string {
 26    const text = compactWhitespace(message.text || '')
 27    if (text) return truncate(text, MAX_MESSAGE_SNIPPET_LENGTH)
 28  
 29    const attachmentCount = (message.attachedFiles?.length || 0) + (message.imagePath ? 1 : 0)
 30    if (attachmentCount > 0) {
 31      return attachmentCount === 1 ? '[shared attachment]' : `[shared ${attachmentCount} attachments]`
 32    }
 33  
 34    return ''
 35  }
 36  
 37  export function parseBreakoutCommand(value: string): BreakoutCommandParseResult {
 38    const normalized = value.replace(/\r/g, '')
 39    if (normalized.includes('\n')) return { kind: 'none', query: '', topic: '' }
 40  
 41    const trimmed = normalized.trim()
 42    if (!trimmed.startsWith('/')) return { kind: 'none', query: '', topic: '' }
 43  
 44    const withoutSlash = trimmed.slice(1)
 45    const firstSpace = withoutSlash.indexOf(' ')
 46    const commandToken = (firstSpace === -1 ? withoutSlash : withoutSlash.slice(0, firstSpace)).toLowerCase()
 47    const topic = firstSpace === -1 ? '' : compactWhitespace(withoutSlash.slice(firstSpace + 1))
 48  
 49    if (!commandToken) return { kind: 'candidate', query: '', topic: '' }
 50    if (BREAKOUT_COMMAND.slice(1).startsWith(commandToken)) {
 51      if (commandToken === BREAKOUT_COMMAND.slice(1)) {
 52        return { kind: 'command', query: 'breakout', topic }
 53      }
 54      return { kind: 'candidate', query: commandToken, topic: '' }
 55    }
 56  
 57    return { kind: 'none', query: '', topic: '' }
 58  }
 59  
 60  export function completeBreakoutCommand(value: string): string {
 61    const parsed = parseBreakoutCommand(value)
 62    if (parsed.kind === 'command') {
 63      return parsed.topic ? `${BREAKOUT_COMMAND} ${parsed.topic}` : `${BREAKOUT_COMMAND} `
 64    }
 65    return `${BREAKOUT_COMMAND} `
 66  }
 67  
 68  export function buildBreakoutTitle(chatroomName: string | null | undefined, topic: string): string {
 69    const compactTopic = compactWhitespace(topic)
 70    if (compactTopic) return truncate(`Breakout: ${compactTopic}`, MAX_TITLE_LENGTH)
 71  
 72    const fallback = compactWhitespace(chatroomName || '') || 'Current chatroom'
 73    return truncate(`Breakout: ${fallback}`, MAX_TITLE_LENGTH)
 74  }
 75  
 76  export function buildBreakoutKickoffContext(messages: ChatroomMessage[]): string {
 77    const candidates = messages
 78      .filter((message) => message.senderId !== 'system' && message.historyExcluded !== true)
 79      .map((message) => {
 80        const summary = summarizeMessage(message)
 81        if (!summary) return null
 82        return `${message.senderName}: ${summary}`
 83      })
 84      .filter(Boolean) as string[]
 85  
 86    if (candidates.length === 0) return ''
 87  
 88    const chosen: string[] = []
 89    let totalLength = 0
 90  
 91    for (let index = candidates.length - 1; index >= 0; index -= 1) {
 92      const entry = candidates[index]
 93      const nextLength = totalLength + entry.length + (chosen.length > 0 ? 2 : 0)
 94      if (chosen.length >= MAX_KICKOFF_MESSAGES || (nextLength > MAX_KICKOFF_LENGTH && chosen.length > 0)) {
 95        break
 96      }
 97      chosen.unshift(entry)
 98      totalLength = nextLength
 99    }
100  
101    return `Recent room context:\n${chosen.join('\n\n')}`
102  }
103  
104  export function buildBreakoutLaunchContext(
105    chatroom: Pick<Chatroom, 'id' | 'name' | 'agentIds' | 'messages'>,
106    topic: string,
107  ): StructuredSessionLaunchContext {
108    return {
109      parentChatroomId: chatroom.id,
110      parentChatroomLabel: chatroom.name,
111      participantAgentIds: [...chatroom.agentIds],
112      facilitatorAgentId: chatroom.agentIds[0] || null,
113      title: buildBreakoutTitle(chatroom.name, topic),
114      goal: compactWhitespace(topic),
115      kickoffMessage: buildBreakoutKickoffContext(chatroom.messages),
116      autoStart: true,
117      createTranscript: true,
118    }
119  }