plotcoder-board 0.1.16 → 0.1.18
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 +6 -3
- package/package.json +1 -1
- package/scripts/plotcoder-mcp-server.mjs +254 -84
- package/src/board/agents.js +4 -4
- package/src/board/fdx.js +29 -16
- package/src/board/fountain.d.ts +2 -0
- package/src/board/fountain.js +24 -6
- package/src/board/markdown.d.ts +2 -1
- package/src/board/markdown.js +32 -14
- package/src/board/numbering.d.ts +8 -1
- package/src/board/numbering.js +36 -3
- package/src/board/organize.js +3 -30
- package/src/board/readWall.d.ts +3 -1
- package/src/board/readWall.js +69 -9
- package/src/board/reducer.d.ts +8 -2
- package/src/board/reducer.js +52 -7
- package/src/board/words.js +1 -1
|
@@ -45,9 +45,9 @@ import { toMarkdown, toPlainText } from "../src/board/markdown.js";
|
|
|
45
45
|
import { fromProjectFile, toProjectFile } from "../src/board/projectFile.js";
|
|
46
46
|
import { describeSetAside, fromFdx, toFdx } from "../src/board/fdx.js";
|
|
47
47
|
import { paginate } from "../src/board/paginate.js";
|
|
48
|
-
import { readingOrder } from "../src/board/readWall.js";
|
|
49
|
-
import { REVISION_COLORS, sceneNumbers } from "../src/board/numbering.js";
|
|
50
|
-
import { sceneHeading } from "../src/board/fountain.js";
|
|
48
|
+
import { readingOrder, storyOrder } from "../src/board/readWall.js";
|
|
49
|
+
import { REVISION_COLORS, revisionMarks, sceneNumbers } from "../src/board/numbering.js";
|
|
50
|
+
import { sceneHeading, standInFor } from "../src/board/fountain.js";
|
|
51
51
|
import { segmentBrief, WORKFLOWS } from "../src/board/workflows.js";
|
|
52
52
|
import { DEFAULT_REMINDERS, titleFromBody } from "../src/board/reminders.js";
|
|
53
53
|
import crypto from "node:crypto";
|
|
@@ -762,6 +762,46 @@ const undone = [];
|
|
|
762
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
763
|
let lastReading = null;
|
|
764
764
|
const sinceRead = [];
|
|
765
|
+
/** What the last write did to the wall's questions and runtime, said once on that write's tail (round fourteen, entries 18, 19, 42). */
|
|
766
|
+
let lastChange = null;
|
|
767
|
+
const findingKey = (finding) => `${finding.kind}|${finding.ids.join(",")}|${finding.text}`;
|
|
768
|
+
function noteChange(before, after) {
|
|
769
|
+
const was = readWall(before);
|
|
770
|
+
const now = readWall(after);
|
|
771
|
+
const wasKeys = new Set(was.findings.map(findingKey));
|
|
772
|
+
const nowKeys = new Set(now.findings.map(findingKey));
|
|
773
|
+
lastChange = {
|
|
774
|
+
gone: was.findings.filter((finding) => !nowKeys.has(findingKey(finding))),
|
|
775
|
+
came: now.findings.filter((finding) => !wasKeys.has(findingKey(finding))),
|
|
776
|
+
asks: now.findings.length,
|
|
777
|
+
leftBefore: was.left.length,
|
|
778
|
+
leftAfter: now.left.length,
|
|
779
|
+
eighthsBefore: boardEighths(before),
|
|
780
|
+
eighthsAfter: boardEighths(after),
|
|
781
|
+
target: after.targetEighths,
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
function changeNote() {
|
|
785
|
+
const change = lastChange;
|
|
786
|
+
lastChange = null;
|
|
787
|
+
if (!change) return "";
|
|
788
|
+
const parts = [];
|
|
789
|
+
if (change.gone.length || change.came.length) {
|
|
790
|
+
parts.push(
|
|
791
|
+
`the wall now asks ${change.asks} question${change.asks === 1 ? "" : "s"}${change.gone.length ? ` (gone: ${change.gone.map((finding) => `[${finding.kind}]`).join(" ")})` : ""}${change.came.length ? ` (new: ${change.came.map((finding) => `[${finding.kind}] ${finding.text}`).join(" ")})` : ""}`,
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
if (change.leftAfter !== change.leftBefore) parts.push(`left, for now: ${change.leftAfter} (was ${change.leftBefore})`);
|
|
795
|
+
if (change.eighthsAfter !== change.eighthsBefore) parts.push(`runtime now about ${formatPages(change.eighthsAfter)} of ${formatPages(change.target)} pages`);
|
|
796
|
+
return parts.length ? ` ${parts.join("; ")}.` : "";
|
|
797
|
+
}
|
|
798
|
+
/** Said once per session, so a reply does not repeat its advice eighteen times (round thirteen, entry 10). */
|
|
799
|
+
const saidOnce = new Set();
|
|
800
|
+
function once(key, text) {
|
|
801
|
+
if (saidOnce.has(key)) return "";
|
|
802
|
+
saidOnce.add(key);
|
|
803
|
+
return text;
|
|
804
|
+
}
|
|
765
805
|
|
|
766
806
|
function describeCommand(command) {
|
|
767
807
|
switch (command.type) {
|
|
@@ -794,6 +834,7 @@ function canon(state) {
|
|
|
794
834
|
}
|
|
795
835
|
|
|
796
836
|
async function commit(command) {
|
|
837
|
+
lastChange = null;
|
|
797
838
|
const { state, rev, base, boardId } = await readBoard();
|
|
798
839
|
const { state: next, changed, result } = applyCommand(state, command);
|
|
799
840
|
// `changed` is passed back so a tool can tell the agent that nothing
|
|
@@ -804,6 +845,7 @@ async function commit(command) {
|
|
|
804
845
|
const live = await writeBoard(next, rev, base, boardId, "exact");
|
|
805
846
|
trail.push({ before: state, after: canon(next), what: describeCommand(command) });
|
|
806
847
|
sinceRead.push(describeCommand(command));
|
|
848
|
+
noteChange(state, next);
|
|
807
849
|
if (trail.length > TRAIL_CAP) trail.shift();
|
|
808
850
|
undone.length = 0;
|
|
809
851
|
return { state: next, changed, result, live };
|
|
@@ -818,6 +860,7 @@ async function commit(command) {
|
|
|
818
860
|
* cast off an agent's new card and left the card (R33).
|
|
819
861
|
*/
|
|
820
862
|
async function commitAll(what, build) {
|
|
863
|
+
lastChange = null;
|
|
821
864
|
const { state, rev, base, boardId } = await readBoard();
|
|
822
865
|
let current = state;
|
|
823
866
|
let changed = false;
|
|
@@ -834,6 +877,7 @@ async function commitAll(what, build) {
|
|
|
834
877
|
const live = await writeBoard(current, rev, base, boardId, "exact");
|
|
835
878
|
trail.push({ before: state, after: canon(current), what });
|
|
836
879
|
sinceRead.push(what);
|
|
880
|
+
noteChange(state, current);
|
|
837
881
|
if (trail.length > TRAIL_CAP) trail.shift();
|
|
838
882
|
undone.length = 0;
|
|
839
883
|
return { state: current, changed: true, value, live };
|
|
@@ -842,7 +886,7 @@ async function commitAll(what, build) {
|
|
|
842
886
|
/** Said once per session: that cards stack until organize (round seven, finding 11). */
|
|
843
887
|
/** Where a new card lands when the agent gives no position: after the last card in reading order, wrapping five wide, so cards never stack (round eleven, finding 14). */
|
|
844
888
|
function nextPlace(state) {
|
|
845
|
-
const order =
|
|
889
|
+
const order = storyOrder(state);
|
|
846
890
|
const last = order[order.length - 1];
|
|
847
891
|
if (!last) return { x: 140, y: 140 };
|
|
848
892
|
const originX = Math.min(...state.notes.map((note) => note.x));
|
|
@@ -854,7 +898,7 @@ function nextPlace(state) {
|
|
|
854
898
|
/** Which door a read came through, for the head of a reply: the account as whom, the open app, or the file at which path. */
|
|
855
899
|
function door(live, base = null) {
|
|
856
900
|
if (live === ACCOUNT) {
|
|
857
|
-
const others = accountDoor.projectCount > 1 ? `, ${accountDoor.projectCount} projects on the account — list_projects for the others` : "";
|
|
901
|
+
const others = accountDoor.projectCount > 1 ? `, ${accountDoor.projectCount} projects on the account — list_projects for the others` : ", the only project on the account";
|
|
858
902
|
return `the account, as ${accountDoor.email}, working "${accountDoor.projectName}"${others}`;
|
|
859
903
|
}
|
|
860
904
|
return live ? `the open app at ${base ?? "localhost"}` : `the file at ${BOARD_FILE}; no app running`;
|
|
@@ -869,8 +913,8 @@ function workingProject(id, name, count) {
|
|
|
869
913
|
|
|
870
914
|
/** Where a change landed, for the tail of a tool's reply. */
|
|
871
915
|
function where(live) {
|
|
872
|
-
|
|
873
|
-
return
|
|
916
|
+
const tail = live === ACCOUNT ? " (saved to the account; live on every open wall)" : live ? " (visible on the open board)" : " (written to file; the wall shows it the next time the app runs from this folder)";
|
|
917
|
+
return `${tail}${changeNote()}`;
|
|
874
918
|
}
|
|
875
919
|
|
|
876
920
|
/** The wall PlotCoder starts with — Maya, Tom, the letter — and nothing of the writer's yet. */
|
|
@@ -900,7 +944,7 @@ const SAMPLE_NOTE = "sample: this is the wall PlotCoder starts with (Maya, Tom,
|
|
|
900
944
|
|
|
901
945
|
function summarize(state) {
|
|
902
946
|
const nameOf = new Map(state.characters.map((character) => [character.id, character.name]));
|
|
903
|
-
const notes = state
|
|
947
|
+
const notes = storyOrder(state)
|
|
904
948
|
.map((note) => {
|
|
905
949
|
const cast = note.characterIds.map((id) => nameOf.get(id) ?? id);
|
|
906
950
|
const who = cast.length ? `, cast: ${cast.join(", ")}` : "";
|
|
@@ -908,9 +952,10 @@ function summarize(state) {
|
|
|
908
952
|
const snap = state.revision?.snapshot?.[note.id];
|
|
909
953
|
const revised = snap && (snap.headline !== note.headline || snap.change !== note.change || (snap.text ?? "") !== (note.text ?? "") || (snap.location ?? "") !== (note.location ?? "")) ? `, changed in ${state.revision.color}` : "";
|
|
910
954
|
const place = note.location ? `, at: ${note.location}` : "";
|
|
955
|
+
const when = note.when ? `, when: ${note.when}` : "";
|
|
911
956
|
const count = formatPages(noteEighths(note));
|
|
912
957
|
const pages = isMeasured(note) ? `${count} ${count === "1" ? "page" : "pages"}, written` : note.lengthEighths === null ? "about a page, unsized" : `${count} ${count === "1" ? "page" : "pages"}`;
|
|
913
|
-
return ` - ${note.id} [${note.rank ?? "scene"}, ${pages}${who}${place}${plant}${revised}] — "${note.headline}" (${note.color}) at ${Math.round(note.x)},${Math.round(note.y)}`;
|
|
958
|
+
return ` - ${note.id} [${note.rank ?? "scene"}, ${pages}${who}${place}${when}${plant}${revised}] — "${note.headline}" (${note.color}) at ${Math.round(note.x)},${Math.round(note.y)}`;
|
|
914
959
|
})
|
|
915
960
|
.join("\n");
|
|
916
961
|
const cast = state.characters
|
|
@@ -938,13 +983,13 @@ function summarize(state) {
|
|
|
938
983
|
|
|
939
984
|
// Groups and arrows are listed with their own ids, not just counted. An agent
|
|
940
985
|
// cannot ungroup, rename, or delete an arrow it has never been told the id of.
|
|
986
|
+
const storyIndex = new Map(storyOrder(state).map((note, index) => [note.id, index]));
|
|
941
987
|
const groups = state.groups
|
|
942
988
|
.map(
|
|
943
989
|
(group) =>
|
|
944
|
-
` - ${group.id} — "${group.title}" holds ${group.noteIds.length}: ${group.noteIds.join(", ")}`,
|
|
990
|
+
` - ${group.id} — "${group.title}" holds ${group.noteIds.length}, in story order: ${[...group.noteIds].sort((a, b) => (storyIndex.get(a) ?? Infinity) - (storyIndex.get(b) ?? Infinity)).join(", ")}`,
|
|
945
991
|
)
|
|
946
992
|
.join("\n");
|
|
947
|
-
const storyIndex = new Map(readingOrder(state.notes).map((note, index) => [note.id, index]));
|
|
948
993
|
const arrows = [...state.arrows]
|
|
949
994
|
.sort((a, b) => (storyIndex.get(a.from) ?? Infinity) - (storyIndex.get(b.from) ?? Infinity) || (a.kind === "setup") - (b.kind === "setup"))
|
|
950
995
|
.map(
|
|
@@ -955,28 +1000,40 @@ function summarize(state) {
|
|
|
955
1000
|
|
|
956
1001
|
// No blank lines: ok() uses the first blank line to separate prose from the
|
|
957
1002
|
// JSON payload, so one in here would swallow the payload.
|
|
958
|
-
const numbers = state.lock ? sceneNumbers(
|
|
1003
|
+
const numbers = state.lock ? sceneNumbers(storyOrder(state), state.lock) : null;
|
|
959
1004
|
const production = [
|
|
960
1005
|
`numbers: ${state.lock ? `locked ${String(state.lock.at).slice(0, 10)} — ${[...numbers.entries()].map(([id, n]) => `${n}:${id}`).join(" ")}` : "follow the wall's order"}`,
|
|
961
1006
|
`revision: ${state.revision ? `"${state.revision.name}" in ${state.revision.color} since ${String(state.revision.since).slice(0, 10)}` : "none"}`,
|
|
962
1007
|
];
|
|
963
1008
|
const runtime = boardEighths(state);
|
|
964
1009
|
const over = runtime - state.targetEighths;
|
|
1010
|
+
// The wall as rows, the nearest thing to a look at it without the app (round fourteen, entry 15).
|
|
1011
|
+
const rows = [];
|
|
1012
|
+
for (const note of readingOrder(state.notes)) {
|
|
1013
|
+
const row = rows[rows.length - 1];
|
|
1014
|
+
if (row && note.y - row.top <= NOTE_HEIGHT / 2) row.notes.push(note);
|
|
1015
|
+
else rows.push({ top: note.y, notes: [note] });
|
|
1016
|
+
}
|
|
1017
|
+
const rowLines = rows.map((row, index) => ` ${index + 1}: ${row.notes.map((note) => `${note.rank === "beat" ? "★ " : ""}"${note.headline}"`).join(" · ")}`);
|
|
1018
|
+
const leftCount = (state.left ?? []).length;
|
|
965
1019
|
return [
|
|
966
1020
|
...(isSampleWall(state) ? [SAMPLE_NOTE] : []),
|
|
967
1021
|
`logline: ${state.logline ? `"${state.logline}"` : "(not set)"}`,
|
|
968
1022
|
...production,
|
|
1023
|
+
`left, for now: ${leftCount ? `${leftCount} question(s) the writer left; read_wall lists them` : "none"}`,
|
|
969
1024
|
`beats: ${beats}, scenes: ${scenes}`,
|
|
970
1025
|
state.targetEighths === DEFAULT_TARGET_EIGHTHS
|
|
971
1026
|
? `runtime: about ${formatPages(runtime)} pages (an estimate from the cards; a page runs about a minute); no target set — set_target for a pilot (60) or a half-hour (30); against the feature default of 120 it would be ${formatPages(-over)} under`
|
|
972
1027
|
: `runtime: about ${formatPages(runtime)} pages of a ${formatPages(state.targetEighths)}-page target — ${over > 0 ? `${formatPages(over)} over` : over < 0 ? `${formatPages(-over)} under` : "on it"} (an estimate from the cards; a page runs about a minute)`,
|
|
973
1028
|
`notes: ${state.notes.length}, groups: ${state.groups.length}, arrows: ${state.arrows.length}, cast: ${state.characters.length}`,
|
|
1029
|
+
"cards (in story order — the follows arrows over the rows; each with its id):",
|
|
1030
|
+
notes || " (no cards)",
|
|
1031
|
+
"rows on the wall (top to bottom, left to right; ★ a beat):",
|
|
1032
|
+
...(rowLines.length ? rowLines : [" (no cards)"]),
|
|
974
1033
|
"cast (the project's; every board of it casts from here):",
|
|
975
1034
|
cast || " (no one yet — add_character to start the roster)",
|
|
976
1035
|
"places (each phrase is its own place, and the app relates none of them — if two are one place, set_location them the same):",
|
|
977
1036
|
places || " (no card says where it happens yet)",
|
|
978
|
-
"cards:",
|
|
979
|
-
notes || " (no cards)",
|
|
980
1037
|
"groups:",
|
|
981
1038
|
groups || " (no groups)",
|
|
982
1039
|
"arrows:",
|
|
@@ -988,7 +1045,7 @@ function summarize(state) {
|
|
|
988
1045
|
// contain a blank line of its own or the payload becomes unparseable.
|
|
989
1046
|
// PLOTCODER_JSON=0 drops the JSON tail from every reply, for an agent that
|
|
990
1047
|
// reads the sentence and wants nothing more (a blind run found 400-line replies).
|
|
991
|
-
const TEXT_ONLY = env.PLOTCODER_JSON
|
|
1048
|
+
const TEXT_ONLY = env.PLOTCODER_JSON !== "1";
|
|
992
1049
|
function ok(text, data) {
|
|
993
1050
|
const body = data === undefined || TEXT_ONLY ? text : `${text}\n\n${JSON.stringify(data, null, 2)}`;
|
|
994
1051
|
return { content: [{ type: "text", text: body }] };
|
|
@@ -1115,14 +1172,14 @@ server.registerTool(
|
|
|
1115
1172
|
: `Nothing changed: ${these} at ${args.pages} page(s) already, or not on the board (list_board for the ids).`,
|
|
1116
1173
|
);
|
|
1117
1174
|
}
|
|
1118
|
-
|
|
1175
|
+
// The runtime rides on the write's own tail (changeNote), so it is not said twice here.
|
|
1119
1176
|
if (unsizing) {
|
|
1120
1177
|
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"
|
|
1178
|
+
`${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".`,
|
|
1122
1179
|
result,
|
|
1123
1180
|
);
|
|
1124
1181
|
}
|
|
1125
|
-
return ok(`${result.length} card(s) now run ${args.pages} page(s), the writer's estimate${where(live)}
|
|
1182
|
+
return ok(`${result.length} card(s) now run ${args.pages} page(s), the writer's estimate${where(live)}.`, result);
|
|
1126
1183
|
},
|
|
1127
1184
|
);
|
|
1128
1185
|
|
|
@@ -1161,6 +1218,7 @@ server.registerTool(
|
|
|
1161
1218
|
pages: pagesSchema.optional(),
|
|
1162
1219
|
plants: z.boolean().optional(),
|
|
1163
1220
|
location: z.string().optional(),
|
|
1221
|
+
when: z.string().optional().describe('When the scene happens, as the writer says it — "night", "day four, dawn" — printed after the place on the scene heading.'),
|
|
1164
1222
|
characters: z.array(z.string().min(1)).optional(),
|
|
1165
1223
|
x: z.number().optional(),
|
|
1166
1224
|
y: z.number().optional(),
|
|
@@ -1172,7 +1230,7 @@ server.registerTool(
|
|
|
1172
1230
|
const added = [];
|
|
1173
1231
|
// The card, anyone new in its cast, and the casting land as one change, so
|
|
1174
1232
|
// one ⌘Z on the wall takes back the whole call and not just the cast.
|
|
1175
|
-
const { value: result, live } = await commitAll(`create_note "${args.headline}"`, (step, current) => {
|
|
1233
|
+
const { value: result, live, state: after } = await commitAll(`create_note "${args.headline}"`, (step, current) => {
|
|
1176
1234
|
let made = step({
|
|
1177
1235
|
type: "create_note",
|
|
1178
1236
|
headline: args.headline,
|
|
@@ -1186,6 +1244,7 @@ server.registerTool(
|
|
|
1186
1244
|
lengthEighths: args.pages === undefined ? undefined : toEighths(args.pages),
|
|
1187
1245
|
plants: args.plants,
|
|
1188
1246
|
location: args.location,
|
|
1247
|
+
when: args.when,
|
|
1189
1248
|
x: landing.x,
|
|
1190
1249
|
y: landing.y,
|
|
1191
1250
|
}).result;
|
|
@@ -1212,12 +1271,16 @@ server.registerTool(
|
|
|
1212
1271
|
const landed = [
|
|
1213
1272
|
result?.rank === "beat" ? "a beat" : "a scene",
|
|
1214
1273
|
result?.lengthEighths === null ? "about a page (unsized: the writer's guess until set_length)" : `${formatPages(noteEighths(result))} ${formatPages(noteEighths(result)) === "1" ? "page" : "pages"}`,
|
|
1215
|
-
result?.color ? `${result.color} paper${args.color ? "" : " (pass color to choose)"}` : null,
|
|
1274
|
+
result?.color ? `${result.color} paper${args.color ? "" : once("paper", " (pass color to choose)")}` : null,
|
|
1216
1275
|
result?.plants ? "corner folded" : null,
|
|
1217
|
-
result?.location ? `at ${result.location}` :
|
|
1276
|
+
result?.location ? `at ${result.location}` : `no place yet${once("place", " (location here, or set_location)")}`,
|
|
1277
|
+
result?.when ? `when: ${result.when}` : null,
|
|
1218
1278
|
].filter(Boolean).join(", ");
|
|
1219
|
-
|
|
1220
|
-
|
|
1279
|
+
// Where it landed matters only until the tidy, so the reply says the rule once and never the coordinates (round fourteen, entry 11).
|
|
1280
|
+
const placed = args.x === undefined && args.y === undefined ? once("placed", " Placed after the last card in story order; organize lays the wall out along the arrows.") : "";
|
|
1281
|
+
// 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).
|
|
1282
|
+
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, and follows the scene if it moves).` : "";
|
|
1283
|
+
return ok(`Created card ${result?.id ?? ""}: ${landed}${where(live)}.${castLine}${placed}${numbered}`, result);
|
|
1221
1284
|
},
|
|
1222
1285
|
);
|
|
1223
1286
|
|
|
@@ -1225,25 +1288,32 @@ server.registerTool(
|
|
|
1225
1288
|
"update_note",
|
|
1226
1289
|
{
|
|
1227
1290
|
title: "Update note",
|
|
1228
|
-
description: "Change the headline, change
|
|
1291
|
+
description: "Change the headline, change line, location and/or when of an existing card by id. The reply says which field changed, from what to what.",
|
|
1229
1292
|
inputSchema: {
|
|
1230
1293
|
id: z.string(),
|
|
1231
1294
|
headline: z.string().optional(),
|
|
1232
1295
|
change: z.string().optional(),
|
|
1233
1296
|
location: z.string().optional(),
|
|
1297
|
+
when: z.string().optional(),
|
|
1234
1298
|
},
|
|
1235
1299
|
},
|
|
1236
1300
|
async (args) => {
|
|
1237
|
-
const
|
|
1301
|
+
const prior = (await readBoard()).state.notes.find((note) => note.id === args.id);
|
|
1302
|
+
const { state, result, live, changed } = await commit({
|
|
1238
1303
|
type: "update_note",
|
|
1239
1304
|
id: args.id,
|
|
1240
1305
|
headline: args.headline,
|
|
1241
1306
|
change: args.change,
|
|
1242
1307
|
location: args.location,
|
|
1308
|
+
when: args.when,
|
|
1243
1309
|
});
|
|
1244
1310
|
if (result === undefined) return ok(`No card with id ${args.id}.`);
|
|
1311
|
+
const fields = [["headline", "headline"], ["change", "change line"], ["location", "place"], ["when", "when"]]
|
|
1312
|
+
.filter(([field]) => args[field] !== undefined && (prior?.[field] ?? "") !== (result[field] ?? ""))
|
|
1313
|
+
.map(([field, word]) => `${word}: "${prior?.[field] ?? ""}" → "${result[field] ?? ""}"`);
|
|
1314
|
+
if (!changed || fields.length === 0) return ok(`Nothing changed on "${result.headline}": the card already read that way.`);
|
|
1245
1315
|
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}".` : "";
|
|
1246
|
-
return ok(`Updated "${result.headline}"
|
|
1316
|
+
return ok(`Updated "${result.headline}" — ${fields.join("; ")}${where(live)}.${mark}`, result);
|
|
1247
1317
|
},
|
|
1248
1318
|
);
|
|
1249
1319
|
|
|
@@ -1286,7 +1356,7 @@ server.registerTool(
|
|
|
1286
1356
|
{
|
|
1287
1357
|
title: "Delete note",
|
|
1288
1358
|
description:
|
|
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
|
|
1359
|
+
"Remove a card from the board. Its arrows go with it and it leaves its group; a card wired into a chain — one follows in, one out — leaves the chain joined behind it. The reply names each arrow by its cards, the join, and what the group kept; undo brings all of it back.",
|
|
1290
1360
|
inputSchema: { id: z.string() },
|
|
1291
1361
|
},
|
|
1292
1362
|
async (args) => {
|
|
@@ -1299,7 +1369,8 @@ server.registerTool(
|
|
|
1299
1369
|
const groups = result.groups
|
|
1300
1370
|
.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
1371
|
.join("");
|
|
1302
|
-
|
|
1372
|
+
const joined = result.joined ? ` The chain is joined behind it: "${result.joined.fromHeadline}" → "${result.joined.toHeadline}" (follows).` : "";
|
|
1373
|
+
return ok(`Deleted "${result.headline}"${where(live)}.${arrows}${joined}${groups}`, result);
|
|
1303
1374
|
},
|
|
1304
1375
|
);
|
|
1305
1376
|
|
|
@@ -1310,7 +1381,7 @@ server.registerTool(
|
|
|
1310
1381
|
{
|
|
1311
1382
|
title: "Read the wall",
|
|
1312
1383
|
description:
|
|
1313
|
-
"Read the board back: the beats in wall order (rows top to bottom, cards left to right), the pages of scenes between consecutive beats with the cards in each, every setup with the distance to its payoff, and the questions the wall raises — no beat marked yet; a run out of proportion with the others; beats back to back with nothing between them (a chain of them is one question); a card with a placeholder headline or no change line; a card no arrow touches; two headlines that read like the same scene; a group too long to be one sequence; a person in the cast on no card; a person gone for more than a third of the story and ten pages; a payoff before its setup on the wall; a folded card no setup arrow pays off; cards that say no place once any card has one. These are questions, not fixes: put them to the writer and do not act on them unasked. A question the writer answers with \"leave it\" is left with leave_question and listed under \"left, for now\" instead, until it would read differently. It says nothing about how many beats there should be, and neither should you. The prose carries every id; the
|
|
1384
|
+
"Read the board back: the beats in wall order (rows top to bottom, cards left to right), the pages of scenes between consecutive beats with the cards in each, every setup with the distance to its payoff, and the questions the wall raises — no beat marked yet; a run out of proportion with the others; beats back to back with nothing between them (a chain of them is one question); a card with a placeholder headline or no change line; a card no arrow touches; two headlines that read like the same scene; a group too long to be one sequence; a person in the cast on no card; a person gone for more than a third of the story and ten pages; a payoff before its setup on the wall; a folded card no setup arrow pays off; cards that say no place once any card has one. These are questions, not fixes: put them to the writer and do not act on them unasked. A question the writer answers with \"leave it\" is left with leave_question and listed under \"left, for now\" instead, until it would read differently. It says nothing about how many beats there should be, and neither should you. The prose carries every id; PLOTCODER_JSON=1 in the server's environment adds the same reading as JSON after it, for a program.",
|
|
1314
1385
|
inputSchema: {},
|
|
1315
1386
|
},
|
|
1316
1387
|
async () => {
|
|
@@ -1368,7 +1439,7 @@ server.registerTool(
|
|
|
1368
1439
|
...(reading.left.length
|
|
1369
1440
|
? [
|
|
1370
1441
|
"left, for now (the writer's word; kept until the question would read differently, and ask_again brings one back):",
|
|
1371
|
-
...reading.left.map((finding) => ` - [${finding.kind}] ${finding.text} (left ${String(finding.since).slice(0, 10)}${finding.ids.length ? `; ids: ${finding.ids.join(", ")}` : ""})`),
|
|
1442
|
+
...reading.left.map((finding) => ` - [${finding.kind}] ${finding.text} (left ${String(finding.since).slice(0, 10)}${finding.why ? `, "${finding.why}"` : ""}${finding.ids.length ? `; ids: ${finding.ids.join(", ")}` : ""})`),
|
|
1372
1443
|
]
|
|
1373
1444
|
: []),
|
|
1374
1445
|
`checks: ${CHECKS.length} run — ${(() => {
|
|
@@ -1378,7 +1449,7 @@ server.registerTool(
|
|
|
1378
1449
|
const counts = new Map();
|
|
1379
1450
|
for (const finding of asked) counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + 1);
|
|
1380
1451
|
return `asking ${asked.length} question${asked.length === 1 ? "" : "s"} of ${counts.size} kind${counts.size === 1 ? "" : "s"}: ${[...counts.entries()].map(([kind, n]) => (n > 1 ? `${kind} ×${n}` : kind)).join(", ")}${held}`;
|
|
1381
|
-
})()}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind)).map((kind) => {
|
|
1452
|
+
})()}${reading.left.length ? `; left by the writer, not clean: ${[...new Set(reading.left.map((finding) => finding.kind))].join(", ")}` : ""}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind) && !reading.left.some((finding) => finding.kind === kind)).map((kind) => {
|
|
1382
1453
|
if (kind === "unlinked" && state.arrows.length === 0) return "no card without an arrow (not asked: no arrows yet)";
|
|
1383
1454
|
if (kind === "unplaced" && !state.notes.some((note) => (note.location ?? "").trim())) return "no card without a place (not asked: no card placed yet)";
|
|
1384
1455
|
if (kind === "sequence" && state.groups.length === 0) return "no group too long for one sequence (not asked: no groups)";
|
|
@@ -1399,41 +1470,63 @@ server.registerTool(
|
|
|
1399
1470
|
{
|
|
1400
1471
|
title: "Leave a question, for now",
|
|
1401
1472
|
description:
|
|
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.",
|
|
1403
|
-
inputSchema: {
|
|
1473
|
+
"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; `why` keeps the writer's reason with it, so the next reader sees why. Several at once: `questions`, a list of {kind, ids, why}, one step. 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 reply says what the wall still asks, so no read after is needed. 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.",
|
|
1474
|
+
inputSchema: {
|
|
1475
|
+
kind: z.string().optional(),
|
|
1476
|
+
ids: z.array(z.string()).optional(),
|
|
1477
|
+
why: z.string().optional(),
|
|
1478
|
+
questions: z.array(z.object({ kind: z.string().min(1), ids: z.array(z.string()).optional(), why: z.string().optional() })).optional(),
|
|
1479
|
+
},
|
|
1404
1480
|
},
|
|
1405
1481
|
async (args) => {
|
|
1482
|
+
const wanted = args.questions?.length ? args.questions : args.kind ? [{ kind: args.kind, ids: args.ids, why: args.why }] : [];
|
|
1483
|
+
if (!wanted.length) return ok("Say which question: its kind as read_wall names it (and ids when that kind is asked more than once), or a list under questions.");
|
|
1406
1484
|
const { state } = await readBoard();
|
|
1407
1485
|
const reading = readWall(state);
|
|
1408
|
-
const
|
|
1409
|
-
const
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
const
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1486
|
+
const replies = [];
|
|
1487
|
+
const toLeave = [];
|
|
1488
|
+
for (const want of wanted) {
|
|
1489
|
+
const already = reading.left.filter((finding) => finding.kind === want.kind && (!want.ids || sameList(finding.ids, want.ids)));
|
|
1490
|
+
const matches = reading.findings.filter((finding) => finding.kind === want.kind && (!want.ids || sameList(finding.ids, want.ids)));
|
|
1491
|
+
if (matches.length === 0) {
|
|
1492
|
+
if (already.length) { replies.push(`Already left: [${want.kind}] ${already[0].text} It stays left until the question would read differently; ask_again brings it back.`); continue; }
|
|
1493
|
+
// The reading the agent was answering, when the last read_wall had this question (round thirteen, entry 19).
|
|
1494
|
+
const earlier = lastReading?.findings.find((finding) => finding.kind === want.kind && (!want.ids || sameList(finding.ids, want.ids)));
|
|
1495
|
+
const now = reading.findings.filter((finding) => finding.kind === want.kind);
|
|
1496
|
+
if (earlier) {
|
|
1497
|
+
const since = sinceRead.length
|
|
1498
|
+
? `${sinceRead.length} change${sinceRead.length === 1 ? "" : "s"} landed since (${[...new Set(sinceRead)].join(", ")})`
|
|
1499
|
+
: "the wall changed elsewhere since";
|
|
1500
|
+
const state_ = now.length
|
|
1501
|
+
? `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("; ")}`
|
|
1502
|
+
: "the wall no longer asks it — the cards answered it";
|
|
1503
|
+
replies.push(`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.`);
|
|
1504
|
+
continue;
|
|
1505
|
+
}
|
|
1506
|
+
replies.push(`The wall is not asking a question of kind "${want.kind}"${want.ids ? ` about ids ${want.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.` : ""}`);
|
|
1507
|
+
continue;
|
|
1508
|
+
}
|
|
1509
|
+
if (matches.length > 1) {
|
|
1510
|
+
replies.push(`The wall asks ${matches.length} questions of kind "${want.kind}"; pass ids to say which:\n${matches.map((finding) => ` - ${finding.text} (ids: ${finding.ids.join(", ")})`).join("\n")}`);
|
|
1511
|
+
continue;
|
|
1425
1512
|
}
|
|
1426
|
-
|
|
1513
|
+
toLeave.push({ finding: matches[0], why: (want.why ?? "").trim() });
|
|
1427
1514
|
}
|
|
1428
|
-
|
|
1429
|
-
|
|
1515
|
+
let live = null;
|
|
1516
|
+
let after = state;
|
|
1517
|
+
if (toLeave.length) {
|
|
1518
|
+
const out = await commitAll(`leave_question ×${toLeave.length}`, (step) => {
|
|
1519
|
+
for (const item of toLeave) step({ type: "leave_question", kind: item.finding.kind, ids: item.finding.ids, text: item.finding.text, why: item.why });
|
|
1520
|
+
});
|
|
1521
|
+
live = out.live;
|
|
1522
|
+
after = out.state;
|
|
1523
|
+
for (const item of toLeave) replies.push(`Left, for now: [${item.finding.kind}] ${item.finding.text}${item.why ? ` — "${item.why}"` : ""}`);
|
|
1430
1524
|
}
|
|
1431
|
-
const
|
|
1432
|
-
const
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
);
|
|
1525
|
+
const still = readWall(after).findings;
|
|
1526
|
+
const tail = toLeave.length
|
|
1527
|
+
? `${where(live)} ${once("leave-rule", "The wall keeps the writer's word and asks a left question again on its own when it would read differently; ask_again brings one back now. ")}The wall still asks ${still.length === 0 ? "nothing" : `${still.length}: ${still.map((finding) => `[${finding.kind}] ${finding.text}`).join(" ")}`}.`
|
|
1528
|
+
: "";
|
|
1529
|
+
return ok(`${replies.join("\n")}${tail}`, toLeave.map((item) => ({ kind: item.finding.kind, ids: item.finding.ids, why: item.why })));
|
|
1437
1530
|
},
|
|
1438
1531
|
);
|
|
1439
1532
|
|
|
@@ -1481,6 +1574,7 @@ server.registerTool(
|
|
|
1481
1574
|
}
|
|
1482
1575
|
let removed = 0;
|
|
1483
1576
|
let drawn = 0;
|
|
1577
|
+
let joinedGroup = null;
|
|
1484
1578
|
// The whole move — its dozen arrows and the tidy — as one change: one frame on
|
|
1485
1579
|
// the bridge, one ⌘Z on the wall, one step for undo here.
|
|
1486
1580
|
const { state: final, live } = await commitAll(`move_scene "${card.headline}"`, (step, current) => {
|
|
@@ -1512,11 +1606,20 @@ server.registerTool(
|
|
|
1512
1606
|
}
|
|
1513
1607
|
run({ type: "create_arrow", from: card.id, to: target.id, kind: "follows" });
|
|
1514
1608
|
}
|
|
1609
|
+
// Landing beside a card of an act puts the scene in that act, or the tidy
|
|
1610
|
+
// keeps the act as a block and lays the scene past it (round fourteen, entry 38).
|
|
1611
|
+
const targetGroup = current().groups.find((group) => group.noteIds.includes(target.id));
|
|
1612
|
+
if (targetGroup && !targetGroup.noteIds.includes(card.id)) {
|
|
1613
|
+
run({ type: "add_to_group", id: targetGroup.id, noteIds: [card.id] });
|
|
1614
|
+
joinedGroup = targetGroup.title || "an untitled group";
|
|
1615
|
+
}
|
|
1515
1616
|
run({ type: "apply_poses", poses: organizePoses(current(), {}) });
|
|
1516
1617
|
});
|
|
1517
|
-
const order =
|
|
1618
|
+
const order = storyOrder(final);
|
|
1518
1619
|
const group = final.groups.find((item) => item.noteIds.includes(card.id));
|
|
1519
|
-
const groupLine =
|
|
1620
|
+
const groupLine = joinedGroup
|
|
1621
|
+
? ` It joined "${joinedGroup}", the group it landed in, so the tidy keeps it with the act.`
|
|
1622
|
+
: group ? ` It is still in "${group.title || "an untitled group"}"; a frame does not follow a move, so say if the act or sequence should change.` : "";
|
|
1520
1623
|
return ok(
|
|
1521
1624
|
`Moved "${card.headline}" to ${args.after ? "after" : "before"} "${target.headline}": ${removed} follows arrow(s) removed, ${drawn} drawn, setup arrows untouched, the wall tidied along them${where(live)}. Story order now: ${order.map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}.${groupLine} One undo takes the whole move back.`,
|
|
1522
1625
|
{ order: order.map((note) => note.id) },
|
|
@@ -1656,7 +1759,7 @@ server.registerTool(
|
|
|
1656
1759
|
},
|
|
1657
1760
|
async (args) => {
|
|
1658
1761
|
const { state } = await readBoard();
|
|
1659
|
-
const order =
|
|
1762
|
+
const order = storyOrder(state).map((note) => note.id);
|
|
1660
1763
|
const beats = structureBeats(state.notes, order);
|
|
1661
1764
|
if (beats.length === 0) return ok("Nothing to save: no card on this board is marked as a beat. Mark the turns with set_rank first.");
|
|
1662
1765
|
const { project, boards, rev, base, live } = await readProject();
|
|
@@ -1710,7 +1813,7 @@ server.registerTool(
|
|
|
1710
1813
|
{
|
|
1711
1814
|
title: "Export the wall as Markdown",
|
|
1712
1815
|
description:
|
|
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
|
|
1816
|
+
"The open board as Markdown, for a collaborator who lives in Google Docs or the like: titled for the project — a one-board film is its project, and the board's name follows only when the project has several boards — the premise and the logline under it, beats as second-level headings, a third-level heading per scene from its place with its scene number, the headline as a synopsis line, then the scene's text — a speech as its cue in bold with the lines under it — or, unwritten, its change line after the mark [Unwritten] in bold, so a reader can tell a placeholder from a page. Carries the beats and every headline; does not carry the cast or the fold (Fountain's notes do). In Google Docs, Paste from Markdown keeps the headings. Pass a path to write a .md file; otherwise the text comes back.",
|
|
1714
1817
|
inputSchema: { path: z.string().optional() },
|
|
1715
1818
|
},
|
|
1716
1819
|
async (args) => {
|
|
@@ -1733,7 +1836,7 @@ server.registerTool(
|
|
|
1733
1836
|
{
|
|
1734
1837
|
title: "Export the script as plain text",
|
|
1735
1838
|
description:
|
|
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.",
|
|
1839
|
+
"The open board's script as plain text, set as it prints: the paginator's lines at Courier's columns kept with spaces, scene numbers in both margins (the wall's order, or as locked), no page numbers, an unwritten scene's change line as action after the mark [Unwritten], a revision's stars in the right margin. The script and nothing else: no headlines, no beats, no cast — the heading is the place and the when. Titled for the project, a one-board film being its project. Pastes into anything and reads as a script wherever the font is monospaced. Pass a path to write a .txt file; otherwise the text comes back.",
|
|
1737
1840
|
inputSchema: { path: z.string().optional() },
|
|
1738
1841
|
},
|
|
1739
1842
|
async (args) => {
|
|
@@ -1760,19 +1863,51 @@ server.registerTool(
|
|
|
1760
1863
|
inputSchema: { id: z.string(), text: z.string() },
|
|
1761
1864
|
},
|
|
1762
1865
|
async (args) => {
|
|
1763
|
-
const { changed, result, live } = await commit({ type: "set_text", id: args.id, text: args.text });
|
|
1866
|
+
const { state, changed, result, live } = await commit({ type: "set_text", id: args.id, text: args.text });
|
|
1764
1867
|
if (!changed) {
|
|
1765
1868
|
if (!result) return ok(`No card with id ${args.id}. Call list_board.`);
|
|
1766
1869
|
return ok(`Nothing changed: "${result.headline}" already reads that way.`);
|
|
1767
1870
|
}
|
|
1768
1871
|
const printed = sceneLineCount(args.text);
|
|
1769
1872
|
return ok(
|
|
1770
|
-
`Wrote "${result.headline}": ${printed} line(s) as they print (headings, blank lines and wrapped dialogue counted), measured at ${formatPages(noteEighths(result))} of a 55-line page, rounded to the nearest eighth and never below one${where(live)}. The heading comes from the card's place, so the text starts with the action. While the text stands the card is measured, not estimated;
|
|
1873
|
+
`Wrote "${result.headline}": ${printed} line(s) as they print (headings, blank lines and wrapped dialogue counted), measured at ${formatPages(noteEighths(result))} of a 55-line page, rounded to the nearest eighth and never below one${where(live)}.${revisionMark(state, result.id)}${once("heading-from-place", " The heading comes from the card's place and when, so the text starts with the action.")} While the text stands the card is measured, not estimated; the estimate underneath is untouched.`,
|
|
1771
1874
|
{ ...result, eighths: noteEighths(result), measured: true, printedLines: printed },
|
|
1772
1875
|
);
|
|
1773
1876
|
},
|
|
1774
1877
|
);
|
|
1775
1878
|
|
|
1879
|
+
/** " Marked changed in the blue revision: 3 line(s)." when a revision is on and the card differs from its snapshot. */
|
|
1880
|
+
function revisionMark(state, id) {
|
|
1881
|
+
const mark = revisionMarks(state).get(id);
|
|
1882
|
+
if (!state.revision || !mark?.revised) return "";
|
|
1883
|
+
return ` Marked changed in the ${state.revision.color} revision${mark.lines.size ? `: ${mark.lines.size} line(s), starred on the page and in every export` : ""}.`;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
server.registerTool(
|
|
1887
|
+
"edit_scene",
|
|
1888
|
+
{
|
|
1889
|
+
title: "Change a line of a scene",
|
|
1890
|
+
description:
|
|
1891
|
+
"Change one line of a card's scene text without resending the scene: the exact text to find, and what replaces it. The text must occur once in the scene. The card is measured again and, under a revision, the changed line is marked. For a new scene or a rewrite, write_scene.",
|
|
1892
|
+
inputSchema: { id: z.string(), find: z.string().min(1), replace: z.string() },
|
|
1893
|
+
},
|
|
1894
|
+
async (args) => {
|
|
1895
|
+
const { state: before } = await readBoard();
|
|
1896
|
+
const note = before.notes.find((item) => item.id === args.id);
|
|
1897
|
+
if (!note) return ok(`No card with id ${args.id}. Call list_board.`);
|
|
1898
|
+
const text = note.text ?? "";
|
|
1899
|
+
if (!text.trim()) return ok(`"${note.headline}" is unwritten; write_scene it first.`);
|
|
1900
|
+
const count = text.split(args.find).length - 1;
|
|
1901
|
+
if (count === 0) return ok(`"${args.find}" is not in "${note.headline}"'s text. read_pages shows the scene as it stands.`);
|
|
1902
|
+
if (count > 1) return ok(`"${args.find}" occurs ${count} times in "${note.headline}"; give more of the line so it occurs once.`);
|
|
1903
|
+
const { state, result, live } = await commit({ type: "set_text", id: note.id, text: text.replace(args.find, args.replace) });
|
|
1904
|
+
return ok(
|
|
1905
|
+
`Changed one line of "${result.headline}": "${args.find}" → "${args.replace}"${where(live)}. Now ${sceneLineCount(result.text)} line(s) as they print, measured at ${formatPages(noteEighths(result))} of a page.${revisionMark(state, result.id)}`,
|
|
1906
|
+
{ ...result, eighths: noteEighths(result), measured: true },
|
|
1907
|
+
);
|
|
1908
|
+
},
|
|
1909
|
+
);
|
|
1910
|
+
|
|
1776
1911
|
server.registerTool(
|
|
1777
1912
|
"read_pages",
|
|
1778
1913
|
{
|
|
@@ -1785,10 +1920,11 @@ server.registerTool(
|
|
|
1785
1920
|
const { state } = await readBoard();
|
|
1786
1921
|
const { project } = await readProject();
|
|
1787
1922
|
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
1788
|
-
const text = toFountain(state, {
|
|
1923
|
+
const text = toFountain(state, { ...scriptTitles(project, board), premise: project.premise || undefined });
|
|
1789
1924
|
const parsed = fromFountain(text);
|
|
1925
|
+
const marks = revisionMarks(state);
|
|
1790
1926
|
const ids = mergeFountain(state, parsed).matched.map((item) => item.id);
|
|
1791
|
-
const pageNumbers = state.lock ? sceneNumbers(
|
|
1927
|
+
const pageNumbers = state.lock ? sceneNumbers(storyOrder(state), state.lock) : null;
|
|
1792
1928
|
const lines = [];
|
|
1793
1929
|
let index = 0;
|
|
1794
1930
|
for (const line of text.split("\n")) {
|
|
@@ -1797,7 +1933,8 @@ server.registerTool(
|
|
|
1797
1933
|
index += 1;
|
|
1798
1934
|
const standIn = note && !(note.location ?? "").trim() ? " · no place: the headline stands in for the heading" : "";
|
|
1799
1935
|
const numbered = note && pageNumbers?.get(note.id) ? ` · locked no. ${pageNumbers.get(note.id)}` : "";
|
|
1800
|
-
|
|
1936
|
+
const revised = note && marks.get(note.id)?.revised ? ` · changed in the ${state.revision.color} revision` : "";
|
|
1937
|
+
lines.push(`${line} [[id: ${note?.id ?? "?"} · ${note && isMeasured(note) ? "measured" : "estimated"} ${formatPages(note ? noteEighths(note) : 0)}pp${standIn}${numbered}${revised}]]`);
|
|
1801
1938
|
} else {
|
|
1802
1939
|
lines.push(line);
|
|
1803
1940
|
}
|
|
@@ -1826,8 +1963,9 @@ server.registerTool(
|
|
|
1826
1963
|
});
|
|
1827
1964
|
const written = commands.filter((command) => command.type === "set_text").length;
|
|
1828
1965
|
const created = matched.filter((item) => item.created).length;
|
|
1966
|
+
const same = matched.length - created - written;
|
|
1829
1967
|
return ok(
|
|
1830
|
-
`Imported ${parsed.scenes.length} scene(s): ${written} written onto cards, ${created} new card(s)${where(live)}.`,
|
|
1968
|
+
`Imported ${parsed.scenes.length} scene(s): ${written} written onto cards, ${same} matched with the same text (unchanged), ${created} new card(s)${where(live)}.`,
|
|
1831
1969
|
matched,
|
|
1832
1970
|
);
|
|
1833
1971
|
},
|
|
@@ -1911,7 +2049,7 @@ server.registerTool(
|
|
|
1911
2049
|
{
|
|
1912
2050
|
title: "Import a Final Draft script",
|
|
1913
2051
|
description:
|
|
1914
|
-
"Read a Final Draft .fdx (by path) or its XML onto the open board: each scene's paragraphs become Fountain on the card with the same heading in order, a scene the wall does not have becomes a new card after the last matched one, and nothing is deleted.",
|
|
2052
|
+
"Read a Final Draft .fdx (by path) or its XML onto the open board: each scene's paragraphs become Fountain on the card with the same heading in order, a scene the wall does not have becomes a new card after the last matched one, and nothing is deleted. A scene whose text is already on its card is matched and left alone. Matching is by heading and then by order, so a file whose scenes were reordered lands each scene's text on the next card with that heading — the wall's own order does not change; a scene of the same heading in a new place is a move_scene here, not an import.",
|
|
1915
2053
|
inputSchema: { path: z.string().optional(), xml: z.string().optional() },
|
|
1916
2054
|
},
|
|
1917
2055
|
async (args) => {
|
|
@@ -1926,8 +2064,9 @@ server.registerTool(
|
|
|
1926
2064
|
});
|
|
1927
2065
|
const written = commands.filter((command) => command.type === "set_text").length;
|
|
1928
2066
|
const created = matched.filter((item) => item.created).length;
|
|
2067
|
+
const same = matched.length - created - written;
|
|
1929
2068
|
const receipt = describeSetAside(parsed.setAside);
|
|
1930
|
-
return ok(`Imported ${parsed.scenes.length} scene(s) from Final Draft: ${written} written onto cards, ${created} new card(s)${where(live)}.${receipt ? ` ${receipt}` : ""}`, matched);
|
|
2069
|
+
return ok(`Imported ${parsed.scenes.length} scene(s) from Final Draft: ${written} written onto cards, ${same} matched with the same text (unchanged), ${created} new card(s)${where(live)}.${receipt ? ` ${receipt}` : ""}`, matched);
|
|
1931
2070
|
},
|
|
1932
2071
|
);
|
|
1933
2072
|
|
|
@@ -1941,9 +2080,10 @@ server.registerTool(
|
|
|
1941
2080
|
},
|
|
1942
2081
|
async () => {
|
|
1943
2082
|
const { state } = await readBoard();
|
|
1944
|
-
const order =
|
|
2083
|
+
const order = storyOrder(state);
|
|
2084
|
+
const numbers = sceneNumbers(order, state.lock);
|
|
1945
2085
|
const result = paginate(
|
|
1946
|
-
order.map((note) => ({ id: note.id, heading: sceneHeading(note).slice(1), text: note.text, change: note
|
|
2086
|
+
order.map((note) => ({ id: note.id, heading: sceneHeading(note).slice(1), text: note.text, change: standInFor(note), written: Boolean(note.text && note.text.trim()), number: numbers.get(note.id) ?? undefined })),
|
|
1947
2087
|
);
|
|
1948
2088
|
const lines = result.scenes.map((scene) => {
|
|
1949
2089
|
const note = order.find((item) => item.id === scene.id);
|
|
@@ -1957,9 +2097,9 @@ server.registerTool(
|
|
|
1957
2097
|
);
|
|
1958
2098
|
}
|
|
1959
2099
|
const note = unwritten
|
|
1960
|
-
? [`${unwritten} of ${order.length} scenes are unwritten and
|
|
2100
|
+
? [`${unwritten} of ${order.length} scenes are unwritten and set their change line as action, marked [Unwritten], a few lines each — so this is the script so far, not the runtime: the estimate from the cards is about ${formatPages(boardEighths(state))} pages, the number to use until the scenes are written.`]
|
|
1961
2101
|
: [];
|
|
1962
|
-
return ok([`pages: ${result.pageCount} of ${Math.round(state.targetEighths / 8)}`, `scene numbers here are ${state.lock ? "the locked numbers" : "
|
|
2102
|
+
return ok([...note, `pages: ${result.pageCount} of ${Math.round(state.targetEighths / 8)}`, `scene numbers here are ${state.lock ? "the locked numbers" : "story order (not locked)"}`, ...lines].join("\n"), result.scenes);
|
|
1963
2103
|
},
|
|
1964
2104
|
);
|
|
1965
2105
|
|
|
@@ -1968,12 +2108,12 @@ server.registerTool(
|
|
|
1968
2108
|
{
|
|
1969
2109
|
title: "Lock the scene numbers",
|
|
1970
2110
|
description:
|
|
1971
|
-
"Once a draft has gone out: every scene keeps the number it has by the
|
|
2111
|
+
"Once a draft has gone out: every scene keeps the number it has by the story's order, and moving a locked scene never renumbers it. A scene added after the lock has a letter, not a number of its own — between 14 and 15 it is 14A, then 14B — and the letter is its place between locked scenes, so it follows the scene if the scene moves. Final Draft out carries the locked numbers. Ask the writer; it is a decision about the document going out.",
|
|
1972
2112
|
inputSchema: {},
|
|
1973
2113
|
},
|
|
1974
2114
|
async () => {
|
|
1975
2115
|
const { state } = await readBoard();
|
|
1976
|
-
const order =
|
|
2116
|
+
const order = storyOrder(state).map((note) => note.id);
|
|
1977
2117
|
const { changed, result, live } = await commit({ type: "lock_numbers", order });
|
|
1978
2118
|
if (!changed) return ok("Nothing to lock.");
|
|
1979
2119
|
return ok(`Locked ${Object.keys(result.numbers).length} scene number(s)${where(live)}.`, result);
|
|
@@ -1996,7 +2136,7 @@ server.registerTool(
|
|
|
1996
2136
|
{
|
|
1997
2137
|
title: "Start a revision",
|
|
1998
2138
|
description:
|
|
1999
|
-
`
|
|
2139
|
+
`Start a revision in one of the industry's colours (${REVISION_COLORS.join(", ")}), with a name or, left out, named for the colour. Every card is snapshotted; from then on a changed line is starred — on the page, in plain text's right margin, as a revision in Final Draft — a changed scene's heading is marked in Markdown and noted in Fountain, and a changed card wears the colour on the wall. write_scene and edit_scene say when they mark a card.`,
|
|
2000
2140
|
inputSchema: { name: z.string().optional(), color: z.string().optional() },
|
|
2001
2141
|
},
|
|
2002
2142
|
async (args) => {
|
|
@@ -2192,9 +2332,14 @@ server.registerTool(
|
|
|
2192
2332
|
undone.push(last);
|
|
2193
2333
|
const { boardId } = await readBoard();
|
|
2194
2334
|
const live = await writeBoard(last.before, rev, base, boardId, "exact");
|
|
2195
|
-
const orderLine = /^(move_scene|organize)/.test(last.what) ? ` Story order now: ${
|
|
2335
|
+
const orderLine = /^(move_scene|organize)/.test(last.what) ? ` Story order now: ${storyOrder(last.before).map((note, index) => `${index + 1}. ${note.headline}`).join(", ")}.` : "";
|
|
2196
2336
|
const cardsDiff = last.before.notes.length - state.notes.length;
|
|
2197
|
-
const
|
|
2337
|
+
const arrowsDiff = last.before.arrows.length - state.arrows.length;
|
|
2338
|
+
const groupsChanged = last.before.groups
|
|
2339
|
+
.filter((group) => { const now = state.groups.find((item) => item.id === group.id); return !now || now.noteIds.length !== group.noteIds.length; })
|
|
2340
|
+
.map((group) => `"${group.title}" (${group.noteIds.length} cards)`);
|
|
2341
|
+
const withIt = [arrowsDiff > 0 ? `${arrowsDiff} arrow(s) back` : arrowsDiff < 0 ? `${-arrowsDiff} arrow(s) gone` : null, groupsChanged.length ? `groups as they were: ${groupsChanged.join(", ")}` : null].filter(Boolean);
|
|
2342
|
+
const countLine = `${cardsDiff > 0 ? ` ${cardsDiff} card(s) back` : cardsDiff < 0 ? ` ${-cardsDiff} card(s) gone` : ""}${withIt.length ? `${cardsDiff ? ", with " : " "}${withIt.join("; ")}` : ""}${cardsDiff || withIt.length ? "." : ""}`;
|
|
2198
2343
|
const more = trail.length >= TRAIL_CAP ? `${trail.length} more of mine can be undone — the most I keep, so the oldest have gone` : `${trail.length} more of mine can be undone`;
|
|
2199
2344
|
return ok(`Undid ${last.what}${where(live)}.${orderLine}${countLine} ${more}. list_board has the board.`, { undid: last.what, notes: last.before.notes.length, arrows: last.before.arrows.length, groups: last.before.groups.length });
|
|
2200
2345
|
},
|
|
@@ -2223,6 +2368,30 @@ server.registerTool(
|
|
|
2223
2368
|
},
|
|
2224
2369
|
);
|
|
2225
2370
|
|
|
2371
|
+
server.registerTool(
|
|
2372
|
+
"set_when",
|
|
2373
|
+
{
|
|
2374
|
+
title: "Set when scenes happen",
|
|
2375
|
+
description:
|
|
2376
|
+
"When one or more scenes happen, as the writer would say it — \"night\", \"day four, dawn\", \"the next morning\" — on the card beside its place, and printed after the place on every scene heading: THE PIER AT FENIT - NIGHT. Free text, the writer's phrase; an empty string clears it. This is where a scene's day and time live, not the headline, so the duplicate check never reads a day as a scene's words. create_note and update_note take when too; list_board shows it as when: …",
|
|
2377
|
+
inputSchema: { ids: z.array(z.string()).min(1), when: z.string() },
|
|
2378
|
+
},
|
|
2379
|
+
async (args) => {
|
|
2380
|
+
const { state, changed, result, live } = await commit({ type: "set_when", ids: args.ids, when: args.when });
|
|
2381
|
+
if (!changed) {
|
|
2382
|
+
const missing = args.ids.filter((id) => !state.notes.some((note) => note.id === id));
|
|
2383
|
+
return ok(missing.length ? `No card with id ${missing.join(", ")}. Call list_board for the real ids.` : `Nothing changed: ${args.ids.length === 1 ? "the card already says" : "those cards already say"} "${args.when.trim()}".`);
|
|
2384
|
+
}
|
|
2385
|
+
const when = result[0]?.when ?? "";
|
|
2386
|
+
return ok(
|
|
2387
|
+
when
|
|
2388
|
+
? `${result.length} card(s) now happen ${/^(at|on|in|by|the)\b/i.test(when) ? "" : "at "}"${when}"${where(live)}. The heading prints as ${sceneHeading(result[0]).slice(1)}.`
|
|
2389
|
+
: `${result.length} card(s) no longer say when they happen${where(live)}.`,
|
|
2390
|
+
result,
|
|
2391
|
+
);
|
|
2392
|
+
},
|
|
2393
|
+
);
|
|
2394
|
+
|
|
2226
2395
|
server.registerTool(
|
|
2227
2396
|
"set_plant",
|
|
2228
2397
|
{
|
|
@@ -2346,9 +2515,9 @@ server.registerTool(
|
|
|
2346
2515
|
const wanted = key.toLowerCase();
|
|
2347
2516
|
const person = state.characters.find((item) => item.id === key) ?? state.characters.find((item) => item.name.trim().toLowerCase() === wanted);
|
|
2348
2517
|
if (!person) return ok(`Nobody called "${key}" in the cast. Call list_board for the cast, or add_character.`);
|
|
2349
|
-
const on = state.
|
|
2518
|
+
const on = storyOrder(state).filter((note) => note.characterIds.includes(person.id));
|
|
2350
2519
|
const lines = [
|
|
2351
|
-
`${person.name} (${person.id}) — on ${on.length} card${on.length === 1 ? "" : "s"}${on.length ?
|
|
2520
|
+
`${person.name} (${person.id}) — on ${on.length} card${on.length === 1 ? "" : "s"}${on.length ? `, in story order: ${on.map((note) => `"${note.headline}"`).join(", ")}` : ""}`,
|
|
2352
2521
|
...CHARACTER_FIELDS.map((field) => ` ${field}: ${(person[field] ?? "").trim() || "(empty)"}`),
|
|
2353
2522
|
];
|
|
2354
2523
|
return ok(lines.join("\n"), { ...person, cards: on.map((note) => note.id) });
|
|
@@ -2560,7 +2729,8 @@ server.registerTool(
|
|
|
2560
2729
|
const left = result.left
|
|
2561
2730
|
.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
2731
|
.join("");
|
|
2563
|
-
|
|
2732
|
+
const inOrder = storyOrder(state).filter((note) => result.group.noteIds.includes(note.id)).map((note) => `"${note.headline}"`).join(", ");
|
|
2733
|
+
return ok(`Added ${names} to "${result.group.title}", which now holds ${result.group.noteIds.length} cards, in story order: ${inOrder}${where(live)}. The frame reaches them where they are; organize lays the group out as a block.${left}`, result);
|
|
2564
2734
|
},
|
|
2565
2735
|
);
|
|
2566
2736
|
|