Skip to content

05: Turn Planning

Persona selection + per-turn state assembly.

File: src/utils/chat/turnPlanner.ts:49-288

Decide which persona(s) will respond and in what order, and produce one fully-populated ChatTurn per responding persona. Runs the long sequence of gating checks (trigger validation, access, cooldown, credentials, persona match, rate limit, quota) and assembles the per-turn closure that stages 06-per-turn will consume.

LockedChatTurn (from stage 04). Carries the RunnableChatAdmission plus channel-lock metadata.

ChatTurnPlan:

{
lockedTurn: LockedChatTurn;
turns: ChatTurn[]; // empty array means "skip — no response"
}

turns: [] is the silent-skip signal for any failed gate (access denied, cooldown rejected, no persona matched, quota exhausted, etc.) — the coordinator treats it as “release lock, replay queue.”

ChatTurn (one per responding persona — see src/utils/chat/types.ts:141-171):

  • The chosen persona: TomoriState plus all sibling personas for context
  • userRow, triggererName, requestSnapshot (privacy, blacklist, member ref)
  • Channel/server name and description, DM flag, self-message flag
  • Resolved credential policy (textCredentialSource: "server" | "personal", personalRoutingUserId, personalTextProvider)
  • Text-quota preflight result (shouldApplyTextQuota, textQuotaTriggerKey, textQuotaState)
  • User-error visibility (shouldSurfaceUserErrors) so passive autochat/random turns can fail quietly while deliberate mentions, trigger words, replies, DMs, reminders, and command-driven turns still get feedback.
  • Forced mentions, impersonation flags
  • Trigger user registrationloadOrRegisterTriggerUser registers first-seen users via userRepository.register and updates admission.userRow in place.
  • Autochat counter incrementupdateAutochatCounter advances autoch_counter and resets the next-target threshold via configRepository.incrementTomoriCounter. Skipped for thought-log channels, non-autochat channels, and non-real-user messages.
  • Trigger user mutation on admissionadmission.locale, admission.userRow, admission.tomoriState, admission.allPersonas, incoming.isUserImpersonation, incoming.impersonatedUserId are set on the carried admission/incoming objects so downstream stages share the resolved state.
  • Multi-persona queueing — if more than one persona matched (and the message is a real user message, non-stop-response, non-reminder), queueAdditionalPersonaTurns puts the extra personas at the front of the channel queue. Only the first persona runs in the current turn-sequence; others are replayed as queued messages immediately after.
  • Channel-lock active-turn statesetActiveChannelTurnState records the first persona’s ID, follow-up eligibility, impersonation flags so the channel lock can authorize follow-up interrupts.
  • Cooldown enforcement and settingrejectOnMessageTriggerCooldown may send a user-visible cooldown notice for deliberate turns, then setMessageTriggerCooldownForAdmission runs on success.
  • Credential errorsresolveTextCredentialPolicy may send error embeds for PersonalProviderRequiredError or CredentialUnavailableError only when shouldSurfaceUserErrors is true, then returns null (causing turns: []).
  • Text-quota preflightcheckTextQuotaForAdmission may send a quota-exceeded embed for deliberate turns and reject the turn. Passive turns reject silently.

After this stage runs:

  • If turns.length > 0, every entry has a non-null persona, a resolved userRow, and a credential policy (textCredentialSource is set).
  • If multi-persona response is needed, only turns[0] runs now; the rest are queued at the front of the channel queue and run after lock release.
  • The channel lock’s activePersonaId/followUpEligible/impersonation flags reflect the persona that will speak in this turn-sequence.
  • All trigger/access/cooldown side effects (counter increment, cooldown set, blocked-by-quota handling) have fired before the per-turn loop starts — stages 06-per-turn never re-check these. Passive autochat/random turns may log and abort without emitting a user-facing error embed.

This stage is the densest decision surface in the chat pipeline, and most of its decisions are delegated to named helpers. Each helper is the plugin-relevant seam:

DecisionHelperPlugin-relevance
Direct-trigger validation (mention/reply/word-trigger)validateDirectChatTrigger (admissionGuards.ts)A plugin adding a new trigger style would extend here
Access policy (whitelist, spotlight, role gates)evaluateChatAccess (admissionGuards.ts)→ plugin plan candidate if plugins want to add access rules
Per-persona reply decisionshouldBotReply (replyDecision.ts)A plugin adding a new auto-reply mode hooks here
Persona matchingdetermineMatchingPersonas (triggerProcessor.ts)Heavy logic; coupled to autochat/DTM/always-reply/personal-spotlight semantics
Credential resolution (server vs personal BYOK)resolveCapabilityCredentials (credentialResolver.ts)The credential-source abstraction is the plugin-relevant seam
Rate limit (per-user, per-server, global)enforceGlobalRateLimit (admissionGuards.ts)Internal — coupled to rate-limit metric names
Message-trigger cooldownrejectOnMessageTriggerCooldown, setMessageTriggerCooldownForAdmissionInternal — coupled to CooldownType enum
Text-quota preflightcheckTextQuotaForAdmission (admissionGuards.ts) + textQuotaManagerInternal — coupled to quota table schema
Cascade trigger limitgetSelfReplyChainState (selfReplyState.ts)Internal — coupled to self-reply chain semantics

The stage itself is internal. A future plugin extension for “modify the turn plan” would likely take the form of a post-plan hook (afterPlanTurns(plan) → plan') that adds or filters turns after the fixed gating completes, not a wholesale override.

  • Credential resolution: → provider pipeline.
  • Autochat / DTM / always-reply mode semantics: → folded into triggerProcessor.ts; no dedicated doc yet.
  • Cooldown types and behavior: → docs/subsystems/cooldowns.md.
  • Text quota: → no dedicated doc yet; quota-manager helper only.