plotcoder-board 0.1.28 → 0.1.30
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 +9 -5
- package/package.json +1 -1
- package/scripts/plotcoder-mcp-server.mjs +170 -24
- package/src/board/agents.js +2 -2
- package/src/board/fountain.js +2 -0
- package/src/board/readWall.d.ts +6 -2
- package/src/board/readWall.js +37 -2
- package/src/board/reducer.d.ts +16 -0
- package/src/board/reducer.js +84 -2
- package/src/board/words.js +5 -0
- package/src/board/workflows.js +3 -3
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-
|
|
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-five 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_open` (a card the writer has not decided), `create_thread`, `update_thread` and `delete_thread` (a named string through cards, either end open until tied), `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,11 +51,15 @@ 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. Nineteen 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 seventeen is fixed or decided
|
|
56
|
+
first seventeen is fixed or decided. Round seventeen, the first to start from
|
|
57
57
|
a page of notes instead of a treatment, found the wall has no way to hold a
|
|
58
|
-
maybe — the open card, R59,
|
|
58
|
+
maybe — the open card, R59, built from it; round eighteen confirmed that a
|
|
59
|
+
thread is not a fold — the thread, R60, built from it, a named string through
|
|
60
|
+
cards with either end open until the writer ties it; round nineteen measured
|
|
61
|
+
the thread and found the fold beside it cannot say which of two things a
|
|
62
|
+
scene plants. [`blind-runs/`](blind-runs/) holds the rules that keep a round honest,
|
|
59
63
|
the table of rounds, and the next round's prompt with the test account filled in.
|
|
60
64
|
|
|
61
65
|
A round works a **test account** — a throwaway marked on its writer row, and the
|
|
@@ -100,4 +104,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
|
|
|
100
104
|
|
|
101
105
|
## Status
|
|
102
106
|
|
|
103
|
-
Version 0.1.
|
|
107
|
+
Version 0.1.30. 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
|
@@ -856,6 +856,10 @@ function describeCommand(command) {
|
|
|
856
856
|
return "recolor_note";
|
|
857
857
|
case "apply_poses":
|
|
858
858
|
return "organize";
|
|
859
|
+
case "create_thread":
|
|
860
|
+
return `create_thread "${command.name ?? ""}"`;
|
|
861
|
+
case "update_thread":
|
|
862
|
+
return typeof command.startOpen === "boolean" || typeof command.endOpen === "boolean" ? "update_thread (an end tied or opened)" : "update_thread";
|
|
859
863
|
default:
|
|
860
864
|
return command.type;
|
|
861
865
|
}
|
|
@@ -968,7 +972,7 @@ function isSampleWall(state) {
|
|
|
968
972
|
return state.notes.map((note) => note.headline).sort().join("\n") === sample;
|
|
969
973
|
}
|
|
970
974
|
/** Every check read_wall runs, so silence can be named. */
|
|
971
|
-
const CHECKS = ["unmarked", "sag", "empty", "unwritten", "unlinked", "duplicate", "sequence", "uncast", "nobody", "absent", "backwards", "unpaid", "unplanted", "unplaced"];
|
|
975
|
+
const CHECKS = ["unmarked", "sag", "empty", "unwritten", "unlinked", "duplicate", "sequence", "uncast", "nobody", "absent", "backwards", "unpaid", "unplanted", "unplaced", "loose"];
|
|
972
976
|
/** What each check looks for, in words, so "clean" says what was checked rather than a kind's name. */
|
|
973
977
|
const CHECK_WORDS = {
|
|
974
978
|
unmarked: "a beat is marked",
|
|
@@ -985,6 +989,20 @@ const CHECK_WORDS = {
|
|
|
985
989
|
unpaid: "no fold without a payoff",
|
|
986
990
|
unplanted: "no payoff without its fold",
|
|
987
991
|
unplaced: "no card without a place",
|
|
992
|
+
loose: "no thread with a loose end",
|
|
993
|
+
};
|
|
994
|
+
/** What an open card would be asked once closed, as the question and not the check's clean form (round nineteen, entry 31). */
|
|
995
|
+
const ASK_WORDS = {
|
|
996
|
+
unwritten: "its change line",
|
|
997
|
+
unlinked: "what comes before and after it",
|
|
998
|
+
duplicate: "which of two alike headlines it is",
|
|
999
|
+
sequence: "whether its group is one sequence",
|
|
1000
|
+
nobody: "who is in it",
|
|
1001
|
+
absent: "who has been gone too long",
|
|
1002
|
+
backwards: "why its payoff comes before its setup",
|
|
1003
|
+
unpaid: "where its fold pays off",
|
|
1004
|
+
unplanted: "what plants its payoff",
|
|
1005
|
+
unplaced: "where it happens",
|
|
988
1006
|
};
|
|
989
1007
|
const SAMPLE_NOTE = "sample: this is the wall PlotCoder starts with (Maya, Tom, the letter); nothing here is the writer's. Replace it, or new_board.";
|
|
990
1008
|
|
|
@@ -1046,7 +1064,7 @@ function summarize(state) {
|
|
|
1046
1064
|
const revised = snap && (snap.headline !== note.headline || snap.change !== note.change || (snap.text ?? "") !== (note.text ?? "") || (snap.location ?? "") !== (note.location ?? "")) ? `, changed in ${state.revision.color}` : "";
|
|
1047
1065
|
const place = note.location ? `, at: ${note.location}` : "";
|
|
1048
1066
|
const when = note.when ? `, when: ${note.when}` : "";
|
|
1049
|
-
const openWord = note.open ? `, open: ${note.open}` : "";
|
|
1067
|
+
const openWord = note.open ? `, open (the writer's words): "${note.open}"` : "";
|
|
1050
1068
|
const count = formatPages(noteEighths(note));
|
|
1051
1069
|
// A written card's estimate is kept underneath for when the text goes; say it, or it is invisible (round sixteen, entry 44).
|
|
1052
1070
|
const underneath = isMeasured(note) && note.lengthEighths !== null ? `; the writer's estimate underneath: ${formatPages(note.lengthEighths)}` : "";
|
|
@@ -1122,18 +1140,20 @@ function summarize(state) {
|
|
|
1122
1140
|
`beats: ${beats}, scenes: ${scenes}`,
|
|
1123
1141
|
state.targetEighths === DEFAULT_TARGET_EIGHTHS
|
|
1124
1142
|
? `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)}`
|
|
1125
|
-
: `runtime: about ${formatPages(runtime)} pages of
|
|
1143
|
+
: `runtime: about ${formatPages(runtime)} pages of the ${formatPages(state.targetEighths)}-page target the writer set (set_target changes it) — ${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)}`,
|
|
1126
1144
|
`notes: ${state.notes.length}, groups: ${state.groups.length}, arrows: ${state.arrows.length}, cast: ${state.characters.length}`,
|
|
1127
1145
|
"cards (in story order — the follows arrows over the rows; each with its id):",
|
|
1128
1146
|
notes || " (no cards)",
|
|
1129
1147
|
"rows on the wall (top to bottom, left to right; ★ a beat):",
|
|
1130
1148
|
...(rowLines.length ? rowLines : [" (no cards)"]),
|
|
1131
|
-
"cast (the project's; every board of it casts from here):",
|
|
1149
|
+
"cast (the project's; every board of it casts from here; read_character reads a person's whole page, update_character writes it):",
|
|
1132
1150
|
cast || " (no one yet — add_character to start the roster)",
|
|
1133
1151
|
"places (each phrase is its own place, and the app relates none of them — if two are one place, set_location them the same):",
|
|
1134
1152
|
places || " (no card says where it happens yet)",
|
|
1135
1153
|
"groups:",
|
|
1136
1154
|
groups || " (no groups)",
|
|
1155
|
+
"threads (a named string through cards, in story order; an open end is asked about by the reading):",
|
|
1156
|
+
...((state.threads ?? []).length ? state.threads.map((thread) => ` - ${threadLine(state, thread)}`) : [" (no threads — create_thread names one)"]),
|
|
1137
1157
|
"arrows:",
|
|
1138
1158
|
arrows || " (no arrows)",
|
|
1139
1159
|
].join("\n");
|
|
@@ -1380,7 +1400,8 @@ server.registerTool(
|
|
|
1380
1400
|
}
|
|
1381
1401
|
}
|
|
1382
1402
|
// Wired into the story where the writer said, in the same frame.
|
|
1383
|
-
|
|
1403
|
+
// On a wall with no follows arrows yet, after or before draws the first one (round nineteen, entry 14).
|
|
1404
|
+
if (beside && made?.id) {
|
|
1384
1405
|
// Count the rewiring, so the reply can say it as move_scene does (round eighteen, entry 36).
|
|
1385
1406
|
const run = (command) => {
|
|
1386
1407
|
const done = step(command);
|
|
@@ -1406,9 +1427,7 @@ server.registerTool(
|
|
|
1406
1427
|
].filter(Boolean).join(", ");
|
|
1407
1428
|
// Where it landed matters only until the tidy, so the reply says the rule once and never the coordinates (round fourteen, entry 11).
|
|
1408
1429
|
const placed = beside
|
|
1409
|
-
? wallHasFollows
|
|
1410
|
-
? ` Wired ${args.after ? "after" : "before"} "${beside.headline}" in the story (${removedArrows} follows arrow${removedArrows === 1 ? "" : "s"} removed, ${drawnArrows} drawn)${joinedGroup ? `, in "${joinedGroup}"` : ""}, and the wall tidied.`
|
|
1411
|
-
: ` The wall has no follows arrows, so "${args.after ? "after" : "before"}" has no story to land in: it sits after the last card; create_arrow the sequence, then move_scene.`
|
|
1430
|
+
? ` Wired ${args.after ? "after" : "before"} "${beside.headline}" in the story (${removedArrows} follows arrow${removedArrows === 1 ? "" : "s"} removed, ${drawnArrows} drawn${wallHasFollows ? "" : "; the wall's first, so the story order starts here"})${joinedGroup ? `, in "${joinedGroup}"` : ""}, and the wall tidied.`
|
|
1412
1431
|
: args.x === undefined && args.y === undefined ? ` Placed after the last card in story order.${once("placed", " organize lays the wall out along the arrows.")}` : "";
|
|
1413
1432
|
// Under a lock a new scene has a letter, not a number: say it, since the board is the only other place to learn it (round fourteen, entry 44).
|
|
1414
1433
|
const numbered = after?.lock && result?.id ? ` Numbered ${sceneNumbers(storyOrder(after), after.lock).get(result.id)} (the numbers are locked; a new scene's letter is its place between locked ones now, worked out again from where it sits if it moves; the locked numbers never move).` : "";
|
|
@@ -1445,7 +1464,8 @@ server.registerTool(
|
|
|
1445
1464
|
.map(([field, word]) => `${word}: "${prior?.[field] ?? ""}" → "${result[field] ?? ""}"`);
|
|
1446
1465
|
if (!changed || fields.length === 0) return ok(`Nothing changed on "${result.headline}": the card already read that way.`);
|
|
1447
1466
|
const mark = state.revision && state.revision.snapshot?.[result.id] && (state.revision.snapshot[result.id].headline !== result.headline || state.revision.snapshot[result.id].change !== result.change) ? ` Marked changed in the ${state.revision.color} revision "${state.revision.name}".` : "";
|
|
1448
|
-
|
|
1467
|
+
const placeNote = args.location !== undefined && (prior?.location ?? "") !== (result.location ?? "") ? nearPlaces(state, result.location) : "";
|
|
1468
|
+
return ok(`Updated "${result.headline}" — ${fields.join("; ")}${where(live)}.${mark}${placeNote}`, result);
|
|
1449
1469
|
},
|
|
1450
1470
|
);
|
|
1451
1471
|
|
|
@@ -1551,7 +1571,7 @@ server.registerTool(
|
|
|
1551
1571
|
"the cast and the places are list_board's, not the reading's",
|
|
1552
1572
|
state.targetEighths === DEFAULT_TARGET_EIGHTHS
|
|
1553
1573
|
? `runtime: about ${formatPages(boardEighths(state))} pages (${whose || "no cards"}); no target set (set_target)`
|
|
1554
|
-
: `runtime: about ${formatPages(boardEighths(state))} pages of
|
|
1574
|
+
: `runtime: about ${formatPages(boardEighths(state))} pages of the ${formatPages(state.targetEighths)}-page target the writer set (set_target changes it) — ${boardEighths(state) > state.targetEighths ? `${formatPages(boardEighths(state) - state.targetEighths)} over` : boardEighths(state) < state.targetEighths ? `${formatPages(state.targetEighths - boardEighths(state))} under` : "on it"} (${whose || "no cards"}; page_count is the script so far)`,
|
|
1555
1575
|
`groups: ${
|
|
1556
1576
|
state.groups.length
|
|
1557
1577
|
? state.groups
|
|
@@ -1580,9 +1600,12 @@ server.registerTool(
|
|
|
1580
1600
|
...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"}`),
|
|
1581
1601
|
...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`),
|
|
1582
1602
|
...(reading.open.length
|
|
1583
|
-
? ["open, by the writer's word (listed, not asked about while the words stand; set_open with \"\" closes):", ...reading.open.map((item) => ` - "${state.notes.find((note) => note.id === item.id)?.headline ?? item.id}" — ${item.words}${item.hides.length ? ` (would be asked
|
|
1603
|
+
? ["open, by the writer's word (listed, not asked about while the words stand; set_open with \"\" closes):", ...reading.open.map((item) => ` - "${state.notes.find((note) => note.id === item.id)?.headline ?? item.id}" — ${item.words}${item.hides.length ? ` (closed, it would be asked ${item.hides.map((kind) => ASK_WORDS[kind] ?? CHECK_WORDS[kind] ?? kind).join("; ")})` : ""}`)]
|
|
1604
|
+
: []),
|
|
1605
|
+
...(reading.threads.length
|
|
1606
|
+
? ["threads (the writer's strings through the story; a loose end is asked about below):", ...reading.threads.map((thread) => ` - "${thread.name}": ${thread.ids.length ? thread.ids.map((id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`).join(" → ") : "no card yet"}${thread.startOpen ? " — starts nowhere yet" : ""}${thread.endOpen ? " — ends nowhere yet" : ""}`)]
|
|
1584
1607
|
: []),
|
|
1585
|
-
"questions the wall raises:",
|
|
1608
|
+
"questions the wall raises (each stands on every reading until the wall changes to answer it, or the writer leaves it — leave_question, with their reason):",
|
|
1586
1609
|
...(reading.findings.length
|
|
1587
1610
|
? reading.findings.map((finding) => ` - [${finding.kind}] ${finding.text}${finding.ids.length ? ` (ids: ${finding.ids.join(", ")})` : ""}`)
|
|
1588
1611
|
: [reading.left.length ? " (none the writer has not left)" : " (none that this reading can see)"]),
|
|
@@ -2016,7 +2039,7 @@ server.registerTool(
|
|
|
2016
2039
|
const allMeasured = state.notes.length > 0 && state.notes.every((note) => isMeasured(note));
|
|
2017
2040
|
const short = state.targetEighths > 0 && boardEighths(state) * 2 < state.targetEighths;
|
|
2018
2041
|
const lines = [
|
|
2019
|
-
`"${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:`,
|
|
2042
|
+
`"${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}, the page its last card ends on — read_wall counts the same cards as about ${formatPages(boardEighths(state))} pages${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:`,
|
|
2020
2043
|
// On a wall under half its target the pairing is arithmetic; say so before the rows, not after them (round eighteen, entry 52).
|
|
2021
2044
|
...(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`] : []),
|
|
2022
2045
|
...describeComparison(comparison).map((line) => ` - ${line}`),
|
|
@@ -2941,7 +2964,7 @@ server.registerTool(
|
|
|
2941
2964
|
});
|
|
2942
2965
|
const total = parts.reduce((sum, part) => sum + part.on.length, 0);
|
|
2943
2966
|
// An open card reads as open on a person's page too (round eighteen, entry 53).
|
|
2944
|
-
const where_ = (note) => [note.location ? `at ${note.location}` : "", note.when ? note.when : "", note.rank === "beat" ? "beat" : "", note.open ? `open: ${note.open}` : ""].filter(Boolean).join(" · ");
|
|
2967
|
+
const where_ = (note) => [note.location ? `at ${note.location}` : "", note.when ? note.when : "", note.rank === "beat" ? "beat" : "", note.open ? `open: "${note.open}"` : ""].filter(Boolean).join(" · ");
|
|
2945
2968
|
// A read opens with the door it came through, like every reading (round sixteen, entry 28); one scene a line (29).
|
|
2946
2969
|
const lines = [
|
|
2947
2970
|
`PlotCoder cast (${door(live, base)})`,
|
|
@@ -2999,6 +3022,17 @@ server.registerTool(
|
|
|
2999
3022
|
},
|
|
3000
3023
|
);
|
|
3001
3024
|
|
|
3025
|
+
/** A near match on the wall is probably the same place spelled twice (round ten; update_note too, round nineteen entry 36). */
|
|
3026
|
+
function nearPlaces(state, place) {
|
|
3027
|
+
const wanted = (place ?? "").trim().toLowerCase();
|
|
3028
|
+
const near = wanted
|
|
3029
|
+
? [...new Set(state.notes.map((note) => (note.location ?? "").trim()).filter(Boolean))].filter(
|
|
3030
|
+
(other) => other.toLowerCase() !== wanted && (other.toLowerCase().includes(wanted) || wanted.includes(other.toLowerCase())),
|
|
3031
|
+
)
|
|
3032
|
+
: [];
|
|
3033
|
+
return near.length ? ` The wall also has ${near.map((other) => `"${other}"`).join(", ")} — the same place spelled twice, or two places? Each distinct phrase counts as one place.` : "";
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3002
3036
|
server.registerTool(
|
|
3003
3037
|
"set_location",
|
|
3004
3038
|
{
|
|
@@ -3019,16 +3053,8 @@ server.registerTool(
|
|
|
3019
3053
|
return ok("No place changed: those cards already read that way.");
|
|
3020
3054
|
}
|
|
3021
3055
|
const place = result[0].location;
|
|
3022
|
-
// A near match on the wall is probably the same place spelled twice.
|
|
3023
|
-
const wanted = (place ?? "").trim().toLowerCase();
|
|
3024
|
-
const near = wanted
|
|
3025
|
-
? [...new Set(state.notes.map((note) => (note.location ?? "").trim()).filter(Boolean))].filter(
|
|
3026
|
-
(other) => other.toLowerCase() !== wanted && (other.toLowerCase().includes(wanted) || wanted.includes(other.toLowerCase())),
|
|
3027
|
-
)
|
|
3028
|
-
: [];
|
|
3029
|
-
const warn = near.length ? ` The wall also has ${near.map((other) => `"${other}"`).join(", ")} — the same place spelled twice, or two places? Each distinct phrase counts as one place.` : "";
|
|
3030
3056
|
return ok(
|
|
3031
|
-
`${result.length} card(s) now ${place ? `at ${place}` : "nowhere"}${where(live)}.${
|
|
3057
|
+
`${result.length} card(s) now ${place ? `at ${place}` : "nowhere"}${where(live)}.${nearPlaces(state, place)}${stillOpen(result)}`,
|
|
3032
3058
|
result,
|
|
3033
3059
|
);
|
|
3034
3060
|
},
|
|
@@ -3106,6 +3132,126 @@ server.registerTool(
|
|
|
3106
3132
|
},
|
|
3107
3133
|
);
|
|
3108
3134
|
|
|
3135
|
+
// --- Threads (R60) ----------------------------------------------------------
|
|
3136
|
+
//
|
|
3137
|
+
// A thread is a named string through cards, either end open until the writer
|
|
3138
|
+
// ties it: the record for a thing the writer knows the far end of — the
|
|
3139
|
+
// bucket in the last scene, the key that changes hands — and not where it is
|
|
3140
|
+
// first seen. A fold and a setup arrow are still how a plant and its payoff
|
|
3141
|
+
// are drawn; a thread is beside them, in the writer's words, and the reading
|
|
3142
|
+
// asks about each loose end from that end.
|
|
3143
|
+
|
|
3144
|
+
function threadByRef(state, ref) {
|
|
3145
|
+
const wanted = (ref ?? "").trim();
|
|
3146
|
+
if (!wanted) return null;
|
|
3147
|
+
return (state.threads ?? []).find((thread) => thread.id === wanted) ?? (state.threads ?? []).find((thread) => thread.name.trim().toLowerCase() === wanted.toLowerCase()) ?? null;
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
function cardsByRef(state, refs) {
|
|
3151
|
+
const found = [];
|
|
3152
|
+
const missing = [];
|
|
3153
|
+
for (const ref of refs ?? []) {
|
|
3154
|
+
const wanted = (ref ?? "").trim();
|
|
3155
|
+
const note = state.notes.find((item) => item.id === wanted) ?? state.notes.find((item) => item.headline.trim().toLowerCase() === wanted.toLowerCase()) ?? null;
|
|
3156
|
+
if (note) found.push(note.id);
|
|
3157
|
+
else missing.push(ref);
|
|
3158
|
+
}
|
|
3159
|
+
return { found, missing };
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
function threadLine(state, thread) {
|
|
3163
|
+
const order = storyOrder(state).filter((note) => thread.noteIds.includes(note.id));
|
|
3164
|
+
const cards = order.length ? order.map((note) => `"${note.headline}"`).join(" → ") : "no card yet";
|
|
3165
|
+
const ends = [thread.startOpen ? "starts nowhere yet" : null, thread.endOpen ? "ends nowhere yet" : null].filter(Boolean);
|
|
3166
|
+
return `"${thread.name}" (${thread.id}): ${cards}${ends.length ? ` — ${ends.join(", ")}` : order.length ? " — both ends tied" : ""}`;
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
server.registerTool(
|
|
3170
|
+
"create_thread",
|
|
3171
|
+
{
|
|
3172
|
+
title: "Name a thread",
|
|
3173
|
+
description:
|
|
3174
|
+
"Name a thread — a thing that runs through the story and is first seen somewhere and comes out somewhere: \"the letter\", \"the shop's lease\", a subplot — and string it through the cards it touches, by id or headline, in story order. Say which end is not decided: startOpen when the writer knows where it comes out and not where it is first seen; endOpen when they know where it starts and not where it comes out. The reading asks about each open end from that end — \"where is it first seen?\" — until update_thread ties it, and lists every thread with its cards. A thread is beside the fold and the setup arrow, not instead of them: fold the card that plants and draw the setup arrow when both scenes exist; a thread is for the writer's word before they do, and for a strand a fold cannot hold. Only on the writer's word: a thread is theirs to name.",
|
|
3175
|
+
inputSchema: {
|
|
3176
|
+
name: z.string().min(1),
|
|
3177
|
+
cards: z.array(z.string()).optional(),
|
|
3178
|
+
startOpen: z.boolean().optional(),
|
|
3179
|
+
endOpen: z.boolean().optional(),
|
|
3180
|
+
},
|
|
3181
|
+
},
|
|
3182
|
+
async (args) => {
|
|
3183
|
+
const current = await readBoard();
|
|
3184
|
+
const { found, missing } = cardsByRef(current.state, args.cards);
|
|
3185
|
+
if (missing.length) return ok(`No thread made: not on the board — ${missing.map((ref) => `"${ref}"`).join(", ")}. Call list_board for the ids or the exact headlines.`);
|
|
3186
|
+
const { state, changed, result, live } = await commit({ type: "create_thread", name: args.name, noteIds: found, startOpen: args.startOpen === true, endOpen: args.endOpen === true });
|
|
3187
|
+
if (!changed) return ok("No thread made: a thread needs a name.");
|
|
3188
|
+
const asks = [result.startOpen ? "where it is first seen" : null, result.endOpen ? "where it comes out" : null].filter(Boolean);
|
|
3189
|
+
return ok(
|
|
3190
|
+
`Named the thread ${threadLine(state, result)}${where(live)}.${asks.length ? ` The reading asks ${asks.join(" and ")} until update_thread ties ${asks.length === 1 ? "that end" : "them"}.` : result.noteIds.length ? " Both ends are tied; the reading lists it and asks nothing." : " No card yet: the reading asks where it is first seen and where it comes out."} The wall draws it as a string through its cards, a loose end where one is open.`,
|
|
3191
|
+
result,
|
|
3192
|
+
);
|
|
3193
|
+
},
|
|
3194
|
+
);
|
|
3195
|
+
|
|
3196
|
+
server.registerTool(
|
|
3197
|
+
"update_thread",
|
|
3198
|
+
{
|
|
3199
|
+
title: "Tie or change a thread",
|
|
3200
|
+
description:
|
|
3201
|
+
"Change a thread by id or name: rename it, add cards (by id or headline) or remove them, or tie an end — startOpen false once the writer says where it is first seen, endOpen false once they say where it comes out; true reopens an end. Adding the card where a thing is first seen and tying the start is one call: add plus startOpen false. The reading stops asking about an end the moment it is tied.",
|
|
3202
|
+
inputSchema: {
|
|
3203
|
+
thread: z.string(),
|
|
3204
|
+
name: z.string().optional(),
|
|
3205
|
+
add: z.array(z.string()).optional(),
|
|
3206
|
+
remove: z.array(z.string()).optional(),
|
|
3207
|
+
startOpen: z.boolean().optional(),
|
|
3208
|
+
endOpen: z.boolean().optional(),
|
|
3209
|
+
},
|
|
3210
|
+
},
|
|
3211
|
+
async (args) => {
|
|
3212
|
+
const current = await readBoard();
|
|
3213
|
+
const thread = threadByRef(current.state, args.thread);
|
|
3214
|
+
if (!thread) return ok(`No thread called "${args.thread}". list_board names the threads on this board; create_thread names a new one.`);
|
|
3215
|
+
const add = cardsByRef(current.state, args.add);
|
|
3216
|
+
const remove = cardsByRef(current.state, args.remove);
|
|
3217
|
+
const missing = [...add.missing, ...remove.missing];
|
|
3218
|
+
if (missing.length) return ok(`Nothing changed: not on the board — ${missing.map((ref) => `"${ref}"`).join(", ")}. Call list_board for the ids or the exact headlines.`);
|
|
3219
|
+
const { state, changed, result, live } = await commit({
|
|
3220
|
+
type: "update_thread",
|
|
3221
|
+
id: thread.id,
|
|
3222
|
+
...(args.name !== undefined ? { name: args.name } : {}),
|
|
3223
|
+
...(add.found.length ? { add: add.found } : {}),
|
|
3224
|
+
...(remove.found.length ? { remove: remove.found } : {}),
|
|
3225
|
+
...(typeof args.startOpen === "boolean" ? { startOpen: args.startOpen } : {}),
|
|
3226
|
+
...(typeof args.endOpen === "boolean" ? { endOpen: args.endOpen } : {}),
|
|
3227
|
+
});
|
|
3228
|
+
if (!changed) return ok(`Nothing changed: "${thread.name}" already reads that way.`);
|
|
3229
|
+
const tied = [result.before.startOpen && !result.thread.startOpen ? "its start" : null, result.before.endOpen && !result.thread.endOpen ? "its end" : null].filter(Boolean);
|
|
3230
|
+
const reopened = [!result.before.startOpen && result.thread.startOpen ? "its start" : null, !result.before.endOpen && result.thread.endOpen ? "its end" : null].filter(Boolean);
|
|
3231
|
+
return ok(
|
|
3232
|
+
`Now ${threadLine(state, result.thread)}${where(live)}.${tied.length ? ` Tied ${tied.join(" and ")}; the reading stops asking about ${tied.length === 1 ? "it" : "them"}.` : ""}${reopened.length ? ` Opened ${reopened.join(" and ")}; the reading asks about ${reopened.length === 1 ? "it" : "them"} again.` : ""}`,
|
|
3233
|
+
result.thread,
|
|
3234
|
+
);
|
|
3235
|
+
},
|
|
3236
|
+
);
|
|
3237
|
+
|
|
3238
|
+
server.registerTool(
|
|
3239
|
+
"delete_thread",
|
|
3240
|
+
{
|
|
3241
|
+
title: "Cut a thread",
|
|
3242
|
+
description: "Remove a thread by id or name. The cards stay; only the string and its name go. Only on the writer's word.",
|
|
3243
|
+
inputSchema: { thread: z.string() },
|
|
3244
|
+
},
|
|
3245
|
+
async (args) => {
|
|
3246
|
+
const current = await readBoard();
|
|
3247
|
+
const thread = threadByRef(current.state, args.thread);
|
|
3248
|
+
if (!thread) return ok(`No thread called "${args.thread}". list_board names the threads on this board.`);
|
|
3249
|
+
const { changed, result, live } = await commit({ type: "delete_thread", id: thread.id });
|
|
3250
|
+
if (!changed) return ok(`No thread called "${args.thread}".`);
|
|
3251
|
+
return ok(`Cut the thread "${result.name}"${where(live)}; its ${result.noteIds.length} card${result.noteIds.length === 1 ? "" : "s"} stay where they are.`, result);
|
|
3252
|
+
},
|
|
3253
|
+
);
|
|
3254
|
+
|
|
3109
3255
|
// --- Groups -----------------------------------------------------------
|
|
3110
3256
|
|
|
3111
3257
|
server.registerTool(
|
|
@@ -3650,7 +3796,7 @@ server.registerTool(
|
|
|
3650
3796
|
{
|
|
3651
3797
|
title: "Save the project as a file",
|
|
3652
3798
|
description:
|
|
3653
|
-
|
|
3799
|
+
`The project the server is working, as the file Save project writes and Open project takes: the record, every board with its cards, the reminders and the writer's structures. Pass path to write it (a .json) — an absolute path, since a relative one resolves from the folder the server was started in, which is ${process.cwd()}, not yours; without a path, the reply's JSON is the file. Pictures and takes on the account are not in the file. Works through every door.`,
|
|
3654
3800
|
inputSchema: { path: z.string().optional() },
|
|
3655
3801
|
},
|
|
3656
3802
|
async (args) => {
|
package/src/board/agents.js
CHANGED
|
@@ -55,7 +55,7 @@ export const AGENTS = {
|
|
|
55
55
|
"No opinions about how many beats there should be. Marking the turns a treatment plainly makes is reading it, not an opinion: mark them, say which, and let the writer strike or add.",
|
|
56
56
|
"Page counts are estimates.",
|
|
57
57
|
"Ask before delete_board, delete_project, empty_account, delete_account, unlock_numbers, remove_file, an import_project that replaces, or claim_account — the writer gives the email and the password; never invent one. export_project first, when something might be wanted back.",
|
|
58
|
-
"Do not invent people or a logline. What the treatment states — an age, a job, a bad knee — is not invented: it goes in the person's notes. An unnamed person is named by their role — Dana's mother, the dispatcher — which is a name until the writer gives one. A scene is one place and one stretch of time; a new place or time is a new card. A beat is a whole card; a setup arrow lands on the scene's card, so a payoff never needs a card of its own. Acts are groups titled Act one, Act two, when the treatment has them; the wall never asks whether an act is a sequence. Paper colour means nothing to the app. Under target is a fact to report plainly, like over; neither is a verdict.",
|
|
58
|
+
"Do not invent people or a logline. What the treatment states — an age, a job, a bad knee — is not invented: it goes in the person's notes. An unnamed person is named by their role — Dana's mother, the dispatcher — which is a name until the writer gives one. A scene is one place and one stretch of time; a new place or time is a new card. A beat is a whole card; a setup arrow lands on the scene's card, so a payoff never needs a card of its own. Acts are groups titled Act one, Act two, when the treatment has them; the wall never asks whether an act is a sequence. Paper colour means nothing to the app. Under target is a fact to report plainly, like over; neither is a verdict. A thing the writer has not decided is an open card in their words (set_open), never a guess to fill the field; a thing whose far end the writer knows and not where it is first seen — the letter, the ring — is a thread (create_thread) with an open start, and the wall asks from that end.",
|
|
59
59
|
],
|
|
60
60
|
person:
|
|
61
61
|
"Give your agent the account door only on a machine you trust; it signs in as you and shows under People as “an agent, as you” while it runs. Wire the server before you start the agent's session, with the two sign-in lines beside it, and the agent has every tool from its first message; wired from inside a session, the server connects only on the next one. Your agent can also make your account: give it your email and a password of your choosing.",
|
|
@@ -68,7 +68,7 @@ export function agentsAsText() {
|
|
|
68
68
|
// The calls and the rules first, the doors after: an agent with the tools in
|
|
69
69
|
// front of it reads two pages of wiring it was told to skip before it reached
|
|
70
70
|
// the first call (round fifteen, entry 1).
|
|
71
|
-
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
|
|
71
|
+
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.`, "", "## Call these first"];
|
|
72
72
|
AGENTS.first.forEach((item, index) => lines.push(`${index + 1}. ${item.tool} — ${item.why}`));
|
|
73
73
|
lines.push("", AGENTS.firstNote);
|
|
74
74
|
lines.push("", "## Rules");
|
package/src/board/fountain.js
CHANGED
|
@@ -124,6 +124,8 @@ export function toFountain(state, options = {}) {
|
|
|
124
124
|
if (cast.length) marks.push(`with ${cast.join(", ")}`);
|
|
125
125
|
if (note.plants) marks.push("plants something to pay off later");
|
|
126
126
|
if (note.open) marks.push(`open: ${note.open}`);
|
|
127
|
+
const onThreads = (state.threads ?? []).filter((thread) => thread.noteIds.includes(note.id)).map((thread) => thread.name);
|
|
128
|
+
if (onThreads.length) marks.push(`thread: ${onThreads.join(", ")}`);
|
|
127
129
|
if (revisionMarksFor(marks, note)) marks.push(`changed in the ${state.revision.color} revision`);
|
|
128
130
|
if (marks.length) {
|
|
129
131
|
body.push(`[[${marks.join(" · ")}]]`);
|
package/src/board/readWall.d.ts
CHANGED
|
@@ -33,11 +33,13 @@ export type FindingKind =
|
|
|
33
33
|
/** A setup arrow whose payoff comes before its setup on the wall. */
|
|
34
34
|
| "backwards"
|
|
35
35
|
/** A card with a folded corner and no setup arrow leaving it. */
|
|
36
|
-
| "unpaid"
|
|
36
|
+
| "unpaid"
|
|
37
|
+
/** A thread (R60) with an end the writer has not tied: where it is first seen, or where it comes out. */
|
|
38
|
+
| "loose";
|
|
37
39
|
|
|
38
40
|
export type Finding = {
|
|
39
41
|
kind: FindingKind;
|
|
40
|
-
/** Card ids; a group id for "sequence"; a character id (then card ids) for "uncast" and "absent"; an arrow id (then card ids) for "backwards". Empty for "unmarked". */
|
|
42
|
+
/** Card ids; a group id for "sequence"; a character id (then card ids) for "uncast" and "absent"; an arrow id (then card ids) for "backwards"; a thread id (then card ids) for "loose". Empty for "unmarked". */
|
|
41
43
|
ids: string[];
|
|
42
44
|
/** The question, written for a writer. */
|
|
43
45
|
text: string;
|
|
@@ -69,6 +71,8 @@ export type WallReading = {
|
|
|
69
71
|
later: { id: string; boardId: string; noteId: string | null }[];
|
|
70
72
|
/** Open cards (R59): the writer's words for what is not decided, in story order; not asked about while they stand. */
|
|
71
73
|
open: Array<{ id: string; words: string; hides: FindingKind[] }>;
|
|
74
|
+
/** Threads (R60): each named string with its cards in story order and which ends are open. */
|
|
75
|
+
threads: Array<{ id: string; name: string; ids: string[]; startOpen: boolean; endOpen: boolean }>;
|
|
72
76
|
/** Cards here that pay off a fold of another board (R58), composed by the door from the project. */
|
|
73
77
|
paidBy: Array<{ id: string; fromBoardId: string; fromBoardName: string; fromNoteId: string; fromHeadline: string; fromColor: string }>;
|
|
74
78
|
/** The questions the wall asks now. A left one (R53) is not here while its words hold. */
|
package/src/board/readWall.js
CHANGED
|
@@ -502,6 +502,30 @@ export function readWall(state, options = {}) {
|
|
|
502
502
|
}
|
|
503
503
|
}
|
|
504
504
|
|
|
505
|
+
// Threads (R60): a named string through cards, in story order, either end
|
|
506
|
+
// open until the writer ties it. The reading asks about each loose end from
|
|
507
|
+
// that end — the question a fold cannot ask, where a thing is first seen.
|
|
508
|
+
const threads = (state.threads ?? []).map((thread) => ({
|
|
509
|
+
id: thread.id,
|
|
510
|
+
name: thread.name,
|
|
511
|
+
ids: order.filter((note) => thread.noteIds.includes(note.id)).map((note) => note.id),
|
|
512
|
+
startOpen: thread.startOpen === true,
|
|
513
|
+
endOpen: thread.endOpen === true,
|
|
514
|
+
}));
|
|
515
|
+
for (const thread of threads) {
|
|
516
|
+
const first = thread.ids.length ? byId.get(thread.ids[0]) : null;
|
|
517
|
+
const last = thread.ids.length ? byId.get(thread.ids[thread.ids.length - 1]) : null;
|
|
518
|
+
if (!thread.ids.length) {
|
|
519
|
+
findings.push({ kind: "loose", ids: [thread.id], text: `"${thread.name}" runs through no card yet. Where is it first seen, and where does it come out?` });
|
|
520
|
+
} else if (thread.startOpen && thread.endOpen) {
|
|
521
|
+
findings.push({ kind: "loose", ids: [thread.id, ...thread.ids], text: `"${thread.name}" runs through ${list(thread.ids.map((id) => byId.get(id)))} and neither end is tied. Where is it first seen, and where does it come out?` });
|
|
522
|
+
} else if (thread.startOpen) {
|
|
523
|
+
findings.push({ kind: "loose", ids: [thread.id, first.id], text: `"${thread.name}" starts nowhere yet: it runs ${thread.ids.length === 1 ? "to" : "through"} ${list(thread.ids.map((id) => byId.get(id)))}. Where is it first seen?` });
|
|
524
|
+
} else if (thread.endOpen) {
|
|
525
|
+
findings.push({ kind: "loose", ids: [thread.id, last.id], text: `"${thread.name}" ends nowhere yet: it runs ${thread.ids.length === 1 ? "from" : "through"} ${list(thread.ids.map((id) => byId.get(id)))}. Where does it come out?` });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
505
529
|
// A question the writer has left (R53) is held back while it is still the
|
|
506
530
|
// same question — same kind, same cards, same words. The moment it would
|
|
507
531
|
// read differently (a page moved, a headline changed, the median shifted)
|
|
@@ -509,7 +533,11 @@ export function readWall(state, options = {}) {
|
|
|
509
533
|
// reading does, on every read.
|
|
510
534
|
const left = [];
|
|
511
535
|
// A question whose every card is open is not asked (R59): the writer's word covers it.
|
|
512
|
-
|
|
536
|
+
// Not so for a question about the story around the card: a thread's loose end
|
|
537
|
+
// (R60) is the writer's own claim, and the run's questions — beats back to
|
|
538
|
+
// back, a sag — are about the cards between, as the guide promises (round
|
|
539
|
+
// nineteen, entry 28). Those are asked whether or not the cards are open.
|
|
540
|
+
const openCards = openIds.size ? findings.filter((finding) => !ASKED_OF_OPEN_CARDS.has(finding.kind) && finding.ids.some((id) => byId.has(id)) && finding.ids.filter((id) => byId.has(id)).every((id) => openIds.has(id))) : [];
|
|
513
541
|
const asked = findings.filter((finding) => {
|
|
514
542
|
if (openCards.includes(finding)) return false;
|
|
515
543
|
const entry = (state.left ?? []).find(
|
|
@@ -529,6 +557,7 @@ export function readWall(state, options = {}) {
|
|
|
529
557
|
later,
|
|
530
558
|
paidBy: paidHere,
|
|
531
559
|
open: describeOpen(state, options, order, openIds),
|
|
560
|
+
threads,
|
|
532
561
|
findings: asked,
|
|
533
562
|
left,
|
|
534
563
|
};
|
|
@@ -539,13 +568,19 @@ export function readWall(state, options = {}) {
|
|
|
539
568
|
* closed (round eighteen, entry 27): the reading of the same wall with the
|
|
540
569
|
* words cleared, read once more, so the writer can see what the words hide.
|
|
541
570
|
*/
|
|
571
|
+
/** The question kinds an open card does not silence: about the story around it, not the card (R59, R60). */
|
|
572
|
+
const ASKED_OF_OPEN_CARDS = new Set(["loose", "empty", "sag"]);
|
|
573
|
+
|
|
542
574
|
function describeOpen(state, options, order, openIds) {
|
|
543
575
|
if (!openIds.size) return [];
|
|
544
576
|
const hides = new Map();
|
|
545
577
|
if (!options.closedReading) {
|
|
546
578
|
const closed = { ...state, notes: state.notes.map((note) => (openIds.has(note.id) ? { ...note, open: "" } : note)) };
|
|
547
579
|
const again = readWall(closed, { ...options, closedReading: true });
|
|
548
|
-
|
|
580
|
+
// A question about the story around the card is asked whether or not the
|
|
581
|
+
// card is open, so it is never something the open words hide (round
|
|
582
|
+
// nineteen, entries 23 and 28).
|
|
583
|
+
for (const finding of again.findings) if (!ASKED_OF_OPEN_CARDS.has(finding.kind)) for (const id of finding.ids) if (openIds.has(id)) (hides.get(id) ?? hides.set(id, new Set()).get(id)).add(finding.kind);
|
|
549
584
|
}
|
|
550
585
|
return order.filter((note) => openIds.has(note.id)).map((note) => ({ id: note.id, words: note.open.trim(), hides: [...(hides.get(note.id) ?? [])] }));
|
|
551
586
|
}
|
package/src/board/reducer.d.ts
CHANGED
|
@@ -118,6 +118,8 @@ export type BoardState = {
|
|
|
118
118
|
characters: BoardCharacter[];
|
|
119
119
|
notes: BoardNote[];
|
|
120
120
|
groups: BoardGroup[];
|
|
121
|
+
/** Threads (R60): named strings through cards, either end open until tied. */
|
|
122
|
+
threads: BoardThread[];
|
|
121
123
|
arrows: BoardArrow[];
|
|
122
124
|
/** Locked scene numbers, once a draft has gone out (Roadmap 2, item 8); null until then. */
|
|
123
125
|
lock: import("./numbering").Lock | null;
|
|
@@ -127,6 +129,17 @@ export type BoardState = {
|
|
|
127
129
|
left: LeftQuestion[];
|
|
128
130
|
};
|
|
129
131
|
|
|
132
|
+
/** A thread (R60): a name, the cards it runs through, and whether either end is still open. */
|
|
133
|
+
export type BoardThread = {
|
|
134
|
+
id: string;
|
|
135
|
+
name: string;
|
|
136
|
+
noteIds: string[];
|
|
137
|
+
/** Where it is first seen is not decided. */
|
|
138
|
+
startOpen: boolean;
|
|
139
|
+
/** Where it comes out is not decided. */
|
|
140
|
+
endOpen: boolean;
|
|
141
|
+
};
|
|
142
|
+
|
|
130
143
|
/** A question the wall asked and the writer left (R53). */
|
|
131
144
|
export type LeftQuestion = {
|
|
132
145
|
kind: string;
|
|
@@ -179,6 +192,9 @@ export type Command =
|
|
|
179
192
|
| { type: "ungroup"; id: string }
|
|
180
193
|
| { type: "add_to_group"; id: string; noteIds: string[] }
|
|
181
194
|
| { type: "rename_group"; id: string; title: string }
|
|
195
|
+
| { type: "create_thread"; name: string; noteIds?: string[]; startOpen?: boolean; endOpen?: boolean; id?: string }
|
|
196
|
+
| { type: "update_thread"; id: string; name?: string; noteIds?: string[]; add?: string[]; remove?: string[]; startOpen?: boolean; endOpen?: boolean }
|
|
197
|
+
| { type: "delete_thread"; id: string }
|
|
182
198
|
| { type: "create_arrow"; from: string; to: string; kind?: ArrowKind }
|
|
183
199
|
| { type: "delete_arrow"; id: string }
|
|
184
200
|
| { type: "set_arrow_kind"; id: string; kind: ArrowKind }
|
package/src/board/reducer.js
CHANGED
|
@@ -223,6 +223,7 @@ export function emptyState() {
|
|
|
223
223
|
characters: [],
|
|
224
224
|
notes: [],
|
|
225
225
|
groups: [],
|
|
226
|
+
threads: [],
|
|
226
227
|
arrows: [],
|
|
227
228
|
lock: null,
|
|
228
229
|
revision: null,
|
|
@@ -277,6 +278,7 @@ export function seedState(now = nowIso()) {
|
|
|
277
278
|
mk("letter-aloud", "The letter is read aloud", "The plan dies in the room.", "blue", 196, 340, 0.8, 3, ["maya", "tom"]),
|
|
278
279
|
],
|
|
279
280
|
groups: [],
|
|
281
|
+
threads: [],
|
|
280
282
|
arrows: [],
|
|
281
283
|
lock: null,
|
|
282
284
|
revision: null,
|
|
@@ -390,6 +392,25 @@ export function normalizeState(value) {
|
|
|
390
392
|
// the writer leaves it.
|
|
391
393
|
const left = Array.isArray(value.left) ? value.left.filter(isLeftQuestion) : [];
|
|
392
394
|
const leftPatched = !Array.isArray(value.left) || left.length !== value.left.length;
|
|
395
|
+
// Boards written before R60 have no threads: a wall has none until the
|
|
396
|
+
// writer names one. A thread that names a card the board no longer holds
|
|
397
|
+
// drops that card; a thread with no name is not a thread.
|
|
398
|
+
const noteIdSet = new Set(notes.map((note) => note.id));
|
|
399
|
+
let threadsPatched = !Array.isArray(value.threads);
|
|
400
|
+
const threads = (Array.isArray(value.threads) ? value.threads : [])
|
|
401
|
+
.map((thread) => {
|
|
402
|
+
if (!thread || typeof thread !== "object" || typeof thread.id !== "string" || typeof thread.name !== "string" || !thread.name.trim()) {
|
|
403
|
+
threadsPatched = true;
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
const noteIds = Array.isArray(thread.noteIds) ? thread.noteIds.filter((id, index) => typeof id === "string" && noteIdSet.has(id) && thread.noteIds.indexOf(id) === index) : [];
|
|
407
|
+
const startOpen = thread.startOpen === true;
|
|
408
|
+
const endOpen = thread.endOpen === true;
|
|
409
|
+
if (Array.isArray(thread.noteIds) && noteIds.length === thread.noteIds.length && thread.startOpen === startOpen && thread.endOpen === endOpen) return thread;
|
|
410
|
+
threadsPatched = true;
|
|
411
|
+
return { ...thread, noteIds, startOpen, endOpen };
|
|
412
|
+
})
|
|
413
|
+
.filter(Boolean);
|
|
393
414
|
if (
|
|
394
415
|
value.logline === logline &&
|
|
395
416
|
value.targetEighths === targetEighths &&
|
|
@@ -398,7 +419,8 @@ export function normalizeState(value) {
|
|
|
398
419
|
!patched &&
|
|
399
420
|
value.lock === lock &&
|
|
400
421
|
value.revision === revision &&
|
|
401
|
-
!leftPatched
|
|
422
|
+
!leftPatched &&
|
|
423
|
+
!threadsPatched
|
|
402
424
|
) {
|
|
403
425
|
return value;
|
|
404
426
|
}
|
|
@@ -412,6 +434,7 @@ export function normalizeState(value) {
|
|
|
412
434
|
lock,
|
|
413
435
|
revision,
|
|
414
436
|
left,
|
|
437
|
+
threads,
|
|
415
438
|
};
|
|
416
439
|
}
|
|
417
440
|
|
|
@@ -435,6 +458,11 @@ function cleanOpen(value) {
|
|
|
435
458
|
return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
|
|
436
459
|
}
|
|
437
460
|
|
|
461
|
+
/** A thread's name as the writer typed it, one line, spaces collapsed (R60). */
|
|
462
|
+
function cleanThreadName(value) {
|
|
463
|
+
return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
|
|
464
|
+
}
|
|
465
|
+
|
|
438
466
|
/** A when as the writer typed it, one line, spaces collapsed. */
|
|
439
467
|
function cleanWhen(value) {
|
|
440
468
|
return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
|
|
@@ -631,10 +659,19 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
631
659
|
return { ...group, noteIds };
|
|
632
660
|
}),
|
|
633
661
|
);
|
|
662
|
+
// A thread through the card keeps its name and loses the card (R60);
|
|
663
|
+
// the thread stays, with one card fewer, so the writer can retie it.
|
|
664
|
+
const threadsLeft = [];
|
|
665
|
+
const threads = (state.threads ?? []).map((thread) => {
|
|
666
|
+
if (!thread.noteIds.includes(command.id)) return thread;
|
|
667
|
+
const noteIds = thread.noteIds.filter((id) => id !== command.id);
|
|
668
|
+
threadsLeft.push({ id: thread.id, name: thread.name, remaining: noteIds.length });
|
|
669
|
+
return { ...thread, noteIds };
|
|
670
|
+
});
|
|
634
671
|
// The result says what went with the card, so a door can say it too
|
|
635
672
|
// (round thirteen, entry 17: "Deleted card." and nothing of the arrows).
|
|
636
673
|
return {
|
|
637
|
-
state: { ...state, notes, arrows, groups },
|
|
674
|
+
state: { ...state, notes, arrows, groups, threads },
|
|
638
675
|
changed: true,
|
|
639
676
|
result: {
|
|
640
677
|
id: command.id,
|
|
@@ -647,6 +684,7 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
647
684
|
arrows: taken.map((arrow) => ({ ...arrow, fromHeadline: headlineOf(arrow.from), toHeadline: headlineOf(arrow.to) })),
|
|
648
685
|
joined: joined ? { ...joined, fromHeadline: headlineOf(joined.from), toHeadline: headlineOf(joined.to) } : null,
|
|
649
686
|
groups: left,
|
|
687
|
+
threads: threadsLeft,
|
|
650
688
|
},
|
|
651
689
|
};
|
|
652
690
|
}
|
|
@@ -699,6 +737,50 @@ export function applyCommand(state, command, now = nowIso()) {
|
|
|
699
737
|
return { state: { ...state, groups }, changed: true };
|
|
700
738
|
}
|
|
701
739
|
|
|
740
|
+
// A thread (R60): a named string through cards, either end open until the
|
|
741
|
+
// writer ties it. A record of its own beside the fold and the setup arrow;
|
|
742
|
+
// the reading asks about each loose end from that end.
|
|
743
|
+
case "create_thread": {
|
|
744
|
+
const name = cleanThreadName(command.name);
|
|
745
|
+
if (!name) return { state, changed: false };
|
|
746
|
+
const noteIds = (command.noteIds ?? []).filter((id, index, all) => all.indexOf(id) === index && state.notes.some((note) => note.id === id));
|
|
747
|
+
const thread = {
|
|
748
|
+
id: typeof command.id === "string" && command.id && !(state.threads ?? []).some((item) => item.id === command.id) ? command.id : newId(),
|
|
749
|
+
name,
|
|
750
|
+
noteIds,
|
|
751
|
+
startOpen: command.startOpen === true,
|
|
752
|
+
endOpen: command.endOpen === true,
|
|
753
|
+
};
|
|
754
|
+
return { state: { ...state, threads: [...(state.threads ?? []), thread] }, changed: true, result: thread };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
case "update_thread": {
|
|
758
|
+
const current = (state.threads ?? []).find((thread) => thread.id === command.id);
|
|
759
|
+
if (!current) return { state, changed: false };
|
|
760
|
+
const exists = (id) => state.notes.some((note) => note.id === id);
|
|
761
|
+
let noteIds = Array.isArray(command.noteIds) ? command.noteIds.filter((id, index, all) => all.indexOf(id) === index && exists(id)) : [...current.noteIds];
|
|
762
|
+
if (Array.isArray(command.add)) for (const id of command.add) if (exists(id) && !noteIds.includes(id)) noteIds.push(id);
|
|
763
|
+
if (Array.isArray(command.remove)) noteIds = noteIds.filter((id) => !command.remove.includes(id));
|
|
764
|
+
const name = command.name === undefined ? current.name : cleanThreadName(command.name) || current.name;
|
|
765
|
+
const startOpen = typeof command.startOpen === "boolean" ? command.startOpen : current.startOpen;
|
|
766
|
+
const endOpen = typeof command.endOpen === "boolean" ? command.endOpen : current.endOpen;
|
|
767
|
+
if (name === current.name && startOpen === current.startOpen && endOpen === current.endOpen && sameIds(noteIds, current.noteIds) && noteIds.length === current.noteIds.length) {
|
|
768
|
+
return { state, changed: false };
|
|
769
|
+
}
|
|
770
|
+
const next = { ...current, name, noteIds, startOpen, endOpen };
|
|
771
|
+
return {
|
|
772
|
+
state: { ...state, threads: state.threads.map((thread) => (thread.id === command.id ? next : thread)) },
|
|
773
|
+
changed: true,
|
|
774
|
+
result: { thread: next, before: current },
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
case "delete_thread": {
|
|
779
|
+
const gone = (state.threads ?? []).find((thread) => thread.id === command.id);
|
|
780
|
+
if (!gone) return { state, changed: false };
|
|
781
|
+
return { state: { ...state, threads: state.threads.filter((thread) => thread.id !== command.id) }, changed: true, result: gone };
|
|
782
|
+
}
|
|
783
|
+
|
|
702
784
|
case "create_group": {
|
|
703
785
|
const noteIds = command.noteIds.filter((id) =>
|
|
704
786
|
state.notes.some((note) => note.id === id),
|
package/src/board/words.js
CHANGED
|
@@ -68,6 +68,11 @@ export const WORD_GROUPS = [
|
|
|
68
68
|
name: "Open",
|
|
69
69
|
sentence: "A card the writer has not decided, in their words on its edge: the reading lists it and asks nothing else of it until the words are cleared.",
|
|
70
70
|
},
|
|
71
|
+
{
|
|
72
|
+
id: "thread",
|
|
73
|
+
name: "A thread",
|
|
74
|
+
sentence: "A named string through the cards a thing runs through — the letter, the key, a subplot — with either end open until the writer ties it. Drawn on the wall as a dashed string, a ring where an end is loose; the reading asks where a loose thread is first seen, or where it comes out.",
|
|
75
|
+
},
|
|
71
76
|
{
|
|
72
77
|
id: "corner",
|
|
73
78
|
name: "The folded corner",
|
package/src/board/workflows.js
CHANGED
|
@@ -15,7 +15,7 @@ export const WORKFLOWS = [
|
|
|
15
15
|
id: "break-a-treatment",
|
|
16
16
|
name: "Break a treatment into a wall",
|
|
17
17
|
ask: "Here is a treatment. Break it into a wall: one card per scene with a headline and what changes, the cast on each card, the places, and the major turns marked as beats.",
|
|
18
|
-
tools: ["list_words", "read_wall", "list_reminders", "list_board", "new_project", "new_board", "rename_project", "rename_board", "set_target", "set_logline", "set_premise", "create_note", "add_character", "cast", "update_character", "set_location", "set_when", "set_rank", "set_length", "set_plant", "create_arrow", "create_group", "organize"],
|
|
18
|
+
tools: ["list_words", "read_wall", "list_reminders", "list_board", "new_project", "new_board", "rename_project", "rename_board", "set_target", "set_logline", "set_premise", "create_note", "add_character", "cast", "update_character", "set_location", "set_when", "set_rank", "set_length", "set_plant", "create_thread", "create_arrow", "create_group", "organize"],
|
|
19
19
|
then: "Start where the wall will live: on the account, new_project names it; in a folder, new_board for the writer's wall, then rename_project. Read the wall (read_wall) and say what it asks. A treatment is cards, one create_note each, with characters, location, rank and plants on the call; import_fountain is the door for pages, not a treatment — a scene's text measures its card.",
|
|
20
20
|
// What a treatment should say (R49): eleven blind runs asked the writer
|
|
21
21
|
// the same questions at the end of every build. Each is a fact the wall
|
|
@@ -25,12 +25,12 @@ export const WORKFLOWS = [
|
|
|
25
25
|
needs: [
|
|
26
26
|
{ question: "How long is it?", hint: "An hour, a half-hour, a feature — or a page count, if you have one.", tool: "set_target" },
|
|
27
27
|
{ question: "What is the central question, in one sentence?", hint: "And if this is one episode of something, what is the series about?", tool: "set_logline, set_premise" },
|
|
28
|
-
{ question: "Which scenes are the turns?", hint: "Name them,
|
|
28
|
+
{ question: "Which scenes are the turns?", hint: "Name them, say \"propose them and I will strike\", or say \"mark none yet\" — every card stays a scene and the reading asks once for a beat until you do.", tool: "set_rank" },
|
|
29
29
|
{ question: "Does it have acts?", hint: "If so, where does each break fall?", tool: "create_group" },
|
|
30
30
|
{ question: "Where does each scene happen?", hint: "In your own words. A scene that moves through one location is still one place.", tool: "set_location" },
|
|
31
31
|
{ question: "When does a scene happen, where that matters?", hint: "That night; the fourth of October. It goes beside the place, never in the headline.", tool: "set_when" },
|
|
32
32
|
{ question: "Who is in each scene, and what do we call them?", hint: "A full name, or a role for someone unnamed — the man in 42. And who is only spoken of, never in a scene? They go in someone's notes, not the cast.", tool: "add_character, cast, update_character" },
|
|
33
|
-
{ question: "What is planted, and where does it pay off?", hint: "Name the episode when it pays off outside this one, so the fold is deliberate and the wall knows where to look.", tool: "set_plant with later, create_arrow" },
|
|
33
|
+
{ question: "What is planted, and where does it pay off?", hint: "Name the episode when it pays off outside this one, so the fold is deliberate and the wall knows where to look. A thing whose far end you know and not its first sighting — the key, the bucket — is a thread with an open start.", tool: "set_plant with later, create_arrow; create_thread" },
|
|
34
34
|
{ question: "Which scenes do you already know run long or short?", hint: "A day in the story is not a page count; leave the rest unsized.", tool: "set_length" },
|
|
35
35
|
{ question: "What are the project and the board called?", hint: "The series, and this episode.", tool: "rename_project, rename_board" },
|
|
36
36
|
{ question: "What must not be invented?", hint: "Looks and voices are yours until you say; so is anything the treatment does not state.", tool: "update_character, later" },
|