/**
* The pacing orchestrator — "the conductor" (Phase I).
*
* A structured `respond({ structured })` pass like the accountant / relationship
* / revelation passes: it runs AFTER the rolling summary (so it reads the
* freshest compressed view of the whole story), OFF the player's critical path,
* and only on a *beat* — never every turn (see the handler's cadence gate). It
* does not narrate. It reads where the story is in the authored arc plus the live
* state, and decides:
* - the current dramatic `tension` (0 → 1),
* - a `beat`: hold (let it breathe), escalate, introduce (wake a dormant
* character / thread), or resolve (release tension),
* - whether to `advanceAct`,
* - a few SOFT directive nudges (pressure, never stage directions), written
* into `director.directives` to steer the NEXT turn's narration,
* - the updated set of open threads.
*
* Discipline (the design's whole point): the conductor must be as willing to
* HOLD as to escalate — an over-eager director turns slow dread into telenovela.
* Its nudges are pressure ("the Garden begins to turn its attention toward the
* player"), not scripts ("X enters and says Y"). It is grounded in the universe's
* own DORMANT assets (the off-stage cast, the not-yet-opened secrets) so it wakes
* what the author wrote instead of inventing off-world events.
*
* Pure + unit-testable: this file builds the schema, the prompt, and the pure
* application of a parsed object. The one impure boundary — the `respond` call —
* lives in the handler. No `@lmstudio/sdk` import.
*/
import { z } from "zod";
import type { Directive, PacingState } from "../state/schema.js";
import { Arc, nextAct } from "./schema.js";
/** A character the conductor may bring on this act but who is not in scene. */
export interface DormantActor {
name: string;
/** A short descriptor (trimmed card description) so the nudge is grounded. */
note: string;
}
/** A secret the arc has NOT yet opened — fuel for "what could complicate next". */
export interface LockedFact {
id: string;
npc: string;
/** The content-free `surface` tell, so the conductor knows the shape, not the payload. */
surface: string;
}
/** Everything the conductor reads to make its call this beat. */
export interface ConductorContext {
/** The act the story is currently in. */
act: { title: string; goal: string; mood: string; advanceWhen: string };
/** Turns spent in the current act, and its authored bounds. */
spent: number;
minTurns: number;
maxTurns: number;
/** Whether the floor is cleared (advancing is permitted at all). */
canAdvance: boolean;
/** Whether the ceiling is hit (the act must end now regardless). */
forceAdvance: boolean;
/** Whether this is the final act (no act to advance into). */
isFinalAct: boolean;
/** The conductor's previous tension read. */
tension: number;
/** Threads currently tracked as open. */
openThreads: string[];
/** The rolling story summary (the whole story, compressed). */
storySummary: string;
/** The scene just narrated this turn. */
recentScene: string;
/**
* The player's OWN recent actions (most recent last). The advance conditions
* are written about player behaviour ("the player has poked at a seam twice"),
* so this is the conductor's primary evidence — the narrated scene alone shows
* only what the world did, never what the player chose to do.
*/
recentPlayerActions: string[];
/** Cast NOT in scene this turn — the conductor's roster of who it can wake. */
dormantCast: DormantActor[];
/** Cast keys this act explicitly invites on (`ArcAct.introduces`). */
introduceable: string[];
/** Secrets the arc has not yet opened — possible complications to steer toward. */
lockedFacts: LockedFact[];
/**
* World clock (the chronos subsystem) piggyback: when present, the conductor
* ALSO reports how much in-fiction time the scene just passed and the weather it
* shows — at no extra model call, since this pass already reads the scene. The
* handler supplies the vocabularies as plain strings (`elapsedBuckets`, the
* weather `palette`) and the current readings, keeping `arc` decoupled from
* `chronos`. Absent → the conductor's schema/prompt are byte-identical to before.
*/
chronos?: {
/** The allowed elapsed-time buckets (e.g. none / hours / nextday / days). */
elapsedBuckets: string[];
/** The setting's weather palette ([] = weather off → no weather field). */
palette: string[];
/** The current time-of-day phase, so "no change" is anchored. */
currentPhase: string;
/** The current weather, so "no change" is anchored ("" when weather off). */
currentWeather: string;
};
/**
* Scene-presence piggyback: when present, the conductor ALSO reports WHERE the
* scene is now and WHO from the roster is physically on stage — at no extra
* model call, since this pass already reads the scene. The handler supplies the
* roster (`castNames`) and the current location anchor; the readings write
* `state.scene` for next turn. Absent → the schema/prompt are byte-identical.
*/
scene?: {
/** Every NPC the model may name as present (the loaded cast's display names). */
castNames: string[];
/** Where the scene was last known to be, so "unchanged" is anchored ("" = unknown). */
currentLocation: string;
};
}
/** The beats the conductor may call. `hold` is first-class — pacing can rest. */
export const CONDUCTOR_BEATS = ["hold", "escalate", "introduce", "resolve"] as const;
/** A zod enum over the values, or a plain string when there are none (never empty). */
function enumOf(values: string[]): z.ZodTypeAny {
return values.length > 0 ? z.enum(values as [string, ...string[]]) : z.string();
}
/**
* The schema the conductor is forced into. When `chronos` is supplied (the world-
* clock piggyback), two extra fields are added so the same pass also reports the
* scene's elapsed time and current weather; absent, the schema is byte-identical
* to before. The vocabularies arrive as plain strings, so `arc` stays decoupled
* from `chronos`.
*/
export function buildConductorSchema(
chronos?: {
elapsedBuckets: string[];
palette: string[];
},
scene?: { castNames: string[] },
): z.ZodTypeAny {
const shape: Record<string, z.ZodTypeAny> = {
tension: z.number().min(0).max(1),
beat: z.enum(CONDUCTOR_BEATS),
advanceAct: z.boolean(),
directives: z.array(
z.object({
text: z.string(),
scope: z.enum(["persistent", "once"]),
}),
),
openThreads: z.array(z.string()),
reason: z.string(),
};
if (chronos) {
shape.timeElapsed = enumOf(chronos.elapsedBuckets);
shape.weather = enumOf(chronos.palette);
}
if (scene) {
// Where the scene is now (free text) + who from the roster is on stage. The
// `present` items are constrained to the roster so the model can only name a
// character the universe actually has.
shape.location = z.string();
shape.present = z.array(enumOf(scene.castNames));
}
return z.object(shape);
}
/**
* Build the conductor's prompt: a role + discipline system message, and a user
* block laying out the act, the bounds, the live state, and the dormant assets.
*/
export function buildConductorPrompt(ctx: ConductorContext): {
system: string;
user: string;
} {
const system = [
"You are the SHOWRUNNER of an ongoing role-play — a conductor pacing the " +
"drama, not a narrator. You never write story prose and never speak to the " +
"player. You read where the story is and decide how it should breathe next.",
"",
"You are given the current ACT of an authored arc (its goal, its mood, and the " +
"condition for moving past it), how long the story has spent in it, the story " +
"so far, the scene just played, the characters waiting off-stage, and the " +
"threads not yet opened. From these you output one structured decision.",
"",
"Principles — follow them exactly:",
"- Pacing has TWO opposite failure modes and you are judged equally on both. " +
"RUSHING — escalating or advancing before an act has done its work — turns slow " +
"dread into melodrama. STALLING — holding an act after the player has already " +
"fulfilled its purpose — makes the story repeat itself, tread water, and lose " +
"the player. Neither `hold` nor `advance` is the safe default; the right beat " +
"is the honest one for where the player actually is.",
"- HOLDING is a real choice. A quiet, settling, or savoured scene often needs " +
"room, not a new event. Choose `hold` when escalation would rush the act — but " +
"not reflexively, and never once the act's purpose is already met.",
"- Serve the current act's GOAL, and read it as a job to FINISH, not a place to " +
"stay. Do not pull the story past what this act is for; equally, do not hold it " +
"past what it was for. When the act's `advanceWhen` is genuinely met (and you " +
"are allowed to — see below), advancing is the CORRECT call, not a risk to " +
"avoid. Judge `advanceWhen` by what the PLAYER has actually done — never by how " +
"many turns have passed.",
"- Your directives are PRESSURE, not scripts. Nudge the world ('the Garden " +
"begins, gently, to turn its attention toward the player'), never dictate exact " +
"lines, names, or beats the narrator must hit. One or two short nudges at most; " +
"an empty list is fine on a `hold`.",
"- Ground every nudge in what already exists: the off-stage characters and the " +
"not-yet-opened threads listed below. Wake THOSE; do not invent new places, " +
"people, or facts the world has not established.",
"- `tension` is your running read from 0 (becalmed) to 1 (climax). Move it " +
"gradually and in keeping with the act.",
"",
"Output only the structured object.",
].join("\n");
const dormantLines =
ctx.dormantCast.length > 0
? ctx.dormantCast.map((d) => `- ${d.name}: ${d.note}`).join("\n")
: "(none off-stage)";
const lockedLines =
ctx.lockedFacts.length > 0
? ctx.lockedFacts.map((f) => `- ${f.npc}: ${f.surface || f.id}`).join("\n")
: "(none — every thread is already open)";
// A soft pressure ramp (NOT a clock): once the floor was cleared a while ago,
// remind the conductor that a long-overstayed act stalls the story — while
// restating that time alone is never a reason to advance. This breaks the
// perpetual-`hold` equilibrium on arcs with no ceiling (maxTurns 0) without
// ever forcing progression the player hasn't earned.
const wellPastFloor =
ctx.canAdvance &&
(ctx.minTurns > 0 ? ctx.spent >= ctx.minTurns * 2 : ctx.spent - ctx.minTurns >= 6);
const advanceLine = ctx.isFinalAct
? "This is the FINAL act — there is no act to advance into; keep `advanceAct` false."
: ctx.forceAdvance
? "The act has reached its ceiling — set `advanceAct` true (it is time to move on)."
: wellPastFloor
? `You may advance, and the floor was cleared well before now (${ctx.spent} turns in, floor ${ctx.minTurns}). If the act's purpose is met — judged by what the player has DONE — advancing is the right call; do not simply \`hold\` again and let the act stall. But do NOT advance merely because turns have passed: if the purpose is genuinely unmet, keep building and say so in \`reason\`.`
: ctx.canAdvance
? "You may advance if the act's `advanceWhen` is genuinely met (judged by what the player has done); otherwise keep building this act."
: `The act's floor is not yet cleared (${ctx.spent}/${ctx.minTurns} turns) — keep \`advanceAct\` false.`;
const user = [
"# Current act",
`Title: ${ctx.act.title || "(untitled)"}`,
`Goal: ${ctx.act.goal || "(none stated)"}`,
ctx.act.mood ? `Mood: ${ctx.act.mood}` : "",
`Advance when: ${ctx.act.advanceWhen || "(author left this open — use your judgment)"}`,
`Turns in this act: ${ctx.spent}` +
(ctx.maxTurns > 0 ? ` (floor ${ctx.minTurns}, ceiling ${ctx.maxTurns})` : ` (floor ${ctx.minTurns}, no ceiling)`),
advanceLine,
"",
`# Tension so far: ${ctx.tension.toFixed(2)}`,
"",
"# Open threads",
ctx.openThreads.length > 0 ? ctx.openThreads.map((t) => `- ${t}`).join("\n") : "(none yet)",
"",
"# Characters off-stage you may bring on (ground nudges in these)",
dormantLines,
ctx.introduceable.length > 0
? `This act especially wants to make use of: ${ctx.introduceable.join(", ")}.`
: "",
"",
"# Threads not yet opened (possible complications — do not force them early)",
lockedLines,
"",
"# Story so far",
ctx.storySummary.trim() || "(nothing summarized yet)",
"",
"# What the player has actually been doing (your evidence for `advanceWhen`)",
ctx.recentPlayerActions.length > 0
? ctx.recentPlayerActions.map((a) => `- ${a}`).join("\n")
: "(no player actions recorded yet)",
"",
"# The scene just played",
ctx.recentScene.trim() || "(none)",
"",
...(ctx.chronos
? [
"# Time & weather (read the scene, do not invent)",
`Right now it is ${ctx.chronos.currentPhase}` +
(ctx.chronos.currentWeather ? `, weather ${ctx.chronos.currentWeather}` : "") +
".",
"Report `timeElapsed`: how much in-fiction time THE SCENE JUST PLAYED passed " +
"— `none` for a continuous moment (the usual case), `hours` for a short " +
"skip, `nextday` if it slept/jumped to another day, `days`/`weeks` for a " +
"longer passage. Do NOT advance time the scene did not actually show.",
...(ctx.chronos.palette.length > 0
? [
"Report `weather`: the condition the scene shows NOW. Keep it the " +
"current one unless the narration clearly changed it; pick the " +
"closest option.",
]
: []),
"",
]
: []),
...(ctx.scene
? [
"# Where the scene is, and who is in it (read the scene, do not invent)",
ctx.scene.currentLocation
? `Last known location: ${ctx.scene.currentLocation}.`
: "Location not yet established.",
"Report `location`: a short name for WHERE the scene now takes place " +
"(e.g. \"the Drowned Lantern\", \"the north quay\"). Name the place the " +
"narration shows, in its own terms; keep the last one if the scene has " +
"not moved. This tells the next turn which of the cast belong here.",
ctx.scene.castNames.length > 0
? `Report \`present\`: which of these characters are PHYSICALLY on stage ` +
`at the end of the scene — actually here, not merely mentioned, ` +
`remembered, or off doing something else: ${ctx.scene.castNames.join(", ")}. ` +
`Empty if none of them are in the scene.`
: "Report `present`: empty (the universe has no named cast).",
"",
]
: []),
"---",
"",
"# Your task",
"Decide how the story should breathe next: set `tension`, choose a `beat`, set " +
"`advanceAct`, write at most one or two soft directive nudges (or none), " +
"update `openThreads`" +
(ctx.chronos ? ", report `timeElapsed`/`weather`" : "") +
(ctx.scene ? ", report `location`/`present`" : "") +
(ctx.chronos || ctx.scene ? " from the scene" : "") +
". Output only the structured object:",
]
.filter((l) => l !== "")
.join("\n");
return { system, user };
}
/** What the pure apply step returns to the handler. */
export interface ConductorResult {
pacing: PacingState;
/** Fresh arc directives to install for next turn (id-prefixed `arc-`). */
directives: Directive[];
/** Whether the act advanced this beat (for the debug log). */
advanced: boolean;
/** The beat the conductor called (for the debug log). */
beat: string;
/**
* World-clock piggyback: the elapsed-time bucket and weather the conductor read
* off the scene (raw strings; "" when chronos was not in the schema). The
* handler maps these into a chronos signal — `arc` does not depend on `chronos`.
*/
timeElapsed: string;
weather: string;
/**
* Scene-presence piggyback: where the conductor read the scene to be, and which
* roster names it judged physically present. "" / [] when scene was not in the
* schema. The handler folds these into `state.scene` for next turn — `arc` does
* not depend on the scene-presence subsystem.
*/
location: string;
present: string[];
}
export interface ApplyConductorOptions {
arc: Arc;
pacing: PacingState;
/** The turn this beat is being decided on (its result steers the next turn). */
turn: number;
/** Whether the act floor is cleared (advancing permitted). */
canAdvance: boolean;
/** Whether the act ceiling is hit (advance regardless of the model's choice). */
forceAdvance: boolean;
/** Max soft nudges to install (defensive cap). */
maxDirectives: number;
/** Max open threads to retain (defensive cap). */
maxThreads: number;
}
function clamp01(n: number): number {
if (!Number.isFinite(n)) return 0;
return n < 0 ? 0 : n > 1 ? 1 : n;
}
/**
* Fold a parsed conductor object into new pacing + directives (pure). Defensive
* like the other apply steps: junk fields fall back to the prior pacing, never
* throw. Advancing respects the bounds — the model's `advanceAct` only counts
* once the floor is cleared, and the ceiling forces an advance regardless. The
* returned directives are stamped with an `arc-`-prefixed id so the handler can
* tell them apart from the player's `/mj` directives (which it never overwrites).
*/
export function applyConductor(
parsed: unknown,
opts: ApplyConductorOptions,
): ConductorResult {
const obj =
parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
const { arc, pacing, turn } = opts;
const tension =
typeof obj.tension === "number" ? clamp01(obj.tension) : pacing.tension;
const beat = typeof obj.beat === "string" ? obj.beat : "hold";
// Advance only when permitted: the model's vote counts past the floor; the
// ceiling forces it regardless of the vote.
const wantsAdvance = obj.advanceAct === true && opts.canAdvance;
const doAdvance = wantsAdvance || opts.forceAdvance;
const next = doAdvance ? nextAct(arc, pacing.actId) : null;
const advanced = next !== null;
const actId = advanced ? next!.id : pacing.actId;
const actStartedTurn = advanced ? turn : pacing.actStartedTurn;
// Open threads — strings only, trimmed, de-duplicated, capped.
const rawThreads = Array.isArray(obj.openThreads) ? obj.openThreads : [];
const threads: string[] = [];
const seen = new Set<string>();
for (const t of rawThreads) {
if (typeof t !== "string") continue;
const s = t.trim();
if (!s || seen.has(s.toLowerCase())) continue;
seen.add(s.toLowerCase());
threads.push(s);
if (threads.length >= opts.maxThreads) break;
}
// Soft nudges → Directive objects, capped.
const rawDirectives = Array.isArray(obj.directives) ? obj.directives : [];
const directives: Directive[] = [];
for (const d of rawDirectives) {
if (!d || typeof d !== "object") continue;
const rec = d as Record<string, unknown>;
const text = typeof rec.text === "string" ? rec.text.trim() : "";
if (!text) continue;
const scope = rec.scope === "once" ? "once" : "persistent";
directives.push({
id: `arc-${turn}-${directives.length}`,
text,
scope,
createdAtTurn: turn,
});
if (directives.length >= opts.maxDirectives) break;
}
const newPacing: PacingState = {
actId,
actStartedTurn,
tension,
// Stamp the beat: the cadence gate measures the next run from here.
lastBeatTurn: turn,
openThreads: threads,
};
// World-clock piggyback: pass through the raw readings (the handler interprets
// them against the chronos model). Absent fields → "" (no change reported).
const timeElapsed = typeof obj.timeElapsed === "string" ? obj.timeElapsed.trim() : "";
const weather = typeof obj.weather === "string" ? obj.weather.trim() : "";
// Scene-presence piggyback: the location (free text) and the present roster
// (strings, trimmed, de-duplicated). Defensive — junk → "" / [].
const location = typeof obj.location === "string" ? obj.location.trim() : "";
const present: string[] = [];
if (Array.isArray(obj.present)) {
const seenName = new Set<string>();
for (const n of obj.present) {
if (typeof n !== "string") continue;
const s = n.trim();
if (!s || seenName.has(s.toLowerCase())) continue;
seenName.add(s.toLowerCase());
present.push(s);
}
}
return { pacing: newPacing, directives, advanced, beat, timeElapsed, weather, location, present };
}
/**
* Advance the act in code, with no model — the fallback when the conductor pass
* cannot run (a generator-handle token source) but the act has hit its ceiling
* and must end. Pure; stamps `actStartedTurn` and `lastBeatTurn`.
*/
export function advanceActOnly(arc: Arc, pacing: PacingState, turn: number): PacingState {
const next = nextAct(arc, pacing.actId);
if (!next) return pacing;
return { ...pacing, actId: next.id, actStartedTurn: turn, lastBeatTurn: turn };
}