/**
* Plugin configuration schema.
*
* LM Studio auto-generates the UI controls from this declaration โ a plugin
* cannot draw its own interface, and the config UI has **no visual grouping**
* (no sections / collapsible foldouts): every field renders as a flat row with
* a `displayName` and an optional `hint` (โ tooltip) โ see the official docs at
* https://lmstudio.ai/docs/typescript/plugins/custom-configuration/config-ts.
*
* To keep the panel simple and accessible, we expose only the handful of
* settings a player actually touches. The expert tuning knobs are NOT shown;
* they live in `src/tuning.ts` (`TUNING_DEFAULTS`) and can be overridden โ with
* no rebuild โ via an optional `roleplay.config.json` next to the plugin (see
* `docs/configuration.md`). Ask to re-expose any of them in the UI if needed.
*
* Read at runtime via:
* ctl.getPluginConfig(configSchematics).get("fieldName")
* ctl.getGlobalPluginConfig(globalConfigSchematics).get("fieldName")
*/
import { readdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { resolve } from "node:path";
import { createConfigSchematics } from "@lmstudio/sdk";
import { defaultResponseLanguage, languageOptions } from "./director/language.js";
import { parseStateFileName } from "./state/index.js";
import { TUNING_DEFAULTS } from "./tuning.js";
/**
* Last-resort universe name, used ONLY when no universe folder/file is found on
* disk (a broken or stripped install). Normal installs ship the example world
* as a `characters/<name>/` folder + `lore/<name>.json`, and everything below is
* driven by *that folder's name* โ rename the shipped folder and discovery
* follows, no code change. This constant is the single hardcoded fallback.
*/
export const FALLBACK_UNIVERSE = "saltmere";
/**
* Universes to float to the top of the "Active universe" dropdown, in this
* order. Any name here that exists on disk is listed first (and the first such
* one becomes the default selection); every other discovered universe follows,
* alphabetically. Folder-driven discovery is otherwise unchanged โ a name here
* with no matching folder is simply ignored.
*/
export const FEATURED_UNIVERSES = ["saltmere"];
/**
* Discover existing universes for the "Active universe" dropdown.
*
* A universe is one self-contained package folder: `universes/<name>/` (holding
* world.json / lore.json / characters/). We list those subfolders, with the
* `FEATURED_UNIVERSES` floated to the front and the rest sorted alphabetically.
*
* Caveat: `select` options are fixed when the plugin loads, so a universe
* created after load only appears once the plugin is reloaded; a custom
* `universesDir` override isn't known at schema-build time. If nothing is found
* on disk, `FALLBACK_UNIVERSE` is used so the dropdown is never empty.
*/
function discoverUniverses(): string[] {
const found = new Set<string>();
try {
const dir = resolve(process.cwd(), "universes");
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.isDirectory()) found.add(e.name);
}
} catch {
// No universes/ folder yet โ fine.
}
if (found.size === 0) found.add(FALLBACK_UNIVERSE);
const featured = FEATURED_UNIVERSES.filter((u) => found.has(u));
const rest = [...found]
.filter((u) => !featured.includes(u))
.sort((a, b) => a.localeCompare(b));
return [...featured, ...rest];
}
/**
* Default selection for the "Active universe" dropdown: the first universe
* actually discovered on disk (folder-driven), so the shipped example world is
* pre-selected out of the box without hardcoding its name here.
*/
function defaultUniverse(): string {
return discoverUniverses()[0] ?? FALLBACK_UNIVERSE;
}
function universeOptions(): { value: string; displayName: string }[] {
return discoverUniverses().map((u) => ({ value: u, displayName: u }));
}
/**
* Sentinel value for the "Story" dropdown's "start a new story" entry. When this
* is selected, the handler reads the separate `newStoryName` text field instead.
* Reserved: a real save literally named this is shadowed (acceptably unlikely).
*/
export const NEW_STORY_SENTINEL = "__new_story__";
/**
* Sentinel value for the "Story" dropdown's "default (unnamed) save" entry. The
* SDK rejects an empty option `value`, so the default save is represented by
* this non-empty token and mapped back to "" (the shared, unnamed save) in the
* handler. Reserved: a real save literally named this is shadowed.
*/
export const DEFAULT_STORY_SENTINEL = "__default_story__";
/**
* Discover existing named save slots for the "Story" dropdown, across ALL
* universes (the schema is built before the active universe is known โ same
* load-time caveat as `discoverUniverses`). Save state lives in `saves/`,
* separate from the universe packages. The default story (empty save) is not a
* named slot; it is offered as a fixed first option instead.
*/
function discoverSaves(): string[] {
const found = new Set<string>();
try {
const dir = resolve(process.cwd(), "saves");
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (!e.isFile()) continue;
const parsed = parseStateFileName(e.name);
if (parsed && parsed.save) found.add(parsed.save);
}
} catch {
// No saves/ folder yet โ fine.
}
return [...found].sort((a, b) => a.localeCompare(b));
}
function storyOptions(): { value: string; displayName: string }[] {
return [
{ value: DEFAULT_STORY_SENTINEL, displayName: "Default story" },
...discoverSaves().map((s) => ({ value: s, displayName: s })),
{ value: NEW_STORY_SENTINEL, displayName: "๏ผ New story (type the name below)" },
];
}
/**
* Sentinel for the "Embedding model" dropdown's "let the plugin pick" entry. The
* SDK rejects an empty option `value`, so "use the default loaded embedding
* model" is represented by this token and mapped back to "" (auto) in the
* handler โ `embed.ts` already treats "" as "use the default loaded model".
*/
export const DEFAULT_EMBEDDING_SENTINEL = "__default_embedding__";
/**
* Discover downloaded embedding models for the "Embedding model" dropdown.
*
* Same constraint as `discoverUniverses`/`discoverSaves`: `select` options are
* fixed at plugin load, built synchronously with no `client` access โ so we
* can't ask `client.embedding.listLoaded()` here. Instead we read LM Studio's
* own on-disk model index (`.internal/model-index-cache.json`), which lists
* every downloaded model with its `domain`, and keep the ones whose domain is
* "embedding". A model downloaded after load only appears once the plugin is
* reloaded.
*
* Returns the canonical model identifier (its index key) as `value` and the
* friendly name as `displayName`. Any failure (file missing, schema drift)
* yields an empty list โ the dropdown then offers only the "default" entry.
*/
function discoverEmbeddingModels(): { value: string; displayName: string }[] {
// LM Studio's data dir moved across versions; try both known locations.
const candidates = [
resolve(homedir(), ".lmstudio", ".internal", "model-index-cache.json"),
resolve(homedir(), ".cache", "lm-studio", ".internal", "model-index-cache.json"),
];
for (const path of candidates) {
try {
const index = JSON.parse(readFileSync(path, "utf-8")) as {
models?: { domain?: string; indexedModelIdentifier?: string; displayName?: string }[];
};
const models = index.models ?? [];
const found = models
.filter((m) => m.domain === "embedding" && m.indexedModelIdentifier)
.map((m) => ({
value: m.indexedModelIdentifier as string,
displayName: m.displayName || (m.indexedModelIdentifier as string),
}));
// First readable index wins, even if it lists zero embedding models.
return found;
} catch {
// Try the next candidate location.
}
}
return [];
}
/**
* Human-readable list of discovered embedding model identifiers, for the field
* subtitle. The field itself is a free-text `string` (not a `select`) so a saved
* value can never fail schema validation when the model list changes between
* loads โ we just *suggest* the known identifiers here.
*/
function discoveredEmbeddingModelsHint(): string {
const models = discoverEmbeddingModels();
if (models.length === 0) {
return "No embedding model detected โ install one in LM Studio (search tab), e.g. bge-m3.";
}
return "Downloaded embedding models: " + models.map((m) => m.value).join(", ") + ".";
}
/**
* Per-chat configuration โ only the essentials. Each field shows just its name;
* the full explanation lives in the `hint` (โ) tooltip. Expert knobs are in
* `TUNING_DEFAULTS` / `docs/configuration.md`, not here.
*/
export const configSchematics = createConfigSchematics()
.field(
"activeUniverse",
"select",
{
displayName: "Active universe",
hint:
"Universe to load (its world, characters and lore). The list is detected " +
"at plugin load: to add a new universe, create a 'universes/<name>/' " +
"folder (with world.json, lore.json, characters/) and reload the plugin.",
options: universeOptions(),
},
defaultUniverse(),
)
.field(
"storyName",
"select",
{
displayName: "Story (save)",
hint:
"Which playthrough of this universe to load โ each save has its own " +
"memory, turn count and /mj directives, all on the SAME world, " +
"characters and lore. 'Default story' is the shared, unnamed save. Pick " +
"an existing save to switch to it, or '๏ผ New story' and type a name in " +
"the field below to start a fresh, independent one. The list is detected " +
"at plugin load (across all universes) โ reload to refresh it. To wipe a " +
"save, delete its universes/<universe>[__<save>].json file.",
options: storyOptions(),
},
DEFAULT_STORY_SENTINEL,
)
.field(
"newStoryName",
"string",
{
displayName: "New story name",
hint:
"Used ONLY when 'Story' is set to '๏ผ New story'. Type a name to start a " +
"separate playthrough on this universe (its memory and progress are " +
"stored independently; the setup check runs again). Ignored when 'Story' " +
"points at the default or an existing save.",
},
"",
)
// World identity (name / setting / narration style) is NOT a per-chat setting:
// it belongs to the universe and lives in `universes/<universe>/world.json`, so
// selecting a universe brings its world with it (no re-typing). Power users can
// still override per-install via `roleplay.config.json` if ever needed.
.field(
"enableChoices",
"boolean",
{
displayName: "Offer numbered choices",
hint:
"When on, the game master ends each reply with numbered options plus an " +
"invitation to act freely. (Number of options is fixed at " +
`${TUNING_DEFAULTS.choiceCount}; see docs/configuration.md.)`,
},
true,
)
.field(
"mechanicsEnabled",
"boolean",
{
displayName: "Game mechanics (stats, dice, HP)",
hint:
"When on, and the active universe ships a 'rules.json', enable the " +
"structured layer: character sheet (health, money, stats), PbtA dice " +
"resolution of risky choices, dice-calculated rewards, enemies with " +
"their own health, and a code-enforced game-over. Universes without a " +
"rules.json are unaffected.",
},
true,
)
.field(
"showStatusLine",
"boolean",
{
displayName: "Show status line",
hint:
"Prepend a one-line status (e.g. 'โค Health 9/10 ยท ๐ช Coin 6', plus any " +
"live enemies) to every reply, rendered by the plugin from the true " +
"values. Only applies when game mechanics are active.",
},
true,
)
.field(
"showDiceReadout",
"boolean",
{
displayName: "Show risk & fate",
hint:
"Show a short 'system' layer beside the story (in your play language): a " +
"glanceable risk gauge on each risky option before you commit, and a " +
"luck-flavoured line when a roll resolves (a masterstroke, a costly win, a " +
"betrayal of fate). Evocative, never numeric โ the odds stay hidden so the " +
"gamble keeps its thrill. Only applies when game mechanics are active.",
},
true,
)
.field(
"generateSheets",
"boolean",
{
displayName: "Generate sheets from character cards",
hint:
"When on, derive each character's starting stats/resources from their " +
"card description (the model proposes, the engine clamps to the rules' " +
"bounds) instead of using flat defaults โ once per character, then " +
"cached. Named NPCs entering combat use their generated sheet. Only " +
"applies when game mechanics are active.",
},
true,
)
.field(
"adjudicationEnabled",
"boolean",
{
displayName: "Adjudicate free-form actions (referee)",
hint:
"When on, an impartial referee vets each typed (free-form) action before " +
"the scene is written: an impossible action (e.g. casting magic in a " +
"world without it) is narrated as a failed attempt, an uncertain one is " +
"resolved by dice, and a fact the player merely asserts is treated as a " +
"claim โ so free text gets the same weight as a numbered choice. Only " +
"applies when game mechanics are active; off = the Phase G2 behaviour.",
},
true,
)
.field(
"summaryEnabled",
"boolean",
{
displayName: "Rolling memory summary",
hint:
"Keep long sessions coherent: maintain a running summary of the story so " +
"far and reinject it as a '# Story so far' block, so substance isn't lost " +
"when old messages scroll out of the context window.",
},
true,
)
.field(
"ragEnabled",
"boolean",
{
displayName: "Long-term recall (vector RAG)",
hint:
"Recover precise old details the rolling summary compresses away: keep " +
"past messages in a per-save store and resurface the few most relevant " +
"to the current scene as a '# Relevant past events' block. Needs an " +
"embedding model (set it in global settings); complements the summary.",
},
true,
)
.field(
"relationshipMemory",
"boolean",
{
displayName: "Relationship memory",
hint:
"Let bonds grow over the story: each character tracks how well they know " +
"the player and how they feel about them, plus a running history of the " +
"two โ injected as a '# Relationships' block. A character met for the " +
"first time treats the player as a stranger (no knowing their name); warmth " +
"or wariness then evolves turn to turn. Updated by a background pass beside " +
"the memory summary.",
},
true,
)
.field(
"volitionEnabled",
"boolean",
{
displayName: "Character will (social referee)",
hint:
"Give NPCs a will of their own so they can refuse. Before each scene, an " +
"impartial referee reads how every present character is disposed to your " +
"action โ from their personality (Big Five), how they regard you, and their " +
"mood โ and the narrator must honour it: a self-interested or wary character " +
"may decline, negotiate, deflect, or push back instead of always complying. " +
"Adds one short background pass on free-form turns with a character present.",
},
true,
)
.field(
"knowledgeGating",
"boolean",
{
displayName: "Knowledge gating (secret reveals)",
hint:
"Stop NPCs from blurting their secrets on first meeting. A character's " +
"secret (declared on their card) stays out of the narrator entirely until " +
"earned โ through trust, the right subject coming up, and learning the " +
"facts it depends on first โ so reveals build credibly instead of all at " +
"once. Needs relationship memory for the trust part; off = no gating.",
},
true,
)
.field(
"firstMentionIntros",
"boolean",
{
displayName: "Introduce people & places on first mention",
hint:
"Stop the narrator from name-dropping a character, place or faction the " +
"player has never heard of. The first time an element of the world is " +
"brought on, the game master grounds it in a line or two so you can place " +
"it โ then stops once you've met it. No extra model call; off = elements " +
"may appear by name with no introduction.",
},
true,
)
.field(
"dramaticArc",
"boolean",
{
displayName: "Dramatic arc & pacing",
hint:
"Give the story a shape over time. A background 'showrunner' pass paces the " +
"drama โ letting quiet scenes breathe and nudging the world when it's time " +
"to escalate or bring someone new on โ and follows the universe's authored " +
"acts when it ships one (universes/<u>/arc.json): e.g. a shadowless opening " +
"that holds its secrets back until the story has earned them. Without an " +
"arc.json, a sensible default 'establish then escalate' shape is used. Off " +
"= no pacing pass and no act gating (unchanged behaviour).",
},
true,
)
.field(
"timeWeather",
"boolean",
{
displayName: "Time of day & weather",
hint:
"Give the world a living clock: the time of day, the day count and the " +
"season are tracked and woven into the narration's senses, and the weather " +
"shifts over time. Time creeps forward each turn and jumps when the story " +
"itself passes time ('they travel three days'); weather drifts through the " +
"setting's own palette (authored in world.json โ a universe with none shows " +
"time only). Off = no clock and no '# Time & weather' block (unchanged).",
},
true,
)
.field(
"semanticMatching",
"boolean",
{
displayName: "Semantic matching (embeddings)",
hint:
"Trigger lore and NPCs by meaning, not just exact keywords โ so a French " +
"line can surface an English entry. Needs an embedding model (set it in " +
"global settings); off = keyword/name-only.",
},
true,
)
.field(
"temperature",
"numeric",
{
displayName: "Temperature",
hint:
"Sampling temperature the plugin applies when generating. Higher = more " +
"varied, creative narration; lower = more deterministic. 0.7โ0.85 suits " +
"most roleplay. Set by the plugin each turn, independent of the model preset.",
slider: { min: 0, max: 1.5, step: 0.05 },
},
0.8,
)
.field(
"setupCheck",
"boolean",
{
displayName: "First-turn setup check",
hint:
"Once per story, show a readiness report (player persona, NPCs, world, " +
"lore, embedding model) with advice for anything missing, plus a quick " +
"how-to-play โ then start normally. Turn off once you're set up.",
},
true,
)
.field(
"debugLogging",
"boolean",
{
displayName: "Debug logging",
hint:
"Print the exact text this plugin sends to the model each turn, to the " +
"dev server log (`lms log stream`). Also logs when the rolling memory " +
"summary fires and prints the resulting summary.",
},
false,
)
.field(
"transcriptLogging",
"boolean",
{
displayName: "Full LLM transcript (file)",
hint:
"Write a separate, human-readable file logging EVERY exchange with the " +
"model โ the full system prompt and messages it receives, plus its raw " +
"reply (reasoning included). Covers the main narration and the auxiliary " +
"calls (sheet, summary, title). One file per save: " +
"saves/<universe>[__<save>].transcript.md (append-only; delete it to " +
"reset). Use this to understand how the prompt is built.",
},
false,
)
.build();
/** Global configuration โ applies to every chat on this machine. */
export const globalConfigSchematics = createConfigSchematics()
.field(
"responseLanguage",
"select",
{
displayName: "Response language",
hint:
"Force the game master to reply in this language, regardless of the " +
"language the player writes in. Defaults to LM Studio's own interface " +
"language (detected at plugin load), falling back to English. 'Model " +
"default' leaves it to the model.",
options: languageOptions(),
},
defaultResponseLanguage(),
)
.field(
"embeddingModel",
"string",
{
displayName: "Embedding model",
subtitle:
"Leave empty to auto-detect the loaded model. " + discoveredEmbeddingModelsHint(),
hint:
"Semantic lore and NPC matching needs an embedding model. Recommended: " +
"bge-m3, a multilingual model so a line in one language can trigger an " +
"entry written in another. Leave EMPTY to use whatever embedding model is " +
"currently loaded in LM Studio; or paste a model identifier to pin one " +
"(your downloaded embedding models are listed in the subtitle above). " +
"This is a free-text field so a saved value never breaks the config if " +
"the model is later uninstalled or LM Studio reorganizes its cache.",
},
"",
)
.build();