plotcoder-board 0.1.22 → 0.1.23
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/README.md +4 -4
- package/package.json +1 -1
- package/scripts/plotcoder-mcp-server.mjs +176 -78
- package/src/board/agents.js +2 -1
- package/src/board/workflows.js +1 -1
package/README.md
CHANGED
|
@@ -51,10 +51,10 @@ An agent should call the tools, never fake mouse drags. The skill in `.cursor/sk
|
|
|
51
51
|
A **blind run** is a fresh agent given the on-ramp and a treatment and nothing
|
|
52
52
|
else, asked to build a wall and to keep a log of everything that made the job
|
|
53
53
|
harder than it should have been. The friction log is the product; the wall is
|
|
54
|
-
just what produces it.
|
|
54
|
+
just what produces it. Sixteen rounds have been run, the first three through a
|
|
55
55
|
repo checkout and the rest through the account door; every finding from the
|
|
56
|
-
first
|
|
57
|
-
forty-
|
|
56
|
+
first fifteen is fixed or decided, and forty-one of round sixteen's
|
|
57
|
+
forty-eight were fixed the same night, while it ran. [`blind-runs/`](blind-runs/) holds the rules that keep a round honest,
|
|
58
58
|
the table of rounds, and the next round's prompt with the test account filled in.
|
|
59
59
|
|
|
60
60
|
A round works a **test account** — a throwaway marked on its writer row, and the
|
|
@@ -99,4 +99,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
|
|
|
99
99
|
|
|
100
100
|
## Status
|
|
101
101
|
|
|
102
|
-
Version 0.1.
|
|
102
|
+
Version 0.1.23. A project of boards; sign in with your email and a password from the PlotCoder mark and your projects follow you to every device, share one with another writer by email and write it together live, or stay signed out and work on this device as before. Pages sit beside the wall: a scene's text lives on its card, measures it, paginates to the industry's rules, prints, goes out and comes in as Fountain or Final Draft, and goes out as Markdown or plain text for a collaborator in Google Docs. It installs as a progressive web app and opens offline; plotcoder.com serves over HTTPS. The wall, beats, card length, groups, arrows, pan and zoom, save and open, and the agent surface are in use.
|
package/package.json
CHANGED
|
@@ -55,7 +55,7 @@ import crypto from "node:crypto";
|
|
|
55
55
|
import { describeRuns, describeSetups, readWall } from "../src/board/readWall.js";
|
|
56
56
|
import { compareStructure, describeComparison, MATCH_PAGES } from "../src/board/compareStructure.js";
|
|
57
57
|
import { GAP, ROW_WIDTH, organizePoses } from "../src/board/organize.js";
|
|
58
|
-
import { sceneLineCount } from "../src/board/paginate.js";
|
|
58
|
+
import { parseScene, sceneLineCount } from "../src/board/paginate.js";
|
|
59
59
|
import {
|
|
60
60
|
addBoard,
|
|
61
61
|
addStructure,
|
|
@@ -831,12 +831,13 @@ function changeNote() {
|
|
|
831
831
|
const parts = [];
|
|
832
832
|
if (change.gone.length || change.came.length) {
|
|
833
833
|
parts.push(
|
|
834
|
-
`the wall now asks ${change.asks} question${change.asks === 1 ? "" : "s"}${change.gone.length ? ` (gone: ${change.gone.map((finding) => `[${finding.kind}]`).join(" ")})` : ""}${change.came.length ? ` (new: ${change.came.map((finding) => `[${finding.kind}] ${finding.text}`).join(" ")})` : ""}`,
|
|
834
|
+
`the wall now asks ${change.asks} question${change.asks === 1 ? "" : "s"}${change.gone.length ? ` (gone: ${change.gone.map((finding) => `[${finding.kind}] ${finding.text}`).join(" ")})` : ""}${change.came.length ? ` (new: ${change.came.map((finding) => `[${finding.kind}] ${finding.text}`).join(" ")})` : ""}`,
|
|
835
835
|
);
|
|
836
836
|
}
|
|
837
837
|
if (change.leftAfter !== change.leftBefore) parts.push(`left, for now: ${change.leftAfter} (was ${change.leftBefore})`);
|
|
838
838
|
if (change.eighthsAfter !== change.eighthsBefore) parts.push(`runtime now about ${formatPages(change.eighthsAfter)} of ${formatPages(change.target)} pages`);
|
|
839
|
-
|
|
839
|
+
// No full stop of its own: the reply it rides on ends the sentence (round sixteen, entry 11).
|
|
840
|
+
return parts.length ? `; ${parts.join("; ")}` : "";
|
|
840
841
|
}
|
|
841
842
|
/** Said once per session, so a reply does not repeat its advice eighteen times (round thirteen, entry 10). */
|
|
842
843
|
const saidOnce = new Set();
|
|
@@ -985,13 +986,35 @@ const SAMPLE_NOTE = "sample: this is the wall PlotCoder starts with (Maya, Tom,
|
|
|
985
986
|
|
|
986
987
|
// --- Reporting -------------------------------------------------------------
|
|
987
988
|
|
|
989
|
+
/**
|
|
990
|
+
* The cues in a scene's text against the cast (round sixteen, entry 30): a cue
|
|
991
|
+
* matches a person by the whole name, so DANA is not "Dana Kerr", and the
|
|
992
|
+
* reply says which cues found nobody and whom they nearly named.
|
|
993
|
+
*/
|
|
994
|
+
function cueReport(state, text) {
|
|
995
|
+
const names = [...new Set(parseScene(text ?? "").filter((element) => element.kind === "speech" && element.name).map((element) => element.name.replace(/\s*\(.*\)\s*$/, "").trim()).filter(Boolean))];
|
|
996
|
+
if (!names.length) return "";
|
|
997
|
+
const cast = state.characters ?? [];
|
|
998
|
+
const parts = names.map((cue) => {
|
|
999
|
+
const whole = cast.find((person) => person.name.trim().toLowerCase() === cue.toLowerCase());
|
|
1000
|
+
if (whole) return `${cue} (in the cast)`;
|
|
1001
|
+
const near = cast.filter((person) => person.name.toLowerCase().split(/\s+/).includes(cue.toLowerCase()));
|
|
1002
|
+
return near.length
|
|
1003
|
+
? `${cue} (nobody by that whole name — cues match by the whole name, and the cast has ${near.map((person) => `"${person.name}"`).join(", ")}: cue ${near.length === 1 ? near[0].name.toUpperCase() : "the whole name"})`
|
|
1004
|
+
: `${cue} (nobody in the cast)`;
|
|
1005
|
+
});
|
|
1006
|
+
return ` Cues: ${parts.join("; ")}.`;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
988
1009
|
/** What kinds of number a runtime folds together: measured from text, set by the writer, or the default page (round fifteen, entry 39). */
|
|
989
1010
|
function runtimeKinds(state) {
|
|
990
|
-
const measured = state.notes.filter((note) => isMeasured(note))
|
|
991
|
-
const sized = state.notes.filter((note) => !isMeasured(note) && note.lengthEighths !== null)
|
|
992
|
-
const unsized = state.notes.
|
|
1011
|
+
const measured = state.notes.filter((note) => isMeasured(note));
|
|
1012
|
+
const sized = state.notes.filter((note) => !isMeasured(note) && note.lengthEighths !== null);
|
|
1013
|
+
const unsized = state.notes.filter((note) => !isMeasured(note) && note.lengthEighths === null);
|
|
993
1014
|
if (!state.notes.length) return "";
|
|
994
|
-
|
|
1015
|
+
const sum = (notes) => formatPages(notes.reduce((total, note) => total + noteEighths(note), 0));
|
|
1016
|
+
// Pages per kind, not only cards (round sixteen, entry 43).
|
|
1017
|
+
return `; of its ${state.notes.length} cards, ${measured.length} measured from written text (${sum(measured)} pages), ${sized.length} sized by the writer (${sum(sized)}), ${unsized.length} unsized and read as a page each (${sum(unsized)})`;
|
|
995
1018
|
}
|
|
996
1019
|
|
|
997
1020
|
function summarize(state) {
|
|
@@ -1013,7 +1036,9 @@ function summarize(state) {
|
|
|
1013
1036
|
const place = note.location ? `, at: ${note.location}` : "";
|
|
1014
1037
|
const when = note.when ? `, when: ${note.when}` : "";
|
|
1015
1038
|
const count = formatPages(noteEighths(note));
|
|
1016
|
-
|
|
1039
|
+
// A written card's estimate is kept underneath for when the text goes; say it, or it is invisible (round sixteen, entry 44).
|
|
1040
|
+
const underneath = isMeasured(note) && note.lengthEighths !== null ? `; the writer's estimate underneath: ${formatPages(note.lengthEighths)}` : "";
|
|
1041
|
+
const pages = isMeasured(note) ? `${count} ${count === "1" ? "page" : "pages"}, written${underneath}` : note.lengthEighths === null ? "about a page, unsized" : `${count} ${count === "1" ? "page" : "pages"}`;
|
|
1017
1042
|
return ` - ${note.id} [${note.rank ?? "scene"}, ${pages}${who}${place}${when}${plant}${pays}${revised}] — "${note.headline}" (${note.color}) at ${Math.round(note.x)},${Math.round(note.y)}`;
|
|
1018
1043
|
})
|
|
1019
1044
|
.join("\n");
|
|
@@ -1024,7 +1049,9 @@ function summarize(state) {
|
|
|
1024
1049
|
// brief and who is still a name.
|
|
1025
1050
|
const page = filledCharacterFields(character);
|
|
1026
1051
|
const brief = page.length ? ` · page: ${page.join(", ")}` : " · page: empty";
|
|
1027
|
-
|
|
1052
|
+
// On this board, and on the others (R51): a per-board count beside a project-wide check read as a contradiction (round sixteen, entry 18).
|
|
1053
|
+
const away = lastHeld?.project ? (castElsewhere(lastHeld.project, lastHeld.boards, lastHeld.project.activeBoardId)[character.id] ?? []).reduce((sum, item) => sum + item.cards, 0) : 0;
|
|
1054
|
+
return ` - ${character.id} — "${character.name}" on ${on} card${on === 1 ? "" : "s"} of this board${away ? ` and ${away} of other boards` : ""}${brief}`;
|
|
1028
1055
|
})
|
|
1029
1056
|
.join("\n");
|
|
1030
1057
|
const placeCounts = new Map();
|
|
@@ -1276,6 +1303,8 @@ server.registerTool(
|
|
|
1276
1303
|
rank: rankSchema.optional(),
|
|
1277
1304
|
pages: pagesSchema.optional(),
|
|
1278
1305
|
plants: z.boolean().optional(),
|
|
1306
|
+
after: z.string().optional().describe("Wire the new scene into the story after this card (id or headline): one call, one number under a lock. Needs follows arrows on the wall."),
|
|
1307
|
+
before: z.string().optional().describe("Or before this card (id or headline)."),
|
|
1279
1308
|
location: z.string().optional(),
|
|
1280
1309
|
when: z.string().optional().describe('When the scene happens, as the writer says it — "night", "day four, dawn" — printed after the place on the scene heading.'),
|
|
1281
1310
|
characters: z.array(z.string().min(1)).optional(),
|
|
@@ -1284,11 +1313,19 @@ server.registerTool(
|
|
|
1284
1313
|
},
|
|
1285
1314
|
},
|
|
1286
1315
|
async (args) => {
|
|
1287
|
-
const
|
|
1316
|
+
const { state } = await readBoard();
|
|
1317
|
+
const landing = args.x === undefined && args.y === undefined ? nextPlace(state) : { x: args.x, y: args.y };
|
|
1288
1318
|
const names = (args.characters ?? []).map((name) => name.trim()).filter(Boolean);
|
|
1289
1319
|
const added = [];
|
|
1290
1320
|
// The card, anyone new in its cast, and the casting land as one change, so
|
|
1291
1321
|
// one ⌘Z on the wall takes back the whole call and not just the cast.
|
|
1322
|
+
if (args.after && args.before) return ok("Say where: after one card, or before one, not both.");
|
|
1323
|
+
// The card beside which the new scene goes (round sixteen, entry 36): by id or headline, on this board.
|
|
1324
|
+
const besideKey = (args.after ?? args.before ?? "").trim();
|
|
1325
|
+
const beside = besideKey ? state.notes.find((note) => note.id === besideKey) ?? state.notes.find((note) => note.headline.trim().toLowerCase() === besideKey.toLowerCase()) ?? null : null;
|
|
1326
|
+
if (besideKey && !beside) return ok(`No card with id or headline "${besideKey}" on this board. Call list_board.`);
|
|
1327
|
+
const wallHasFollows = state.arrows.some((arrow) => arrow.kind !== "setup");
|
|
1328
|
+
let joinedGroup = null;
|
|
1292
1329
|
const { value: result, live, state: after } = await commitAll(`create_note "${args.headline}"`, (step, current) => {
|
|
1293
1330
|
let made = step({
|
|
1294
1331
|
type: "create_note",
|
|
@@ -1324,6 +1361,12 @@ server.registerTool(
|
|
|
1324
1361
|
made = cast.state.notes.find((note) => note.id === made.id) ?? made;
|
|
1325
1362
|
}
|
|
1326
1363
|
}
|
|
1364
|
+
// Wired into the story where the writer said, in the same frame.
|
|
1365
|
+
if (beside && made?.id && wallHasFollows) {
|
|
1366
|
+
joinedGroup = landBeside((command) => step(command), current, made.id, beside, Boolean(args.after));
|
|
1367
|
+
step({ type: "apply_poses", poses: organizePoses(current(), {}) });
|
|
1368
|
+
made = current().notes.find((note) => note.id === made.id) ?? made;
|
|
1369
|
+
}
|
|
1327
1370
|
return made;
|
|
1328
1371
|
});
|
|
1329
1372
|
const castLine = names.length && result?.id ? ` Cast: ${names.join(", ")}${added.length ? ` (added to the roster: ${added.join(", ")})` : ""}.` : "";
|
|
@@ -1336,9 +1379,13 @@ server.registerTool(
|
|
|
1336
1379
|
result?.when ? `when: ${result.when}` : null,
|
|
1337
1380
|
].filter(Boolean).join(", ");
|
|
1338
1381
|
// Where it landed matters only until the tidy, so the reply says the rule once and never the coordinates (round fourteen, entry 11).
|
|
1339
|
-
const placed =
|
|
1382
|
+
const placed = beside
|
|
1383
|
+
? wallHasFollows
|
|
1384
|
+
? ` Wired ${args.after ? "after" : "before"} "${beside.headline}" in the story${joinedGroup ? `, in "${joinedGroup}"` : ""}, and the wall tidied.`
|
|
1385
|
+
: ` The wall has no follows arrows, so "${args.after ? "after" : "before"}" has no story to land in: it sits after the last card; create_arrow the sequence, then move_scene.`
|
|
1386
|
+
: args.x === undefined && args.y === undefined ? ` Placed after the last card in story order.${once("placed", " organize lays the wall out along the arrows.")}` : "";
|
|
1340
1387
|
// Under a lock a new scene has a letter, not a number: say it, since the board is the only other place to learn it (round fourteen, entry 44).
|
|
1341
|
-
const numbered = after?.lock && result?.id ? ` Numbered ${sceneNumbers(storyOrder(after), after.lock).get(result.id)} (the numbers are locked; a new scene's letter is its place between locked ones,
|
|
1388
|
+
const numbered = after?.lock && result?.id ? ` Numbered ${sceneNumbers(storyOrder(after), after.lock).get(result.id)} (the numbers are locked; a new scene's letter is its place between locked ones now, worked out again from where it sits if it moves; the locked numbers never move).` : "";
|
|
1342
1389
|
return ok(`Created card ${result?.id ?? ""}: ${landed}${where(live)}.${castLine}${placed}${numbered}`, result);
|
|
1343
1390
|
},
|
|
1344
1391
|
);
|
|
@@ -1467,7 +1514,7 @@ server.registerTool(
|
|
|
1467
1514
|
const lines = [
|
|
1468
1515
|
`PlotCoder wall (${door(live, base)})`,
|
|
1469
1516
|
...(state.lock ? [`numbers: locked since ${String(state.lock.at).slice(0, 10)}; read_pages shows each scene's number`] : []),
|
|
1470
|
-
`board: "${readBoardMeta?.name ?? "?"}"${projectForRead.boards.length > 1 ? ` — ${projectForRead.boards.
|
|
1517
|
+
`board: "${readBoardMeta?.name ?? "?"}"${projectForRead.boards.length > 1 ? ` — board ${projectForRead.boards.findIndex((meta) => meta.id === readBoardMeta?.id) + 1} of ${projectForRead.boards.length} in the project "${projectForRead.name}"; open_board reads another` : ""}`,
|
|
1471
1518
|
`logline: ${state.logline ? `"${state.logline}"` : "(none yet)"}`,
|
|
1472
1519
|
"the cast and the places are list_board's, not the reading's",
|
|
1473
1520
|
state.targetEighths === DEFAULT_TARGET_EIGHTHS
|
|
@@ -1495,7 +1542,7 @@ server.registerTool(
|
|
|
1495
1542
|
`setups and payoffs${written < state.notes.length ? " (distances in estimated pages)" : ""}:`,
|
|
1496
1543
|
...(reading.setups.length
|
|
1497
1544
|
? describeSetups(reading, state).map((line) => ` - ${line}`)
|
|
1498
|
-
: [" (no arrow is marked as a setup)"]),
|
|
1545
|
+
: [reading.paidBy.length ? " (no setup arrow on this board; what pays off a fold of another board is listed below)" : " (no arrow is marked as a setup)"]),
|
|
1499
1546
|
...reading.later.map((item) => ` - "${state.notes.find((note) => note.id === item.id)?.headline ?? item.id}" is folded and pays off later, on "${boardById(projectForRead, item.boardId)?.name ?? item.boardId}"${item.noteId ? `, at ${episodeLabel(projectForRead, boardsNow, item.boardId, item.noteId)} "${boardsNow[item.boardId]?.notes?.find((note) => note.id === item.noteId)?.headline ?? item.noteId}"` : " — no scene there claims it yet"}`),
|
|
1500
1547
|
...reading.paidBy.map((item) => ` - "${state.notes.find((note) => note.id === item.id)?.headline ?? item.id}" pays off "${item.fromHeadline}" from "${item.fromBoardName}" (${episodeLabel(projectForRead, boardsNow, item.fromBoardId, item.fromNoteId)}), one board earlier`),
|
|
1501
1548
|
"questions the wall raises:",
|
|
@@ -1515,7 +1562,7 @@ server.registerTool(
|
|
|
1515
1562
|
const counts = new Map();
|
|
1516
1563
|
for (const finding of asked) counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + 1);
|
|
1517
1564
|
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}`;
|
|
1518
|
-
})()}${reading.left.length ? `; left by the writer, not clean: ${[...new Set(reading.left.map((finding) => finding.kind))].join("
|
|
1565
|
+
})()}${reading.left.length ? `; left by the writer, so not clean: ${[...new Set(reading.left.map((finding) => finding.kind))].map((kind) => `[${kind}]`).join(" ")}` : ""}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind) && !reading.left.some((finding) => finding.kind === kind)).map((kind) => {
|
|
1519
1566
|
if (kind === "unlinked" && state.arrows.length === 0) return "no card without an arrow (not asked: no arrows yet)";
|
|
1520
1567
|
if (kind === "unplaced" && !state.notes.some((note) => (note.location ?? "").trim())) return "no card without a place (not asked: no card placed yet)";
|
|
1521
1568
|
if (kind === "sequence" && state.groups.length === 0) return "no group too long for one sequence (not asked: no groups)";
|
|
@@ -1619,6 +1666,36 @@ server.registerTool(
|
|
|
1619
1666
|
},
|
|
1620
1667
|
);
|
|
1621
1668
|
|
|
1669
|
+
/**
|
|
1670
|
+
* Land a card beside another in the story (R56): between the target and what
|
|
1671
|
+
* followed it (after) or what led to it (before), rewiring the follows arrows,
|
|
1672
|
+
* and into the target's group so the tidy keeps the act as a block. Shared by
|
|
1673
|
+
* move_scene and by create_note with after/before (round sixteen, entry 36).
|
|
1674
|
+
*/
|
|
1675
|
+
function landBeside(run, current, cardId, target, after) {
|
|
1676
|
+
const isFollows = (arrow) => arrow.kind !== "setup";
|
|
1677
|
+
const mid = current();
|
|
1678
|
+
if (after) {
|
|
1679
|
+
for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.from === target.id && item.to !== cardId)) {
|
|
1680
|
+
run({ type: "delete_arrow", id: arrow.id });
|
|
1681
|
+
run({ type: "create_arrow", from: cardId, to: arrow.to, kind: "follows" });
|
|
1682
|
+
}
|
|
1683
|
+
run({ type: "create_arrow", from: target.id, to: cardId, kind: "follows" });
|
|
1684
|
+
} else {
|
|
1685
|
+
for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.to === target.id && item.from !== cardId)) {
|
|
1686
|
+
run({ type: "delete_arrow", id: arrow.id });
|
|
1687
|
+
run({ type: "create_arrow", from: arrow.from, to: cardId, kind: "follows" });
|
|
1688
|
+
}
|
|
1689
|
+
run({ type: "create_arrow", from: cardId, to: target.id, kind: "follows" });
|
|
1690
|
+
}
|
|
1691
|
+
const targetGroup = current().groups.find((group) => group.noteIds.includes(target.id));
|
|
1692
|
+
if (targetGroup && !targetGroup.noteIds.includes(cardId)) {
|
|
1693
|
+
run({ type: "add_to_group", id: targetGroup.id, noteIds: [cardId] });
|
|
1694
|
+
return targetGroup.title || "an untitled group";
|
|
1695
|
+
}
|
|
1696
|
+
return null;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1622
1699
|
/**
|
|
1623
1700
|
* A scene moves to another board of the project (round fifteen, entry 16: a
|
|
1624
1701
|
* writer's "the shim should open episode two" had no tool, and the way round
|
|
@@ -1700,16 +1777,17 @@ async function moveAcrossBoards(args, target, open, held) {
|
|
|
1700
1777
|
step({ type: "apply_poses", poses: organizePoses(current(), {}) });
|
|
1701
1778
|
});
|
|
1702
1779
|
const order = storyOrder(final);
|
|
1780
|
+
// "Left behind" read as "kept" (round sixteen, entry 22): the arrows are dropped, and the reply says so.
|
|
1703
1781
|
const arrows = taken?.arrows?.length
|
|
1704
|
-
? `
|
|
1782
|
+
? ` Dropped on "${fromMeta.name}", because an arrow joins two cards of one wall: ${taken.arrows.map((arrow) => `"${arrow.fromHeadline}" → "${arrow.toHeadline}" (${arrow.kind}${arrow.kind === "setup" && arrow.to === card.id ? "; that fold is unpaid again" : ""})`).join(", ")}${taken.joined ? `; the chain there is joined behind it, "${taken.joined.fromHeadline}" → "${taken.joined.toHeadline}"` : ""}.`
|
|
1705
1783
|
: ` No arrow touched it on "${fromMeta.name}".`;
|
|
1706
1784
|
const groups = (taken?.groups ?? []).map((group) => (group.dissolved ? ` Its group "${group.title}" there dissolved: a frame needs two cards.` : ` It left its group "${group.title}" there, which keeps ${group.remaining} card${group.remaining === 1 ? "" : "s"}.`)).join("");
|
|
1707
1785
|
const landed = anchor
|
|
1708
|
-
? `${args.after ? "after" : "before"} "${anchor.headline}"${joinedGroup ? `, in "${joinedGroup}"` : ""}`
|
|
1709
|
-
: headOf ? `at the head of the story,
|
|
1786
|
+
? `${args.after ? "after" : "before"} "${anchor.headline}", with the follows arrows rewired around it${joinedGroup ? `, in "${joinedGroup}"` : `; "${anchor.headline}" is in no group, so this card joined none`}`
|
|
1787
|
+
: headOf ? `at the head of the story, with a follows arrow drawn from it to "${headOf}"; it is in no group` : "as the only card wired to nothing yet";
|
|
1710
1788
|
const fold = card.plants ? (forgotLater ? " It paid off later on this board, so that mark is forgotten: draw the setup arrow here." : " Its folded corner came with it; a setup arrow does not cross boards, so draw the payoff here if it is here.") : "";
|
|
1711
1789
|
return ok(
|
|
1712
|
-
`Moved "${card.headline}" from "${fromMeta.name}" to "${target.name}", with its cast, place, when, rank, length${card.text ? ", text" : ""} and
|
|
1790
|
+
`Moved "${card.headline}" from "${fromMeta.name}" to "${target.name}", with its cast, place, when, rank, length${card.text ? ", text" : ""}, colour${card.plants ? " and folded corner" : ""}; it is card ${landedId} there${where(live)}.${arrows}${groups} It landed ${landed}, and the wall was tidied.${fold} Story order on "${target.name}" now: ${order.map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}. "${target.name}" is the open board now. Undo is per board: undo here takes back the landing; open_board "${fromMeta.name}" and undo takes back the leaving.`,
|
|
1713
1791
|
{ id: landedId, board: target.id, order: order.map((note) => note.id) },
|
|
1714
1792
|
);
|
|
1715
1793
|
}
|
|
@@ -1761,37 +1839,20 @@ server.registerTool(
|
|
|
1761
1839
|
const outs = state.arrows.filter((arrow) => isFollows(arrow) && arrow.from === card.id);
|
|
1762
1840
|
for (const arrow of [...ins, ...outs]) run({ type: "delete_arrow", id: arrow.id });
|
|
1763
1841
|
for (const before of ins) for (const after of outs) if (before.from !== after.to) run({ type: "create_arrow", from: before.from, to: after.to, kind: "follows" });
|
|
1764
|
-
// Land: between the target and what followed it (or what led to it)
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.from === target.id && item.to !== card.id)) {
|
|
1768
|
-
run({ type: "delete_arrow", id: arrow.id });
|
|
1769
|
-
run({ type: "create_arrow", from: card.id, to: arrow.to, kind: "follows" });
|
|
1770
|
-
}
|
|
1771
|
-
run({ type: "create_arrow", from: target.id, to: card.id, kind: "follows" });
|
|
1772
|
-
} else {
|
|
1773
|
-
for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.to === target.id && item.from !== card.id)) {
|
|
1774
|
-
run({ type: "delete_arrow", id: arrow.id });
|
|
1775
|
-
run({ type: "create_arrow", from: arrow.from, to: card.id, kind: "follows" });
|
|
1776
|
-
}
|
|
1777
|
-
run({ type: "create_arrow", from: card.id, to: target.id, kind: "follows" });
|
|
1778
|
-
}
|
|
1779
|
-
// Landing beside a card of an act puts the scene in that act, or the tidy
|
|
1780
|
-
// keeps the act as a block and lays the scene past it (round fourteen, entry 38).
|
|
1781
|
-
const targetGroup = current().groups.find((group) => group.noteIds.includes(target.id));
|
|
1782
|
-
if (targetGroup && !targetGroup.noteIds.includes(card.id)) {
|
|
1783
|
-
run({ type: "add_to_group", id: targetGroup.id, noteIds: [card.id] });
|
|
1784
|
-
joinedGroup = targetGroup.title || "an untitled group";
|
|
1785
|
-
}
|
|
1842
|
+
// Land: between the target and what followed it (or what led to it), and
|
|
1843
|
+
// into the act it lands beside (round fourteen, entry 38).
|
|
1844
|
+
joinedGroup = landBeside(run, current, card.id, target, Boolean(args.after));
|
|
1786
1845
|
run({ type: "apply_poses", poses: organizePoses(current(), {}) });
|
|
1787
1846
|
});
|
|
1788
1847
|
const order = storyOrder(final);
|
|
1848
|
+
// Under a lock the letter is worked out again from where the scene landed (round sixteen, entry 35).
|
|
1849
|
+
const lockedNow = final.lock ? ` Under the lock it is now ${sceneNumbers(order, final.lock).get(card.id)}; the locked numbers never move, and an added scene's letter is worked out from where it sits.` : "";
|
|
1789
1850
|
const group = final.groups.find((item) => item.noteIds.includes(card.id));
|
|
1790
1851
|
const groupLine = joinedGroup
|
|
1791
1852
|
? ` It joined "${joinedGroup}", the group it landed in, so the tidy keeps it with the act.`
|
|
1792
1853
|
: 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.` : "";
|
|
1793
1854
|
return ok(
|
|
1794
|
-
`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.`,
|
|
1855
|
+
`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}${lockedNow} One undo takes the whole move back.`,
|
|
1795
1856
|
{ order: order.map((note) => note.id) },
|
|
1796
1857
|
);
|
|
1797
1858
|
},
|
|
@@ -1964,7 +2025,7 @@ server.registerTool(
|
|
|
1964
2025
|
{
|
|
1965
2026
|
title: "Export the wall as Fountain",
|
|
1966
2027
|
description:
|
|
1967
|
-
"The open board as a Fountain screenplay: a title page (with the premise and logline in its notes), beats as sections, one scene per card in wall order — a forced heading from the card's place (or its headline), the headline as a synopsis, the cast and the fold as notes, the change line as action after the mark [Unwritten] until the scene is written. Titled for the project, a one-board film being its project. Plain text a writer can open in any Fountain editor. Pass a path to write a .fountain file; otherwise the text comes back.",
|
|
2028
|
+
"The open board as a Fountain screenplay: a title page (with the premise and logline in its notes), beats as sections, one scene per card in wall order — a forced heading from the card's place (or its headline), the headline as a synopsis, the cast and the fold as notes, the change line as action after the mark [Unwritten] until the scene is written. Titled for the project, a one-board film being its project. Plain text a writer can open in any Fountain editor. Pass a path (relative to the server's folder) to write a .fountain file; otherwise the text comes back.",
|
|
1968
2029
|
inputSchema: { path: z.string().optional() },
|
|
1969
2030
|
},
|
|
1970
2031
|
async (args) => {
|
|
@@ -1987,7 +2048,7 @@ server.registerTool(
|
|
|
1987
2048
|
{
|
|
1988
2049
|
title: "Export the wall as Markdown",
|
|
1989
2050
|
description:
|
|
1990
|
-
"The open board as Markdown, for a collaborator who lives in Google Docs or the like: titled for the project — a one-board film is its project, and the board's name follows only when the project has several boards — the premise and the logline under it, beats as second-level headings, a third-level heading per scene from its place with its scene number, the headline as a synopsis line, then the scene's text — a speech as its cue in bold with the lines under it — or, unwritten, its change line after the mark [Unwritten] in bold, so a reader can tell a placeholder from a page. Carries the beats and every headline; does not carry the cast or the fold (Fountain's notes do). In Google Docs, Paste from Markdown keeps the headings. Pass a path to write a .md file; otherwise the text comes back.",
|
|
2051
|
+
"The open board as Markdown, for a collaborator who lives in Google Docs or the like: titled for the project — a one-board film is its project, and the board's name follows only when the project has several boards — the premise and the logline under it, beats as second-level headings, a third-level heading per scene from its place with its scene number, the headline as a synopsis line, then the scene's text — a speech as its cue in bold with the lines under it — or, unwritten, its change line after the mark [Unwritten] in bold, so a reader can tell a placeholder from a page. Carries the beats and every headline; does not carry the cast or the fold (Fountain's notes do). In Google Docs, Paste from Markdown keeps the headings. Pass a path (relative to the server's folder) to write a .md file; otherwise the text comes back.",
|
|
1991
2052
|
inputSchema: { path: z.string().optional() },
|
|
1992
2053
|
},
|
|
1993
2054
|
async (args) => {
|
|
@@ -2010,7 +2071,7 @@ server.registerTool(
|
|
|
2010
2071
|
{
|
|
2011
2072
|
title: "Export the script as plain text",
|
|
2012
2073
|
description:
|
|
2013
|
-
"The open board's script as plain text, set as it prints: the paginator's lines at Courier's columns kept with spaces, scene numbers in both margins (the wall's order, or as locked), no page numbers, an unwritten scene's change line as action after the mark [Unwritten], a revision's stars in the right margin. The script and nothing else: no headlines, no beats, no cast — the heading is the place and the when. Titled for the project, a one-board film being its project. Pastes into anything and reads as a script wherever the font is monospaced. Pass a path to write a .txt file; otherwise the text comes back.",
|
|
2074
|
+
"The open board's script as plain text, set as it prints: the paginator's lines at Courier's columns kept with spaces, scene numbers in both margins (the wall's order, or as locked), no page numbers, an unwritten scene's change line as action after the mark [Unwritten], a revision's stars in the right margin. The script and nothing else: no headlines, no beats, no cast — the heading is the place and the when. Titled for the project, a one-board film being its project. Pastes into anything and reads as a script wherever the font is monospaced. Pass a path (relative to the server's folder) to write a .txt file; otherwise the text comes back.",
|
|
2014
2075
|
inputSchema: { path: z.string().optional() },
|
|
2015
2076
|
},
|
|
2016
2077
|
async (args) => {
|
|
@@ -2044,7 +2105,7 @@ server.registerTool(
|
|
|
2044
2105
|
}
|
|
2045
2106
|
const printed = sceneLineCount(args.text);
|
|
2046
2107
|
return ok(
|
|
2047
|
-
`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 eighth${where(live)}.${revisionMark(state, result.id)}${once("heading-from-place", " The heading comes from the card's place and when, so the text starts with the action.")} While the text stands the wall reads the measure, not the estimate${result.lengthEighths !== null ? ` (the writer's ${formatPages(result.lengthEighths)} pages)` : ""}; the estimate is kept for when the text goes, and set_length changes it
|
|
2108
|
+
`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 eighth${where(live)}.${revisionMark(state, result.id)}${once("heading-from-place", " The heading comes from the card's place and when, so the text starts with the action.")} While the text stands the wall reads the measure, not the estimate${result.lengthEighths !== null ? ` (the writer's ${formatPages(result.lengthEighths)} pages)` : ""}; the estimate is kept for when the text goes, and set_length changes it.${cueReport(state, result.text)}`,
|
|
2048
2109
|
{ ...result, eighths: noteEighths(result), measured: true, printedLines: printed },
|
|
2049
2110
|
);
|
|
2050
2111
|
},
|
|
@@ -2074,9 +2135,11 @@ server.registerTool(
|
|
|
2074
2135
|
const count = text.split(args.find).length - 1;
|
|
2075
2136
|
if (count === 0) return ok(`"${args.find}" is not in "${note.headline}"'s text. read_pages shows the scene as it stands.`);
|
|
2076
2137
|
if (count > 1) return ok(`"${args.find}" occurs ${count} times in "${note.headline}"; give more of the line so it occurs once.`);
|
|
2138
|
+
const linesBefore = sceneLineCount(text);
|
|
2077
2139
|
const { state, result, live } = await commit({ type: "set_text", id: note.id, text: text.replace(args.find, args.replace) });
|
|
2140
|
+
const linesAfter = sceneLineCount(result.text);
|
|
2078
2141
|
return ok(
|
|
2079
|
-
`Changed one line of "${result.headline}": "${args.find}" → "${args.replace}"${where(live)}. Now ${
|
|
2142
|
+
`Changed one line of "${result.headline}": "${args.find}" → "${args.replace}"${where(live)}. Now ${linesAfter} line(s) as they print${linesAfter !== linesBefore ? ` (was ${linesBefore}: a line wraps differently now)` : ""}, measured at ${formatPages(noteEighths(result))} of a page.${revisionMark(state, result.id)}${cueReport(state, result.text)}`,
|
|
2080
2143
|
{ ...result, eighths: noteEighths(result), measured: true },
|
|
2081
2144
|
);
|
|
2082
2145
|
},
|
|
@@ -2101,16 +2164,21 @@ server.registerTool(
|
|
|
2101
2164
|
const pageNumbers = state.lock ? sceneNumbers(storyOrder(state), state.lock) : null;
|
|
2102
2165
|
const lines = [];
|
|
2103
2166
|
let index = 0;
|
|
2167
|
+
// The changed lines of the scene being printed, starred in the right margin as plain text stars them (round sixteen, entry 32).
|
|
2168
|
+
let changedTexts = new Set();
|
|
2104
2169
|
for (const line of text.split("\n")) {
|
|
2105
2170
|
if (/^\.(?!\.)/.test(line) && index < ids.length) {
|
|
2106
2171
|
const note = state.notes.find((item) => item.id === ids[index]);
|
|
2107
2172
|
index += 1;
|
|
2108
2173
|
const standIn = note && !(note.location ?? "").trim() ? " · no place: the headline stands in for the heading" : "";
|
|
2109
2174
|
const numbered = note && pageNumbers?.get(note.id) ? ` · locked no. ${pageNumbers.get(note.id)}` : "";
|
|
2110
|
-
const
|
|
2175
|
+
const mark = note ? marks.get(note.id) : null;
|
|
2176
|
+
const sourceLines = (note?.text ?? "").split("\n");
|
|
2177
|
+
changedTexts = new Set([...(mark?.lines ?? [])].map((at) => sourceLines[at]).filter((item) => item && item.trim()));
|
|
2178
|
+
const revised = mark?.revised ? ` · changed in the ${state.revision.color} revision${changedTexts.size ? ` (${changedTexts.size} line${changedTexts.size === 1 ? "" : "s"} starred below)` : ""}` : "";
|
|
2111
2179
|
lines.push(`${line} [[id: ${note?.id ?? "?"} · ${note && isMeasured(note) ? "measured" : "estimated"} ${formatPages(note ? noteEighths(note) : 0)}pp${standIn}${numbered}${revised}]]`);
|
|
2112
2180
|
} else {
|
|
2113
|
-
lines.push(line);
|
|
2181
|
+
lines.push(changedTexts.has(line) ? `${line} *` : line);
|
|
2114
2182
|
}
|
|
2115
2183
|
}
|
|
2116
2184
|
return ok(lines.join("\n"));
|
|
@@ -2200,7 +2268,7 @@ server.registerTool(
|
|
|
2200
2268
|
{
|
|
2201
2269
|
title: "Export as Final Draft",
|
|
2202
2270
|
description:
|
|
2203
|
-
"The open board as a Final Draft .fdx: a heading per card with its scene number
|
|
2271
|
+
"The open board as a Final Draft .fdx: a heading per card with its scene number (the locked numbers under a lock, else story order), the scene's text as script paragraphs (action, character, parenthetical, dialogue, dual dialogue, transition) or the change line as action after the mark [Unwritten] when unwritten, and a title page: the project's name, and for a series the episode line (Episode 2 of 6 · its name). One board per file; a series is one file per episode. A lock's date and a revision's name print on the title page, and changed paragraphs carry the revision's mark. Pass a path to write the file (a relative path resolves from the server's folder); otherwise the XML comes back with the file's name in a comment on its second line, and the reply with a path repeats that name.",
|
|
2204
2272
|
inputSchema: { path: z.string().optional() },
|
|
2205
2273
|
},
|
|
2206
2274
|
async (args) => {
|
|
@@ -2214,7 +2282,7 @@ server.registerTool(
|
|
|
2214
2282
|
if (args.path) {
|
|
2215
2283
|
fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
|
|
2216
2284
|
fs.writeFileSync(args.path, xml);
|
|
2217
|
-
return ok(`Wrote a Final Draft file with ${state.notes.length} scene(s), titled "${titles.title}"${titles.episode ? ` (${titles.episode})` : ""}, to ${args.path}.`);
|
|
2285
|
+
return ok(`Wrote a Final Draft file with ${state.notes.length} scene(s), titled "${titles.title}"${titles.episode ? ` (${titles.episode})` : ""}, to ${args.path}; the app's own name for it is "${filename}".${state.lock ? " The title page says when the numbers were locked." : ""}${state.revision ? ` The ${state.revision.color} revision is declared in the file and its changed paragraphs marked.` : ""}`);
|
|
2218
2286
|
}
|
|
2219
2287
|
return ok(xml.replace(/^(<\?xml[^>]*\?>\n)/, `$1<!-- Save as: ${filename.replace(/--/g, "- -")} -->\n`));
|
|
2220
2288
|
},
|
|
@@ -2266,13 +2334,11 @@ server.registerTool(
|
|
|
2266
2334
|
return ` - ${scene.number}. ${note?.headline ?? scene.id} (${scene.id}) — p. ${scene.page}${scene.endPage !== scene.page ? `–${scene.endPage}` : ""}`;
|
|
2267
2335
|
});
|
|
2268
2336
|
const unwritten = order.filter((note) => !(note.text && note.text.trim())).length;
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
}
|
|
2275
|
-
const note = unwritten
|
|
2337
|
+
// An unwritten board still paginates, as the guide says it prints: every
|
|
2338
|
+
// change line as action (round sixteen, entry 37).
|
|
2339
|
+
const note = order.length > 0 && unwritten === order.length
|
|
2340
|
+
? [`None of the ${order.length} scenes is written: every scene sets its change line as action, marked [Unwritten], a few lines each — so this is the wall as pages, not a script; the runtime estimate from the cards is about ${formatPages(boardEighths(state))} of ${formatPages(state.targetEighths)} pages.`]
|
|
2341
|
+
: unwritten
|
|
2276
2342
|
? [`${unwritten} of ${order.length} scenes are unwritten and set their change line as action, marked [Unwritten], a few lines each — 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.`]
|
|
2277
2343
|
: [];
|
|
2278
2344
|
return ok([...note, `pages: ${result.pageCount} of ${Math.round(state.targetEighths / 8)}`, `scene numbers here are ${state.lock ? "the locked numbers" : "story order (not locked)"}`, ...lines].join("\n"), result.scenes);
|
|
@@ -2292,7 +2358,9 @@ server.registerTool(
|
|
|
2292
2358
|
const order = storyOrder(state).map((note) => note.id);
|
|
2293
2359
|
const { changed, result, live } = await commit({ type: "lock_numbers", order });
|
|
2294
2360
|
if (!changed) return ok("Nothing to lock.");
|
|
2295
|
-
|
|
2361
|
+
// Which number went on which scene (round sixteen, entry 34).
|
|
2362
|
+
const byNumber = Object.entries(result.numbers).map(([id, number]) => `${number} "${state.notes.find((note) => note.id === id)?.headline ?? id}"`);
|
|
2363
|
+
return ok(`Locked ${Object.keys(result.numbers).length} scene number(s)${where(live)}: ${byNumber.join(", ")}. A scene added now gets a letter for where it sits — 3A between 3 and 4 — worked out again if it moves; the locked numbers never move. Every script out carries these numbers, and its title page says when they were locked.`, result);
|
|
2296
2364
|
},
|
|
2297
2365
|
);
|
|
2298
2366
|
|
|
@@ -2510,14 +2578,31 @@ server.registerTool(
|
|
|
2510
2578
|
const live = await writeBoard(last.before, rev, base, boardId, "exact");
|
|
2511
2579
|
const orderLine = /^(move_scene|organize)/.test(last.what) ? ` Story order now: ${storyOrder(last.before).map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}.` : "";
|
|
2512
2580
|
const cardsDiff = last.before.notes.length - state.notes.length;
|
|
2513
|
-
|
|
2581
|
+
// The arrows by name, back and gone, not the net (round sixteen, entry 45).
|
|
2582
|
+
const headlineIn = (board, id) => board.notes.find((note) => note.id === id)?.headline ?? id;
|
|
2583
|
+
const arrowName = (board, arrow) => `"${headlineIn(board, arrow.from)}" → "${headlineIn(board, arrow.to)}" (${arrow.kind ?? "follows"})`;
|
|
2584
|
+
const arrowsBack = last.before.arrows.filter((arrow) => !state.arrows.some((item) => item.id === arrow.id)).map((arrow) => arrowName(last.before, arrow));
|
|
2585
|
+
const arrowsGone = state.arrows.filter((arrow) => !last.before.arrows.some((item) => item.id === arrow.id)).map((arrow) => arrowName(state, arrow));
|
|
2514
2586
|
const groupsChanged = last.before.groups
|
|
2515
2587
|
.filter((group) => { const now = state.groups.find((item) => item.id === group.id); return !now || now.noteIds.length !== group.noteIds.length; })
|
|
2516
2588
|
.map((group) => `"${group.title}" (${group.noteIds.length} cards)`);
|
|
2517
|
-
const withIt = [
|
|
2589
|
+
const withIt = [
|
|
2590
|
+
arrowsBack.length ? `${arrowsBack.length} arrow(s) back: ${arrowsBack.join(", ")}` : null,
|
|
2591
|
+
arrowsGone.length ? `${arrowsGone.length} arrow(s) gone: ${arrowsGone.join(", ")}` : null,
|
|
2592
|
+
groupsChanged.length ? `groups as they were: ${groupsChanged.join(", ")}` : null,
|
|
2593
|
+
].filter(Boolean);
|
|
2518
2594
|
const countLine = `${cardsDiff > 0 ? ` ${cardsDiff} card(s) back` : cardsDiff < 0 ? ` ${-cardsDiff} card(s) gone` : ""}${withIt.length ? `${cardsDiff ? ", with " : " "}${withIt.join("; ")}` : ""}${cardsDiff || withIt.length ? "." : ""}`;
|
|
2519
|
-
|
|
2520
|
-
|
|
2595
|
+
// Under a lock, the letters that changed with the undo (entry 46).
|
|
2596
|
+
let lockLine = "";
|
|
2597
|
+
if (last.before.lock) {
|
|
2598
|
+
const was = sceneNumbers(storyOrder(state), state.lock ?? last.before.lock);
|
|
2599
|
+
const now = sceneNumbers(storyOrder(last.before), last.before.lock);
|
|
2600
|
+
const moved = last.before.notes.filter((note) => was.get(note.id) && now.get(note.id) && was.get(note.id) !== now.get(note.id)).map((note) => `"${note.headline}" is ${now.get(note.id)} again (was ${was.get(note.id)})`);
|
|
2601
|
+
lockLine = moved.length ? ` Under the lock, ${moved.join("; ")}.` : "";
|
|
2602
|
+
}
|
|
2603
|
+
// Whose trail the count is (entry 47): this session's, across the boards.
|
|
2604
|
+
const more = `${trail.length} more of this session's changes can be undone, across the boards — each only on the board it was made on, and only while that board is as the change left it${trail.length >= TRAIL_CAP ? "; that is the most I keep, so the oldest have gone" : ""}`;
|
|
2605
|
+
return ok(`Undid ${last.what}${where(live)}.${orderLine}${countLine}${lockLine} ${more}. list_board has the board.`, { undid: last.what, notes: last.before.notes.length, arrows: last.before.arrows.length, groups: last.before.groups.length });
|
|
2521
2606
|
},
|
|
2522
2607
|
);
|
|
2523
2608
|
|
|
@@ -2613,8 +2698,11 @@ server.registerTool(
|
|
|
2613
2698
|
}
|
|
2614
2699
|
// The fold and the board it pays off on land as one change: one ⌘Z on the wall.
|
|
2615
2700
|
const { value, live, changed } = await commitAll("set_plant", (step, current) => {
|
|
2701
|
+
// What the folds claimed before, so the reply can say what a new claim replaced (round sixteen, entry 26).
|
|
2702
|
+
const before = new Map(current().notes.filter((note) => args.ids.includes(note.id)).map((note) => [note.id, { plants: note.plants, boardId: note.payoffBoardId, noteId: note.payoffNoteId }]));
|
|
2703
|
+
const alreadyFolded = args.plants && args.ids.every((id) => before.get(id)?.plants);
|
|
2616
2704
|
let { result } = step({ type: "set_plant", ids: args.ids, plants: args.plants });
|
|
2617
|
-
let laterLine = "";
|
|
2705
|
+
let laterLine = alreadyFolded ? " (already folded)" : "";
|
|
2618
2706
|
if (forgetting) {
|
|
2619
2707
|
const cleared = step({ type: "set_payoff_board", ids: args.ids, boardId: null });
|
|
2620
2708
|
if (cleared.changed) {
|
|
@@ -2625,11 +2713,16 @@ server.registerTool(
|
|
|
2625
2713
|
const named = step({ type: "set_payoff_board", ids: args.ids, boardId: target.id, noteId: atNote?.id ?? null });
|
|
2626
2714
|
if (named.changed) result = named.result;
|
|
2627
2715
|
const here = current().notes.filter((note) => args.ids.includes(note.id));
|
|
2628
|
-
|
|
2629
|
-
|
|
2716
|
+
// The claim that stood before, when this one replaces it.
|
|
2717
|
+
const replaced = [...before.values()].filter((was) => was.boardId && (was.boardId !== target.id || (was.noteId && was.noteId !== (atNote?.id ?? null))));
|
|
2718
|
+
const replacedLine = replaced.length
|
|
2719
|
+
? ` That replaces the earlier claim${replaced.length === 1 ? "" : "s"}: ${replaced.map((was) => `${was.noteId ? `"${(isBoardState(lastHeld?.boards?.[was.boardId]) ? lastHeld.boards[was.boardId].notes.find((note) => note.id === was.noteId)?.headline : null) ?? was.noteId}" on` : "a scene to come on"} "${lastHeld?.project?.boards?.find((meta) => meta.id === was.boardId)?.name ?? was.boardId}"`).join(", ")}, which no longer pays anything off.`
|
|
2720
|
+
: "";
|
|
2721
|
+
laterLine = `${laterLine}${atNote
|
|
2722
|
+
? ` ${here.length} fold(s) ${here.length === 1 ? "is" : "are"} paid off at "${atNote.headline}" on "${target.name}": both boards' readings name it, and that card says what it pays off.`
|
|
2630
2723
|
: atClearing
|
|
2631
|
-
? ` ${here.length}
|
|
2632
|
-
: ` ${here.length}
|
|
2724
|
+
? ` ${here.length} fold(s) pay off later, on "${target.name}", and no scene there is claimed: read_wall asks which once that board holds cards.`
|
|
2725
|
+
: ` ${here.length} fold(s) pay off later, on "${target.name}": the card says so, and read_wall asks which scene once that board holds cards — set_plant with at, or set_payoff from that board, names it.`}${replacedLine}`;
|
|
2633
2726
|
}
|
|
2634
2727
|
return { result, laterLine };
|
|
2635
2728
|
});
|
|
@@ -2683,11 +2776,14 @@ server.registerTool(
|
|
|
2683
2776
|
});
|
|
2684
2777
|
const back = await readProject();
|
|
2685
2778
|
const { live } = await openBoardEverywhere(back.project, back.boards, back.rev, back.base, hereId);
|
|
2779
|
+
// The write landed on the fold's board, so its change note is that board's (round sixteen, entry 16).
|
|
2780
|
+
const noteThere = changeNote();
|
|
2781
|
+
const thereLine = noteThere ? ` On "${source.name}"${noteThere}.` : "";
|
|
2686
2782
|
if (!changed) return ok(`No change: ${clearing ? "nothing was claimed" : `"${folds[0].headline}" already pays off at "${card.headline}"`}.`);
|
|
2687
2783
|
return ok(
|
|
2688
2784
|
clearing
|
|
2689
|
-
? `"${card.headline}" no longer pays off ${folds.map((fold) => `"${fold.headline}"`).join(", ")} from "${source.name}"; ${folds.length === 1 ? "that fold is" : "those folds are"} a promise on this board again${where(live)}
|
|
2690
|
-
: `"${card.headline}" pays off "${folds[0].headline}" from "${source.name}" (${episodeLabel(project, { ...boards, [hereId]: here }, source.id, folds[0].id)})${where(live)}. The fold's card there says "paid off in ${episodeLabel(project, { ...boards, [hereId]: here }, hereId, card.id)}", this card says what it pays off, and both readings list it
|
|
2785
|
+
? `"${card.headline}" no longer pays off ${folds.map((fold) => `"${fold.headline}"`).join(", ")} from "${source.name}"; ${folds.length === 1 ? "that fold is" : "those folds are"} a promise on this board again${where(live)}.${thereLine} This board is open again; undo on "${source.name}" takes it back.`
|
|
2786
|
+
: `"${card.headline}" pays off "${folds[0].headline}" from "${source.name}" (${episodeLabel(project, { ...boards, [hereId]: here }, source.id, folds[0].id)})${where(live)}. The fold's card there says "paid off in ${episodeLabel(project, { ...boards, [hereId]: here }, hereId, card.id)}", this card says what it pays off, and both readings list it.${thereLine} This board is open again; undo on "${source.name}" takes the claim back.`,
|
|
2691
2787
|
{ fold: folds.map((fold) => fold.id), board: source.id, card: card.id },
|
|
2692
2788
|
);
|
|
2693
2789
|
},
|
|
@@ -2708,7 +2804,7 @@ server.registerTool(
|
|
|
2708
2804
|
? ok(`Already in the cast as "${result.name}" (${result.id}). Use that id.`, result)
|
|
2709
2805
|
: ok("No character added: the name was empty.");
|
|
2710
2806
|
}
|
|
2711
|
-
return ok(`Added "${result.name}" (id ${result.id}) to the project's cast
|
|
2807
|
+
return ok(`Added "${result.name}" (id ${result.id}) to the project's cast; every board of the project casts from it${where(live)}.`, result);
|
|
2712
2808
|
},
|
|
2713
2809
|
);
|
|
2714
2810
|
|
|
@@ -2752,7 +2848,7 @@ server.registerTool(
|
|
|
2752
2848
|
async (args) => {
|
|
2753
2849
|
const key = (args.id ?? args.name ?? "").trim();
|
|
2754
2850
|
if (!key) return ok("Say who: the person's id or name from list_board.");
|
|
2755
|
-
const { state, boardId } = await readBoard();
|
|
2851
|
+
const { state, boardId, live, base } = await readBoard();
|
|
2756
2852
|
const { project, boards } = await readProject();
|
|
2757
2853
|
const wanted = key.toLowerCase();
|
|
2758
2854
|
const person = state.characters.find((item) => item.id === key) ?? state.characters.find((item) => item.name.trim().toLowerCase() === wanted);
|
|
@@ -2767,13 +2863,15 @@ server.registerTool(
|
|
|
2767
2863
|
});
|
|
2768
2864
|
const total = parts.reduce((sum, part) => sum + part.on.length, 0);
|
|
2769
2865
|
const where_ = (note) => [note.location ? `at ${note.location}` : "", note.when ? note.when : "", note.rank === "beat" ? "beat" : ""].filter(Boolean).join(" · ");
|
|
2866
|
+
// A read opens with the door it came through, like every reading (round sixteen, entry 28); one scene a line (29).
|
|
2770
2867
|
const lines = [
|
|
2868
|
+
`PlotCoder cast (${door(live, base)})`,
|
|
2771
2869
|
`${person.name} (${person.id}) — on ${total} card${total === 1 ? "" : "s"} across ${project.boards.length} board${project.boards.length === 1 ? "" : "s"} of the project`,
|
|
2772
2870
|
...CHARACTER_FIELDS.map((field) => ` ${field}: ${(person[field] ?? "").trim() || "(empty)"}`),
|
|
2773
|
-
...parts.
|
|
2871
|
+
...parts.flatMap((part) =>
|
|
2774
2872
|
part.on.length
|
|
2775
|
-
? ` "${part.meta.name}", ${part.on.length} card${part.on.length === 1 ? "" : "s"} in story order
|
|
2776
|
-
: ` "${part.meta.name}": on no card
|
|
2873
|
+
? [` "${part.meta.name}", ${part.on.length} card${part.on.length === 1 ? "" : "s"} in story order:`, ...part.on.map((note, index) => ` ${index + 1}. "${note.headline}"${where_(note) ? ` (${where_(note)})` : ""}`)]
|
|
2874
|
+
: [` "${part.meta.name}": on no card`],
|
|
2777
2875
|
),
|
|
2778
2876
|
];
|
|
2779
2877
|
return ok(lines.join("\n"), { ...person, cards: parts.flatMap((part) => part.on.map((note) => note.id)), boards: parts.map((part) => ({ id: part.meta.id, name: part.meta.name, cards: part.on.map((note) => note.id) })) });
|
|
@@ -2887,7 +2985,7 @@ server.registerTool(
|
|
|
2887
2985
|
{
|
|
2888
2986
|
title: "Cast a scene",
|
|
2889
2987
|
description:
|
|
2890
|
-
"Set who is in one or more cards. Takes card ids and character names or ids; the list replaces the card's cast, so pass everyone who is in the scene. An empty list clears it. Names must already be in the cast — add_character first — and the tool says which names it did not know.",
|
|
2988
|
+
"Set who is in one or more cards of the open board (open_board first for another board's cards). Takes card ids and character names or ids; the list replaces the card's cast, so pass everyone who is in the scene. An empty list clears it. Names must already be in the cast — add_character first — and the tool says which names it did not know.",
|
|
2891
2989
|
inputSchema: {
|
|
2892
2990
|
noteIds: z.array(z.string()).min(1),
|
|
2893
2991
|
characters: z.array(z.string()),
|
|
@@ -2927,7 +3025,7 @@ server.registerTool(
|
|
|
2927
3025
|
(id) => state.characters.find((character) => character.id === id)?.name ?? id,
|
|
2928
3026
|
);
|
|
2929
3027
|
return ok(
|
|
2930
|
-
`${result.length} card(s) now cast ${names.length ? names.join(", ") : "nobody"}${where(live)}.`,
|
|
3028
|
+
`${result.length} card(s) now cast ${names.length ? names.join(", ") : "nobody"}: ${result.map((note) => `"${note.headline}"`).join(", ")}${where(live)}.`,
|
|
2931
3029
|
result,
|
|
2932
3030
|
);
|
|
2933
3031
|
},
|
|
@@ -3587,7 +3685,7 @@ server.registerTool(
|
|
|
3587
3685
|
const fresh = { ...emptyState(), ...(target ? { targetEighths: target } : {}) };
|
|
3588
3686
|
const { live } = await openBoardEverywhere(next, { ...boards, [board.id]: fresh }, rev, base, board.id);
|
|
3589
3687
|
return ok(
|
|
3590
|
-
`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
|
|
3688
|
+
`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} returns to "${project.boards.find((item) => item.id === project.activeBoardId)?.name ?? "the one before"}". 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." : ""}`,
|
|
3591
3689
|
board,
|
|
3592
3690
|
);
|
|
3593
3691
|
},
|
package/src/board/agents.js
CHANGED
|
@@ -42,12 +42,13 @@ 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: "Make these
|
|
45
|
+
firstNote: "Make these five before anything else; none depends on another, so any order is fine. list_words and list_workflows are the app's and read no project, and their first line says so; every other reading opens with the door it came through — the project it read and how many the account holds — and a write's tail says where it landed; list_projects lists the projects. read_wall, list_reminders and list_board are about the wall you will work, so after open_project or open_board make those three again, and after new_project read the wall once it holds cards. An emptied account has nothing to read: go straight to new_project (name, pages, and board for the first board's name). 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. 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
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, the rows — are list_board's. A fresh folder holds a sample wall (Maya, Tom, the letter) and the reading says so only when it is the sample; it is not the writer's." },
|
|
49
49
|
{ tool: "list_workflows", why: "what a writer can ask you for." },
|
|
50
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
|
+
{ tool: "list_board", why: "the records — every card, the cast, the places, the rows — with their ids, which the reading does not carry and every write needs." },
|
|
51
52
|
],
|
|
52
53
|
rules: [
|
|
53
54
|
"Questions, not fixes, until the writer says.",
|
package/src/board/workflows.js
CHANGED
|
@@ -15,7 +15,7 @@ export const WORKFLOWS = [
|
|
|
15
15
|
id: "break-a-treatment",
|
|
16
16
|
name: "Break a treatment into a wall",
|
|
17
17
|
ask: "Here is a treatment. Break it into a wall: one card per scene with a headline and what changes, the cast on each card, the places, and the major turns marked as beats.",
|
|
18
|
-
tools: ["list_words", "read_wall", "list_reminders", "new_project", "new_board", "rename_project", "set_target", "
|
|
18
|
+
tools: ["list_words", "read_wall", "list_reminders", "list_board", "new_project", "new_board", "rename_project", "rename_board", "set_target", "set_logline", "set_premise", "create_note", "add_character", "cast", "update_character", "set_location", "set_when", "set_rank", "set_length", "set_plant", "create_arrow", "create_group", "organize"],
|
|
19
19
|
then: "Start where the wall will live: on the account, new_project names it; in a folder, new_board for the writer's wall, then rename_project. Read the wall (read_wall) and say what it asks. A treatment is cards, one create_note each, with characters, location, rank and plants on the call; import_fountain is the door for pages, not a treatment — a scene's text measures its card.",
|
|
20
20
|
// What a treatment should say (R49): eleven blind runs asked the writer
|
|
21
21
|
// the same questions at the end of every build. Each is a fact the wall
|