plotcoder-board 0.1.14 → 0.1.15
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 +196 -102
- package/src/board/markdown.d.ts +15 -0
- package/src/board/markdown.js +177 -0
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. Seventy-
|
|
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. Seventy-seven tools: reading (`list_board`, `read_wall`, `read_pages`, `read_character`, `page_count`, `list_words`, `list_workflows`, `segment_brief`, `compare_structure`); the card, cast, place, group and arrow verbs, `set_logline`, `set_target`, `set_rank`, `set_plant`, `write_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,9 +51,9 @@ 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.
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
just what produces it. Twelve rounds have been run, the first three through a
|
|
55
|
+
repo checkout and the rest through the account door, and every finding they
|
|
56
|
+
logged is fixed. [`blind-runs/`](blind-runs/) holds the rules that keep a round honest,
|
|
57
57
|
the table of rounds, and the next round's prompt with the test account filled in.
|
|
58
58
|
|
|
59
59
|
A round works a **test account** — a throwaway marked on its writer row, and the
|
|
@@ -96,4 +96,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
|
|
|
96
96
|
|
|
97
97
|
## Status
|
|
98
98
|
|
|
99
|
-
Version 0.1.
|
|
99
|
+
Version 0.1.14. 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
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
import { TEMPLATES } from "../src/board/templates.js";
|
|
42
42
|
import { wordSentence, wordsAsText } from "../src/board/words.js";
|
|
43
43
|
import { fromFountain, mergeFountain, toFountain } from "../src/board/fountain.js";
|
|
44
|
+
import { toMarkdown, toPlainText } from "../src/board/markdown.js";
|
|
44
45
|
import { fromProjectFile, toProjectFile } from "../src/board/projectFile.js";
|
|
45
46
|
import { describeSetAside, fromFdx, toFdx } from "../src/board/fdx.js";
|
|
46
47
|
import { paginate } from "../src/board/paginate.js";
|
|
@@ -698,7 +699,18 @@ async function writeBoard(next, rev, base, boardId = null, roster = "merge") {
|
|
|
698
699
|
const boards = {};
|
|
699
700
|
for (const [id, state] of Object.entries(held.boards)) boards[id] = withRoster(state, project);
|
|
700
701
|
boards[boardId ?? project.activeBoardId] = withRoster(toWrite, project);
|
|
702
|
+
// The order matters on the bridge: the board frame carries the exact roster
|
|
703
|
+
// and lands first, so the wall records one undo step holding the card, the
|
|
704
|
+
// cast and the person together (R33, R51); the project frame that follows
|
|
705
|
+
// then changes nothing. The account door keeps the project first, because
|
|
706
|
+
// its live path composes a board's cast against the project row it holds.
|
|
707
|
+
if (base === ACCOUNT) {
|
|
708
|
+
await writeProject(project, boards, held.rev, held.base);
|
|
709
|
+
return writeBoardRaw(toWrite, rev, base, boardId);
|
|
710
|
+
}
|
|
711
|
+
const live = await writeBoardRaw(toWrite, rev, base, boardId);
|
|
701
712
|
await writeProject(project, boards, held.rev, held.base);
|
|
713
|
+
return live;
|
|
702
714
|
}
|
|
703
715
|
}
|
|
704
716
|
return writeBoardRaw(toWrite, rev, base, boardId);
|
|
@@ -792,6 +804,35 @@ async function commit(command) {
|
|
|
792
804
|
return { state: next, changed, result, live };
|
|
793
805
|
}
|
|
794
806
|
|
|
807
|
+
/**
|
|
808
|
+
* Several kernel commands as one change. Read once; `build(step, current)` applies
|
|
809
|
+
* each command through the kernel against the running state; write once. One tool
|
|
810
|
+
* call is then one frame on the bridge — one ⌘Z on the wall — and one entry on this
|
|
811
|
+
* server's own trail, however many commands it took: create_note with its cast,
|
|
812
|
+
* set_plant with later, move_scene, an import. Before this, ⌘Z on the wall took the
|
|
813
|
+
* cast off an agent's new card and left the card (R33).
|
|
814
|
+
*/
|
|
815
|
+
async function commitAll(what, build) {
|
|
816
|
+
const { state, rev, base, boardId } = await readBoard();
|
|
817
|
+
let current = state;
|
|
818
|
+
let changed = false;
|
|
819
|
+
const step = (command) => {
|
|
820
|
+
const out = applyCommand(current, command);
|
|
821
|
+
if (out.changed) {
|
|
822
|
+
current = out.state;
|
|
823
|
+
changed = true;
|
|
824
|
+
}
|
|
825
|
+
return out;
|
|
826
|
+
};
|
|
827
|
+
const value = await build(step, () => current);
|
|
828
|
+
if (!changed) return { state: current, changed: false, value, live: base !== null };
|
|
829
|
+
const live = await writeBoard(current, rev, base, boardId, "exact");
|
|
830
|
+
trail.push({ before: state, after: canon(current), what });
|
|
831
|
+
if (trail.length > TRAIL_CAP) trail.shift();
|
|
832
|
+
undone.length = 0;
|
|
833
|
+
return { state: current, changed: true, value, live };
|
|
834
|
+
}
|
|
835
|
+
|
|
795
836
|
/** Said once per session: that cards stack until organize (round seven, finding 11). */
|
|
796
837
|
/** Where a new card lands when the agent gives no position: after the last card in reading order, wrapping five wide, so cards never stack (round eleven, finding 14). */
|
|
797
838
|
function nextPlace(state) {
|
|
@@ -1106,45 +1147,47 @@ server.registerTool(
|
|
|
1106
1147
|
},
|
|
1107
1148
|
async (args) => {
|
|
1108
1149
|
const landing = args.x === undefined && args.y === undefined ? nextPlace((await readBoard()).state) : { x: args.x, y: args.y };
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
const
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
person
|
|
1136
|
-
|
|
1150
|
+
const names = (args.characters ?? []).map((name) => name.trim()).filter(Boolean);
|
|
1151
|
+
const added = [];
|
|
1152
|
+
// The card, anyone new in its cast, and the casting land as one change, so
|
|
1153
|
+
// one ⌘Z on the wall takes back the whole call and not just the cast.
|
|
1154
|
+
const { value: result, live } = await commitAll(`create_note "${args.headline}"`, (step, current) => {
|
|
1155
|
+
let made = step({
|
|
1156
|
+
type: "create_note",
|
|
1157
|
+
headline: args.headline,
|
|
1158
|
+
change: args.change,
|
|
1159
|
+
// One colour unless the agent chooses: a wall an agent builds in one go
|
|
1160
|
+
// would otherwise stripe through the cycle, and a writer reads a pattern
|
|
1161
|
+
// into it (round four, finding 17). The wall's own new-card button keeps
|
|
1162
|
+
// cycling for a person adding cards by hand.
|
|
1163
|
+
color: args.color ?? "yellow",
|
|
1164
|
+
rank: args.rank,
|
|
1165
|
+
lengthEighths: args.pages === undefined ? undefined : toEighths(args.pages),
|
|
1166
|
+
plants: args.plants,
|
|
1167
|
+
location: args.location,
|
|
1168
|
+
x: landing.x,
|
|
1169
|
+
y: landing.y,
|
|
1170
|
+
}).result;
|
|
1171
|
+
if (names.length && made?.id) {
|
|
1172
|
+
const ids = [];
|
|
1173
|
+
for (const name of names) {
|
|
1174
|
+
const wanted = name.toLowerCase();
|
|
1175
|
+
let person = current().characters.find((item) => item.id === name) ?? current().characters.find((item) => item.name.trim().toLowerCase() === wanted);
|
|
1176
|
+
if (!person) {
|
|
1177
|
+
person = step({ type: "add_character", name }).result;
|
|
1178
|
+
if (person) added.push(`${person.name} (${person.id})`);
|
|
1179
|
+
}
|
|
1180
|
+
if (person && !ids.includes(person.id)) ids.push(person.id);
|
|
1181
|
+
}
|
|
1182
|
+
if (ids.length) {
|
|
1183
|
+
const cast = step({ type: "set_cast", ids: [made.id], characterIds: ids });
|
|
1184
|
+
// The card as it is now, cast and all, so the reply's JSON agrees with its prose.
|
|
1185
|
+
made = cast.state.notes.find((note) => note.id === made.id) ?? made;
|
|
1137
1186
|
}
|
|
1138
|
-
if (person) ids.push(person.id);
|
|
1139
|
-
}
|
|
1140
|
-
if (ids.length) {
|
|
1141
|
-
const cast = await commit({ type: "set_cast", ids: [result.id], characterIds: ids });
|
|
1142
|
-
// The card as it is now, cast and all, so the reply's JSON agrees with its prose.
|
|
1143
|
-
const after = cast.state.notes.find((note) => note.id === result.id);
|
|
1144
|
-
if (after) result = after;
|
|
1145
1187
|
}
|
|
1146
|
-
|
|
1147
|
-
}
|
|
1188
|
+
return made;
|
|
1189
|
+
});
|
|
1190
|
+
const castLine = names.length && result?.id ? ` Cast: ${names.join(", ")}${added.length ? ` (added to the roster: ${added.join(", ")})` : ""}.` : "";
|
|
1148
1191
|
const landed = [
|
|
1149
1192
|
result?.rank === "beat" ? "a beat" : "a scene",
|
|
1150
1193
|
result?.lengthEighths === null ? "about a page (unsized: the writer's guess until set_length)" : `${formatPages(noteEighths(result))} ${formatPages(noteEighths(result)) === "1" ? "page" : "pages"}`,
|
|
@@ -1392,46 +1435,41 @@ server.registerTool(
|
|
|
1392
1435
|
if (!state.arrows.some(isFollows)) {
|
|
1393
1436
|
return ok("The wall has no follows arrows, so there is no story order to move within: create_arrow the sequence first, or move_note the card by position.");
|
|
1394
1437
|
}
|
|
1395
|
-
const trailBefore = trail.length;
|
|
1396
1438
|
let removed = 0;
|
|
1397
1439
|
let drawn = 0;
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
if (
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1440
|
+
// The whole move — its dozen arrows and the tidy — as one change: one frame on
|
|
1441
|
+
// the bridge, one ⌘Z on the wall, one step for undo here.
|
|
1442
|
+
const { state: final, live } = await commitAll(`move_scene "${card.headline}"`, (step, current) => {
|
|
1443
|
+
const run = (command) => {
|
|
1444
|
+
const done = step(command);
|
|
1445
|
+
if (done.changed) {
|
|
1446
|
+
if (command.type === "delete_arrow") removed += 1;
|
|
1447
|
+
if (command.type === "create_arrow") drawn += 1;
|
|
1448
|
+
}
|
|
1449
|
+
return done;
|
|
1450
|
+
};
|
|
1451
|
+
// Leave: what pointed at the card points at what the card pointed at.
|
|
1452
|
+
const ins = state.arrows.filter((arrow) => isFollows(arrow) && arrow.to === card.id);
|
|
1453
|
+
const outs = state.arrows.filter((arrow) => isFollows(arrow) && arrow.from === card.id);
|
|
1454
|
+
for (const arrow of [...ins, ...outs]) run({ type: "delete_arrow", id: arrow.id });
|
|
1455
|
+
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" });
|
|
1456
|
+
// Land: between the target and what followed it (or what led to it).
|
|
1457
|
+
const mid = current();
|
|
1458
|
+
if (args.after) {
|
|
1459
|
+
for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.from === target.id && item.to !== card.id)) {
|
|
1460
|
+
run({ type: "delete_arrow", id: arrow.id });
|
|
1461
|
+
run({ type: "create_arrow", from: card.id, to: arrow.to, kind: "follows" });
|
|
1462
|
+
}
|
|
1463
|
+
run({ type: "create_arrow", from: target.id, to: card.id, kind: "follows" });
|
|
1464
|
+
} else {
|
|
1465
|
+
for (const arrow of mid.arrows.filter((item) => isFollows(item) && item.to === target.id && item.from !== card.id)) {
|
|
1466
|
+
run({ type: "delete_arrow", id: arrow.id });
|
|
1467
|
+
run({ type: "create_arrow", from: arrow.from, to: card.id, kind: "follows" });
|
|
1468
|
+
}
|
|
1469
|
+
run({ type: "create_arrow", from: card.id, to: target.id, kind: "follows" });
|
|
1425
1470
|
}
|
|
1426
|
-
|
|
1427
|
-
}
|
|
1428
|
-
const { state: linked } = await readBoard();
|
|
1429
|
-
const tidied = await step({ type: "apply_poses", poses: organizePoses(linked, {}) });
|
|
1430
|
-
const final = tidied.state;
|
|
1431
|
-
// One step for undo: the whole move, not its dozen arrows.
|
|
1432
|
-
trail.splice(trailBefore);
|
|
1433
|
-
trail.push({ before: state, after: canon(final), what: `move_scene "${card.headline}"` });
|
|
1434
|
-
undone.length = 0;
|
|
1471
|
+
run({ type: "apply_poses", poses: organizePoses(current(), {}) });
|
|
1472
|
+
});
|
|
1435
1473
|
const order = readingOrder(final.notes);
|
|
1436
1474
|
const group = final.groups.find((item) => item.noteIds.includes(card.id));
|
|
1437
1475
|
const groupLine = group ? ` It is still in "${group.title || "an untitled group"}"; a frame does not follow a move, so say if the act or sequence should change.` : "";
|
|
@@ -1627,6 +1665,57 @@ server.registerTool(
|
|
|
1627
1665
|
},
|
|
1628
1666
|
);
|
|
1629
1667
|
|
|
1668
|
+
server.registerTool(
|
|
1669
|
+
"export_markdown",
|
|
1670
|
+
{
|
|
1671
|
+
title: "Export the wall as Markdown",
|
|
1672
|
+
description:
|
|
1673
|
+
"The open board as Markdown, for a collaborator who lives in Google Docs or the like: the board as the title (the project's name before it 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. Pass a path to write a .md file; otherwise the text comes back.",
|
|
1674
|
+
inputSchema: { path: z.string().optional() },
|
|
1675
|
+
},
|
|
1676
|
+
async (args) => {
|
|
1677
|
+
const { state } = await readBoard();
|
|
1678
|
+
const { project } = await readProject();
|
|
1679
|
+
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
1680
|
+
const text = toMarkdown(state, {
|
|
1681
|
+
title: board?.name,
|
|
1682
|
+
project: project.boards.length > 1 && project.name !== "Untitled project" ? project.name : undefined,
|
|
1683
|
+
premise: project.premise || undefined,
|
|
1684
|
+
});
|
|
1685
|
+
if (args.path) {
|
|
1686
|
+
fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
|
|
1687
|
+
fs.writeFileSync(args.path, text);
|
|
1688
|
+
return ok(`Wrote ${text.split("\n").length} lines of Markdown to ${path.resolve(args.path)}.`);
|
|
1689
|
+
}
|
|
1690
|
+
return ok(text);
|
|
1691
|
+
},
|
|
1692
|
+
);
|
|
1693
|
+
|
|
1694
|
+
server.registerTool(
|
|
1695
|
+
"export_text",
|
|
1696
|
+
{
|
|
1697
|
+
title: "Export the script as plain text",
|
|
1698
|
+
description:
|
|
1699
|
+
"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. 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.",
|
|
1700
|
+
inputSchema: { path: z.string().optional() },
|
|
1701
|
+
},
|
|
1702
|
+
async (args) => {
|
|
1703
|
+
const { state } = await readBoard();
|
|
1704
|
+
const { project } = await readProject();
|
|
1705
|
+
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
1706
|
+
const text = toPlainText(state, {
|
|
1707
|
+
title: board?.name,
|
|
1708
|
+
project: project.boards.length > 1 && project.name !== "Untitled project" ? project.name : undefined,
|
|
1709
|
+
});
|
|
1710
|
+
if (args.path) {
|
|
1711
|
+
fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
|
|
1712
|
+
fs.writeFileSync(args.path, text);
|
|
1713
|
+
return ok(`Wrote ${text.split("\n").length} lines of plain text to ${path.resolve(args.path)}.`);
|
|
1714
|
+
}
|
|
1715
|
+
return ok(text);
|
|
1716
|
+
},
|
|
1717
|
+
);
|
|
1718
|
+
|
|
1630
1719
|
server.registerTool(
|
|
1631
1720
|
"write_scene",
|
|
1632
1721
|
{
|
|
@@ -1696,8 +1785,10 @@ server.registerTool(
|
|
|
1696
1785
|
const { state } = await readBoard();
|
|
1697
1786
|
const parsed = fromFountain(source);
|
|
1698
1787
|
const { commands, matched } = mergeFountain(state, parsed);
|
|
1699
|
-
|
|
1700
|
-
|
|
1788
|
+
// The whole import as one change, so one undo takes every scene back.
|
|
1789
|
+
const { live } = await commitAll(`import_fountain (${parsed.scenes.length} scene(s))`, (step) => {
|
|
1790
|
+
for (const command of commands) step(command);
|
|
1791
|
+
});
|
|
1701
1792
|
const written = commands.filter((command) => command.type === "set_text").length;
|
|
1702
1793
|
const created = matched.filter((item) => item.created).length;
|
|
1703
1794
|
return ok(
|
|
@@ -1793,8 +1884,10 @@ server.registerTool(
|
|
|
1793
1884
|
const { state } = await readBoard();
|
|
1794
1885
|
const parsed = fromFdx(source);
|
|
1795
1886
|
const { commands, matched } = mergeFountain(state, parsed);
|
|
1796
|
-
|
|
1797
|
-
|
|
1887
|
+
// The whole import as one change, so one undo takes every scene back.
|
|
1888
|
+
const { live } = await commitAll(`import_fdx (${parsed.scenes.length} scene(s))`, (step) => {
|
|
1889
|
+
for (const command of commands) step(command);
|
|
1890
|
+
});
|
|
1798
1891
|
const written = commands.filter((command) => command.type === "set_text").length;
|
|
1799
1892
|
const created = matched.filter((item) => item.created).length;
|
|
1800
1893
|
const receipt = describeSetAside(parsed.setAside);
|
|
@@ -2107,39 +2200,40 @@ server.registerTool(
|
|
|
2107
2200
|
},
|
|
2108
2201
|
},
|
|
2109
2202
|
async (args) => {
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
});
|
|
2115
|
-
let laterLine = "";
|
|
2203
|
+
// A series plant (R50): the fold pays off on another board of the project.
|
|
2204
|
+
// The kernel cannot check the board exists; this door can, before anything lands.
|
|
2205
|
+
let target = null;
|
|
2206
|
+
let forgetting = false;
|
|
2116
2207
|
if (args.plants && args.later !== undefined) {
|
|
2117
|
-
// A series plant (R50): the fold pays off on another board of the project.
|
|
2118
|
-
// The kernel cannot check the board exists; this door can.
|
|
2119
2208
|
const { project } = await readProject();
|
|
2120
|
-
const {
|
|
2121
|
-
const here = now.notes.filter((note) => args.ids.includes(note.id));
|
|
2209
|
+
const { boardId: current } = await readBoard();
|
|
2122
2210
|
if (args.later.trim() === "") {
|
|
2123
|
-
|
|
2124
|
-
if (cleared.changed) {
|
|
2125
|
-
result = cleared.result;
|
|
2126
|
-
live = cleared.live;
|
|
2127
|
-
changed = true;
|
|
2128
|
-
laterLine = " The board it paid off on is forgotten; read_wall asks again until a setup arrow or a board pays it off.";
|
|
2129
|
-
}
|
|
2211
|
+
forgetting = true;
|
|
2130
2212
|
} else {
|
|
2131
|
-
|
|
2213
|
+
target = findBoard(project, args.later);
|
|
2132
2214
|
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.`);
|
|
2133
2215
|
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'.`);
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
// The fold and the board it pays off on land as one change: one ⌘Z on the wall.
|
|
2219
|
+
const { value, live, changed } = await commitAll("set_plant", (step, current) => {
|
|
2220
|
+
let { result } = step({ type: "set_plant", ids: args.ids, plants: args.plants });
|
|
2221
|
+
let laterLine = "";
|
|
2222
|
+
if (forgetting) {
|
|
2223
|
+
const cleared = step({ type: "set_payoff_board", ids: args.ids, boardId: null });
|
|
2224
|
+
if (cleared.changed) {
|
|
2225
|
+
result = cleared.result;
|
|
2226
|
+
laterLine = " The board it paid off on is forgotten; read_wall asks again until a setup arrow or a board pays it off.";
|
|
2139
2227
|
}
|
|
2228
|
+
} else if (target) {
|
|
2229
|
+
const named = step({ type: "set_payoff_board", ids: args.ids, boardId: target.id });
|
|
2230
|
+
if (named.changed) result = named.result;
|
|
2231
|
+
const here = current().notes.filter((note) => args.ids.includes(note.id));
|
|
2140
2232
|
laterLine = ` ${here.length} card(s) pay off later, on "${target.name}": read_wall stops asking where they come back, and the card says so.`;
|
|
2141
2233
|
}
|
|
2142
|
-
|
|
2234
|
+
return { result, laterLine };
|
|
2235
|
+
});
|
|
2236
|
+
const { result, laterLine } = value;
|
|
2143
2237
|
const count = result?.length ?? 0;
|
|
2144
2238
|
if (!changed || count === 0) return ok("No change: those cards were already that way, or the ids are not on the board.");
|
|
2145
2239
|
return ok(
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Line } from "./paginate";
|
|
2
|
+
import type { BoardState } from "./reducer";
|
|
3
|
+
|
|
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; project?: string; premise?: string };
|
|
6
|
+
|
|
7
|
+
/** The wall as Markdown (R54): title, premise, logline, beats as headings, a heading per scene, the text or the change line. */
|
|
8
|
+
export declare function toMarkdown(state: BoardState, options?: TakeOptions): string;
|
|
9
|
+
|
|
10
|
+
/** The script as plain text, set as it prints: Courier's columns kept with spaces, scene numbers in the margins. */
|
|
11
|
+
export declare function toPlainText(state: BoardState, options?: TakeOptions): string;
|
|
12
|
+
|
|
13
|
+
export declare const GUTTER: number;
|
|
14
|
+
export declare const COLUMN: Record<"character" | "more" | "parenthetical" | "dialogue", number>;
|
|
15
|
+
export declare function setLine(line: Line): string;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Take the pages with you (R54): the wall as Markdown, and the script as
|
|
2
|
+
// plain text — for a collaborator who lives in Google Docs rather than in a
|
|
3
|
+
// screenwriting app. Both are pure and shared by every door: the Pages
|
|
4
|
+
// panel's sheet, the console, and the MCP server's export_markdown and
|
|
5
|
+
// export_text.
|
|
6
|
+
//
|
|
7
|
+
// Markdown is the wall read out, in wall order: the board as the title, the
|
|
8
|
+
// premise and the logline under it, beats as second-level headings, one
|
|
9
|
+
// third-level heading per scene from its place (with its scene number, locked
|
|
10
|
+
// or by wall order), the headline as a synopsis line, then the scene's text —
|
|
11
|
+
// set by what each element is, from the same rules the paginator reads by —
|
|
12
|
+
// or, unwritten, its change line. Nothing invisible: Markdown has no notes
|
|
13
|
+
// that print as nothing, so the cast and the fold stay on the wall.
|
|
14
|
+
//
|
|
15
|
+
// Plain text is the script as it prints: the paginator's lines, Courier's
|
|
16
|
+
// columns kept with spaces, scene numbers in both margins, no page numbers.
|
|
17
|
+
// It pastes into anything and reads as a script wherever the font is
|
|
18
|
+
// monospaced.
|
|
19
|
+
|
|
20
|
+
import { readingOrder } from "./readWall.js";
|
|
21
|
+
import { sceneHeading } from "./fountain.js";
|
|
22
|
+
import { paginate, parseScene, WIDTH } from "./paginate.js";
|
|
23
|
+
import { sceneNumbers } from "./numbering.js";
|
|
24
|
+
|
|
25
|
+
function upper(text) {
|
|
26
|
+
return text.trim().replace(/\s+/g, " ").toUpperCase();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function documentTitle(options) {
|
|
30
|
+
const title = options.title || "Untitled";
|
|
31
|
+
return options.project && options.project !== title ? `${options.project} · ${title}` : title;
|
|
32
|
+
}
|
|
33
|
+
|
|
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. */
|
|
35
|
+
function sceneMarkdown(text) {
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const element of parseScene(text)) {
|
|
38
|
+
switch (element.kind) {
|
|
39
|
+
case "break":
|
|
40
|
+
out.push("---", "");
|
|
41
|
+
break;
|
|
42
|
+
case "speech": {
|
|
43
|
+
const lines = [`**${element.name}**`];
|
|
44
|
+
for (const part of element.parts) lines.push(part.kind === "parenthetical" ? `*${part.text}*` : part.text);
|
|
45
|
+
// Two trailing spaces: Markdown's line break, so the cue and its lines stay on their own lines.
|
|
46
|
+
out.push(lines.join(" \n"), "");
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
case "centered":
|
|
50
|
+
case "transition":
|
|
51
|
+
case "action":
|
|
52
|
+
default:
|
|
53
|
+
out.push(element.text, "");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The whole wall as Markdown.
|
|
61
|
+
*
|
|
62
|
+
* @param state the board
|
|
63
|
+
* @param options.title the board's name; options.project the project's name
|
|
64
|
+
* when the project has several boards; options.premise
|
|
65
|
+
*/
|
|
66
|
+
export function toMarkdown(state, options = {}) {
|
|
67
|
+
const order = readingOrder(state.notes);
|
|
68
|
+
const numbers = sceneNumbers(order, state.lock);
|
|
69
|
+
const out = [`# ${documentTitle(options)}`, ""];
|
|
70
|
+
if (options.premise) out.push(`*${options.premise}*`, "");
|
|
71
|
+
if (state.logline) out.push(`**${state.logline}**`, "");
|
|
72
|
+
let beat = 0;
|
|
73
|
+
for (const note of order) {
|
|
74
|
+
if (note.rank === "beat") {
|
|
75
|
+
beat += 1;
|
|
76
|
+
out.push(`## ${beat}. ${note.headline || "Untitled beat"}`, "");
|
|
77
|
+
}
|
|
78
|
+
const heading = sceneHeading(note).slice(1);
|
|
79
|
+
out.push(`### ${numbers.get(note.id) ?? ""} · ${heading}`.replace(/^### · /, "### "), "");
|
|
80
|
+
if (note.headline && upper(note.headline) !== heading) out.push(`*${note.headline.trim()}*`, "");
|
|
81
|
+
if (note.text && note.text.trim()) out.push(...sceneMarkdown(note.text));
|
|
82
|
+
else if (note.change && note.change.trim()) out.push(note.change.trim(), "");
|
|
83
|
+
}
|
|
84
|
+
return `${out.join("\n").trimEnd()}\n`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The column the script's body starts at: room for a scene number on the left. */
|
|
88
|
+
export const GUTTER = 5;
|
|
89
|
+
|
|
90
|
+
/** Where each kind of line starts, in characters from the body's left edge — the print stylesheet's columns. */
|
|
91
|
+
export const COLUMN = { character: 22, more: 22, parenthetical: 16, dialogue: 10 };
|
|
92
|
+
|
|
93
|
+
const DUAL_RIGHT = 32;
|
|
94
|
+
const DUAL_CUE = 8;
|
|
95
|
+
|
|
96
|
+
function pad(n) {
|
|
97
|
+
return " ".repeat(Math.max(0, n));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function centred(text, width = WIDTH.action) {
|
|
101
|
+
return pad(GUTTER + Math.floor((width - text.length) / 2)) + text;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One line of a dual-dialogue column, from the column's own left edge. */
|
|
105
|
+
function columnLine(line) {
|
|
106
|
+
if (!line || line.kind === "blank") return "";
|
|
107
|
+
const text = line.text ?? "";
|
|
108
|
+
if (line.kind === "character") return pad(DUAL_CUE) + text;
|
|
109
|
+
if (line.kind === "parenthetical") return pad(4) + text;
|
|
110
|
+
return text;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A printed line set with spaces. Exported for the test; the file is `toPlainText`. */
|
|
114
|
+
export function setLine(line) {
|
|
115
|
+
const text = line.text ?? "";
|
|
116
|
+
switch (line.kind) {
|
|
117
|
+
case "blank":
|
|
118
|
+
return "";
|
|
119
|
+
case "heading": {
|
|
120
|
+
const number = line.sceneNumber === null || line.sceneNumber === undefined ? "" : String(line.sceneNumber);
|
|
121
|
+
if (!number) return pad(GUTTER) + text;
|
|
122
|
+
return `${number.padEnd(GUTTER)}${text.padEnd(WIDTH.action)} ${number}`.trimEnd();
|
|
123
|
+
}
|
|
124
|
+
case "character":
|
|
125
|
+
case "more":
|
|
126
|
+
case "parenthetical":
|
|
127
|
+
case "dialogue":
|
|
128
|
+
return pad(GUTTER + COLUMN[line.kind]) + text;
|
|
129
|
+
case "transition":
|
|
130
|
+
return pad(GUTTER + WIDTH.action - text.length) + text;
|
|
131
|
+
case "centered":
|
|
132
|
+
return centred(text);
|
|
133
|
+
case "dual": {
|
|
134
|
+
const left = pad(GUTTER) + columnLine(line.left);
|
|
135
|
+
const right = columnLine(line.right);
|
|
136
|
+
return right ? `${left.padEnd(GUTTER + DUAL_RIGHT)}${right}` : left.trimEnd();
|
|
137
|
+
}
|
|
138
|
+
case "action":
|
|
139
|
+
default:
|
|
140
|
+
return pad(GUTTER) + text;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The script as plain text, set as it prints.
|
|
146
|
+
*
|
|
147
|
+
* @param state the board
|
|
148
|
+
* @param options.title the board's name; options.project the project's name
|
|
149
|
+
* when the project has several boards
|
|
150
|
+
*/
|
|
151
|
+
export function toPlainText(state, options = {}) {
|
|
152
|
+
const order = readingOrder(state.notes);
|
|
153
|
+
const numbers = sceneNumbers(order, state.lock);
|
|
154
|
+
const result = paginate(
|
|
155
|
+
order.map((note) => ({
|
|
156
|
+
id: note.id,
|
|
157
|
+
heading: sceneHeading(note).slice(1),
|
|
158
|
+
text: note.text,
|
|
159
|
+
change: note.change,
|
|
160
|
+
written: Boolean(note.text && note.text.trim()),
|
|
161
|
+
number: numbers.get(note.id) ?? undefined,
|
|
162
|
+
})),
|
|
163
|
+
);
|
|
164
|
+
const title = options.title || "Untitled";
|
|
165
|
+
const out = [];
|
|
166
|
+
if (options.project && options.project !== title) {
|
|
167
|
+
out.push(centred(upper(options.project)), "", centred(title));
|
|
168
|
+
} else {
|
|
169
|
+
out.push(centred(upper(title)));
|
|
170
|
+
}
|
|
171
|
+
out.push("", "");
|
|
172
|
+
for (const page of result.pages) {
|
|
173
|
+
if (page.number > 1) out.push("");
|
|
174
|
+
for (const line of page.lines) out.push(setLine(line));
|
|
175
|
+
}
|
|
176
|
+
return `${out.join("\n").replace(/\n{4,}/g, "\n\n\n").trimEnd()}\n`;
|
|
177
|
+
}
|