System prompt assembly¶
web/src/lib/prompt.ts is the layer that turns a character + persona + history + new user text into the outbound messages[] array that goes to the LLM.
The entry point¶
assembleMessages({
character, // Tavern card (or null for direct-LLM chat)
userName, // global display name
persona, // optional Persona
history, // previous messages: { role, content }[]
newUserText, // the user's freshly composed turn
}, samplerProfile)
Returns OutboundMessage[] ready to ship to /api/chat.
Two modes inside:
| Mode | Triggered when |
|---|---|
| Preset | The active sampler profile carries prompts[] + prompt_order[] (a SillyTavern preset). |
| Fallback | No preset, so synthesise a default frame from the card fields. |
Preset mode¶
ST presets ship as a JSON describing:
prompts[]— every named prompt block (charDescription,scenario,dialogueExamples,jailbreak,personaDescription, plus user-defined ones).prompt_order[]— the order to emit them, withchatHistoryas a sentinel marker.
assembleViaPreset():
- Picks the active
prompt_order(uses the default-character-id100000entry if present, else the first). - Walks the order. For each entry that's enabled:
- If it's the
chatHistorymarker, flush the system-buffer and switch to "after history" mode. - Else look up the prompt by id.
- If the prompt is a marker (
charDescription,scenario, …), resolve from the active card. - Else use the prompt's literal
content.
- If it's the
- Concatenates the before-history blocks into a single
role: systemmessage, emits the history, concatenates the after-history blocks into a trailingrole: systemmessage, appends the new user turn.
Marker resolutions:
| Marker | Resolved to |
|---|---|
charDescription |
card.data.description |
charPersonality |
card.data.personality |
scenario |
card.data.scenario |
dialogueExamples |
card.data.mes_example |
personaDescription |
"The user's name is <name>." [+ persona description] |
worldInfoBefore / worldInfoAfter / enhanceDefinitions |
Empty (lorebooks not wired yet; tracked in smelt-vd8) |
Fallback mode¶
No preset selected. assembleFallback() synthesises:
- Persona anchor (when a persona is attached):
# The user / The user's name is X. [+ description]. - Legacy system prompt from the card: either the card's own
system_promptif set, or a synthesised composition of description + personality + scenario + example dialogue + a formatting hint. - History, with
{{user}}/{{char}}substituted per message. - Post-history instructions if the card has them (as a trailing
role: system). - New user message, substituted.
The formatting hint added to legacy fallback:
# Formatting
Wrap spoken dialogue in straight double-quotes: "like this".
Wrap actions, gestures, thoughts, and narration in asterisks: *like this*.
Use plain text for everything else. Do not use code blocks unless quoting code.
This is the convention the chat-render layer (md.ts) recognises with its custom speech token.
{{user}} and {{char}} substitution¶
substitute(text, userName, charName) replaces both {{user}} / {{User}} and {{char}} / {{Char}} (case-preserving in the literal markup, not the substitution).
userName comes from resolveIdentity():
- Persona attached →
persona.name(oruserNameif empty) - No persona →
userName
charName comes from card.data.name (or empty for direct-LLM chat).
Context-size trimming¶
When the active sampler profile sets context_size, trimToBudget() drops oldest-non-sticky messages until the total estimated tokens fit. Sticky messages:
- Everything with
role: system - The last message (the user's freshly composed turn)
Token estimate is len(content) / 4 + 4 per message — coarse but conservative for English. Underestimates CJK and code; that's acceptable because the trim is a safety net, not a precise allocation.
Mid-chat persona swaps¶
The persona anchor is in the system prompt, not the chat history. Switching the persona mid-chat changes what the next outbound messages[] carries; the history (which references old {{user}} substitutions in card prose) stays. Because the anchor is explicit ("The user's name is now Alice"), the model reliably switches form of address even when the prior history was full of "you".
This is the fix landed in prompt.ts 5cn — earlier behaviour only emitted the persona anchor when the persona carried a description, so name-only personas didn't anchor the form of address.