plotcoder-board 0.1.15 → 0.1.16
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 +96 -37
- package/src/board/fdx.js +3 -2
- package/src/board/fountain.d.ts +4 -0
- package/src/board/fountain.js +33 -7
- package/src/board/markdown.js +5 -3
- package/src/board/project.d.ts +2 -0
- package/src/board/project.js +15 -0
- package/src/board/reducer.d.ts +2 -1
- package/src/board/reducer.js +54 -16
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-eight 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 (with `add_to_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
|
-
repo checkout and the rest through the account door
|
|
56
|
-
|
|
54
|
+
just what produces it. Thirteen rounds have been run, the first three through a
|
|
55
|
+
repo checkout and the rest through the account door; every finding from the
|
|
56
|
+
first twelve is fixed, and round thirteen's six chosen first are too. [`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.15. 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
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
liftCast,
|
|
76
76
|
mergeRoster,
|
|
77
77
|
sameRoster,
|
|
78
|
+
scriptTitles,
|
|
78
79
|
withRoster,
|
|
79
80
|
} from "../src/board/project.js";
|
|
80
81
|
|
|
@@ -758,6 +759,9 @@ const TRAIL_CAP = 50;
|
|
|
758
759
|
const trail = [];
|
|
759
760
|
/** What undo took back, newest last; a new change of this server's clears it. */
|
|
760
761
|
const undone = [];
|
|
762
|
+
/** The last read_wall's questions, and what changed since: a leave answers the reading in front of the agent (round thirteen, entry 19). */
|
|
763
|
+
let lastReading = null;
|
|
764
|
+
const sinceRead = [];
|
|
761
765
|
|
|
762
766
|
function describeCommand(command) {
|
|
763
767
|
switch (command.type) {
|
|
@@ -799,6 +803,7 @@ async function commit(command) {
|
|
|
799
803
|
|
|
800
804
|
const live = await writeBoard(next, rev, base, boardId, "exact");
|
|
801
805
|
trail.push({ before: state, after: canon(next), what: describeCommand(command) });
|
|
806
|
+
sinceRead.push(describeCommand(command));
|
|
802
807
|
if (trail.length > TRAIL_CAP) trail.shift();
|
|
803
808
|
undone.length = 0;
|
|
804
809
|
return { state: next, changed, result, live };
|
|
@@ -828,6 +833,7 @@ async function commitAll(what, build) {
|
|
|
828
833
|
if (!changed) return { state: current, changed: false, value, live: base !== null };
|
|
829
834
|
const live = await writeBoard(current, rev, base, boardId, "exact");
|
|
830
835
|
trail.push({ before: state, after: canon(current), what });
|
|
836
|
+
sinceRead.push(what);
|
|
831
837
|
if (trail.length > TRAIL_CAP) trail.shift();
|
|
832
838
|
undone.length = 0;
|
|
833
839
|
return { state: current, changed: true, value, live };
|
|
@@ -1086,22 +1092,37 @@ server.registerTool(
|
|
|
1086
1092
|
{
|
|
1087
1093
|
title: "Set card length",
|
|
1088
1094
|
description:
|
|
1089
|
-
"Set how long cards run, in pages. An ordinary scene is about 1; a quick beat might be 0.25; a set piece might be 3 or 4. This is an estimate the writer owns — set it when you are told a length or when the card plainly describes one, and do not silently re-estimate a card someone has already sized.",
|
|
1095
|
+
"Set how long cards run, in pages. An ordinary scene is about 1; a quick beat might be 0.25; a set piece might be 3 or 4. This is an estimate the writer owns — set it when you are told a length or when the card plainly describes one, and do not silently re-estimate a card someone has already sized. Pass pages \"unsized\" (or 0) to take a length away: the card claims nothing again and reads as about a page, the way a new card does.",
|
|
1090
1096
|
inputSchema: {
|
|
1091
1097
|
ids: z.array(z.string()).min(1),
|
|
1092
|
-
pages:
|
|
1098
|
+
pages: z
|
|
1099
|
+
.union([pagesSchema, z.literal(0), z.literal("unsized"), z.null()])
|
|
1100
|
+
.describe('Pages; a fraction is fine. "unsized" (or 0) takes the length away.'),
|
|
1093
1101
|
},
|
|
1094
1102
|
},
|
|
1095
1103
|
async (args) => {
|
|
1096
|
-
const
|
|
1104
|
+
const unsizing = args.pages === "unsized" || args.pages === 0 || args.pages === null;
|
|
1105
|
+
const { state, changed, result, live } = await commit({
|
|
1097
1106
|
type: "set_length",
|
|
1098
1107
|
ids: args.ids,
|
|
1099
|
-
lengthEighths: toEighths(args.pages),
|
|
1108
|
+
lengthEighths: unsizing ? null : toEighths(args.pages),
|
|
1100
1109
|
});
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1110
|
+
if (!changed) {
|
|
1111
|
+
const these = args.ids.length === 1 ? "the card is" : "those cards are";
|
|
1112
|
+
return ok(
|
|
1113
|
+
unsizing
|
|
1114
|
+
? `Nothing to unsize: ${these} unsized already, or not on the board (list_board for the ids).`
|
|
1115
|
+
: `Nothing changed: ${these} at ${args.pages} page(s) already, or not on the board (list_board for the ids).`,
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
const runtime = `The board runs about ${formatPages(boardEighths(state))} pages against a ${formatPages(state.targetEighths)}-page target.`;
|
|
1119
|
+
if (unsizing) {
|
|
1120
|
+
return ok(
|
|
1121
|
+
`${result.length} card(s) unsized${where(live)}: no length claimed, so each reads as about a page until someone sizes it, and list_board says "unsized". ${runtime}`,
|
|
1122
|
+
result,
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
return ok(`${result.length} card(s) now run ${args.pages} page(s), the writer's estimate${where(live)}. ${runtime}`, result);
|
|
1105
1126
|
},
|
|
1106
1127
|
);
|
|
1107
1128
|
|
|
@@ -1265,13 +1286,20 @@ server.registerTool(
|
|
|
1265
1286
|
{
|
|
1266
1287
|
title: "Delete note",
|
|
1267
1288
|
description:
|
|
1268
|
-
"Remove a card from the board.
|
|
1289
|
+
"Remove a card from the board. Its arrows go with it and it leaves its group; the reply names each arrow by its cards and says what the group kept, and undo brings all of it back.",
|
|
1269
1290
|
inputSchema: { id: z.string() },
|
|
1270
1291
|
},
|
|
1271
1292
|
async (args) => {
|
|
1272
|
-
const { result } = await commit({ type: "delete_note", id: args.id });
|
|
1293
|
+
const { result, live } = await commit({ type: "delete_note", id: args.id });
|
|
1273
1294
|
if (result === undefined) return ok(`No card with id ${args.id}.`);
|
|
1274
|
-
|
|
1295
|
+
// Say what went with the card (round thirteen, entry 17).
|
|
1296
|
+
const arrows = result.arrows.length
|
|
1297
|
+
? ` Took ${result.arrows.length === 1 ? "its arrow" : `its ${result.arrows.length} arrows`} with it: ${result.arrows.map((arrow) => `"${arrow.fromHeadline}" → "${arrow.toHeadline}" (${arrow.kind})`).join(", ")}.`
|
|
1298
|
+
: " No arrow touched it.";
|
|
1299
|
+
const groups = result.groups
|
|
1300
|
+
.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"}.`))
|
|
1301
|
+
.join("");
|
|
1302
|
+
return ok(`Deleted "${result.headline}"${where(live)}.${arrows}${groups}`, result);
|
|
1275
1303
|
},
|
|
1276
1304
|
);
|
|
1277
1305
|
|
|
@@ -1292,6 +1320,8 @@ server.registerTool(
|
|
|
1292
1320
|
const { boards: boardsForRead } = await readProject();
|
|
1293
1321
|
const elsewhereForRead = castElsewhere(projectForRead, boardsForRead, readBoardId ?? projectForRead.activeBoardId);
|
|
1294
1322
|
const reading = readWall(state, { elsewhere: Object.keys(elsewhereForRead) });
|
|
1323
|
+
lastReading = { findings: reading.findings };
|
|
1324
|
+
sinceRead.length = 0;
|
|
1295
1325
|
const runs = describeRuns(reading, state).map((line, index) => {
|
|
1296
1326
|
const ids = reading.runs[index]?.ids ?? [];
|
|
1297
1327
|
return ids.length ? `${line} — ${ids.map((id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`).join(", ")}` : line;
|
|
@@ -1369,7 +1399,7 @@ server.registerTool(
|
|
|
1369
1399
|
{
|
|
1370
1400
|
title: "Leave a question, for now",
|
|
1371
1401
|
description:
|
|
1372
|
-
"Write the writer's word on a question the wall asks — \"leave it\" — so the reading stops asking it. Pass the question's kind as read_wall names it (sag, empty, unpaid, …) and, when that kind is asked more than once, its ids as read_wall lists them. The wall keeps
|
|
1402
|
+
"Write the writer's word on a question the wall asks — \"leave it\" — so the reading stops asking it. Pass the question's kind as read_wall names it (sag, empty, unpaid, …) and, when that kind is asked more than once, its ids as read_wall lists them. A leave answers the reading in front of you: edits change the questions, so make the writer's changes first, read_wall, then leave what they still want left — a question that changed or went since the last reading is refused, with what it was. The wall keeps a left question and asks it again on its own the moment it would read differently — a card in it changes, a page moves, the median shifts — so a left question is never a dismissal; ask_again brings one back now. Only on the writer's word: never leave a question unasked.",
|
|
1373
1403
|
inputSchema: { kind: z.string().min(1), ids: z.array(z.string()).optional() },
|
|
1374
1404
|
},
|
|
1375
1405
|
async (args) => {
|
|
@@ -1379,7 +1409,21 @@ server.registerTool(
|
|
|
1379
1409
|
const matches = reading.findings.filter((finding) => finding.kind === args.kind && (!args.ids || sameList(finding.ids, args.ids)));
|
|
1380
1410
|
if (matches.length === 0) {
|
|
1381
1411
|
if (already.length) return ok(`Already left: [${args.kind}] ${already[0].text} It stays left until the question would read differently; ask_again brings it back.`);
|
|
1382
|
-
|
|
1412
|
+
// The reading the agent was answering, when the last read_wall had this question (round thirteen, entry 19).
|
|
1413
|
+
const earlier = lastReading?.findings.find((finding) => finding.kind === args.kind && (!args.ids || sameList(finding.ids, args.ids)));
|
|
1414
|
+
const now = reading.findings.filter((finding) => finding.kind === args.kind);
|
|
1415
|
+
if (earlier) {
|
|
1416
|
+
const since = sinceRead.length
|
|
1417
|
+
? `${sinceRead.length} change${sinceRead.length === 1 ? "" : "s"} landed since (${[...new Set(sinceRead)].join(", ")})`
|
|
1418
|
+
: "the wall changed elsewhere since";
|
|
1419
|
+
const state_ = now.length
|
|
1420
|
+
? `the wall now asks ${now.length === 1 ? "it differently" : `${now.length} questions of that kind`}: ${now.map((finding) => `${finding.text} (ids: ${finding.ids.join(", ")})`).join("; ")}`
|
|
1421
|
+
: "the wall no longer asks it — the cards answered it";
|
|
1422
|
+
return ok(
|
|
1423
|
+
`Not left. When you last read the wall it asked [${earlier.kind}] ${earlier.text}${earlier.ids.length ? ` (ids: ${earlier.ids.join(", ")})` : ""}; ${since}, and ${state_}. A leave answers the reading in front of you: make the writer's edits first, read_wall, then leave what they still want left.`,
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1426
|
+
return ok(`The wall is not asking a question of kind "${args.kind}"${args.ids ? ` about ids ${args.ids.join(", ")}` : ""}. read_wall lists the questions it asks now, each with its kind and ids.${sinceRead.length ? ` ${sinceRead.length} change(s) landed since the last read_wall, so read it again first.` : ""}`);
|
|
1383
1427
|
}
|
|
1384
1428
|
if (matches.length > 1) {
|
|
1385
1429
|
return ok(`The wall asks ${matches.length} questions of kind "${args.kind}"; pass ids to say which:\n${matches.map((finding) => ` - ${finding.text} (ids: ${finding.ids.join(", ")})`).join("\n")}`);
|
|
@@ -1643,23 +1687,19 @@ server.registerTool(
|
|
|
1643
1687
|
{
|
|
1644
1688
|
title: "Export the wall as Fountain",
|
|
1645
1689
|
description:
|
|
1646
|
-
"The open board as a Fountain screenplay: a title page (with the premise and logline in its notes), beats as sections, one scene per card in wall order — a forced heading from the card's place (or its headline), the headline as a synopsis, the cast and the fold as notes, the change line as action. Plain text a writer can open in any Fountain editor. Pass a path to write a .fountain file; otherwise the text comes back.",
|
|
1690
|
+
"The open board as a Fountain screenplay: a title page (with the premise and logline in its notes), beats as sections, one scene per card in wall order — a forced heading from the card's place (or its headline), the headline as a synopsis, the cast and the fold as notes, the change line as action after the mark [Unwritten] until the scene is written. Titled for the project, a one-board film being its project. Plain text a writer can open in any Fountain editor. Pass a path to write a .fountain file; otherwise the text comes back.",
|
|
1647
1691
|
inputSchema: { path: z.string().optional() },
|
|
1648
1692
|
},
|
|
1649
1693
|
async (args) => {
|
|
1650
1694
|
const { state } = await readBoard();
|
|
1651
1695
|
const { project } = await readProject();
|
|
1652
1696
|
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
1653
|
-
const
|
|
1654
|
-
|
|
1655
|
-
project: project.boards.length > 1 && project.name !== "Untitled project" ? project.name : undefined,
|
|
1656
|
-
premise: project.premise || undefined,
|
|
1657
|
-
draftDate: new Date().toISOString(),
|
|
1658
|
-
});
|
|
1697
|
+
const titles = scriptTitles(project, board);
|
|
1698
|
+
const text = toFountain(state, { ...titles, premise: project.premise || undefined, draftDate: new Date().toISOString() });
|
|
1659
1699
|
if (args.path) {
|
|
1660
1700
|
fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
|
|
1661
1701
|
fs.writeFileSync(args.path, text);
|
|
1662
|
-
return ok(`Wrote ${text.split("\n").length} lines of Fountain to ${args.path}.`);
|
|
1702
|
+
return ok(`Wrote ${text.split("\n").length} lines of Fountain, titled "${titles.title}", to ${args.path}.`);
|
|
1663
1703
|
}
|
|
1664
1704
|
return ok(text);
|
|
1665
1705
|
},
|
|
@@ -1670,22 +1710,19 @@ server.registerTool(
|
|
|
1670
1710
|
{
|
|
1671
1711
|
title: "Export the wall as Markdown",
|
|
1672
1712
|
description:
|
|
1673
|
-
"The open board as Markdown, for a collaborator who lives in Google Docs or the like: the board
|
|
1713
|
+
"The open board as Markdown, for a collaborator who lives in Google Docs or the like: titled for the project — a one-board film is its project, and the board's name follows only when the project has several boards — the premise and the logline under it, beats as second-level headings, a third-level heading per scene from its place with its scene number, the headline as a synopsis line, then the scene's text — a speech as its cue in bold with the lines under it — or, unwritten, its change line in italics after the mark [Unwritten], so a reader can tell a placeholder from a page. Pass a path to write a .md file; otherwise the text comes back.",
|
|
1674
1714
|
inputSchema: { path: z.string().optional() },
|
|
1675
1715
|
},
|
|
1676
1716
|
async (args) => {
|
|
1677
1717
|
const { state } = await readBoard();
|
|
1678
1718
|
const { project } = await readProject();
|
|
1679
1719
|
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
1680
|
-
const
|
|
1681
|
-
|
|
1682
|
-
project: project.boards.length > 1 && project.name !== "Untitled project" ? project.name : undefined,
|
|
1683
|
-
premise: project.premise || undefined,
|
|
1684
|
-
});
|
|
1720
|
+
const titles = scriptTitles(project, board);
|
|
1721
|
+
const text = toMarkdown(state, { ...titles, premise: project.premise || undefined });
|
|
1685
1722
|
if (args.path) {
|
|
1686
1723
|
fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
|
|
1687
1724
|
fs.writeFileSync(args.path, text);
|
|
1688
|
-
return ok(`Wrote ${text.split("\n").length} lines of Markdown to ${path.resolve(args.path)}.`);
|
|
1725
|
+
return ok(`Wrote ${text.split("\n").length} lines of Markdown, titled "${titles.title}", to ${path.resolve(args.path)}.`);
|
|
1689
1726
|
}
|
|
1690
1727
|
return ok(text);
|
|
1691
1728
|
},
|
|
@@ -1696,21 +1733,19 @@ server.registerTool(
|
|
|
1696
1733
|
{
|
|
1697
1734
|
title: "Export the script as plain text",
|
|
1698
1735
|
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.",
|
|
1736
|
+
"The open board's script as plain text, set as it prints: the paginator's lines at Courier's columns kept with spaces, scene numbers in both margins (the wall's order, or as locked), no page numbers, an unwritten scene's change line as action after the mark [Unwritten]. Titled for the project, a one-board film being its project. Pastes into anything and reads as a script wherever the font is monospaced. Pass a path to write a .txt file; otherwise the text comes back.",
|
|
1700
1737
|
inputSchema: { path: z.string().optional() },
|
|
1701
1738
|
},
|
|
1702
1739
|
async (args) => {
|
|
1703
1740
|
const { state } = await readBoard();
|
|
1704
1741
|
const { project } = await readProject();
|
|
1705
1742
|
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
1706
|
-
const
|
|
1707
|
-
|
|
1708
|
-
project: project.boards.length > 1 && project.name !== "Untitled project" ? project.name : undefined,
|
|
1709
|
-
});
|
|
1743
|
+
const titles = scriptTitles(project, board);
|
|
1744
|
+
const text = toPlainText(state, titles);
|
|
1710
1745
|
if (args.path) {
|
|
1711
1746
|
fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
|
|
1712
1747
|
fs.writeFileSync(args.path, text);
|
|
1713
|
-
return ok(`Wrote ${text.split("\n").length} lines of plain text to ${path.resolve(args.path)}.`);
|
|
1748
|
+
return ok(`Wrote ${text.split("\n").length} lines of plain text, titled "${titles.title}", to ${path.resolve(args.path)}.`);
|
|
1714
1749
|
}
|
|
1715
1750
|
return ok(text);
|
|
1716
1751
|
},
|
|
@@ -1853,18 +1888,19 @@ server.registerTool(
|
|
|
1853
1888
|
{
|
|
1854
1889
|
title: "Export as Final Draft",
|
|
1855
1890
|
description:
|
|
1856
|
-
"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 when unwritten, and a title page. Pass a path to write the file; otherwise the XML comes back.",
|
|
1891
|
+
"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 project (a one-board film is its project). Pass a path to write the file; otherwise the XML comes back.",
|
|
1857
1892
|
inputSchema: { path: z.string().optional() },
|
|
1858
1893
|
},
|
|
1859
1894
|
async (args) => {
|
|
1860
1895
|
const { state } = await readBoard();
|
|
1861
1896
|
const { project } = await readProject();
|
|
1862
1897
|
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
1863
|
-
const
|
|
1898
|
+
const titles = scriptTitles(project, board);
|
|
1899
|
+
const xml = toFdx(state, { ...titles, draftDate: new Date().toISOString() });
|
|
1864
1900
|
if (args.path) {
|
|
1865
1901
|
fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
|
|
1866
1902
|
fs.writeFileSync(args.path, xml);
|
|
1867
|
-
return ok(`Wrote a Final Draft file with ${state.notes.length} scene(s) to ${args.path}.`);
|
|
1903
|
+
return ok(`Wrote a Final Draft file with ${state.notes.length} scene(s), titled "${titles.title}", to ${args.path}.`);
|
|
1868
1904
|
}
|
|
1869
1905
|
return ok(xml);
|
|
1870
1906
|
},
|
|
@@ -2505,6 +2541,29 @@ server.registerTool(
|
|
|
2505
2541
|
},
|
|
2506
2542
|
);
|
|
2507
2543
|
|
|
2544
|
+
server.registerTool(
|
|
2545
|
+
"add_to_group",
|
|
2546
|
+
{
|
|
2547
|
+
title: "Add cards to a group",
|
|
2548
|
+
description:
|
|
2549
|
+
"Put one or more cards into a group that already exists — the agent's side of dragging a card into a frame. The frame reaches the cards where they are; nothing moves. A card is in one group at a time, so it leaves any other frame on the way, and a frame left with one card dissolves. Needs the group's id and the cards' ids from list_board; organize keeps a group together as a block.",
|
|
2550
|
+
inputSchema: { id: z.string(), noteIds: z.array(z.string()).min(1) },
|
|
2551
|
+
},
|
|
2552
|
+
async (args) => {
|
|
2553
|
+
const { state, changed, result, live } = await commit({ type: "add_to_group", id: args.id, noteIds: args.noteIds });
|
|
2554
|
+
if (!changed) {
|
|
2555
|
+
if (!state.groups.some((group) => group.id === args.id)) return ok(`No group with id ${args.id}. Call list_board for the real ids; create_group makes a new frame.`);
|
|
2556
|
+
const missing = args.noteIds.filter((id) => !state.notes.some((note) => note.id === id));
|
|
2557
|
+
return ok(missing.length ? `Nothing added: not on the board — ${missing.join(", ")}. Call list_board to check the ids.` : "Nothing added: those cards are in that group already.");
|
|
2558
|
+
}
|
|
2559
|
+
const names = result.added.map((id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`).join(", ");
|
|
2560
|
+
const left = result.left
|
|
2561
|
+
.map((group) => (group.dissolved ? ` "${group.title}" dissolved on the way: a frame needs two cards.` : ` Left "${group.title}", which keeps ${group.remaining} card${group.remaining === 1 ? "" : "s"}.`))
|
|
2562
|
+
.join("");
|
|
2563
|
+
return ok(`Added ${names} to "${result.group.title}", which now holds ${result.group.noteIds.length} cards${where(live)}. The frame reaches them where they are; organize lays the group out as a block.${left}`, result);
|
|
2564
|
+
},
|
|
2565
|
+
);
|
|
2566
|
+
|
|
2508
2567
|
server.registerTool(
|
|
2509
2568
|
"rename_group",
|
|
2510
2569
|
{
|
package/src/board/fdx.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { parseScene, TRANSITION } from "./paginate.js";
|
|
14
14
|
import { readingOrder } from "./readWall.js";
|
|
15
|
-
import { sceneHeading } from "./fountain.js";
|
|
15
|
+
import { sceneHeading, standInFor } from "./fountain.js";
|
|
16
16
|
import { sceneNumbers } from "./numbering.js";
|
|
17
17
|
|
|
18
18
|
function escapeXml(text) {
|
|
@@ -60,7 +60,8 @@ export function toFdx(state, options = {}) {
|
|
|
60
60
|
"<Text>",
|
|
61
61
|
`<SceneProperties Length="" Page="" Title="${escapeXml(note.headline)}" />\n <Text>`,
|
|
62
62
|
);
|
|
63
|
-
|
|
63
|
+
// Unwritten: the change line stands in as action, marked as every export marks it.
|
|
64
|
+
const elements = parseScene(note.text && note.text.trim() ? note.text : standInFor(note));
|
|
64
65
|
for (let i = 0; i < elements.length; i += 1) {
|
|
65
66
|
const element = elements[i];
|
|
66
67
|
if (element.kind === "action") content += paragraph("Action", element.text);
|
package/src/board/fountain.d.ts
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
import type { BoardNote, BoardState } from "./reducer";
|
|
4
4
|
|
|
5
5
|
export declare function sceneHeading(note: BoardNote): string;
|
|
6
|
+
/** The mark every export sets before an unwritten scene's change line. */
|
|
7
|
+
export declare const UNWRITTEN_MARK: string;
|
|
8
|
+
export declare function standInFor(note: Pick<BoardNote, "change">): string;
|
|
9
|
+
export declare function unmark(text: string | null | undefined): { text: string; marked: boolean };
|
|
6
10
|
|
|
7
11
|
export declare function titlePage(titles: {
|
|
8
12
|
title?: string;
|
package/src/board/fountain.js
CHANGED
|
@@ -26,6 +26,28 @@ export function sceneHeading(note) {
|
|
|
26
26
|
return `.${upper(words)}`;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The mark every export sets before an unwritten scene's change line, so a
|
|
31
|
+
* reader can tell a placeholder from a page (round thirteen, entry 27). One
|
|
32
|
+
* mark, the same words in Markdown, plain text, Fountain and Final Draft;
|
|
33
|
+
* coming back in, a body that is the mark and the card's change line is
|
|
34
|
+
* still unwritten.
|
|
35
|
+
*/
|
|
36
|
+
export const UNWRITTEN_MARK = "[Unwritten]";
|
|
37
|
+
|
|
38
|
+
/** What stands in for an unwritten scene's body: the mark, then the change line. */
|
|
39
|
+
export function standInFor(note) {
|
|
40
|
+
const change = (note.change ?? "").trim();
|
|
41
|
+
return change ? `${UNWRITTEN_MARK} ${change}` : UNWRITTEN_MARK;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A body without its mark, and whether it carried one. */
|
|
45
|
+
export function unmark(text) {
|
|
46
|
+
const trimmed = (text ?? "").trim();
|
|
47
|
+
if (!trimmed.startsWith(UNWRITTEN_MARK)) return { text: trimmed, marked: false };
|
|
48
|
+
return { text: trimmed.slice(UNWRITTEN_MARK.length).trim(), marked: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
29
51
|
/** The title page block. `titles` is what the writer would put above the script. */
|
|
30
52
|
export function titlePage({ title, credit, author, draftDate, notes }) {
|
|
31
53
|
const lines = [];
|
|
@@ -89,8 +111,9 @@ export function toFountain(state, options = {}) {
|
|
|
89
111
|
body.push(`[[${marks.join(" · ")}]]`);
|
|
90
112
|
body.push("");
|
|
91
113
|
}
|
|
92
|
-
// The scene's text when it is written; the change line stands in until
|
|
93
|
-
|
|
114
|
+
// The scene's text when it is written; the change line stands in until
|
|
115
|
+
// then, marked so a printed page never passes a placeholder off as a scene.
|
|
116
|
+
body.push(note.text && note.text.trim() ? note.text.trim() : standInFor(note));
|
|
94
117
|
body.push("");
|
|
95
118
|
}
|
|
96
119
|
|
|
@@ -206,9 +229,10 @@ export function mergeFountain(state, parsed) {
|
|
|
206
229
|
if (found) {
|
|
207
230
|
used.add(found.id);
|
|
208
231
|
cursor = order.indexOf(found) + 1;
|
|
209
|
-
// A scene whose body is the card's own change line is the
|
|
210
|
-
// unwritten card coming back: still unwritten, not a page.
|
|
211
|
-
const
|
|
232
|
+
// A scene whose body is the mark, or the card's own change line, is the
|
|
233
|
+
// export of an unwritten card coming back: still unwritten, not a page.
|
|
234
|
+
const body = unmark(scene.text);
|
|
235
|
+
const standIn = !(found.text ?? "").trim() && (body.marked || sameWords(body.text, found.change ?? ""));
|
|
212
236
|
if ((found.text ?? "") !== scene.text && !standIn) {
|
|
213
237
|
commands.push({ type: "set_text", id: found.id, text: scene.text });
|
|
214
238
|
}
|
|
@@ -220,13 +244,15 @@ export function mergeFountain(state, parsed) {
|
|
|
220
244
|
const headline = scene.synopsis || titleCase(scene.heading);
|
|
221
245
|
const isPlace = scene.forced && Boolean(scene.synopsis);
|
|
222
246
|
const id = `scene-${Math.random().toString(36).slice(2, 8)}`;
|
|
247
|
+
// A marked body is an unwritten scene: its words are the change line, not a page.
|
|
248
|
+
const body = unmark(scene.text);
|
|
223
249
|
commands.push({
|
|
224
250
|
type: "create_note",
|
|
225
251
|
id,
|
|
226
252
|
headline,
|
|
227
|
-
change: scene.text ? firstSentence(scene.text) : "What changes?",
|
|
253
|
+
change: body.marked ? body.text || "What changes?" : scene.text ? firstSentence(scene.text) : "What changes?",
|
|
228
254
|
location: isPlace ? titleCase(scene.heading) : "",
|
|
229
|
-
text: scene.text,
|
|
255
|
+
text: body.marked ? "" : scene.text,
|
|
230
256
|
x: anchor ? anchor.x + 40 : 140,
|
|
231
257
|
y: anchor ? anchor.y + 40 : 140,
|
|
232
258
|
});
|
package/src/board/markdown.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// monospaced.
|
|
19
19
|
|
|
20
20
|
import { readingOrder } from "./readWall.js";
|
|
21
|
-
import { sceneHeading } from "./fountain.js";
|
|
21
|
+
import { sceneHeading, standInFor } from "./fountain.js";
|
|
22
22
|
import { paginate, parseScene, WIDTH } from "./paginate.js";
|
|
23
23
|
import { sceneNumbers } from "./numbering.js";
|
|
24
24
|
|
|
@@ -79,7 +79,9 @@ export function toMarkdown(state, options = {}) {
|
|
|
79
79
|
out.push(`### ${numbers.get(note.id) ?? ""} · ${heading}`.replace(/^### · /, "### "), "");
|
|
80
80
|
if (note.headline && upper(note.headline) !== heading) out.push(`*${note.headline.trim()}*`, "");
|
|
81
81
|
if (note.text && note.text.trim()) out.push(...sceneMarkdown(note.text));
|
|
82
|
-
|
|
82
|
+
// Unwritten: the change line stands in, marked and in italics, so a reader
|
|
83
|
+
// in Docs can tell the one written scene from sixteen placeholders.
|
|
84
|
+
else out.push(`*${standInFor(note)}*`, "");
|
|
83
85
|
}
|
|
84
86
|
return `${out.join("\n").trimEnd()}\n`;
|
|
85
87
|
}
|
|
@@ -156,7 +158,7 @@ export function toPlainText(state, options = {}) {
|
|
|
156
158
|
id: note.id,
|
|
157
159
|
heading: sceneHeading(note).slice(1),
|
|
158
160
|
text: note.text,
|
|
159
|
-
change: note
|
|
161
|
+
change: standInFor(note),
|
|
160
162
|
written: Boolean(note.text && note.text.trim()),
|
|
161
163
|
number: numbers.get(note.id) ?? undefined,
|
|
162
164
|
})),
|
package/src/board/project.d.ts
CHANGED
|
@@ -67,6 +67,8 @@ export declare function setActiveBoard(project: ProjectRecord, id: string, now?:
|
|
|
67
67
|
export declare function renameProject(project: ProjectRecord, name: string, now?: string): ProjectRecord;
|
|
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
|
+
/** 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; project?: string };
|
|
70
72
|
export declare function findBoard(project: ProjectRecord, key: string): BoardMeta | null;
|
|
71
73
|
export declare function reidentifyProject(
|
|
72
74
|
project: ProjectRecord,
|
package/src/board/project.js
CHANGED
|
@@ -289,6 +289,21 @@ export function setPremise(project, premise, now = nowIso()) {
|
|
|
289
289
|
return touch(project, { premise: next }, now);
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
+
/**
|
|
293
|
+
* What a script going out is called (round thirteen, entry 26): a named
|
|
294
|
+
* project is the title — a one-board film is its project — with the board's
|
|
295
|
+
* name beside it only when the project has several boards; an untitled
|
|
296
|
+
* project's board is the title. One rule for Markdown, plain text, Fountain
|
|
297
|
+
* and Final Draft, through every door.
|
|
298
|
+
*/
|
|
299
|
+
export function scriptTitles(project, board) {
|
|
300
|
+
const boardName = (board?.name ?? "").trim() || "Untitled";
|
|
301
|
+
const named = typeof project?.name === "string" && project.name.trim() && project.name !== DEFAULT_PROJECT_NAME;
|
|
302
|
+
if (!named) return { title: boardName };
|
|
303
|
+
if ((project.boards ?? []).length > 1) return { title: boardName, project: project.name };
|
|
304
|
+
return { title: project.name };
|
|
305
|
+
}
|
|
306
|
+
|
|
292
307
|
export function boardById(project, id) {
|
|
293
308
|
return project.boards.find((board) => board.id === id) ?? null;
|
|
294
309
|
}
|
package/src/board/reducer.d.ts
CHANGED
|
@@ -139,7 +139,7 @@ export declare function sameName(a: string, b: string): boolean;
|
|
|
139
139
|
export type Command =
|
|
140
140
|
| { type: "set_logline"; logline: string }
|
|
141
141
|
| { type: "set_rank"; ids: string[]; rank: NoteRank }
|
|
142
|
-
| { type: "set_length"; ids: string[]; lengthEighths: number }
|
|
142
|
+
| { type: "set_length"; ids: string[]; lengthEighths: number | null }
|
|
143
143
|
| { type: "set_target"; targetEighths: number }
|
|
144
144
|
| {
|
|
145
145
|
type: "create_note";
|
|
@@ -167,6 +167,7 @@ export type Command =
|
|
|
167
167
|
| { type: "settle_note"; id: string }
|
|
168
168
|
| { type: "create_group"; title?: string; noteIds: string[] }
|
|
169
169
|
| { type: "ungroup"; id: string }
|
|
170
|
+
| { type: "add_to_group"; id: string; noteIds: string[] }
|
|
170
171
|
| { type: "rename_group"; id: string; title: string }
|
|
171
172
|
| { type: "create_arrow"; from: string; to: string; kind?: ArrowKind }
|
|
172
173
|
| { type: "delete_arrow"; id: string }
|
package/src/board/reducer.js
CHANGED
|
@@ -497,11 +497,12 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
497
497
|
case "set_length": {
|
|
498
498
|
const ids = new Set(command.ids);
|
|
499
499
|
if (ids.size === 0) return { state, changed: false };
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
500
|
+
// null unsizes: the card claims nothing again and reads as about a page
|
|
501
|
+
// (round thirteen, entry 16: there was no way back from a length).
|
|
502
|
+
const lengthEighths =
|
|
503
|
+
command.lengthEighths === null
|
|
504
|
+
? null
|
|
505
|
+
: clampEighths(command.lengthEighths, DEFAULT_NOTE_EIGHTHS, MAX_NOTE_EIGHTHS);
|
|
505
506
|
const touched = [];
|
|
506
507
|
const notes = state.notes.map((note) => {
|
|
507
508
|
if (!ids.has(note.id) || note.lengthEighths === lengthEighths) return note;
|
|
@@ -575,23 +576,32 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
575
576
|
}
|
|
576
577
|
|
|
577
578
|
case "delete_note": {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
579
|
+
const gone = state.notes.find((note) => note.id === command.id);
|
|
580
|
+
if (!gone) return { state, changed: false };
|
|
581
|
+
const headlineOf = (id) => state.notes.find((note) => note.id === id)?.headline ?? id;
|
|
581
582
|
const notes = state.notes.filter((note) => note.id !== command.id);
|
|
582
|
-
const
|
|
583
|
-
|
|
584
|
-
|
|
583
|
+
const taken = state.arrows.filter((arrow) => arrow.from === command.id || arrow.to === command.id);
|
|
584
|
+
const arrows = state.arrows.filter((arrow) => !taken.includes(arrow));
|
|
585
|
+
const left = [];
|
|
585
586
|
const groups = pruneGroups(
|
|
586
|
-
state.groups.map((group) =>
|
|
587
|
-
|
|
588
|
-
noteIds
|
|
589
|
-
|
|
587
|
+
state.groups.map((group) => {
|
|
588
|
+
if (!group.noteIds.includes(command.id)) return group;
|
|
589
|
+
const noteIds = group.noteIds.filter((id) => id !== command.id);
|
|
590
|
+
left.push({ id: group.id, title: group.title, remaining: noteIds.length, dissolved: noteIds.length < 2 });
|
|
591
|
+
return { ...group, noteIds };
|
|
592
|
+
}),
|
|
590
593
|
);
|
|
594
|
+
// The result says what went with the card, so a door can say it too
|
|
595
|
+
// (round thirteen, entry 17: "Deleted card." and nothing of the arrows).
|
|
591
596
|
return {
|
|
592
597
|
state: { ...state, notes, arrows, groups },
|
|
593
598
|
changed: true,
|
|
594
|
-
result: {
|
|
599
|
+
result: {
|
|
600
|
+
id: command.id,
|
|
601
|
+
headline: gone.headline,
|
|
602
|
+
arrows: taken.map((arrow) => ({ ...arrow, fromHeadline: headlineOf(arrow.from), toHeadline: headlineOf(arrow.to) })),
|
|
603
|
+
groups: left,
|
|
604
|
+
},
|
|
595
605
|
};
|
|
596
606
|
}
|
|
597
607
|
|
|
@@ -672,6 +682,34 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
672
682
|
};
|
|
673
683
|
}
|
|
674
684
|
|
|
685
|
+
case "add_to_group": {
|
|
686
|
+
// The agent's twin of dragging a card into a frame (round thirteen,
|
|
687
|
+
// entry 18). Membership only: the frame reaches the card where it is,
|
|
688
|
+
// and a card leaves any other group on the way, as create_group does.
|
|
689
|
+
const group = state.groups.find((item) => item.id === command.id);
|
|
690
|
+
if (!group) return { state, changed: false };
|
|
691
|
+
const joining = command.noteIds.filter(
|
|
692
|
+
(id, index) =>
|
|
693
|
+
command.noteIds.indexOf(id) === index &&
|
|
694
|
+
!group.noteIds.includes(id) &&
|
|
695
|
+
state.notes.some((note) => note.id === id),
|
|
696
|
+
);
|
|
697
|
+
if (joining.length === 0) return { state, changed: false };
|
|
698
|
+
const idSet = new Set(joining);
|
|
699
|
+
const left = [];
|
|
700
|
+
const groups = pruneGroups(
|
|
701
|
+
state.groups.map((existing) => {
|
|
702
|
+
if (existing.id === group.id) return { ...existing, noteIds: [...existing.noteIds, ...joining] };
|
|
703
|
+
if (!existing.noteIds.some((id) => idSet.has(id))) return existing;
|
|
704
|
+
const noteIds = existing.noteIds.filter((id) => !idSet.has(id));
|
|
705
|
+
left.push({ id: existing.id, title: existing.title, remaining: noteIds.length, dissolved: noteIds.length < 2 });
|
|
706
|
+
return { ...existing, noteIds };
|
|
707
|
+
}),
|
|
708
|
+
);
|
|
709
|
+
const joined = groups.find((item) => item.id === group.id);
|
|
710
|
+
return { state: { ...state, groups }, changed: true, result: { group: joined, added: joining, left } };
|
|
711
|
+
}
|
|
712
|
+
|
|
675
713
|
case "rename_group": {
|
|
676
714
|
let renamed = false;
|
|
677
715
|
const groups = state.groups.map((group) => {
|