Add dramaturgic Socratic Ink harness path
This commit is contained in:
parent
e8bcc36ed0
commit
fc29ddccb2
4 changed files with 594 additions and 7 deletions
|
|
@ -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",
|
||||
|
|
|
|||
64
prompts/socratic-ink-face-beat.prompt.md
Normal file
64
prompts/socratic-ink-face-beat.prompt.md
Normal 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.
|
||||
125
scenarios/fixtures/socratic-ink-dramaturgy.fixture.json
Normal file
125
scenarios/fixtures/socratic-ink-dramaturgy.fixture.json
Normal 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."
|
||||
]
|
||||
}
|
||||
|
|
@ -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)}`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue