plotcoder-board 0.1.19 → 0.1.21
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 +5 -5
- package/package.json +1 -1
- package/scripts/plotcoder-mcp-server.mjs +314 -44
- package/src/board/agents.js +11 -8
- package/src/board/fdx.d.ts +1 -1
- package/src/board/fdx.js +3 -2
- package/src/board/fountain.d.ts +2 -1
- package/src/board/fountain.js +3 -2
- package/src/board/markdown.d.ts +1 -1
- package/src/board/markdown.js +3 -3
- package/src/board/project.d.ts +6 -1
- package/src/board/project.js +45 -1
- package/src/board/readWall.d.ts +8 -2
- package/src/board/readWall.js +35 -4
- package/src/board/reducer.d.ts +3 -1
- package/src/board/reducer.js +23 -6
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ Every board verb goes through one command kernel, `src/board/reducer.js`, and th
|
|
|
41
41
|
|
|
42
42
|
- **The wall.** Tap the words to type, drag the paper to move. Lasso to select, then Group. Drag a card's handle onto another card for an arrow. ⌘Z takes back any change, whichever door made it.
|
|
43
43
|
- **`window.plotcoder`** on the page, for a console or a CDP session.
|
|
44
|
-
- **The MCP server**, published to npm as `plotcoder-board`: `npx -y plotcoder-board@latest` is the server, `npx -y plotcoder-board@latest call <tool> '{json}'` one call from a shell, `npx -y plotcoder-board@latest serve` the hosted door on a port (a `Dockerfile` is here too). Inside the repo it is `scripts/plotcoder-mcp.mjs`, wired for Cursor in `.cursor/mcp.json` and for Claude Code in `.mcp.json` (run `npm ci` once first). A version tag (`v0.2.0`) publishes it, with `NPM_TOKEN` in the repo's secrets. Eighty tools: reading (`list_board`, `read_wall`, `read_pages`, `read_character`, `page_count`, `list_words`, `list_workflows`, `segment_brief`, `compare_structure`); the card, cast, place and when (`set_when`), group (with `add_to_group`) and arrow verbs, `set_logline`, `set_target`, `set_rank`, `set_plant`, `write_scene`, `edit_scene`, `move_scene`, `organize`, `apply_template` with `list_structures`, `save_structure`, `remove_structure`; `leave_question` and `ask_again`; `undo` and `redo`; the project's `list_boards`, `open_board`, `new_board`, `rename_board`, `delete_board`, `set_premise`, `rename_project` and the reminders; Fountain and Final Draft both ways, Markdown and plain text out (`export_markdown`, `export_text`); the production half (`lock_numbers`, `unlock_numbers`, `start_revision`, `end_revision`); the project as a file both ways (`export_project`, `import_project`); and, through the account door, `list_projects`, `open_project`, `new_project`, `delete_project`, `empty_account`, `delete_account`, `add_picture`, `add_take`, `list_takes`, `list_files`, `remove_file`, `build_segment`. If the dev app is open, a tool call lands on the wall within a second; if not, it edits the board file and the wall catches up on the next load.
|
|
44
|
+
- **The MCP server**, published to npm as `plotcoder-board`: `npx -y plotcoder-board@latest` is the server, `npx -y plotcoder-board@latest call <tool> '{json}'` one call from a shell, `npx -y plotcoder-board@latest serve` the hosted door on a port (a `Dockerfile` is here too). Inside the repo it is `scripts/plotcoder-mcp.mjs`, wired for Cursor in `.cursor/mcp.json` and for Claude Code in `.mcp.json` (run `npm ci` once first). A version tag (`v0.2.0`) publishes it, with `NPM_TOKEN` in the repo's secrets. Eighty-one tools: reading (`list_board`, `read_wall`, `read_pages`, `read_character`, `page_count`, `list_words`, `list_workflows`, `segment_brief`, `compare_structure`); the card, cast, place and when (`set_when`), group (with `add_to_group`) and arrow verbs, `set_logline`, `set_target`, `set_rank`, `set_plant` (with `later` and `at`) and `set_payoff`, `write_scene`, `edit_scene`, `move_scene`, `organize`, `apply_template` with `list_structures`, `save_structure`, `remove_structure`; `leave_question` and `ask_again`; `undo` and `redo`; the project's `list_boards`, `open_board`, `new_board`, `rename_board`, `delete_board`, `set_premise`, `rename_project` and the reminders; Fountain and Final Draft both ways, Markdown and plain text out (`export_markdown`, `export_text`); the production half (`lock_numbers`, `unlock_numbers`, `start_revision`, `end_revision`); the project as a file both ways (`export_project`, `import_project`); and, through the account door, `list_projects`, `open_project`, `new_project`, `delete_project`, `empty_account`, `delete_account`, `add_picture`, `add_take`, `list_takes`, `list_files`, `remove_file`, `build_segment`. If the dev app is open, a tool call lands on the wall within a second; if not, it edits the board file and the wall catches up on the next load.
|
|
45
45
|
- **The account door.** With `PLOTCODER_EMAIL` and `PLOTCODER_PASSWORD` in the agent's environment — the writer's own — and no dev app answering, the same server works the writer's project on the account directly, and every change lands live on every open wall. `PLOTCODER_PROJECT` picks a project by name or id. No account yet? `claim_account` makes one with the writer's email and a password they chose. The on-ramp — the doors, what to call first, the rules — is in the app behind *Are you an agent? Start here* and served at [plotcoder.com/llms.txt](https://plotcoder.com/llms.txt), both from `src/board/agents.js`.
|
|
46
46
|
|
|
47
47
|
An agent should call the tools, never fake mouse drags. The skill in `.cursor/skills/plotcoder-board/SKILL.md` says how; `.claude/skills/plotcoder-board` is a symlink to the same file.
|
|
@@ -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. Fifteen 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
|
-
|
|
56
|
+
first fourteen is fixed or decided, and thirty-four of round fifteen's
|
|
57
|
+
forty-five were fixed the same evening, 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.21. 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
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
NOTE_WIDTH,
|
|
34
34
|
DEFAULT_TARGET_EIGHTHS,
|
|
35
35
|
normalizeState,
|
|
36
|
+
newId,
|
|
36
37
|
noteEighths,
|
|
37
38
|
NOTE_COLORS,
|
|
38
39
|
NOTE_RANKS,
|
|
@@ -72,6 +73,8 @@ import {
|
|
|
72
73
|
reidentifyProject,
|
|
73
74
|
renameProject,
|
|
74
75
|
castElsewhere,
|
|
76
|
+
landingsOn,
|
|
77
|
+
laterBoards,
|
|
75
78
|
liftCast,
|
|
76
79
|
mergeRoster,
|
|
77
80
|
sameRoster,
|
|
@@ -564,7 +567,40 @@ async function throughAccount(read) {
|
|
|
564
567
|
throw new DoorReply(accountRefusal ?? "The account door is shut.");
|
|
565
568
|
}
|
|
566
569
|
|
|
570
|
+
/** The project as last read, so any reading of the open board can know who is cast on another board (R51) without a second read. */
|
|
571
|
+
let lastHeld = null;
|
|
567
572
|
async function readProject() {
|
|
573
|
+
const held = await readProjectUncached();
|
|
574
|
+
lastHeld = held;
|
|
575
|
+
return held;
|
|
576
|
+
}
|
|
577
|
+
/** People on a card of another board of the project: the ids readWall must not ask about (round fifteen, entries 18 and 21). */
|
|
578
|
+
function elsewhereIds(boardId) {
|
|
579
|
+
if (!lastHeld?.project) return [];
|
|
580
|
+
return Object.keys(castElsewhere(lastHeld.project, lastHeld.boards, boardId ?? lastHeld.project.activeBoardId));
|
|
581
|
+
}
|
|
582
|
+
/** Everything a reading of one board needs to know about the rest of the project (R51, R58), from the last project read. */
|
|
583
|
+
function readOptions(boardId, state = null) {
|
|
584
|
+
if (!lastHeld?.project) return { elsewhere: [] };
|
|
585
|
+
const id = boardId ?? lastHeld.project.activeBoardId;
|
|
586
|
+
const boards = state ? { ...lastHeld.boards, [id]: state } : lastHeld.boards;
|
|
587
|
+
return { elsewhere: elsewhereIds(id), laterBoards: laterBoards(lastHeld.project, boards), paidBy: landingsOn(lastHeld.project, boards, id).paid };
|
|
588
|
+
}
|
|
589
|
+
/** A card's number as the reading prints it: the lock's when locked, else its place in story order. */
|
|
590
|
+
function sceneLabel(state, noteId) {
|
|
591
|
+
const numbers = state.lock ? sceneNumbers(storyOrder(state), state.lock) : null;
|
|
592
|
+
const numbered = numbers?.get?.(noteId);
|
|
593
|
+
if (numbered) return numbered;
|
|
594
|
+
const index = storyOrder(state).findIndex((note) => note.id === noteId);
|
|
595
|
+
return index >= 0 ? String(index + 1) : "?";
|
|
596
|
+
}
|
|
597
|
+
/** "Ep 1, sc 4": a scene on another board, named by the board's place in the project and the scene's in its story (R58). */
|
|
598
|
+
function episodeLabel(project, boards, boardId, noteId) {
|
|
599
|
+
const index = project.boards.findIndex((meta) => meta.id === boardId);
|
|
600
|
+
const state = isBoardState(boards[boardId]) ? normalizeState(boards[boardId]) : null;
|
|
601
|
+
return `Ep ${index + 1}${state && noteId ? `, sc ${sceneLabel(state, noteId)}` : ""}`;
|
|
602
|
+
}
|
|
603
|
+
async function readProjectUncached() {
|
|
568
604
|
const viaAccount = await throughAccount(accountReadProject);
|
|
569
605
|
if (viaAccount) return viaAccount;
|
|
570
606
|
const base = await findBridge();
|
|
@@ -738,8 +774,13 @@ async function writeBoardRaw(next, rev, base, boardId = null) {
|
|
|
738
774
|
log("bridge write failed, falling back to file:", error);
|
|
739
775
|
}
|
|
740
776
|
}
|
|
741
|
-
|
|
742
|
-
|
|
777
|
+
// A board file from before the project file has no id of its own; once a
|
|
778
|
+
// project file exists it is that project's open board, and it has to say so,
|
|
779
|
+
// or nothing written here reaches the project's copy and open_board later
|
|
780
|
+
// brings back a stale one (found by round fifteen's cross-board move).
|
|
781
|
+
const id = boardId ?? readFileProject()?.project.activeBoardId ?? null;
|
|
782
|
+
writeFileBoard(next, rev + 1, id);
|
|
783
|
+
syncProjectFileBoard(id, next);
|
|
743
784
|
return false;
|
|
744
785
|
}
|
|
745
786
|
|
|
@@ -765,9 +806,11 @@ const sinceRead = [];
|
|
|
765
806
|
/** What the last write did to the wall's questions and runtime, said once on that write's tail (round fourteen, entries 18, 19, 42). */
|
|
766
807
|
let lastChange = null;
|
|
767
808
|
const findingKey = (finding) => `${finding.kind}|${finding.ids.join(",")}|${finding.text}`;
|
|
768
|
-
function noteChange(before, after) {
|
|
769
|
-
|
|
770
|
-
|
|
809
|
+
function noteChange(before, after, boardId = null) {
|
|
810
|
+
// The same reading read_wall gives: a person cast on another board is not
|
|
811
|
+
// asked about, so a write's tail never names a question the reading does not.
|
|
812
|
+
const was = readWall(before, readOptions(boardId, before));
|
|
813
|
+
const now = readWall(after, readOptions(boardId, after));
|
|
771
814
|
const wasKeys = new Set(was.findings.map(findingKey));
|
|
772
815
|
const nowKeys = new Set(now.findings.map(findingKey));
|
|
773
816
|
lastChange = {
|
|
@@ -845,7 +888,7 @@ async function commit(command) {
|
|
|
845
888
|
const live = await writeBoard(next, rev, base, boardId, "exact");
|
|
846
889
|
trail.push({ before: state, after: canon(next), what: describeCommand(command) });
|
|
847
890
|
sinceRead.push(describeCommand(command));
|
|
848
|
-
noteChange(state, next);
|
|
891
|
+
noteChange(state, next, boardId);
|
|
849
892
|
if (trail.length > TRAIL_CAP) trail.shift();
|
|
850
893
|
undone.length = 0;
|
|
851
894
|
return { state: next, changed, result, live };
|
|
@@ -877,7 +920,7 @@ async function commitAll(what, build) {
|
|
|
877
920
|
const live = await writeBoard(current, rev, base, boardId, "exact");
|
|
878
921
|
trail.push({ before: state, after: canon(current), what });
|
|
879
922
|
sinceRead.push(what);
|
|
880
|
-
noteChange(state, current);
|
|
923
|
+
noteChange(state, current, boardId);
|
|
881
924
|
if (trail.length > TRAIL_CAP) trail.shift();
|
|
882
925
|
undone.length = 0;
|
|
883
926
|
return { state: current, changed: true, value, live };
|
|
@@ -932,7 +975,7 @@ const CHECK_WORDS = {
|
|
|
932
975
|
unlinked: "no card without an arrow",
|
|
933
976
|
duplicate: "no two headlines alike",
|
|
934
977
|
sequence: "no group too long for one sequence (act groups are not asked)",
|
|
935
|
-
uncast: "nobody in the cast on no card",
|
|
978
|
+
uncast: "nobody in the cast on no card of the project",
|
|
936
979
|
absent: "nobody gone for a third of the story",
|
|
937
980
|
backwards: "no payoff before its setup",
|
|
938
981
|
unpaid: "no fold without a payoff",
|
|
@@ -942,20 +985,36 @@ const SAMPLE_NOTE = "sample: this is the wall PlotCoder starts with (Maya, Tom,
|
|
|
942
985
|
|
|
943
986
|
// --- Reporting -------------------------------------------------------------
|
|
944
987
|
|
|
988
|
+
/** What kinds of number a runtime folds together: measured from text, set by the writer, or the default page (round fifteen, entry 39). */
|
|
989
|
+
function runtimeKinds(state) {
|
|
990
|
+
const measured = state.notes.filter((note) => isMeasured(note)).length;
|
|
991
|
+
const sized = state.notes.filter((note) => !isMeasured(note) && note.lengthEighths !== null).length;
|
|
992
|
+
const unsized = state.notes.length - measured - sized;
|
|
993
|
+
if (!state.notes.length) return "";
|
|
994
|
+
return `; of its ${state.notes.length} cards, ${measured} measured from written text, ${sized} sized by the writer, ${unsized} unsized and read as a page each`;
|
|
995
|
+
}
|
|
996
|
+
|
|
945
997
|
function summarize(state) {
|
|
946
998
|
const nameOf = new Map(state.characters.map((character) => [character.id, character.name]));
|
|
999
|
+
// What lands here from the other boards' folds (R58), from the last project read.
|
|
1000
|
+
const paidByHere = lastHeld?.project ? landingsOn(lastHeld.project, { ...lastHeld.boards, [lastHeld.project.activeBoardId]: state }, lastHeld.project.activeBoardId).paid : [];
|
|
947
1001
|
const notes = storyOrder(state)
|
|
948
1002
|
.map((note) => {
|
|
949
1003
|
const cast = note.characterIds.map((id) => nameOf.get(id) ?? id);
|
|
950
1004
|
const who = cast.length ? `, cast: ${cast.join(", ")}` : "";
|
|
951
|
-
|
|
1005
|
+
// The board it pays off on, named here as read_wall names it (round fifteen, entry 44).
|
|
1006
|
+
const laterName = note.payoffBoardId ? (lastHeld?.project?.boards?.find((meta) => meta.id === note.payoffBoardId)?.name ?? note.payoffBoardId) : null;
|
|
1007
|
+
const laterAt = note.payoffBoardId && note.payoffNoteId && lastHeld?.project ? ` at ${episodeLabel(lastHeld.project, lastHeld.boards, note.payoffBoardId, note.payoffNoteId)} "${lastHeld.boards[note.payoffBoardId]?.notes?.find((item) => item.id === note.payoffNoteId)?.headline ?? note.payoffNoteId}"` : "";
|
|
1008
|
+
const plant = note.plants ? (note.payoffBoardId ? `, plants → pays off later on "${laterName}"${laterAt || ", no scene there claimed yet"}` : ", plants") : "";
|
|
1009
|
+
// The receiving end (R58): what this card pays off from another board, composed from the project.
|
|
1010
|
+
const pays = paidByHere.filter((item) => item.id === note.id).map((item) => `, pays off "${item.fromHeadline}" from "${item.fromBoardName}" (${episodeLabel(lastHeld.project, lastHeld.boards, item.fromBoardId, item.fromNoteId)})`).join("");
|
|
952
1011
|
const snap = state.revision?.snapshot?.[note.id];
|
|
953
1012
|
const revised = snap && (snap.headline !== note.headline || snap.change !== note.change || (snap.text ?? "") !== (note.text ?? "") || (snap.location ?? "") !== (note.location ?? "")) ? `, changed in ${state.revision.color}` : "";
|
|
954
1013
|
const place = note.location ? `, at: ${note.location}` : "";
|
|
955
1014
|
const when = note.when ? `, when: ${note.when}` : "";
|
|
956
1015
|
const count = formatPages(noteEighths(note));
|
|
957
1016
|
const pages = isMeasured(note) ? `${count} ${count === "1" ? "page" : "pages"}, written` : note.lengthEighths === null ? "about a page, unsized" : `${count} ${count === "1" ? "page" : "pages"}`;
|
|
958
|
-
return ` - ${note.id} [${note.rank ?? "scene"}, ${pages}${who}${place}${when}${plant}${revised}] — "${note.headline}" (${note.color}) at ${Math.round(note.x)},${Math.round(note.y)}`;
|
|
1017
|
+
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)}`;
|
|
959
1018
|
})
|
|
960
1019
|
.join("\n");
|
|
961
1020
|
const cast = state.characters
|
|
@@ -1023,8 +1082,8 @@ function summarize(state) {
|
|
|
1023
1082
|
`left, for now: ${leftCount ? `${leftCount} question(s) the writer left; read_wall lists them` : "none"}`,
|
|
1024
1083
|
`beats: ${beats}, scenes: ${scenes}`,
|
|
1025
1084
|
state.targetEighths === DEFAULT_TARGET_EIGHTHS
|
|
1026
|
-
? `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`
|
|
1027
|
-
: `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)`,
|
|
1085
|
+
? `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${runtimeKinds(state)}`
|
|
1086
|
+
: `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)${runtimeKinds(state)}`,
|
|
1028
1087
|
`notes: ${state.notes.length}, groups: ${state.groups.length}, arrows: ${state.arrows.length}, cast: ${state.characters.length}`,
|
|
1029
1088
|
"cards (in story order — the follows arrows over the rows; each with its id):",
|
|
1030
1089
|
notes || " (no cards)",
|
|
@@ -1370,7 +1429,11 @@ server.registerTool(
|
|
|
1370
1429
|
.map((group) => (group.dissolved ? ` Its group "${group.title}" dissolved: a frame needs two cards.` : ` Left its group "${group.title}", which keeps ${group.remaining} card${group.remaining === 1 ? "" : "s"}.`))
|
|
1371
1430
|
.join("");
|
|
1372
1431
|
const joined = result.joined ? ` The chain is joined behind it: "${result.joined.fromHeadline}" → "${result.joined.toHeadline}" (follows).` : "";
|
|
1373
|
-
|
|
1432
|
+
// The fold and where it paid off went with the card (round fifteen, entry 17).
|
|
1433
|
+
const fold = result.plants
|
|
1434
|
+
? ` Its folded corner went with it${result.payoffBoardId ? ` — it paid off later, on "${(await readProject()).project.boards.find((meta) => meta.id === result.payoffBoardId)?.name ?? result.payoffBoardId}"` : ""}; nothing on the wall plants that now.`
|
|
1435
|
+
: "";
|
|
1436
|
+
return ok(`Deleted "${result.headline}"${where(live)}.${arrows}${joined}${groups}${fold}`, result);
|
|
1374
1437
|
},
|
|
1375
1438
|
);
|
|
1376
1439
|
|
|
@@ -1389,8 +1452,10 @@ server.registerTool(
|
|
|
1389
1452
|
const { project: projectForRead } = await readProject();
|
|
1390
1453
|
const readBoardMeta = boardById(projectForRead, readBoardId ?? projectForRead.activeBoardId);
|
|
1391
1454
|
const { boards: boardsForRead } = await readProject();
|
|
1392
|
-
const
|
|
1393
|
-
const
|
|
1455
|
+
const readId = readBoardId ?? projectForRead.activeBoardId;
|
|
1456
|
+
const boardsNow = { ...boardsForRead, [readId]: state };
|
|
1457
|
+
const elsewhereForRead = castElsewhere(projectForRead, boardsForRead, readId);
|
|
1458
|
+
const reading = readWall(state, { elsewhere: Object.keys(elsewhereForRead), laterBoards: laterBoards(projectForRead, boardsNow), paidBy: landingsOn(projectForRead, boardsNow, readId).paid });
|
|
1394
1459
|
lastReading = { findings: reading.findings };
|
|
1395
1460
|
sinceRead.length = 0;
|
|
1396
1461
|
const runs = describeRuns(reading, state).map((line, index) => {
|
|
@@ -1431,7 +1496,8 @@ server.registerTool(
|
|
|
1431
1496
|
...(reading.setups.length
|
|
1432
1497
|
? describeSetups(reading, state).map((line) => ` - ${line}`)
|
|
1433
1498
|
: [" (no arrow is marked as a setup)"]),
|
|
1434
|
-
...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}"`),
|
|
1499
|
+
...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
|
+
...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`),
|
|
1435
1501
|
"questions the wall raises:",
|
|
1436
1502
|
...(reading.findings.length
|
|
1437
1503
|
? reading.findings.map((finding) => ` - [${finding.kind}] ${finding.text}${finding.ids.length ? ` (ids: ${finding.ids.join(", ")})` : ""}`)
|
|
@@ -1481,8 +1547,9 @@ server.registerTool(
|
|
|
1481
1547
|
async (args) => {
|
|
1482
1548
|
const wanted = args.questions?.length ? args.questions : args.kind ? [{ kind: args.kind, ids: args.ids, why: args.why }] : [];
|
|
1483
1549
|
if (!wanted.length) return ok("Say which question: its kind as read_wall names it (and ids when that kind is asked more than once), or a list under questions.");
|
|
1484
|
-
const { state } = await readBoard();
|
|
1485
|
-
const
|
|
1550
|
+
const { state, boardId: leaveBoardId } = await readBoard();
|
|
1551
|
+
const readOptions_ = readOptions(leaveBoardId, state);
|
|
1552
|
+
const reading = readWall(state, readOptions_);
|
|
1486
1553
|
const replies = [];
|
|
1487
1554
|
const toLeave = [];
|
|
1488
1555
|
for (const want of wanted) {
|
|
@@ -1522,7 +1589,7 @@ server.registerTool(
|
|
|
1522
1589
|
after = out.state;
|
|
1523
1590
|
for (const item of toLeave) replies.push(`Left, for now: [${item.finding.kind}] ${item.finding.text}${item.why ? ` — "${item.why}"` : ""}`);
|
|
1524
1591
|
}
|
|
1525
|
-
const still = readWall(after).findings;
|
|
1592
|
+
const still = readWall(after, readOptions(leaveBoardId, after)).findings;
|
|
1526
1593
|
const tail = toLeave.length
|
|
1527
1594
|
? `${where(live)} ${once("leave-rule", "The wall keeps the writer's word and asks a left question again on its own when it would read differently; ask_again brings one back now. ")}The wall still asks ${still.length === 0 ? "nothing" : `${still.length}: ${still.map((finding) => `[${finding.kind}] ${finding.text}`).join(" ")}`}.`
|
|
1528
1595
|
: "";
|
|
@@ -1542,7 +1609,8 @@ server.registerTool(
|
|
|
1542
1609
|
const held = (state.left ?? []).filter((item) => item.kind === args.kind && (!args.ids || sameList(item.ids, args.ids)));
|
|
1543
1610
|
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".`);
|
|
1544
1611
|
const { result, live } = await commit({ type: "ask_again", kind: args.kind, ids: args.ids });
|
|
1545
|
-
const
|
|
1612
|
+
const again = await readBoard();
|
|
1613
|
+
const reading = readWall(again.state, readOptions(again.boardId, again.state));
|
|
1546
1614
|
const back = reading.findings.filter((finding) => held.some((item) => item.kind === finding.kind && sameList(item.ids, finding.ids)));
|
|
1547
1615
|
return ok(
|
|
1548
1616
|
`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"}.`,
|
|
@@ -1551,15 +1619,117 @@ server.registerTool(
|
|
|
1551
1619
|
},
|
|
1552
1620
|
);
|
|
1553
1621
|
|
|
1622
|
+
/**
|
|
1623
|
+
* A scene moves to another board of the project (round fifteen, entry 16: a
|
|
1624
|
+
* writer's "the shim should open episode two" had no tool, and the way round
|
|
1625
|
+
* was a delete and a recreate by hand). Two frames, one per board: the card
|
|
1626
|
+
* leaves the open board with everything a delete takes, and lands on the
|
|
1627
|
+
* target with its own record — cast, place, when, rank, length, text, fold —
|
|
1628
|
+
* wired after or before a card there, or at the head of that board's story.
|
|
1629
|
+
*/
|
|
1630
|
+
async function moveAcrossBoards(args, target, open, held) {
|
|
1631
|
+
if (args.after && args.before) return ok("Say where: after one card's id, or before one, not both.");
|
|
1632
|
+
const card = open.state.notes.find((note) => note.id === args.id);
|
|
1633
|
+
if (!card) return ok(`No card with id ${args.id}. Call list_board.`);
|
|
1634
|
+
const fromMeta = boardById(held.project, open.boardId ?? held.project.activeBoardId);
|
|
1635
|
+
const targetState = isBoardState(held.boards[target.id]) ? normalizeState(held.boards[target.id]) : emptyState();
|
|
1636
|
+
const anchorId = args.after ?? args.before;
|
|
1637
|
+
const anchor = anchorId ? targetState.notes.find((note) => note.id === anchorId) : null;
|
|
1638
|
+
if (anchorId && !anchor) return ok(`No card with id ${anchorId} on "${target.name}". open_board there and list_board for its ids, or leave after and before out to land at the head of its story.`);
|
|
1639
|
+
// Leave: one frame on the board it is on.
|
|
1640
|
+
let taken = null;
|
|
1641
|
+
await commitAll(`move_scene "${card.headline}" to "${target.name}" (leave)`, (step) => {
|
|
1642
|
+
taken = step({ type: "delete_note", id: card.id }).result;
|
|
1643
|
+
});
|
|
1644
|
+
// Open the board it is going to, everywhere.
|
|
1645
|
+
const now = await readProject();
|
|
1646
|
+
await openBoardEverywhere(now.project, now.boards, now.rev, now.base, target.id);
|
|
1647
|
+
// Land: one frame there.
|
|
1648
|
+
const isFollows = (arrow) => arrow.kind !== "setup";
|
|
1649
|
+
let landedId = card.id;
|
|
1650
|
+
let joinedGroup = null;
|
|
1651
|
+
let headOf = null;
|
|
1652
|
+
let forgotLater = false;
|
|
1653
|
+
const { state: final, live } = await commitAll(`move_scene "${card.headline}" to "${target.name}" (land)`, (step, current) => {
|
|
1654
|
+
const here = current();
|
|
1655
|
+
landedId = here.notes.some((note) => note.id === card.id) ? newId() : card.id;
|
|
1656
|
+
step({
|
|
1657
|
+
type: "create_note",
|
|
1658
|
+
id: landedId,
|
|
1659
|
+
headline: card.headline,
|
|
1660
|
+
change: card.change,
|
|
1661
|
+
color: card.color,
|
|
1662
|
+
rank: card.rank,
|
|
1663
|
+
lengthEighths: card.lengthEighths,
|
|
1664
|
+
characterIds: card.characterIds,
|
|
1665
|
+
plants: card.plants,
|
|
1666
|
+
location: card.location,
|
|
1667
|
+
when: card.when,
|
|
1668
|
+
text: card.text,
|
|
1669
|
+
...nextPlace(here),
|
|
1670
|
+
});
|
|
1671
|
+
if (card.plants && card.payoffBoardId && card.payoffBoardId !== target.id) step({ type: "set_payoff_board", ids: [landedId], boardId: card.payoffBoardId });
|
|
1672
|
+
if (card.plants && card.payoffBoardId === target.id) forgotLater = true;
|
|
1673
|
+
if (anchor) {
|
|
1674
|
+
const mid = current();
|
|
1675
|
+
if (args.after) {
|
|
1676
|
+
for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.from === anchor.id)) {
|
|
1677
|
+
step({ type: "delete_arrow", id: arrow.id });
|
|
1678
|
+
step({ type: "create_arrow", from: landedId, to: arrow.to, kind: "follows" });
|
|
1679
|
+
}
|
|
1680
|
+
step({ type: "create_arrow", from: anchor.id, to: landedId, kind: "follows" });
|
|
1681
|
+
} else {
|
|
1682
|
+
for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.to === anchor.id)) {
|
|
1683
|
+
step({ type: "delete_arrow", id: arrow.id });
|
|
1684
|
+
step({ type: "create_arrow", from: arrow.from, to: landedId, kind: "follows" });
|
|
1685
|
+
}
|
|
1686
|
+
step({ type: "create_arrow", from: landedId, to: anchor.id, kind: "follows" });
|
|
1687
|
+
}
|
|
1688
|
+
const anchorGroup = current().groups.find((group) => group.noteIds.includes(anchor.id));
|
|
1689
|
+
if (anchorGroup) {
|
|
1690
|
+
step({ type: "add_to_group", id: anchorGroup.id, noteIds: [landedId] });
|
|
1691
|
+
joinedGroup = anchorGroup.title || "an untitled group";
|
|
1692
|
+
}
|
|
1693
|
+
} else {
|
|
1694
|
+
const head = storyOrder(here)[0];
|
|
1695
|
+
if (head && here.arrows.some(isFollows)) {
|
|
1696
|
+
step({ type: "create_arrow", from: landedId, to: head.id, kind: "follows" });
|
|
1697
|
+
headOf = head.headline;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
step({ type: "apply_poses", poses: organizePoses(current(), {}) });
|
|
1701
|
+
});
|
|
1702
|
+
const order = storyOrder(final);
|
|
1703
|
+
const arrows = taken?.arrows?.length
|
|
1704
|
+
? ` Left behind on "${fromMeta.name}": ${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 is joined behind it, "${taken.joined.fromHeadline}" → "${taken.joined.toHeadline}"` : ""}.`
|
|
1705
|
+
: ` No arrow touched it on "${fromMeta.name}".`;
|
|
1706
|
+
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
|
+
const landed = anchor
|
|
1708
|
+
? `${args.after ? "after" : "before"} "${anchor.headline}"${joinedGroup ? `, in "${joinedGroup}"` : ""}`
|
|
1709
|
+
: headOf ? `at the head of the story, before "${headOf}"` : "as the only card wired to nothing yet";
|
|
1710
|
+
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
|
+
return ok(
|
|
1712
|
+
`Moved "${card.headline}" from "${fromMeta.name}" to "${target.name}", with its cast, place, when, rank, length${card.text ? ", text" : ""} and colour; 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
|
+
{ id: landedId, board: target.id, order: order.map((note) => note.id) },
|
|
1714
|
+
);
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1554
1717
|
server.registerTool(
|
|
1555
1718
|
"move_scene",
|
|
1556
1719
|
{
|
|
1557
1720
|
title: "Move a scene in the story",
|
|
1558
1721
|
description:
|
|
1559
|
-
"Move a card to another place in the story order — after one card, or before one — by rewiring its follows arrows and tidying the wall along them, as one step that undo takes back whole. The story order is the follows arrows: the card leaves its place (what pointed at it now points at what it pointed at) and lands between the target and what followed it. A person does this by dragging in the outline. Needs a wall with follows arrows; on a wall without any, create_arrow the sequence first, or move_note by position.",
|
|
1560
|
-
inputSchema: { id: z.string(), after: z.string().optional(), before: z.string().optional() },
|
|
1722
|
+
"Move a card to another place in the story order — after one card, or before one — by rewiring its follows arrows and tidying the wall along them, as one step that undo takes back whole. The story order is the follows arrows: the card leaves its place (what pointed at it now points at what it pointed at) and lands between the target and what followed it. A person does this by dragging in the outline. Needs a wall with follows arrows; on a wall without any, create_arrow the sequence first, or move_note by position. To another board of the project: pass board (name, id or number from list_boards) and, optionally, after or before a card there; with neither the card lands at the head of that board's story. Across boards the card keeps its cast, place, when, rank, length, text and fold; its arrows stay behind, and that board is then the open one. Undo is per board: one step there, one on the board it left.",
|
|
1723
|
+
inputSchema: { id: z.string(), after: z.string().optional(), before: z.string().optional(), board: z.union([z.string().min(1), z.number()]).optional() },
|
|
1561
1724
|
},
|
|
1562
1725
|
async (args) => {
|
|
1726
|
+
if (args.board !== undefined) {
|
|
1727
|
+
const held = await readProject();
|
|
1728
|
+
const target = findBoard(held.project, String(args.board));
|
|
1729
|
+
if (!target) return ok(`No board matches "${args.board}". Call list_boards for the real ones.`);
|
|
1730
|
+
const open = await readBoard();
|
|
1731
|
+
if (target.id !== (open.boardId ?? held.project.activeBoardId)) return moveAcrossBoards(args, target, open, held);
|
|
1732
|
+
}
|
|
1563
1733
|
if (!args.after === !args.before) return ok("Say where: after one card's id, or before one, not both.");
|
|
1564
1734
|
const { state } = await readBoard();
|
|
1565
1735
|
const find = (id) => state.notes.find((note) => note.id === id);
|
|
@@ -1711,7 +1881,8 @@ server.registerTool(
|
|
|
1711
1881
|
const lines = [
|
|
1712
1882
|
`the writer's own: ${own.length}`,
|
|
1713
1883
|
...own.map((structure) => ` - ${structure.id} — "${structure.name}" (${structure.beats.length} beats: ${structure.beats.map((beat) => `${beat.name} at ${Math.round(beat.at * 100)}%`).join(", ")})`),
|
|
1714
|
-
`built in: ${TEMPLATES.length}
|
|
1884
|
+
`built in: ${TEMPLATES.length}`,
|
|
1885
|
+
...TEMPLATES.map((template) => ` - ${template.id} — "${template.name}" (${template.beats.length} beats: ${template.beats.map((beat) => `${beat.name} at ${Math.round(beat.at * 100)}%`).join(", ")})`),
|
|
1715
1886
|
"compare_structure sets one of these beside this wall's beats, page by page, and lays nothing",
|
|
1716
1887
|
];
|
|
1717
1888
|
return ok(lines.join("\n"), { builtIn: TEMPLATES.map((template) => ({ id: template.id, name: template.name, beats: template.beats })), own });
|
|
@@ -1737,12 +1908,15 @@ server.registerTool(
|
|
|
1737
1908
|
if (!chosen) return ok(`No structure called "${args.structure}". list_structures names the built-in five and the writer's own.`);
|
|
1738
1909
|
const comparison = compareStructure(state, chosen.beats);
|
|
1739
1910
|
const beats = state.notes.filter((note) => note.rank === "beat").length;
|
|
1911
|
+
const allMeasured = state.notes.length > 0 && state.notes.every((note) => isMeasured(note));
|
|
1912
|
+
const short = state.targetEighths > 0 && boardEighths(state) * 2 < state.targetEighths;
|
|
1740
1913
|
const lines = [
|
|
1741
|
-
`"${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:`,
|
|
1914
|
+
`"${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}${allMeasured ? ", measured" : ", an estimate: unsized cards read as a page each"}); a match is the nearest of the wall's beats within ${MATCH_PAGES} pages, one to one and in order:`,
|
|
1742
1915
|
...describeComparison(comparison).map((line) => ` - ${line}`),
|
|
1743
1916
|
comparison.unmatched.length
|
|
1744
1917
|
? `beats of the wall no beat of the structure answers: ${comparison.unmatched.map((beat) => `"${beat.headline}" (p. ${beat.page})`).join(", ")}`
|
|
1745
1918
|
: "every beat of the wall answers one of the structure's",
|
|
1919
|
+
...(short ? [`the wall runs to less than half its target, so its beats sit early and the ${MATCH_PAGES}-page window pairs them with the structure's first beats by arithmetic; the pairing says more once the cards are sized or written, and whether a turn is missing is the writer's call, not this reading's`] : []),
|
|
1746
1920
|
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.",
|
|
1747
1921
|
];
|
|
1748
1922
|
return ok(lines.join("\n"), { structure: { id: chosen.id, name: chosen.name }, ...comparison });
|
|
@@ -1870,7 +2044,7 @@ server.registerTool(
|
|
|
1870
2044
|
}
|
|
1871
2045
|
const printed = sceneLineCount(args.text);
|
|
1872
2046
|
return ok(
|
|
1873
|
-
`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)}.${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
|
|
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.`,
|
|
1874
2048
|
{ ...result, eighths: noteEighths(result), measured: true, printedLines: printed },
|
|
1875
2049
|
);
|
|
1876
2050
|
},
|
|
@@ -2026,7 +2200,7 @@ server.registerTool(
|
|
|
2026
2200
|
{
|
|
2027
2201
|
title: "Export as Final Draft",
|
|
2028
2202
|
description:
|
|
2029
|
-
"The open board as a Final Draft .fdx: a heading per card with its scene number by wall 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 for the
|
|
2203
|
+
"The open board as a Final Draft .fdx: a heading per card with its scene number by wall 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. Pass a path to write the file; otherwise the XML comes back with the file's name in a comment on its second line.",
|
|
2030
2204
|
inputSchema: { path: z.string().optional() },
|
|
2031
2205
|
},
|
|
2032
2206
|
async (args) => {
|
|
@@ -2035,12 +2209,14 @@ server.registerTool(
|
|
|
2035
2209
|
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
2036
2210
|
const titles = scriptTitles(project, board);
|
|
2037
2211
|
const xml = toFdx(state, { ...titles, draftDate: new Date().toISOString() });
|
|
2212
|
+
// The file's name, so an agent writing it by hand has one (round fifteen, entry 34).
|
|
2213
|
+
const filename = `${titles.title}${titles.episode ? ` - ${board?.name ?? ""}` : ""}`.replace(/[\\/:*?"<>|]+/g, " ").replace(/\s+/g, " ").trim() + ".fdx";
|
|
2038
2214
|
if (args.path) {
|
|
2039
2215
|
fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
|
|
2040
2216
|
fs.writeFileSync(args.path, xml);
|
|
2041
|
-
return ok(`Wrote a Final Draft file with ${state.notes.length} scene(s), titled "${titles.title}", to ${args.path}.`);
|
|
2217
|
+
return ok(`Wrote a Final Draft file with ${state.notes.length} scene(s), titled "${titles.title}"${titles.episode ? ` (${titles.episode})` : ""}, to ${args.path}.`);
|
|
2042
2218
|
}
|
|
2043
|
-
return ok(xml);
|
|
2219
|
+
return ok(xml.replace(/^(<\?xml[^>]*\?>\n)/, `$1<!-- Save as: ${filename.replace(/--/g, "- -")} -->\n`));
|
|
2044
2220
|
},
|
|
2045
2221
|
);
|
|
2046
2222
|
|
|
@@ -2397,11 +2573,12 @@ server.registerTool(
|
|
|
2397
2573
|
{
|
|
2398
2574
|
title: "Fold the corner",
|
|
2399
2575
|
description:
|
|
2400
|
-
`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
|
|
2576
|
+
`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, once you know it, at: the scene on that board that pays it off, by id or headline. A board alone is a promise: the reading lists the card under 'later' and, once that board holds cards, asks which scene until one claims it; with at, both boards' readings name the payoff and the paying-off card says so. later '' forgets the board; at '' keeps the board and forgets the scene. set_payoff makes the same claim from the other board. Folding never moves a card.`,
|
|
2401
2577
|
inputSchema: {
|
|
2402
2578
|
ids: z.array(z.string()).min(1),
|
|
2403
2579
|
plants: z.boolean(),
|
|
2404
2580
|
later: z.string().optional(),
|
|
2581
|
+
at: z.string().optional(),
|
|
2405
2582
|
},
|
|
2406
2583
|
},
|
|
2407
2584
|
async (args) => {
|
|
@@ -2409,15 +2586,29 @@ server.registerTool(
|
|
|
2409
2586
|
// The kernel cannot check the board exists; this door can, before anything lands.
|
|
2410
2587
|
let target = null;
|
|
2411
2588
|
let forgetting = false;
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2589
|
+
let atNote = null;
|
|
2590
|
+
let atClearing = false;
|
|
2591
|
+
if (args.plants && (args.later !== undefined || args.at !== undefined)) {
|
|
2592
|
+
const { project, boards } = await readProject();
|
|
2593
|
+
const { state: here, boardId: current } = await readBoard();
|
|
2594
|
+
if (args.later !== undefined && args.later.trim() === "") {
|
|
2416
2595
|
forgetting = true;
|
|
2417
2596
|
} else {
|
|
2418
|
-
|
|
2597
|
+
// `at` alone means the board the fold already names.
|
|
2598
|
+
const key = args.later ?? here.notes.find((note) => args.ids.includes(note.id) && note.payoffBoardId)?.payoffBoardId;
|
|
2599
|
+
if (!key) return ok("Say which board with later before at: a scene belongs to a board.");
|
|
2600
|
+
target = findBoard(project, String(key));
|
|
2419
2601
|
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.`);
|
|
2420
2602
|
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'.`);
|
|
2603
|
+
if (args.at !== undefined) {
|
|
2604
|
+
if (args.at.trim() === "") atClearing = true;
|
|
2605
|
+
else {
|
|
2606
|
+
const there = isBoardState(boards[target.id]) ? normalizeState(boards[target.id]) : emptyState();
|
|
2607
|
+
const wanted = args.at.trim().toLowerCase();
|
|
2608
|
+
atNote = there.notes.find((note) => note.id === args.at.trim()) ?? there.notes.find((note) => note.headline.trim().toLowerCase() === wanted) ?? null;
|
|
2609
|
+
if (!atNote) return ok(`No card on "${target.name}" with id or headline "${args.at}". open_board there and list_board for its cards; or leave at out and the fold stays a promise on that board.`);
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2421
2612
|
}
|
|
2422
2613
|
}
|
|
2423
2614
|
// The fold and the board it pays off on land as one change: one ⌘Z on the wall.
|
|
@@ -2431,10 +2622,14 @@ server.registerTool(
|
|
|
2431
2622
|
laterLine = " The board it paid off on is forgotten; read_wall asks again until a setup arrow or a board pays it off.";
|
|
2432
2623
|
}
|
|
2433
2624
|
} else if (target) {
|
|
2434
|
-
const named = step({ type: "set_payoff_board", ids: args.ids, boardId: target.id });
|
|
2625
|
+
const named = step({ type: "set_payoff_board", ids: args.ids, boardId: target.id, noteId: atNote?.id ?? null });
|
|
2435
2626
|
if (named.changed) result = named.result;
|
|
2436
2627
|
const here = current().notes.filter((note) => args.ids.includes(note.id));
|
|
2437
|
-
laterLine =
|
|
2628
|
+
laterLine = atNote
|
|
2629
|
+
? ` ${here.length} card(s) pay off at "${atNote.headline}" on "${target.name}": both boards' readings name it, and that card says what it pays off.`
|
|
2630
|
+
: atClearing
|
|
2631
|
+
? ` ${here.length} card(s) pay off later, on "${target.name}", and no scene there is claimed: read_wall asks which once that board holds cards.`
|
|
2632
|
+
: ` ${here.length} card(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.`;
|
|
2438
2633
|
}
|
|
2439
2634
|
return { result, laterLine };
|
|
2440
2635
|
});
|
|
@@ -2452,6 +2647,52 @@ server.registerTool(
|
|
|
2452
2647
|
|
|
2453
2648
|
// --- Characters -------------------------------------------------------
|
|
2454
2649
|
|
|
2650
|
+
// The receiving end of a series plant (R58), claimed from the board it lands
|
|
2651
|
+
// on: the same record set_plant's `at` writes, on the fold's own card, so one
|
|
2652
|
+
// claim has one owner. Two board switches around one frame on the fold's
|
|
2653
|
+
// board, which is where undo takes it back.
|
|
2654
|
+
server.registerTool(
|
|
2655
|
+
"set_payoff",
|
|
2656
|
+
{
|
|
2657
|
+
title: "Pay off a fold from another board",
|
|
2658
|
+
description:
|
|
2659
|
+
"Say that a card on this board pays off a fold of another board of the project: id (the card here), from (that board, by name, id or number), and fold (the folded card there, by id or headline). The claim is written on the fold's card — the twin of set_plant with later and at — so both boards' readings name it and the card here says what it pays off. fold '' takes back every claim of that board on this card. The wall's other board is opened for the write and this one reopened after; undo on that board takes the claim back.",
|
|
2660
|
+
inputSchema: { id: z.string(), from: z.union([z.string().min(1), z.number()]), fold: z.string() },
|
|
2661
|
+
},
|
|
2662
|
+
async (args) => {
|
|
2663
|
+
const { project, boards } = await readProject();
|
|
2664
|
+
const { state: here, boardId } = await readBoard();
|
|
2665
|
+
const hereId = boardId ?? project.activeBoardId;
|
|
2666
|
+
const card = here.notes.find((note) => note.id === args.id);
|
|
2667
|
+
if (!card) return ok(`No card with id ${args.id} on this board. Call list_board.`);
|
|
2668
|
+
const source = findBoard(project, String(args.from));
|
|
2669
|
+
if (!source) return ok(`No board matches "${args.from}". Call list_boards for the real ones.`);
|
|
2670
|
+
if (source.id === hereId) return ok(`"${source.name}" is this board. A payoff on the same board is a setup arrow: create_arrow from the fold to this card, kind 'setup'.`);
|
|
2671
|
+
const there = isBoardState(boards[source.id]) ? normalizeState(boards[source.id]) : emptyState();
|
|
2672
|
+
const clearing = args.fold.trim() === "";
|
|
2673
|
+
const wanted = args.fold.trim().toLowerCase();
|
|
2674
|
+
const folds = clearing
|
|
2675
|
+
? there.notes.filter((note) => note.plants && note.payoffBoardId === hereId && note.payoffNoteId === card.id)
|
|
2676
|
+
: [there.notes.find((note) => note.id === args.fold.trim()) ?? there.notes.find((note) => note.headline.trim().toLowerCase() === wanted)].filter(Boolean);
|
|
2677
|
+
if (!folds.length) return ok(clearing ? `No fold of "${source.name}" claims "${card.headline}".` : `No card on "${source.name}" with id or headline "${args.fold}". open_board there and list_board for its cards.`);
|
|
2678
|
+
if (!clearing && !folds[0].plants) return ok(`"${folds[0].headline}" on "${source.name}" is not folded: nothing to pay off. set_plant it there first, with later "${boardById(project, hereId)?.name ?? hereId}".`);
|
|
2679
|
+
const held = await readProject();
|
|
2680
|
+
await openBoardEverywhere(held.project, held.boards, held.rev, held.base, source.id);
|
|
2681
|
+
const { changed } = await commitAll(`set_payoff "${card.headline}"`, (step) => {
|
|
2682
|
+
for (const fold of folds) step({ type: "set_payoff_board", ids: [fold.id], boardId: clearing ? hereId : hereId, noteId: clearing ? null : card.id });
|
|
2683
|
+
});
|
|
2684
|
+
const back = await readProject();
|
|
2685
|
+
const { live } = await openBoardEverywhere(back.project, back.boards, back.rev, back.base, hereId);
|
|
2686
|
+
if (!changed) return ok(`No change: ${clearing ? "nothing was claimed" : `"${folds[0].headline}" already pays off at "${card.headline}"`}.`);
|
|
2687
|
+
return ok(
|
|
2688
|
+
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)}. This board is open again; undo on "${source.name}" takes it back.`
|
|
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. This board is open again; undo on "${source.name}" takes the claim back.`,
|
|
2691
|
+
{ fold: folds.map((fold) => fold.id), board: source.id, card: card.id },
|
|
2692
|
+
);
|
|
2693
|
+
},
|
|
2694
|
+
);
|
|
2695
|
+
|
|
2455
2696
|
server.registerTool(
|
|
2456
2697
|
"add_character",
|
|
2457
2698
|
{
|
|
@@ -2467,7 +2708,7 @@ server.registerTool(
|
|
|
2467
2708
|
? ok(`Already in the cast as "${result.name}" (${result.id}). Use that id.`, result)
|
|
2468
2709
|
: ok("No character added: the name was empty.");
|
|
2469
2710
|
}
|
|
2470
|
-
return ok(`Added "${result.name}" to the project's cast${where(live)}; every board of the project casts from it.`, result);
|
|
2711
|
+
return ok(`Added "${result.name}" (id ${result.id}) to the project's cast${where(live)}; every board of the project casts from it.`, result);
|
|
2471
2712
|
},
|
|
2472
2713
|
);
|
|
2473
2714
|
|
|
@@ -2505,22 +2746,37 @@ server.registerTool(
|
|
|
2505
2746
|
{
|
|
2506
2747
|
title: "Read a person's page",
|
|
2507
2748
|
description:
|
|
2508
|
-
"Read one person's page back, by id or by name: the five lines — looks, voice, wants, needs, notes — as they stand, and
|
|
2749
|
+
"Read one person's page back, by id or by name: the five lines — looks, voice, wants, needs, notes — as they stand, and every card the person is on across every board of the project, in story order, each with its place, when and rank. The cast is the project's (one record, one page), so this reads all of it; list_board says only which lines are written.",
|
|
2509
2750
|
inputSchema: { id: z.string().optional(), name: z.string().optional() },
|
|
2510
2751
|
},
|
|
2511
2752
|
async (args) => {
|
|
2512
2753
|
const key = (args.id ?? args.name ?? "").trim();
|
|
2513
2754
|
if (!key) return ok("Say who: the person's id or name from list_board.");
|
|
2514
|
-
const { state } = await readBoard();
|
|
2755
|
+
const { state, boardId } = await readBoard();
|
|
2756
|
+
const { project, boards } = await readProject();
|
|
2515
2757
|
const wanted = key.toLowerCase();
|
|
2516
2758
|
const person = state.characters.find((item) => item.id === key) ?? state.characters.find((item) => item.name.trim().toLowerCase() === wanted);
|
|
2517
2759
|
if (!person) return ok(`Nobody called "${key}" in the cast. Call list_board for the cast, or add_character.`);
|
|
2518
|
-
|
|
2760
|
+
// The person's part is the project's, not one board's (R51; round fifteen,
|
|
2761
|
+
// entries 22 and 23): every board, in the project's order, the open one read live.
|
|
2762
|
+
const openId = boardId ?? project.activeBoardId;
|
|
2763
|
+
const parts = project.boards.map((meta) => {
|
|
2764
|
+
const held = meta.id === openId ? state : isBoardState(boards[meta.id]) ? normalizeState(boards[meta.id]) : emptyState();
|
|
2765
|
+
const on = storyOrder(held).filter((note) => (note.characterIds ?? []).includes(person.id));
|
|
2766
|
+
return { meta, on };
|
|
2767
|
+
});
|
|
2768
|
+
const total = parts.reduce((sum, part) => sum + part.on.length, 0);
|
|
2769
|
+
const where_ = (note) => [note.location ? `at ${note.location}` : "", note.when ? note.when : "", note.rank === "beat" ? "beat" : ""].filter(Boolean).join(" · ");
|
|
2519
2770
|
const lines = [
|
|
2520
|
-
`${person.name} (${person.id}) — on ${
|
|
2771
|
+
`${person.name} (${person.id}) — on ${total} card${total === 1 ? "" : "s"} across ${project.boards.length} board${project.boards.length === 1 ? "" : "s"} of the project`,
|
|
2521
2772
|
...CHARACTER_FIELDS.map((field) => ` ${field}: ${(person[field] ?? "").trim() || "(empty)"}`),
|
|
2773
|
+
...parts.map((part) =>
|
|
2774
|
+
part.on.length
|
|
2775
|
+
? ` "${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)})` : ""}`).join("; ")}`
|
|
2776
|
+
: ` "${part.meta.name}": on no card`,
|
|
2777
|
+
),
|
|
2522
2778
|
];
|
|
2523
|
-
return ok(lines.join("\n"), { ...person, cards: on.map((note) => note.id) });
|
|
2779
|
+
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) })) });
|
|
2524
2780
|
},
|
|
2525
2781
|
);
|
|
2526
2782
|
|
|
@@ -2706,7 +2962,7 @@ server.registerTool(
|
|
|
2706
2962
|
: "No group made: a group needs at least two cards.",
|
|
2707
2963
|
);
|
|
2708
2964
|
}
|
|
2709
|
-
return ok(`Grouped ${result.noteIds.length} cards as "${result.title}"${where(live)}.`, result);
|
|
2965
|
+
return ok(`Grouped ${result.noteIds.length} cards as "${result.title}" (group id ${result.id})${where(live)}.`, result);
|
|
2710
2966
|
},
|
|
2711
2967
|
);
|
|
2712
2968
|
|
|
@@ -2876,6 +3132,16 @@ server.registerTool(
|
|
|
2876
3132
|
`premise: ${project.premise ? `"${project.premise}"` : "(not set)"}`,
|
|
2877
3133
|
`boards: ${project.boards.length}`,
|
|
2878
3134
|
describeBoards(project, boards, changedAt),
|
|
3135
|
+
// The project's length as one line, so a series is not arithmetic by hand (round fifteen, entry 38).
|
|
3136
|
+
...(project.boards.length > 1
|
|
3137
|
+
? (() => {
|
|
3138
|
+
const states = project.boards.map((meta) => (isBoardState(boards[meta.id]) ? normalizeState(boards[meta.id]) : null)).filter(Boolean);
|
|
3139
|
+
const pages = states.reduce((sum, state) => sum + boardEighths(state), 0);
|
|
3140
|
+
const target = states.reduce((sum, state) => sum + state.targetEighths, 0);
|
|
3141
|
+
const cards = states.reduce((sum, state) => sum + state.notes.length, 0);
|
|
3142
|
+
return [`the whole project: ${cards} cards, about ${formatPages(pages)} of ${formatPages(target)} pages across ${states.length} boards (each board's runtime is an estimate unless every scene is written)`];
|
|
3143
|
+
})()
|
|
3144
|
+
: []),
|
|
2879
3145
|
].join("\n"),
|
|
2880
3146
|
project,
|
|
2881
3147
|
);
|
|
@@ -2911,7 +3177,11 @@ server.registerTool(
|
|
|
2911
3177
|
const next = renameProject(project, args.name);
|
|
2912
3178
|
if (next === project) return ok("Project name unchanged.");
|
|
2913
3179
|
await writeProject(next, boards, rev, base);
|
|
2914
|
-
|
|
3180
|
+
// The line at the head of every reply names the project it works; it must
|
|
3181
|
+
// follow the rename (round fifteen, entry 40).
|
|
3182
|
+
if (accountDoor) workingProject(next.id, next.name);
|
|
3183
|
+
const several = (next.boards ?? []).length > 1;
|
|
3184
|
+
return ok(`Project renamed to "${next.name}"${where(live)}. It shows at the head of every reply, in list_boards and read_wall, and as the title of every script out${several ? `, where each board follows it as an episode line (Episode 1 of ${next.boards.length} · ${next.boards[0].name})` : ""}. The boards keep their names.`, next);
|
|
2915
3185
|
},
|
|
2916
3186
|
);
|
|
2917
3187
|
|
package/src/board/agents.js
CHANGED
|
@@ -42,7 +42,7 @@ 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 four 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; read_wall and list_reminders are about the wall you will work, so after open_project
|
|
45
|
+
firstNote: "Make these four 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; read_wall and list_reminders are about the wall you will work, so after open_project or open_board make those two again, and after new_project read the wall once it holds cards. An emptied account has nothing to read: go straight to new_project. Every reply's first line names the project it read and how many the account holds; list_projects lists them. 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 (name, pages, and board for the first board's name). The reading holds the beats and the runs; the ids of every card, the cast and the places are list_board's, so make that your fifth call before you touch anything. 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." },
|
|
@@ -64,16 +64,19 @@ 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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
lines.push("## Call these first");
|
|
67
|
+
// The calls and the rules first, the doors after: an agent with the tools in
|
|
68
|
+
// front of it reads two pages of wiring it was told to skip before it reached
|
|
69
|
+
// the first call (round fifteen, entry 1).
|
|
70
|
+
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 at the end are for wiring a server in; skip them when the tools are already in front of you.`, "", "## Call these first"];
|
|
73
71
|
AGENTS.first.forEach((item, index) => lines.push(`${index + 1}. ${item.tool} — ${item.why}`));
|
|
74
72
|
lines.push("", AGENTS.firstNote);
|
|
75
73
|
lines.push("", "## Rules");
|
|
76
74
|
for (const rule of AGENTS.rules) lines.push(`- ${rule}`);
|
|
77
|
-
lines.push("", "##
|
|
75
|
+
lines.push("", "## Doors");
|
|
76
|
+
for (const door of AGENTS.doors) {
|
|
77
|
+
lines.push(`- ${door.name}: ${door.text}`);
|
|
78
|
+
if (door.code) lines.push("", "```", door.code, "```", "");
|
|
79
|
+
}
|
|
80
|
+
lines.push("## For the person", AGENTS.person, "", "## The guide", AGENTS.guide, "");
|
|
78
81
|
return lines.join("\n");
|
|
79
82
|
}
|
package/src/board/fdx.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { FountainScene } from "./fountain";
|
|
|
5
5
|
|
|
6
6
|
export declare function toFdx(
|
|
7
7
|
state: BoardState,
|
|
8
|
-
options?: { title?: string;
|
|
8
|
+
options?: { title?: string; episode?: string; author?: string; draftDate?: string },
|
|
9
9
|
): string;
|
|
10
10
|
|
|
11
11
|
export type SetAside = {
|
package/src/board/fdx.js
CHANGED
|
@@ -88,10 +88,11 @@ export function toFdx(state, options = {}) {
|
|
|
88
88
|
|
|
89
89
|
const title = [];
|
|
90
90
|
if (options.title) title.push(paragraph("General", options.title, ' Alignment="Center"'));
|
|
91
|
-
if (options.
|
|
91
|
+
if (options.episode) title.push(paragraph("General", options.episode, ' Alignment="Center"'));
|
|
92
92
|
if (options.author) title.push(paragraph("General", `Written by ${options.author}`, ' Alignment="Center"'));
|
|
93
93
|
if (options.draftDate) title.push(paragraph("General", options.draftDate.slice(0, 10)));
|
|
94
|
-
|
|
94
|
+
// A lock is a fact about the document; that there is none is the app's business, not the title page's (round fifteen, entry 36).
|
|
95
|
+
if (state.lock) title.push(paragraph("General", `Scene numbers locked ${String(state.lock.at).slice(0, 10)}.`));
|
|
95
96
|
if (state.revision) title.push(paragraph("General", `${revisionLine(state)}; changed paragraphs are marked.`));
|
|
96
97
|
// The revision set Final Draft shows its marks from (round fourteen, entry 45).
|
|
97
98
|
const revisions = state.revision
|
package/src/board/fountain.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export declare function unmark(text: string | null | undefined): { text: string;
|
|
|
12
12
|
|
|
13
13
|
export declare function titlePage(titles: {
|
|
14
14
|
title?: string;
|
|
15
|
+
episode?: string;
|
|
15
16
|
credit?: string;
|
|
16
17
|
author?: string;
|
|
17
18
|
draftDate?: string;
|
|
@@ -22,7 +23,7 @@ export type FountainOptions = {
|
|
|
22
23
|
/** The board's name. */
|
|
23
24
|
title?: string;
|
|
24
25
|
/** The project's name, when the board is one of several. */
|
|
25
|
-
|
|
26
|
+
episode?: string;
|
|
26
27
|
premise?: string;
|
|
27
28
|
author?: string;
|
|
28
29
|
/** ISO date string; only the date is printed. */
|
package/src/board/fountain.js
CHANGED
|
@@ -61,9 +61,10 @@ export function unmark(text) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/** The title page block. `titles` is what the writer would put above the script. */
|
|
64
|
-
export function titlePage({ title, credit, author, draftDate, notes }) {
|
|
64
|
+
export function titlePage({ title, episode, credit, author, draftDate, notes }) {
|
|
65
65
|
const lines = [];
|
|
66
66
|
if (title) lines.push(`Title: ${title}`);
|
|
67
|
+
if (episode) lines.push(`Episode: ${episode}`);
|
|
67
68
|
if (credit) lines.push(`Credit: ${credit}`);
|
|
68
69
|
if (author) lines.push(`Author: ${author}`);
|
|
69
70
|
if (draftDate) lines.push(`Draft date: ${draftDate}`);
|
|
@@ -97,7 +98,7 @@ export function toFountain(state, options = {}) {
|
|
|
97
98
|
|
|
98
99
|
const head = titlePage({
|
|
99
100
|
title: options.title || "Untitled",
|
|
100
|
-
|
|
101
|
+
episode: options.episode,
|
|
101
102
|
author: options.author,
|
|
102
103
|
draftDate: options.draftDate ? options.draftDate.slice(0, 10) : undefined,
|
|
103
104
|
notes,
|
package/src/board/markdown.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Line } from "./paginate";
|
|
|
2
2
|
import type { BoardState } from "./reducer";
|
|
3
3
|
|
|
4
4
|
/** What a document carries above the script: the board's name, the project's when it has several boards, the premise. */
|
|
5
|
-
export type TakeOptions = { title?: string;
|
|
5
|
+
export type TakeOptions = { title?: string; episode?: string; premise?: string };
|
|
6
6
|
|
|
7
7
|
/** The wall as Markdown (R54): title, premise, logline, beats as headings, a heading per scene, the text or the change line. */
|
|
8
8
|
export declare function toMarkdown(state: BoardState, options?: TakeOptions): string;
|
package/src/board/markdown.js
CHANGED
|
@@ -28,7 +28,7 @@ function upper(text) {
|
|
|
28
28
|
|
|
29
29
|
function documentTitle(options) {
|
|
30
30
|
const title = options.title || "Untitled";
|
|
31
|
-
return options.
|
|
31
|
+
return options.episode ? `${title} · ${options.episode}` : title;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/** A scene's text as Markdown paragraphs: action as it is, a speech as its cue in bold with the lines hard-broken under it. */
|
|
@@ -177,8 +177,8 @@ export function toPlainText(state, options = {}) {
|
|
|
177
177
|
);
|
|
178
178
|
const title = options.title || "Untitled";
|
|
179
179
|
const out = [];
|
|
180
|
-
if (options.
|
|
181
|
-
out.push(centred(upper(
|
|
180
|
+
if (options.episode) {
|
|
181
|
+
out.push(centred(upper(title)), "", centred(options.episode));
|
|
182
182
|
} else {
|
|
183
183
|
out.push(centred(upper(title)));
|
|
184
184
|
}
|
package/src/board/project.d.ts
CHANGED
|
@@ -68,7 +68,7 @@ export declare function renameProject(project: ProjectRecord, name: string, now?
|
|
|
68
68
|
export declare function setPremise(project: ProjectRecord, premise: string, now?: string): ProjectRecord;
|
|
69
69
|
export declare function boardById(project: ProjectRecord, id: string): BoardMeta | null;
|
|
70
70
|
/** What a script going out is called: a named project is the title, its board beside it only when the project has several. */
|
|
71
|
-
export declare function scriptTitles(project: ProjectRecord, board: BoardMeta | null | undefined): { title: string;
|
|
71
|
+
export declare function scriptTitles(project: ProjectRecord, board: BoardMeta | null | undefined): { title: string; episode?: string };
|
|
72
72
|
export declare function findBoard(project: ProjectRecord, key: string): BoardMeta | null;
|
|
73
73
|
export declare function reidentifyProject(
|
|
74
74
|
project: ProjectRecord,
|
|
@@ -85,6 +85,11 @@ export declare function liftCast(
|
|
|
85
85
|
): { project: ProjectRecord; boards: Record<string, BoardState>; changed: boolean };
|
|
86
86
|
export type CastElsewhere = Record<string, Array<{ board: string; boardId: string; cards: number }>>;
|
|
87
87
|
export declare function castElsewhere(project: ProjectRecord, boards: Record<string, BoardState>, activeBoardId: string): CastElsewhere;
|
|
88
|
+
|
|
89
|
+
/** A fold on another board that lands here (R58): the fold's board, card and colour, and the paying-off card here (null while waiting). */
|
|
90
|
+
export type Landing = { id: string | null; fromBoardId: string; fromBoardName: string; fromNoteId: string; fromHeadline: string; fromColor: string };
|
|
91
|
+
export declare function landingsOn(project: ProjectRecord, boards: Record<string, BoardState>, boardId: string): { paid: Array<Landing & { id: string }>; waiting: Landing[] };
|
|
92
|
+
export declare function laterBoards(project: ProjectRecord, boards: Record<string, BoardState>): Record<string, { name: string; cards: number; noteIds: string[] }>;
|
|
88
93
|
export declare function mergeRoster(
|
|
89
94
|
project: ProjectRecord,
|
|
90
95
|
state: BoardState,
|
package/src/board/project.js
CHANGED
|
@@ -220,6 +220,42 @@ export function castElsewhere(project, boards, activeBoardId) {
|
|
|
220
220
|
return map;
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
/**
|
|
224
|
+
* What lands on a board from the folds of the project's other boards (R58):
|
|
225
|
+
* `paid` — folds that name a scene here, keyed for the paying-off card;
|
|
226
|
+
* `waiting` — folds that name this board and no scene on it yet (or a scene
|
|
227
|
+
* that is gone). The receiving board carries no record of its own; this is
|
|
228
|
+
* composed from the project, the way castElsewhere composes the cast.
|
|
229
|
+
*/
|
|
230
|
+
export function landingsOn(project, boards, boardId) {
|
|
231
|
+
const here = boards[boardId];
|
|
232
|
+
const hereIds = new Set((here?.notes ?? []).map((note) => note.id));
|
|
233
|
+
const paid = [];
|
|
234
|
+
const waiting = [];
|
|
235
|
+
for (const meta of project.boards) {
|
|
236
|
+
if (meta.id === boardId) continue;
|
|
237
|
+
const state = boards[meta.id];
|
|
238
|
+
if (!state) continue;
|
|
239
|
+
for (const note of state.notes) {
|
|
240
|
+
if (!note.plants || note.payoffBoardId !== boardId) continue;
|
|
241
|
+
const landing = { fromBoardId: meta.id, fromBoardName: meta.name, fromNoteId: note.id, fromHeadline: note.headline, fromColor: note.color };
|
|
242
|
+
if (note.payoffNoteId && hereIds.has(note.payoffNoteId)) paid.push({ ...landing, id: note.payoffNoteId });
|
|
243
|
+
else waiting.push({ ...landing, id: note.payoffNoteId ?? null });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return { paid, waiting };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Every board's name, card count and card ids, for the reading to judge a fold's promise by (R58). */
|
|
250
|
+
export function laterBoards(project, boards) {
|
|
251
|
+
const map = {};
|
|
252
|
+
for (const meta of project.boards) {
|
|
253
|
+
const state = boards[meta.id];
|
|
254
|
+
map[meta.id] = { name: meta.name, cards: state?.notes?.length ?? 0, noteIds: (state?.notes ?? []).map((note) => note.id) };
|
|
255
|
+
}
|
|
256
|
+
return map;
|
|
257
|
+
}
|
|
258
|
+
|
|
223
259
|
function touch(project, patch, now) {
|
|
224
260
|
return { ...project, ...patch, updatedAt: now };
|
|
225
261
|
}
|
|
@@ -300,7 +336,15 @@ export function scriptTitles(project, board) {
|
|
|
300
336
|
const boardName = (board?.name ?? "").trim() || "Untitled";
|
|
301
337
|
const named = typeof project?.name === "string" && project.name.trim() && project.name !== DEFAULT_PROJECT_NAME;
|
|
302
338
|
if (!named) return { title: boardName };
|
|
303
|
-
|
|
339
|
+
const boards = project.boards ?? [];
|
|
340
|
+
if (boards.length > 1) {
|
|
341
|
+
// A series: the project is the title and the board is the episode line,
|
|
342
|
+
// numbered in the project's order, so one file says which episode it is
|
|
343
|
+
// (round fifteen, entries 32 and 37).
|
|
344
|
+
const index = boards.findIndex((item) => item.id === board?.id);
|
|
345
|
+
const number = index >= 0 ? `Episode ${index + 1} of ${boards.length}` : "An episode";
|
|
346
|
+
return { title: project.name, episode: `${number} · ${boardName}` };
|
|
347
|
+
}
|
|
304
348
|
return { title: project.name };
|
|
305
349
|
}
|
|
306
350
|
|
package/src/board/readWall.d.ts
CHANGED
|
@@ -61,8 +61,10 @@ export type WallReading = {
|
|
|
61
61
|
/** Every planted card: the scene that pays it off (first setup arrow, by wall order), or null while unpaid. */
|
|
62
62
|
/** For each folded card, the cards its setup arrows land on, in wall order; empty when unpaid. */
|
|
63
63
|
payoffs: Record<string, string[]>;
|
|
64
|
-
/** Folded cards that pay off on another board of the project (R50): the card and the board. */
|
|
65
|
-
later: { id: string; boardId: string }[];
|
|
64
|
+
/** Folded cards that pay off on another board of the project (R50): the card, the board, and the scene there that claims it (R58) or null while the board is a promise. */
|
|
65
|
+
later: { id: string; boardId: string; noteId: string | null }[];
|
|
66
|
+
/** Cards here that pay off a fold of another board (R58), composed by the door from the project. */
|
|
67
|
+
paidBy: Array<{ id: string; fromBoardId: string; fromBoardName: string; fromNoteId: string; fromHeadline: string; fromColor: string }>;
|
|
66
68
|
/** The questions the wall asks now. A left one (R53) is not here while its words hold. */
|
|
67
69
|
findings: Finding[];
|
|
68
70
|
/** Questions the writer has left, for now: the same question, with when it was left. */
|
|
@@ -78,6 +80,10 @@ export declare function readWall(
|
|
|
78
80
|
options?: {
|
|
79
81
|
/** Cast ids on a card of another board of the project (R51): not asked about as uncast here. */
|
|
80
82
|
elsewhere?: string[];
|
|
83
|
+
/** Every board of the project by id (R58): a fold's promise is asked about once that board holds cards and no scene claims it. */
|
|
84
|
+
laterBoards?: Record<string, { name: string; cards: number; noteIds: string[] }>;
|
|
85
|
+
/** Folds of other boards that land on cards here (R58). */
|
|
86
|
+
paidBy?: Array<{ id: string; fromBoardId: string; fromBoardName: string; fromNoteId: string; fromHeadline: string; fromColor: string }>;
|
|
81
87
|
},
|
|
82
88
|
): WallReading;
|
|
83
89
|
export declare function describeRuns(reading: WallReading, state: BoardState): string[];
|
package/src/board/readWall.js
CHANGED
|
@@ -165,6 +165,11 @@ export function readWall(state, options = {}) {
|
|
|
165
165
|
// People on a card of another board of the project (R51) are cast, and
|
|
166
166
|
// are not asked about here.
|
|
167
167
|
const elsewhere = new Set(Array.isArray(options.elsewhere) ? options.elsewhere : []);
|
|
168
|
+
// The project's other boards, for a fold that pays off later (R58): a board
|
|
169
|
+
// that holds cards and no claimed scene is a promise the wall asks about.
|
|
170
|
+
const boardsHeld = options.laterBoards && typeof options.laterBoards === "object" ? options.laterBoards : null;
|
|
171
|
+
// Folds of other boards that land on a card here (R58), composed by the door.
|
|
172
|
+
const paidBy = Array.isArray(options.paidBy) ? options.paidBy : [];
|
|
168
173
|
const order = storyOrder(state);
|
|
169
174
|
const beats = order.filter((note) => note.rank === "beat");
|
|
170
175
|
|
|
@@ -230,9 +235,14 @@ export function readWall(state, options = {}) {
|
|
|
230
235
|
const note = byId.get(id);
|
|
231
236
|
return note && (note.lengthEighths !== null || (note.text ?? "").trim());
|
|
232
237
|
}));
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
238
|
+
// The typical run is the median of the runs that hold a card: an empty run
|
|
239
|
+
// is a question of its own ("empty"), and counting it here made a small
|
|
240
|
+
// wall's one ordinary scene read as a sag against a median of an eighth
|
|
241
|
+
// (round fifteen, entry 10).
|
|
242
|
+
const filled = between.filter((run) => run.ids.length > 0);
|
|
243
|
+
if (filled.length >= 2 && claimed) {
|
|
244
|
+
const typical = median(filled.map((run) => run.eighths));
|
|
245
|
+
const longest = filled.reduce((top, run) => (run.eighths > top.eighths ? run : top));
|
|
236
246
|
if (typical > 0 && longest.eighths > SAG_RATIO * typical) {
|
|
237
247
|
findings.push({
|
|
238
248
|
kind: "sag",
|
|
@@ -375,7 +385,13 @@ export function readWall(state, options = {}) {
|
|
|
375
385
|
}
|
|
376
386
|
// A fold that pays off on another board (R50) is not unpaid: it is listed
|
|
377
387
|
// under `later`, and the door that knows the project names the board.
|
|
378
|
-
const later = order
|
|
388
|
+
const later = order
|
|
389
|
+
.filter((note) => note.plants && note.payoffBoardId)
|
|
390
|
+
.map((note) => {
|
|
391
|
+
const held = boardsHeld?.[note.payoffBoardId];
|
|
392
|
+
const claimed = Boolean(note.payoffNoteId && (!held || held.noteIds.includes(note.payoffNoteId)));
|
|
393
|
+
return { id: note.id, boardId: note.payoffBoardId, noteId: claimed ? note.payoffNoteId : null };
|
|
394
|
+
});
|
|
379
395
|
for (const note of order) {
|
|
380
396
|
if (note.plants && !paysOff.has(note.id) && !note.payoffBoardId) {
|
|
381
397
|
findings.push({
|
|
@@ -385,6 +401,20 @@ export function readWall(state, options = {}) {
|
|
|
385
401
|
});
|
|
386
402
|
}
|
|
387
403
|
}
|
|
404
|
+
// A board's name is a promise; a scene is the payoff (R58). Once the board
|
|
405
|
+
// has cards, the wall asks which one, until a scene there claims the fold.
|
|
406
|
+
for (const item of later) {
|
|
407
|
+
const held = boardsHeld?.[item.boardId];
|
|
408
|
+
if (!held || held.cards === 0 || item.noteId) continue;
|
|
409
|
+
const note = byId.get(item.id);
|
|
410
|
+
findings.push({
|
|
411
|
+
kind: "unpaid",
|
|
412
|
+
ids: [item.id],
|
|
413
|
+
text: `${quote(note)} pays off later, on "${held.name}", but no scene there claims it yet. Which one?`,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
const hereIds = new Set(order.map((note) => note.id));
|
|
417
|
+
const paidHere = paidBy.filter((item) => hereIds.has(item.id));
|
|
388
418
|
|
|
389
419
|
// The cast (R29): someone who vanishes for a stretch, or never appears.
|
|
390
420
|
const total = boardEighths(state);
|
|
@@ -458,6 +488,7 @@ export function readWall(state, options = {}) {
|
|
|
458
488
|
setups,
|
|
459
489
|
payoffs,
|
|
460
490
|
later,
|
|
491
|
+
paidBy: paidHere,
|
|
461
492
|
findings: asked,
|
|
462
493
|
left,
|
|
463
494
|
};
|
package/src/board/reducer.d.ts
CHANGED
|
@@ -81,6 +81,8 @@ export type BoardNote = {
|
|
|
81
81
|
plants: boolean;
|
|
82
82
|
/** When folded: the id of another board of the project where it pays off (R50), or null. */
|
|
83
83
|
payoffBoardId: string | null;
|
|
84
|
+
/** The scene on that board that pays it off (R58), or null while the board is only a promise. */
|
|
85
|
+
payoffNoteId: string | null;
|
|
84
86
|
/** Where the scene happens (R37): a phrase in the writer's words; empty until set. */
|
|
85
87
|
location: string;
|
|
86
88
|
/** When the scene happens, as the writer says it — "night", "day four, dawn" — printed after the place on the heading (R55). Empty when unsaid. */
|
|
@@ -184,7 +186,7 @@ export type Command =
|
|
|
184
186
|
| ({ type: "update_character"; id: string } & Partial<Record<CharacterField, string>>)
|
|
185
187
|
| { type: "set_cast"; ids: string[]; characterIds: string[] }
|
|
186
188
|
| { type: "set_plant"; ids: string[]; plants: boolean }
|
|
187
|
-
| { type: "set_payoff_board"; ids: string[]; boardId: string | null }
|
|
189
|
+
| { type: "set_payoff_board"; ids: string[]; boardId: string | null; noteId?: string | null }
|
|
188
190
|
| { type: "set_location"; ids: string[]; location: string }
|
|
189
191
|
| { type: "set_when"; ids: string[]; when: string }
|
|
190
192
|
| { type: "apply_template"; template: string; beats?: Array<{ name: string; prompt: string; at: number }> }
|
package/src/board/reducer.js
CHANGED
|
@@ -249,8 +249,10 @@ export function seedState(now = nowIso()) {
|
|
|
249
249
|
text: "",
|
|
250
250
|
plants: false,
|
|
251
251
|
// A fold that pays off on another board — a later episode — names it here;
|
|
252
|
-
// null claims nothing (R50).
|
|
252
|
+
// null claims nothing (R50). The scene there that pays it off, once one
|
|
253
|
+
// does (R58); null while the board is a promise.
|
|
253
254
|
payoffBoardId: null,
|
|
255
|
+
payoffNoteId: null,
|
|
254
256
|
createdAt: now,
|
|
255
257
|
updatedAt: now,
|
|
256
258
|
});
|
|
@@ -346,6 +348,8 @@ export function normalizeState(value) {
|
|
|
346
348
|
const plants = note?.plants === true;
|
|
347
349
|
// Cards written before R50 pay off on their own board or not at all.
|
|
348
350
|
const payoffBoardId = plants && typeof note?.payoffBoardId === "string" && note.payoffBoardId ? note.payoffBoardId : null;
|
|
351
|
+
// Cards written before R58 name a board and no scene on it.
|
|
352
|
+
const payoffNoteId = payoffBoardId && typeof note?.payoffNoteId === "string" && note.payoffNoteId ? note.payoffNoteId : null;
|
|
349
353
|
// Cards written before R37 have no place; a scene is nowhere until it is.
|
|
350
354
|
const location = typeof note?.location === "string" ? note.location : "";
|
|
351
355
|
// Cards written before R55 have no when; a scene is at no time until it is.
|
|
@@ -360,6 +364,7 @@ export function normalizeState(value) {
|
|
|
360
364
|
sameIds(note.characterIds, characterIds) &&
|
|
361
365
|
note.plants === plants &&
|
|
362
366
|
note.payoffBoardId === payoffBoardId &&
|
|
367
|
+
note.payoffNoteId === payoffNoteId &&
|
|
363
368
|
note.location === location &&
|
|
364
369
|
note.when === when &&
|
|
365
370
|
note.text === text
|
|
@@ -367,7 +372,7 @@ export function normalizeState(value) {
|
|
|
367
372
|
return note;
|
|
368
373
|
}
|
|
369
374
|
patched = true;
|
|
370
|
-
return { ...note, rank, lengthEighths, characterIds, plants, payoffBoardId, location, when, text };
|
|
375
|
+
return { ...note, rank, lengthEighths, characterIds, plants, payoffBoardId, payoffNoteId, location, when, text };
|
|
371
376
|
});
|
|
372
377
|
|
|
373
378
|
// Boards written before the production half (Roadmap 2, item 8) have no
|
|
@@ -453,6 +458,7 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
453
458
|
characterIds: knownCast(command.characterIds, state.characters ?? []),
|
|
454
459
|
plants: command.plants === true,
|
|
455
460
|
payoffBoardId: null,
|
|
461
|
+
payoffNoteId: null,
|
|
456
462
|
location: cleanPlace(command.location),
|
|
457
463
|
when: cleanWhen(command.when),
|
|
458
464
|
text: typeof command.text === "string" ? command.text : "",
|
|
@@ -620,6 +626,11 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
620
626
|
result: {
|
|
621
627
|
id: command.id,
|
|
622
628
|
headline: gone.headline,
|
|
629
|
+
// The fold and where it paid off go with the card too; a door that
|
|
630
|
+
// says what went should say these (round fifteen, entry 17).
|
|
631
|
+
plants: gone.plants === true,
|
|
632
|
+
payoffBoardId: gone.payoffBoardId ?? null,
|
|
633
|
+
payoffNoteId: gone.payoffNoteId ?? null,
|
|
623
634
|
arrows: taken.map((arrow) => ({ ...arrow, fromHeadline: headlineOf(arrow.from), toHeadline: headlineOf(arrow.to) })),
|
|
624
635
|
joined: joined ? { ...joined, fromHeadline: headlineOf(joined.from), toHeadline: headlineOf(joined.to) } : null,
|
|
625
636
|
groups: left,
|
|
@@ -905,6 +916,7 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
905
916
|
characterIds: [],
|
|
906
917
|
plants: false,
|
|
907
918
|
payoffBoardId: null,
|
|
919
|
+
payoffNoteId: null,
|
|
908
920
|
location: "",
|
|
909
921
|
when: "",
|
|
910
922
|
text: "",
|
|
@@ -1036,8 +1048,9 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
1036
1048
|
if (!ids.has(note.id)) return note;
|
|
1037
1049
|
// Unfolding forgets where it paid off; a claim that no longer stands.
|
|
1038
1050
|
const payoffBoardId = plants ? note.payoffBoardId : null;
|
|
1039
|
-
|
|
1040
|
-
|
|
1051
|
+
const payoffNoteId = plants ? (note.payoffNoteId ?? null) : null;
|
|
1052
|
+
if (note.plants === plants && note.payoffBoardId === payoffBoardId && note.payoffNoteId === payoffNoteId) return note;
|
|
1053
|
+
const next = bump(note, { plants, payoffBoardId, payoffNoteId }, now);
|
|
1041
1054
|
touched.push(next);
|
|
1042
1055
|
return next;
|
|
1043
1056
|
});
|
|
@@ -1049,14 +1062,18 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
1049
1062
|
// stops asking where it comes back, and the reading says where. The
|
|
1050
1063
|
// kernel cannot check the board exists; the door that knows the project
|
|
1051
1064
|
// does. Null takes the claim back.
|
|
1065
|
+
// With noteId, the scene on that board that pays it off (R58): the
|
|
1066
|
+
// receiving end, kept here on the fold so one claim has one owner. A
|
|
1067
|
+
// board alone is a promise; a note is the payoff.
|
|
1052
1068
|
case "set_payoff_board": {
|
|
1053
1069
|
const ids = new Set(command.ids);
|
|
1054
1070
|
if (ids.size === 0) return { state, changed: false };
|
|
1055
1071
|
const payoffBoardId = typeof command.boardId === "string" && command.boardId ? command.boardId : null;
|
|
1072
|
+
const payoffNoteId = payoffBoardId && typeof command.noteId === "string" && command.noteId ? command.noteId : null;
|
|
1056
1073
|
const touched = [];
|
|
1057
1074
|
const notes = state.notes.map((note) => {
|
|
1058
|
-
if (!ids.has(note.id) || !note.plants || note.payoffBoardId === payoffBoardId) return note;
|
|
1059
|
-
const next = bump(note, { payoffBoardId }, now);
|
|
1075
|
+
if (!ids.has(note.id) || !note.plants || (note.payoffBoardId === payoffBoardId && (note.payoffNoteId ?? null) === payoffNoteId)) return note;
|
|
1076
|
+
const next = bump(note, { payoffBoardId, payoffNoteId }, now);
|
|
1060
1077
|
touched.push(next);
|
|
1061
1078
|
return next;
|
|
1062
1079
|
});
|