Tutorial: Expansions — architecture

Expansions — architecture

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-detaillegacy and legacy-person-detaillegacy-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 nsfw name-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.