Skip to content

Persona Presets

Official persona presets are seeded from the typed catalog in src/db/seed/catalog/personas.ts into persona_presets. They are the canonical definitions for bundled characters such as Tomori/Rose, Temari, Aphel, Lilya, and Nerine.

This page covers how official preset text/config content is applied and kept current through the copy-on-write pointer model. Avatar media is applied as a one-time Discord/storage operation and is called out separately below.

Official preset rows carry a stable preset_lineage_id.

  • Reuse the same preset_lineage_id across locale variants of the same character.
  • Do not change a character’s preset_lineage_id just because text, triggers, sample dialogue, or avatar art changed.
  • New official characters need a new stable preset_lineage_id.
  • (preset_lineage_id, preset_language) is the catalog upsert key, so the display name (persona_preset_name) is freely renamable: edit the catalog name field and the existing row is relabeled in place on the next boot. Renaming a character does not need a new lineage or a migration.

preset_lineage_id is related to, but not the same operational concern as personas.persona_lineage_id:

  • preset_lineage_id identifies the official preset family (which character a preset is).
  • persona_lineage_id scopes memory/conditioning identity.
  • Applying a preset usually stamps the preset lineage onto the persona’s persona_lineage_id, so personas derived from the same official character share memory scope.
  • Legacy bootstrapping does not rewrite an existing persona_lineage_id, because that would move memory scope.
  • Import with fork identity can intentionally allocate a fresh persona_lineage_id while still pointing at the same official preset.

Personas can point at live official preset rows instead of storing an independent copy:

  • is_pointer marks a persona as a live preset pointer.
  • preset_lineage_id and preset_language identify the persona_presets row to resolve.
  • persona_lineage_id remains the memory/conditioning identity and is preserved when the pointer materializes.

When is_pointer = true, runtime reads resolve the persona’s preset-backed content from persona_presets:

  • attributes and public flags
  • sample dialogues
  • trigger words
  • persona prompt

The persona row and normalized child rows still carry copied values for compatibility with older surfaces, but live preset data is authoritative while the persona is a pointer.

Trigger words are the one preset-resolved field that is further post-processed: because every preset bundles the shared base word (tomori), loadAllForServer collapses contested words to a single owner after resolving them (main persona wins base words; alters keep only what no higher-priority persona already claimed). See docs/en/architecture/subsystems/multi-persona.md → Single-owner trigger resolution. Message routing uses the de-duped result.

Avatar synchronization depends on the delivery channel:

  • Alter avatars: Delivered per-message as a webhook avatarURL, exactly like sprites, so they live-resolve from one shared image. The seed uploads each preset avatar once to the immutable presets/{lineage}/avatar-{hash}.png prefix and records the URL + content hash on persona_presets.preset_avatar_shared_url / preset_avatar_hash. An unforked pointer alter with a NULL personas.webhook_avatar_url resolves the shared URL at state-load time (PersonaRepository.resolvePointerAlterAvatarUrl), so catalog avatar edits fan out to it on the next reseed; materialization copies it by reference. No per-server upload occurs.
  • Main persona avatars: The bot’s Discord guild member avatar is Discord-owned state that cannot live-resolve per message. A throttled, best-effort, resumable background reconciler fans out changes (see Main avatar fan-out below).

persona_presets.preset_avatar_path remains the local catalog source asset the seed reads to build the shared upload; preset_avatar_shared_url is the runtime-resolved field.

Sprites (unlike avatars) do fan out to pointer personas. A sprite image is fetched on demand by URL at webhook-send time (it never becomes an external Discord resource), so it can be a shared reference resolved live, exactly like attributes/dialogues/triggers.

preset_sprites holds the official sprite set keyed by (preset_lineage_id, preset_language, sprite_key). Each image is uploaded once to the immutable shared presets/{lineage}/sprites/{key}-{hash}.png storage prefix (content-addressed filename), so N servers cost one stored copy. The key carries no language segment: every locale variant of a preset declares the same avatarPath and the same sprite files, so keying by language stored one identical copy per authored locale. The per-language row stays, because sprite_name and usage_instructions genuinely are localized; only the image converges. The catalog authors sprites via the optional PersonaInput.sprites array (image files live under the persona’s avatarPath directory); seedPersonaSpritesFromCatalog() uploads each once (idempotent: same content → same filename → skipped) and reconciles removed sprites. See adding-persona-preset.

Shared preset images remain in storage when catalog art changes or a sprite declaration is removed. The manual bun run sweep-preset-assets command lists GCS, S3, or local preset objects and compares them with references in preset_sprites, persona_presets, personas, and persona_sprites. It prints deletion candidates without deleting them. After reviewing the list, an operator can run it with --apply. The command refuses deletion if a database reference points to a missing object or an object cannot be classified. It keeps every referenced object, including retired per-language art copied into materialized personas, and objects uploaded within the last 24 hours. Cleanup stays outside startup because a failed seed must leave its previous images available to pointer and materialized personas.

The post-ready sprite and avatar seeders share a PostgreSQL advisory lock with the sweep’s --apply pass. The sweep waits for any in-progress seed, then lists objects and reads references again while holding the lock. This prevents it from deleting an older content-addressed object between the seeder’s upload and its database write. Dry runs do not acquire the lock or delete objects.

Production JSONL records preset_art_phase at the start and end of the full seed, sprite seed, avatar seed, and main-avatar fan-out. Each record includes process RSS, heap, external memory, and array buffers; completed phases also record duration. A start without an end identifies the phase interrupted by a process exit. The full-seed marker also covers waiting for the advisory lock.

Resolution is centralized in PersonaSpriteRepository.listForPersona(): for a pointer persona it returns the shared preset_sprites set (shaped as PersonaSpriteRow); for a materialized persona it returns the persona’s own persona_sprites rows. Every downstream consumer (prompt context builder, render-modifier resolver, /config > Persona > Sprites) reads through that one method, so they are pointer-agnostic. Editing the catalog sprite set fans out to all still-pointer personas on the next boot.

That reconcile is deliberately conservative, because these rows are the only record of a usable shared image URL and pointer personas resolve them live:

  • It deletes only keys the catalog no longer declares, and scopes the delete to every key the catalog does declare, including sprites whose upload failed this run. A failed upload is therefore never mistaken for a removed sprite.
  • A preset variant that seeds none of its declared sprites preserves its stored rows and warns instead of reconciling, because a mass upload failure is indistinguishable from a catalog that dropped every key, and the rows are the only surviving reference to images that stay in storage.
  • A preset that declares an empty sprites array preserves its rows and warns, because an empty declaration carries no key to scope a delete to, so its rows cannot be told apart from a wider removal. Omitting the sprites field keeps a preset out of the reconcile, which is how a preset opts out.
  • A sprite name that normalizes to no key at all (only invisible or control characters) contributes nothing to the protected set, because an unnormalized value can never match a stored sprite_key.
  • The seed returns counts (presets, declarations, seeded, failed, removed) and startup prints them. declarations counts each preset variant’s own set, so the same art appears once per authored locale, and presets counts variants rather than lineages. A boot that seeds nothing has to be distinguishable from a healthy one in the log, because otherwise the two look identical.

Because a seed short-circuits on a stored reference that already ends with the expected key, the cheap metadata-refresh path only applies while rows exist. With the rows gone the seeder must upload on every boot, so repopulating them also requires a working storage write path.

The shared presets/ images are immutable and never deleted by per-persona paths: deletePersonaAvatarFromStorage refuses any reference under that prefix (isSharedPresetAssetReference), so one server replacing/removing a sprite, or re-running /persona default, can never delete art other servers rely on. The guard covers both shared asset layouts: sprites (presets/{lineage}/sprites/...) and avatars (presets/{lineage}/avatar-{hash}.png), and it still matches the retired per-language layout that stored rows keep until an environment re-seeds.

Avatar syncing (alter live-resolve + main fan-out)

Section titled “Avatar syncing (alter live-resolve + main fan-out)”

seedPersonaAvatarsFromCatalog() runs right after the sprite seed in the bot’s post-ready background task. It reads each persona’s catalog avatar (PersonaInput.avatarPath), normalizes it to PNG, content-addresses it, uploads it once to presets/{lineage}/avatar-{hash}.png (idempotent: same art → same filename → skipped), and stamps persona_presets.preset_avatar_shared_url + preset_avatar_hash.

  • Alter avatars (live-resolve): A pointer alter stores NULL in personas.webhook_avatar_url; PersonaRepository resolves the shared URL into the cached TomoriState at load time, so every avatar consumer (webhook identity, render-modifier resolver, avatar tool) reads it transparently. The existing pointer-state cache invalidation refreshes it after a reseed, so editing catalog avatar art fans out to all still-pointer alters on the next boot. Materialization copies the shared URL by reference into webhook_avatar_url (no byte duplication; the delete guard still protects it).
  • Main avatars (fan-out reconciler): The bot’s guild member avatar is Discord-owned and cannot live-resolve, so reconcilePresetMainAvatars() (in src/utils/persona/presetAvatarReconciler.ts) PATCHes it per guild. Every PATCH is gated on a real byte change via personas.applied_avatar_hash != persona_presets.preset_avatar_hash, making the run idempotent and resumable: a rate-limited or interrupted run resumes on the next boot, and materialized (non-pointer) mains are skipped automatically. It runs as fire-and-forget background work after clientReady (never blocking startup), processes guilds sequentially with a delay to stay under Discord’s bot-wide global limit, caches each shared image per URL, and backs off on 429. Knobs (all in .env.optional.example): PRESET_AVATAR_SYNC_ENABLED (kill switch, default on), PRESET_AVATAR_SYNC_DELAY_MS, PRESET_AVATAR_SYNC_API_TIMEOUT_MS, PRESET_AVATAR_SYNC_MAX_PER_RUN (0 = unlimited).

persona_presets.preset_attribute_public_flags

Section titled “persona_presets.preset_attribute_public_flags”

A BOOLEAN[] aligned 1:1 with preset_attribute_list. Pointer personas resolve these flags from the live preset row; materialized copies store them in persona_attributes.is_public. Official Tomori presets mark their first appearance-style attribute public; public attributes can be shown in another persona’s participant profile when the persona spoke in visible history, is a co-responder, or is referenced by trigger text. All other seeded attributes are private.

seedPersonasFromCatalog() derives the flags at seed time through the preserved official_attribute_flags update after the persona upsert for lineage IDs 4, 716, 1770, 3585, and 50. Catalog rows do not author the flags.

Applying an official preset creates a copy-on-write pointer when the preset has a preset_lineage_id. The persona follows the live persona_presets row until the first local content edit materializes it into an independent copy.

Preset selectors in both /setup (Starting Settings) and /persona default order the Default Tomori preset first, followed by remaining presets alphabetically.

Setup creates the main persona as a pointer to the selected official preset, stamps persona_lineage_id from the preset lineage, and applies the preset avatar to the bot’s Discord guild avatar when running in a guild.

For the main/default target, /persona default re-points the main persona to the selected official preset and patches the bot’s Discord guild avatar. Re-running it after customization discards local preset-backed content by establishing a fresh pointer. This also resets sprites: the persona’s own persona_sprites rows are dropped (server-owned sprite images are deleted; shared presets/ images are kept) so the persona resolves the official preset sprite set live again.

For the main/default target, re-pointing also resets the avatar: applyPresetPointerToPersona clears webhook_avatar_url (a fresh pointer) and the command deletes the old server-owned image (shared presets/ images are skipped by the delete guard). The guild member avatar is patched immediately for UX, and the main-avatar reconciler keeps it in sync thereafter.

For type=alter, /persona default creates an alter pointer from the preset and leaves personas.webhook_avatar_url NULL: no per-server upload. The alter live-resolves the shared preset avatar (preset_avatar_shared_url) at load time, so N servers share one image and catalog avatar edits fan out on the next reseed.

Preset-application avatar writes for the main persona are one-time operational Discord updates and do not materialize a pointer: /setup, /persona default, and /persona import can establish or preserve the pointer while patching the guild avatar. Direct /server avatar edits are different; setting or resetting a persona avatar is deliberate customization and materializes a pointer before the avatar write.

All main-persona avatar uploads are re-encoded to PNG before the guild-member PATCH, same as alter avatars. Discord returns 200 OK for structurally corrupt image files but stores an unservable asset (the CDN returns 415 and clients silently keep the old avatar), so raw user bytes are never sent as-is.

The first local content edit forks a pointer into an independent copy. Materialization preserves persona_id and persona_lineage_id, copies the current live preset content into personas, persona_attributes, persona_configs, and persona_sprites, then sets is_pointer = false. Preset sprites are copied by reference: the new persona_sprites rows reuse the shared presets/ image URL (no byte duplication), so the immutable-delete guard still protects them. A sprite edit (/persona sprites add|edit|remove|import) forks through this same path, so the user keeps the default sprite set and layers their changes on top. An avatar edit is the exception: once the avatar write succeeds, removePresetSpritesAfterAvatarChange deletes the persona’s sprite rows that still reference shared presets/ images, because the preset character’s expressions would contradict the new face. Sprites the user uploaded are kept, the shared image files are untouched, and /persona default restores the preset set. Main-persona imports already clear every sprite (see Import/export cards), so they need no separate pass. An alter’s avatar is likewise copied by reference: if a forking alter had no avatar of its own (webhook_avatar_url NULL), materialization stamps it with preset_avatar_shared_url so it keeps the same shared image after forking.

The first content edit forks the entire persona. Future seed updates no longer change that persona’s preset-backed content. Re-running /persona default restores the live official preset.

Memory and runtime-state writes do not materialize pointers. Server memories, personal memories, conditioning history, autochat runtime counters, cooldowns, and similar runtime rows continue to use persona_lineage_id/persona_id normally.

Native Tomori preset exports include attribute_public_flags, aligned 1:1 with attribute_list. Older Tomori preset files that do not have this field remain valid; import normalizes them to all-private flags before writing persona_attributes.

Native preset export and import accept persona prompts up to 16003 characters, including the separators the four-part /config persona prompt editor can insert. Other preset strings retain their 5000-character import limit.

Exports materialize pointer personas into self-contained preset files by reading the current live preset content. When the source persona is or was derived from an official preset, export stamps preset_lineage_id in the native Tomori preset payload.

Import re-links to an official preset pointer only when all of these are true:

  • The file has preset_lineage_id.
  • A seeded persona_presets row with that lineage exists.
  • Attributes, public flags, sample dialogues, trigger words, and persona prompt exactly match that official preset.
  • The file does not carry persona-specific NovelAI/custom fields that are not part of official presets.

If any exact-match check fails, import creates an independent copy with is_pointer = false. The imported preset_lineage_id is kept as provenance when present, but preset_language remains null.

Main-persona imports snapshot the current sprite rows before changing the persona. A materialized import deletes those rows after the persona write and uses the rows returned by that delete for best-effort storage cleanup. An import that becomes a preset pointer deletes its persona rows inside the pointer transaction and uses the pre-import snapshot for storage cleanup. A snapshot or row-delete failure is reported as an incomplete import, while a storage-file failure is reported as partial cleanup. Shared presets/ references are skipped.

/persona generate emits the canonical six generated attributes and marks only the generated Appearance attribute public. /persona create emits an explicit all-private flag array because its single freeform description is not guaranteed to be an appearance-only field. SillyTavern card conversion also defaults converted attributes to private because ST cards do not carry Tomori public visibility metadata.

/persona generate accepts an optional uploaded image. The primary model always performs generation, so its provider, key, and codename stay paired for the whole flow. What changes is how the image reaches it:

  • No image: text-only generation, primary model throughout.
  • Image, primary model sees images (tomoriState.llm.sees_images): the image goes to the primary model’s own generation call.
  • Image, primary model cannot see images, a vision model is configured: a separate caption call asks the vision model to describe the avatar, and only that text reaches the primary model’s generation call. The image itself is never sent to the primary model.
  • Image, no usable vision anywhere: generation fails immediately with a message naming the missing capability, unless the PNG carried a card or preset, whose extracted text replaces the need for vision.
  • Image carrying an extracted card or preset: the extracted data only substitutes for vision when no model can see the image. If either model can, the image still wins: the card is a text approximation, while the upload is the character the user actually chose, so the two travel together.

planImageHandling() in the command owns this precedence, and its unit tests are what keep the cases from drifting.

The caption travels in GeneratePresetParams.appearanceDescription, which is a distinct prompt section from existingPresetContext: the prompt labels one as observed appearance and the other as an uploaded card’s data, so the model does not read a caption as structured preset input.

Both this path and AnalyzeImageTool share analyzeImageWithVisionModel() in src/utils/provider/visionCaption.ts, which reconciles resolveCapabilityCredentials against the cached vision_llm row. The vision model may be saved under a different provider than the primary model, each with its own encrypted key, and the reconciliation is what keeps the transport (chosen from the resolved credentials) and the model codename sent to it describing the same model. The primary generation call is unaffected by that pairing problem, because it never leaves the primary model’s own credentials.

The captioning request and its wall-clock ceiling are separate budgets from the chat reply caps (DEFAULT_VISION_CAPTION_MAX_OUTPUT_TOKENS, VISION_CAPTION_TIMEOUT_MS), because shortening a chat reply must not truncate the description a persona is generated from.

The /persona generate and /persona create success messages are rendered as Discord Components V2 containers (buildPersonaResultContainer in src/utils/discord/ui/interactionCore.ts) rather than classic embeds: the generated PNG is surfaced through a single-item MediaGallery, the old embed color maps to the container accentColor, and an action button can live inside the same card.

/persona generate also renders its processing state and post-processing error states as Components V2. This keeps the original interaction reply in one message mode for its full lifecycle; Discord rejects edits that combine MessageFlags.IsComponentsV2 with legacy embeds or content, and an earlier processing embed can otherwise make the final V2 success edit fail. When an error response preserves preset_generation_input.txt, the file is referenced through a Components V2 File component.

In guild contexts that card carries a manager-only Import Now button. Because the success message is public, the restriction is enforced on click (ManageGuild), not by visibility. Pressing it imports the freshly generated persona as an alter (it never replaces the existing main persona) using the same importAlterPreset core (src/utils/persona/importAlterPreset.ts) that backs /persona import type=alter. The in-memory preset and PNG buffer are reused, so no re-upload or re-download occurs. Wiring lives in src/utils/persona/importNowButton.ts.

The button is one-shot (a successful import disables it and relabels it “Imported”; a failure re-arms it) and driven by a per-message collector; DMs never receive it because alter import is guild-only. Collector lifetime is capped below Discord’s 15-minute interaction-token window so the timeout teardown can still edit the original reply (IMPORT_NOW_BUTTON_TIMEOUT_MS, 14 minutes).

Editing seeded text/config fields in src/db/seed/catalog/personas/** changes live pointer personas and future applications after the next startup seed. Editing a persona’s avatar art also fans out: the post-ready avatar seed uploads the changed image (new content hash → new preset_avatar_shared_url and preset_avatar_hash), invalidates pointer state caches, and then the main-avatar reconciler re-PATCHes each still-pointer main persona’s guild avatar (gated on the hash change). Pointer alters read the new shared URL after cache invalidation. Independent (materialized) copies do not change.

System prompt presets are authored separately in src/db/seed/catalog/systemPrompts.ts and seeded by seedSystemPromptsFromCatalog() immediately after persona presets.

The previous seed-time 3-way rebase sync design has been removed. It used a per-persona baseline and attempted to merge official updates into local copies, but it was fragile and never shipped to production.

Migration 020_persona_preset_pointers.sql adds the pointer columns and converts only exact matches into pointers. The original production pass was too narrow for older setup rows because many of them used generated persona_lineage_id values rather than the official preset_lineage_id, so they stayed independent copies.

Migration 026_repair_legacy_persona_preset_pointers.sql is the follow-up repair pass. It recognizes known official preset copy fingerprints from historical seed shapes, including the legacy form that stored "{bot}'s Description: ..." as the first attribute, and marks exact copies as pointers even when their memory lineage is custom/generated. It preserves persona_lineage_id so existing memories and conditioning stay in their original scope, and it does not rewrite avatars, nicknames, or copied child rows. Customized personas that do not match an official historical fingerprint stay independent copies.

If a pointer references a missing official preset row, materialization fails closed by logging an error and refusing the fork. Runtime reads are more resilient: they log a warning and fall back to the persona’s last copied snapshot instead of failing the whole state load.

Every official language variant declares prefix, suffix, and standalone address-term maps for masculine, feminine, and neutral addressing styles. Empty maps are explicit and valid. A gendered address term requires a neutral fallback, and authored {user_term} content also requires a neutral term. {user_formatted} is used for user-vocative names in samples.

Pointer personas read preset_naming_config live. /config > Persona > Identity & Personality materializes a pointer before saving a custom persona_naming_configs row, so other servers remain on the shared preset. Export emits the naming map; older imports default it to empty. Official-preset matching includes the map, preventing a customized persona from collapsing back into a pointer.