06: Segment Normalization
Normalizes a flushed text segment — capturing render modifiers, cleaning LLM output artifacts, resolving Discord mentions, enforcing the speaker guard, and managing output prefill — before handing it to stage 07 for Discord delivery.
File: src/utils/discord/stream/segmentProcessor.ts:21-233
Mission
Section titled “Mission”StreamSegmentProcessor.sendBufferSegment() receives a raw, flushed text segment from stage 05
and a BufferedDeliveryBoundary label describing why the flush occurred. It applies a pipeline
of transformations to produce clean, Discord-safe text, then delegates to
StreamMessageDelivery.sendSegment() (stage 07).
The transformation pipeline runs in this order:
-
Orphan-punctuation guard — segments consisting entirely of punctuation (e.g., a lone
…or...) are held instate.pendingOrphanPunctuationand prepended to the next non-empty segment instead of being sent standalone, preventing jarring single-character messages. -
Render-modifier capture — before normal own-name cleanup runs, an active persona line that starts as
SourcePersona (modifier): textis parsed. The modifier resolves against the active persona’spersona_spritesfirst; a sprite match sends stage 07 an identity override and the sprite avatar, plus aspriteRecordso the message → sprite-label mapping is persisted after the send (persona_sprite_messages). Ordinary sprites use the clean usernameSourcePersona(no(sprite)suffix in Discord); identity sprites (is_identity = true) use the flipped usernamesprite (SourcePersona)shown directly in Discord, DID-alter style. The accumulated-text prefix keeps the decoratedSourcePersona (sprite):label so the model still sees its own sprite usage. If the sprite row matches but its image cannot be loaded, the parenthetical modifier is stripped and the line is delivered as normal source-persona output without trying copied identity. If no sprite matches, the modifier falls back to copied-render resolution against known personas and users in current context. The candidate identities and purpose-filtered aliases come from the participant target index; copied matches use the flipped webhook usernametarget (SourcePersona)(impersonated name first for the in-chat disguise) while the accumulated-text prefix stays source-first (SourcePersona (target):) for the model. Unknown or ambiguous copied targets are stripped and delivered as normal output. This path is line-scoped across stream splits and ignored inside code blocks and list-like starts. -
Opening-label leak guard (
matchLeadingSpeakerLeak+parseLeadingGenericSpeakerLabel) — runs only at response start (nothing accumulated or sent yet) on segments the render-modifier capture refused, and regardless ofllm_stop_speaker_pattern_enabled. It catches the model cross-breeding history label formats into a foreign speaker label the other guards all miss: the decorated grammar with a non-persona name (Chris (smug): …— any name outside the active persona/aliases using the parenthetical form is unambiguously a leak), or a plainName:opening whereNameis a known conversation participant (another persona, or a user fromKNOWLEDGE_USERS_IN_CONVERSATIONviacollectKnownSpeakerNames). Prose openings (Note:,TL;DR:), list items, blockquotes, headings, code fences, and bracketed starts (links/mentions/timestamps) never fire. On detection:- Retry budget remaining (
context.emptyResponseRetryCount < MAX_EMPTY_RESPONSE_RETRIES, threaded fromincoming.retryCountthroughStreamingContext→buildStreamContext): the attempt is discarded viarequestStop(channelId, "speaker_guard")with nothing sent, which classifies the turn asempty_responsesomaybeScheduleEmptyResponseRetryregenerates it with the “reply only as {persona}” directive injected. - Budget exhausted: the leaked label is stripped and the body delivered as the active
persona — a stripped reply beats silence.
Deliberate impersonation (
Tomori (Chris): …) is unaffected: the render-modifier capture consumes it before this guard runs.
- Retry budget remaining (
-
Custom emoji deduplication (
filterDuplicateCustomEmojis) — strips any custom emoji shortcode (:name:) from the segment if the same emoji was already used in a recent bot message (lookback window controlled byEMOJI_UNIQUE_LOOKBACK, default 5). History is stored in converted Discord format (<:name:id>), so the filter normalises that form to shortcodes before comparison. -
LLM output cleaning (
cleanLLMOutput) — strips the bot’s own name-prefix if the model writes it (e.g.,"Tomori: hello"→"hello"), converts:name:shortcodes to full Discord custom emoji syntax (<:name:id>) using the server emoji list, strips unresolved shortcodes by default, optionally preserves unresolved shortcodes whenEMOJI_PRESERVE_UNRESOLVED_SHORTCODES=true, removes all emoji attempts whenemojiUsageEnabledisfalse, and optionally uncensors Unicode space characters and sanitizes encoded content. The own-name strip also peels a leaked multi-name opening label chain when a persona answers to more than one name — e.g. the bundled “Shy Tomori (Lilya)” persona prefixes"Tomori: Lilya: …"(lore/default name + webhook nickname).textConfig.botNameAliases(collectPersonaNameAliases:DEFAULT_BOTNAME+ the persona’strigger_words) supplies those extra names; the leaked-preamble and later-boundary passes stay scoped to the active name so mid-prose"Name:"usages are preserved. DecoratedName (modifier):labels are less ambiguous, so their opening and boundary safety-net forms accept the active name and aliases, ASCII or full-width colons, and the same bold wrappers accepted for plain labels. Decorated labels remain excluded from the leaked-preamble pass so a stranded mid-body label cannot delete preceding reply text.The opening chain also matches identity-macro labels —
{bot}:,{{char}}:,{user}:and their bold forms — because the model sometimes labels its turn with the template syntax instead of the resolved name. Like aliases, macros widen the opening-chain match only. Identity macros elsewhere in the response are deliberately delivered to Discord verbatim: a persona asked to draft a preset or system prompt is supposed to emit{{char}}: …sample dialogue, and that draft must survive both the send and its round-trip back through dialogue history (see11-dialogue-history.md). A response opening with a code fence never matches the leading pattern, so fenced drafts are safe regardless. -
Guild mention resolution (
resolveGuildMentions) — converts name-based handle references in the text (e.g.,@alice) to Discord snowflake mentions (<@1234567890>) using the mention map built at stream init fromContextItemTag.KNOWLEDGE_USERS_IN_CONVERSATIONitems. -
Output prefill strip/inject (
stripPrefillFromSegment/applyPrefillToSegment) — whencontext.outputPrefillis set (hybrid prefix streaming for NAI), the first segment strips the model-echoed prefill from its start and the cleaned prefill is prepended to the outgoing segment (injected exactly once; subsequent segments are unmodified). -
Speaker guard (
truncateBeforeGenericSpeakerLine) — ifllm_stop_speaker_pattern_enabledis true and a speaker-label line (e.g.,User:) appears in the segment, the text is truncated before it andrequestStop(channelId, "speaker_guard")is queued. The segment is sent with the truncated content; the stop is processed by the stage 04 orchestrator on the next iteration. Active render-modifier labels such asRen (mad):orRen (target):are explicitly allowed through both the provider-level fallback guard and this segment-level guard so they can be resolved instead of treated as foreign speaker turns. This guard skips the response’s opening line (includeStartonly turns on once text has accumulated) — that position is covered by the always-on opening-label leak guard in step 3. -
Markdown table detection (
extractMarkdownTableSegments) — if the segment contains a rendered Markdown table, the segment is split into text parts and table parts. Table parts are routed toStreamMessageDelivery.sendRenderedMarkdownTable()which renders the table to a PNG viarenderMarkdownTableToPng()and sends it as a Discord file attachment.
segment: string— raw text segment flushed fromstate.bufferby stage 05.boundary: BufferedDeliveryBoundary | undefined— flush reason:"code_open","code_close","newline","period","overflow","attachment","final","tool_call".textConfig: TextProcessingConfig— mention map, emoji config, speaker name set, persona name aliases (botNameAliases), delivery mode.typingConfig: TypingSimulationConfig— forwarded to stage 07.context: StreamContext— channel ID (stop requests),tomoriState.config, prefill state.state: StreamState— orphan punctuation state, prefill matching state, accumulated text.
Output
Section titled “Output”No return value. The normalized segment (or its table-split parts) is forwarded to stage 07.
Side effects
Section titled “Side effects”-
state.pendingOrphanPunctuation— may be set (hold) or cleared (prepend to segment). -
state.prefillMatched/state.prefillInjected/state.prefillMatchFailed— updated as prefill stripping/injection progresses. -
state.activeRenderModifier— tracks the active render-modifier identity override so period or chunk splits keep using the sprite/copied identity. Expiry differs by modifier kind:- Copied identities (impersonating a user / another persona — no
spriteRecord) expire at the end of their line (a newline boundary or an embedded\n), so the bot reverts to itself on the next line unless it re-declares the label. - Persona sprites (regular and
is_identity, carrying aspriteRecord) persist across newlines and only switch when a differentSourcePersona (sprite):label appears — an expression is a sustained visual state, e.g."Touko (mad): ARGGHHH!\nFine... I'll do it"keeps themadsprite for the second line.
- Copied identities (impersonating a user / another persona — no
-
Sprite group-break alternation (
channelDeliveryContinuity.ts, keyed by channel — notStreamState) — tracks the last non-identity sprite delivered in the channel plus a toggle flipped on each sprite change. The toggle decides whether the sprite uses the clean persona name (false) or the decoratedPersona (sprite)name (true). Discord groups consecutive webhook messages bywebhook_id+username(ignoring the avatar) and strips zero-width/blank chars from usernames, so a visibly distinct name is the only reliable break: adjacent different-sprite messages alternate clean/decorated and never match, forcing Discord to render each avatar instead of grouping them under the first one, while same-sprite runs keep an identical username and still group. Identity sprites are excluded (their decorated name is already distinct).The run restarts on the clean name whenever anything else has posted since our last delivery — a user, another persona’s webhook, or our own bot-user fallback (a different Discord author than the webhook). Discord groups a message only with the one directly above it, so a broken group leaves nothing to collide with and the decorated name would read as an emotion display rather than a collision break. Adjacency is tested by comparing the channel’s
lastMessageIdagainst the id of our last webhook delivery. The comparison is on snowflake magnitude, not equality:lastMessageIdis maintained from the gatewayMESSAGE_CREATEdispatch and can lag a send we just made, and an equality check would read our own back-to-back segments as non-adjacent and drop the break that keeps them from merging. Only a strictly greater id proves a later message exists; a lagging read falls through to “adjacent”, whose worst case is a redundant suffix rather than two sprites collapsing under one avatar.The state is channel-scoped because Discord’s grouping spans turns. It previously lived in
StreamState, which is rebuilt for every SDK call, so any turn boundary (queued chain, follow-up, persona job, tool-loop continuation) reset the alternation and let the new turn’s first sprite collide with the previous turn’s last one. That per-turn reset also stood in for adjacency, which is now tested directly, so removing it is what made the suffix appear after unrelated messages. Entries expire afterSPRITE_GROUP_CONTINUITY_TTL_MINUTES(default 10) — past Discord’s own grouping window, continuity no longer matters.The same module also records the identity each message was actually delivered under (
recordChannelDeliveredWebhookIdentityon a webhook send,recordChannelDeliveredBotMessageon an ordinary bot send). Post-turn artifacts read it back viagetChannelDeliveredWebhookIdentity()so they group with the message they follow — see stage 07. -
requestStop(channelId, "speaker_guard")— queued if the speaker guard fires; the stop is consumed by the stage 04 orchestrator on the next loop iteration. The opening-label leak guard (step 3) queues the same stop with nothing sent, which the state machine classifies asempty_response/speaker_guardso post-turn effects schedule a regeneration. -
PNG attachment — when a Markdown table is detected and rendered successfully, a Discord file attachment is sent with a “Show Markdown” button, and the table’s raw Markdown is cached in
markdownTableCache(keyed by message ID) both for subsequent context reads and to serve the button’s ephemeral reply. See stage 07 § Rendered Markdown tables. -
prepareOutputPrefill()(companion method) — called once before stage 02 begins (fromexecuteStreamsetup). Resolves the prefill string through the same mention/cleaning pipeline and stores it onstate.prefillTarget.
Invariants
Section titled “Invariants”After this stage (per segment):
- If the cleaned segment is empty (e.g., contained only the bot’s name prefix), stage 07 is not called — no empty Discord messages are sent.
- If the speaker guard fired,
statecontains the queued stop and the segment sent to Discord is the truncated pre-guard portion only. - Custom emoji deduplication was applied — no custom emoji that appeared in a recent bot message (within the lookback window) is present in the segment as delivered.
Extension points
Section titled “Extension points”| Surface | Plugin-relevance |
|---|---|
cleanLLMOutput() |
src/utils/text/processors/llmOutputProcessor.ts. Internal — LLM output normalization is tightly coupled to TomoriBot’s persona-name conventions and Discord formatting rules. The emojiUsageEnabled and uncensor_* DB config flags are the configuration surfaces. |
resolveGuildMentions() |
src/utils/discord/stream/mentionResolver.ts. Internal — mention resolution uses the static mention map derived at stream-init from the participant target index. A plugin adding custom handle → user-ID mappings would extend the participant source/profile contracts, not this stage. Takes (text, channel, mentionMap, mentionIdSet, personaMentionMap?) so it can be shared by non-stream callers (see cleanToolReplyText, below). Known persona handles stay as bare @trigger text after Discord user mentions are resolved. |
cleanToolReplyText() |
src/utils/discord/toolReplyText.ts. Internal — applies this stage’s filterDuplicateCustomEmojis → cleanLLMOutput → resolveGuildMentions chain to tool-authored reply text (e.g. the reply action of interact_with_recent_message), which bypasses the streaming segment path. Keeps tool replies and normal replies rendering identically (emoji, Discord @mention resolution, and persona @trigger preservation). A plugin adding another tool that sends Tomori-authored Discord text should route it through this helper. |
filterDuplicateCustomEmojis() |
src/utils/text/emojiPenalty.ts. Internal — emoji deduplication heuristic; no plugin-relevant seam. |
extractMarkdownTableSegments() + renderMarkdownTableToPng() |
src/utils/text/markdownTable.ts + src/utils/image/markdownTableRenderer.ts. The table renderer path is the only place in the stream pipeline where image attachments are sent during streaming (as opposed to tool results). A plugin adding other attachment types mid-stream would extend here. → plugin plan candidate |
Speaker guard (truncateBeforeGenericSpeakerLine) |
src/utils/text/processors/llmOutputProcessor.ts. Internal — speaker-label detection runs in both the adapter (stage 02) and the segment processor. The llm_stop_speaker_pattern_enabled DB flag is the configuration surface. |
Opening-label leak guard (parseLeadingGenericSpeakerLabel + collectKnownSpeakerNames) |
src/utils/discord/renderModifierParser.ts + src/utils/discord/renderModifierResolver.ts. Internal — always-on response-start companion to the speaker guard; no configuration surface by design (the shapes it fires on are unambiguous leaks). |
Output prefill (context.outputPrefill) |
Internal — NAI-specific hybrid prefix streaming mechanism; not a general extension point. |
Configuration
Section titled “Configuration”| Source | Key / Env var | Default | Purpose |
|---|---|---|---|
TomoriState.config |
llm_stop_speaker_pattern_enabled |
false |
Activates speaker-guard truncation in this stage |
TomoriState.config |
uncensor_unicode_space_enabled |
false |
Replaces Unicode 0x2800 braille blank with regular space in output |
TomoriState.config |
uncensor_sanitize_enabled |
false |
Strips encoded characters that bypass content filters |
StreamConfig |
emojiUsageEnabled |
from TomoriState |
Passed through to cleanLLMOutput; controls custom emoji presence |
| Env var | EMOJI_PRESERVE_UNRESOLVED_SHORTCODES |
false |
When true, unresolved :name: emoji shortcodes are sent as literal text instead of being stripped |
Related docs
Section titled “Related docs”- Stage 05 (produces the segment consumed here): →
05-buffer-management.md - Stage 07 (receives the normalized segment from here): →
07-discord-delivery.md - Mention resolution:
src/utils/discord/stream/mentionResolver.ts - LLM output processor:
src/utils/text/processors/llmOutputProcessor.ts - Markdown table renderer:
src/utils/image/markdownTableRenderer.ts - Emoji penalty:
src/utils/text/emojiPenalty.ts TextProcessingConfigtype:src/types/stream/types.ts:98BufferedDeliveryBoundarytype:src/utils/discord/stream/messageDelivery.ts:15