plotcoder-board 0.1.7 → 0.1.9
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/package.json +1 -1
- package/scripts/plotcoder-mcp-server.mjs +50 -23
- package/src/board/agents.js +4 -4
package/package.json
CHANGED
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
formatPages,
|
|
30
30
|
isBoardState,
|
|
31
31
|
isMeasured,
|
|
32
|
+
NOTE_HEIGHT,
|
|
33
|
+
NOTE_WIDTH,
|
|
32
34
|
DEFAULT_TARGET_EIGHTHS,
|
|
33
35
|
normalizeState,
|
|
34
36
|
noteEighths,
|
|
@@ -49,7 +51,7 @@ import { segmentBrief, WORKFLOWS } from "../src/board/workflows.js";
|
|
|
49
51
|
import { DEFAULT_REMINDERS, titleFromBody } from "../src/board/reminders.js";
|
|
50
52
|
import crypto from "node:crypto";
|
|
51
53
|
import { describeRuns, describeSetups, readWall } from "../src/board/readWall.js";
|
|
52
|
-
import { organizePoses } from "../src/board/organize.js";
|
|
54
|
+
import { GAP, ROW_WIDTH, organizePoses } from "../src/board/organize.js";
|
|
53
55
|
import {
|
|
54
56
|
addBoard,
|
|
55
57
|
addStructure,
|
|
@@ -737,7 +739,16 @@ async function commit(command) {
|
|
|
737
739
|
}
|
|
738
740
|
|
|
739
741
|
/** Said once per session: that cards stack until organize (round seven, finding 11). */
|
|
740
|
-
|
|
742
|
+
/** 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). */
|
|
743
|
+
function nextPlace(state) {
|
|
744
|
+
const order = readingOrder(state.notes);
|
|
745
|
+
const last = order[order.length - 1];
|
|
746
|
+
if (!last) return { x: 140, y: 140 };
|
|
747
|
+
const originX = Math.min(...state.notes.map((note) => note.x));
|
|
748
|
+
const x = last.x + NOTE_WIDTH + GAP;
|
|
749
|
+
if (x + NOTE_WIDTH > originX + ROW_WIDTH) return { x: originX, y: last.y + NOTE_HEIGHT + GAP };
|
|
750
|
+
return { x, y: last.y };
|
|
751
|
+
}
|
|
741
752
|
|
|
742
753
|
/** 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. */
|
|
743
754
|
function door(live, base = null) {
|
|
@@ -857,7 +868,7 @@ function summarize(state) {
|
|
|
857
868
|
`notes: ${state.notes.length}, groups: ${state.groups.length}, arrows: ${state.arrows.length}, cast: ${state.characters.length}`,
|
|
858
869
|
"cast:",
|
|
859
870
|
cast || " (no one yet — add_character to start the roster)",
|
|
860
|
-
"places (each
|
|
871
|
+
"places (each phrase is its own place, and the app relates none of them — if two are one place, set_location them the same):",
|
|
861
872
|
places || " (no card says where it happens yet)",
|
|
862
873
|
"cards:",
|
|
863
874
|
notes || " (no cards)",
|
|
@@ -884,15 +895,23 @@ const server = new McpServer({ name: "plotcoder-board", version: "0.1.0" });
|
|
|
884
895
|
|
|
885
896
|
// A door's answer is a reply, not an error: a shut account door, or an
|
|
886
897
|
// account with no project yet, says so in words from every tool alike.
|
|
898
|
+
// Tool calls run one at a time. Every tool reads the board, decides, and
|
|
899
|
+
// writes it back; two calls interleaving at those awaits would each read the
|
|
900
|
+
// same board and the second would write over the first — thirteen parallel
|
|
901
|
+
// create_note calls naming Nessa made thirteen Nessas in prospect (round
|
|
902
|
+
// eleven, finding 22). One lane, in the order the calls arrive.
|
|
887
903
|
const registerTool = server.registerTool.bind(server);
|
|
904
|
+
let lane = Promise.resolve();
|
|
888
905
|
server.registerTool = (name, config, handler) =>
|
|
889
|
-
registerTool(name, config,
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
906
|
+
registerTool(name, config, (...args) => {
|
|
907
|
+
const turn = lane.then(() =>
|
|
908
|
+
handler(...args).catch((error) => {
|
|
909
|
+
if (error instanceof DoorReply) return ok(error.message);
|
|
910
|
+
throw error;
|
|
911
|
+
}),
|
|
912
|
+
);
|
|
913
|
+
lane = turn.catch(() => undefined);
|
|
914
|
+
return turn;
|
|
896
915
|
});
|
|
897
916
|
|
|
898
917
|
server.registerTool(
|
|
@@ -1028,6 +1047,7 @@ server.registerTool(
|
|
|
1028
1047
|
},
|
|
1029
1048
|
},
|
|
1030
1049
|
async (args) => {
|
|
1050
|
+
const landing = args.x === undefined && args.y === undefined ? nextPlace((await readBoard()).state) : { x: args.x, y: args.y };
|
|
1031
1051
|
let { result, live } = await commit({
|
|
1032
1052
|
type: "create_note",
|
|
1033
1053
|
headline: args.headline,
|
|
@@ -1041,8 +1061,8 @@ server.registerTool(
|
|
|
1041
1061
|
lengthEighths: args.pages === undefined ? undefined : toEighths(args.pages),
|
|
1042
1062
|
plants: args.plants,
|
|
1043
1063
|
location: args.location,
|
|
1044
|
-
x:
|
|
1045
|
-
y:
|
|
1064
|
+
x: landing.x,
|
|
1065
|
+
y: landing.y,
|
|
1046
1066
|
});
|
|
1047
1067
|
let castLine = "";
|
|
1048
1068
|
if (args.characters && args.characters.length && result?.id) {
|
|
@@ -1074,8 +1094,7 @@ server.registerTool(
|
|
|
1074
1094
|
result?.plants ? "corner folded" : null,
|
|
1075
1095
|
result?.location ? `at ${result.location}` : "no place yet (location here, or set_location)",
|
|
1076
1096
|
].filter(Boolean).join(", ");
|
|
1077
|
-
const placed = args.x === undefined && args.y === undefined
|
|
1078
|
-
if (placed) saidStack = true;
|
|
1097
|
+
const placed = args.x === undefined && args.y === undefined ? ` Placed after the last card in reading order (${Math.round(landing.x)},${Math.round(landing.y)}); organize lays the wall out along the arrows.` : "";
|
|
1079
1098
|
return ok(`Created card ${result?.id ?? ""}: ${landed}${where(live)}.${castLine}${placed}`, result);
|
|
1080
1099
|
},
|
|
1081
1100
|
);
|
|
@@ -1161,7 +1180,7 @@ server.registerTool(
|
|
|
1161
1180
|
{
|
|
1162
1181
|
title: "Read the wall",
|
|
1163
1182
|
description:
|
|
1164
|
-
"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. It says nothing about how many beats there should be, and neither should you.",
|
|
1183
|
+
"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. It says nothing about how many beats there should be, and neither should you. The prose carries every id; the JSON after it is the same reading for a program, and PLOTCODER_JSON=0 in the server's environment drops it.",
|
|
1165
1184
|
inputSchema: {},
|
|
1166
1185
|
},
|
|
1167
1186
|
async () => {
|
|
@@ -1176,6 +1195,7 @@ server.registerTool(
|
|
|
1176
1195
|
const lines = [
|
|
1177
1196
|
`PlotCoder wall (${door(live, base)})`,
|
|
1178
1197
|
`logline: ${state.logline ? `"${state.logline}"` : "(none yet)"}`,
|
|
1198
|
+
"the cast and the places are list_board's, not the reading's",
|
|
1179
1199
|
state.targetEighths === DEFAULT_TARGET_EIGHTHS
|
|
1180
1200
|
? `runtime: about ${formatPages(boardEighths(state))} pages; no target set (set_target)`
|
|
1181
1201
|
: `runtime: about ${formatPages(boardEighths(state))} pages of a ${formatPages(state.targetEighths)}-page target — ${boardEighths(state) > state.targetEighths ? `${formatPages(boardEighths(state) - state.targetEighths)} over` : boardEighths(state) < state.targetEighths ? `${formatPages(state.targetEighths - boardEighths(state))} under` : "on it"}`,
|
|
@@ -1185,12 +1205,12 @@ server.registerTool(
|
|
|
1185
1205
|
.map((group) => {
|
|
1186
1206
|
const members = state.notes.filter((note) => group.noteIds.includes(note.id));
|
|
1187
1207
|
const act = /^act\b/i.test((group.title ?? "").trim());
|
|
1188
|
-
return `"${group.title || "(untitled)"}" — ${members.length} card(s), about ${formatPages(members.reduce((sum, note) => sum + noteEighths(note), 0))} pages${act ? ", read as an act
|
|
1208
|
+
return `"${group.title || "(untitled)"}" — ${members.length} card(s), about ${formatPages(members.reduce((sum, note) => sum + noteEighths(note), 0))} pages${act ? ", read as an act, so its length is not questioned" : ", read as a sequence"}`;
|
|
1189
1209
|
})
|
|
1190
1210
|
.join("; ")
|
|
1191
1211
|
: "(none)"
|
|
1192
1212
|
}`,
|
|
1193
|
-
`pages: ${written === 0 ? "all estimates — no scene is written yet, so every card is the writer's guess" : written === state.notes.length ? "measured — every scene is written" : `estimates — ${written} of ${state.notes.length} cards are written, the rest are guesses`}`,
|
|
1213
|
+
`pages: ${written === 0 ? "all estimates — no scene is written yet, so every card is the writer's guess" : written === state.notes.length ? "measured — every scene is written" : `estimates — ${written} of ${state.notes.length} cards are written${written <= 5 ? ` (${state.notes.filter((note) => isMeasured(note)).map((note) => `"${note.headline}"`).join(", ")})` : ""}, the rest are guesses`}`,
|
|
1194
1214
|
`beats in wall order: ${
|
|
1195
1215
|
reading.beats.length
|
|
1196
1216
|
? reading.beats.map((beat) => `"${beat.headline}"`).join(", ")
|
|
@@ -1206,7 +1226,13 @@ server.registerTool(
|
|
|
1206
1226
|
...(reading.findings.length
|
|
1207
1227
|
? reading.findings.map((finding) => ` - [${finding.kind}] ${finding.text}${finding.ids.length ? ` (ids: ${finding.ids.join(", ")})` : ""}`)
|
|
1208
1228
|
: [" (none that this reading can see)"]),
|
|
1209
|
-
`checks: ${CHECKS.length} run —
|
|
1229
|
+
`checks: ${CHECKS.length} run — ${(() => {
|
|
1230
|
+
const asked = reading.findings.filter((finding) => CHECKS.includes(finding.kind));
|
|
1231
|
+
if (asked.length === 0) return "asking nothing";
|
|
1232
|
+
const counts = new Map();
|
|
1233
|
+
for (const finding of asked) counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + 1);
|
|
1234
|
+
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(", ")}`;
|
|
1235
|
+
})()}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind)).map((kind) => CHECK_WORDS[kind]).join("; ") || "(nothing — every check found something)"}`,
|
|
1210
1236
|
];
|
|
1211
1237
|
if (isSampleWall(state)) lines.unshift(SAMPLE_NOTE);
|
|
1212
1238
|
return ok(lines.join("\n"), { ...reading, sample: isSampleWall(state) });
|
|
@@ -1315,7 +1341,7 @@ server.registerTool(
|
|
|
1315
1341
|
else if (note && !wrappedUnder.some((item) => item.beat === currentBeat)) wrappedUnder.push({ beat: currentBeat, first: note });
|
|
1316
1342
|
}
|
|
1317
1343
|
const shape = beats
|
|
1318
|
-
? `${opening ? `an opening row of ${opening} card(s) before the first beat, then ` : ""}${beats} row(s), one per beat${wrappedUnder.length ? `; ${wrappedUnder.map((item) => `the row of "${item.beat.headline}" wraps under from "${item.first.headline}"`).join(", ")}` : ""}`
|
|
1344
|
+
? `${opening ? `an opening row of ${opening} card(s) before the first beat, then ` : ""}${beats} row(s), one per beat${wrappedUnder.length ? `; ${wrappedUnder.map((item) => `the row of "${item.beat.headline}" wraps under from "${item.first.headline}", indented under the row's first scene, never under the beat`).join(", ")}` : ""}`
|
|
1319
1345
|
: `${rows} row(s) five cards wide — no beats yet, so nothing sets the rows; set_rank the turns and organize again for a row per beat`;
|
|
1320
1346
|
return ok(`Organized ${poses.length} card(s) along the arrows into ${shape}${where(live)}.`, poses);
|
|
1321
1347
|
},
|
|
@@ -2021,7 +2047,7 @@ server.registerTool(
|
|
|
2021
2047
|
if (!result) return ok(`No character with id ${person.id}. Call list_board for the cast.`);
|
|
2022
2048
|
return ok(`Nothing changed on ${result.name}'s page: those lines already read that way.`, result);
|
|
2023
2049
|
}
|
|
2024
|
-
const trim = (text) => (text.length >
|
|
2050
|
+
const trim = (text) => (text.length > 400 ? `${text.slice(0, 140)}… (${text.length} characters in all, every one landed)` : text);
|
|
2025
2051
|
const lines = Object.keys(patch).map((field) => {
|
|
2026
2052
|
const had = (person[field] ?? "").trim();
|
|
2027
2053
|
const now = (result[field] ?? "").trim();
|
|
@@ -2528,7 +2554,8 @@ server.registerTool(
|
|
|
2528
2554
|
workingProject(record.id, record.name, (account.projectCount ?? 0) + 1);
|
|
2529
2555
|
joinPresence(record.id);
|
|
2530
2556
|
const targetLine = target === undefined ? ` Its target is ${formatPages(state.targetEighths)} pages, the default for a feature; set_target for a pilot or a half-hour, or pass pages or minutes here.` : ` Its target is ${formatPages(state.targetEighths)} pages.`;
|
|
2531
|
-
|
|
2557
|
+
const first = record.boards[0];
|
|
2558
|
+
return ok(`Started "${record.name}" (${record.id}) with its first board "${first.name}" (${first.id}), and working it now, as ${account.email}.${targetLine}${oneCallHint(record)}`, { id: record.id, name: record.name, boardId: first.id, boardName: first.name, targetEighths: state.targetEighths });
|
|
2532
2559
|
},
|
|
2533
2560
|
);
|
|
2534
2561
|
|
|
@@ -2628,7 +2655,7 @@ server.registerTool(
|
|
|
2628
2655
|
{
|
|
2629
2656
|
title: "Save the project as a file",
|
|
2630
2657
|
description:
|
|
2631
|
-
"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); 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.",
|
|
2658
|
+
"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 server's own folder, 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.",
|
|
2632
2659
|
inputSchema: { path: z.string().optional() },
|
|
2633
2660
|
},
|
|
2634
2661
|
async (args) => {
|
|
@@ -2640,7 +2667,7 @@ server.registerTool(
|
|
|
2640
2667
|
if (args.path) {
|
|
2641
2668
|
fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
|
|
2642
2669
|
fs.writeFileSync(args.path, JSON.stringify(file, null, 2));
|
|
2643
|
-
return ok(`Saved ${what}. Written to ${args.path}: Open project in the app takes it, import_project brings it onto an account.`, { path: args.path, boards: project.boards.length, cards });
|
|
2670
|
+
return ok(`Saved ${what}. Written to ${path.resolve(args.path)}: Open project in the app takes it, import_project brings it onto an account.`, { path: path.resolve(args.path), boards: project.boards.length, cards });
|
|
2644
2671
|
}
|
|
2645
2672
|
return ok(`The project as a file — ${what}. The JSON below is the file; write it to a .json for Open project or import_project.`, file);
|
|
2646
2673
|
},
|
package/src/board/agents.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
export const AGENTS = {
|
|
9
9
|
lead:
|
|
10
|
-
"A storyline wall. Cards are scenes, beats are the big turns, arrows say what follows or pays off what. An agent driven by a person has every tool a person here has; the person directs, the agent operates. Call the tools; never fake a mouse.",
|
|
10
|
+
"A storyline wall. Cards are scenes, beats are the big turns, arrows say what follows or pays off what. An agent driven by a person has every tool a person here has; the person directs, the agent operates. Call the tools; never fake a mouse. Already have the plotcoder-board tools in front of you? Skip the doors and go to Call these first.",
|
|
11
11
|
doors: [
|
|
12
12
|
{
|
|
13
13
|
id: "mcp",
|
|
@@ -42,7 +42,7 @@ export const AGENTS = {
|
|
|
42
42
|
text: "Skip this when the account is the wall. Without an account, a wall is a folder: any folder, empty is fine — choose one that will outlive your session, never a scratch one. The app run from that folder shows the wall, and the server writes it there (PLOTCODER_ROOT, or the folder it is run from). A fresh folder holds the sample; new_board for the writer's wall, then rename_project. No app running? export_fountain is the wall in order, as text.",
|
|
43
43
|
},
|
|
44
44
|
],
|
|
45
|
-
firstNote: "These four are about the wall you will work, so after open_project or
|
|
45
|
+
firstNote: "These four are about the wall you will work, so after open_project, open_board, new_project or empty_account, read_wall again. On an account with no project yet, read_wall has nothing to read and says so, and list_reminders gives the house principles every project starts with; new_project, then the four again. No server in front of you, and no shell to take the shell door? Nothing gets you in from inside the session: say so, and ask the person to wire the server and start a new session.",
|
|
46
46
|
first: [
|
|
47
47
|
{ tool: "list_words", why: "the room's words, the app's meaning." },
|
|
48
48
|
{ tool: "read_wall", why: "what is here, and what it asks. A fresh folder holds a sample wall (Maya, Tom, the letter) and says so; it is not the writer's." },
|
|
@@ -51,7 +51,7 @@ export const AGENTS = {
|
|
|
51
51
|
],
|
|
52
52
|
rules: [
|
|
53
53
|
"Questions, not fixes, until the writer says.",
|
|
54
|
-
"No opinions about how many beats there should be.",
|
|
54
|
+
"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.",
|
|
55
55
|
"Page counts are estimates.",
|
|
56
56
|
"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.",
|
|
57
57
|
"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.",
|
|
@@ -64,7 +64,7 @@ export const AGENTS = {
|
|
|
64
64
|
|
|
65
65
|
/** The on-ramp as one text: the file at /llms.txt, and what an agent reads. */
|
|
66
66
|
export function agentsAsText() {
|
|
67
|
-
const lines = ["# PlotCoder — for agents", "", AGENTS.lead, "", `The guide, read once before anything: ${AGENTS.guide}.`, "", "## Doors"];
|
|
67
|
+
const lines = ["# PlotCoder — for agents", "", AGENTS.lead, "", `The guide, read once before anything: ${AGENTS.guide}. The guide is the whole and this page is its first page; where the two differ, the guide wins.`, "", "## Doors"];
|
|
68
68
|
for (const door of AGENTS.doors) {
|
|
69
69
|
lines.push(`- ${door.name}: ${door.text}`);
|
|
70
70
|
if (door.code) lines.push("", "```", door.code, "```", "");
|