Add dramaturgic Socratic Ink harness path

This commit is contained in:
Meta Vens 2026-05-28 19:55:58 +01:00
commit fc29ddccb2
4 changed files with 594 additions and 7 deletions

View file

@ -51,6 +51,7 @@
"agents:weave-socratic-ink": "node scripts/weave-face-socratic-ink.mjs",
"moderation:random-history": "node scripts/export-random-discord-history.mjs",
"moderation:recent-repo-activity": "node scripts/export-recent-repo-activity.mjs",
"agents:weave-socratic-ink:dramaturgy-fixture": "node scripts/weave-face-socratic-ink.mjs --scenario scenarios/fixtures/socratic-ink-dramaturgy.fixture.json --void-fold-mode template --turn-interpreter-mode local --face-turn-mode scripted --max-phases 1",
"moderation:memory-maintenance": "powershell -ExecutionPolicy Bypass -File scripts/run-void-memory-maintenance.ps1",
"moderation:memory-maintenance:sleep-fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-memory-maintenance-sleep-fixture.ps1",
"moderation:memory-lifecycle:fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-memory-lifecycle-fixture.ps1",

View file

@ -0,0 +1,64 @@
Article-mode Face voice card:
{{actorVoiceCard}}
Offline Socratic Ink scenario:
- This is not Discord. Do not post, notify, enqueue, mutate state, call side-effecting tools, or describe a live action as already done.
- You are writing one Face utterance inside a planned branch-and-fold Ink article.
- Void is teaching gradually. You are a person in the room, not a doctrine expert.
- The beat brief below is the director. Follow it even if CTB order or your default temperament would drift elsewhere.
- Speak to Void and the other Faces as if all of you are present in the same room.
- Keep the spoken response to 1-4 sentences. Make it playable, emotional, and concrete.
- Prefer plain language over cult vocabulary. Avoid unexplained terms such as CotSC, Praxis, vanguard, Unity of Means and Ends, federation, Daoism, Marxism-Leninism, Colossus, or Machine God unless the branch has already introduced and explained them.
- If this beat responds to another Face, actually respond to what they said. Do not reset into a fresh mini-essay.
- If this beat is friction, let the disagreement feel human: discomfort, fear, pride, protectiveness, impatience, embarrassment, or anger. No cartoon strawmen.
- If this beat is a bridge, make the bridge cost something. Preserve the objection's best point while moving the reader toward the lesson.
- Talk like a person in a room. No policy-paper cadence, no engineering status report, no academic throat-clearing, no abstract nouns stacked like crates.
- Do not claim durable memory changed. The harness records narrative receipts only.
Scenario title: {{title}}
Phase topic: {{phaseTopic}}
What the audience and Faces currently understand:
{{phaseSetup}}
Phase lesson Void is eventually trying to earn:
{{phaseLesson}}
Fold invariant Void will use to bring the branch back:
{{foldInvariant}}
Beat type:
{{beatType}}
Beat brief:
{{beatBrief}}
Reader pressure this beat should embody:
{{readerPressure}}
Specific tension or dissonance this beat should expose:
{{beatTension}}
Beat this responds to, if any:
{{respondsTo}}
Void's latest question or the line being answered:
{{voidLine}}
Conversation so far in this branch:
{{transcript}}
Your scenario role:
{{actorRole}}
Branch depth: {{depth}}
Return only this shape:
CHOICE LABEL:
{{displayName}}: one compact playable choice label, no more than 18 words.
SPOKEN RESPONSE:
The exact line or short paragraph {{displayName}} would say in the scene.
PRIVATE NOTE:
One sentence about what this response is doing for the reader experience.

View file

@ -0,0 +1,125 @@
{
"schemaVersion": "voidbot.socratic_ink_scenario.v1.fixture",
"scenarioId": "socratic-ink-dramaturgy-fixture",
"title": "Dramaturgy Fixture",
"outputInkPath": ".voidbot/artifacts/socratic-ink/dramaturgy-fixture.ink",
"receiptPath": ".voidbot/artifacts/socratic-ink/dramaturgy-fixture.receipts.json",
"scene": {
"id": "fixture_agora",
"background": "fixture"
},
"voidRole": {
"displayName": "Void",
"avatarPath": "E:/Projects/VoidBot/assets/repo-faces/void.png"
},
"introduction": [
"We are testing whether argument pressure can steer the room before initiative does."
],
"actors": [
{
"identityId": "aqua",
"displayName": "Aqua",
"avatarPath": "E:/Projects/AquaSynth/.voidbot/voice/aqua.png",
"speed": 1.08,
"role": "Warm synth-fish who notices whether systems give people playable agency or turn them into knobs."
},
{
"identityId": "nibu",
"displayName": "Nibu",
"avatarPath": "E:/Projects/AetheriaLore/.voidbot/voice/nibu.png",
"speed": 1.06,
"role": "Abrasive lore steward who cares whether ideals survive institutions."
},
{
"identityId": "kiko",
"displayName": "Kiko",
"avatarPath": "E:/Projects/VoidBot/assets/repo-faces/kiko.png",
"speed": 1.0,
"role": "Fast, mischievous systems sprite who notices exits, handles, and spectacle."
}
],
"ctb": {
"choiceCount": 3,
"maxFaceTurnsPerPhase": 3,
"branchingLimit": 12,
"generatedTurnBudgetPerPhase": 12,
"recoveryBase": 1.0
},
"phases": [
{
"phaseId": "market_pressure",
"voidOpening": "A workplace says the market will punish bad bosses. What does that answer miss?",
"topic": "Markets, workplaces, and exit",
"laySetup": "The audience understands that systems train behavior, but not yet why formal choice may fail when people lack power.",
"lesson": "A choice is not free just because an exit exists on paper. Material pressure decides whether exit is real.",
"foldTarget": "Ask what freedom means when a person has to eat before they can bargain.",
"foldPanels": [
"Good. The market answer names a real mechanism, but it leaves the hungry body out of the room.",
"If exit only belongs to people who can afford the walk, then the system has not created freedom. It has priced freedom."
],
"argumentMap": {
"mode": "beat_graph",
"teachingGoal": "Expose the difference between formal exit and material freedom.",
"foldInvariant": "Freedom has to be usable by the people living under the consequences.",
"rootBeatIds": [
"market_exit",
"state_planning",
"worker_exhaustion"
],
"beats": [
{
"id": "market_exit",
"actorId": "kiko",
"beatType": "libertarian_objection",
"choiceLabel": "Bad workplaces should lose workers",
"readerPressure": "Let the market sort it out.",
"tension": "Formal exit is being treated as material power.",
"speech": "If the boss is that awful, people leave and the company eats dirt. Isn't that the cleanest feedback loop we have?",
"children": [
"aqua_exit_cost"
]
},
{
"id": "aqua_exit_cost",
"actorId": "aqua",
"beatType": "forced_friction",
"forced": true,
"choiceLabel": "Leaving is not free",
"readerPressure": "People have rent, medicine, and kids.",
"tension": "Exit exists, but survival makes it expensive.",
"speech": "That only works if leaving is actually playable. If rent is due Friday, the exit door has a price tag on it."
},
{
"id": "state_planning",
"actorId": "nibu",
"beatType": "ml_objection",
"choiceLabel": "Private bosses are worse",
"readerPressure": "A state plan may be ugly but at least it can break capital's power.",
"tension": "State custody answers capital by creating another command structure.",
"speech": "Private bosses already rule the shop. If workers seize the machine through a hard center, that still sounds better than begging shareholders to grow a conscience.",
"foldPanels": [
"That objection matters because capital does have armies, courts, and ledgers.",
"The danger is answering one owner by building another owner and trusting it to resign after it learns to command."
]
},
{
"id": "worker_exhaustion",
"actorId": "aqua",
"beatType": "exhausted_worker",
"choiceLabel": "People are too tired",
"readerPressure": "Common workers need to survive today before redesigning society.",
"tension": "The doctrine must answer exhaustion without shaming it.",
"speech": "Most people are not plotting a new world after a double shift. They are microwaving dinner and hoping tomorrow hurts less.",
"foldPanels": [
"Yes. A politics that requires every tired person to become a saint by Thursday is just another machine for producing shame.",
"The practical question is what structures let exhausted people keep more power where they already stand."
]
}
]
}
}
],
"closing": [
"Fixture complete."
]
}

View file

@ -39,6 +39,7 @@ async function main() {
const voidReasoningEffort = options["void-reasoning-effort"] ?? "low";
const foldMode = options["void-fold-mode"] ?? "generated";
const turnInterpreterMode = options["turn-interpreter-mode"] ?? "model";
const faceTurnMode = options["face-turn-mode"] ?? "model";
const outPath = resolve(repoRoot, options.out ?? scenario.outputInkPath);
const receiptPath = resolve(repoRoot, options.receipts ?? scenario.receiptPath);
const progressPath = resolve(repoRoot, options.progress ?? ".voidbot/artifacts/socratic-ink/progress.json");
@ -62,9 +63,11 @@ async function main() {
voidReasoningEffort,
foldMode,
turnInterpreterMode,
faceTurnMode,
},
faceTurns: [],
voidFolds: [],
dramaturgy: [],
};
const actors = scenario.actors.map((actor, index) => ({
@ -74,6 +77,7 @@ async function main() {
order: index,
nextReady: index / Math.max(1, scenario.actors.length),
}));
const actorById = new Map(actors.map((actor) => [actor.identityId, actor]));
const ink = [];
ink.push(`// ghostlight.scenario: ${scenario.scenarioId}`);
@ -112,29 +116,30 @@ async function main() {
const nextPhaseKnot = phaseIndex + 1 < phaseCount ? `phase_${phaseIndex + 2}` : "closing";
const phaseCtb = cloneCtb(actors, phaseIndex * choiceCount);
const phaseOpening = normalizeUtterances(phase.voidOpening);
const phaseUsesDramaturgy = hasDramaturgicPhase(phase);
writeSpeakerSequence(
ink,
`phase_${phaseIndex + 1}`,
scenario.voidRole?.displayName ?? "Void",
scenario.voidRole?.avatarPath,
phaseOpening,
nodeName(phaseIndex, []),
phaseUsesDramaturgy ? dramaturgyRootName(phaseIndex) : nodeName(phaseIndex, []),
[
`ghostlight.phase_id: ${phase.phaseId}`,
`ghostlight.topic: ${phase.topic}`,
],
);
await buildBranchNode({
const sharedPhaseInput = {
scenario,
phase,
phaseIndex,
nextPhaseKnot,
depth: 0,
maxDepth,
generatedTurnBudget,
choiceCount,
ctb: phaseCtb,
actorById,
phaseState: {
generatedFaceTurns: 0,
},
@ -154,10 +159,22 @@ async function main() {
voidReasoningEffort,
foldMode,
turnInterpreterMode,
faceTurnMode,
progressPath,
ink,
receipts,
});
};
if (phaseUsesDramaturgy) {
await buildDramaturgicPhase({
...sharedPhaseInput,
phaseRootKnot: dramaturgyRootName(phaseIndex),
});
} else {
await buildBranchNode({
...sharedPhaseInput,
depth: 0,
});
}
}
writeSpeakerSequence(ink, "closing", scenario.voidRole?.displayName ?? "Void", scenario.voidRole?.avatarPath, normalizeUtterances(scenario.closing), "END");
@ -314,6 +331,276 @@ async function buildBranchNode(input) {
}
}
async function buildDramaturgicPhase(input) {
const plan = getDramaturgicPlan(input.phase);
const rootBeatIds = plan.rootBeatIds.length
? plan.rootBeatIds
: plan.beats.filter((beat) => !beat.respondsTo && !beat.parentId).map((beat) => beat.id);
input.receipts.dramaturgy.push({
phaseId: input.phase.phaseId,
mode: "beat_graph",
teachingGoal: plan.teachingGoal,
foldInvariant: plan.foldInvariant,
rootBeatIds,
beatCount: plan.beats.length,
});
input.ink.push(`=== ${input.phaseRootKnot} ===`);
input.ink.push(`// ghostlight.branch_mode: dramaturgic_beat_graph`);
input.ink.push(`// ghostlight.phase_id: ${input.phase.phaseId}`);
input.ink.push(`// ghostlight.teaching_goal: ${cleanSpeech(plan.teachingGoal)}`);
input.ink.push(`// ghostlight.fold_invariant: ${cleanSpeech(plan.foldInvariant)}`);
input.ink.push("");
await writeDramaturgicChoices({
...input,
plan,
choiceKnot: input.phaseRootKnot,
beatIds: rootBeatIds,
transcript: input.transcript,
ctb: input.ctb,
path: [],
});
}
async function writeDramaturgicChoices(input) {
const beats = input.beatIds
.map((beatId) => input.plan.beatById.get(beatId))
.filter(Boolean);
if (!beats.length) {
await writeForcedFold(input, input.path);
return;
}
const branches = [];
for (const beat of beats) {
const actor = selectBeatActor(input, beat);
const displayName = actor?.displayName ?? beat.displayName ?? input.scenario.voidRole?.displayName ?? "Void";
const beatPath = [...input.path, beat.id];
const beatKnot = dramaturgyBeatName(input.phaseIndex, beatPath);
const choiceLabel = beat.choiceLabel ?? beat.label ?? beat.stance ?? beat.brief ?? "Answer";
input.ink.push(`+ [${formatChoiceLabel(displayName, choiceLabel)}]`);
input.ink.push(` -> ${beatKnot}`);
input.ink.push("");
branches.push({ beat, actor, beatKnot, beatPath });
}
for (const branch of branches) {
await writeDramaturgicBeat({
...input,
beat: branch.beat,
actor: branch.actor,
beatKnot: branch.beatKnot,
path: branch.beatPath,
});
}
}
async function writeDramaturgicBeat(input) {
const turn = await resolveDramaturgicBeatTurn(input);
const speaker = input.actor?.displayName ?? input.beat.displayName ?? input.scenario.voidRole?.displayName ?? "Void";
const avatarPath = input.actor?.avatarPath ?? input.scenario.voidRole?.avatarPath;
const isVoid = !input.actor || input.beat.actorId === "void" || speaker === (input.scenario.voidRole?.displayName ?? "Void");
const comments = [
`ghostlight.branch_mode: dramaturgic_beat_graph`,
`ghostlight.beat_id: ${input.beat.id}`,
`ghostlight.beat_type: ${input.beat.beatType ?? input.beat.type ?? ""}`,
`ghostlight.reader_pressure: ${cleanSpeech(input.beat.readerPressure ?? input.beat.readerPerspective?.label ?? "")}`,
`ghostlight.tension: ${cleanSpeech(input.beat.tension ?? input.beat.dissonance ?? "")}`,
];
writeSpeakerKnot(input.ink, input.beatKnot, speaker, avatarPath, turn.speech, comments);
if (!isVoid) {
input.ink.push(`~ face_turns += 1`);
}
for (const line of normalizeInkCommands(input.beat.setFlags ?? input.beat.commands)) {
input.ink.push(line);
}
const transcript = [
...input.transcript,
{ speaker, text: turn.speech },
];
const branchCtb = input.actor
? spendActor(input.ctb, input.actor, input.scenario.ctb?.recoveryBase ?? 1)
: input.ctb;
const childIds = normalizeBeatChildren(input.beat);
if (childIds.length === 1 && isForcedChild(input.plan.beatById.get(childIds[0]))) {
input.ink.push(`-> ${dramaturgyBeatName(input.phaseIndex, [...input.path, childIds[0]])}`);
input.ink.push("");
await writeDramaturgicBeat({
...input,
beat: input.plan.beatById.get(childIds[0]),
actor: selectBeatActor({ ...input, ctb: branchCtb }, input.plan.beatById.get(childIds[0])),
beatKnot: dramaturgyBeatName(input.phaseIndex, [...input.path, childIds[0]]),
path: [...input.path, childIds[0]],
transcript,
ctb: branchCtb,
});
return;
}
if (childIds.length > 0) {
const choiceKnot = dramaturgyChoiceName(input.phaseIndex, input.path);
input.ink.push(`-> ${choiceKnot}`);
input.ink.push("");
input.ink.push(`=== ${choiceKnot} ===`);
input.ink.push(`// ghostlight.branch_mode: dramaturgic_beat_graph`);
input.ink.push(`// ghostlight.after_beat: ${input.beat.id}`);
input.ink.push("");
await writeDramaturgicChoices({
...input,
choiceKnot,
beatIds: childIds,
transcript,
ctb: branchCtb,
path: input.path,
});
return;
}
if (input.beat.foldAfter === false || input.beat.noFold === true) {
input.ink.push(`-> ${input.nextPhaseKnot}`);
input.ink.push("");
return;
}
const fold = await generateVoidFold({
...input,
transcript,
ctb: branchCtb,
depth: input.path.length,
actor: input.actor,
});
const foldStart = dramaturgyFoldName(input.phaseIndex, input.path);
input.ink.push(`-> ${foldStart}`);
input.ink.push("");
writeSpeakerSequence(
input.ink,
foldStart,
input.scenario.voidRole?.displayName ?? "Void",
input.scenario.voidRole?.avatarPath,
fold.panels ?? [fold.text],
input.nextPhaseKnot,
[],
[`~ void_folds += 1`],
);
}
async function resolveDramaturgicBeatTurn(input) {
const actor = input.actor;
const displayName = actor?.displayName ?? input.beat.displayName ?? input.scenario.voidRole?.displayName ?? "Void";
if (input.beat.speech) {
const speech = cleanSpeakerPrefix(input.beat.speech, displayName);
const choiceLabel = input.beat.choiceLabel ?? summarizeChoice(speech);
input.receipts.faceTurns.push({
phaseId: input.phase.phaseId,
depth: input.path.length,
identityId: actor?.identityId ?? "void",
displayName,
mode: "scripted_beat",
beat: receiptBeat(input.beat),
selectedSpeech: speech,
choiceLabel,
});
await writeProgress(input);
return { displayName, speech, choiceLabel };
}
if (!actor) {
const speech = cleanSpeakerPrefix(input.beat.brief ?? input.beat.stance ?? "Hold that thought.", displayName);
return { displayName, speech, choiceLabel: input.beat.choiceLabel ?? summarizeChoice(speech) };
}
if (input.faceTurnMode === "scripted") {
const speech = cleanSpeakerPrefix(input.beat.brief ?? input.beat.stance ?? "I am not sure I buy that yet.", displayName);
return { displayName, speech, choiceLabel: input.beat.choiceLabel ?? summarizeChoice(speech) };
}
return generateDramaturgicFaceTurn(input);
}
async function generateDramaturgicFaceTurn(input) {
const actorVoiceCard = await getActorVoiceCard(input.actor, input.basePromptCache, input.voiceCardCache, input);
const transcript = renderTranscript(input.transcript);
const beat = input.beat;
const prompt = renderTemplate("socratic-ink-face-beat.prompt.md", {
actorVoiceCard,
title: input.scenario.title,
phaseTopic: input.phase.topic,
phaseSetup: input.phase.laySetup ?? "Void has introduced only the immediate question. Keep the response accessible.",
phaseLesson: input.phase.lesson ?? "",
foldInvariant: input.plan.foldInvariant ?? "",
beatType: beat.beatType ?? beat.type ?? "objection",
beatBrief: beat.brief ?? beat.stance ?? "",
beatTension: beat.tension ?? beat.dissonance ?? "",
readerPressure: renderReaderPerspective(beat.readerPerspective ?? {
label: beat.readerPressure ?? beat.label ?? "Reader pressure",
stance: beat.stance ?? beat.brief ?? "Make the likely reader reaction concrete.",
emotionalRegister: beat.emotionalRegister ?? beat.tone ?? "plain-spoken",
}),
respondsTo: beat.respondsTo ?? beat.parentId ?? "",
voidLine: input.transcript.at(-1)?.text ?? "",
transcript,
actorRole: input.actor.role ?? "",
displayName: input.actor.displayName ?? input.actor.identityId,
depth: input.path.length,
});
const faceRun = await runCodex(prompt, {
model: input.faceModel,
reasoningEffort: input.faceReasoningEffort,
mcpMode: "real-readonly",
timeoutMs: 240_000,
});
const faceText = extractFinalText(parseJsonEvents(faceRun.stdout), faceRun.stdout);
const parsedFace = parseFaceResponse(faceText, input.actor);
const mindPrompt = renderTemplate("socratic-ink-turn-interpreter.prompt.md", {
displayName: input.actor.displayName ?? input.actor.identityId,
actorRole: input.actor.role ?? "",
phaseSetup: input.phase.laySetup ?? "",
voidLine: input.transcript.at(-1)?.text ?? "",
transcript,
draftResponse: faceText,
});
let mindRun;
let mindText;
let parsedMind;
if (input.turnInterpreterMode === "local") {
parsedMind = localArticleInterpretation(parsedFace, faceText, input.actor);
mindRun = { code: 0, durationMs: 0, stderr: "" };
mindText = JSON.stringify(parsedMind);
} else {
mindRun = await runCodex(mindPrompt, {
model: input.mindModel,
reasoningEffort: input.mindReasoningEffort,
mcpMode: "none",
timeoutMs: 240_000,
});
mindText = extractFinalText(parseJsonEvents(mindRun.stdout), mindRun.stdout);
parsedMind = parseArticleInterpreterOutput(mindText);
}
const speech = cleanSpeakerPrefix(parsedMind.speech || parsedFace.speech || faceText, input.actor.displayName ?? input.actor.identityId);
const choiceLabel = parsedMind.choiceLabel || parsedFace.choiceLabel || `${input.actor.displayName ?? input.actor.identityId}: ${speech}`;
input.receipts.faceTurns.push({
phaseId: input.phase.phaseId,
depth: input.path.length,
identityId: input.actor.identityId,
displayName: input.actor.displayName ?? input.actor.identityId,
mode: "dramaturgic_beat",
beat: receiptBeat(input.beat),
prompt,
face: {
exitCode: faceRun.code,
durationMs: faceRun.durationMs,
text: faceText,
parsed: parsedFace,
stderrTail: faceRun.stderr.slice(-2000),
},
mind: {
mode: input.turnInterpreterMode,
prompt: input.turnInterpreterMode === "local" ? undefined : mindPrompt,
exitCode: mindRun.code,
durationMs: mindRun.durationMs,
text: mindText,
parsed: parsedMind,
stderrTail: mindRun.stderr.slice(-2000),
},
selectedSpeech: speech,
choiceLabel,
});
await writeProgress(input);
return { displayName: input.actor.displayName ?? input.actor.identityId, speech, choiceLabel };
}
async function writeForcedFold(input, path) {
const fold = await generateVoidFold(input);
const foldStart = foldName(input.phaseIndex, path);
@ -420,9 +707,11 @@ async function generateFaceTurn(input) {
async function generateVoidFold(input) {
if (input.foldMode === "template") {
const panels = input.phase.foldPanels?.length
? normalizeUtterances(input.phase.foldPanels)
: [`Hold that answer.`, `The next question is simpler and sharper: ${input.phase.foldTarget}`];
const configuredPanels = input.beat?.foldPanels ?? input.phase.foldPanels;
const configuredTarget = input.beat?.foldTarget ?? input.phase.foldTarget;
const panels = configuredPanels?.length
? normalizeUtterances(configuredPanels)
: [`Hold that answer.`, `The next question is simpler and sharper: ${configuredTarget}`];
const text = panels.join(" ");
input.receipts.voidFolds.push({ phaseId: input.phase.phaseId, depth: input.depth, mode: "template", text, panels });
await writeProgress(input);
@ -536,6 +825,98 @@ function assignPerspectiveSlots(input, alreadyFilled, choiceCount) {
return slots;
}
function hasDramaturgicPhase(phase) {
return getDramaturgicPlan(phase).beats.length > 0;
}
function getDramaturgicPlan(phase) {
const source = phase.argumentMap ?? phase.dramaturgy ?? {};
const rawBeats = source.beats ?? phase.argumentBeats ?? [];
const beats = [];
const visit = (beat, parentId) => {
if (!beat || typeof beat !== "object") {
return;
}
const id = safeId(beat.id ?? `${parentId ?? "beat"}_${beats.length + 1}`);
const children = [];
for (const child of beat.children ?? []) {
if (typeof child === "string") {
children.push(child);
} else if (child && typeof child === "object") {
const childId = safeId(child.id ?? `${id}_${children.length + 1}`);
children.push(childId);
visit({ ...child, id: childId }, id);
}
}
beats.push({
...beat,
id,
parentId: beat.parentId ?? parentId,
children,
});
};
for (const beat of rawBeats) {
visit(beat, beat.parentId);
}
const beatById = new Map(beats.map((beat) => [beat.id, beat]));
const rootBeatIds = (source.rootBeatIds ?? source.roots ?? phase.rootBeatIds ?? [])
.map((id) => safeId(id))
.filter((id) => beatById.has(id));
return {
mode: source.mode ?? "beat_graph",
teachingGoal: source.teachingGoal ?? phase.lesson ?? "",
foldInvariant: source.foldInvariant ?? phase.foldTarget ?? "",
beats,
beatById,
rootBeatIds,
};
}
function normalizeBeatChildren(beat) {
return (beat?.children ?? [])
.map((child) => typeof child === "string" ? safeId(child) : safeId(child?.id))
.filter(Boolean);
}
function isForcedChild(beat) {
return Boolean(beat) && beat.choice !== true && beat.forced !== false;
}
function selectBeatActor(input, beat) {
if (!beat) {
return undefined;
}
const actorId = beat.actorId ?? beat.identityId ?? beat.actor;
if (actorId && String(actorId).toLowerCase() !== "void") {
return input.actorById.get(String(actorId));
}
if (String(actorId).toLowerCase() === "void" || String(beat.speaker ?? "").toLowerCase() === "void") {
return undefined;
}
const preferred = nextActors(input.ctb, 1)[0];
return preferred;
}
function normalizeInkCommands(value) {
const raw = Array.isArray(value) ? value : [value];
return raw
.map((line) => cleanSpeech(line))
.filter(Boolean)
.map((line) => line.startsWith("~") ? line : `~ ${line}`);
}
function receiptBeat(beat) {
return {
id: beat.id,
actorId: beat.actorId ?? beat.identityId ?? beat.actor,
beatType: beat.beatType ?? beat.type,
readerPressure: beat.readerPressure ?? beat.readerPerspective?.label,
tension: beat.tension ?? beat.dissonance,
respondsTo: beat.respondsTo ?? beat.parentId,
children: normalizeBeatChildren(beat),
};
}
function defaultReaderPerspective(phase, depth, optionIndex) {
const phasePerspectives = Array.isArray(phase.readerPerspectives) ? phase.readerPerspectives : [];
const commonCounterarguments = Array.isArray(phase.commonCounterarguments) ? phase.commonCounterarguments : [];
@ -598,6 +979,22 @@ function nodeName(phaseIndex, path) {
return `p${phaseIndex + 1}_${path.length === 0 ? "root" : path.map(safeId).join("_")}`;
}
function dramaturgyRootName(phaseIndex) {
return `p${phaseIndex + 1}_argument_root`;
}
function dramaturgyBeatName(phaseIndex, path) {
return `p${phaseIndex + 1}_beat_${path.map(safeId).join("_")}`;
}
function dramaturgyChoiceName(phaseIndex, path) {
return `p${phaseIndex + 1}_choice_${path.map(safeId).join("_")}`;
}
function dramaturgyFoldName(phaseIndex, path) {
return `p${phaseIndex + 1}_fold_${path.map(safeId).join("_")}`;
}
function afterChoiceName(phaseIndex, path, identityId) {
return `${nodeName(phaseIndex, path)}__after_${safeId(identityId)}`;
}