plotcoder-board 0.1.12 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/plotcoder-mcp-server.mjs +237 -51
- package/src/board/agents.js +4 -4
- package/src/board/compareStructure.d.ts +32 -0
- package/src/board/compareStructure.js +85 -0
- package/src/board/project.d.ts +19 -1
- package/src/board/project.js +134 -2
- package/src/board/readWall.d.ts +10 -1
- package/src/board/readWall.js +30 -3
- package/src/board/reducer.d.ts +18 -1
- package/src/board/reducer.js +52 -5
- package/src/board/workflows.d.ts +1 -1
- package/src/board/workflows.js +8 -2
package/package.json
CHANGED
|
@@ -51,6 +51,7 @@ import { segmentBrief, WORKFLOWS } from "../src/board/workflows.js";
|
|
|
51
51
|
import { DEFAULT_REMINDERS, titleFromBody } from "../src/board/reminders.js";
|
|
52
52
|
import crypto from "node:crypto";
|
|
53
53
|
import { describeRuns, describeSetups, readWall } from "../src/board/readWall.js";
|
|
54
|
+
import { compareStructure, describeComparison, MATCH_PAGES } from "../src/board/compareStructure.js";
|
|
54
55
|
import { GAP, ROW_WIDTH, organizePoses } from "../src/board/organize.js";
|
|
55
56
|
import { sceneLineCount } from "../src/board/paginate.js";
|
|
56
57
|
import {
|
|
@@ -69,6 +70,11 @@ import {
|
|
|
69
70
|
structureBeats,
|
|
70
71
|
reidentifyProject,
|
|
71
72
|
renameProject,
|
|
73
|
+
castElsewhere,
|
|
74
|
+
liftCast,
|
|
75
|
+
mergeRoster,
|
|
76
|
+
sameRoster,
|
|
77
|
+
withRoster,
|
|
72
78
|
} from "../src/board/project.js";
|
|
73
79
|
|
|
74
80
|
/**
|
|
@@ -626,7 +632,23 @@ async function openBoardEverywhere(project, boards, projectRev, base, boardId) {
|
|
|
626
632
|
return { project: opened, state, live };
|
|
627
633
|
}
|
|
628
634
|
|
|
635
|
+
/**
|
|
636
|
+
* The open board with the project's cast in it (R51). A project written
|
|
637
|
+
* before the cast moved to the record is lifted once, here, and written back.
|
|
638
|
+
*/
|
|
629
639
|
async function readBoard() {
|
|
640
|
+
const raw = await readBoardRaw();
|
|
641
|
+
const held = await readProject();
|
|
642
|
+
if (!Array.isArray(held.project.characters)) {
|
|
643
|
+
const boardId = raw.boardId ?? held.project.activeBoardId;
|
|
644
|
+
const lifted = liftCast(held.project, { ...held.boards, [boardId]: raw.state });
|
|
645
|
+
await writeProject(lifted.project, lifted.boards, held.rev, held.base);
|
|
646
|
+
return { ...raw, state: lifted.boards[boardId] ?? withRoster(raw.state, lifted.project) };
|
|
647
|
+
}
|
|
648
|
+
return { ...raw, state: withRoster(raw.state, held.project) };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function readBoardRaw() {
|
|
630
652
|
const viaAccount = await throughAccount(accountReadBoard);
|
|
631
653
|
if (viaAccount) return viaAccount;
|
|
632
654
|
const base = await findBridge();
|
|
@@ -653,7 +675,36 @@ async function readBoard() {
|
|
|
653
675
|
return { ...file, base: null, live: false };
|
|
654
676
|
}
|
|
655
677
|
|
|
656
|
-
|
|
678
|
+
/**
|
|
679
|
+
* Write the board, keeping the project's cast (R51) with it. A kernel
|
|
680
|
+
* command's result ("exact": commit, undo, redo) is the roster as the writer
|
|
681
|
+
* now wants it, removals included, and it is lifted onto the record. Any
|
|
682
|
+
* other state — a board opened, a file imported — joins the cast without
|
|
683
|
+
* shrinking it ("merge"), and is written composed with the record.
|
|
684
|
+
*/
|
|
685
|
+
async function writeBoard(next, rev, base, boardId = null, roster = "merge") {
|
|
686
|
+
const held = await readProject();
|
|
687
|
+
let toWrite = next;
|
|
688
|
+
if (Array.isArray(held.project.characters)) {
|
|
689
|
+
let project = held.project;
|
|
690
|
+
if (roster === "exact") {
|
|
691
|
+
if (!sameRoster(project.characters, next.characters)) project = { ...project, characters: next.characters, updatedAt: new Date().toISOString() };
|
|
692
|
+
} else {
|
|
693
|
+
const merged = mergeRoster(project, next);
|
|
694
|
+
project = merged.project;
|
|
695
|
+
toWrite = merged.state;
|
|
696
|
+
}
|
|
697
|
+
if (project !== held.project) {
|
|
698
|
+
const boards = {};
|
|
699
|
+
for (const [id, state] of Object.entries(held.boards)) boards[id] = withRoster(state, project);
|
|
700
|
+
boards[boardId ?? project.activeBoardId] = withRoster(toWrite, project);
|
|
701
|
+
await writeProject(project, boards, held.rev, held.base);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return writeBoardRaw(toWrite, rev, base, boardId);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async function writeBoardRaw(next, rev, base, boardId = null) {
|
|
657
708
|
if (base === ACCOUNT) return accountWriteBoard(next, rev, boardId);
|
|
658
709
|
if (base) {
|
|
659
710
|
try {
|
|
@@ -734,7 +785,7 @@ async function commit(command) {
|
|
|
734
785
|
// command teaches the agent the board is in a state it is not.
|
|
735
786
|
if (!changed) return { state: next, changed, result, live: base !== null };
|
|
736
787
|
|
|
737
|
-
const live = await writeBoard(next, rev, base, boardId);
|
|
788
|
+
const live = await writeBoard(next, rev, base, boardId, "exact");
|
|
738
789
|
trail.push({ before: state, after: canon(next), what: describeCommand(command) });
|
|
739
790
|
if (trail.length > TRAIL_CAP) trail.shift();
|
|
740
791
|
undone.length = 0;
|
|
@@ -807,10 +858,12 @@ function summarize(state) {
|
|
|
807
858
|
const cast = note.characterIds.map((id) => nameOf.get(id) ?? id);
|
|
808
859
|
const who = cast.length ? `, cast: ${cast.join(", ")}` : "";
|
|
809
860
|
const plant = note.plants ? (note.payoffBoardId ? ", plants → pays off later" : ", plants") : "";
|
|
861
|
+
const snap = state.revision?.snapshot?.[note.id];
|
|
862
|
+
const revised = snap && (snap.headline !== note.headline || snap.change !== note.change || (snap.text ?? "") !== (note.text ?? "") || (snap.location ?? "") !== (note.location ?? "")) ? `, changed in ${state.revision.color}` : "";
|
|
810
863
|
const place = note.location ? `, at: ${note.location}` : "";
|
|
811
864
|
const count = formatPages(noteEighths(note));
|
|
812
|
-
const pages = `${count} ${count === "1" ? "page" : "pages"}
|
|
813
|
-
return ` - ${note.id} [${note.rank ?? "scene"}, ${pages}${who}${place}${plant}] — "${note.headline}" (${note.color}) at ${Math.round(note.x)},${Math.round(note.y)}`;
|
|
865
|
+
const pages = isMeasured(note) ? `${count} ${count === "1" ? "page" : "pages"}, written` : note.lengthEighths === null ? "about a page, unsized" : `${count} ${count === "1" ? "page" : "pages"}`;
|
|
866
|
+
return ` - ${note.id} [${note.rank ?? "scene"}, ${pages}${who}${place}${plant}${revised}] — "${note.headline}" (${note.color}) at ${Math.round(note.x)},${Math.round(note.y)}`;
|
|
814
867
|
})
|
|
815
868
|
.join("\n");
|
|
816
869
|
const cast = state.characters
|
|
@@ -871,7 +924,7 @@ function summarize(state) {
|
|
|
871
924
|
? `runtime: about ${formatPages(runtime)} pages (an estimate from the cards; a page runs about a minute); no target set — set_target for a pilot (60) or a half-hour (30); against the feature default of 120 it would be ${formatPages(-over)} under`
|
|
872
925
|
: `runtime: about ${formatPages(runtime)} pages of a ${formatPages(state.targetEighths)}-page target — ${over > 0 ? `${formatPages(over)} over` : over < 0 ? `${formatPages(-over)} under` : "on it"} (an estimate from the cards; a page runs about a minute)`,
|
|
873
926
|
`notes: ${state.notes.length}, groups: ${state.groups.length}, arrows: ${state.arrows.length}, cast: ${state.characters.length}`,
|
|
874
|
-
"cast:",
|
|
927
|
+
"cast (the project's; every board of it casts from here):",
|
|
875
928
|
cast || " (no one yet — add_character to start the roster)",
|
|
876
929
|
"places (each phrase is its own place, and the app relates none of them — if two are one place, set_location them the same):",
|
|
877
930
|
places || " (no card says where it happens yet)",
|
|
@@ -936,7 +989,7 @@ server.registerTool(
|
|
|
936
989
|
: "board";
|
|
937
990
|
return ok(
|
|
938
991
|
`PlotCoder ${which} (${door(live, base)})\n${summarize(state)}`,
|
|
939
|
-
{ ...state, notes: state.notes.map((note) => ({ ...note, eighths: noteEighths(note), measured: isMeasured(note) })) },
|
|
992
|
+
{ ...state, revision: state.revision ? { name: state.revision.name, color: state.revision.color, since: state.revision.since } : null, notes: state.notes.map((note) => ({ ...note, eighths: noteEighths(note), measured: isMeasured(note) })) },
|
|
940
993
|
);
|
|
941
994
|
},
|
|
942
995
|
);
|
|
@@ -1117,7 +1170,7 @@ server.registerTool(
|
|
|
1117
1170
|
},
|
|
1118
1171
|
},
|
|
1119
1172
|
async (args) => {
|
|
1120
|
-
const { result } = await commit({
|
|
1173
|
+
const { state, result, live } = await commit({
|
|
1121
1174
|
type: "update_note",
|
|
1122
1175
|
id: args.id,
|
|
1123
1176
|
headline: args.headline,
|
|
@@ -1125,7 +1178,8 @@ server.registerTool(
|
|
|
1125
1178
|
location: args.location,
|
|
1126
1179
|
});
|
|
1127
1180
|
if (result === undefined) return ok(`No card with id ${args.id}.`);
|
|
1128
|
-
|
|
1181
|
+
const mark = state.revision && state.revision.snapshot?.[result.id] && (state.revision.snapshot[result.id].headline !== result.headline || state.revision.snapshot[result.id].change !== result.change) ? ` Marked changed in the ${state.revision.color} revision "${state.revision.name}".` : "";
|
|
1182
|
+
return ok(`Updated "${result.headline}"${args.change !== undefined ? `: change line "${result.change}"` : ""}${where(live)}.${mark}`, result);
|
|
1129
1183
|
},
|
|
1130
1184
|
);
|
|
1131
1185
|
|
|
@@ -1185,13 +1239,16 @@ server.registerTool(
|
|
|
1185
1239
|
{
|
|
1186
1240
|
title: "Read the wall",
|
|
1187
1241
|
description:
|
|
1188
|
-
"Read the board back: the beats in wall order (rows top to bottom, cards left to right), the pages of scenes between consecutive beats with the cards in each, every setup with the distance to its payoff, and the questions the wall raises — no beat marked yet; a run out of proportion with the others; beats back to back with nothing between them (a chain of them is one question); a card with a placeholder headline or no change line; a card no arrow touches; two headlines that read like the same scene; a group too long to be one sequence; a person in the cast on no card; a person gone for more than a third of the story and ten pages; a payoff before its setup on the wall; a folded card no setup arrow pays off; cards that say no place once any card has one. These are questions, not fixes: put them to the writer and do not act on them unasked. It says nothing about how many beats there should be, and neither should you. The prose carries every id; the JSON after it is the same reading for a program, and PLOTCODER_JSON=0 in the server's environment drops it.",
|
|
1242
|
+
"Read the board back: the beats in wall order (rows top to bottom, cards left to right), the pages of scenes between consecutive beats with the cards in each, every setup with the distance to its payoff, and the questions the wall raises — no beat marked yet; a run out of proportion with the others; beats back to back with nothing between them (a chain of them is one question); a card with a placeholder headline or no change line; a card no arrow touches; two headlines that read like the same scene; a group too long to be one sequence; a person in the cast on no card; a person gone for more than a third of the story and ten pages; a payoff before its setup on the wall; a folded card no setup arrow pays off; cards that say no place once any card has one. These are questions, not fixes: put them to the writer and do not act on them unasked. A question the writer answers with \"leave it\" is left with leave_question and listed under \"left, for now\" instead, until it would read differently. It says nothing about how many beats there should be, and neither should you. The prose carries every id; the JSON after it is the same reading for a program, and PLOTCODER_JSON=0 in the server's environment drops it.",
|
|
1189
1243
|
inputSchema: {},
|
|
1190
1244
|
},
|
|
1191
1245
|
async () => {
|
|
1192
|
-
const { state, live, base } = await readBoard();
|
|
1246
|
+
const { state, live, base, boardId: readBoardId } = await readBoard();
|
|
1193
1247
|
const { project: projectForRead } = await readProject();
|
|
1194
|
-
const
|
|
1248
|
+
const readBoardMeta = boardById(projectForRead, readBoardId ?? projectForRead.activeBoardId);
|
|
1249
|
+
const { boards: boardsForRead } = await readProject();
|
|
1250
|
+
const elsewhereForRead = castElsewhere(projectForRead, boardsForRead, readBoardId ?? projectForRead.activeBoardId);
|
|
1251
|
+
const reading = readWall(state, { elsewhere: Object.keys(elsewhereForRead) });
|
|
1195
1252
|
const runs = describeRuns(reading, state).map((line, index) => {
|
|
1196
1253
|
const ids = reading.runs[index]?.ids ?? [];
|
|
1197
1254
|
return ids.length ? `${line} — ${ids.map((id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`).join(", ")}` : line;
|
|
@@ -1200,6 +1257,8 @@ server.registerTool(
|
|
|
1200
1257
|
const written = state.notes.filter((note) => isMeasured(note)).length;
|
|
1201
1258
|
const lines = [
|
|
1202
1259
|
`PlotCoder wall (${door(live, base)})`,
|
|
1260
|
+
...(state.lock ? [`numbers: locked since ${String(state.lock.at).slice(0, 10)}; read_pages shows each scene's number`] : []),
|
|
1261
|
+
`board: "${readBoardMeta?.name ?? "?"}"${projectForRead.boards.length > 1 ? ` — ${projectForRead.boards.length} boards in "${projectForRead.name}"; open_board reads another` : ""}`,
|
|
1203
1262
|
`logline: ${state.logline ? `"${state.logline}"` : "(none yet)"}`,
|
|
1204
1263
|
"the cast and the places are list_board's, not the reading's",
|
|
1205
1264
|
state.targetEighths === DEFAULT_TARGET_EIGHTHS
|
|
@@ -1232,20 +1291,86 @@ server.registerTool(
|
|
|
1232
1291
|
"questions the wall raises:",
|
|
1233
1292
|
...(reading.findings.length
|
|
1234
1293
|
? reading.findings.map((finding) => ` - [${finding.kind}] ${finding.text}${finding.ids.length ? ` (ids: ${finding.ids.join(", ")})` : ""}`)
|
|
1235
|
-
: [" (none that this reading can see)"]),
|
|
1294
|
+
: [reading.left.length ? " (none the writer has not left)" : " (none that this reading can see)"]),
|
|
1295
|
+
...(reading.left.length
|
|
1296
|
+
? [
|
|
1297
|
+
"left, for now (the writer's word; kept until the question would read differently, and ask_again brings one back):",
|
|
1298
|
+
...reading.left.map((finding) => ` - [${finding.kind}] ${finding.text} (left ${String(finding.since).slice(0, 10)}${finding.ids.length ? `; ids: ${finding.ids.join(", ")}` : ""})`),
|
|
1299
|
+
]
|
|
1300
|
+
: []),
|
|
1236
1301
|
`checks: ${CHECKS.length} run — ${(() => {
|
|
1237
|
-
const asked = reading.findings
|
|
1238
|
-
|
|
1302
|
+
const asked = reading.findings;
|
|
1303
|
+
const held = reading.left.length ? `, ${reading.left.length} left by the writer` : "";
|
|
1304
|
+
if (asked.length === 0) return `asking nothing${held}`;
|
|
1239
1305
|
const counts = new Map();
|
|
1240
1306
|
for (const finding of asked) counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + 1);
|
|
1241
|
-
return `asking ${asked.length} question${asked.length === 1 ? "" : "s"} of ${counts.size} kind${counts.size === 1 ? "" : "s"}: ${[...counts.entries()].map(([kind, n]) => (n > 1 ? `${kind} ×${n}` : kind)).join(", ")}`;
|
|
1242
|
-
})()}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind)).map((kind) =>
|
|
1307
|
+
return `asking ${asked.length} question${asked.length === 1 ? "" : "s"} of ${counts.size} kind${counts.size === 1 ? "" : "s"}: ${[...counts.entries()].map(([kind, n]) => (n > 1 ? `${kind} ×${n}` : kind)).join(", ")}${held}`;
|
|
1308
|
+
})()}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind)).map((kind) => {
|
|
1309
|
+
if (kind === "unlinked" && state.arrows.length === 0) return "no card without an arrow (not asked: no arrows yet)";
|
|
1310
|
+
if (kind === "unplaced" && !state.notes.some((note) => (note.location ?? "").trim())) return "no card without a place (not asked: no card placed yet)";
|
|
1311
|
+
if (kind === "sequence" && state.groups.length === 0) return "no group too long for one sequence (not asked: no groups)";
|
|
1312
|
+
return CHECK_WORDS[kind];
|
|
1313
|
+
}).join("; ") || "(nothing — every check found something)"}`,
|
|
1243
1314
|
];
|
|
1244
1315
|
if (isSampleWall(state)) lines.unshift(SAMPLE_NOTE);
|
|
1245
1316
|
return ok(lines.join("\n"), { ...reading, sample: isSampleWall(state) });
|
|
1246
1317
|
},
|
|
1247
1318
|
);
|
|
1248
1319
|
|
|
1320
|
+
// --- Leaving a question (R53) ----------------------------------------
|
|
1321
|
+
|
|
1322
|
+
const sameList = (a, b) => a.length === b.length && a.every((id, index) => id === b[index]);
|
|
1323
|
+
|
|
1324
|
+
server.registerTool(
|
|
1325
|
+
"leave_question",
|
|
1326
|
+
{
|
|
1327
|
+
title: "Leave a question, for now",
|
|
1328
|
+
description:
|
|
1329
|
+
"Write the writer's word on a question the wall asks — \"leave it\" — so the reading stops asking it. Pass the question's kind as read_wall names it (sag, empty, unpaid, …) and, when that kind is asked more than once, its ids as read_wall lists them. The wall keeps the question and asks it again on its own the moment it would read differently — a card in it changes, a page moves, the median shifts — so a left question is never a dismissal; ask_again brings one back now. Only on the writer's word: never leave a question unasked.",
|
|
1330
|
+
inputSchema: { kind: z.string().min(1), ids: z.array(z.string()).optional() },
|
|
1331
|
+
},
|
|
1332
|
+
async (args) => {
|
|
1333
|
+
const { state } = await readBoard();
|
|
1334
|
+
const reading = readWall(state);
|
|
1335
|
+
const already = reading.left.filter((finding) => finding.kind === args.kind && (!args.ids || sameList(finding.ids, args.ids)));
|
|
1336
|
+
const matches = reading.findings.filter((finding) => finding.kind === args.kind && (!args.ids || sameList(finding.ids, args.ids)));
|
|
1337
|
+
if (matches.length === 0) {
|
|
1338
|
+
if (already.length) return ok(`Already left: [${args.kind}] ${already[0].text} It stays left until the question would read differently; ask_again brings it back.`);
|
|
1339
|
+
return ok(`The wall is not asking a question of kind "${args.kind}"${args.ids ? ` about ids ${args.ids.join(", ")}` : ""}. read_wall lists the questions it asks now, each with its kind and ids.`);
|
|
1340
|
+
}
|
|
1341
|
+
if (matches.length > 1) {
|
|
1342
|
+
return ok(`The wall asks ${matches.length} questions of kind "${args.kind}"; pass ids to say which:\n${matches.map((finding) => ` - ${finding.text} (ids: ${finding.ids.join(", ")})`).join("\n")}`);
|
|
1343
|
+
}
|
|
1344
|
+
const finding = matches[0];
|
|
1345
|
+
const { result, live } = await commit({ type: "leave_question", kind: finding.kind, ids: finding.ids, text: finding.text });
|
|
1346
|
+
return ok(
|
|
1347
|
+
`Left, for now: [${finding.kind}] ${finding.text}${where(live)} The wall stops asking it and keeps the writer's word; it asks again on its own when the question would read differently, and ask_again brings it back now. read_wall lists it under "left, for now".`,
|
|
1348
|
+
result,
|
|
1349
|
+
);
|
|
1350
|
+
},
|
|
1351
|
+
);
|
|
1352
|
+
|
|
1353
|
+
server.registerTool(
|
|
1354
|
+
"ask_again",
|
|
1355
|
+
{
|
|
1356
|
+
title: "Ask a left question again",
|
|
1357
|
+
description: "Take back a left question by its kind (and ids, when that kind was left more than once), so the wall asks it again now. Without ids, every left question of that kind comes back.",
|
|
1358
|
+
inputSchema: { kind: z.string().min(1), ids: z.array(z.string()).optional() },
|
|
1359
|
+
},
|
|
1360
|
+
async (args) => {
|
|
1361
|
+
const { state } = await readBoard();
|
|
1362
|
+
const held = (state.left ?? []).filter((item) => item.kind === args.kind && (!args.ids || sameList(item.ids, args.ids)));
|
|
1363
|
+
if (held.length === 0) return ok(`Nothing of kind "${args.kind}"${args.ids ? ` about ids ${args.ids.join(", ")}` : ""} is left. read_wall lists what is, under "left, for now".`);
|
|
1364
|
+
const { result, live } = await commit({ type: "ask_again", kind: args.kind, ids: args.ids });
|
|
1365
|
+
const reading = readWall((await readBoard()).state);
|
|
1366
|
+
const back = reading.findings.filter((finding) => held.some((item) => item.kind === finding.kind && sameList(item.ids, finding.ids)));
|
|
1367
|
+
return ok(
|
|
1368
|
+
`Asked again${where(live)}: ${held.length} question${held.length === 1 ? "" : "s"} of kind "${args.kind}" ${held.length === 1 ? "is" : "are"} no longer left${back.length ? ` — the wall asks ${back.length === 1 ? "it" : `${back.length} of them`} now: ${back.map((finding) => finding.text).join(" ")}` : " — and the wall no longer asks it; the question had already changed"}.`,
|
|
1369
|
+
result,
|
|
1370
|
+
);
|
|
1371
|
+
},
|
|
1372
|
+
);
|
|
1373
|
+
|
|
1249
1374
|
server.registerTool(
|
|
1250
1375
|
"move_scene",
|
|
1251
1376
|
{
|
|
@@ -1311,7 +1436,7 @@ server.registerTool(
|
|
|
1311
1436
|
const group = final.groups.find((item) => item.noteIds.includes(card.id));
|
|
1312
1437
|
const groupLine = group ? ` It is still in "${group.title || "an untitled group"}"; a frame does not follow a move, so say if the act or sequence should change.` : "";
|
|
1313
1438
|
return ok(
|
|
1314
|
-
`Moved "${card.headline}" to ${args.after ? "after" : "before"} "${target.headline}": ${removed} arrow(s) removed, ${drawn} drawn, the wall tidied along them${where(live)}. Story order now: ${order.map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}.${groupLine} One undo takes the whole move back.`,
|
|
1439
|
+
`Moved "${card.headline}" to ${args.after ? "after" : "before"} "${target.headline}": ${removed} follows arrow(s) removed, ${drawn} drawn, setup arrows untouched, the wall tidied along them${where(live)}. Story order now: ${order.map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}.${groupLine} One undo takes the whole move back.`,
|
|
1315
1440
|
{ order: order.map((note) => note.id) },
|
|
1316
1441
|
);
|
|
1317
1442
|
},
|
|
@@ -1392,22 +1517,53 @@ server.registerTool(
|
|
|
1392
1517
|
"list_structures",
|
|
1393
1518
|
{
|
|
1394
1519
|
title: "List the structures",
|
|
1395
|
-
description: "The structures apply_template can lay on a wall: the built-in ones, and the writer's own saved from their walls (save_structure), each with its beats.",
|
|
1520
|
+
description: "The structures apply_template can lay on a wall: the built-in ones, and the writer's own saved from their walls (save_structure), each with its beats. compare_structure sets one beside this wall's beats without laying anything.",
|
|
1396
1521
|
inputSchema: {},
|
|
1397
1522
|
},
|
|
1398
1523
|
async () => {
|
|
1399
1524
|
const { project } = await readProject();
|
|
1400
1525
|
const own = project.structures ?? [];
|
|
1401
1526
|
const lines = [
|
|
1402
|
-
`built in: ${TEMPLATES.length}`,
|
|
1403
|
-
...TEMPLATES.map((template) => ` - ${template.id} — "${template.name}" (${template.beats.length} beats)`),
|
|
1404
1527
|
`the writer's own: ${own.length}`,
|
|
1405
|
-
...own.map((structure) => ` - ${structure.id} — "${structure.name}" (${structure.beats.length} beats: ${structure.beats.map((beat) => beat.name).join(", ")})`),
|
|
1528
|
+
...own.map((structure) => ` - ${structure.id} — "${structure.name}" (${structure.beats.length} beats: ${structure.beats.map((beat) => `${beat.name} at ${Math.round(beat.at * 100)}%`).join(", ")})`),
|
|
1529
|
+
`built in: ${TEMPLATES.length} — ${TEMPLATES.map((template) => `${template.id} "${template.name}" (${template.beats.length} beats)`).join(", ")}; each beat's name, prompt and place in the story are in the JSON`,
|
|
1530
|
+
"compare_structure sets one of these beside this wall's beats, page by page, and lays nothing",
|
|
1406
1531
|
];
|
|
1407
1532
|
return ok(lines.join("\n"), { builtIn: TEMPLATES.map((template) => ({ id: template.id, name: template.name, beats: template.beats })), own });
|
|
1408
1533
|
},
|
|
1409
1534
|
);
|
|
1410
1535
|
|
|
1536
|
+
server.registerTool(
|
|
1537
|
+
"compare_structure",
|
|
1538
|
+
{
|
|
1539
|
+
title: "A structure beside the wall",
|
|
1540
|
+
description:
|
|
1541
|
+
"Set a structure beside this wall's beats without laying anything: each of the structure's beats with the page it falls near on this board's target, and the nearest of the wall's own beats within six pages — one to one, in order — with how far off it is (here, near, N pp early or late). A reading, like read_wall: nothing moves and no card is made. Takes a built-in structure by id (turns, three-acts, eight-sequences, fifteen-beats, story-circle) or one of the writer's own by name or id; turns is the default.",
|
|
1542
|
+
inputSchema: { structure: z.string().optional() },
|
|
1543
|
+
},
|
|
1544
|
+
async (args) => {
|
|
1545
|
+
const { state } = await readBoard();
|
|
1546
|
+
const { project } = await readProject();
|
|
1547
|
+
const wanted = (args.structure ?? "turns").trim().toLowerCase();
|
|
1548
|
+
const own = project.structures ?? [];
|
|
1549
|
+
const chosen =
|
|
1550
|
+
TEMPLATES.find((template) => template.id === wanted || template.name.toLowerCase() === wanted) ??
|
|
1551
|
+
own.find((structure) => structure.id === wanted || structure.name.toLowerCase() === wanted);
|
|
1552
|
+
if (!chosen) return ok(`No structure called "${args.structure}". list_structures names the built-in five and the writer's own.`);
|
|
1553
|
+
const comparison = compareStructure(state, chosen.beats);
|
|
1554
|
+
const beats = state.notes.filter((note) => note.rank === "beat").length;
|
|
1555
|
+
const lines = [
|
|
1556
|
+
`"${chosen.name}" beside this wall's ${beats} beat${beats === 1 ? "" : "s"}, of ${formatPages(state.targetEighths)} pages (the story so far runs to p. ${comparison.soFar}); a match is the nearest of the wall's beats within ${MATCH_PAGES} pages, one to one and in order:`,
|
|
1557
|
+
...describeComparison(comparison).map((line) => ` - ${line}`),
|
|
1558
|
+
comparison.unmatched.length
|
|
1559
|
+
? `beats of the wall no beat of the structure answers: ${comparison.unmatched.map((beat) => `"${beat.headline}" (p. ${beat.page})`).join(", ")}`
|
|
1560
|
+
: "every beat of the wall answers one of the structure's",
|
|
1561
|
+
beats === 0 ? "No card on this board is marked as a beat (set_rank), so there is nothing to compare; apply_template lays the structure's beats to fill." : "Nothing moved and nothing was made: this is a reading. apply_template lays the beats as cards when the writer wants them.",
|
|
1562
|
+
];
|
|
1563
|
+
return ok(lines.join("\n"), { structure: { id: chosen.id, name: chosen.name }, ...comparison });
|
|
1564
|
+
},
|
|
1565
|
+
);
|
|
1566
|
+
|
|
1411
1567
|
server.registerTool(
|
|
1412
1568
|
"save_structure",
|
|
1413
1569
|
{
|
|
@@ -1424,7 +1580,7 @@ server.registerTool(
|
|
|
1424
1580
|
const { project, boards, rev, base, live } = await readProject();
|
|
1425
1581
|
const { project: next, structure } = addStructure(project, args.name, beats);
|
|
1426
1582
|
await writeProject(next, boards, rev, base);
|
|
1427
|
-
return ok(`Saved "${structure.name}" with ${beats.length} beats${where(live)}: ${beats.map((beat) => beat.name).join(", ")}.`, structure);
|
|
1583
|
+
return ok(`Saved "${structure.name}" with ${beats.length} beats${where(live)}: ${beats.map((beat) => `${beat.name} at ${Math.round(beat.at * 100)}%`).join(", ")}. Each beat's prompt is its change line here; apply_template lays the same turns on another board, the next episode's, as beat cards to fill.`, structure);
|
|
1428
1584
|
},
|
|
1429
1585
|
);
|
|
1430
1586
|
|
|
@@ -1487,7 +1643,7 @@ server.registerTool(
|
|
|
1487
1643
|
}
|
|
1488
1644
|
const printed = sceneLineCount(args.text);
|
|
1489
1645
|
return ok(
|
|
1490
|
-
`Wrote "${result.headline}": ${printed} line(s) as they print (headings, blank lines and wrapped dialogue counted), measured at ${formatPages(noteEighths(result))} of a 55-line page, rounded to the nearest eighth and never below one${where(live)}. The heading comes from the card's place, so the text starts with the action. While the text stands the card is measured, not estimated;
|
|
1646
|
+
`Wrote "${result.headline}": ${printed} line(s) as they print (headings, blank lines and wrapped dialogue counted), measured at ${formatPages(noteEighths(result))} of a 55-line page, rounded to the nearest eighth and never below one${where(live)}. The heading comes from the card's place, so the text starts with the action. While the text stands the card is measured, not estimated; every reading uses the measure (eighths here), and the estimate underneath (lengthEighths) is untouched.`,
|
|
1491
1647
|
{ ...result, eighths: noteEighths(result), measured: true, printedLines: printed },
|
|
1492
1648
|
);
|
|
1493
1649
|
},
|
|
@@ -1508,6 +1664,7 @@ server.registerTool(
|
|
|
1508
1664
|
const text = toFountain(state, { title: board?.name, premise: project.premise || undefined });
|
|
1509
1665
|
const parsed = fromFountain(text);
|
|
1510
1666
|
const ids = mergeFountain(state, parsed).matched.map((item) => item.id);
|
|
1667
|
+
const pageNumbers = state.lock ? sceneNumbers(readingOrder(state.notes), state.lock) : null;
|
|
1511
1668
|
const lines = [];
|
|
1512
1669
|
let index = 0;
|
|
1513
1670
|
for (const line of text.split("\n")) {
|
|
@@ -1515,7 +1672,8 @@ server.registerTool(
|
|
|
1515
1672
|
const note = state.notes.find((item) => item.id === ids[index]);
|
|
1516
1673
|
index += 1;
|
|
1517
1674
|
const standIn = note && !(note.location ?? "").trim() ? " · no place: the headline stands in for the heading" : "";
|
|
1518
|
-
|
|
1675
|
+
const numbered = note && pageNumbers?.get(note.id) ? ` · locked no. ${pageNumbers.get(note.id)}` : "";
|
|
1676
|
+
lines.push(`${line} [[id: ${note?.id ?? "?"} · ${note && isMeasured(note) ? "measured" : "estimated"} ${formatPages(note ? noteEighths(note) : 0)}pp${standIn}${numbered}]]`);
|
|
1519
1677
|
} else {
|
|
1520
1678
|
lines.push(line);
|
|
1521
1679
|
}
|
|
@@ -1593,7 +1751,7 @@ server.registerTool(
|
|
|
1593
1751
|
const { state } = await readBoard();
|
|
1594
1752
|
const { project } = await readProject();
|
|
1595
1753
|
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
1596
|
-
const brief = segmentBrief(state, args.ids, { title: board?.name });
|
|
1754
|
+
const brief = segmentBrief(state, args.ids, { title: board?.name, boards: project.boards });
|
|
1597
1755
|
if (!brief) return ok(`No cards with ids ${args.ids.join(", ")}. Call list_board.`);
|
|
1598
1756
|
return ok(brief);
|
|
1599
1757
|
},
|
|
@@ -1672,7 +1830,7 @@ server.registerTool(
|
|
|
1672
1830
|
const note = unwritten
|
|
1673
1831
|
? [`${unwritten} of ${order.length} scenes are unwritten and count as one line each here, so this is the script so far, not the runtime: the estimate from the cards is about ${formatPages(boardEighths(state))} pages, the number to use until the scenes are written.`]
|
|
1674
1832
|
: [];
|
|
1675
|
-
return ok([`pages: ${result.pageCount} of ${Math.round(state.targetEighths / 8)}`, ...note, ...lines].join("\n"), result.scenes);
|
|
1833
|
+
return ok([`pages: ${result.pageCount} of ${Math.round(state.targetEighths / 8)}`, `scene numbers here are ${state.lock ? "the locked numbers" : "wall order (not locked)"}`, ...note, ...lines].join("\n"), result.scenes);
|
|
1676
1834
|
},
|
|
1677
1835
|
);
|
|
1678
1836
|
|
|
@@ -1697,8 +1855,10 @@ server.registerTool(
|
|
|
1697
1855
|
"unlock_numbers",
|
|
1698
1856
|
{ title: "Unlock the scene numbers", description: "Numbers follow the wall's order again.", inputSchema: {} },
|
|
1699
1857
|
async () => {
|
|
1858
|
+
const { state: before } = await readBoard();
|
|
1859
|
+
|
|
1700
1860
|
const { changed, live } = await commit({ type: "unlock_numbers" });
|
|
1701
|
-
return ok(changed ? `Unlocked${where(live)}.` : "The numbers were not locked.");
|
|
1861
|
+
return ok(changed ? `Unlocked ${Object.keys(before.lock?.numbers ?? {}).length} scene number(s)${where(live)}; scenes number by wall order again.` : "The numbers were not locked.");
|
|
1702
1862
|
},
|
|
1703
1863
|
);
|
|
1704
1864
|
|
|
@@ -1708,11 +1868,13 @@ server.registerTool(
|
|
|
1708
1868
|
title: "Start a revision",
|
|
1709
1869
|
description:
|
|
1710
1870
|
`Name a revision and give it one of the industry's colours (${REVISION_COLORS.join(", ")}). Every card is snapshotted; from then on a changed line prints in the colour with a star in the margin, and a changed card wears the colour on the wall.`,
|
|
1711
|
-
inputSchema: { name: z.string().
|
|
1871
|
+
inputSchema: { name: z.string().optional(), color: z.string().optional() },
|
|
1712
1872
|
},
|
|
1713
1873
|
async (args) => {
|
|
1714
|
-
const
|
|
1715
|
-
if (!
|
|
1874
|
+
const revisionName = (args.name ?? "").trim() || (args.color ? `${args.color.charAt(0).toUpperCase()}${args.color.slice(1)}` : "");
|
|
1875
|
+
if (!revisionName) return ok("No revision started: give it a name, or a colour to name it after.");
|
|
1876
|
+
const { changed, result, live } = await commit({ type: "start_revision", name: revisionName, color: args.color });
|
|
1877
|
+
if (!changed) return ok("No revision started: one is already in progress; end_revision first.");
|
|
1716
1878
|
return ok(`Started the ${result.color} revision "${result.name}"${where(live)}.`, { name: result.name, color: result.color, since: result.since });
|
|
1717
1879
|
},
|
|
1718
1880
|
);
|
|
@@ -1721,8 +1883,10 @@ server.registerTool(
|
|
|
1721
1883
|
"end_revision",
|
|
1722
1884
|
{ title: "End the revision", description: "The marks come off; the snapshot is dropped.", inputSchema: {} },
|
|
1723
1885
|
async () => {
|
|
1886
|
+
const { state: before } = await readBoard();
|
|
1887
|
+
|
|
1724
1888
|
const { changed, live } = await commit({ type: "end_revision" });
|
|
1725
|
-
return ok(changed ? `Revision ended${where(live)}.` : "No revision in progress.");
|
|
1889
|
+
return ok(changed ? `Revision "${before.revision?.name ?? ""}" (${before.revision?.color ?? ""}) ended${where(live)}: its marks come off and its snapshot is dropped.` : "No revision in progress.");
|
|
1726
1890
|
},
|
|
1727
1891
|
);
|
|
1728
1892
|
|
|
@@ -1740,7 +1904,7 @@ server.registerTool(
|
|
|
1740
1904
|
const { state } = await readBoard();
|
|
1741
1905
|
const { project } = await readProject();
|
|
1742
1906
|
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
1743
|
-
const brief = segmentBrief(state, args.ids, { title: board?.name });
|
|
1907
|
+
const brief = segmentBrief(state, args.ids, { title: board?.name, boards: project.boards });
|
|
1744
1908
|
if (!brief) return ok(`No cards with ids ${args.ids.join(", ")}. Call list_board.`);
|
|
1745
1909
|
const provider = env.PLOTCODER_VIDEO_PROVIDER;
|
|
1746
1910
|
if (!provider) {
|
|
@@ -1898,10 +2062,12 @@ server.registerTool(
|
|
|
1898
2062
|
trail.pop();
|
|
1899
2063
|
undone.push(last);
|
|
1900
2064
|
const { boardId } = await readBoard();
|
|
1901
|
-
const live = await writeBoard(last.before, rev, base, boardId);
|
|
2065
|
+
const live = await writeBoard(last.before, rev, base, boardId, "exact");
|
|
1902
2066
|
const orderLine = /^(move_scene|organize)/.test(last.what) ? ` Story order now: ${readingOrder(last.before.notes).map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}.` : "";
|
|
2067
|
+
const cardsDiff = last.before.notes.length - state.notes.length;
|
|
2068
|
+
const countLine = cardsDiff > 0 ? ` ${cardsDiff} card(s) back.` : cardsDiff < 0 ? ` ${-cardsDiff} card(s) gone.` : "";
|
|
1903
2069
|
const more = trail.length >= TRAIL_CAP ? `${trail.length} more of mine can be undone — the most I keep, so the oldest have gone` : `${trail.length} more of mine can be undone`;
|
|
1904
|
-
return ok(`Undid ${last.what}${where(live)}.${orderLine} ${more}.`, last.before);
|
|
2070
|
+
return ok(`Undid ${last.what}${where(live)}.${orderLine}${countLine} ${more}. list_board has the board.`, { undid: last.what, notes: last.before.notes.length, arrows: last.before.arrows.length, groups: last.before.groups.length });
|
|
1905
2071
|
},
|
|
1906
2072
|
);
|
|
1907
2073
|
|
|
@@ -1922,9 +2088,9 @@ server.registerTool(
|
|
|
1922
2088
|
}
|
|
1923
2089
|
undone.pop();
|
|
1924
2090
|
const after = normalizeState(JSON.parse(last.after));
|
|
1925
|
-
const live = await writeBoard(after, rev, base, boardId);
|
|
2091
|
+
const live = await writeBoard(after, rev, base, boardId, "exact");
|
|
1926
2092
|
trail.push(last);
|
|
1927
|
-
return ok(`Redid ${last.what}${where(live)}. ${undone.length} more can be redone.`, after);
|
|
2093
|
+
return ok(`Redid ${last.what}${where(live)}. ${undone.length} more can be redone. list_board has the board.`, { redid: last.what, notes: after.notes.length, arrows: after.arrows.length, groups: after.groups.length });
|
|
1928
2094
|
},
|
|
1929
2095
|
);
|
|
1930
2096
|
|
|
@@ -1933,7 +2099,7 @@ server.registerTool(
|
|
|
1933
2099
|
{
|
|
1934
2100
|
title: "Fold the corner",
|
|
1935
2101
|
description:
|
|
1936
|
-
`Fold the corner of cards — mark them as planting something — or unfold them. ${wordSentence("corner")} The setup arrow is create_arrow with kind 'setup'. A fold that pays off in a later episode: pass later, another board of the project by name, id or number
|
|
2102
|
+
`Fold the corner of cards — mark them as planting something — or unfold them. ${wordSentence("corner")} The setup arrow is create_arrow with kind 'setup'. A fold that pays off in a later episode: pass later, another board of the project by name, id or number — a board that exists; new_board makes one — and the wall stops asking where it comes back, listing the card under the reading's 'later' instead of 'payoffs'; later '' forgets it. Folding never moves a card.`,
|
|
1937
2103
|
inputSchema: {
|
|
1938
2104
|
ids: z.array(z.string()).min(1),
|
|
1939
2105
|
plants: z.boolean(),
|
|
@@ -1963,7 +2129,7 @@ server.registerTool(
|
|
|
1963
2129
|
}
|
|
1964
2130
|
} else {
|
|
1965
2131
|
const target = findBoard(project, args.later);
|
|
1966
|
-
if (!target) return ok(`No board
|
|
2132
|
+
if (!target) return ok(`No board called "${args.later}" yet. A fold pays off later on a board of the project: new_board "${args.later}" makes it (empty), open_board back to this one, then set_plant again with later.`);
|
|
1967
2133
|
if (target.id === (current ?? project.activeBoardId)) return ok(`"${target.name}" is this board. A payoff on the same board is a setup arrow: create_arrow from the fold to the scene, kind 'setup'.`);
|
|
1968
2134
|
const named = await commit({ type: "set_payoff_board", ids: args.ids, boardId: target.id });
|
|
1969
2135
|
if (named.changed) {
|
|
@@ -1992,7 +2158,7 @@ server.registerTool(
|
|
|
1992
2158
|
{
|
|
1993
2159
|
title: "Add character",
|
|
1994
2160
|
description:
|
|
1995
|
-
"Add a person to the
|
|
2161
|
+
"Add a person to the project's cast — one roster every board of the project casts from, so a person is one record across the pilot and the episodes after it. The same name twice is refused and the existing record returned. Add someone here before casting them on a card.",
|
|
1996
2162
|
inputSchema: { name: z.string().min(1) },
|
|
1997
2163
|
},
|
|
1998
2164
|
async (args) => {
|
|
@@ -2002,7 +2168,7 @@ server.registerTool(
|
|
|
2002
2168
|
? ok(`Already in the cast as "${result.name}" (${result.id}). Use that id.`, result)
|
|
2003
2169
|
: ok("No character added: the name was empty.");
|
|
2004
2170
|
}
|
|
2005
|
-
return ok(`Added "${result.name}" to the cast${where(live)}.`, result);
|
|
2171
|
+
return ok(`Added "${result.name}" to the project's cast${where(live)}; every board of the project casts from it.`, result);
|
|
2006
2172
|
},
|
|
2007
2173
|
);
|
|
2008
2174
|
|
|
@@ -2141,13 +2307,23 @@ server.registerTool(
|
|
|
2141
2307
|
{
|
|
2142
2308
|
title: "Remove character",
|
|
2143
2309
|
description:
|
|
2144
|
-
"Remove a person from the cast by id. They leave every card they were on
|
|
2310
|
+
"Remove a person from the project's cast by id. They leave every card they were on here; the cards themselves stay. Refused while another board of the project has them on a card: take them off there first.",
|
|
2145
2311
|
inputSchema: { id: z.string() },
|
|
2146
2312
|
},
|
|
2147
2313
|
async (args) => {
|
|
2314
|
+
const { state, boardId } = await readBoard();
|
|
2315
|
+
const person = state.characters.find((character) => character.id === args.id);
|
|
2316
|
+
if (!person) return ok(`No character with id ${args.id}. Call list_board for the cast.`);
|
|
2317
|
+
const { project, boards } = await readProject();
|
|
2318
|
+
const elsewhere = castElsewhere(project, boards, boardId ?? project.activeBoardId)[args.id] ?? [];
|
|
2319
|
+
if (elsewhere.length) {
|
|
2320
|
+
return ok(
|
|
2321
|
+
`"${person.name}" stays: the cast is the project's, and a person leaves it only when no board has them on a card — they are on ${elsewhere.map((item) => `${item.cards} card${item.cards === 1 ? "" : "s"} of "${item.board}"`).join(" and ")}. open_board there and cast them off those cards first, or leave them.`,
|
|
2322
|
+
);
|
|
2323
|
+
}
|
|
2148
2324
|
const { changed, live } = await commit({ type: "remove_character", id: args.id });
|
|
2149
2325
|
if (!changed) return ok(`No character with id ${args.id}. Call list_board for the cast.`);
|
|
2150
|
-
return ok(`Removed from the cast and from every card${where(live)}.`);
|
|
2326
|
+
return ok(`Removed "${person.name}" from the project's cast and from every card${where(live)}.`);
|
|
2151
2327
|
},
|
|
2152
2328
|
);
|
|
2153
2329
|
|
|
@@ -2280,6 +2456,7 @@ server.registerTool(
|
|
|
2280
2456
|
inputSchema: { from: z.string(), to: z.string(), kind: arrowKindSchema.optional() },
|
|
2281
2457
|
},
|
|
2282
2458
|
async (args) => {
|
|
2459
|
+
const { boardId } = await readBoard();
|
|
2283
2460
|
const { state, changed, result, live } = await commit({
|
|
2284
2461
|
type: "create_arrow",
|
|
2285
2462
|
from: args.from,
|
|
@@ -2290,13 +2467,19 @@ server.registerTool(
|
|
|
2290
2467
|
// Say which of the three reasons it was. "Something went wrong" makes an
|
|
2291
2468
|
// agent retry the same call; naming the cause makes it fix the input.
|
|
2292
2469
|
const onBoard = (id) => state.notes.some((note) => note.id === id);
|
|
2470
|
+
const { project: wholeProject, boards: allBoards } = await readProject();
|
|
2471
|
+
const elsewhere = (id) => wholeProject.boards.find((meta) => meta.id !== (boardId ?? wholeProject.activeBoardId) && isBoardState(allBoards[meta.id]) && allBoards[meta.id].notes.some((note) => note.id === id));
|
|
2472
|
+
const missing = (id) => {
|
|
2473
|
+
const other = elsewhere(id);
|
|
2474
|
+
return other ? `card ${id} is on another board, "${other.name}" — an arrow stays on one board; a fold that pays off there is set_plant with later: "${other.name}"` : `there is no card with id ${id}`;
|
|
2475
|
+
};
|
|
2293
2476
|
const why =
|
|
2294
2477
|
args.from === args.to
|
|
2295
2478
|
? "a card cannot point at itself"
|
|
2296
2479
|
: !onBoard(args.from)
|
|
2297
|
-
?
|
|
2480
|
+
? missing(args.from)
|
|
2298
2481
|
: !onBoard(args.to)
|
|
2299
|
-
?
|
|
2482
|
+
? missing(args.to)
|
|
2300
2483
|
: "that arrow already exists";
|
|
2301
2484
|
return ok(`No arrow drawn: ${why}. Call list_board to check.`);
|
|
2302
2485
|
}
|
|
@@ -2444,8 +2627,8 @@ server.registerTool(
|
|
|
2444
2627
|
const own = list.filter((item) => !item.builtIn).length;
|
|
2445
2628
|
return ok(
|
|
2446
2629
|
[
|
|
2447
|
-
`reminders on "${project.name}" (${door(live, base)}): ${list.length} — ${list.length - own} the house principles the app starts with (built in), ${own} the writer's own${own === 0 ? "; add_reminder adds one the writer asks to keep" : ""}`,
|
|
2448
|
-
...list.map((item) => ` - ${item.id}${item.builtIn ? " (built in)" : ""} — ${item.title}: ${item.body}`),
|
|
2630
|
+
`reminders on "${project.name}" (${door(live, base)}): ${list.length} — ${list.length - own} the house principles the app starts with (built in), ${own} the writer's own${own === 0 ? "; add_reminder adds one the writer asks to keep" : ""}. Reminders live on the project and go with it`,
|
|
2631
|
+
...list.map((item) => ` - ${item.id}${item.builtIn ? " (built in)" : ""} — ${item.body.replace(/\.$/, "").startsWith(item.title.replace(/\.$/, "")) ? item.body : `${item.title}: ${item.body}`}`),
|
|
2449
2632
|
].join("\n"),
|
|
2450
2633
|
list,
|
|
2451
2634
|
);
|
|
@@ -2584,12 +2767,13 @@ server.registerTool(
|
|
|
2584
2767
|
title: "Start a project",
|
|
2585
2768
|
description:
|
|
2586
2769
|
"Through the account door: start a new project of the writer's with this name — one empty board, nothing on it — and work it from now on. The writer sees it under Projects on every device.",
|
|
2587
|
-
inputSchema: { name: z.string().min(1), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
|
|
2770
|
+
inputSchema: { name: z.string().min(1), board: z.string().optional(), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
|
|
2588
2771
|
},
|
|
2589
2772
|
async (args) => {
|
|
2590
2773
|
const account = await findAccount();
|
|
2591
2774
|
if (!account) return shut("No account door: there is one project here, the open one. Set PLOTCODER_EMAIL and PLOTCODER_PASSWORD to start another on the writer's account.");
|
|
2592
|
-
|
|
2775
|
+
let record = renameProject(emptyProject(), args.name.trim());
|
|
2776
|
+
if (args.board?.trim()) record = renameBoard(record, record.activeBoardId, args.board.trim());
|
|
2593
2777
|
const inserted = await account.client.from("projects").insert({ id: record.id, record, reminders: null, rev: 1 });
|
|
2594
2778
|
if (inserted.error) return ok(`Could not start the project: ${inserted.error.message}`);
|
|
2595
2779
|
const target = args.pages ?? args.minutes;
|
|
@@ -2645,7 +2829,7 @@ server.registerTool(
|
|
|
2645
2829
|
for (const row of owned) plans.push(await deletionPlan(row));
|
|
2646
2830
|
const survive = shared.length ? ` ${shared.length} project(s) shared with the writer by others stay: ${shared.map((row) => `"${row.record.name}"`).join(", ")}.` : "";
|
|
2647
2831
|
if (plans.length === 0) return ok(`The account holds nothing of the writer's own to delete.${survive}`);
|
|
2648
|
-
if (!args.confirm) return ok(`Emptying the account deletes ${plans.length} project(s) of the writer's own: ${plans.map(describePlan).join("; ")}. Cannot be undone. Ask the writer; export_project each first if they might want them back
|
|
2832
|
+
if (!args.confirm) return ok(`Emptying the account deletes ${plans.length} project(s) of the writer's own: ${plans.map(describePlan).join("; ")}. Cannot be undone. Ask the writer, or if they have already said so, pass confirm: true now; export_project each first if they might want them back.${survive}`, plans);
|
|
2649
2833
|
for (const plan of plans) await deleteProjectRows(plan);
|
|
2650
2834
|
const next = await workWhatIsLeft(plans.map((plan) => plan.id));
|
|
2651
2835
|
return ok(`Emptied the account as ${account.email}: deleted ${plans.map(describePlan).join("; ")}.${survive}${next}`, plans);
|
|
@@ -2708,7 +2892,9 @@ server.registerTool(
|
|
|
2708
2892
|
const { project, boards, reminders } = await readProject();
|
|
2709
2893
|
const file = toProjectFile({ project, boards, reminders: reminders ?? null });
|
|
2710
2894
|
const cards = countCards(boards);
|
|
2711
|
-
const
|
|
2895
|
+
const ownReminders = (reminders ?? []).filter((item) => !item.builtIn).length;
|
|
2896
|
+
const builtInReminders = (reminders ?? []).length - ownReminders;
|
|
2897
|
+
const what = `"${project.name}": ${project.boards.length} board(s) — ${project.boards.map((meta) => `"${meta.name}" (${isBoardState(boards[meta.id]) ? boards[meta.id].notes.length : 0} cards)`).join(", ")} — ${cards} card(s) in all${reminders?.length ? `, ${reminders.length} reminder(s) (${builtInReminders} built in, ${ownReminders} the writer's own)` : ", the six built-in reminders come with every project and no reminders of the writer's own (none to write)"}${project.structures?.length ? `, ${project.structures.length} structure(s)` : ", no structures of the writer's own (none to write)"}. Pictures and takes on the account are not in the file`;
|
|
2712
2898
|
if (args.path) {
|
|
2713
2899
|
fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
|
|
2714
2900
|
fs.writeFileSync(args.path, JSON.stringify(file, null, 2));
|
|
@@ -2808,7 +2994,7 @@ server.registerTool(
|
|
|
2808
2994
|
const fresh = { ...emptyState(), ...(target ? { targetEighths: target } : {}) };
|
|
2809
2995
|
const { live } = await openBoardEverywhere(next, { ...boards, [board.id]: fresh }, rev, base, board.id);
|
|
2810
2996
|
return ok(
|
|
2811
|
-
`Added "${board.name}" (${board.id}) and opened it${where(live)}. It is empty. The logline is the story's question when the writer has one — leave it empty rather than invent it — and the cards come next.${next.name === "Untitled project" ? " The project is still \"Untitled project\": rename_project names it." : ""}${next.boards.length === 2 && isSampleWall(isBoardState(boards[next.boards[0].id]) ? normalizeState(boards[next.boards[0].id]) : emptyState()) ? " The sample stays as Board 1; delete_board drops it." : ""}`,
|
|
2997
|
+
`Added "${board.name}" (${board.id}) and opened it${where(live)}: every card call lands there now, and the writer's open wall switched with it; open_board "${project.boards.findIndex((item) => item.id === project.activeBoardId) + 1}" comes back. It is empty, and the project's cast is already there to cast from. The logline is the story's question when the writer has one — leave it empty rather than invent it — and the cards come next.${next.name === "Untitled project" ? " The project is still \"Untitled project\": rename_project names it." : ""}${next.boards.length === 2 && isSampleWall(isBoardState(boards[next.boards[0].id]) ? normalizeState(boards[next.boards[0].id]) : emptyState()) ? " The sample stays as Board 1; delete_board drops it." : ""}`,
|
|
2812
2998
|
board,
|
|
2813
2999
|
);
|
|
2814
3000
|
},
|
package/src/board/agents.js
CHANGED
|
@@ -42,12 +42,12 @@ export const AGENTS = {
|
|
|
42
42
|
text: "Skip this when the account is the wall. Without an account, a wall is a folder: any folder, empty is fine — choose one that will outlive your session, never a scratch one. The app run from that folder shows the wall, and the server writes it there (PLOTCODER_ROOT, or the folder it is run from). A fresh folder holds the sample; new_board for the writer's wall, then rename_project. No app running? export_fountain is the wall in order, as text.",
|
|
43
43
|
},
|
|
44
44
|
],
|
|
45
|
-
firstNote: "
|
|
45
|
+
firstNote: "Make these four before anything else; none depends on another, so any order is fine. They are about the wall you will work, so after open_project, open_board, new_project or empty_account, read_wall again. On an account with no project yet, read_wall has nothing to read and says so, and list_reminders gives the house principles every project starts with; new_project, then the four again. No server in front of you, and no shell to take the shell door? Nothing gets you in from inside the session: say so, and ask the person to wire the server and start a new session.",
|
|
46
46
|
first: [
|
|
47
47
|
{ tool: "list_words", why: "the room's words, the app's meaning." },
|
|
48
|
-
{ tool: "read_wall", why: "
|
|
48
|
+
{ tool: "read_wall", why: "the reading: the beats, the runs, the setups, and what the wall asks. The records — every card, the cast, the places — are list_board's. A fresh folder holds a sample wall (Maya, Tom, the letter) and says so; it is not the writer's." },
|
|
49
49
|
{ tool: "list_workflows", why: "what a writer can ask you for." },
|
|
50
|
-
{ tool: "list_reminders", why: "the house principles the app starts with, and the writer's own; read them before you change anything." },
|
|
50
|
+
{ tool: "list_reminders", why: "the house principles the app starts with, and the writer's own; read them before you change anything. Reminders live on the project and go with it." },
|
|
51
51
|
],
|
|
52
52
|
rules: [
|
|
53
53
|
"Questions, not fixes, until the writer says.",
|
|
@@ -64,7 +64,7 @@ export const AGENTS = {
|
|
|
64
64
|
|
|
65
65
|
/** The on-ramp as one text: the file at /llms.txt, and what an agent reads. */
|
|
66
66
|
export function agentsAsText() {
|
|
67
|
-
const lines = ["# PlotCoder — for agents", "", AGENTS.lead, "", `The guide
|
|
67
|
+
const lines = ["# PlotCoder — for agents", "", AGENTS.lead, "", `The guide: ${AGENTS.guide}. Read it once, before your first call if you can; it is the whole and this page is its first page, and where the two differ, the guide wins. Then Call these first, below. The doors between are for wiring a server in; skip them when the tools are already in front of you.`, "", "## Doors"];
|
|
68
68
|
for (const door of AGENTS.doors) {
|
|
69
69
|
lines.push(`- ${door.name}: ${door.text}`);
|
|
70
70
|
if (door.code) lines.push("", "```", door.code, "```", "");
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Type surface for compareStructure.js — a structure beside the wall (R52).
|
|
2
|
+
|
|
3
|
+
import type { BoardState } from "./reducer";
|
|
4
|
+
|
|
5
|
+
export declare const MATCH_PAGES: number;
|
|
6
|
+
export declare const NEAR_PAGES: number;
|
|
7
|
+
|
|
8
|
+
export type ComparedBeat = {
|
|
9
|
+
name: string;
|
|
10
|
+
at: number;
|
|
11
|
+
/** The page the structure's beat falls near on this board's target. */
|
|
12
|
+
page: number;
|
|
13
|
+
/** The wall's beat that answers it, or null. */
|
|
14
|
+
match: { id: string; headline: string; page: number } | null;
|
|
15
|
+
/** Pages the wall's beat is off by: negative is early. Null with no match. */
|
|
16
|
+
drift: number | null;
|
|
17
|
+
/** No match, and the page is past the story so far. */
|
|
18
|
+
beyond: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type StructureComparison = {
|
|
22
|
+
rows: ComparedBeat[];
|
|
23
|
+
/** The wall's beats no beat of the structure took. */
|
|
24
|
+
unmatched: Array<{ id: string; headline: string; page: number }>;
|
|
25
|
+
/** Pages on the wall so far. */
|
|
26
|
+
soFar: number;
|
|
27
|
+
targetEighths: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export declare function compareStructure(state: BoardState, beats: ReadonlyArray<{ name: string; at: number }>): StructureComparison;
|
|
31
|
+
export declare function driftWord(drift: number | null | undefined): string | null;
|
|
32
|
+
export declare function describeComparison(comparison: StructureComparison): string[];
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// A structure beside the wall (R52).
|
|
2
|
+
//
|
|
3
|
+
// A structure's beats each carry the share of the story they tend to fall
|
|
4
|
+
// near. Laying one out makes cards; this is the other thing the sheet's
|
|
5
|
+
// words promise — a comparison. Each of the structure's beats gets the page
|
|
6
|
+
// it falls near on this board's target, and the nearest of the wall's own
|
|
7
|
+
// beats within reach, one to one and in order, with how far off it is. A
|
|
8
|
+
// reading, like read_wall: it moves nothing and makes nothing.
|
|
9
|
+
//
|
|
10
|
+
// Pure and DOM-free, like the kernel: the sheet, the strip and the MCP
|
|
11
|
+
// server all read the same rows.
|
|
12
|
+
|
|
13
|
+
import { readingOrder } from "./readWall.js";
|
|
14
|
+
import { EIGHTHS_PER_PAGE, noteEighths } from "./reducer.js";
|
|
15
|
+
import { beatPage } from "./templates.js";
|
|
16
|
+
|
|
17
|
+
/** Within this many pages a beat of the wall answers a beat of the structure. */
|
|
18
|
+
export const MATCH_PAGES = 6;
|
|
19
|
+
/** Within this many pages the match is "near" rather than early or late. */
|
|
20
|
+
export const NEAR_PAGES = 2;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Set a structure's beats beside this wall's. `beats` are a template's or a
|
|
24
|
+
* saved structure's: `{ name, at }`. The wall's beats are taken in reading
|
|
25
|
+
* order; each of the structure's takes the nearest wall beat not yet taken
|
|
26
|
+
* and not before the last one taken, within MATCH_PAGES, so the matching
|
|
27
|
+
* never crosses.
|
|
28
|
+
*/
|
|
29
|
+
export function compareStructure(state, beats) {
|
|
30
|
+
const order = readingOrder(state.notes);
|
|
31
|
+
const wallBeats = [];
|
|
32
|
+
let at = 0;
|
|
33
|
+
for (const note of order) {
|
|
34
|
+
if (note.rank === "beat") {
|
|
35
|
+
wallBeats.push({ id: note.id, headline: note.headline, page: Math.floor(at / EIGHTHS_PER_PAGE) + 1 });
|
|
36
|
+
}
|
|
37
|
+
at += noteEighths(note);
|
|
38
|
+
}
|
|
39
|
+
const soFar = Math.ceil(at / EIGHTHS_PER_PAGE);
|
|
40
|
+
let from = 0;
|
|
41
|
+
const taken = new Set();
|
|
42
|
+
const rows = beats.map((beat) => {
|
|
43
|
+
const page = beatPage(beat.at, state.targetEighths);
|
|
44
|
+
let best = -1;
|
|
45
|
+
for (let i = from; i < wallBeats.length; i += 1) {
|
|
46
|
+
const gap = Math.abs(wallBeats[i].page - page);
|
|
47
|
+
if (gap > MATCH_PAGES) continue;
|
|
48
|
+
if (best < 0 || gap < Math.abs(wallBeats[best].page - page)) best = i;
|
|
49
|
+
}
|
|
50
|
+
if (best >= 0) {
|
|
51
|
+
taken.add(best);
|
|
52
|
+
from = best + 1;
|
|
53
|
+
}
|
|
54
|
+
const match = best >= 0 ? wallBeats[best] : null;
|
|
55
|
+
return {
|
|
56
|
+
name: beat.name,
|
|
57
|
+
at: beat.at,
|
|
58
|
+
page,
|
|
59
|
+
match,
|
|
60
|
+
drift: match ? match.page - page : null,
|
|
61
|
+
// No match and the page is past the story so far: nothing is there yet.
|
|
62
|
+
beyond: !match && page > soFar,
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
const unmatched = wallBeats.filter((_, index) => !taken.has(index));
|
|
66
|
+
return { rows, unmatched, soFar, targetEighths: state.targetEighths };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The drift as a word or two: here, near, 3 pp early, 12 pp late. */
|
|
70
|
+
export function driftWord(drift) {
|
|
71
|
+
if (drift === null || drift === undefined) return null;
|
|
72
|
+
if (drift === 0) return "here";
|
|
73
|
+
if (Math.abs(drift) <= NEAR_PAGES) return "near";
|
|
74
|
+
return drift < 0 ? `${-drift} pp early` : `${drift} pp late`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One line per row, for a reply or a sheet: the structure's beat, its page, and the wall's answer. */
|
|
78
|
+
export function describeComparison(comparison) {
|
|
79
|
+
return comparison.rows.map((row) => {
|
|
80
|
+
const head = `${row.name} (p. ${row.page})`;
|
|
81
|
+
if (row.match) return `${head} — yours: "${row.match.headline}" p. ${row.match.page} · ${driftWord(row.drift)}`;
|
|
82
|
+
if (row.beyond) return `${head} — nothing yet: past p. ${comparison.soFar}, the story so far`;
|
|
83
|
+
return `${head} — none of yours within ${MATCH_PAGES} pages`;
|
|
84
|
+
});
|
|
85
|
+
}
|
package/src/board/project.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Type surface for project.js — the project record (R35).
|
|
2
2
|
|
|
3
|
-
import type { BoardNote } from "./reducer";
|
|
3
|
+
import type { BoardCharacter, BoardNote, BoardState } from "./reducer";
|
|
4
4
|
|
|
5
5
|
export declare const PROJECT_VERSION: number;
|
|
6
6
|
export declare const DEFAULT_PROJECT_NAME: string;
|
|
@@ -22,6 +22,8 @@ export type ProjectRecord = {
|
|
|
22
22
|
activeBoardId: string;
|
|
23
23
|
/** A writer's own structures, saved from a wall's beats (Roadmap 2, item 7). */
|
|
24
24
|
structures?: OwnStructure[];
|
|
25
|
+
/** The project's cast (R51): one roster every board draws from. Absent until liftCast has run. */
|
|
26
|
+
characters?: BoardCharacter[];
|
|
25
27
|
createdAt: string;
|
|
26
28
|
updatedAt: string;
|
|
27
29
|
};
|
|
@@ -70,3 +72,19 @@ export declare function reidentifyProject(
|
|
|
70
72
|
project: ProjectRecord,
|
|
71
73
|
now?: string,
|
|
72
74
|
): ProjectRecord & { renamed: Record<string, string> };
|
|
75
|
+
|
|
76
|
+
/** A board's state composed with the project's cast (R51). */
|
|
77
|
+
export declare function withRoster(state: BoardState, project: ProjectRecord): BoardState;
|
|
78
|
+
export declare function sameRoster(a: BoardCharacter[] | undefined, b: BoardCharacter[] | undefined): boolean;
|
|
79
|
+
export declare function liftCast(
|
|
80
|
+
project: ProjectRecord,
|
|
81
|
+
boards: Record<string, BoardState>,
|
|
82
|
+
now?: string,
|
|
83
|
+
): { project: ProjectRecord; boards: Record<string, BoardState>; changed: boolean };
|
|
84
|
+
export type CastElsewhere = Record<string, Array<{ board: string; boardId: string; cards: number }>>;
|
|
85
|
+
export declare function castElsewhere(project: ProjectRecord, boards: Record<string, BoardState>, activeBoardId: string): CastElsewhere;
|
|
86
|
+
export declare function mergeRoster(
|
|
87
|
+
project: ProjectRecord,
|
|
88
|
+
state: BoardState,
|
|
89
|
+
now?: string,
|
|
90
|
+
): { project: ProjectRecord; state: BoardState };
|
package/src/board/project.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// Plain ESM with a sibling .d.ts, like the kernel, so the browser store and the
|
|
10
10
|
// MCP server share one idea of what a project is. Keep it free of `window`.
|
|
11
11
|
|
|
12
|
-
import { newId, noteEighths, nowIso } from "./reducer.js";
|
|
12
|
+
import { CHARACTER_FIELDS, fillCharacter, isCharacter, newId, normalizeState, noteEighths, nowIso, sameName } from "./reducer.js";
|
|
13
13
|
|
|
14
14
|
export const PROJECT_VERSION = 2;
|
|
15
15
|
export const DEFAULT_PROJECT_NAME = "Untitled project";
|
|
@@ -75,7 +75,7 @@ export function normalizeProject(value, now = nowIso()) {
|
|
|
75
75
|
: [];
|
|
76
76
|
// `renamed` is reidentifyProject's map for the store, never part of the record.
|
|
77
77
|
const { renamed: _renamed, ...rest } = value;
|
|
78
|
-
|
|
78
|
+
const record = {
|
|
79
79
|
...rest,
|
|
80
80
|
version: PROJECT_VERSION,
|
|
81
81
|
name: trimmed(value.name, DEFAULT_PROJECT_NAME),
|
|
@@ -86,6 +86,138 @@ export function normalizeProject(value, now = nowIso()) {
|
|
|
86
86
|
createdAt: typeof value.createdAt === "string" ? value.createdAt : now,
|
|
87
87
|
updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : now,
|
|
88
88
|
};
|
|
89
|
+
// The project's cast (R51). A record with none has not been lifted yet —
|
|
90
|
+
// its boards still carry their own rosters — and liftCast does that at the
|
|
91
|
+
// next load boundary; so absent stays absent, and never becomes [].
|
|
92
|
+
if (Array.isArray(value.characters)) record.characters = value.characters.filter(isCharacter).map(fillCharacter);
|
|
93
|
+
else delete record.characters;
|
|
94
|
+
return record;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- The project's cast (R51) -----------------------------------------
|
|
98
|
+
//
|
|
99
|
+
// One roster for the project, on the record, that every board draws from.
|
|
100
|
+
// A board's state still carries `characters`, but as a copy of the
|
|
101
|
+
// project's: every load boundary composes it with withRoster, and every
|
|
102
|
+
// store lifts a roster a kernel command changed back onto the record. So
|
|
103
|
+
// the kernel, the readings and the wall go on reading state.characters,
|
|
104
|
+
// and there is one Nessa across the pilot and episode two.
|
|
105
|
+
|
|
106
|
+
/** A board's state with the project's cast in it; unknown cast ids on cards drop. */
|
|
107
|
+
export function withRoster(state, project) {
|
|
108
|
+
if (!Array.isArray(project.characters) || sameRoster(project.characters, state.characters)) return state;
|
|
109
|
+
const next = normalizeState({ ...state, characters: project.characters });
|
|
110
|
+
return next.characters === project.characters ? next : { ...next, characters: project.characters };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** True when two rosters are the same people with the same pages. */
|
|
114
|
+
export function sameRoster(a, b) {
|
|
115
|
+
if (a === b) return true;
|
|
116
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
117
|
+
return a.every((character, index) => {
|
|
118
|
+
const other = b[index];
|
|
119
|
+
return other && character.id === other.id && character.name === other.name && CHARACTER_FIELDS.every((field) => (character[field] ?? "") === (other[field] ?? ""));
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Lift the cast onto the project. A record that already has one composes
|
|
125
|
+
* every board with it. A record with none — written before R51 — takes the
|
|
126
|
+
* boards' rosters in board order and merges them by name: the first record
|
|
127
|
+
* of a name keeps its id, later ones fold into it (a page line fills from the
|
|
128
|
+
* first board that had it), and every card that cast a folded id casts the
|
|
129
|
+
* kept one. Returns the record, every board composed, and whether anything
|
|
130
|
+
* changed.
|
|
131
|
+
*/
|
|
132
|
+
export function liftCast(project, boards, now = nowIso()) {
|
|
133
|
+
if (Array.isArray(project.characters)) {
|
|
134
|
+
let changed = false;
|
|
135
|
+
const out = {};
|
|
136
|
+
for (const [id, state] of Object.entries(boards)) {
|
|
137
|
+
const next = withRoster(state, project);
|
|
138
|
+
if (next !== state) changed = true;
|
|
139
|
+
out[id] = next;
|
|
140
|
+
}
|
|
141
|
+
return { project, boards: out, changed };
|
|
142
|
+
}
|
|
143
|
+
const roster = [];
|
|
144
|
+
const folded = {};
|
|
145
|
+
const order = [
|
|
146
|
+
...project.boards.map((meta) => meta.id).filter((id) => boards[id]),
|
|
147
|
+
...Object.keys(boards).filter((id) => !project.boards.some((meta) => meta.id === id)),
|
|
148
|
+
];
|
|
149
|
+
for (const boardId of order) {
|
|
150
|
+
const map = {};
|
|
151
|
+
for (const character of boards[boardId].characters ?? []) {
|
|
152
|
+
if (!isCharacter(character)) continue;
|
|
153
|
+
const kept = roster.find((item) => sameName(item.name, character.name));
|
|
154
|
+
if (!kept) {
|
|
155
|
+
roster.push(fillCharacter({ ...character }));
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
map[character.id] = kept.id;
|
|
159
|
+
for (const field of CHARACTER_FIELDS) {
|
|
160
|
+
if (!kept[field].trim() && typeof character[field] === "string" && character[field].trim()) kept[field] = character[field];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
folded[boardId] = map;
|
|
164
|
+
}
|
|
165
|
+
const lifted = { ...project, characters: roster, updatedAt: now };
|
|
166
|
+
const out = {};
|
|
167
|
+
for (const [boardId, state] of Object.entries(boards)) {
|
|
168
|
+
const map = folded[boardId] ?? {};
|
|
169
|
+
const notes = state.notes.map((note) => {
|
|
170
|
+
const ids = [...new Set(note.characterIds.map((id) => map[id] ?? id))];
|
|
171
|
+
return ids.length === note.characterIds.length && ids.every((id, index) => id === note.characterIds[index]) ? note : { ...note, characterIds: ids };
|
|
172
|
+
});
|
|
173
|
+
out[boardId] = normalizeState({ ...state, notes, characters: roster });
|
|
174
|
+
}
|
|
175
|
+
return { project: lifted, boards: out, changed: true };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A board with a roster of its own — an imported file, a board opened from
|
|
180
|
+
* elsewhere — joins the project's cast without shrinking it: a name the
|
|
181
|
+
* project already has keeps the project's record (its cards recast to that
|
|
182
|
+
* id), a new name is appended with the record it came with. Returns the
|
|
183
|
+
* record and the board composed with it.
|
|
184
|
+
*/
|
|
185
|
+
export function mergeRoster(project, state, now = nowIso()) {
|
|
186
|
+
if (!Array.isArray(project.characters)) return { project, state };
|
|
187
|
+
const roster = [...project.characters];
|
|
188
|
+
const map = {};
|
|
189
|
+
let grew = false;
|
|
190
|
+
for (const character of state.characters ?? []) {
|
|
191
|
+
if (!isCharacter(character)) continue;
|
|
192
|
+
const kept = roster.find((item) => sameName(item.name, character.name));
|
|
193
|
+
if (kept) {
|
|
194
|
+
if (kept.id !== character.id) map[character.id] = kept.id;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
roster.push(fillCharacter({ ...character }));
|
|
198
|
+
grew = true;
|
|
199
|
+
}
|
|
200
|
+
const next = grew ? { ...project, characters: roster, updatedAt: now } : project;
|
|
201
|
+
const notes = state.notes.map((note) => {
|
|
202
|
+
const ids = [...new Set(note.characterIds.map((id) => map[id] ?? id))];
|
|
203
|
+
return ids.length === note.characterIds.length && ids.every((id, index) => id === note.characterIds[index]) ? note : { ...note, characterIds: ids };
|
|
204
|
+
});
|
|
205
|
+
const recast = notes.some((note, index) => note !== state.notes[index]) ? { ...state, notes } : state;
|
|
206
|
+
return { project: next, state: withRoster(recast, next) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Which other boards of the project have each person on a card: id -> [{ board, cards }]. */
|
|
210
|
+
export function castElsewhere(project, boards, activeBoardId) {
|
|
211
|
+
const map = {};
|
|
212
|
+
for (const meta of project.boards) {
|
|
213
|
+
if (meta.id === activeBoardId) continue;
|
|
214
|
+
const state = boards[meta.id];
|
|
215
|
+
if (!state) continue;
|
|
216
|
+
const counts = {};
|
|
217
|
+
for (const note of state.notes) for (const id of note.characterIds ?? []) counts[id] = (counts[id] ?? 0) + 1;
|
|
218
|
+
for (const [id, cards] of Object.entries(counts)) (map[id] ??= []).push({ board: meta.name, boardId: meta.id, cards });
|
|
219
|
+
}
|
|
220
|
+
return map;
|
|
89
221
|
}
|
|
90
222
|
|
|
91
223
|
function touch(project, patch, now) {
|
package/src/board/readWall.d.ts
CHANGED
|
@@ -63,11 +63,20 @@ export type WallReading = {
|
|
|
63
63
|
payoffs: Record<string, string[]>;
|
|
64
64
|
/** Folded cards that pay off on another board of the project (R50): the card and the board. */
|
|
65
65
|
later: { id: string; boardId: string }[];
|
|
66
|
+
/** The questions the wall asks now. A left one (R53) is not here while its words hold. */
|
|
66
67
|
findings: Finding[];
|
|
68
|
+
/** Questions the writer has left, for now: the same question, with when it was left. */
|
|
69
|
+
left: Array<Finding & { since: string }>;
|
|
67
70
|
};
|
|
68
71
|
|
|
69
72
|
/** Rows top to bottom, cards left to right within a row. */
|
|
70
73
|
export declare function readingOrder(notes: BoardNote[]): BoardNote[];
|
|
71
|
-
export declare function readWall(
|
|
74
|
+
export declare function readWall(
|
|
75
|
+
state: BoardState,
|
|
76
|
+
options?: {
|
|
77
|
+
/** Cast ids on a card of another board of the project (R51): not asked about as uncast here. */
|
|
78
|
+
elsewhere?: string[];
|
|
79
|
+
},
|
|
80
|
+
): WallReading;
|
|
72
81
|
export declare function describeRuns(reading: WallReading, state: BoardState): string[];
|
|
73
82
|
export declare function describeSetups(reading: WallReading, state: BoardState): string[];
|
package/src/board/readWall.js
CHANGED
|
@@ -110,7 +110,10 @@ function list(notes) {
|
|
|
110
110
|
* Read the board. Returns the reading and the findings; see readWall.d.ts for
|
|
111
111
|
* the shape. Never mutates the state.
|
|
112
112
|
*/
|
|
113
|
-
export function readWall(state) {
|
|
113
|
+
export function readWall(state, options = {}) {
|
|
114
|
+
// People on a card of another board of the project (R51) are cast, and
|
|
115
|
+
// are not asked about here.
|
|
116
|
+
const elsewhere = new Set(Array.isArray(options.elsewhere) ? options.elsewhere : []);
|
|
114
117
|
const order = readingOrder(state.notes);
|
|
115
118
|
const beats = order.filter((note) => note.rank === "beat");
|
|
116
119
|
|
|
@@ -304,7 +307,10 @@ export function readWall(state) {
|
|
|
304
307
|
.map((arrow) => arrow.to)
|
|
305
308
|
.sort((a, b) => (wallIndex.get(a) ?? Infinity) - (wallIndex.get(b) ?? Infinity));
|
|
306
309
|
// Every payoff, in wall order: a card can plant two things (the ledger
|
|
307
|
-
// pays off at the cash and again at the initials), and both count.
|
|
310
|
+
// pays off at the cash and again at the initials), and both count. A
|
|
311
|
+
// fold that pays off only on another board is not here at all — it is
|
|
312
|
+
// under `later` — so the two never disagree about one card.
|
|
313
|
+
if (!heads.length && note.payoffBoardId) continue;
|
|
308
314
|
payoffs[note.id] = heads;
|
|
309
315
|
}
|
|
310
316
|
// A fold that pays off on another board (R50) is not unpaid: it is listed
|
|
@@ -331,6 +337,7 @@ export function readWall(state) {
|
|
|
331
337
|
for (const character of state.characters ?? []) {
|
|
332
338
|
const scenes = order.filter((note) => note.characterIds?.includes(character.id));
|
|
333
339
|
if (scenes.length === 0) {
|
|
340
|
+
if (elsewhere.has(character.id)) continue;
|
|
334
341
|
findings.push({
|
|
335
342
|
kind: "uncast",
|
|
336
343
|
ids: [character.id],
|
|
@@ -369,6 +376,21 @@ export function readWall(state) {
|
|
|
369
376
|
}
|
|
370
377
|
}
|
|
371
378
|
|
|
379
|
+
// A question the writer has left (R53) is held back while it is still the
|
|
380
|
+
// same question — same kind, same cards, same words. The moment it would
|
|
381
|
+
// read differently (a page moved, a headline changed, the median shifted)
|
|
382
|
+
// it is a new question and is asked. The kernel never decides this; the
|
|
383
|
+
// reading does, on every read.
|
|
384
|
+
const left = [];
|
|
385
|
+
const asked = findings.filter((finding) => {
|
|
386
|
+
const entry = (state.left ?? []).find(
|
|
387
|
+
(item) => item.kind === finding.kind && sameList(item.ids, finding.ids) && item.text === finding.text,
|
|
388
|
+
);
|
|
389
|
+
if (!entry) return true;
|
|
390
|
+
left.push({ ...finding, since: entry.since });
|
|
391
|
+
return false;
|
|
392
|
+
});
|
|
393
|
+
|
|
372
394
|
return {
|
|
373
395
|
order: order.map((note) => note.id),
|
|
374
396
|
beats: beats.map((note) => ({ id: note.id, headline: note.headline })),
|
|
@@ -376,10 +398,15 @@ export function readWall(state) {
|
|
|
376
398
|
setups,
|
|
377
399
|
payoffs,
|
|
378
400
|
later,
|
|
379
|
-
findings,
|
|
401
|
+
findings: asked,
|
|
402
|
+
left,
|
|
380
403
|
};
|
|
381
404
|
}
|
|
382
405
|
|
|
406
|
+
function sameList(a, b) {
|
|
407
|
+
return a.length === b.length && a.every((id, index) => id === b[index]);
|
|
408
|
+
}
|
|
409
|
+
|
|
383
410
|
/** The setups as prose lines: what plants what, and how far apart. */
|
|
384
411
|
export function describeSetups(reading, state) {
|
|
385
412
|
const byId = new Map(state.notes.map((note) => [note.id, note]));
|
package/src/board/reducer.d.ts
CHANGED
|
@@ -117,10 +117,25 @@ export type BoardState = {
|
|
|
117
117
|
lock: import("./numbering").Lock | null;
|
|
118
118
|
/** The revision in progress — a name, a colour, a snapshot — or null. */
|
|
119
119
|
revision: import("./numbering").Revision | null;
|
|
120
|
+
/** Questions the writer has left, for now (R53): kept until the question would read differently. */
|
|
121
|
+
left: LeftQuestion[];
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** A question the wall asked and the writer left (R53). */
|
|
125
|
+
export type LeftQuestion = {
|
|
126
|
+
kind: string;
|
|
127
|
+
ids: string[];
|
|
128
|
+
/** The question's words when it was left; it comes back when they would differ. */
|
|
129
|
+
text: string;
|
|
130
|
+
since: string;
|
|
120
131
|
};
|
|
121
132
|
|
|
122
133
|
export type Pose = { id: string; x: number; y: number; rotate: number };
|
|
123
134
|
|
|
135
|
+
export declare function isCharacter(value: unknown): value is { id: string; name: string };
|
|
136
|
+
export declare function fillCharacter(character: { id: string; name: string } & Partial<BoardCharacter>): BoardCharacter;
|
|
137
|
+
export declare function sameName(a: string, b: string): boolean;
|
|
138
|
+
|
|
124
139
|
export type Command =
|
|
125
140
|
| { type: "set_logline"; logline: string }
|
|
126
141
|
| { type: "set_rank"; ids: string[]; rank: NoteRank }
|
|
@@ -170,7 +185,9 @@ export type Command =
|
|
|
170
185
|
| { type: "lock_numbers"; order?: string[] }
|
|
171
186
|
| { type: "unlock_numbers" }
|
|
172
187
|
| { type: "start_revision"; name: string; color?: string }
|
|
173
|
-
| { type: "end_revision" }
|
|
188
|
+
| { type: "end_revision" }
|
|
189
|
+
| { type: "leave_question"; kind: string; ids: string[]; text: string }
|
|
190
|
+
| { type: "ask_again"; kind: string; ids?: string[] };
|
|
174
191
|
|
|
175
192
|
export type CommandResult = {
|
|
176
193
|
state: BoardState;
|
package/src/board/reducer.js
CHANGED
|
@@ -33,7 +33,7 @@ import { lockFrom, REVISION_COLORS } from "./numbering.js";
|
|
|
33
33
|
// same person on every card. Long term the record grows — what they look like,
|
|
34
34
|
// the details a writer needs to pull up — which is why it has an id and
|
|
35
35
|
// timestamps now rather than being a word on a card.
|
|
36
|
-
function isCharacter(value) {
|
|
36
|
+
export function isCharacter(value) {
|
|
37
37
|
return Boolean(value) && typeof value.id === "string" && typeof value.name === "string";
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -45,7 +45,7 @@ function isCharacter(value) {
|
|
|
45
45
|
export const CHARACTER_FIELDS = ["looks", "voice", "wants", "needs", "notes"];
|
|
46
46
|
|
|
47
47
|
/** A roster record with every page field present, so the page never reads undefined. */
|
|
48
|
-
function fillCharacter(character) {
|
|
48
|
+
export function fillCharacter(character) {
|
|
49
49
|
let filled = character;
|
|
50
50
|
for (const field of CHARACTER_FIELDS) {
|
|
51
51
|
if (typeof filled[field] !== "string") {
|
|
@@ -93,7 +93,7 @@ export function atPlace(note, place) {
|
|
|
93
93
|
return Boolean(note.location) && samePlace(note.location, place);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
function sameName(a, b) {
|
|
96
|
+
export function sameName(a, b) {
|
|
97
97
|
return a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
98
98
|
}
|
|
99
99
|
|
|
@@ -101,6 +101,19 @@ function sameIds(a, b) {
|
|
|
101
101
|
return a.length === b.length && a.every((id, index) => id === b[index]);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/** A question the writer has left (R53): its kind, the cards it was about, and the words it had. */
|
|
105
|
+
function isLeftQuestion(value) {
|
|
106
|
+
return (
|
|
107
|
+
!!value &&
|
|
108
|
+
typeof value === "object" &&
|
|
109
|
+
typeof value.kind === "string" &&
|
|
110
|
+
Array.isArray(value.ids) &&
|
|
111
|
+
value.ids.every((id) => typeof id === "string") &&
|
|
112
|
+
typeof value.text === "string" &&
|
|
113
|
+
typeof value.since === "string"
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
104
117
|
/** Keep only ids that name someone in the roster, once each, in the order given. */
|
|
105
118
|
function knownCast(ids, characters) {
|
|
106
119
|
if (!Array.isArray(ids)) return [];
|
|
@@ -213,6 +226,7 @@ export function emptyState() {
|
|
|
213
226
|
arrows: [],
|
|
214
227
|
lock: null,
|
|
215
228
|
revision: null,
|
|
229
|
+
left: [],
|
|
216
230
|
};
|
|
217
231
|
}
|
|
218
232
|
|
|
@@ -259,6 +273,7 @@ export function seedState(now = nowIso()) {
|
|
|
259
273
|
arrows: [],
|
|
260
274
|
lock: null,
|
|
261
275
|
revision: null,
|
|
276
|
+
left: [],
|
|
262
277
|
};
|
|
263
278
|
}
|
|
264
279
|
|
|
@@ -355,6 +370,10 @@ export function normalizeState(value) {
|
|
|
355
370
|
// lock and no revision; both are null until a draft goes out.
|
|
356
371
|
const lock = value.lock && typeof value.lock === "object" && value.lock.numbers ? value.lock : null;
|
|
357
372
|
const revision = value.revision && typeof value.revision === "object" && typeof value.revision.name === "string" ? value.revision : null;
|
|
373
|
+
// Boards written before R53 have no left questions: nothing is left until
|
|
374
|
+
// the writer leaves it.
|
|
375
|
+
const left = Array.isArray(value.left) ? value.left.filter(isLeftQuestion) : [];
|
|
376
|
+
const leftPatched = !Array.isArray(value.left) || left.length !== value.left.length;
|
|
358
377
|
if (
|
|
359
378
|
value.logline === logline &&
|
|
360
379
|
value.targetEighths === targetEighths &&
|
|
@@ -362,7 +381,8 @@ export function normalizeState(value) {
|
|
|
362
381
|
!arrowsPatched &&
|
|
363
382
|
!patched &&
|
|
364
383
|
value.lock === lock &&
|
|
365
|
-
value.revision === revision
|
|
384
|
+
value.revision === revision &&
|
|
385
|
+
!leftPatched
|
|
366
386
|
) {
|
|
367
387
|
return value;
|
|
368
388
|
}
|
|
@@ -375,6 +395,7 @@ export function normalizeState(value) {
|
|
|
375
395
|
arrows: arrowsPatched ? arrows : value.arrows,
|
|
376
396
|
lock,
|
|
377
397
|
revision,
|
|
398
|
+
left,
|
|
378
399
|
};
|
|
379
400
|
}
|
|
380
401
|
|
|
@@ -809,7 +830,8 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
809
830
|
id: newId(),
|
|
810
831
|
headline: item.name,
|
|
811
832
|
change: item.prompt,
|
|
812
|
-
|
|
833
|
+
// One colour: paper means nothing to the app, and a structure is not a pattern (round eleven).
|
|
834
|
+
color: "yellow",
|
|
813
835
|
x: left + (index % 5) * (NOTE_WIDTH + 28),
|
|
814
836
|
y: top + Math.floor(index / 5) * (NOTE_HEIGHT + 40),
|
|
815
837
|
rotate: ((index % 5) - 2) * 0.8,
|
|
@@ -878,6 +900,31 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
878
900
|
return { state: { ...state, revision: null }, changed: true, result: null };
|
|
879
901
|
}
|
|
880
902
|
|
|
903
|
+
// Leaving a question (R53): the writer's word on a question the wall
|
|
904
|
+
// asks, written on the wall as the question's kind, the cards it was
|
|
905
|
+
// about and the words it had. The reading holds it back while a question
|
|
906
|
+
// with those words is still what the wall would ask, and asks again on
|
|
907
|
+
// its own the moment the question would read differently. Never a
|
|
908
|
+
// dismissal: the kernel records the word; the reading decides.
|
|
909
|
+
case "leave_question": {
|
|
910
|
+
const kind = typeof command.kind === "string" ? command.kind : "";
|
|
911
|
+
const ids = Array.isArray(command.ids) ? command.ids.filter((id) => typeof id === "string") : [];
|
|
912
|
+
const text = typeof command.text === "string" ? command.text : "";
|
|
913
|
+
if (!kind || !text) return { state, changed: false };
|
|
914
|
+
const entry = { kind, ids, text, since: now };
|
|
915
|
+
const rest = (state.left ?? []).filter((item) => !(item.kind === kind && sameIds(item.ids, ids)));
|
|
916
|
+
return { state: { ...state, left: [...rest, entry] }, changed: true, result: entry };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
case "ask_again": {
|
|
920
|
+
const kind = typeof command.kind === "string" ? command.kind : "";
|
|
921
|
+
const ids = Array.isArray(command.ids) ? command.ids : null;
|
|
922
|
+
const gone = (state.left ?? []).filter((item) => item.kind === kind && (!ids || sameIds(item.ids, ids)));
|
|
923
|
+
if (gone.length === 0) return { state, changed: false };
|
|
924
|
+
const left = (state.left ?? []).filter((item) => !gone.includes(item));
|
|
925
|
+
return { state: { ...state, left }, changed: true, result: gone };
|
|
926
|
+
}
|
|
927
|
+
|
|
881
928
|
// Where a scene happens (R37): one place on one or more cards; an empty
|
|
882
929
|
// place clears it.
|
|
883
930
|
case "set_location": {
|
package/src/board/workflows.d.ts
CHANGED
|
@@ -22,5 +22,5 @@ export declare function workflowById(id: string): Workflow | null;
|
|
|
22
22
|
export declare function segmentBrief(
|
|
23
23
|
state: BoardState,
|
|
24
24
|
ids: string[],
|
|
25
|
-
options?: { title?: string },
|
|
25
|
+
options?: { title?: string; boards?: { id: string; name: string }[] },
|
|
26
26
|
): string | null;
|
package/src/board/workflows.js
CHANGED
|
@@ -91,6 +91,7 @@ function personLine(character) {
|
|
|
91
91
|
if (character.voice) lines.push(`voice: ${character.voice}`);
|
|
92
92
|
if (character.wants) lines.push(`wants: ${character.wants}`);
|
|
93
93
|
if (character.needs) lines.push(`needs: ${character.needs}`);
|
|
94
|
+
if (character.notes) lines.push(`notes: ${character.notes}`);
|
|
94
95
|
return `${character.name}${lines.length ? ` — ${lines.join("; ")}` : " — (no page yet)"}`;
|
|
95
96
|
}
|
|
96
97
|
|
|
@@ -120,7 +121,12 @@ export function segmentBrief(state, ids, options = {}) {
|
|
|
120
121
|
lines.push("");
|
|
121
122
|
lines.push(`SCENE: ${note.headline}${note.location ? ` — at ${note.location}` : ""}`);
|
|
122
123
|
lines.push(`WHAT CHANGES: ${note.change}`);
|
|
123
|
-
if (note.plants)
|
|
124
|
+
if (note.plants) {
|
|
125
|
+
const heads = state.arrows.filter((arrow) => arrow.kind === "setup" && arrow.from === note.id).map((arrow) => byId.get(arrow.to)?.headline).filter(Boolean);
|
|
126
|
+
const later = note.payoffBoardId ? (options.boards ?? []).find((board) => board.id === note.payoffBoardId)?.name ?? "a later board" : null;
|
|
127
|
+
const where = heads.length ? `pays off at ${heads.map((headline) => `"${headline}"`).join(" and ")}` : later ? `pays off later, on "${later}"` : "pays off later, nowhere yet";
|
|
128
|
+
lines.push(`PLANTS: something here ${where}; keep it visible.`);
|
|
129
|
+
}
|
|
124
130
|
if (note.text && note.text.trim()) {
|
|
125
131
|
lines.push("SCRIPT:");
|
|
126
132
|
lines.push(note.text.trim());
|
|
@@ -129,6 +135,6 @@ export function segmentBrief(state, ids, options = {}) {
|
|
|
129
135
|
}
|
|
130
136
|
}
|
|
131
137
|
lines.push("");
|
|
132
|
-
lines.push(`AFTER: ${notes.at(-1).change}`);
|
|
138
|
+
lines.push(`AFTER: ${notes.at(-1).change}${notes.length === 1 ? " (the change line, until the scene is written)" : ""}`);
|
|
133
139
|
return lines.join("\n");
|
|
134
140
|
}
|