/** * Turn-based speaking manager for group channels. * * Rules: * - Humans (humanList) and the moderator bot are never in the turn order * - Turn order is auto-populated from channel/server members minus humans/moderator * - currentSpeaker can be null (dormant state) * - When ALL agents in a cycle have NO_REPLY'd, state goes dormant (null) * - Dormant → any new message reactivates: * - If sender is NOT in turn order → current = first in list * - If sender IS in turn order → current = next after sender * - When turn order is first created via discovery, all agents are forced no-reply * until moderator activates the first speaker * - When turn order is pre-populated (e.g. channel-private-create), the first * speaker is immediately activated via moderator handoff */ export type ChannelTurnState = { /** Ordered accountIds for this channel (auto-populated, shuffled) */ turnOrder: string[]; /** Current speaker accountId, or null if dormant */ currentSpeaker: string | null; /** Set of accountIds that have NO_REPLY'd this cycle */ noRepliedThisCycle: Set; /** Timestamp of last state change */ lastChangedAt: number; /** * Whether this turn state was pre-populated (e.g. from channel-private-create params). * Pre-populated states have a complete member list and can be activated immediately. * Discovery-based states may be incomplete and need stabilization. */ prePopulated: boolean; }; const channelTurns = new Map(); /** Turn timeout: if the current speaker hasn't responded, auto-advance */ const TURN_TIMEOUT_MS = 60_000; // --- helpers --- function shuffleArray(arr: T[]): T[] { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } // --- public API --- /** * Check if a channel already has turn state initialized. */ export function hasTurnState(channelId: string): boolean { return channelTurns.has(channelId); } /** * Initialize or update the turn order for a channel. * Called with the list of bot accountIds (already filtered, humans excluded). * Starts in dormant state — activation happens via onNewMessage or activateFirstSpeaker. * * @returns true if a NEW turn state was created (callers should trigger moderator activation) */ export function initTurnOrder(channelId: string, botAccountIds: string[]): boolean { const existing = channelTurns.get(channelId); if (existing) { // Check if membership changed const oldSet = new Set(existing.turnOrder); const newSet = new Set(botAccountIds); const same = oldSet.size === newSet.size && [...oldSet].every(id => newSet.has(id)); if (same) return false; // no change // Membership changed — update turn order, preserve current state where possible const newOrder = shuffleArray(botAccountIds); existing.turnOrder = newOrder; // If current speaker was removed, go dormant if (existing.currentSpeaker && !newSet.has(existing.currentSpeaker)) { existing.currentSpeaker = newOrder.length > 0 ? newOrder[0] : null; } // Clean up noRepliedThisCycle for (const id of existing.noRepliedThisCycle) { if (!newSet.has(id)) existing.noRepliedThisCycle.delete(id); } existing.lastChangedAt = Date.now(); return false; } channelTurns.set(channelId, { turnOrder: shuffleArray(botAccountIds), currentSpeaker: null, // start dormant noRepliedThisCycle: new Set(), lastChangedAt: Date.now(), prePopulated: false, }); return true; } /** * Initialize turn order from a known complete member list (e.g. channel-private-create). * Immediately activates the first speaker. * * @returns the first speaker accountId, or null if no bots */ export function initTurnOrderPrePopulated(channelId: string, botAccountIds: string[]): string | null { if (botAccountIds.length === 0) return null; const order = shuffleArray(botAccountIds); channelTurns.set(channelId, { turnOrder: order, currentSpeaker: order[0], noRepliedThisCycle: new Set(), lastChangedAt: Date.now(), prePopulated: true, }); return order[0]; } /** * Update turn order when members are added or removed (e.g. channel-private-update). * Preserves current speaker if still present, otherwise activates first in new order. * * @returns { turnOrder, currentSpeaker, needsHandoff } — needsHandoff is true if * currentSpeaker changed and moderator should send a handoff message */ export function updateTurnMembers( channelId: string, addAccountIds: string[], removeAccountIds: string[], ): { turnOrder: string[]; currentSpeaker: string | null; needsHandoff: boolean } { const state = channelTurns.get(channelId); const removeSet = new Set(removeAccountIds); if (!state) { // No existing state — create fresh with the added members const order = shuffleArray(addAccountIds.filter(id => !removeSet.has(id))); if (order.length === 0) return { turnOrder: [], currentSpeaker: null, needsHandoff: false }; channelTurns.set(channelId, { turnOrder: order, currentSpeaker: order[0], noRepliedThisCycle: new Set(), lastChangedAt: Date.now(), prePopulated: true, }); return { turnOrder: order, currentSpeaker: order[0], needsHandoff: true }; } const oldSpeaker = state.currentSpeaker; // Remove members if (removeAccountIds.length > 0) { state.turnOrder = state.turnOrder.filter(id => !removeSet.has(id)); for (const id of removeAccountIds) { state.noRepliedThisCycle.delete(id); } } // Add new members (append, don't shuffle existing order to preserve continuity) for (const id of addAccountIds) { if (!removeSet.has(id) && !state.turnOrder.includes(id)) { state.turnOrder.push(id); } } // Handle current speaker removal let needsHandoff = false; if (oldSpeaker && !state.turnOrder.includes(oldSpeaker)) { // Current speaker was removed — activate first available state.currentSpeaker = state.turnOrder.length > 0 ? state.turnOrder[0] : null; needsHandoff = state.currentSpeaker !== null; } state.lastChangedAt = Date.now(); return { turnOrder: state.turnOrder, currentSpeaker: state.currentSpeaker, needsHandoff }; } /** * Activate the first speaker in the turn order. * Used after turn order creation to start the cycle. * * @returns the activated speaker accountId, or null if no bots / already active */ export function activateFirstSpeaker(channelId: string): string | null { const state = channelTurns.get(channelId); if (!state || state.turnOrder.length === 0) return null; if (state.currentSpeaker !== null) return null; // already active state.currentSpeaker = state.turnOrder[0]; state.noRepliedThisCycle = new Set(); state.lastChangedAt = Date.now(); return state.currentSpeaker; } /** * Check if the given accountId is allowed to speak. */ export function checkTurn(channelId: string, accountId: string): { allowed: boolean; currentSpeaker: string | null; reason: string; } { const state = channelTurns.get(channelId); if (!state || state.turnOrder.length === 0) { return { allowed: true, currentSpeaker: null, reason: "no_turn_state" }; } // Not in turn order (human or unknown) → always allowed if (!state.turnOrder.includes(accountId)) { return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "not_in_turn_order" }; } // Dormant → not allowed (will be activated by onNewMessage or moderator) if (state.currentSpeaker === null) { return { allowed: false, currentSpeaker: null, reason: "dormant" }; } // Check timeout → auto-advance if (Date.now() - state.lastChangedAt > TURN_TIMEOUT_MS) { advanceTurn(channelId); // Re-check after advance const updated = channelTurns.get(channelId)!; if (updated.currentSpeaker === accountId) { return { allowed: true, currentSpeaker: updated.currentSpeaker, reason: "timeout_advanced_to_self" }; } return { allowed: false, currentSpeaker: updated.currentSpeaker, reason: "timeout_advanced_to_other" }; } if (accountId === state.currentSpeaker) { return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "is_current_speaker" }; } return { allowed: false, currentSpeaker: state.currentSpeaker, reason: "not_current_speaker" }; } /** * Called when a new message arrives in the channel. * Handles reactivation from dormant state. * Any member (human or bot) can trigger activation when dormant. * * @param senderAccountId - the accountId of the message sender (could be human/bot/unknown) * @param isHuman - whether the sender is in the humanList */ export function onNewMessage(channelId: string, senderAccountId: string | undefined, isHuman: boolean): void { const state = channelTurns.get(channelId); if (!state || state.turnOrder.length === 0) return; if (state.currentSpeaker !== null) { // Already active — human messages reset the cycle, bot messages do nothing if (isHuman) { state.currentSpeaker = state.turnOrder[0]; state.noRepliedThisCycle = new Set(); state.lastChangedAt = Date.now(); } return; } // Dormant state → reactivate (any member can trigger) if (senderAccountId && state.turnOrder.includes(senderAccountId)) { // Sender is in turn order → next after sender const idx = state.turnOrder.indexOf(senderAccountId); const nextIdx = (idx + 1) % state.turnOrder.length; state.currentSpeaker = state.turnOrder[nextIdx]; } else { // Sender not in turn order (human or unknown) → start from first state.currentSpeaker = state.turnOrder[0]; } state.noRepliedThisCycle = new Set(); state.lastChangedAt = Date.now(); } /** * Called when the current speaker finishes (end symbol detected) or says NO_REPLY. * @param wasNoReply - true if the speaker said NO_REPLY (empty/silent) * @returns the new currentSpeaker (or null if dormant) */ export function onSpeakerDone(channelId: string, accountId: string, wasNoReply: boolean): string | null { const state = channelTurns.get(channelId); if (!state) return null; if (state.currentSpeaker !== accountId) return state.currentSpeaker; // not current speaker, ignore if (wasNoReply) { state.noRepliedThisCycle.add(accountId); // Check if ALL agents have NO_REPLY'd this cycle const allNoReplied = state.turnOrder.every(id => state.noRepliedThisCycle.has(id)); if (allNoReplied) { // Go dormant state.currentSpeaker = null; state.noRepliedThisCycle = new Set(); state.lastChangedAt = Date.now(); return null; } } else { // Successful speech resets the cycle counter state.noRepliedThisCycle = new Set(); } return advanceTurn(channelId); } /** * Advance to next speaker in order. */ export function advanceTurn(channelId: string): string | null { const state = channelTurns.get(channelId); if (!state || state.turnOrder.length === 0) return null; if (state.currentSpeaker === null) return null; const idx = state.turnOrder.indexOf(state.currentSpeaker); const nextIdx = (idx + 1) % state.turnOrder.length; // Skip agents that already NO_REPLY'd this cycle let attempts = 0; let candidateIdx = nextIdx; while (state.noRepliedThisCycle.has(state.turnOrder[candidateIdx]) && attempts < state.turnOrder.length) { candidateIdx = (candidateIdx + 1) % state.turnOrder.length; attempts++; } if (attempts >= state.turnOrder.length) { // All have NO_REPLY'd state.currentSpeaker = null; state.lastChangedAt = Date.now(); return null; } state.currentSpeaker = state.turnOrder[candidateIdx]; state.lastChangedAt = Date.now(); return state.currentSpeaker; } /** * Force reset: go dormant. */ export function resetTurn(channelId: string): void { const state = channelTurns.get(channelId); if (state) { state.currentSpeaker = null; state.noRepliedThisCycle = new Set(); state.lastChangedAt = Date.now(); } } /** * Get debug info. */ export function getTurnDebugInfo(channelId: string): Record { const state = channelTurns.get(channelId); if (!state) return { channelId, hasTurnState: false }; return { channelId, hasTurnState: true, turnOrder: state.turnOrder, currentSpeaker: state.currentSpeaker, noRepliedThisCycle: [...state.noRepliedThisCycle], lastChangedAt: state.lastChangedAt, dormant: state.currentSpeaker === null, prePopulated: state.prePopulated, }; }