Expansions — architecture
How data/expansions/ is structured after the 2026-06-21 modernization that brought it to
parity with the keyword-list system (see list-architecture.md).
Only the parts that make sense for expansions were ported. SFW/NSFW file splitting was left out
(snippets are hand-authored). Folder pick-one groups were initially skipped but added in 2.5.0
(<folder> splices one random expansion — see below); list-style line unions are still not a thing.
What an expansion is
An expansion is a data/expansions/<name>.txt file referenced in a prompt as <name>.
The expansion stage (src/core/stages/expansion.js)
splices the file's text in verbatim, recursively (up to 10 passes) and LoRA-safe (it masks
<lora:…> so it is never read as an expansion). The spliced text may itself contain #dynamic
prompts and {lists}, which then expand — so an expansion can still introduce randomness, but
the snippet itself is fixed.
The engine is loader-injected: the only active expansion code is the core stage, which calls
loader.readExpansion(name). (src/prompt-modules/expansion.js is dead reference code — not
imported anywhere.) So all of the resolution below lives in the two loaders
(nodeLoader.js fs, browserLoader.js Vite glob).
Folder nesting + path-suffix resolution (ported from lists)
Expansions now nest into category folders (detail/, style/, lighting/, subject/,
scene/) purely for organization. References resolve by path suffix via the shared
resolveName() from listManifest.js — exact path wins, else the shallowest name ending
/<ref>, ties broken by compareNames(). Basenames were kept unique during the move, so every
pre-existing <rays> / <legacy-detail> reference in the dynamic prompts still resolves with no
edits. Discovery is recursive in both loaders (namesUnder() walk in Node; **/*.txt glob in
the browser), and _-prefixed files are skipped (the same internal/config convention as lists).
Description sidecars + tooltips (ported from lists)
Each expansion may carry an optional <name>.json sidecar ({ "description": "…" }) and each
category folder a <folder>.json, read via loader.readExpansionMeta(name). The SPA token cloud
(web-app/src/lib/promptEngine.js expansionItems())
renders expansions as folder categories, mirroring the Lists block: an alphabetical run per folder
preceded by a category pill (folder name + description tooltip), with each entry's button showing
the shortest unambiguous token via computeButtonNames() and its description as the tooltip. The
pills are plain labels, not clickable group buttons (expansions have no <folder> union token).
The sidecars are regenerated by
scripts/expansion-meta/write-expansion-meta.mjs,
the analog of scripts/list-cleanup/write-list-meta.mjs.
_force-prefix (ported on demand)
A folder with an empty _force-prefix marker shows/inserts its entries with the path from that folder down,
exactly like lists. The loaders expose expansionForcedPrefixDirs() (a markedDirs walk in Node; a
**/_force-prefix glob in the browser) and the SPA passes it to computeButtonNames. The one user so far is
detail/: its entries were renamed legacy-detail→legacy and legacy-person-detail→legacy-person (the
redundant "detail" is now carried by the folder) and the folder is force-prefixed, so they read
<detail/legacy> / <detail/legacy-person>. As with lists this is display-only — suffix resolution still
works — so it surfaces the folder for context rather than hard-requiring it. The lone code reference
(data/dynamic-prompts/futuristic.js) was updated to <detail/legacy>.
The classic Pug editor lists expansions flat with a single section tooltip — the same treatment
lists get there (/api/files/expansions returns the canonical names, so nested paths render
consistently with /api/files/lists). The rich categorization is an SPA feature.
Pick-one folder groups (added 2.5.0)
A category folder with 2+ expansions is now an IMPLIED group: <lighting> splices one random
expansion from that folder (and .group files + _enable/_disable-group-list markers work too). This
is the "pick one" rule — it selects a single snippet and splices it, NOT a union of all members. The
expansion stage (src/core/stages/expansion.js) resolves the
folder, samples a member (gate-aware via hasNsfwToken), and splices it; the loaders expose
expansionGroupDirs() / readExpansionGroup(). The SPA's category pills are clickable group buttons for
these folders (inserting <folder>), mirroring Lists.
What was intentionally NOT ported
- List-style line unions — a
<folder>group picks ONE member snippet, never a union of all members' text (an expansion is a discrete snippet, not a line pool). - SFW/NSFW file splitting — the snippets are small, hand-authored, and benign, so there is no
CSV-driven split to maintain; adult content would follow the
nsfwname-token convention (isGatedList/ the group's gate-aware member filter key off the name).
(_force-prefix was initially skipped — basenames are unique so computeButtonNames() yields bare tokens —
but was later ported on demand for detail/; see above.)
See ../../data/expansions/README.md for the user-facing
reference.