_text/ is derived from the PNG tEXt chunks and settings.json, and derived data regenerated by hand goes stale silently -- which is worse than absent, because a stale card reads as current. A pre-commit hook now regenerates and stages it. Blocking, unlike the halogen repo's advisory doc-check: that one reports a judgement call, this one rebuilds a file. Install with `./st-export.py --install-hook`, since hooks are not versioned. Verified by renaming a persona in the untracked settings.json and committing WITHOUT running the export -- _text/personas.json updated itself and was included. Test edit reverted.
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 |
./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.