05: Turn Planning
Turn planning evaluates whether an admitted message should trigger a bot response, determines which
persona(s) reply, validates access and quotas, and produces an ordered ChatTurnPlan.
Flow and ownership
Section titled “Flow and ownership”planChatTurns() in src/utils/chat/turnPlanner.ts runs inside runWithChannelLock(). It receives
LockedChatTurn and returns a ChatTurnPlan:
LockedChatTurn │ ├─► loadOrRegisterTriggerUser() ──► hydrates UserRow and locale ├─► validateDirectChatTrigger() ──► validates direct mention / trigger words ├─► evaluateChatAccess() ──► checks whitelist, blacklist, and role rules ├─► rejectOnMessageTriggerCooldown() ──► enforces trigger cooldowns ├─► updateAutochatCounter() ──► advances autochat count when qualifying │ ▼determineMatchingPersonas() │ ├─► 0 matches? ──► returns turns: [] (silent skip, lock released) ├─► 1 match? ──► returns turns: [ChatTurn] └─► >1 matches? ├─► first persona ──► turns: [ChatTurn] (runs now) └─► extra personas ──► queueAdditionalPersonaTurns() (queued at front)User hydration and trigger validation
Section titled “User hydration and trigger validation”- User loading:
loadOrRegisterTriggerUser()fetches or registers the triggering user inuserRepository. It attaches the user record and preferred locale toadmission.userRow. - Direct trigger checks:
validateDirectChatTrigger()checks whether the message explicitly mentions the bot, uses trigger words, or addresses a specific persona. - Access gating:
evaluateChatAccess()checks server channel whitelists, role gates, and member blacklist status. If access is denied, planning aborts silently. - Cooldown enforcement:
rejectOnMessageTriggerCooldown()verifies message trigger cooldowns. If on cooldown, it sends a localized notice on deliberate turns and aborts planning.
Persona matching and multi-persona sequencing
Section titled “Persona matching and multi-persona sequencing”determineMatchingPersonas() in src/utils/chat/triggerProcessor.ts selects which personas should respond:
- Single persona match: produces one
ChatTurnpopulated with the persona’sTomoriState, resolved credential policy (textCredentialSource: "server" | "personal" | "supporter"), funding scope, and text quota reservation state. - Multiple persona matches: when multiple personas qualify on a real user message,
queueAdditionalPersonaTurns()places the additional personas at the front of the channel’smessageQueue. Only the first persona runs in the current turn. The remaining personas replay as priority jobs immediately after lock release, so each persona executes under its own lock and typing cycle. - Zero matches or failed gates: returns
turns: []. The coordinator treats an empty list as a skip, immediately releasing the channel lock and triggering queue replay.
Model and credential admission
Section titled “Model and credential admission”Each matched persona resolves credentials separately. Personal Text takes precedence over server models. Server routes choose the persona override, then the channel override, then the workspace model; an explicit invocation model override applies last. Each subscription attempt resolves funding for that effective model and its owning scope. A persona without usable credentials is skipped while other admitted personas can continue, and a refused trigger shows at most one credential notice and at most one quota notice however many personas matched.
Subscription ownership comes from the selected saved provider entry. The server’s
subscription_capabilities records that choice separately from catalog model IDs, so choosing an
OpenRouter BYOK entry keeps its own key even while the server has a sponsor. Personal provider rows
record ownership through their assigned and enabled capabilities.
Server-funded subscription routes reserve the same per-member text quota as server BYOK, and any admitted server route requires the server trigger cooldown. Personal subscriptions and personal BYOK stay outside server quota. Admission reserves one unit per trigger, and persona jobs and retries share it. A delivered reply keeps the unit. A trigger whose turns produce no reply releases it, so a retry can claim one again.
An inactive or unlinked personal subscription retains its configured personal text fallback pool.
A server route is considered only after that pool fails, subject to the user’s fallback setting and
user_byok_mode. When no attempt remains, the turn reports the subscription refusal instead of
skipping silently. Text commands honor the same fallback setting: a user who explicitly opted out
stops at the refusal, while a user with no personal provider keeps the server route. Other
capabilities can use an available configured server route when the personal subscription is inactive
or missing. Billing and storage failures remain visible failures.
Server admission, context assembly, and generation use src/utils/provider/textModelSelection.ts for
the same model choice: persona override, channel override (including a thread’s parent), then workspace default.
Impersonation uses the workspace model. An invocation codename override loads that provider’s model
metadata when registered. Personal credentials keep their own model routing ahead of server selection.
Channel lock state arming
Section titled “Channel lock state arming”When turns.length > 0, planChatTurns() updates the channel lock entry via setActiveChannelTurnState().
It records the lead persona’s ID, follow-up eligibility, and impersonation status so that subsequent user
messages can be evaluated for follow-up interrupts.
Constraints and rationale
Section titled “Constraints and rationale”- Gating before generation: access rules, cooldowns, and quota reservations execute before prompt assembly or LLM requests, so a turn that never reaches a provider spends nothing, and a turn that produces no reply returns its reserved unit.
- Front-of-queue multi-persona serialization: breaking multi-persona replies into distinct serialized turns prevents long single-lock blocks and provides dedicated Discord typing indicators and webhook identities for each responding persona.
- Selective error visibility:
shouldSurfaceUserErrorssuppresses error notices for passive autochat and random triggers while delivering error feedback for direct mentions, DMs, and slash commands. - Nudge dormancy: admitted direct guild chat renews the server’s 14-day nudge activity window
before credentials resolve; passive and system turns do not renew it, and queued nudge turns
are discarded when the shared eligibility gate closes. Direct turns keep
isDirectChaton an empty-response retry, and their persisted stats refresh heat and channel eligibility (see Natural Nudges).
Source pointers
Section titled “Source pointers”src/utils/chat/turnPlanner.ts:planChatTurns()coordinator.src/utils/chat/triggerProcessor.ts: persona matching and trigger detection.src/utils/chat/personaQueue.ts:queueAdditionalPersonaTurns()multi-persona queueing.src/utils/chat/admissionGuards.ts: access, cooldown, and rate limit validation helpers.src/utils/chat/textQuotaState.ts: per-trigger text quota reservation and release state.