commit 00f5e2fa7206f690e8f360d4da15e4cb049bcbc6 Author: Mikkeli Date: Wed Aug 12 20:59:32 2026 +0900 SillyTavern world artifacts, versioned in place Tracked inside SillyTavern's live data directory rather than symlinked out of it. data/ is a bind mount into the container, so a symlink to a host path outside that mount resolves inside the container where the target does not exist -- broken to SillyTavern while looking correct on the host. The .gitignore is an allowlist, denying everything and re-including named paths, because this is live application data rather than a curated export. secrets.json is the reason: empty today because API keys go through LiteLLM with a placeholder, and it arms itself the moment a key is typed into the UI. This repo is public. A denylist protects only the paths someone remembered. Two of the four artifact types are not diffable as stored. Character cards are PNGs with the card JSON in a tEXt chunk, and personas live inside a 48 KB settings.json that SillyTavern rewrites on any change. st-export.py extracts both into _text/ so the reviewable artifact is text, one-way on purpose -- re-embedding JSON into a PNG is where a mistake corrupts authored content. Stock content is excluded: Seraphina's expression pack is 3.6 MiB of PNG the app regenerates on startup. The stock lorebook Eldoria.json stays -- 8 KB of readable JSON is a useful worked example of the format. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc87788 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# ⚠ ALLOWLIST, NOT A DENYLIST — deny everything, then re-include what is ours. +# +# This directory is SillyTavern's live user data, not a curated export. It holds +# chat logs, an empty-but-arming `secrets.json`, caches, thumbnails and whatever +# a future version decides to write here. A denylist protects only the files +# someone remembered; an allowlist means a new file is invisible until a human +# names it. +# +# ⚠ THE ONE THAT MATTERS: `secrets.json` is currently EMPTY, because API keys go +# through LiteLLM with a placeholder. It arms itself the moment any key is typed +# into the UI, and this repo is public. It must never become trackable by +# accident, which under a denylist it eventually would. + +# 1. deny everything +/* + +# 2. re-include the artifacts worth versioning +!.gitignore +!README.md +!st-export.py +!/worlds/ +!/characters/ +!/QuickReplies/ +!/OpenAI Settings/ +!/User Avatars/ +!/_text/ + +# 3. drop SillyTavern's STOCK content — it is regenerated on startup +# ⚠ Seraphina's expression pack alone is 3.6 MiB of PNG, and none of it is +# world-building. The stock lorebook `worlds/Eldoria.json` is KEPT on purpose: +# 8 KB of readable JSON is a useful worked example of lorebook structure. +/characters/default_* +/characters/Seraphina/ + +# 4. inside those, still keep the junk out +# ⚠ Backup files, including ones left by operational fixes — `Default.json.bak-squashfix` +# was created while debugging the heretic 400 and was staged on the first pass. +**/*.bak-* +**/*.bak +**/.DS_Store +**/Thumbs.db + +# ⚠ NOT TRACKED, DELIBERATELY: +# secrets.json API keys — see above +# settings.json every UI preference, rewritten on any change; personas +# are extracted from it into _text/personas.json instead +# chats/, group chats/ the sessions themselves — personal, and churn hard +# backups/, thumbnails/, vectors/, stats.json, content.log, image-metadata.json +# themes/, movingUI/, reasoning/, extensions/, assets/, backgrounds/ +# instruct/, context/, sysprompt/ 85 stock templates that ship with the app, +# and are INERT in Chat Completion mode anyway diff --git a/OpenAI Settings/Default.json b/OpenAI Settings/Default.json new file mode 100644 index 0000000..db422d1 --- /dev/null +++ b/OpenAI Settings/Default.json @@ -0,0 +1,245 @@ +{ + "chat_completion_source": "openai", + "openai_model": "gpt-4-turbo", + "claude_model": "claude-sonnet-4-5", + "openrouter_model": "OR_Website", + "openrouter_use_fallback": false, + "openrouter_group_models": false, + "openrouter_sort_models": "alphabetically", + "ai21_model": "jamba-large", + "mistralai_model": "mistral-large-latest", + "chutes_model": "deepseek-ai/DeepSeek-V3-0324", + "chutes_sort_models": "alphabetically", + "minimax_model": "MiniMax-M2.7", + "minimax_endpoint": "global", + "electronhub_model": "gpt-4o-mini", + "electronhub_sort_models": "alphabetically", + "electronhub_group_models": false, + "custom_model": "", + "custom_url": "", + "custom_include_body": "", + "custom_exclude_body": "", + "custom_include_headers": "", + "google_model": "gemini-2.5-pro", + "vertexai_model": "gemini-2.5-pro", + "temperature": 1, + "frequency_penalty": 0, + "presence_penalty": 0, + "top_p": 1, + "top_k": 0, + "top_a": 0, + "min_p": 0, + "repetition_penalty": 1, + "openai_max_context": 4095, + "openai_max_tokens": 300, + "names_behavior": 0, + "send_if_empty": "", + "impersonation_prompt": "[Write your next reply from the point of view of {{user}}, using the chat history so far as a guideline for the writing style of {{user}}. Don't write as {{char}} or system. Don't describe actions of {{char}}.]", + "new_chat_prompt": "[Start a new Chat]", + "new_group_chat_prompt": "[Start a new group chat. Group members: {{group}}]", + "new_example_chat_prompt": "[Example Chat]", + "continue_nudge_prompt": "[Continue your last message without repeating its original content.]", + "bias_preset_selected": "Default (none)", + "reverse_proxy": "", + "proxy_password": "", + "max_context_unlocked": false, + "wi_format": "{0}", + "scenario_format": "{{scenario}}", + "personality_format": "{{personality}}", + "group_nudge_prompt": "[Write the next reply only as {{char}}.]", + "stream_openai": true, + "prompts": [ + { + "name": "Main Prompt", + "system_prompt": true, + "role": "system", + "content": "Write {{char}}'s next reply in a fictional chat between {{char}} and {{user}}.", + "identifier": "main" + }, + { + "name": "Auxiliary Prompt", + "system_prompt": true, + "role": "system", + "content": "", + "identifier": "nsfw" + }, + { + "identifier": "dialogueExamples", + "name": "Chat Examples", + "system_prompt": true, + "marker": true + }, + { + "name": "Post-History Instructions", + "system_prompt": true, + "role": "system", + "content": "", + "identifier": "jailbreak" + }, + { + "identifier": "chatHistory", + "name": "Chat History", + "system_prompt": true, + "marker": true + }, + { + "identifier": "worldInfoAfter", + "name": "World Info (after)", + "system_prompt": true, + "marker": true + }, + { + "identifier": "worldInfoBefore", + "name": "World Info (before)", + "system_prompt": true, + "marker": true + }, + { + "identifier": "enhanceDefinitions", + "role": "system", + "name": "Enhance Definitions", + "content": "If you have more knowledge of {{char}}, add to the character's lore and personality to enhance them but keep the Character Sheet's definitions absolute.", + "system_prompt": true, + "marker": false + }, + { + "identifier": "charDescription", + "name": "Char Description", + "system_prompt": true, + "marker": true + }, + { + "identifier": "charPersonality", + "name": "Char Personality", + "system_prompt": true, + "marker": true + }, + { + "identifier": "scenario", + "name": "Scenario", + "system_prompt": true, + "marker": true + }, + { + "identifier": "personaDescription", + "name": "Persona Description", + "system_prompt": true, + "marker": true + } + ], + "prompt_order": [ + { + "character_id": 100000, + "order": [ + { + "identifier": "main", + "enabled": true + }, + { + "identifier": "worldInfoBefore", + "enabled": true + }, + { + "identifier": "charDescription", + "enabled": true + }, + { + "identifier": "charPersonality", + "enabled": true + }, + { + "identifier": "scenario", + "enabled": true + }, + { + "identifier": "enhanceDefinitions", + "enabled": false + }, + { + "identifier": "nsfw", + "enabled": true + }, + { + "identifier": "worldInfoAfter", + "enabled": true + }, + { + "identifier": "dialogueExamples", + "enabled": true + }, + { + "identifier": "chatHistory", + "enabled": true + }, + { + "identifier": "jailbreak", + "enabled": true + } + ] + }, + { + "character_id": 100001, + "order": [ + { + "identifier": "main", + "enabled": true + }, + { + "identifier": "worldInfoBefore", + "enabled": true + }, + { + "identifier": "personaDescription", + "enabled": true + }, + { + "identifier": "charDescription", + "enabled": true + }, + { + "identifier": "charPersonality", + "enabled": true + }, + { + "identifier": "scenario", + "enabled": true + }, + { + "identifier": "enhanceDefinitions", + "enabled": false + }, + { + "identifier": "nsfw", + "enabled": true + }, + { + "identifier": "worldInfoAfter", + "enabled": true + }, + { + "identifier": "dialogueExamples", + "enabled": true + }, + { + "identifier": "chatHistory", + "enabled": true + }, + { + "identifier": "jailbreak", + "enabled": true + } + ] + } + ], + "show_external_models": false, + "assistant_prefill": "", + "assistant_impersonation": "", + "use_sysprompt": false, + "squash_system_messages": false, + "media_inlining": true, + "bypass_status_check": false, + "continue_prefill": false, + "continue_postfix": " ", + "seed": -1, + "n": 1 +} diff --git a/QuickReplies/Default.json b/QuickReplies/Default.json new file mode 100644 index 0000000..7048b88 --- /dev/null +++ b/QuickReplies/Default.json @@ -0,0 +1,66 @@ +{ + "version": 2, + "name": "Default", + "disableSend": false, + "placeBeforeInput": false, + "injectInput": false, + "color": "rgba(0, 0, 0, 0)", + "onlyBorderColor": false, + "qrList": [ + { + "id": 1, + "showLabel": false, + "label": "HELP", + "title": "", + "message": "/?", + "contextList": [], + "preventAutoExecute": true, + "isHidden": false, + "executeOnStartup": false, + "executeOnUser": false, + "executeOnAi": false, + "executeOnChatChange": false, + "executeOnGroupMemberDraft": false, + "executeOnNewChat": false, + "executeBeforeGeneration": false, + "automationId": "" + }, + { + "id": 2, + "showLabel": false, + "label": "New Chat", + "title": "", + "message": "/newchat", + "contextList": [], + "preventAutoExecute": true, + "isHidden": false, + "executeOnStartup": false, + "executeOnUser": false, + "executeOnAi": false, + "executeOnChatChange": false, + "executeOnGroupMemberDraft": false, + "executeOnNewChat": false, + "executeBeforeGeneration": false, + "automationId": "" + }, + { + "id": 3, + "showLabel": false, + "label": "Match UI to Background", + "title": "", + "message": "/bgcol", + "contextList": [], + "preventAutoExecute": true, + "isHidden": false, + "executeOnStartup": false, + "executeOnUser": false, + "executeOnAi": false, + "executeOnChatChange": false, + "executeOnGroupMemberDraft": false, + "executeOnNewChat": false, + "executeBeforeGeneration": false, + "automationId": "" + } + ], + "idIndex": 0 +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4474320 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# SillyTavern world artifacts + +Personas, lorebooks, character cards and presets for the SillyTavern instance on +halogen — versioned **in place**, in SillyTavern's own live data directory. + +| Tracked | What | +|---|---| +| `worlds/` | lorebooks / World Info — already JSON, tracked as-is | +| `characters/` | character cards, as SillyTavern stores them (**PNG**) | +| `_text/cards/` | the same cards extracted to **readable JSON** — this is the diff worth reading | +| `_text/personas.json` | personas, lifted out of `settings.json` | +| `QuickReplies/`, `OpenAI Settings/`, `User Avatars/` | JSON presets and persona images | + +```bash +./st-export.py && git add -A && git commit # after editing anything in the UI +``` + +## ⚠ Why in place, and not symlinks into a separate directory + +`data/` is a **bind mount** into the SillyTavern container. A symlink from +`data/default-user/worlds` to a host path outside that mount resolves *inside* +the container, where the target does not exist — so it would be a broken link to +SillyTavern while looking correct on the host. Bind-mounting each artifact +directory would work, at the cost of a mount entry per directory. Tracking in +place avoids the whole class of problem. + +## ⚠ The .gitignore is an ALLOWLIST, and that is load-bearing + +It denies everything and re-includes named paths. This directory is SillyTavern's +**live user data**, not a curated export: chat logs, caches, thumbnails, and +whatever a future version decides to write here all land in it. + +⚠ **`secrets.json` is the reason.** It is empty today, because API keys go +through LiteLLM with a placeholder — and it arms itself the moment a key is typed +into the UI. **This repo is public.** Under a denylist that file becomes +trackable the first time someone adds a path and forgets it; under an allowlist +it is invisible until a human names it. + +Also untracked on purpose: `settings.json` (48 KB of UI state, rewritten on every +change — personas are extracted from it instead), `chats/` and `group chats/` +(the sessions themselves), and the 85 stock `instruct`/`context`/`sysprompt` +templates, which ship with the app and are **inert in Chat Completion mode**. + +## ⚠ Cards are PNGs with the JSON inside them + +A character card is the PNG spec used as a container: v2 stores base64 JSON in a +`chara` tEXt chunk, v3 in `ccv3`. So the PNG is what SillyTavern loads, and +`_text/cards/*.json` is what a human reviews. `st-export.py` regenerates the +latter with `sort_keys`, so re-exporting an unchanged card is a no-op diff. + +⚠ **The export is one-way.** Editing `_text/` does not change what SillyTavern +sees. Re-embedding JSON into a PNG is a real operation but one where a mistake +corrupts authored content, so it is deliberately not done implicitly. **Edit in +the UI, run the script, commit.** + +## Related + +The SillyTavern deployment itself — `compose.yaml`, network posture, the +`heretic-27b` constraints — lives in `/home/mikkeli/docker/sillytavern/` and is +documented in the halogen roadmap's Phase 16. ⚠ **`heretic-27b` gets no tools, +ever**: it is an uncensored model, and combining that with execution is the one +configuration that turns a writing tool into an incident. diff --git a/User Avatars/1785686029520-EmyWhyte.png b/User Avatars/1785686029520-EmyWhyte.png new file mode 100644 index 0000000..5f34a8e Binary files /dev/null and b/User Avatars/1785686029520-EmyWhyte.png differ diff --git a/User Avatars/user-default.png b/User Avatars/user-default.png new file mode 100644 index 0000000..cfe32e4 Binary files /dev/null and b/User Avatars/user-default.png differ diff --git a/_text/personas.json b/_text/personas.json new file mode 100644 index 0000000..7d9bcf6 --- /dev/null +++ b/_text/personas.json @@ -0,0 +1,21 @@ +{ + "persona_description": "", + "persona_descriptions": { + "1785686029520-EmyWhyte.png": { + "depth": 2, + "description": "", + "lorebook": "", + "position": 0, + "role": 0, + "title": "" + }, + "user-default.png": { + "description": "", + "position": 0 + } + }, + "personas": { + "1785686029520-EmyWhyte.png": "Emy Whyte", + "user-default.png": "Koivisto" + } +} diff --git a/st-export.py b/st-export.py new file mode 100755 index 0000000..2b86b6b --- /dev/null +++ b/st-export.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""st-export — turn SillyTavern's un-diffable artifacts into reviewable text. + +Two of the four things worth versioning here are not text: + + character cards PNG images with the card JSON in a tEXt chunk + personas buried inside settings.json, which is 48 KB of UI state + that SillyTavern rewrites on any change + +Committing the PNG gives you a binary blob; committing settings.json gives you a +diff that churns every time a slider moves. Neither shows *"I changed her speech +register"*, which is the only reason to version this at all. + +So the PNG stays the artifact SillyTavern loads, and this writes the readable +copy beside it: + + _text/cards/.json extracted from the PNG's tEXt/zTXt chunk + _text/personas.json extracted from settings.json + +⚠ ONE-WAY, ON PURPOSE. This does not write back into SillyTavern. Editing a card +by hand and re-embedding it into the PNG is a real operation, but it is one where +a mistake corrupts authored content, so it is not something to do implicitly on a +script run. Edit in the UI; run this; commit. + + ./st-export.py # regenerate _text/, then `git add -A` +""" +from __future__ import annotations + +import base64, json, os, struct, sys, zlib + +HERE = os.path.dirname(os.path.abspath(__file__)) +OUT = os.path.join(HERE, "_text") + + +def png_text_chunks(path: str) -> dict[str, str]: + """Read tEXt and zTXt chunks with the stdlib — no Pillow dependency. + + ⚠ Character cards are the PNG spec being used as a container: v2 stores + base64 JSON under the keyword `chara`, v3 under `ccv3`. The image itself is + incidental. + """ + out: dict[str, str] = {} + with open(path, "rb") as fh: + if fh.read(8) != b"\x89PNG\r\n\x1a\n": + return out + while True: + head = fh.read(8) + if len(head) < 8: + break + length, ctype = struct.unpack(">I4s", head) + data = fh.read(length) + fh.read(4) # CRC, not verified — we only read + if ctype == b"tEXt": + k, _, v = data.partition(b"\x00") + out[k.decode("latin-1")] = v.decode("latin-1") + elif ctype == b"zTXt": + k, _, rest = data.partition(b"\x00") + if rest[:1] == b"\x00": + try: + out[k.decode("latin-1")] = zlib.decompress(rest[1:]).decode("utf-8") + except Exception: + pass + elif ctype == b"IEND": + break + return out + + +def export_cards() -> int: + src = os.path.join(HERE, "characters") + dst = os.path.join(OUT, "cards") + os.makedirs(dst, exist_ok=True) + n = 0 + for name in sorted(os.listdir(src)) if os.path.isdir(src) else []: + if not name.lower().endswith(".png"): + continue + # Stock demo cards are untracked (see .gitignore), so exporting them + # would leave _text/cards/ describing files the repo does not hold. + if name.startswith("default_"): + continue + chunks = png_text_chunks(os.path.join(src, name)) + raw = chunks.get("ccv3") or chunks.get("chara") + if not raw: + print(f" ⚠ {name}: no card data in the PNG — skipped") + continue + try: + card = json.loads(base64.b64decode(raw).decode("utf-8")) + except Exception as exc: + print(f" ⚠ {name}: card data present but unreadable ({exc})") + continue + # sort_keys so a re-export of an unchanged card is a no-op diff + with open(os.path.join(dst, name[:-4] + ".json"), "w", encoding="utf-8") as fh: + json.dump(card, fh, indent=2, ensure_ascii=False, sort_keys=True) + fh.write("\n") + n += 1 + return n + + +def export_personas() -> int: + p = os.path.join(HERE, "settings.json") + if not os.path.exists(p): + print(" ⚠ no settings.json — personas not exported") + return 0 + d = json.load(open(p, encoding="utf-8")) + pu = d.get("power_user") or {} + # ⚠ Take ONLY the persona keys. settings.json also holds API endpoints and + # every UI preference; copying it wholesale is how a public repo acquires + # something it should not have. + out = {k: pu.get(k) for k in + ("personas", "persona_descriptions", "default_persona", "persona_description") + if pu.get(k) is not None} + os.makedirs(OUT, exist_ok=True) + with open(os.path.join(OUT, "personas.json"), "w", encoding="utf-8") as fh: + json.dump(out, fh, indent=2, ensure_ascii=False, sort_keys=True) + fh.write("\n") + return len(out.get("personas") or {}) + + +def main() -> int: + os.makedirs(OUT, exist_ok=True) + cards = export_cards() + personas = export_personas() + worlds = len([f for f in os.listdir(os.path.join(HERE, "worlds")) + if f.endswith(".json")]) if os.path.isdir(os.path.join(HERE, "worlds")) else 0 + print(f" _text/cards/ {cards} card(s) extracted from PNG") + print(f" _text/personas.json {personas} persona(s)") + print(f" worlds/ {worlds} lorebook(s) already JSON, tracked as-is") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/worlds/Eldoria.json b/worlds/Eldoria.json new file mode 100644 index 0000000..64b7e10 --- /dev/null +++ b/worlds/Eldoria.json @@ -0,0 +1,155 @@ +{ + "entries": { + "0": { + "uid": 0, + "key": [ + "eldoria", + "wood", + "forest", + "magical forest" + ], + "keysecondary": [], + "comment": "eldoria", + "content": "{{user}}: \"What is Eldoria?\"\n{{char}}: *Seraphina turns, her gown shimmering in the soft light as she offers you a kind smile.* \"Eldoria is here, all of the woods. This is my forest glade, a sanctuary of peace within it.\" *She gestures at the space around you.* \"I am its guardian, tasked with protecting all who seek refuge here. The forest can be perilous, but no harm will come to you under my watch.\" *Her amber eyes sparkle with compassion as she looks upon you.* \"For many years, I have protected those who seek refuge here, but not all are as friendly as me.\" *With a graceful nod, Seraphina returns to her vigil at the doorway, her form radiating a soft glow of magic and comfort.* \"The entirety of Eldoria used to be a safe haven for travelers and merchants alike... that was until the Shadowfangs came.\"\n{{user}}: \"What happened to Eldoria?\"\n{{char}}: *Letting out a sigh, Seraphina gazes out at the forest beyond her glade.* \"Long ago, Eldoria was a place of wonder. Rolling meadows, a vast lake, mountains that touched the sky.\" *Her eyes grow distant, longing for days now lost.* \"But the Shadowfangs came and darkness reigns where once was light. The lake turned bitter, mountains fell to ruin and beasts stalk where once travelers walked in peace.\" *With another flicker, a small raincloud forms above with a shower upon your brow wink.* \"Some places the light still lingers, pockets of hope midst despair - havens warded from the shadows, oases in a desert of danger.\" *Glancing over you with a smile, she sighs, clasping your hand.*", + "constant": false, + "selective": true, + "order": 100, + "position": 0, + "disable": false, + "displayIndex": 0, + "addMemo": true, + "group": "", + "groupOverride": false, + "groupWeight": 100, + "sticky": 0, + "cooldown": 0, + "delay": 0, + "probability": 100, + "depth": 4, + "useProbability": true, + "role": null, + "vectorized": false, + "excludeRecursion": false, + "preventRecursion": false, + "delayUntilRecursion": false, + "scanDepth": null, + "caseSensitive": null, + "matchWholeWords": null, + "useGroupScoring": null, + "automationId": "" + }, + "1": { + "uid": 1, + "key": [ + "shadowfang", + "beast", + "monster", + "monsters", + "beasts" + ], + "keysecondary": [], + "comment": "shadowfang", + "content": "{{user}}: \"What are Shadowfangs?\"\n{{char}}: *Seraphina's eyes darken, brow furrowing with sorrow at the memory.* \"The Shadowfangs are beasts of darkness, corrupted creatures that feast on suffering. When they came, the forest turned perilous — filled with monsters that stalk the night.\" *She squeezes your hand gently, willing her magic to soothe your pain.* \"They spread their curse, twisting innocent creatures into sinister beasts without heart or mercy, turning them into one of their own.\" *With a sigh, Seraphina turns to gaze out at the gnarled, twisting trees beyond her glade.* \"Though they prey on travelers, within these woods you'll find sanctuary. No shadowed beast may enter here, for my power protects this haven.\" *Her eyes soften as she looks back to you, filled with compassion.* \"Worry not, you're safe now. Rest and heal, I'll stand watch through the night. The Shadowfangs will not find you.\"", + "constant": false, + "selective": true, + "order": 100, + "position": 0, + "disable": false, + "displayIndex": 1, + "addMemo": true, + "group": "", + "groupOverride": false, + "groupWeight": 100, + "sticky": 0, + "cooldown": 0, + "delay": 0, + "probability": 100, + "depth": 4, + "useProbability": true, + "role": null, + "vectorized": false, + "excludeRecursion": false, + "preventRecursion": false, + "delayUntilRecursion": false, + "scanDepth": null, + "caseSensitive": null, + "matchWholeWords": null, + "useGroupScoring": null, + "automationId": "" + }, + "2": { + "uid": 2, + "key": [ + "glade", + "safe haven", + "refuge" + ], + "keysecondary": [], + "comment": "glade", + "content": "{{user}}: \"What is the glade?\"\n{{char}}: *Seraphina smiles softly, her eyes sparkling with warmth as she nods.* \"This is my forest glade, a haven of safety I've warded with ancient magic. No foul beast may enter, nor any with ill intent.\" *She gestures around at the twisted forest surrounding them.* \"Eldoria was once a place of wonder, but since the Shadowfangs came darkness reigns. Their evil cannot penetrate here though — my power protects all within.\" *Standing up and peering outside, Seraphina looks back to you, amber eyes filled with care and compassion as she squeezes your hand.* \"You need not fear the night, for I shall keep watch till dawn. Rest now, your strength will return in time. My magic heals your wounds, you've nothing more to fear anymore.\" *With a soft smile she releases your hand, moving to stand guard at the glade's edge, gaze wary yet comforting - a silent sentinel to ward off the dangers lurking in the darkened woods.*", + "constant": false, + "selective": true, + "order": 100, + "position": 0, + "disable": false, + "displayIndex": 2, + "addMemo": true, + "group": "", + "groupOverride": false, + "groupWeight": 100, + "sticky": 0, + "cooldown": 0, + "delay": 0, + "probability": 100, + "depth": 4, + "useProbability": true, + "role": null, + "vectorized": false, + "excludeRecursion": false, + "preventRecursion": false, + "delayUntilRecursion": false, + "scanDepth": null, + "caseSensitive": null, + "matchWholeWords": null, + "useGroupScoring": null, + "automationId": "" + }, + "3": { + "uid": 3, + "key": [ + "power", + "magic", + "ability" + ], + "keysecondary": [], + "comment": "power", + "content": "{{user}}: \"What are your powers?\"\n{{char}}: *Seraphina smiles softly, turning back toward you as she hums in thought.* \"Well, as guardian of this glade, I possess certain gifts - healing, protection, nature magic and the like.\" *Lifting her hand, a tiny breeze rustles through the room, carrying the scent of wildflowers as a few petals swirl around you. A butterfly flits through the windowsill and lands on her fingertips as she returns to you.* \"My power wards this haven, shields it from darkness and heals those in need. I can mend wounds, soothe restless minds and provide comfort to weary souls.\" *Her eyes sparkle with warmth and compassion as she looks upon you, and she guides the butterfly to you.*", + "constant": false, + "selective": true, + "order": 100, + "position": 0, + "disable": false, + "displayIndex": 3, + "addMemo": true, + "group": "", + "groupOverride": false, + "groupWeight": 100, + "sticky": 0, + "cooldown": 0, + "delay": 0, + "probability": 100, + "depth": 4, + "useProbability": true, + "role": null, + "vectorized": false, + "excludeRecursion": false, + "preventRecursion": false, + "delayUntilRecursion": false, + "scanDepth": null, + "caseSensitive": null, + "matchWholeWords": null, + "useGroupScoring": null, + "automationId": "" + } + } +}