06.4: Post-Turn Effects
Side-effect sequence after generation completes.
File: src/utils/chat/postTurnEffects.ts:21-28
Mission
Section titled “Mission”Run the post-generation side effects that depend on the produced result. Eight ordered steps: selected-sticker delivery, empty-response retry, text-quota consumption, self-reply chain bookkeeping, short-term memory write, thought-log emission, boomerang follow-up scheduling, and fire-and-forget usage statistics. Recoverable delivery/storage failures are logged without breaking the completed turn.
ChatTurnContext(the closure built in per-turn stage 01).GenerationTurnResult(from per-turn stage 03).
Output
Section titled “Output”Promise<void> — terminal stage for this turn iteration.
Side effects
Section titled “Side effects”Steps run in this order:
1. sendSelectedSticker
Section titled “1. sendSelectedSticker”If a completed GenerationTurnResult carries selectedSticker:
- An alter persona first sends the sticker URL through its identity webhook, forwarding the thread ID where applicable.
- If that fails, or the active persona is not an alter, a queued turn replies to the trigger message with the native sticker; a non-queued turn sends it directly to the channel.
- Final delivery failures are logged with the server and sticker IDs and do not propagate.
2. maybeScheduleEmptyResponseRetry
Section titled “2. maybeScheduleEmptyResponseRetry”If result.status === "empty_response" and incoming.retryCount < MAX_EMPTY_RESPONSE_RETRIES (default 2):
- Sleeps
EMPTY_RESPONSE_RETRY_DELAY_MS(default 1000ms). - If the empty-response reason was
"speaker_guard", prepends a synthetic speaker-guard directive toinjectedContextItemsviabuildSpeakerGuardRetryDirective. - Re-enters
tomoriChat()withskipLock=true,retryCount + 1,selectedPersonaIdpinned to the same persona, and the OpenRouter finish-reason-length flag forwarded so stage 03 can trim history.
The "speaker_guard" reason is produced both by the config-gated mid-text
speaker guard and by the always-on opening-label leak guard (a response
opening with a foreign speaker label like Chris (smug): — see provider
stage 06). The stream side reads incoming.retryCount (threaded through
StreamingContext.emptyResponseRetryCount) to strip-and-deliver instead of
discarding once this retry budget is exhausted, so leak turns degrade to a
label-stripped reply rather than silence.
3. consumeTextQuota
Section titled “3. consumeTextQuota”If shouldApplyTextQuota was true, quota state exists, it wasn’t already
consumed, and the response was non-empty:
incrementTextQuota(serverId, userDiscId).- Marks the quota state consumed and writes it back to
textQuotaTriggerStates.
4. updateSelfReplyBookkeeping
Section titled “4. updateSelfReplyBookkeeping”If the response was non-empty:
setLastRespondedPersona(channel.id, persona_id)— records which persona spoke last (used by stage 05 self-message persona-rotation).- Increments
selfReplyChainState.triggerCountfor non-manual, non-reminder, non-stop real responses. If the message was a self-message, also setslastWasSelf = true.
5. writeShortTermMemory
Section titled “5. writeShortTermMemory”If not a stop response, history is non-empty, user is not privacy-FULL, and the response was non-empty:
- Builds the last 10 simplified messages + persona responses (one entry per responding persona).
- Calls
storeShortTermMemory(...)once per unique persona ID (or once withnullif no persona IDs are known). - Failures are logged but don’t propagate.
6. emitThoughtLog
Section titled “6. emitThoughtLog”If a thought_log_channel_disc_id is configured, the source channel isn’t
DM, and the source channel isn’t in the persona’s private_channel_ids:
- If
thoughtLoghas content (provider emitted reasoning/thinking blocks): computesgenerationDurationMs = now - message.createdTimestamp, sends a full thought-log embed viasendThoughtLogEmbed. - Else if the response was via personal BYOK (
textCredentialSource === "personal"): sends an attribution-only embed crediting the user’s provider. - Else: no-op.
7. scheduleBoomerangFollowUp
Section titled “7. scheduleBoomerangFollowUp”Schedules a setImmediate callback:
- Checks
consumePendingBoomerang(channel.id)— set by thecrossChannelMessagetool when the active turn used it. - If pending, fetches the latest message in the boomerang’s source channel,
calls
suppressNextSelfReply(sourceChannel.id)to prevent the boomerang from triggering its own self-reply detection, and re-enterstomoriChat()against the source channel with the boomerang’s persona + injected context.
8. recordUsageStats
Section titled “8. recordUsageStats”Starts fire-and-forget recording for completed persona responses: turn/model, token, impersonation, emoji, and sprite metrics. Sticker-use statistics remain at successful tool selection, matching the tool-dispatch timing.
Invariants
Section titled “Invariants”After this stage runs:
- The text-quota state for this trigger is consumed exactly once per successful turn-sequence (across multiple personas responding to the same trigger, only the first non-empty response increments).
setLastRespondedPersonareflects the last persona to actually speak in this channel — used by the next turn’s persona-rotation logic.- Short-term memory entries are scoped per-persona-ID (so each persona has its own conversational continuity in the cache).
- A pending boomerang from this turn is consumed exactly once.
- A selected sticker is delivered only for a completed result and always after the provider text stream has finalized.
- Recursive re-entries (empty-response retry, boomerang) are scheduled
with the appropriate flags (
skipLock=truefor retry,suppressNextSelfReplyfor boomerang) so they do not interfere with the outer lock or self-reply chain semantics.
Extension points
Section titled “Extension points”This is the richest plugin surface in the chat pipeline. Each of the six steps is an independent side-effect concern that a plugin might want to extend or replace:
| Step | Named helper | Plugin-relevance |
|---|---|---|
| Sticker delivery | sendSelectedSticker |
Post-stream media companion path; alter identity webhook with native-sticker fallback |
| Empty-response retry | maybeScheduleEmptyResponseRetry |
Retry policy (provider-specific) — extension via per-provider hook |
| Text-quota consumption | incrementTextQuota |
Quota-manager subsystem; plugins shipping their own quotas would add hooks here |
| Self-reply bookkeeping | setLastRespondedPersona, getSelfReplyChainState |
Cascade-trigger limit semantics; coupled to stage 05 |
| Short-term memory write | storeShortTermMemory |
→ memory pipeline — STM Stage 01 |
| Thought-log emission | sendThoughtLogEmbed, sendAttributionOnlyEmbed |
New “logging channel kinds” plug in here |
| Boomerang follow-up | consumePendingBoomerang, buildBoomerangContext |
Cross-channel-tool-specific; one plugin (the cross-channel tool) owns the pending-boomerang state |
| Usage statistics | recordUsageStats |
Post-turn metrics chokepoint; intentionally fire-and-forget |
The sequencing matters: sticker delivery runs first but is completed-turn
only; an empty result therefore proceeds directly to retry handling. Quota
consumption runs before memory write so quota
exhaustion doesn’t pollute the memory cache; boomerang runs last via
setImmediate (before the non-blocking stats dispatch) so the outer lock has
released before the cross-channel re-entry attempts to acquire its own lock.
A future plugin extension for “add a new post-turn hook” would likely take
the form of a hook list (postTurnHooks: PostTurnHook[]) where each hook
runs after the built-in steps with the same (context, result) signature.
→ plugin plan candidate.
Configuration
Section titled “Configuration”| Constant | Default | Purpose |
|---|---|---|
MAX_EMPTY_RESPONSE_RETRIES |
2 |
Cap on empty-response retry chain (shared constant in src/utils/discord/stream/constants.ts; also read by the stage 06 opening-label leak guard) |
EMPTY_RESPONSE_RETRY_DELAY_MS |
1000 |
Backoff between retries (file-local constant) |
Both are currently file-local — promoting to env vars would be a small follow-up if operational tuning becomes useful.
Related docs
Section titled “Related docs”- Short-term memory: → memory pipeline — see STM Stage 01 for the write path
- Thought log: → no dedicated doc;
thoughtLog.tshelper only - Boomerang / cross-channel tool: → no dedicated doc;
crossChannelMessageTool.tshelper only - Self-reply chain semantics: → folded into stage 05 docs (cascade limits)