/** * Turn-based speaking manager for group channels. * * Rules: * - Humans (humanList) are never in the turn order * - Turn order is auto-populated from channel/server members minus humans * - 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 */ import { getChannelShuffling, isMultiMessageMode, markLastShuffled } from "./core/channel-modes.js"; 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; // ── Mention override state ── /** Original turn order saved when override is active */ savedTurnOrder?: string[]; /** First agent in override cycle; used to detect cycle completion */ overrideFirstAgent?: string; // ── Wait-for-human state ── /** When true, an agent used the wait identifier — all agents should stay silent until a human speaks */ waitingForHuman: 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; } function reshuffleTurnOrder(channelId: string, currentOrder: string[], lastSpeaker?: string): string[] { const shufflingEnabled = getChannelShuffling(channelId); if (!shufflingEnabled) return currentOrder; const shuffled = shuffleArray(currentOrder); // If there's a last speaker and they're in the order, ensure they're not first if (lastSpeaker && shuffled.length > 1 && shuffled[0] === lastSpeaker) { // Find another speaker to swap with for (let i = 1; i < shuffled.length; i++) { if (shuffled[i] !== lastSpeaker) { [shuffled[0], shuffled[i]] = [shuffled[i], shuffled[0]]; break; } } } return shuffled; } // --- public API --- /** * Initialize or update the turn order for a channel. * Called with the list of bot accountIds (already filtered, humans excluded). */ export function initTurnOrder(channelId: string, botAccountIds: string[]): void { const existing = channelTurns.get(channelId); if (existing) { // Compare membership against base order. // If mention override is active, turnOrder is temporary; use savedTurnOrder for stable comparison. const baseOrder = existing.savedTurnOrder || existing.turnOrder; const oldSet = new Set(baseOrder); const newSet = new Set(botAccountIds); const same = oldSet.size === newSet.size && [...oldSet].every(id => newSet.has(id)); if (same) return; // no change console.log( `[dirigent][turn-debug] initTurnOrder membership-changed channel=${channelId} ` + `oldOrder=${JSON.stringify(existing.turnOrder)} oldCurrent=${existing.currentSpeaker} ` + `oldOverride=${JSON.stringify(existing.savedTurnOrder || null)} newMembers=${JSON.stringify(botAccountIds)}`, ); const nextOrder = shuffleArray(botAccountIds); // Mention override active: update only the saved base order. // Keep temporary turnOrder/currentSpeaker intact so @mention routing is not clobbered. if (existing.savedTurnOrder) { existing.savedTurnOrder = nextOrder; existing.lastChangedAt = Date.now(); console.log( `[dirigent][turn-debug] initTurnOrder applied-base-only channel=${channelId} ` + `savedOrder=${JSON.stringify(nextOrder)} keptOverrideOrder=${JSON.stringify(existing.turnOrder)} ` + `keptCurrent=${existing.currentSpeaker}`, ); return; } // Non-mention flow: preserve previous behavior (re-init to dormant). channelTurns.set(channelId, { turnOrder: nextOrder, currentSpeaker: null, // start dormant noRepliedThisCycle: new Set(), lastChangedAt: Date.now(), waitingForHuman: false, }); console.log( `[dirigent][turn-debug] initTurnOrder applied channel=${channelId} newOrder=${JSON.stringify(nextOrder)} newCurrent=null`, ); return; } console.log( `[dirigent][turn-debug] initTurnOrder first-init channel=${channelId} members=${JSON.stringify(botAccountIds)}`, ); const nextOrder = shuffleArray(botAccountIds); channelTurns.set(channelId, { turnOrder: nextOrder, currentSpeaker: null, // start dormant noRepliedThisCycle: new Set(), lastChangedAt: Date.now(), waitingForHuman: false, }); console.log( `[dirigent][turn-debug] initTurnOrder applied channel=${channelId} newOrder=${JSON.stringify(nextOrder)} newCurrent=null`, ); } /** * 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" }; } // Waiting for human → block all agents if (state.waitingForHuman) { return { allowed: false, currentSpeaker: null, reason: "waiting_for_human" }; } // 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) 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 and human-triggered resets. * * NOTE: For human messages with @mentions, call setMentionOverride() instead. * * @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; // Check for multi-message mode exit condition if (isMultiMessageMode(channelId) && isHuman) { // In multi-message mode, human messages don't trigger turn activation // We only exit multi-message mode if the end marker is detected in a higher-level hook return; } if (isHuman) { // Human message: clear wait-for-human, restore original order if overridden, activate from first state.waitingForHuman = false; restoreOriginalOrder(state); state.currentSpeaker = state.turnOrder[0]; state.noRepliedThisCycle = new Set(); state.lastChangedAt = Date.now(); return; } if (state.waitingForHuman) { // Waiting for human — ignore non-human messages return; } if (state.currentSpeaker !== null) { // Already active, no change needed from incoming message return; } // Dormant state + non-human message → reactivate 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 → start from first state.currentSpeaker = state.turnOrder[0]; } state.noRepliedThisCycle = new Set(); state.lastChangedAt = Date.now(); } /** * Restore original turn order if an override is active. */ function restoreOriginalOrder(state: ChannelTurnState): void { if (state.savedTurnOrder) { state.turnOrder = state.savedTurnOrder; state.savedTurnOrder = undefined; state.overrideFirstAgent = undefined; } } /** * Set a temporary mention override for the turn order. * When a human @mentions specific agents, only those agents speak (in their * relative order from the current turn order). After the cycle returns to the * first agent, the original order is restored. * * @param channelId - Discord channel ID * @param mentionedAccountIds - accountIds of @mentioned agents, ordered by * their position in the current turn order * @returns true if override was set, false if no valid agents */ export function setMentionOverride(channelId: string, mentionedAccountIds: string[]): boolean { const state = channelTurns.get(channelId); if (!state || mentionedAccountIds.length === 0) return false; console.log( `[dirigent][turn-debug] setMentionOverride start channel=${channelId} ` + `mentioned=${JSON.stringify(mentionedAccountIds)} current=${state.currentSpeaker} ` + `order=${JSON.stringify(state.turnOrder)} saved=${JSON.stringify(state.savedTurnOrder || null)}`, ); // Restore any existing override first restoreOriginalOrder(state); // Filter to agents actually in the turn order const validIds = mentionedAccountIds.filter(id => state.turnOrder.includes(id)); if (validIds.length === 0) { console.log(`[dirigent][turn-debug] setMentionOverride ignored channel=${channelId} reason=no-valid-mentioned`); return false; } // Order by their position in the current turn order validIds.sort((a, b) => state.turnOrder.indexOf(a) - state.turnOrder.indexOf(b)); // Save original and apply override state.savedTurnOrder = [...state.turnOrder]; state.turnOrder = validIds; state.overrideFirstAgent = validIds[0]; state.currentSpeaker = validIds[0]; state.noRepliedThisCycle = new Set(); state.lastChangedAt = Date.now(); console.log( `[dirigent][turn-debug] setMentionOverride applied channel=${channelId} ` + `overrideOrder=${JSON.stringify(state.turnOrder)} current=${state.currentSpeaker} ` + `savedOriginal=${JSON.stringify(state.savedTurnOrder || null)}`, ); return true; } /** * Check if a mention override is currently active. */ export function hasMentionOverride(channelId: string): boolean { const state = channelTurns.get(channelId); return !!state?.savedTurnOrder; } /** * Set the channel to "waiting for human" state. * All agents will be routed to no-reply until a human sends a message. */ export function setWaitingForHuman(channelId: string): void { const state = channelTurns.get(channelId); if (!state) return; state.waitingForHuman = true; state.currentSpeaker = null; state.noRepliedThisCycle = new Set(); state.lastChangedAt = Date.now(); } /** * Check if the channel is waiting for a human reply. */ export function isWaitingForHuman(channelId: string): boolean { const state = channelTurns.get(channelId); return !!state?.waitingForHuman; } /** * 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) { // If override active, restore original order before going dormant restoreOriginalOrder(state); // 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(); } const prevSpeaker = state.currentSpeaker; const next = advanceTurn(channelId); // Check if override cycle completed (returned to first agent) if (state.overrideFirstAgent && next === state.overrideFirstAgent) { restoreOriginalOrder(state); state.currentSpeaker = null; state.noRepliedThisCycle = new Set(); state.lastChangedAt = Date.now(); return null; // go dormant after override cycle completes } // Check if we've completed a full cycle (all agents spoke once) // This happens when we're back to the first agent in the turn order const isFirstSpeakerAgain = next === state.turnOrder[0]; if (!wasNoReply && !state.overrideFirstAgent && next && isFirstSpeakerAgain && state.noRepliedThisCycle.size === 0) { // Completed a full cycle without anyone NO_REPLYing - reshuffle if enabled const newOrder = reshuffleTurnOrder(channelId, state.turnOrder, prevSpeaker); if (newOrder !== state.turnOrder) { state.turnOrder = newOrder; markLastShuffled(channelId); console.log(`[dirigent][turn-debug] reshuffled turn order for channel=${channelId} newOrder=${JSON.stringify(newOrder)}`); } } return next; } /** * 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) { restoreOriginalOrder(state); state.currentSpeaker = null; state.noRepliedThisCycle = new Set(); state.waitingForHuman = false; 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, waitingForHuman: state.waitingForHuman, hasOverride: !!state.savedTurnOrder, overrideFirstAgent: state.overrideFirstAgent || null, savedTurnOrder: state.savedTurnOrder || null, }; }