plotcoder-board 0.1.0
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 +99 -0
- package/package.json +57 -0
- package/scripts/plotcoder-call.mjs +141 -0
- package/scripts/plotcoder-http.mjs +114 -0
- package/scripts/plotcoder-mcp-server.mjs +2663 -0
- package/scripts/plotcoder-mcp.mjs +93 -0
- package/src/board/agents.d.ts +14 -0
- package/src/board/agents.js +78 -0
- package/src/board/fdx.d.ts +26 -0
- package/src/board/fdx.js +206 -0
- package/src/board/fountain.d.ts +48 -0
- package/src/board/fountain.js +246 -0
- package/src/board/numbering.d.ts +13 -0
- package/src/board/numbering.js +89 -0
- package/src/board/organize.d.ts +24 -0
- package/src/board/organize.js +151 -0
- package/src/board/paginate.d.ts +51 -0
- package/src/board/paginate.js +375 -0
- package/src/board/project.d.ts +72 -0
- package/src/board/project.js +236 -0
- package/src/board/projectFile.d.ts +33 -0
- package/src/board/projectFile.js +97 -0
- package/src/board/readWall.d.ts +70 -0
- package/src/board/readWall.js +406 -0
- package/src/board/reducer.d.ts +191 -0
- package/src/board/reducer.js +921 -0
- package/src/board/reminders.d.ts +6 -0
- package/src/board/reminders.js +53 -0
- package/src/board/sync.d.ts +65 -0
- package/src/board/sync.js +198 -0
- package/src/board/templates.d.ts +21 -0
- package/src/board/templates.js +110 -0
- package/src/board/words.d.ts +10 -0
- package/src/board/words.js +201 -0
- package/src/board/workflows.d.ts +24 -0
- package/src/board/workflows.js +116 -0
- package/src/board/zip.d.ts +5 -0
- package/src/board/zip.js +134 -0
|
@@ -0,0 +1,2663 @@
|
|
|
1
|
+
// PlotCoder board MCP server.
|
|
2
|
+
//
|
|
3
|
+
// Exposes the board to any MCP client (e.g. a Cursor agent) so cards can be
|
|
4
|
+
// created and moved without driving the mouse. Every tool runs the same kernel
|
|
5
|
+
// reducer the app uses, then persists:
|
|
6
|
+
// - live: if the Vite dev bridge is reachable, GET the board, apply the
|
|
7
|
+
// command, and PUT it back so the open wall updates within a second.
|
|
8
|
+
// - offline: otherwise read/write .plotcoder/board.json directly, and the
|
|
9
|
+
// wall catches up the next time the app loads.
|
|
10
|
+
//
|
|
11
|
+
// Runs under plain `node` (see .cursor/mcp.json). Never write to stdout except
|
|
12
|
+
// MCP frames; diagnostics go to stderr.
|
|
13
|
+
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
19
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
20
|
+
import {
|
|
21
|
+
applyCommand,
|
|
22
|
+
ARROW_KINDS,
|
|
23
|
+
boardEighths,
|
|
24
|
+
countRanks,
|
|
25
|
+
EIGHTHS_PER_PAGE,
|
|
26
|
+
CHARACTER_FIELDS,
|
|
27
|
+
emptyState,
|
|
28
|
+
filledCharacterFields,
|
|
29
|
+
formatPages,
|
|
30
|
+
isBoardState,
|
|
31
|
+
isMeasured,
|
|
32
|
+
DEFAULT_TARGET_EIGHTHS,
|
|
33
|
+
normalizeState,
|
|
34
|
+
noteEighths,
|
|
35
|
+
NOTE_COLORS,
|
|
36
|
+
NOTE_RANKS,
|
|
37
|
+
seedState,
|
|
38
|
+
} from "../src/board/reducer.js";
|
|
39
|
+
import { TEMPLATES } from "../src/board/templates.js";
|
|
40
|
+
import { wordSentence, wordsAsText } from "../src/board/words.js";
|
|
41
|
+
import { fromFountain, mergeFountain, toFountain } from "../src/board/fountain.js";
|
|
42
|
+
import { fromProjectFile, toProjectFile } from "../src/board/projectFile.js";
|
|
43
|
+
import { describeSetAside, fromFdx, toFdx } from "../src/board/fdx.js";
|
|
44
|
+
import { paginate } from "../src/board/paginate.js";
|
|
45
|
+
import { readingOrder } from "../src/board/readWall.js";
|
|
46
|
+
import { REVISION_COLORS, sceneNumbers } from "../src/board/numbering.js";
|
|
47
|
+
import { sceneHeading } from "../src/board/fountain.js";
|
|
48
|
+
import { segmentBrief, WORKFLOWS } from "../src/board/workflows.js";
|
|
49
|
+
import { DEFAULT_REMINDERS, titleFromBody } from "../src/board/reminders.js";
|
|
50
|
+
import crypto from "node:crypto";
|
|
51
|
+
import { describeRuns, describeSetups, readWall } from "../src/board/readWall.js";
|
|
52
|
+
import { organizePoses } from "../src/board/organize.js";
|
|
53
|
+
import {
|
|
54
|
+
addBoard,
|
|
55
|
+
addStructure,
|
|
56
|
+
boardById,
|
|
57
|
+
emptyProject,
|
|
58
|
+
findBoard,
|
|
59
|
+
isProjectRecord,
|
|
60
|
+
normalizeProject,
|
|
61
|
+
removeBoard,
|
|
62
|
+
renameBoard,
|
|
63
|
+
removeStructure,
|
|
64
|
+
setActiveBoard,
|
|
65
|
+
setPremise,
|
|
66
|
+
structureBeats,
|
|
67
|
+
reidentifyProject,
|
|
68
|
+
renameProject,
|
|
69
|
+
} from "../src/board/project.js";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One PlotCoder server, with its own doors and its own trail: the stdio door
|
|
73
|
+
* makes one for the process (serveStdio), the hosted door makes one per
|
|
74
|
+
* request with the writer's sign-in from the request (plotcoder-http.mjs).
|
|
75
|
+
* Nothing lives at module level, so two writers never share a door.
|
|
76
|
+
*/
|
|
77
|
+
export function createPlotcoderServer(env = process.env) {
|
|
78
|
+
|
|
79
|
+
const colorSchema = z.enum(NOTE_COLORS);
|
|
80
|
+
const rankSchema = z.enum(NOTE_RANKS);
|
|
81
|
+
const arrowKindSchema = z.enum(ARROW_KINDS);
|
|
82
|
+
// Agents get pages, not eighths. Eighths are the storage unit; asking a
|
|
83
|
+
// model to convert is a needless chance to be wrong by a factor of eight.
|
|
84
|
+
const pagesSchema = z.number().positive();
|
|
85
|
+
const toEighths = (pages) => Math.round(pages * EIGHTHS_PER_PAGE);
|
|
86
|
+
|
|
87
|
+
function log(...args) {
|
|
88
|
+
console.error("[plotcoder-mcp]", ...args);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- Where does the board live? -------------------------------------------
|
|
92
|
+
|
|
93
|
+
function findRepoRoot() {
|
|
94
|
+
if (env.PLOTCODER_ROOT) return path.resolve(env.PLOTCODER_ROOT);
|
|
95
|
+
let dir = process.cwd();
|
|
96
|
+
for (let i = 0; i < 8; i += 1) {
|
|
97
|
+
if (
|
|
98
|
+
fs.existsSync(path.join(dir, "package.json")) ||
|
|
99
|
+
fs.existsSync(path.join(dir, ".git"))
|
|
100
|
+
) {
|
|
101
|
+
return dir;
|
|
102
|
+
}
|
|
103
|
+
const parent = path.dirname(dir);
|
|
104
|
+
if (parent === dir) break;
|
|
105
|
+
dir = parent;
|
|
106
|
+
}
|
|
107
|
+
// Not inside a checkout: the folder the server was started in is the wall's
|
|
108
|
+
// folder — never this package's own folder, which under npx is a cache.
|
|
109
|
+
try {
|
|
110
|
+
return process.cwd();
|
|
111
|
+
} catch {
|
|
112
|
+
return process.cwd();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const REPO_ROOT = findRepoRoot();
|
|
117
|
+
const BOARD_FILE = path.join(REPO_ROOT, ".plotcoder", "board.json");
|
|
118
|
+
const PROJECT_FILE = path.join(REPO_ROOT, ".plotcoder", "project.json");
|
|
119
|
+
|
|
120
|
+
// --- Live dev bridge -------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
function bridgeCandidates() {
|
|
123
|
+
const bases = [];
|
|
124
|
+
if (env.PLOTCODER_BRIDGE_URL) bases.push(env.PLOTCODER_BRIDGE_URL);
|
|
125
|
+
const hosts = ["127.0.0.1", "localhost"];
|
|
126
|
+
const ports = [5173, 5174, 5175, 5176, 5177, 4173];
|
|
127
|
+
for (const port of ports) {
|
|
128
|
+
for (const host of hosts) bases.push(`http://${host}:${port}`);
|
|
129
|
+
}
|
|
130
|
+
return bases;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let cachedBase = null;
|
|
134
|
+
|
|
135
|
+
async function probe(base) {
|
|
136
|
+
try {
|
|
137
|
+
const res = await fetch(`${base}/__plotcoder/board`, {
|
|
138
|
+
signal: AbortSignal.timeout(400),
|
|
139
|
+
});
|
|
140
|
+
if (!res.ok) return false;
|
|
141
|
+
const data = await res.json();
|
|
142
|
+
return data && typeof data === "object" && "rev" in data;
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function findBridge() {
|
|
149
|
+
if (env.PLOTCODER_NO_BRIDGE === "1") return null;
|
|
150
|
+
if (cachedBase && (await probe(cachedBase))) return cachedBase;
|
|
151
|
+
for (const base of bridgeCandidates()) {
|
|
152
|
+
if (await probe(base)) {
|
|
153
|
+
cachedBase = base;
|
|
154
|
+
return base;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
cachedBase = null;
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- The account door (Roadmap 2, item 6) -----------------------------------
|
|
162
|
+
//
|
|
163
|
+
// With PLOTCODER_EMAIL and PLOTCODER_PASSWORD in the environment — the
|
|
164
|
+
// writer's own, never a service key — the server works the writer's project
|
|
165
|
+
// on the account directly: the same rows, the same revisions, and every
|
|
166
|
+
// change landing on every open wall over Realtime. The sign-in being set is
|
|
167
|
+
// the agent saying which wall it means, so the account wins over a dev app
|
|
168
|
+
// that happens to be open on this machine (round five, finding 9: an agent
|
|
169
|
+
// worked another worktree's wall for five calls before it knew). Without the
|
|
170
|
+
// sign-in, the open app comes first, then the file. PLOTCODER_PROJECT picks
|
|
171
|
+
// the project by name or id; otherwise the most recently touched. The
|
|
172
|
+
// password is hashed here exactly as the browser does.
|
|
173
|
+
|
|
174
|
+
const ACCOUNT = "account";
|
|
175
|
+
const SUPABASE_URL = env.VITE_SUPABASE_URL ?? "https://kmpahjsggbleygsnuwug.supabase.co";
|
|
176
|
+
const SUPABASE_KEY = env.VITE_SUPABASE_KEY ?? "sb_publishable_nTTiV21Fva9zp8kvcbf6Kg_ZPOgB6Th";
|
|
177
|
+
let accountDoor = null;
|
|
178
|
+
let accountTried = false;
|
|
179
|
+
/** Why the door is shut when the writer's sign-in is set but failed. Every tool says this; none reads the file instead (round four, findings 5–7). */
|
|
180
|
+
let accountRefusal = null;
|
|
181
|
+
const NO_PROJECT_YET = "The account holds no project yet: new_project starts the writer's first.";
|
|
182
|
+
/** The same, with the folder named only when the door skipped a sample wall there (round eight, finding 9). */
|
|
183
|
+
function noProjectYet() {
|
|
184
|
+
return accountDoor?.skippedFolder ? `${NO_PROJECT_YET} The sample wall in this folder was not uploaded.` : NO_PROJECT_YET;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** A door's answer — shut, or no project yet — thrown from a read and turned into a plain reply by every tool. Not an error. */
|
|
188
|
+
class DoorReply extends Error {}
|
|
189
|
+
|
|
190
|
+
/** The hosted door (R48): one server per request, the writer's sign-in from the request, no disk. */
|
|
191
|
+
function hosted() {
|
|
192
|
+
return env.PLOTCODER_HOSTED === "1";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function accountEnv() {
|
|
196
|
+
return Boolean(env.PLOTCODER_EMAIL && env.PLOTCODER_PASSWORD);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function refusal(email, reason) {
|
|
200
|
+
return `The account door refused ${email.trim().toLowerCase()}: ${reason}. Nothing was read or written anywhere else. Check PLOTCODER_EMAIL and PLOTCODER_PASSWORD in the server's environment; no account yet? claim_account makes one, with the email and password the writer gives.`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Through plotcoder-call every call is a fresh server: what this server chose does not reach the next call unless the environment carries it. */
|
|
204
|
+
function oneCall() {
|
|
205
|
+
return env.PLOTCODER_ONE_CALL === "1";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function oneCallHint(record) {
|
|
209
|
+
return oneCall() ? ` Through plotcoder-call every call is a fresh server, so set PLOTCODER_PROJECT=${record.id} in the environment for the next calls (the name, "${record.name}", works too, while it is the only one).` : "";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
// --- The account is the writer's, from the agent's side (R45) ---------------
|
|
214
|
+
//
|
|
215
|
+
// Signed in as the writer, the agent may delete what the writer owns: a
|
|
216
|
+
// project, every project, the account itself. The database already allows
|
|
217
|
+
// the owner of a project to delete it; the files go first, while the
|
|
218
|
+
// membership the storage rules read still exists. No service key anywhere.
|
|
219
|
+
|
|
220
|
+
/** The writer's projects on the account: the ones they own, and the ones merely shared with them. */
|
|
221
|
+
async function ownedAndShared() {
|
|
222
|
+
const all = await accountProjects();
|
|
223
|
+
const me = accountDoor.user.id;
|
|
224
|
+
return { all, owned: all.filter((row) => row.owner === me), shared: all.filter((row) => row.owner !== me) };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** What deleting a project takes with it. */
|
|
228
|
+
async function deletionPlan(row) {
|
|
229
|
+
const boards = await accountDoor.client.from("boards").select("id, state").eq("project_id", row.id);
|
|
230
|
+
const cards = (boards.data ?? []).reduce((sum, board) => sum + (isBoardState(board.state) ? board.state.notes.length : 0), 0);
|
|
231
|
+
const files = await accountDoor.client.from("assets").select("id, path").eq("project_id", row.id);
|
|
232
|
+
return { id: row.id, name: row.record.name, boards: (boards.data ?? []).length, cards, files: (files.data ?? []).map((file) => file.path) };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function describePlan(plan) {
|
|
236
|
+
return `"${plan.name}" (${plan.id}): ${plan.boards} board(s), ${plan.cards} card(s), ${plan.files.length} file(s)`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Delete one project the writer owns: its files first, then the row; boards and file records cascade. */
|
|
240
|
+
async function deleteProjectRows(plan) {
|
|
241
|
+
if (plan.files.length) {
|
|
242
|
+
const removed = await accountDoor.client.storage.from("projects").remove(plan.files);
|
|
243
|
+
if (removed.error) throw new Error(`could not remove the files of "${plan.name}": ${removed.error.message}`);
|
|
244
|
+
}
|
|
245
|
+
const gone = await accountDoor.client.from("projects").delete().eq("id", plan.id).select("id");
|
|
246
|
+
if (gone.error) throw new Error(gone.error.message);
|
|
247
|
+
if (!gone.data || gone.data.length === 0) throw new Error(`"${plan.name}" was not deleted: the account did not allow it.`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** After a deletion took the working project: work the most recent one left, or nothing. */
|
|
251
|
+
async function workWhatIsLeft(deletedIds) {
|
|
252
|
+
if (!deletedIds.includes(accountDoor.projectId)) return "";
|
|
253
|
+
if (accountDoor.channel) {
|
|
254
|
+
void accountDoor.client.removeChannel(accountDoor.channel);
|
|
255
|
+
accountDoor.channel = null;
|
|
256
|
+
}
|
|
257
|
+
const projects = await accountProjects();
|
|
258
|
+
if (projects.length === 0) {
|
|
259
|
+
accountDoor.projectId = null;
|
|
260
|
+
accountDoor.projectName = null;
|
|
261
|
+
accountDoor.projectCount = 0;
|
|
262
|
+
accountDoor.skippedFolder = false;
|
|
263
|
+
return ` ${NO_PROJECT_YET}`;
|
|
264
|
+
}
|
|
265
|
+
workingProject(projects[0].id, projects[0].record.name, projects.length);
|
|
266
|
+
joinPresence(projects[0].id);
|
|
267
|
+
return ` Working "${projects[0].record.name}" now.`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function countCards(boards) {
|
|
271
|
+
return Object.values(boards).reduce((sum, state) => sum + (isBoardState(state) ? state.notes.length : 0), 0);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The reply when a tool needs the account and there is none: the refusal when the door is shut, the plain text otherwise. */
|
|
275
|
+
function shut(text) {
|
|
276
|
+
return ok(accountRefusal ?? text);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const SESSION_KEY = "sb-plotcoder-auth-token";
|
|
280
|
+
const SESSION_FILE = path.join(REPO_ROOT, ".plotcoder", "account-session.json");
|
|
281
|
+
|
|
282
|
+
/** supabase-js session storage as a file, for the one-call door: a map of keys in .plotcoder/account-session.json, mode 0600. */
|
|
283
|
+
function sessionFileStorage() {
|
|
284
|
+
const read = () => {
|
|
285
|
+
try {
|
|
286
|
+
return JSON.parse(fs.readFileSync(SESSION_FILE, "utf8"));
|
|
287
|
+
} catch {
|
|
288
|
+
return {};
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
const write = (map) => {
|
|
292
|
+
fs.mkdirSync(path.dirname(SESSION_FILE), { recursive: true });
|
|
293
|
+
fs.writeFileSync(SESSION_FILE, JSON.stringify(map), { mode: 0o600 });
|
|
294
|
+
};
|
|
295
|
+
return {
|
|
296
|
+
getItem: (key) => read()[key] ?? null,
|
|
297
|
+
setItem: (key, value) => write({ ...read(), [key]: value }),
|
|
298
|
+
removeItem: (key) => {
|
|
299
|
+
const map = read();
|
|
300
|
+
delete map[key];
|
|
301
|
+
write(map);
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function hashPassword(email, password) {
|
|
307
|
+
return crypto.createHash("sha256").update(`plotcoder\n${email.trim().toLowerCase()}\n${password}`).digest("hex");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function findAccount() {
|
|
311
|
+
const email = env.PLOTCODER_EMAIL;
|
|
312
|
+
const password = env.PLOTCODER_PASSWORD;
|
|
313
|
+
if (!email || !password) return null;
|
|
314
|
+
if (accountDoor) return accountDoor;
|
|
315
|
+
if (accountTried) return null;
|
|
316
|
+
accountTried = true;
|
|
317
|
+
try {
|
|
318
|
+
const { createClient } = await import("@supabase/supabase-js");
|
|
319
|
+
// Node before 22 has no WebSocket of its own; `ws` stands in, for Realtime presence.
|
|
320
|
+
let transport;
|
|
321
|
+
try {
|
|
322
|
+
transport = (await import("ws")).default;
|
|
323
|
+
} catch {
|
|
324
|
+
transport = undefined;
|
|
325
|
+
}
|
|
326
|
+
// Through the shell door every call is a fresh server, so the sign-in is
|
|
327
|
+
// kept in the repo's ignored .plotcoder folder between calls and only the
|
|
328
|
+
// first call signs in (round eight, finding 2). PLOTCODER_SESSION=0 keeps
|
|
329
|
+
// nothing. An MCP session signs in once anyway and keeps nothing on disk.
|
|
330
|
+
const keep = oneCall() && env.PLOTCODER_SESSION !== "0";
|
|
331
|
+
const client = createClient(SUPABASE_URL, SUPABASE_KEY, {
|
|
332
|
+
auth: keep
|
|
333
|
+
? { persistSession: true, autoRefreshToken: true, detectSessionInUrl: false, storage: sessionFileStorage() }
|
|
334
|
+
: { persistSession: false, autoRefreshToken: true },
|
|
335
|
+
...(transport ? { realtime: { transport } } : {}),
|
|
336
|
+
});
|
|
337
|
+
const wanted = email.trim().toLowerCase();
|
|
338
|
+
let user = null;
|
|
339
|
+
if (keep) {
|
|
340
|
+
const kept = await client.auth.getSession();
|
|
341
|
+
if (kept.data.session && (kept.data.session.user?.email ?? "").toLowerCase() === wanted) user = kept.data.session.user;
|
|
342
|
+
}
|
|
343
|
+
if (!user) {
|
|
344
|
+
const signedIn = await client.auth.signInWithPassword({ email: wanted, password: hashPassword(email, password) });
|
|
345
|
+
if (signedIn.error || !signedIn.data.user) {
|
|
346
|
+
accountRefusal = refusal(email, signedIn.error?.message ?? "no user came back");
|
|
347
|
+
log("account door:", accountRefusal);
|
|
348
|
+
if (keep) sessionFileStorage().removeItem(SESSION_KEY);
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
user = signedIn.data.user;
|
|
352
|
+
}
|
|
353
|
+
accountDoor = { client, user, email: wanted, projectId: null, channel: null };
|
|
354
|
+
await chooseProject(env.PLOTCODER_PROJECT ?? "");
|
|
355
|
+
return accountDoor;
|
|
356
|
+
} catch (error) {
|
|
357
|
+
accountRefusal = refusal(email, `could not reach the account service (${error instanceof Error ? error.message : String(error)})`);
|
|
358
|
+
log("account door:", accountRefusal);
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** The writer's projects, newest first. */
|
|
364
|
+
async function accountProjects() {
|
|
365
|
+
const { data, error } = await accountDoor.client.rpc("my_projects");
|
|
366
|
+
if (error) throw new Error(error.message);
|
|
367
|
+
return (data ?? []).filter((row) => isProjectRecord(row.record)).map((row) => ({ ...row, record: normalizeProject(row.record) }));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Pick the project by name or id, or the most recent; with none, make one from the file. */
|
|
371
|
+
async function chooseProject(key) {
|
|
372
|
+
const projects = await accountProjects();
|
|
373
|
+
const wanted = key.trim().toLowerCase();
|
|
374
|
+
let chosen =
|
|
375
|
+
projects.find((row) => row.id === key) ??
|
|
376
|
+
projects.find((row) => row.record.name.trim().toLowerCase() === wanted) ??
|
|
377
|
+
(wanted ? null : projects[0]);
|
|
378
|
+
if (!chosen && wanted && projects.length) {
|
|
379
|
+
log(`account door: no project called "${key}"; using the most recent`);
|
|
380
|
+
chosen = projects[0];
|
|
381
|
+
}
|
|
382
|
+
if (!chosen) {
|
|
383
|
+
// The account holds nothing: this folder's work becomes its first project —
|
|
384
|
+
// unless the folder holds only the sample wall, or nothing, which is
|
|
385
|
+
// nobody's work and never lands on an account (round four, finding 9).
|
|
386
|
+
// Then the account stays empty until new_project.
|
|
387
|
+
const file = readFileProject();
|
|
388
|
+
const board = readFileBoard();
|
|
389
|
+
let record = file ? file.project : emptyProject();
|
|
390
|
+
const boards = file ? file.boards : { [record.activeBoardId]: board.state };
|
|
391
|
+
const work = record.boards.some((meta) => {
|
|
392
|
+
const state = isBoardState(boards[meta.id]) ? normalizeState(boards[meta.id]) : emptyState();
|
|
393
|
+
return state.notes.length > 0 && !isSampleWall(state);
|
|
394
|
+
});
|
|
395
|
+
if (!work) {
|
|
396
|
+
accountDoor.projectId = null;
|
|
397
|
+
accountDoor.skippedFolder = true;
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
record = { ...record, name: record.name || "From the agent" };
|
|
401
|
+
const inserted = await accountDoor.client.from("projects").insert({ id: record.id, record, reminders: file?.reminders ?? null, rev: 1 });
|
|
402
|
+
if (inserted.error) throw new Error(inserted.error.message);
|
|
403
|
+
for (const meta of record.boards) {
|
|
404
|
+
const state = isBoardState(boards[meta.id]) ? normalizeState(boards[meta.id]) : emptyState();
|
|
405
|
+
await accountDoor.client.from("boards").insert({ id: meta.id, project_id: record.id, state, rev: 1, updated_by: null });
|
|
406
|
+
}
|
|
407
|
+
chosen = { id: record.id, record };
|
|
408
|
+
}
|
|
409
|
+
workingProject(chosen.id, chosen.record.name, projects.length);
|
|
410
|
+
joinPresence(chosen.id);
|
|
411
|
+
return chosen;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** "an agent, as robert" under People while the server is up; best effort. */
|
|
415
|
+
function joinPresence(projectId) {
|
|
416
|
+
try {
|
|
417
|
+
if (accountDoor.channel) void accountDoor.client.removeChannel(accountDoor.channel);
|
|
418
|
+
const channel = accountDoor.client.channel(`project:${projectId}`, { config: { presence: { key: `${accountDoor.user.id}-agent` } } });
|
|
419
|
+
channel.subscribe((status) => {
|
|
420
|
+
if (status === "SUBSCRIBED") void channel.track({ name: `an agent, as ${accountDoor.email}` });
|
|
421
|
+
});
|
|
422
|
+
accountDoor.channel = channel;
|
|
423
|
+
} catch (error) {
|
|
424
|
+
log("account door: presence unavailable:", error);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function accountReadProject() {
|
|
429
|
+
if (!accountDoor.projectId) throw new DoorReply(noProjectYet());
|
|
430
|
+
const { data, error } = await accountDoor.client.from("projects").select("id, record, reminders, rev").eq("id", accountDoor.projectId).maybeSingle();
|
|
431
|
+
if (error || !data || !isProjectRecord(data.record)) throw new Error(error?.message ?? "the project is gone from the account");
|
|
432
|
+
const project = normalizeProject(data.record);
|
|
433
|
+
const rows = await accountDoor.client.from("boards").select("id, state, rev").eq("project_id", project.id);
|
|
434
|
+
const boards = {};
|
|
435
|
+
const revs = {};
|
|
436
|
+
for (const row of rows.data ?? []) {
|
|
437
|
+
if (isBoardState(row.state)) boards[row.id] = normalizeState(row.state);
|
|
438
|
+
revs[row.id] = row.rev;
|
|
439
|
+
}
|
|
440
|
+
return { project, boards, revs, reminders: Array.isArray(data.reminders) ? data.reminders : null, rev: data.rev, base: ACCOUNT, live: ACCOUNT };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function accountWriteProject(project, boards, rev, reminders) {
|
|
444
|
+
const patch = { record: project, rev: rev + 1, updated_at: new Date().toISOString(), ...(reminders ? { reminders } : {}) };
|
|
445
|
+
let done = await accountDoor.client.from("projects").update(patch).eq("id", project.id).eq("rev", rev).select("rev");
|
|
446
|
+
if (!done.error && done.data && done.data.length === 0) {
|
|
447
|
+
// Moved elsewhere since we read it: take the account's revision and write over it, once.
|
|
448
|
+
const fresh = await accountDoor.client.from("projects").select("rev").eq("id", project.id).maybeSingle();
|
|
449
|
+
const current = fresh.data?.rev ?? rev;
|
|
450
|
+
done = await accountDoor.client.from("projects").update({ ...patch, rev: current + 1 }).eq("id", project.id).eq("rev", current).select("rev");
|
|
451
|
+
}
|
|
452
|
+
if (done.error) throw new Error(done.error.message);
|
|
453
|
+
// Boards the record names that the account lacks are new; ones it no longer names go.
|
|
454
|
+
const have = await accountDoor.client.from("boards").select("id").eq("project_id", project.id);
|
|
455
|
+
const known = new Set((have.data ?? []).map((row) => row.id));
|
|
456
|
+
for (const meta of project.boards) {
|
|
457
|
+
if (!known.has(meta.id)) {
|
|
458
|
+
const state = isBoardState(boards[meta.id]) ? normalizeState(boards[meta.id]) : emptyState();
|
|
459
|
+
await accountDoor.client.from("boards").insert({ id: meta.id, project_id: project.id, state, rev: 1, updated_by: null });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const named = new Set(project.boards.map((meta) => meta.id));
|
|
463
|
+
const gone = [...known].filter((id) => !named.has(id));
|
|
464
|
+
if (gone.length) await accountDoor.client.from("boards").delete().in("id", gone);
|
|
465
|
+
return ACCOUNT;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function accountReadBoard() {
|
|
469
|
+
const { project, boards, revs } = await accountReadProject();
|
|
470
|
+
const boardId = project.activeBoardId;
|
|
471
|
+
return { state: boards[boardId] ?? emptyState(), rev: revs[boardId] ?? 0, boardId, base: ACCOUNT, live: ACCOUNT };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function accountWriteBoard(next, rev, boardId) {
|
|
475
|
+
// updated_by is null on purpose: the browser skips its own writes by user
|
|
476
|
+
// id, and the agent signs in as the writer.
|
|
477
|
+
const patch = { state: next, rev: rev + 1, updated_by: null, updated_at: new Date().toISOString() };
|
|
478
|
+
let done = await accountDoor.client.from("boards").update(patch).eq("id", boardId).eq("rev", rev).select("rev");
|
|
479
|
+
if (!done.error && done.data && done.data.length === 0) {
|
|
480
|
+
const fresh = await accountDoor.client.from("boards").select("rev").eq("id", boardId).maybeSingle();
|
|
481
|
+
const current = fresh.data?.rev ?? rev;
|
|
482
|
+
done = await accountDoor.client.from("boards").update({ ...patch, rev: current + 1 }).eq("id", boardId).eq("rev", current).select("rev");
|
|
483
|
+
}
|
|
484
|
+
if (done.error) throw new Error(done.error.message);
|
|
485
|
+
return ACCOUNT;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// --- Persistence -----------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
function readFileBoard() {
|
|
491
|
+
try {
|
|
492
|
+
const parsed = JSON.parse(fs.readFileSync(BOARD_FILE, "utf8"));
|
|
493
|
+
if (parsed.state && isBoardState(parsed.state)) {
|
|
494
|
+
// Boards written before the logline existed still load; they just gain an
|
|
495
|
+
// empty one on the way in.
|
|
496
|
+
return {
|
|
497
|
+
state: normalizeState(parsed.state),
|
|
498
|
+
rev: typeof parsed.rev === "number" ? parsed.rev : 0,
|
|
499
|
+
boardId: typeof parsed.boardId === "string" ? parsed.boardId : null,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
} catch {
|
|
503
|
+
/* no file yet */
|
|
504
|
+
}
|
|
505
|
+
return { state: seedState(), rev: 0, boardId: null };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function writeFileBoard(state, rev, boardId = null) {
|
|
509
|
+
fs.mkdirSync(path.dirname(BOARD_FILE), { recursive: true });
|
|
510
|
+
const payload = { app: "plotcoder", version: 1, rev, boardId, state };
|
|
511
|
+
fs.writeFileSync(BOARD_FILE, `${JSON.stringify(payload, null, 2)}\n`);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// --- The project: the record and every board ------------------------
|
|
515
|
+
|
|
516
|
+
function readFileProject() {
|
|
517
|
+
try {
|
|
518
|
+
const parsed = JSON.parse(fs.readFileSync(PROJECT_FILE, "utf8"));
|
|
519
|
+
if (parsed && isProjectRecord(parsed.project)) {
|
|
520
|
+
return {
|
|
521
|
+
project: normalizeProject(parsed.project),
|
|
522
|
+
boards: parsed.boards && typeof parsed.boards === "object" ? parsed.boards : {},
|
|
523
|
+
reminders: Array.isArray(parsed.reminders) ? parsed.reminders : null,
|
|
524
|
+
rev: typeof parsed.rev === "number" ? parsed.rev : 0,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
} catch {
|
|
528
|
+
/* no project file yet */
|
|
529
|
+
}
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function writeFileProject(project, boards, rev, reminders = null) {
|
|
534
|
+
fs.mkdirSync(path.dirname(PROJECT_FILE), { recursive: true });
|
|
535
|
+
const kept = reminders ?? readFileProject()?.reminders ?? null;
|
|
536
|
+
const payload = { app: "plotcoder", version: 2, rev, project, boards, ...(kept ? { reminders: kept } : {}) };
|
|
537
|
+
fs.writeFileSync(PROJECT_FILE, `${JSON.stringify(payload, null, 2)}\n`);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* The project as the bridge or the file holds it. A wall from before projects
|
|
542
|
+
* existed becomes a one-board project around the board on file, so every
|
|
543
|
+
* board tool works on an older checkout too.
|
|
544
|
+
*/
|
|
545
|
+
/** The account door, when the sign-in is set: the project, or the refusal. Null when no sign-in is set. */
|
|
546
|
+
async function throughAccount(read) {
|
|
547
|
+
if (!accountEnv()) return null;
|
|
548
|
+
if (await findAccount()) return read();
|
|
549
|
+
throw new DoorReply(accountRefusal ?? "The account door is shut.");
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function readProject() {
|
|
553
|
+
const viaAccount = await throughAccount(accountReadProject);
|
|
554
|
+
if (viaAccount) return viaAccount;
|
|
555
|
+
const base = await findBridge();
|
|
556
|
+
if (base) {
|
|
557
|
+
try {
|
|
558
|
+
const res = await fetch(`${base}/__plotcoder/project`, { signal: AbortSignal.timeout(1500) });
|
|
559
|
+
const data = await res.json();
|
|
560
|
+
if (data && isProjectRecord(data.project)) {
|
|
561
|
+
return {
|
|
562
|
+
project: normalizeProject(data.project),
|
|
563
|
+
boards: data.boards && typeof data.boards === "object" ? data.boards : {},
|
|
564
|
+
reminders: Array.isArray(data.reminders) ? data.reminders : null,
|
|
565
|
+
rev: typeof data.rev === "number" ? data.rev : 0,
|
|
566
|
+
base,
|
|
567
|
+
live: true,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
} catch (error) {
|
|
571
|
+
log("bridge project read failed, using file:", error);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const file = readFileProject();
|
|
575
|
+
if (file) return { ...file, base: null, live: false };
|
|
576
|
+
const board = readFileBoard();
|
|
577
|
+
const project = emptyProject();
|
|
578
|
+
const id = board.boardId ?? project.boards[0].id;
|
|
579
|
+
const record = board.boardId
|
|
580
|
+
? { ...project, boards: [{ ...project.boards[0], id: board.boardId }], activeBoardId: board.boardId }
|
|
581
|
+
: project;
|
|
582
|
+
// Written down at once, so the ids an agent reads are the ids it keeps
|
|
583
|
+
// (a blind run saw the first board's id change between calls).
|
|
584
|
+
writeFileProject(record, { [id]: board.state }, 1);
|
|
585
|
+
if (!board.boardId) writeFileBoard(board.state, board.rev, id);
|
|
586
|
+
return { project: record, boards: { [id]: board.state }, rev: 1, base: null, live: false };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function writeProject(project, boards, rev, base, reminders = null) {
|
|
590
|
+
if (base === ACCOUNT) return accountWriteProject(project, boards, rev, reminders);
|
|
591
|
+
if (base) {
|
|
592
|
+
try {
|
|
593
|
+
const res = await fetch(`${base}/__plotcoder/project`, {
|
|
594
|
+
method: "PUT",
|
|
595
|
+
headers: { "content-type": "application/json" },
|
|
596
|
+
body: JSON.stringify({ project, boards, rev, ...(reminders ? { reminders } : {}) }),
|
|
597
|
+
signal: AbortSignal.timeout(1500),
|
|
598
|
+
});
|
|
599
|
+
if (res.ok) {
|
|
600
|
+
const data = await res.json();
|
|
601
|
+
writeFileProject(data.project ?? project, data.boards ?? boards, data.rev ?? rev + 1, data.reminders ?? reminders);
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
} catch (error) {
|
|
605
|
+
log("bridge project write failed, falling back to file:", error);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
writeFileProject(project, boards, rev + 1, reminders);
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Open a board everywhere: the record's open board, and the board channel with its id. */
|
|
613
|
+
async function openBoardEverywhere(project, boards, projectRev, base, boardId) {
|
|
614
|
+
const opened = setActiveBoard(project, boardId);
|
|
615
|
+
const state = isBoardState(boards[boardId]) ? normalizeState(boards[boardId]) : emptyState();
|
|
616
|
+
const live = await writeProject(opened, { ...boards, [boardId]: state }, projectRev, base);
|
|
617
|
+
const { rev } = await readBoard();
|
|
618
|
+
await writeBoard(state, rev, base, boardId);
|
|
619
|
+
return { project: opened, state, live };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function readBoard() {
|
|
623
|
+
const viaAccount = await throughAccount(accountReadBoard);
|
|
624
|
+
if (viaAccount) return viaAccount;
|
|
625
|
+
const base = await findBridge();
|
|
626
|
+
if (base) {
|
|
627
|
+
try {
|
|
628
|
+
const res = await fetch(`${base}/__plotcoder/board`, {
|
|
629
|
+
signal: AbortSignal.timeout(1500),
|
|
630
|
+
});
|
|
631
|
+
const data = await res.json();
|
|
632
|
+
const state =
|
|
633
|
+
data.state && isBoardState(data.state) ? normalizeState(data.state) : seedState();
|
|
634
|
+
return {
|
|
635
|
+
state,
|
|
636
|
+
rev: typeof data.rev === "number" ? data.rev : 0,
|
|
637
|
+
boardId: typeof data.boardId === "string" ? data.boardId : null,
|
|
638
|
+
base,
|
|
639
|
+
live: true,
|
|
640
|
+
};
|
|
641
|
+
} catch (error) {
|
|
642
|
+
log("bridge read failed, using file:", error);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
const file = readFileBoard();
|
|
646
|
+
return { ...file, base: null, live: false };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async function writeBoard(next, rev, base, boardId = null) {
|
|
650
|
+
if (base === ACCOUNT) return accountWriteBoard(next, rev, boardId);
|
|
651
|
+
if (base) {
|
|
652
|
+
try {
|
|
653
|
+
const res = await fetch(`${base}/__plotcoder/board`, {
|
|
654
|
+
method: "PUT",
|
|
655
|
+
headers: { "content-type": "application/json" },
|
|
656
|
+
body: JSON.stringify({ state: next, rev, boardId }),
|
|
657
|
+
signal: AbortSignal.timeout(1500),
|
|
658
|
+
});
|
|
659
|
+
if (res.ok) {
|
|
660
|
+
const data = await res.json();
|
|
661
|
+
if (data.state && isBoardState(data.state)) {
|
|
662
|
+
writeFileBoard(data.state, data.rev, data.boardId ?? boardId);
|
|
663
|
+
}
|
|
664
|
+
return true;
|
|
665
|
+
}
|
|
666
|
+
} catch (error) {
|
|
667
|
+
log("bridge write failed, falling back to file:", error);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
writeFileBoard(next, rev + 1, boardId);
|
|
671
|
+
syncProjectFileBoard(boardId, next);
|
|
672
|
+
return false;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Keep the project file's copy of a board current when the app is not open to do it. */
|
|
676
|
+
function syncProjectFileBoard(boardId, state) {
|
|
677
|
+
if (!boardId) return;
|
|
678
|
+
const file = readFileProject();
|
|
679
|
+
if (!file || !file.project.boards.some((board) => board.id === boardId)) return;
|
|
680
|
+
writeFileProject(file.project, { ...file.boards, [boardId]: state }, file.rev + 1);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// This server's own trail of changes: what the board was before each
|
|
684
|
+
// of its tool calls, and what it became. `undo` walks it back — but only when
|
|
685
|
+
// the board still is what the call left, so it never tramples a change the
|
|
686
|
+
// person made on the wall since.
|
|
687
|
+
const TRAIL_CAP = 50;
|
|
688
|
+
const trail = [];
|
|
689
|
+
/** What undo took back, newest last; a new change of this server's clears it. */
|
|
690
|
+
const undone = [];
|
|
691
|
+
|
|
692
|
+
function describeCommand(command) {
|
|
693
|
+
switch (command.type) {
|
|
694
|
+
case "create_note":
|
|
695
|
+
return `create_note "${command.headline ?? ""}"`;
|
|
696
|
+
case "recolor_notes":
|
|
697
|
+
return "recolor_note";
|
|
698
|
+
default:
|
|
699
|
+
return command.type;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function commit(command) {
|
|
704
|
+
const { state, rev, base, boardId } = await readBoard();
|
|
705
|
+
const { state: next, changed, result } = applyCommand(state, command);
|
|
706
|
+
// `changed` is passed back so a tool can tell the agent that nothing
|
|
707
|
+
// happened, and why. A tool that silently reports success on a rejected
|
|
708
|
+
// command teaches the agent the board is in a state it is not.
|
|
709
|
+
if (!changed) return { state: next, changed, result, live: base !== null };
|
|
710
|
+
|
|
711
|
+
const live = await writeBoard(next, rev, base, boardId);
|
|
712
|
+
trail.push({ before: state, after: JSON.stringify(next), what: describeCommand(command) });
|
|
713
|
+
if (trail.length > TRAIL_CAP) trail.shift();
|
|
714
|
+
undone.length = 0;
|
|
715
|
+
return { state: next, changed, result, live };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** Said once per session: that cards stack until organize (round seven, finding 11). */
|
|
719
|
+
let saidStack = false;
|
|
720
|
+
|
|
721
|
+
/** 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. */
|
|
722
|
+
function door(live, base = null) {
|
|
723
|
+
if (live === ACCOUNT) {
|
|
724
|
+
const others = accountDoor.projectCount > 1 ? `, ${accountDoor.projectCount} projects on the account — list_projects for the others` : "";
|
|
725
|
+
return `the account, as ${accountDoor.email}, working "${accountDoor.projectName}"${others}`;
|
|
726
|
+
}
|
|
727
|
+
return live ? `the open app at ${base ?? "localhost"}` : `the file at ${BOARD_FILE}; no app running`;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** Remember which project the account door is working, for every reply's first line. */
|
|
731
|
+
function workingProject(id, name, count) {
|
|
732
|
+
accountDoor.projectId = id;
|
|
733
|
+
accountDoor.projectName = name;
|
|
734
|
+
if (typeof count === "number") accountDoor.projectCount = count;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** Where a change landed, for the tail of a tool's reply. */
|
|
738
|
+
function where(live) {
|
|
739
|
+
if (live === ACCOUNT) return " (saved to the account; live on every open wall)";
|
|
740
|
+
return live ? " (visible on the open board)" : " (written to file; the wall shows it the next time the app runs from this folder)";
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** The wall PlotCoder starts with — Maya, Tom, the letter — and nothing of the writer's yet. */
|
|
744
|
+
function isSampleWall(state) {
|
|
745
|
+
const sample = seedState().notes.map((note) => note.headline).sort().join("\n");
|
|
746
|
+
return state.notes.map((note) => note.headline).sort().join("\n") === sample;
|
|
747
|
+
}
|
|
748
|
+
/** Every check read_wall runs, so silence can be named. */
|
|
749
|
+
const CHECKS = ["sag", "empty", "unwritten", "unlinked", "duplicate", "sequence", "uncast", "absent", "backwards", "unpaid", "unplaced"];
|
|
750
|
+
/** What each check looks for, in words, so "clean" says what was checked rather than a kind's name. */
|
|
751
|
+
const CHECK_WORDS = {
|
|
752
|
+
sag: "no run out of proportion",
|
|
753
|
+
empty: "no beats back to back",
|
|
754
|
+
unwritten: "no card without a headline or change line",
|
|
755
|
+
unlinked: "no card without an arrow",
|
|
756
|
+
duplicate: "no two headlines alike",
|
|
757
|
+
sequence: "no group too long for one sequence",
|
|
758
|
+
uncast: "nobody in the cast on no card",
|
|
759
|
+
absent: "nobody gone for a third of the story",
|
|
760
|
+
backwards: "no payoff before its setup",
|
|
761
|
+
unpaid: "no fold without a payoff",
|
|
762
|
+
unplaced: "no card without a place",
|
|
763
|
+
};
|
|
764
|
+
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.";
|
|
765
|
+
|
|
766
|
+
// --- Reporting -------------------------------------------------------------
|
|
767
|
+
|
|
768
|
+
function summarize(state) {
|
|
769
|
+
const nameOf = new Map(state.characters.map((character) => [character.id, character.name]));
|
|
770
|
+
const notes = state.notes
|
|
771
|
+
.map((note) => {
|
|
772
|
+
const cast = note.characterIds.map((id) => nameOf.get(id) ?? id);
|
|
773
|
+
const who = cast.length ? `, cast: ${cast.join(", ")}` : "";
|
|
774
|
+
const plant = note.plants ? ", plants" : "";
|
|
775
|
+
const place = note.location ? `, at: ${note.location}` : "";
|
|
776
|
+
const count = formatPages(noteEighths(note));
|
|
777
|
+
const pages = `${count} ${count === "1" ? "page" : "pages"}${isMeasured(note) ? ", written" : note.lengthEighths === null ? ", unsized" : ""}`;
|
|
778
|
+
return ` - ${note.id} [${note.rank ?? "scene"}, ${pages}${who}${place}${plant}] — "${note.headline}" (${note.color}) at ${Math.round(note.x)},${Math.round(note.y)}`;
|
|
779
|
+
})
|
|
780
|
+
.join("\n");
|
|
781
|
+
const cast = state.characters
|
|
782
|
+
.map((character) => {
|
|
783
|
+
const on = state.notes.filter((note) => note.characterIds.includes(character.id)).length;
|
|
784
|
+
// Which lines of their page are written, so an agent can see who is a
|
|
785
|
+
// brief and who is still a name.
|
|
786
|
+
const page = filledCharacterFields(character);
|
|
787
|
+
const brief = page.length ? ` · page: ${page.join(", ")}` : " · page: empty";
|
|
788
|
+
return ` - ${character.id} — "${character.name}" on ${on} card${on === 1 ? "" : "s"}${brief}`;
|
|
789
|
+
})
|
|
790
|
+
.join("\n");
|
|
791
|
+
const { beats, scenes } = countRanks(state);
|
|
792
|
+
const headline = (id) =>
|
|
793
|
+
state.notes.find((note) => note.id === id)?.headline ?? "(missing card)";
|
|
794
|
+
|
|
795
|
+
// Groups and arrows are listed with their own ids, not just counted. An agent
|
|
796
|
+
// cannot ungroup, rename, or delete an arrow it has never been told the id of.
|
|
797
|
+
const groups = state.groups
|
|
798
|
+
.map(
|
|
799
|
+
(group) =>
|
|
800
|
+
` - ${group.id} — "${group.title}" holds ${group.noteIds.length}: ${group.noteIds.join(", ")}`,
|
|
801
|
+
)
|
|
802
|
+
.join("\n");
|
|
803
|
+
const arrows = state.arrows
|
|
804
|
+
.map(
|
|
805
|
+
(arrow) =>
|
|
806
|
+
` - ${arrow.id} [${arrow.kind ?? "follows"}] — ${arrow.from} → ${arrow.to} ("${headline(arrow.from)}" ${arrow.kind === "setup" ? "sets up" : "→"} "${headline(arrow.to)}")`,
|
|
807
|
+
)
|
|
808
|
+
.join("\n");
|
|
809
|
+
|
|
810
|
+
// No blank lines: ok() uses the first blank line to separate prose from the
|
|
811
|
+
// JSON payload, so one in here would swallow the payload.
|
|
812
|
+
const numbers = state.lock ? sceneNumbers(readingOrder(state.notes), state.lock) : null;
|
|
813
|
+
const production = [
|
|
814
|
+
`numbers: ${state.lock ? `locked ${String(state.lock.at).slice(0, 10)} — ${[...numbers.entries()].map(([id, n]) => `${n}:${id}`).join(" ")}` : "follow the wall's order"}`,
|
|
815
|
+
`revision: ${state.revision ? `"${state.revision.name}" in ${state.revision.color} since ${String(state.revision.since).slice(0, 10)}` : "none"}`,
|
|
816
|
+
];
|
|
817
|
+
const runtime = boardEighths(state);
|
|
818
|
+
const over = runtime - state.targetEighths;
|
|
819
|
+
return [
|
|
820
|
+
...(isSampleWall(state) ? [SAMPLE_NOTE] : []),
|
|
821
|
+
`logline: ${state.logline ? `"${state.logline}"` : "(not set)"}`,
|
|
822
|
+
...production,
|
|
823
|
+
`beats: ${beats}, scenes: ${scenes}`,
|
|
824
|
+
`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)${state.targetEighths === DEFAULT_TARGET_EIGHTHS ? " — the target is the feature default, nobody's choice yet; set_target for a pilot or a half-hour" : ""}`,
|
|
825
|
+
`notes: ${state.notes.length}, groups: ${state.groups.length}, arrows: ${state.arrows.length}, cast: ${state.characters.length}`,
|
|
826
|
+
"cast:",
|
|
827
|
+
cast || " (no one yet — add_character to start the roster)",
|
|
828
|
+
"cards:",
|
|
829
|
+
notes || " (no cards)",
|
|
830
|
+
"groups:",
|
|
831
|
+
groups || " (no groups)",
|
|
832
|
+
"arrows:",
|
|
833
|
+
arrows || " (no arrows)",
|
|
834
|
+
].join("\n");
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Wire format: prose, one blank line, then the JSON payload. `text` must not
|
|
838
|
+
// contain a blank line of its own or the payload becomes unparseable.
|
|
839
|
+
// PLOTCODER_JSON=0 drops the JSON tail from every reply, for an agent that
|
|
840
|
+
// reads the sentence and wants nothing more (a blind run found 400-line replies).
|
|
841
|
+
const TEXT_ONLY = env.PLOTCODER_JSON === "0";
|
|
842
|
+
function ok(text, data) {
|
|
843
|
+
const body = data === undefined || TEXT_ONLY ? text : `${text}\n\n${JSON.stringify(data, null, 2)}`;
|
|
844
|
+
return { content: [{ type: "text", text: body }] };
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// --- Server ----------------------------------------------------------------
|
|
848
|
+
|
|
849
|
+
const server = new McpServer({ name: "plotcoder-board", version: "0.1.0" });
|
|
850
|
+
|
|
851
|
+
// A door's answer is a reply, not an error: a shut account door, or an
|
|
852
|
+
// account with no project yet, says so in words from every tool alike.
|
|
853
|
+
const registerTool = server.registerTool.bind(server);
|
|
854
|
+
server.registerTool = (name, config, handler) =>
|
|
855
|
+
registerTool(name, config, async (...args) => {
|
|
856
|
+
try {
|
|
857
|
+
return await handler(...args);
|
|
858
|
+
} catch (error) {
|
|
859
|
+
if (error instanceof DoorReply) return ok(error.message);
|
|
860
|
+
throw error;
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
server.registerTool(
|
|
865
|
+
"list_board",
|
|
866
|
+
{
|
|
867
|
+
title: "List board",
|
|
868
|
+
description:
|
|
869
|
+
"Return the PlotCoder board: the logline, the cast (roster) with ids, then every card with id, headline, change, color, rank, length, cast, and position, then groups and arrows with ids. Read this before moving, updating, or casting cards so you use real ids.",
|
|
870
|
+
inputSchema: {},
|
|
871
|
+
},
|
|
872
|
+
async () => {
|
|
873
|
+
const { state, live, boardId, base } = await readBoard();
|
|
874
|
+
const { project } = await readProject();
|
|
875
|
+
const board = boardById(project, boardId ?? project.activeBoardId);
|
|
876
|
+
const which = board
|
|
877
|
+
? `"${board.name}" (${project.boards.findIndex((item) => item.id === board.id) + 1} of ${project.boards.length} in "${project.name}")`
|
|
878
|
+
: "board";
|
|
879
|
+
return ok(
|
|
880
|
+
`PlotCoder ${which} (${door(live, base)})\n${summarize(state)}`,
|
|
881
|
+
state,
|
|
882
|
+
);
|
|
883
|
+
},
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
server.registerTool(
|
|
887
|
+
"set_logline",
|
|
888
|
+
{
|
|
889
|
+
title: "Set logline",
|
|
890
|
+
description:
|
|
891
|
+
"Set the board's logline — the central question, what this story is arguing. One sentence. Every card on the wall should be checkable against it. Pass an empty string to clear it.",
|
|
892
|
+
inputSchema: {
|
|
893
|
+
logline: z.string(),
|
|
894
|
+
},
|
|
895
|
+
},
|
|
896
|
+
async (args) => {
|
|
897
|
+
const { state, live } = await commit({ type: "set_logline", logline: args.logline });
|
|
898
|
+
return ok(
|
|
899
|
+
state.logline
|
|
900
|
+
? `Logline set: "${state.logline}"${where(live)}.`
|
|
901
|
+
: "Logline cleared.",
|
|
902
|
+
{ logline: state.logline },
|
|
903
|
+
);
|
|
904
|
+
},
|
|
905
|
+
);
|
|
906
|
+
|
|
907
|
+
server.registerTool(
|
|
908
|
+
"set_rank",
|
|
909
|
+
{
|
|
910
|
+
title: "Set card rank",
|
|
911
|
+
description:
|
|
912
|
+
`Mark cards as beats or scenes. ${wordSentence("beat")} Rank is carried by the card, not by where it sits. Do not volunteer an opinion about how many beats there should be.`,
|
|
913
|
+
inputSchema: {
|
|
914
|
+
ids: z.array(z.string()).min(1),
|
|
915
|
+
rank: rankSchema,
|
|
916
|
+
},
|
|
917
|
+
},
|
|
918
|
+
async (args) => {
|
|
919
|
+
const { state, result, live } = await commit({
|
|
920
|
+
type: "set_rank",
|
|
921
|
+
ids: args.ids,
|
|
922
|
+
rank: args.rank,
|
|
923
|
+
});
|
|
924
|
+
const { beats, scenes } = countRanks(state);
|
|
925
|
+
return ok(
|
|
926
|
+
`${result?.length ?? 0} card(s) are now ${args.rank}${where(live)}. The board holds ${beats} beats and ${scenes} scenes.`,
|
|
927
|
+
result,
|
|
928
|
+
);
|
|
929
|
+
},
|
|
930
|
+
);
|
|
931
|
+
|
|
932
|
+
server.registerTool(
|
|
933
|
+
"set_length",
|
|
934
|
+
{
|
|
935
|
+
title: "Set card length",
|
|
936
|
+
description:
|
|
937
|
+
"Set how long cards run, in pages. An ordinary scene is about 1; a quick beat might be 0.25; a set piece might be 3 or 4. This is an estimate the writer owns — set it when you are told a length or when the card plainly describes one, and do not silently re-estimate a card someone has already sized.",
|
|
938
|
+
inputSchema: {
|
|
939
|
+
ids: z.array(z.string()).min(1),
|
|
940
|
+
pages: pagesSchema,
|
|
941
|
+
},
|
|
942
|
+
},
|
|
943
|
+
async (args) => {
|
|
944
|
+
const { state, result, live } = await commit({
|
|
945
|
+
type: "set_length",
|
|
946
|
+
ids: args.ids,
|
|
947
|
+
lengthEighths: toEighths(args.pages),
|
|
948
|
+
});
|
|
949
|
+
return ok(
|
|
950
|
+
`${result?.length ?? 0} card(s) now run about ${args.pages} page(s)${where(live)}. The board runs about ${formatPages(boardEighths(state))} pages against a ${formatPages(state.targetEighths)}-page target.`,
|
|
951
|
+
result,
|
|
952
|
+
);
|
|
953
|
+
},
|
|
954
|
+
);
|
|
955
|
+
|
|
956
|
+
server.registerTool(
|
|
957
|
+
"set_target",
|
|
958
|
+
{
|
|
959
|
+
title: "Set target length",
|
|
960
|
+
description:
|
|
961
|
+
"Set the board's target script length, in pages or in minutes (a page runs about a minute): 120 for a feature, 30 for a half-hour, 60 for an hour drama. This is what the runtime estimate is measured against.",
|
|
962
|
+
inputSchema: { pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
|
|
963
|
+
},
|
|
964
|
+
async (args) => {
|
|
965
|
+
if (args.pages === undefined && args.minutes === undefined) return ok("Say the target in pages or in minutes.");
|
|
966
|
+
const { state, live } = await commit({
|
|
967
|
+
type: "set_target",
|
|
968
|
+
targetEighths: toEighths(args.pages ?? args.minutes),
|
|
969
|
+
});
|
|
970
|
+
return ok(
|
|
971
|
+
`Target is ${formatPages(state.targetEighths)} pages${where(live)}. The cards add up to about ${formatPages(boardEighths(state))} — ${boardEighths(state) > state.targetEighths ? `${formatPages(boardEighths(state) - state.targetEighths)} over` : `${formatPages(state.targetEighths - boardEighths(state))} under`}.`,
|
|
972
|
+
{ targetEighths: state.targetEighths },
|
|
973
|
+
);
|
|
974
|
+
},
|
|
975
|
+
);
|
|
976
|
+
|
|
977
|
+
server.registerTool(
|
|
978
|
+
"create_note",
|
|
979
|
+
{
|
|
980
|
+
title: "Create note",
|
|
981
|
+
description:
|
|
982
|
+
"Add a card (post-it) to the board. A card is one scene: a headline plus the change it causes. Provide both headline and change. Optionally set color, x/y position, rank ('beat' for one of the major turns — a beat is a whole card, the scene where the turn happens), pages (how long it runs; leave it out and the card is taken to be about a page), plants (true if this scene sets something up that must pay off later), location (where it happens, as the writer would say it — 'the piano shop', not 'INT. PIANO SHOP'), and characters (who is in the scene, by name; a name not in the cast yet is added to it — name an unnamed person by their role, 'Dana's mother', rather than leaving them off). The reply names the card's id.",
|
|
983
|
+
inputSchema: {
|
|
984
|
+
headline: z.string().min(1),
|
|
985
|
+
change: z.string().min(1),
|
|
986
|
+
color: colorSchema.optional(),
|
|
987
|
+
rank: rankSchema.optional(),
|
|
988
|
+
pages: pagesSchema.optional(),
|
|
989
|
+
plants: z.boolean().optional(),
|
|
990
|
+
location: z.string().optional(),
|
|
991
|
+
characters: z.array(z.string().min(1)).optional(),
|
|
992
|
+
x: z.number().optional(),
|
|
993
|
+
y: z.number().optional(),
|
|
994
|
+
},
|
|
995
|
+
},
|
|
996
|
+
async (args) => {
|
|
997
|
+
const { result, live } = await commit({
|
|
998
|
+
type: "create_note",
|
|
999
|
+
headline: args.headline,
|
|
1000
|
+
change: args.change,
|
|
1001
|
+
// One colour unless the agent chooses: a wall an agent builds in one go
|
|
1002
|
+
// would otherwise stripe through the cycle, and a writer reads a pattern
|
|
1003
|
+
// into it (round four, finding 17). The wall's own new-card button keeps
|
|
1004
|
+
// cycling for a person adding cards by hand.
|
|
1005
|
+
color: args.color ?? "yellow",
|
|
1006
|
+
rank: args.rank,
|
|
1007
|
+
lengthEighths: args.pages === undefined ? undefined : toEighths(args.pages),
|
|
1008
|
+
plants: args.plants,
|
|
1009
|
+
location: args.location,
|
|
1010
|
+
x: args.x,
|
|
1011
|
+
y: args.y,
|
|
1012
|
+
});
|
|
1013
|
+
let castLine = "";
|
|
1014
|
+
if (args.characters && args.characters.length && result?.id) {
|
|
1015
|
+
const added = [];
|
|
1016
|
+
const ids = [];
|
|
1017
|
+
for (const name of args.characters) {
|
|
1018
|
+
const { state } = await readBoard();
|
|
1019
|
+
const wanted = name.trim().toLowerCase();
|
|
1020
|
+
let person = state.characters.find((item) => item.id === name) ?? state.characters.find((item) => item.name.trim().toLowerCase() === wanted);
|
|
1021
|
+
if (!person) {
|
|
1022
|
+
const made = await commit({ type: "add_character", name: name.trim() });
|
|
1023
|
+
person = made.result;
|
|
1024
|
+
if (person) added.push(`${person.name} (${person.id})`);
|
|
1025
|
+
}
|
|
1026
|
+
if (person) ids.push(person.id);
|
|
1027
|
+
}
|
|
1028
|
+
if (ids.length) await commit({ type: "set_cast", ids: [result.id], characterIds: ids });
|
|
1029
|
+
castLine = ` Cast: ${args.characters.map((name) => name.trim()).join(", ")}${added.length ? ` (added to the roster: ${added.join(", ")})` : ""}.`;
|
|
1030
|
+
}
|
|
1031
|
+
const landed = [
|
|
1032
|
+
result?.rank === "beat" ? "a beat" : "a scene",
|
|
1033
|
+
result?.lengthEighths === null ? "about a page (unsized: the writer's guess until set_length)" : `${formatPages(noteEighths(result))} ${formatPages(noteEighths(result)) === "1" ? "page" : "pages"}`,
|
|
1034
|
+
result?.color ? `${result.color} paper${args.color ? "" : " (pass color to choose)"}` : null,
|
|
1035
|
+
result?.plants ? "corner folded" : null,
|
|
1036
|
+
result?.location ? `at ${result.location}` : "no place yet (location here, or set_location)",
|
|
1037
|
+
].filter(Boolean).join(", ");
|
|
1038
|
+
const placed = args.x === undefined && args.y === undefined && !saidStack ? " Cards stack until organize lays them out along the arrows." : "";
|
|
1039
|
+
if (placed) saidStack = true;
|
|
1040
|
+
return ok(`Created card ${result?.id ?? ""}: ${landed}${where(live)}.${castLine}${placed}`, result);
|
|
1041
|
+
},
|
|
1042
|
+
);
|
|
1043
|
+
|
|
1044
|
+
server.registerTool(
|
|
1045
|
+
"update_note",
|
|
1046
|
+
{
|
|
1047
|
+
title: "Update note",
|
|
1048
|
+
description: "Change the headline, change text and/or location of an existing card by id.",
|
|
1049
|
+
inputSchema: {
|
|
1050
|
+
id: z.string(),
|
|
1051
|
+
headline: z.string().optional(),
|
|
1052
|
+
change: z.string().optional(),
|
|
1053
|
+
location: z.string().optional(),
|
|
1054
|
+
},
|
|
1055
|
+
},
|
|
1056
|
+
async (args) => {
|
|
1057
|
+
const { result } = await commit({
|
|
1058
|
+
type: "update_note",
|
|
1059
|
+
id: args.id,
|
|
1060
|
+
headline: args.headline,
|
|
1061
|
+
change: args.change,
|
|
1062
|
+
location: args.location,
|
|
1063
|
+
});
|
|
1064
|
+
if (result === undefined) return ok(`No card with id ${args.id}.`);
|
|
1065
|
+
return ok("Updated card.", result);
|
|
1066
|
+
},
|
|
1067
|
+
);
|
|
1068
|
+
|
|
1069
|
+
server.registerTool(
|
|
1070
|
+
"move_note",
|
|
1071
|
+
{
|
|
1072
|
+
title: "Move note",
|
|
1073
|
+
description:
|
|
1074
|
+
"Move a card to an absolute position on the board (x,y are the top-left of the card, in pixels).",
|
|
1075
|
+
inputSchema: { id: z.string(), x: z.number(), y: z.number() },
|
|
1076
|
+
},
|
|
1077
|
+
async (args) => {
|
|
1078
|
+
const { result } = await commit({ type: "move_note", id: args.id, x: args.x, y: args.y });
|
|
1079
|
+
if (result === undefined) return ok(`No card with id ${args.id}.`);
|
|
1080
|
+
return ok("Moved card.", result);
|
|
1081
|
+
},
|
|
1082
|
+
);
|
|
1083
|
+
|
|
1084
|
+
server.registerTool(
|
|
1085
|
+
"recolor_note",
|
|
1086
|
+
{
|
|
1087
|
+
title: "Recolor note",
|
|
1088
|
+
description: "Change the paper color of a card.",
|
|
1089
|
+
inputSchema: { id: z.string(), color: colorSchema },
|
|
1090
|
+
},
|
|
1091
|
+
async (args) => {
|
|
1092
|
+
const { result } = await commit({
|
|
1093
|
+
type: "recolor_notes",
|
|
1094
|
+
ids: [args.id],
|
|
1095
|
+
color: args.color,
|
|
1096
|
+
});
|
|
1097
|
+
const changed = Array.isArray(result) && result.length > 0;
|
|
1098
|
+
if (!changed) return ok(`No card with id ${args.id}.`);
|
|
1099
|
+
return ok("Recolored card.", result);
|
|
1100
|
+
},
|
|
1101
|
+
);
|
|
1102
|
+
|
|
1103
|
+
server.registerTool(
|
|
1104
|
+
"delete_note",
|
|
1105
|
+
{
|
|
1106
|
+
title: "Delete note",
|
|
1107
|
+
description:
|
|
1108
|
+
"Remove a card from the board. Also removes any arrows touching it and drops it from groups.",
|
|
1109
|
+
inputSchema: { id: z.string() },
|
|
1110
|
+
},
|
|
1111
|
+
async (args) => {
|
|
1112
|
+
const { result } = await commit({ type: "delete_note", id: args.id });
|
|
1113
|
+
if (result === undefined) return ok(`No card with id ${args.id}.`);
|
|
1114
|
+
return ok("Deleted card.", result);
|
|
1115
|
+
},
|
|
1116
|
+
);
|
|
1117
|
+
|
|
1118
|
+
// --- Read the wall ----------------------------------------------------
|
|
1119
|
+
|
|
1120
|
+
server.registerTool(
|
|
1121
|
+
"read_wall",
|
|
1122
|
+
{
|
|
1123
|
+
title: "Read the wall",
|
|
1124
|
+
description:
|
|
1125
|
+
"Read the board back: the beats in wall order (rows top to bottom, cards left to right), the pages of scenes between consecutive beats, and the questions the wall raises — a run out of proportion with the others, a card with no change line, a card no arrow touches, two headlines that read like the same scene, a group too long to be one sequence, beats back to back with nothing between them (a chain of them is one question), 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.",
|
|
1126
|
+
inputSchema: {},
|
|
1127
|
+
},
|
|
1128
|
+
async () => {
|
|
1129
|
+
const { state, live, base } = await readBoard();
|
|
1130
|
+
const reading = readWall(state);
|
|
1131
|
+
const runs = describeRuns(reading, state).map((line, index) => {
|
|
1132
|
+
const ids = reading.runs[index]?.ids ?? [];
|
|
1133
|
+
return ids.length ? `${line} — ${ids.map((id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`).join(", ")}` : line;
|
|
1134
|
+
});
|
|
1135
|
+
// No blank lines: ok() splits prose from payload on the first one.
|
|
1136
|
+
const written = state.notes.filter((note) => isMeasured(note)).length;
|
|
1137
|
+
const lines = [
|
|
1138
|
+
`PlotCoder wall (${door(live, base)})`,
|
|
1139
|
+
`logline: ${state.logline ? `"${state.logline}"` : "(none yet)"}`,
|
|
1140
|
+
`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`}`,
|
|
1141
|
+
`beats in wall order: ${
|
|
1142
|
+
reading.beats.length
|
|
1143
|
+
? reading.beats.map((beat) => `"${beat.headline}"`).join(", ")
|
|
1144
|
+
: "(none marked)"
|
|
1145
|
+
}`,
|
|
1146
|
+
`runs between beats (the scenes between two turns; a beat's own pages are in no run${written < state.notes.length ? "; pages are estimates" : ""}):`,
|
|
1147
|
+
...(runs.length ? runs.map((line) => ` - ${line}`) : [" (none)"]),
|
|
1148
|
+
`setups and payoffs${written < state.notes.length ? " (distances in estimated pages)" : ""}:`,
|
|
1149
|
+
...(reading.setups.length
|
|
1150
|
+
? describeSetups(reading, state).map((line) => ` - ${line}`)
|
|
1151
|
+
: [" (no arrow is marked as a setup)"]),
|
|
1152
|
+
"questions the wall raises:",
|
|
1153
|
+
...(reading.findings.length
|
|
1154
|
+
? reading.findings.map((finding) => ` - [${finding.kind}] ${finding.text}${finding.ids.length ? ` (ids: ${finding.ids.join(", ")})` : ""}`)
|
|
1155
|
+
: [" (none that this reading can see)"]),
|
|
1156
|
+
`checks: ${CHECKS.length} run — asking about ${[...new Set(reading.findings.map((finding) => finding.kind))].filter((kind) => CHECKS.includes(kind)).join(", ") || "nothing"}; checked and clean: ${CHECKS.filter((kind) => !reading.findings.some((finding) => finding.kind === kind)).map((kind) => CHECK_WORDS[kind]).join("; ") || "(nothing — every check found something)"}`,
|
|
1157
|
+
];
|
|
1158
|
+
if (isSampleWall(state)) lines.unshift(SAMPLE_NOTE);
|
|
1159
|
+
return ok(lines.join("\n"), { ...reading, sample: isSampleWall(state) });
|
|
1160
|
+
},
|
|
1161
|
+
);
|
|
1162
|
+
|
|
1163
|
+
server.registerTool(
|
|
1164
|
+
"organize",
|
|
1165
|
+
{
|
|
1166
|
+
title: "Organize the wall",
|
|
1167
|
+
description:
|
|
1168
|
+
"Tidy the wall along the arrows. Cards are ordered by their 'follows' arrows (a card comes after everything that points at it), then by reading order. With beats on the wall, each beat starts a row and the scenes that follow it fill the row to its right, wrapping under themselves when a run is long; with no beats yet, rows wrap five cards wide. Groups stay together. Pass noteIds to tidy only those cards, from their own top-left. Undoable from the wall.",
|
|
1169
|
+
inputSchema: { noteIds: z.array(z.string()).min(2).optional() },
|
|
1170
|
+
},
|
|
1171
|
+
async (args) => {
|
|
1172
|
+
const { state } = await readBoard();
|
|
1173
|
+
const poses = organizePoses(state, { onlyIds: args.noteIds });
|
|
1174
|
+
if (poses.length === 0) return ok("Nothing to organize: no cards in scope.");
|
|
1175
|
+
const { changed, live } = await commit({ type: "apply_poses", poses });
|
|
1176
|
+
if (!changed) return ok("Nothing moved.");
|
|
1177
|
+
const rows = new Set(poses.map((pose) => pose.y)).size;
|
|
1178
|
+
const beats = state.notes.filter(
|
|
1179
|
+
(note) => note.rank === "beat" && poses.some((pose) => pose.id === note.id),
|
|
1180
|
+
).length;
|
|
1181
|
+
// Rows top to bottom; a row whose first card is not a beat is the row
|
|
1182
|
+
// above wrapping under, and the reply names whose row it is.
|
|
1183
|
+
const byRow = new Map();
|
|
1184
|
+
for (const pose of poses) byRow.set(pose.y, [...(byRow.get(pose.y) ?? []), pose]);
|
|
1185
|
+
const wrappedUnder = [];
|
|
1186
|
+
let currentBeat = null;
|
|
1187
|
+
for (const y of [...byRow.keys()].sort((a, b) => a - b)) {
|
|
1188
|
+
const first = byRow.get(y).sort((a, b) => a.x - b.x)[0];
|
|
1189
|
+
const note = state.notes.find((item) => item.id === first.id);
|
|
1190
|
+
if (note?.rank === "beat") currentBeat = note;
|
|
1191
|
+
else if (currentBeat && note && !wrappedUnder.some((item) => item.beat === currentBeat)) wrappedUnder.push({ beat: currentBeat, first: note });
|
|
1192
|
+
}
|
|
1193
|
+
const shape = beats
|
|
1194
|
+
? `${beats} row(s), one per beat${wrappedUnder.length ? `; ${wrappedUnder.map((item) => `the row of "${item.beat.headline}" wraps under from "${item.first.headline}"`).join(", ")}` : ""}`
|
|
1195
|
+
: `${rows} row(s)`;
|
|
1196
|
+
return ok(`Organized ${poses.length} card(s) along the arrows into ${shape}${where(live)}.`, poses);
|
|
1197
|
+
},
|
|
1198
|
+
);
|
|
1199
|
+
|
|
1200
|
+
/** One of the writer's own structures, by id or name. */
|
|
1201
|
+
function findStructure(project, key) {
|
|
1202
|
+
const wanted = key.trim().toLowerCase();
|
|
1203
|
+
const own = project.structures ?? [];
|
|
1204
|
+
return own.find((structure) => structure.id === key) ?? own.find((structure) => structure.name.trim().toLowerCase() === wanted) ?? null;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
server.registerTool(
|
|
1208
|
+
"apply_template",
|
|
1209
|
+
{
|
|
1210
|
+
title: "Start from a structure",
|
|
1211
|
+
description:
|
|
1212
|
+
"Lay a structure's named beats on the wall as beat cards, prompts on their change lines, in one row above the cards already there (or at the top of an empty wall). One undo step. Structures: " +
|
|
1213
|
+
TEMPLATES.map((template) => `${template.id} (${template.name}, ${template.beats.length} beats — ${template.blurb})`).join("; ") +
|
|
1214
|
+
"; or one of the writer's own, by name or id (list_structures). The house method, turns, is the default. Ask the writer which structure before applying one; nothing remembers the template afterwards, there are only cards.",
|
|
1215
|
+
inputSchema: { template: z.string().min(1) },
|
|
1216
|
+
},
|
|
1217
|
+
async (args) => {
|
|
1218
|
+
const { project } = await readProject();
|
|
1219
|
+
const own = findStructure(project, args.template);
|
|
1220
|
+
const command = own
|
|
1221
|
+
? { type: "apply_template", template: own.id, beats: own.beats }
|
|
1222
|
+
: { type: "apply_template", template: args.template };
|
|
1223
|
+
const { changed, result, live } = await commit(command);
|
|
1224
|
+
if (!changed) return ok(`No structure called ${args.template}. Call list_structures for the real ones.`);
|
|
1225
|
+
const names = result.map((note) => note.headline).join(", ");
|
|
1226
|
+
return ok(`Laid out ${result.length} beats${own ? ` of "${own.name}"` : ""}${where(live)}: ${names}.`, result);
|
|
1227
|
+
},
|
|
1228
|
+
);
|
|
1229
|
+
|
|
1230
|
+
// The writer's own structures: saved from a wall's
|
|
1231
|
+
// beats onto the project, laid on another wall with apply_template.
|
|
1232
|
+
server.registerTool(
|
|
1233
|
+
"list_structures",
|
|
1234
|
+
{
|
|
1235
|
+
title: "List the structures",
|
|
1236
|
+
description: "The structures apply_template can lay on a wall: the built-in ones, and the writer's own saved from their walls (save_structure), each with its beats.",
|
|
1237
|
+
inputSchema: {},
|
|
1238
|
+
},
|
|
1239
|
+
async () => {
|
|
1240
|
+
const { project } = await readProject();
|
|
1241
|
+
const own = project.structures ?? [];
|
|
1242
|
+
const lines = [
|
|
1243
|
+
`built in: ${TEMPLATES.length}`,
|
|
1244
|
+
...TEMPLATES.map((template) => ` - ${template.id} — "${template.name}" (${template.beats.length} beats)`),
|
|
1245
|
+
`the writer's own: ${own.length}`,
|
|
1246
|
+
...own.map((structure) => ` - ${structure.id} — "${structure.name}" (${structure.beats.length} beats: ${structure.beats.map((beat) => beat.name).join(", ")})`),
|
|
1247
|
+
];
|
|
1248
|
+
return ok(lines.join("\n"), { builtIn: TEMPLATES.map((template) => ({ id: template.id, name: template.name, beats: template.beats })), own });
|
|
1249
|
+
},
|
|
1250
|
+
);
|
|
1251
|
+
|
|
1252
|
+
server.registerTool(
|
|
1253
|
+
"save_structure",
|
|
1254
|
+
{
|
|
1255
|
+
title: "Save this wall's beats as a structure",
|
|
1256
|
+
description:
|
|
1257
|
+
"Save the open board's beats — in reading order, each one's headline as the beat's name, its change line as the prompt, and where it falls as a share of the wall — as one of the writer's own structures on the project, to lay on another wall with apply_template. Needs at least one card marked as a beat (set_rank).",
|
|
1258
|
+
inputSchema: { name: z.string().min(1) },
|
|
1259
|
+
},
|
|
1260
|
+
async (args) => {
|
|
1261
|
+
const { state } = await readBoard();
|
|
1262
|
+
const order = readingOrder(state.notes).map((note) => note.id);
|
|
1263
|
+
const beats = structureBeats(state.notes, order);
|
|
1264
|
+
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.");
|
|
1265
|
+
const { project, boards, rev, base, live } = await readProject();
|
|
1266
|
+
const { project: next, structure } = addStructure(project, args.name, beats);
|
|
1267
|
+
await writeProject(next, boards, rev, base);
|
|
1268
|
+
return ok(`Saved "${structure.name}" with ${beats.length} beats${where(live)}: ${beats.map((beat) => beat.name).join(", ")}.`, structure);
|
|
1269
|
+
},
|
|
1270
|
+
);
|
|
1271
|
+
|
|
1272
|
+
server.registerTool(
|
|
1273
|
+
"remove_structure",
|
|
1274
|
+
{
|
|
1275
|
+
title: "Remove one of the writer's structures",
|
|
1276
|
+
description: "Remove one of the writer's own structures from the project, by name or id. The built-in ones stay. Cards laid from it before are untouched — there are only cards.",
|
|
1277
|
+
inputSchema: { structure: z.string().min(1) },
|
|
1278
|
+
},
|
|
1279
|
+
async (args) => {
|
|
1280
|
+
const { project, boards, rev, base, live } = await readProject();
|
|
1281
|
+
const found = findStructure(project, args.structure);
|
|
1282
|
+
if (!found) return ok(`No structure of the writer's called "${args.structure}". Call list_structures.`);
|
|
1283
|
+
await writeProject(removeStructure(project, found.id), boards, rev, base);
|
|
1284
|
+
return ok(`Removed "${found.name}"${where(live)}.`, { id: found.id, name: found.name });
|
|
1285
|
+
},
|
|
1286
|
+
);
|
|
1287
|
+
|
|
1288
|
+
server.registerTool(
|
|
1289
|
+
"export_fountain",
|
|
1290
|
+
{
|
|
1291
|
+
title: "Export the wall as Fountain",
|
|
1292
|
+
description:
|
|
1293
|
+
"The open board as a Fountain screenplay: a title page (with the premise and logline in its notes), beats as sections, one scene per card in wall order — a forced heading from the card's place (or its headline), the headline as a synopsis, the cast and the fold as notes, the change line as action. Plain text a writer can open in any Fountain editor. Pass a path to write a .fountain file; otherwise the text comes back.",
|
|
1294
|
+
inputSchema: { path: z.string().optional() },
|
|
1295
|
+
},
|
|
1296
|
+
async (args) => {
|
|
1297
|
+
const { state } = await readBoard();
|
|
1298
|
+
const { project } = await readProject();
|
|
1299
|
+
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
1300
|
+
const text = toFountain(state, {
|
|
1301
|
+
title: board?.name,
|
|
1302
|
+
project: project.boards.length > 1 && project.name !== "Untitled project" ? project.name : undefined,
|
|
1303
|
+
premise: project.premise || undefined,
|
|
1304
|
+
draftDate: new Date().toISOString(),
|
|
1305
|
+
});
|
|
1306
|
+
if (args.path) {
|
|
1307
|
+
fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
|
|
1308
|
+
fs.writeFileSync(args.path, text);
|
|
1309
|
+
return ok(`Wrote ${text.split("\n").length} lines of Fountain to ${args.path}.`);
|
|
1310
|
+
}
|
|
1311
|
+
return ok(text);
|
|
1312
|
+
},
|
|
1313
|
+
);
|
|
1314
|
+
|
|
1315
|
+
server.registerTool(
|
|
1316
|
+
"write_scene",
|
|
1317
|
+
{
|
|
1318
|
+
title: "Write a scene",
|
|
1319
|
+
description:
|
|
1320
|
+
"Write a card's scene text in Fountain — action, character cues in capitals, dialogue under them — onto the card by id. The card is then measured (its lines against a page) instead of estimated. An empty string clears it. Read read_pages first so the scene fits what is around it, and do not write scenes the writer has not asked for.",
|
|
1321
|
+
inputSchema: { id: z.string(), text: z.string() },
|
|
1322
|
+
},
|
|
1323
|
+
async (args) => {
|
|
1324
|
+
const { changed, result, live } = await commit({ type: "set_text", id: args.id, text: args.text });
|
|
1325
|
+
if (!changed) {
|
|
1326
|
+
if (!result) return ok(`No card with id ${args.id}. Call list_board.`);
|
|
1327
|
+
return ok(`Nothing changed: "${result.headline}" already reads that way.`);
|
|
1328
|
+
}
|
|
1329
|
+
return ok(
|
|
1330
|
+
`Wrote "${result.headline}": ${formatPages(noteEighths(result))} page(s) measured${where(live)}.`,
|
|
1331
|
+
result,
|
|
1332
|
+
);
|
|
1333
|
+
},
|
|
1334
|
+
);
|
|
1335
|
+
|
|
1336
|
+
server.registerTool(
|
|
1337
|
+
"read_pages",
|
|
1338
|
+
{
|
|
1339
|
+
title: "Read the pages",
|
|
1340
|
+
description:
|
|
1341
|
+
"The open board as a script in wall order, with each card's id beside its heading and whether its length is measured (written) or estimated. The same text export_fountain writes, plus the ids, so a scene can be written back with write_scene.",
|
|
1342
|
+
inputSchema: {},
|
|
1343
|
+
},
|
|
1344
|
+
async () => {
|
|
1345
|
+
const { state } = await readBoard();
|
|
1346
|
+
const { project } = await readProject();
|
|
1347
|
+
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
1348
|
+
const text = toFountain(state, { title: board?.name, premise: project.premise || undefined });
|
|
1349
|
+
const parsed = fromFountain(text);
|
|
1350
|
+
const ids = mergeFountain(state, parsed).matched.map((item) => item.id);
|
|
1351
|
+
const lines = [];
|
|
1352
|
+
let index = 0;
|
|
1353
|
+
for (const line of text.split("\n")) {
|
|
1354
|
+
if (/^\.(?!\.)/.test(line) && index < ids.length) {
|
|
1355
|
+
const note = state.notes.find((item) => item.id === ids[index]);
|
|
1356
|
+
index += 1;
|
|
1357
|
+
lines.push(`${line} [[id: ${note?.id ?? "?"} · ${note && isMeasured(note) ? "measured" : "estimated"} ${formatPages(note ? noteEighths(note) : 0)}pp]]`);
|
|
1358
|
+
} else {
|
|
1359
|
+
lines.push(line);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
return ok(lines.join("\n"));
|
|
1363
|
+
},
|
|
1364
|
+
);
|
|
1365
|
+
|
|
1366
|
+
server.registerTool(
|
|
1367
|
+
"import_fountain",
|
|
1368
|
+
{
|
|
1369
|
+
title: "Import a Fountain script",
|
|
1370
|
+
description:
|
|
1371
|
+
"Read a .fountain file (by path) or Fountain text onto the open board: each scene's text goes onto 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. What a new card takes from a scene: its headline from the `= synopsis` line (else the heading), its place from a forced heading (`.the piano shop`) when there is a synopsis, its text from the body, and its change line from the body's first sentence. Rank, the fold, the cast, arrows and acts do not travel: set them after. A card with text is measured from it, so a one-line body makes a card of a few lines, not a page — this is the door for pages, not for a treatment. Say what was matched and what was made.",
|
|
1372
|
+
inputSchema: { path: z.string().optional(), text: z.string().optional() },
|
|
1373
|
+
},
|
|
1374
|
+
async (args) => {
|
|
1375
|
+
const source = args.text ?? (args.path ? fs.readFileSync(args.path, "utf8") : null);
|
|
1376
|
+
if (source === null) return ok("Nothing to import: pass a path or text.");
|
|
1377
|
+
const { state } = await readBoard();
|
|
1378
|
+
const parsed = fromFountain(source);
|
|
1379
|
+
const { commands, matched } = mergeFountain(state, parsed);
|
|
1380
|
+
let live = false;
|
|
1381
|
+
for (const command of commands) ({ live } = await commit(command));
|
|
1382
|
+
const written = commands.filter((command) => command.type === "set_text").length;
|
|
1383
|
+
const created = matched.filter((item) => item.created).length;
|
|
1384
|
+
return ok(
|
|
1385
|
+
`Imported ${parsed.scenes.length} scene(s): ${written} written onto cards, ${created} new card(s)${where(live)}.`,
|
|
1386
|
+
matched,
|
|
1387
|
+
);
|
|
1388
|
+
},
|
|
1389
|
+
);
|
|
1390
|
+
|
|
1391
|
+
server.registerTool(
|
|
1392
|
+
"list_words",
|
|
1393
|
+
{
|
|
1394
|
+
title: "What these words mean",
|
|
1395
|
+
description:
|
|
1396
|
+
"PlotCoder's words — beat, logline, change line, the folded corner, eighths, structure, brief — one sentence each, the app's own meaning, in the order a new person meets them. Use these sentences when the writer asks what a word means, so the app and you say the same thing.",
|
|
1397
|
+
inputSchema: {},
|
|
1398
|
+
},
|
|
1399
|
+
async () => ok(`PlotCoder's words — the app's own, the same on every project; no project was read.\n\n${wordsAsText()}`),
|
|
1400
|
+
);
|
|
1401
|
+
|
|
1402
|
+
server.registerTool(
|
|
1403
|
+
"list_workflows",
|
|
1404
|
+
{
|
|
1405
|
+
title: "List workflows",
|
|
1406
|
+
description:
|
|
1407
|
+
"The workflows a writer can ask for: each a sentence, the tools it composes, and the rule to keep while doing it. When the writer's ask matches one, follow it; when it does not, compose the tools yourself and say what you did.",
|
|
1408
|
+
inputSchema: {},
|
|
1409
|
+
},
|
|
1410
|
+
async () =>
|
|
1411
|
+
ok(
|
|
1412
|
+
"The workflows — the app's own, the same on every project; no project was read.\n" +
|
|
1413
|
+
WORKFLOWS.map(
|
|
1414
|
+
(workflow) =>
|
|
1415
|
+
`- ${workflow.id} — ${workflow.name}\n ask: "${workflow.ask}"\n tools: ${workflow.tools.join(", ")}\n keep: ${workflow.then}`,
|
|
1416
|
+
).join("\n"),
|
|
1417
|
+
WORKFLOWS,
|
|
1418
|
+
),
|
|
1419
|
+
);
|
|
1420
|
+
|
|
1421
|
+
server.registerTool(
|
|
1422
|
+
"segment_brief",
|
|
1423
|
+
{
|
|
1424
|
+
title: "Brief a segment",
|
|
1425
|
+
description:
|
|
1426
|
+
"The brief for a segment of the movie: one card, or several in wall order for a run between beats. Everything the wall knows — the story, the people with their pages, the places, what changes, the script or 'unwritten', what must be true after — in the order a video tool would need it. Text only; nothing is generated or sent. Hand it to the writer to approve; fix a wrong brief on the cards.",
|
|
1427
|
+
inputSchema: { ids: z.array(z.string()).min(1) },
|
|
1428
|
+
},
|
|
1429
|
+
async (args) => {
|
|
1430
|
+
const { state } = await readBoard();
|
|
1431
|
+
const { project } = await readProject();
|
|
1432
|
+
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
1433
|
+
const brief = segmentBrief(state, args.ids, { title: board?.name });
|
|
1434
|
+
if (!brief) return ok(`No cards with ids ${args.ids.join(", ")}. Call list_board.`);
|
|
1435
|
+
return ok(brief);
|
|
1436
|
+
},
|
|
1437
|
+
);
|
|
1438
|
+
|
|
1439
|
+
server.registerTool(
|
|
1440
|
+
"export_fdx",
|
|
1441
|
+
{
|
|
1442
|
+
title: "Export as Final Draft",
|
|
1443
|
+
description:
|
|
1444
|
+
"The open board as a Final Draft .fdx: a heading per card with its scene number by wall order, the scene's text as script paragraphs (action, character, parenthetical, dialogue, dual dialogue, transition) or the change line as action when unwritten, and a title page. Pass a path to write the file; otherwise the XML comes back.",
|
|
1445
|
+
inputSchema: { path: z.string().optional() },
|
|
1446
|
+
},
|
|
1447
|
+
async (args) => {
|
|
1448
|
+
const { state } = await readBoard();
|
|
1449
|
+
const { project } = await readProject();
|
|
1450
|
+
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
1451
|
+
const xml = toFdx(state, { title: board?.name, project: project.boards.length > 1 ? project.name : undefined, draftDate: new Date().toISOString() });
|
|
1452
|
+
if (args.path) {
|
|
1453
|
+
fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
|
|
1454
|
+
fs.writeFileSync(args.path, xml);
|
|
1455
|
+
return ok(`Wrote a Final Draft file with ${state.notes.length} scene(s) to ${args.path}.`);
|
|
1456
|
+
}
|
|
1457
|
+
return ok(xml);
|
|
1458
|
+
},
|
|
1459
|
+
);
|
|
1460
|
+
|
|
1461
|
+
server.registerTool(
|
|
1462
|
+
"import_fdx",
|
|
1463
|
+
{
|
|
1464
|
+
title: "Import a Final Draft script",
|
|
1465
|
+
description:
|
|
1466
|
+
"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.",
|
|
1467
|
+
inputSchema: { path: z.string().optional(), xml: z.string().optional() },
|
|
1468
|
+
},
|
|
1469
|
+
async (args) => {
|
|
1470
|
+
const source = args.xml ?? (args.path ? fs.readFileSync(args.path, "utf8") : null);
|
|
1471
|
+
if (source === null) return ok("Nothing to import: pass a path or xml.");
|
|
1472
|
+
const { state } = await readBoard();
|
|
1473
|
+
const parsed = fromFdx(source);
|
|
1474
|
+
const { commands, matched } = mergeFountain(state, parsed);
|
|
1475
|
+
let live = false;
|
|
1476
|
+
for (const command of commands) ({ live } = await commit(command));
|
|
1477
|
+
const written = commands.filter((command) => command.type === "set_text").length;
|
|
1478
|
+
const created = matched.filter((item) => item.created).length;
|
|
1479
|
+
const receipt = describeSetAside(parsed.setAside);
|
|
1480
|
+
return ok(`Imported ${parsed.scenes.length} scene(s) from Final Draft: ${written} written onto cards, ${created} new card(s)${where(live)}.${receipt ? ` ${receipt}` : ""}`, matched);
|
|
1481
|
+
},
|
|
1482
|
+
);
|
|
1483
|
+
|
|
1484
|
+
server.registerTool(
|
|
1485
|
+
"page_count",
|
|
1486
|
+
{
|
|
1487
|
+
title: "Count the pages",
|
|
1488
|
+
description:
|
|
1489
|
+
"The open board paginated as a script — US Letter, Courier 12, fifty-five lines, headings kept with their scenes, dialogue broken with (MORE) and (CONT'D) — with the page each scene starts on. Written scenes are measured; unwritten ones set their change line as action.",
|
|
1490
|
+
inputSchema: {},
|
|
1491
|
+
},
|
|
1492
|
+
async () => {
|
|
1493
|
+
const { state } = await readBoard();
|
|
1494
|
+
const order = readingOrder(state.notes);
|
|
1495
|
+
const result = paginate(
|
|
1496
|
+
order.map((note) => ({ id: note.id, heading: sceneHeading(note).slice(1), text: note.text, change: note.change, written: Boolean(note.text && note.text.trim()) })),
|
|
1497
|
+
);
|
|
1498
|
+
const lines = result.scenes.map((scene) => {
|
|
1499
|
+
const note = order.find((item) => item.id === scene.id);
|
|
1500
|
+
return ` - ${scene.number}. ${note?.headline ?? scene.id} (${scene.id}) — p. ${scene.page}${scene.endPage !== scene.page ? `–${scene.endPage}` : ""}`;
|
|
1501
|
+
});
|
|
1502
|
+
const unwritten = order.filter((note) => !(note.text && note.text.trim())).length;
|
|
1503
|
+
if (order.length > 0 && unwritten === order.length) {
|
|
1504
|
+
return ok(
|
|
1505
|
+
`No pages to count yet: none of the ${order.length} scenes is written. The runtime is list_board's estimate from the cards' lengths — about ${formatPages(boardEighths(state))} of ${formatPages(state.targetEighths)} pages.`,
|
|
1506
|
+
{ pageCount: 0, unwritten, scenes: [] },
|
|
1507
|
+
);
|
|
1508
|
+
}
|
|
1509
|
+
const note = unwritten
|
|
1510
|
+
? [`${unwritten} of ${order.length} scenes are unwritten and count as one line each here; for the estimate from the cards' lengths, see list_board's runtime line.`]
|
|
1511
|
+
: [];
|
|
1512
|
+
return ok([`pages: ${result.pageCount} of ${Math.round(state.targetEighths / 8)}`, ...note, ...lines].join("\n"), result.scenes);
|
|
1513
|
+
},
|
|
1514
|
+
);
|
|
1515
|
+
|
|
1516
|
+
server.registerTool(
|
|
1517
|
+
"lock_numbers",
|
|
1518
|
+
{
|
|
1519
|
+
title: "Lock the scene numbers",
|
|
1520
|
+
description:
|
|
1521
|
+
"Once a draft has gone out: every scene keeps the number it has by the wall's order; a scene added between 14 and 15 becomes 14A, then 14B; moving cards never renumbers. Final Draft out carries the locked numbers. Ask the writer; it is a decision about the document going out.",
|
|
1522
|
+
inputSchema: {},
|
|
1523
|
+
},
|
|
1524
|
+
async () => {
|
|
1525
|
+
const { state } = await readBoard();
|
|
1526
|
+
const order = readingOrder(state.notes).map((note) => note.id);
|
|
1527
|
+
const { changed, result, live } = await commit({ type: "lock_numbers", order });
|
|
1528
|
+
if (!changed) return ok("Nothing to lock.");
|
|
1529
|
+
return ok(`Locked ${Object.keys(result.numbers).length} scene number(s)${where(live)}.`, result);
|
|
1530
|
+
},
|
|
1531
|
+
);
|
|
1532
|
+
|
|
1533
|
+
server.registerTool(
|
|
1534
|
+
"unlock_numbers",
|
|
1535
|
+
{ title: "Unlock the scene numbers", description: "Numbers follow the wall's order again.", inputSchema: {} },
|
|
1536
|
+
async () => {
|
|
1537
|
+
const { changed, live } = await commit({ type: "unlock_numbers" });
|
|
1538
|
+
return ok(changed ? `Unlocked${where(live)}.` : "The numbers were not locked.");
|
|
1539
|
+
},
|
|
1540
|
+
);
|
|
1541
|
+
|
|
1542
|
+
server.registerTool(
|
|
1543
|
+
"start_revision",
|
|
1544
|
+
{
|
|
1545
|
+
title: "Start a revision",
|
|
1546
|
+
description:
|
|
1547
|
+
`Name a revision and give it one of the industry's colours (${REVISION_COLORS.join(", ")}). Every card is snapshotted; from then on a changed line prints in the colour with a star in the margin, and a changed card wears the colour on the wall.`,
|
|
1548
|
+
inputSchema: { name: z.string().min(1), color: z.string().optional() },
|
|
1549
|
+
},
|
|
1550
|
+
async (args) => {
|
|
1551
|
+
const { changed, result, live } = await commit({ type: "start_revision", name: args.name, color: args.color });
|
|
1552
|
+
if (!changed) return ok("No revision started: give it a name.");
|
|
1553
|
+
return ok(`Started the ${result.color} revision "${result.name}"${where(live)}.`, { name: result.name, color: result.color, since: result.since });
|
|
1554
|
+
},
|
|
1555
|
+
);
|
|
1556
|
+
|
|
1557
|
+
server.registerTool(
|
|
1558
|
+
"end_revision",
|
|
1559
|
+
{ title: "End the revision", description: "The marks come off; the snapshot is dropped.", inputSchema: {} },
|
|
1560
|
+
async () => {
|
|
1561
|
+
const { changed, live } = await commit({ type: "end_revision" });
|
|
1562
|
+
return ok(changed ? `Revision ended${where(live)}.` : "No revision in progress.");
|
|
1563
|
+
},
|
|
1564
|
+
);
|
|
1565
|
+
|
|
1566
|
+
// --- The horizon's first surface (R28; Roadmap 2, item 9) -------------------
|
|
1567
|
+
|
|
1568
|
+
server.registerTool(
|
|
1569
|
+
"build_segment",
|
|
1570
|
+
{
|
|
1571
|
+
title: "Build a segment",
|
|
1572
|
+
description:
|
|
1573
|
+
"Hand a segment's brief — one card, or several in wall order — to the video tool. No tool is chosen yet: until one is, this returns the brief with a note saying so, and a take built elsewhere is filed with add_take. When a provider exists it will be a tool behind this same surface; the wall's records are what it is handed. The writer approves the brief before anything is made.",
|
|
1574
|
+
inputSchema: { ids: z.array(z.string()).min(1) },
|
|
1575
|
+
},
|
|
1576
|
+
async (args) => {
|
|
1577
|
+
const { state } = await readBoard();
|
|
1578
|
+
const { project } = await readProject();
|
|
1579
|
+
const board = project.boards.find((item) => item.id === project.activeBoardId);
|
|
1580
|
+
const brief = segmentBrief(state, args.ids, { title: board?.name });
|
|
1581
|
+
if (!brief) return ok(`No cards with ids ${args.ids.join(", ")}. Call list_board.`);
|
|
1582
|
+
const provider = env.PLOTCODER_VIDEO_PROVIDER;
|
|
1583
|
+
if (!provider) {
|
|
1584
|
+
return ok(`No video tool is configured (PLOTCODER_VIDEO_PROVIDER is unset). Hand this brief to one, then file what it makes with add_take.\n\n${brief}`);
|
|
1585
|
+
}
|
|
1586
|
+
return ok(`The video tool "${provider}" is named but not wired yet; this surface is where it goes. The brief:\n\n${brief}`);
|
|
1587
|
+
},
|
|
1588
|
+
);
|
|
1589
|
+
|
|
1590
|
+
server.registerTool(
|
|
1591
|
+
"list_takes",
|
|
1592
|
+
{
|
|
1593
|
+
title: "List the takes",
|
|
1594
|
+
description: "Through the account door: every take filed on the working project — by subject (a card id, or run:<ids>), name, and whether it is the chosen one.",
|
|
1595
|
+
inputSchema: {},
|
|
1596
|
+
},
|
|
1597
|
+
async () => {
|
|
1598
|
+
const account = await findAccount();
|
|
1599
|
+
if (!account) return shut("No account door: takes are files on the project, and need PLOTCODER_EMAIL and PLOTCODER_PASSWORD to read.");
|
|
1600
|
+
if (!account.projectId) return ok(noProjectYet());
|
|
1601
|
+
const { data, error } = await account.client.from("assets").select("id, subject, name, note, created_at").eq("project_id", account.projectId).eq("kind", "take").order("created_at");
|
|
1602
|
+
if (error) return ok(`Could not read the takes: ${error.message}`);
|
|
1603
|
+
const rows = data ?? [];
|
|
1604
|
+
return ok(
|
|
1605
|
+
[`takes: ${rows.length}`, ...rows.map((row) => ` - ${row.id} — ${row.subject} — ${row.name}${row.note === "chosen" ? " (chosen)" : ""}`)].join("\n"),
|
|
1606
|
+
rows,
|
|
1607
|
+
);
|
|
1608
|
+
},
|
|
1609
|
+
);
|
|
1610
|
+
|
|
1611
|
+
/** Upload a file by path into the project's bucket and file an assets row for it. */
|
|
1612
|
+
async function fileAsset(account, kind, subject, filePath, note = "") {
|
|
1613
|
+
const bytes = fs.readFileSync(filePath);
|
|
1614
|
+
const name = path.basename(filePath);
|
|
1615
|
+
const safe = name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || kind;
|
|
1616
|
+
const ext = path.extname(name).toLowerCase();
|
|
1617
|
+
const contentType =
|
|
1618
|
+
{ ".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".pdf": "application/pdf", ".txt": "text/plain", ".fountain": "text/plain", ".fdx": "application/xml" }[ext] ??
|
|
1619
|
+
"application/octet-stream";
|
|
1620
|
+
const storagePath = `${account.projectId}/${kind}/${crypto.randomUUID()}-${safe}`;
|
|
1621
|
+
const up = await account.client.storage.from("projects").upload(storagePath, bytes, { contentType, upsert: false });
|
|
1622
|
+
if (up.error) return { error: `Could not upload ${name}: ${up.error.message}` };
|
|
1623
|
+
const row = await account.client
|
|
1624
|
+
.from("assets")
|
|
1625
|
+
.insert({ project_id: account.projectId, kind, subject, path: storagePath, name, size: bytes.length, content_type: contentType, note })
|
|
1626
|
+
.select("id")
|
|
1627
|
+
.maybeSingle();
|
|
1628
|
+
if (row.error) {
|
|
1629
|
+
await account.client.storage.from("projects").remove([storagePath]);
|
|
1630
|
+
return { error: `Uploaded, but could not file ${name}: ${row.error.message}` };
|
|
1631
|
+
}
|
|
1632
|
+
return { id: row.data?.id, name };
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
server.registerTool(
|
|
1636
|
+
"add_take",
|
|
1637
|
+
{
|
|
1638
|
+
title: "Add a take",
|
|
1639
|
+
description:
|
|
1640
|
+
"Through the account door: file a take a video tool built — a file by path — on the working project, under a card's id or run:<ids joined by +>. The writer then sees it in the Takes panel and chooses.",
|
|
1641
|
+
inputSchema: { subject: z.string().min(1), path: z.string().min(1), chosen: z.boolean().optional() },
|
|
1642
|
+
},
|
|
1643
|
+
async (args) => {
|
|
1644
|
+
const account = await findAccount();
|
|
1645
|
+
if (!account) return shut("No account door: set PLOTCODER_EMAIL and PLOTCODER_PASSWORD to file takes on the project.");
|
|
1646
|
+
if (!account.projectId) return ok(noProjectYet());
|
|
1647
|
+
const filed = await fileAsset(account, "take", args.subject, args.path, args.chosen ? "chosen" : "");
|
|
1648
|
+
if (filed.error) return ok(filed.error);
|
|
1649
|
+
return ok(`Filed "${filed.name}" as a take on ${args.subject}${args.chosen ? ", chosen" : ""} (saved to the account; the writer's Takes panel has it).`, { id: filed.id, subject: args.subject });
|
|
1650
|
+
},
|
|
1651
|
+
);
|
|
1652
|
+
|
|
1653
|
+
server.registerTool(
|
|
1654
|
+
"add_picture",
|
|
1655
|
+
{
|
|
1656
|
+
title: "Add a picture to a person's page",
|
|
1657
|
+
description:
|
|
1658
|
+
"Through the account door: put a picture — an image file by path — on a person's page, by the character's id or name. The writer sees it in the page's gallery; the first picture is the face on their page.",
|
|
1659
|
+
inputSchema: { character: z.string().min(1), path: z.string().min(1) },
|
|
1660
|
+
},
|
|
1661
|
+
async (args) => {
|
|
1662
|
+
const account = await findAccount();
|
|
1663
|
+
if (!account) return shut("No account door: pictures are files on the project, and need PLOTCODER_EMAIL and PLOTCODER_PASSWORD to add.");
|
|
1664
|
+
if (!account.projectId) return ok(noProjectYet());
|
|
1665
|
+
const { state } = await readBoard();
|
|
1666
|
+
const wanted = args.character.trim().toLowerCase();
|
|
1667
|
+
const person = state.characters.find((item) => item.id === args.character) ?? state.characters.find((item) => item.name.trim().toLowerCase() === wanted);
|
|
1668
|
+
if (!person) return ok(`Nobody called "${args.character}" on this board. Call list_board for the cast, or add_character.`);
|
|
1669
|
+
const filed = await fileAsset(account, "picture", person.id, args.path);
|
|
1670
|
+
if (filed.error) return ok(filed.error);
|
|
1671
|
+
return ok(`Added "${filed.name}" to ${person.name}'s page (saved to the account; the writer's gallery has it).`, { id: filed.id, character: person.id });
|
|
1672
|
+
},
|
|
1673
|
+
);
|
|
1674
|
+
|
|
1675
|
+
server.registerTool(
|
|
1676
|
+
"list_files",
|
|
1677
|
+
{
|
|
1678
|
+
title: "List the project's files",
|
|
1679
|
+
description:
|
|
1680
|
+
"Through the account door: every file on the working project — pictures on people's pages, takes on cards, other files — with its id, kind, what it is about (a character id, a card id, or run:<ids>), name and size.",
|
|
1681
|
+
inputSchema: {},
|
|
1682
|
+
},
|
|
1683
|
+
async () => {
|
|
1684
|
+
const account = await findAccount();
|
|
1685
|
+
if (!account) return shut("No account door: files live on the project, and need PLOTCODER_EMAIL and PLOTCODER_PASSWORD to read.");
|
|
1686
|
+
if (!account.projectId) return ok(noProjectYet());
|
|
1687
|
+
const { data, error } = await account.client.from("assets").select("id, kind, subject, name, size, note, created_at").eq("project_id", account.projectId).order("created_at");
|
|
1688
|
+
if (error) return ok(`Could not read the files: ${error.message}`);
|
|
1689
|
+
const rows = data ?? [];
|
|
1690
|
+
return ok(
|
|
1691
|
+
[`files: ${rows.length}`, ...rows.map((row) => ` - ${row.id} — ${row.kind} — ${row.subject || "(the project)"} — ${row.name} (${row.size} bytes)${row.note === "chosen" ? " (chosen)" : ""}`)].join("\n"),
|
|
1692
|
+
rows,
|
|
1693
|
+
);
|
|
1694
|
+
},
|
|
1695
|
+
);
|
|
1696
|
+
|
|
1697
|
+
server.registerTool(
|
|
1698
|
+
"remove_file",
|
|
1699
|
+
{
|
|
1700
|
+
title: "Remove a file from the project",
|
|
1701
|
+
description: "Through the account door: remove one file — a picture, a take, anything — from the working project by its id from list_files. Cannot be undone: ask the writer first.",
|
|
1702
|
+
inputSchema: { id: z.string().min(1) },
|
|
1703
|
+
},
|
|
1704
|
+
async (args) => {
|
|
1705
|
+
const account = await findAccount();
|
|
1706
|
+
if (!account) return shut("No account door: files live on the project, and need PLOTCODER_EMAIL and PLOTCODER_PASSWORD to remove.");
|
|
1707
|
+
if (!account.projectId) return ok(noProjectYet());
|
|
1708
|
+
const found = await account.client.from("assets").select("id, path, name").eq("project_id", account.projectId).eq("id", args.id).maybeSingle();
|
|
1709
|
+
if (found.error) return ok(`Could not find the file: ${found.error.message}`);
|
|
1710
|
+
if (!found.data) return ok(`No file with id ${args.id} on this project. Call list_files.`);
|
|
1711
|
+
const gone = await account.client.from("assets").delete().eq("id", args.id);
|
|
1712
|
+
if (gone.error) return ok(`Could not remove ${found.data.name}: ${gone.error.message}`);
|
|
1713
|
+
await account.client.storage.from("projects").remove([found.data.path]);
|
|
1714
|
+
return ok(`Removed "${found.data.name}" (saved to the account; gone from every open wall).`, { id: args.id });
|
|
1715
|
+
},
|
|
1716
|
+
);
|
|
1717
|
+
|
|
1718
|
+
server.registerTool(
|
|
1719
|
+
"undo",
|
|
1720
|
+
{
|
|
1721
|
+
title: "Undo my last change",
|
|
1722
|
+
description:
|
|
1723
|
+
"Take back the last change this server made, restoring the board to what it was before that call. Refuses if the board has changed since — a person moved on, or another agent did — so it never tramples work; the person can always undo anything from the wall with ⌘Z. Call it again to go back further.",
|
|
1724
|
+
inputSchema: {},
|
|
1725
|
+
},
|
|
1726
|
+
async () => {
|
|
1727
|
+
const last = trail[trail.length - 1];
|
|
1728
|
+
if (!last) return ok(oneCall() ? "Nothing to undo here: through plotcoder-call every call is a fresh server, so undo works only from an MCP session. The writer can take any change back from the wall with ⌘Z." : "Nothing of mine to undo in this session.");
|
|
1729
|
+
const { state, rev, base } = await readBoard();
|
|
1730
|
+
if (JSON.stringify(state) !== last.after) {
|
|
1731
|
+
return ok(
|
|
1732
|
+
`Not undone: the board has changed since my ${last.what}. Undoing now would trample that. Ask the person to undo from the wall if they want it back.`,
|
|
1733
|
+
);
|
|
1734
|
+
}
|
|
1735
|
+
trail.pop();
|
|
1736
|
+
undone.push(last);
|
|
1737
|
+
const { boardId } = await readBoard();
|
|
1738
|
+
const live = await writeBoard(last.before, rev, base, boardId);
|
|
1739
|
+
return ok(
|
|
1740
|
+
`Undid ${last.what}${where(live)}. ${trail.length} more of mine can be undone.`,
|
|
1741
|
+
last.before,
|
|
1742
|
+
);
|
|
1743
|
+
},
|
|
1744
|
+
);
|
|
1745
|
+
|
|
1746
|
+
server.registerTool(
|
|
1747
|
+
"redo",
|
|
1748
|
+
{
|
|
1749
|
+
title: "Redo what I undid",
|
|
1750
|
+
description:
|
|
1751
|
+
"Put back the last change this server undid, newest first. Refuses if the board has changed since the undo — a person moved on, or another agent did — so it never tramples work. Any new change of mine clears what could be redone.",
|
|
1752
|
+
inputSchema: {},
|
|
1753
|
+
},
|
|
1754
|
+
async () => {
|
|
1755
|
+
const last = undone[undone.length - 1];
|
|
1756
|
+
if (!last) return ok("Nothing of mine to redo.");
|
|
1757
|
+
const { state, rev, base, boardId } = await readBoard();
|
|
1758
|
+
if (JSON.stringify(state) !== JSON.stringify(last.before)) {
|
|
1759
|
+
return ok(`Not redone: the board has changed since I undid my ${last.what}. Redoing now would trample that.`);
|
|
1760
|
+
}
|
|
1761
|
+
undone.pop();
|
|
1762
|
+
const after = JSON.parse(last.after);
|
|
1763
|
+
const live = await writeBoard(after, rev, base, boardId);
|
|
1764
|
+
trail.push(last);
|
|
1765
|
+
return ok(`Redid ${last.what}${where(live)}. ${undone.length} more can be redone.`, after);
|
|
1766
|
+
},
|
|
1767
|
+
);
|
|
1768
|
+
|
|
1769
|
+
server.registerTool(
|
|
1770
|
+
"set_plant",
|
|
1771
|
+
{
|
|
1772
|
+
title: "Fold the corner",
|
|
1773
|
+
description:
|
|
1774
|
+
`Fold the corner of cards — mark them as planting something — or unfold them. ${wordSentence("corner")} One thing, three words: the card's corner is folded, plants is the flag, and read_wall calls a fold with no payoff yet 'unpaid'. The setup arrow is create_arrow with kind 'setup'. Folding never moves a card.`,
|
|
1775
|
+
inputSchema: {
|
|
1776
|
+
ids: z.array(z.string()).min(1),
|
|
1777
|
+
plants: z.boolean(),
|
|
1778
|
+
},
|
|
1779
|
+
},
|
|
1780
|
+
async (args) => {
|
|
1781
|
+
const { result, live } = await commit({
|
|
1782
|
+
type: "set_plant",
|
|
1783
|
+
ids: args.ids,
|
|
1784
|
+
plants: args.plants,
|
|
1785
|
+
});
|
|
1786
|
+
const count = result?.length ?? 0;
|
|
1787
|
+
if (count === 0) return ok("No change: those cards were already that way, or the ids are not on the board.");
|
|
1788
|
+
return ok(
|
|
1789
|
+
args.plants
|
|
1790
|
+
? `${count} card(s) now plant something${where(live)}. read_wall will ask about each until a setup arrow pays it off.`
|
|
1791
|
+
: `${count} card(s) no longer marked as planting${where(live)}.`,
|
|
1792
|
+
result,
|
|
1793
|
+
);
|
|
1794
|
+
},
|
|
1795
|
+
);
|
|
1796
|
+
|
|
1797
|
+
// --- Characters -------------------------------------------------------
|
|
1798
|
+
|
|
1799
|
+
server.registerTool(
|
|
1800
|
+
"add_character",
|
|
1801
|
+
{
|
|
1802
|
+
title: "Add character",
|
|
1803
|
+
description:
|
|
1804
|
+
"Add a person to the board's cast — the roster every card casts from. One record per person: the same name twice is refused and the existing record returned. Add someone here before casting them on a card.",
|
|
1805
|
+
inputSchema: { name: z.string().min(1) },
|
|
1806
|
+
},
|
|
1807
|
+
async (args) => {
|
|
1808
|
+
const { changed, result, live } = await commit({ type: "add_character", name: args.name });
|
|
1809
|
+
if (!changed) {
|
|
1810
|
+
return result
|
|
1811
|
+
? ok(`Already in the cast as "${result.name}" (${result.id}). Use that id.`, result)
|
|
1812
|
+
: ok("No character added: the name was empty.");
|
|
1813
|
+
}
|
|
1814
|
+
return ok(`Added "${result.name}" to the cast${where(live)}.`, result);
|
|
1815
|
+
},
|
|
1816
|
+
);
|
|
1817
|
+
|
|
1818
|
+
server.registerTool(
|
|
1819
|
+
"rename_character",
|
|
1820
|
+
{
|
|
1821
|
+
title: "Rename character",
|
|
1822
|
+
description:
|
|
1823
|
+
"Rename a person in the cast by id. Every card they are on follows, because cards hold the id, not the name.",
|
|
1824
|
+
inputSchema: { id: z.string(), name: z.string().min(1) },
|
|
1825
|
+
},
|
|
1826
|
+
async (args) => {
|
|
1827
|
+
const { state, changed, result, live } = await commit({
|
|
1828
|
+
type: "rename_character",
|
|
1829
|
+
id: args.id,
|
|
1830
|
+
name: args.name,
|
|
1831
|
+
});
|
|
1832
|
+
if (!changed) {
|
|
1833
|
+
if (result) return ok(`Not renamed: "${result.name}" (${result.id}) already has that name.`);
|
|
1834
|
+
return state.characters.some((character) => character.id === args.id)
|
|
1835
|
+
? ok("Not renamed: that is already the name.")
|
|
1836
|
+
: ok(`No character with id ${args.id}. Call list_board for the cast.`);
|
|
1837
|
+
}
|
|
1838
|
+
return ok(`Renamed to "${result.name}"${where(live)}.`, result);
|
|
1839
|
+
},
|
|
1840
|
+
);
|
|
1841
|
+
|
|
1842
|
+
server.registerTool(
|
|
1843
|
+
"read_character",
|
|
1844
|
+
{
|
|
1845
|
+
title: "Read a person's page",
|
|
1846
|
+
description:
|
|
1847
|
+
"Read one person's page back, by id or by name: the five lines — looks, voice, wants, needs, notes — as they stand, and which cards the person is on. list_board says only which lines are written; this says what they say.",
|
|
1848
|
+
inputSchema: { id: z.string().optional(), name: z.string().optional() },
|
|
1849
|
+
},
|
|
1850
|
+
async (args) => {
|
|
1851
|
+
const key = (args.id ?? args.name ?? "").trim();
|
|
1852
|
+
if (!key) return ok("Say who: the person's id or name from list_board.");
|
|
1853
|
+
const { state } = await readBoard();
|
|
1854
|
+
const wanted = key.toLowerCase();
|
|
1855
|
+
const person = state.characters.find((item) => item.id === key) ?? state.characters.find((item) => item.name.trim().toLowerCase() === wanted);
|
|
1856
|
+
if (!person) return ok(`Nobody called "${key}" in the cast. Call list_board for the cast, or add_character.`);
|
|
1857
|
+
const on = state.notes.filter((note) => note.characterIds.includes(person.id));
|
|
1858
|
+
const lines = [
|
|
1859
|
+
`${person.name} (${person.id}) — on ${on.length} card${on.length === 1 ? "" : "s"}${on.length ? `: ${on.map((note) => `"${note.headline}"`).join(", ")}` : ""}`,
|
|
1860
|
+
...CHARACTER_FIELDS.map((field) => ` ${field}: ${(person[field] ?? "").trim() || "(empty)"}`),
|
|
1861
|
+
];
|
|
1862
|
+
return ok(lines.join("\n"), { ...person, cards: on.map((note) => note.id) });
|
|
1863
|
+
},
|
|
1864
|
+
);
|
|
1865
|
+
|
|
1866
|
+
server.registerTool(
|
|
1867
|
+
"update_character",
|
|
1868
|
+
{
|
|
1869
|
+
title: "Update a person's page",
|
|
1870
|
+
description:
|
|
1871
|
+
"Write any of the five lines of a person's page, by id or by name: looks (what a stranger would notice), voice (how they sound, and how it changes when they lie), wants (the clear want), needs (what they need and will not admit), notes (anything to pull up mid-scene). All text; pass only the lines you are setting; an empty string clears one. Ask the writer before inventing looks or a voice — the page is theirs.",
|
|
1872
|
+
inputSchema: {
|
|
1873
|
+
id: z.string().optional(),
|
|
1874
|
+
name: z.string().optional(),
|
|
1875
|
+
looks: z.string().optional(),
|
|
1876
|
+
voice: z.string().optional(),
|
|
1877
|
+
wants: z.string().optional(),
|
|
1878
|
+
needs: z.string().optional(),
|
|
1879
|
+
notes: z.string().optional(),
|
|
1880
|
+
},
|
|
1881
|
+
},
|
|
1882
|
+
async (args) => {
|
|
1883
|
+
const patch = {};
|
|
1884
|
+
for (const field of CHARACTER_FIELDS) {
|
|
1885
|
+
if (typeof args[field] === "string") patch[field] = args[field];
|
|
1886
|
+
}
|
|
1887
|
+
const key = (args.id ?? args.name ?? "").trim();
|
|
1888
|
+
if (!key) return ok("Say who: the person's id or name from list_board.");
|
|
1889
|
+
const { state: before } = await readBoard();
|
|
1890
|
+
const wanted = key.toLowerCase();
|
|
1891
|
+
const person = before.characters.find((item) => item.id === key) ?? before.characters.find((item) => item.name.trim().toLowerCase() === wanted);
|
|
1892
|
+
if (!person) return ok(`Nobody called "${key}" in the cast. Call list_board for the cast, or add_character.`);
|
|
1893
|
+
const { changed, result, live } = await commit({ type: "update_character", id: person.id, ...patch });
|
|
1894
|
+
if (!changed) {
|
|
1895
|
+
if (!result) return ok(`No character with id ${person.id}. Call list_board for the cast.`);
|
|
1896
|
+
return ok(`Nothing changed on ${result.name}'s page: those lines already read that way.`, result);
|
|
1897
|
+
}
|
|
1898
|
+
const trim = (text) => (text.length > 140 ? `${text.slice(0, 137)}…` : text);
|
|
1899
|
+
const lines = Object.keys(patch).map((field) => {
|
|
1900
|
+
const had = (person[field] ?? "").trim();
|
|
1901
|
+
const now = (result[field] ?? "").trim();
|
|
1902
|
+
return `${field}${had && now ? " (replacing what was there)" : had && !now ? " (cleared)" : ""}: ${now ? `"${trim(now)}"` : "(empty)"}`;
|
|
1903
|
+
});
|
|
1904
|
+
return ok(`Set ${lines.join("; ")} on ${result.name}'s page${where(live)}. A line set here replaces the old one.`, result);
|
|
1905
|
+
},
|
|
1906
|
+
);
|
|
1907
|
+
|
|
1908
|
+
server.registerTool(
|
|
1909
|
+
"set_location",
|
|
1910
|
+
{
|
|
1911
|
+
title: "Set where scenes happen",
|
|
1912
|
+
description:
|
|
1913
|
+
"Set the place of one or more cards: where the scene happens, as the writer would say it ('the piano shop', 'the flat, kitchen') — a phrase, not a slugline. The same phrase on several cards is one place in the Cast panel; an empty string clears it. list_board shows each card's place as 'at: …'.",
|
|
1914
|
+
inputSchema: { ids: z.array(z.string()).min(1), location: z.string() },
|
|
1915
|
+
},
|
|
1916
|
+
async (args) => {
|
|
1917
|
+
const { state, changed, result, live } = await commit({
|
|
1918
|
+
type: "set_location",
|
|
1919
|
+
ids: args.ids,
|
|
1920
|
+
location: args.location,
|
|
1921
|
+
});
|
|
1922
|
+
if (!changed) {
|
|
1923
|
+
const known = args.ids.filter((id) => state.notes.some((note) => note.id === id));
|
|
1924
|
+
if (known.length === 0) return ok(`No cards with ids ${args.ids.join(", ")}. Call list_board.`);
|
|
1925
|
+
return ok("No place changed: those cards already read that way.");
|
|
1926
|
+
}
|
|
1927
|
+
const place = result[0].location;
|
|
1928
|
+
return ok(
|
|
1929
|
+
`${result.length} card(s) now ${place ? `at ${place}` : "nowhere"}${where(live)}.`,
|
|
1930
|
+
result,
|
|
1931
|
+
);
|
|
1932
|
+
},
|
|
1933
|
+
);
|
|
1934
|
+
|
|
1935
|
+
server.registerTool(
|
|
1936
|
+
"remove_character",
|
|
1937
|
+
{
|
|
1938
|
+
title: "Remove character",
|
|
1939
|
+
description:
|
|
1940
|
+
"Remove a person from the cast by id. They leave every card they were on. The cards themselves stay.",
|
|
1941
|
+
inputSchema: { id: z.string() },
|
|
1942
|
+
},
|
|
1943
|
+
async (args) => {
|
|
1944
|
+
const { changed, live } = await commit({ type: "remove_character", id: args.id });
|
|
1945
|
+
if (!changed) return ok(`No character with id ${args.id}. Call list_board for the cast.`);
|
|
1946
|
+
return ok(`Removed from the cast and from every card${where(live)}.`);
|
|
1947
|
+
},
|
|
1948
|
+
);
|
|
1949
|
+
|
|
1950
|
+
server.registerTool(
|
|
1951
|
+
"cast",
|
|
1952
|
+
{
|
|
1953
|
+
title: "Cast a scene",
|
|
1954
|
+
description:
|
|
1955
|
+
"Set who is in one or more cards. Takes card ids and character names or ids; the list replaces the card's cast, so pass everyone who is in the scene. An empty list clears it. Names must already be in the cast — add_character first — and the tool says which names it did not know.",
|
|
1956
|
+
inputSchema: {
|
|
1957
|
+
noteIds: z.array(z.string()).min(1),
|
|
1958
|
+
characters: z.array(z.string()),
|
|
1959
|
+
},
|
|
1960
|
+
},
|
|
1961
|
+
async (args) => {
|
|
1962
|
+
const { state: before } = await readBoard();
|
|
1963
|
+
const unknown = [];
|
|
1964
|
+
const characterIds = [];
|
|
1965
|
+
for (const who of args.characters) {
|
|
1966
|
+
const match = before.characters.find(
|
|
1967
|
+
(character) =>
|
|
1968
|
+
character.id === who || character.name.trim().toLowerCase() === who.trim().toLowerCase(),
|
|
1969
|
+
);
|
|
1970
|
+
if (match) characterIds.push(match.id);
|
|
1971
|
+
else unknown.push(who);
|
|
1972
|
+
}
|
|
1973
|
+
if (unknown.length > 0) {
|
|
1974
|
+
return ok(
|
|
1975
|
+
`No cast set: not in the cast — ${unknown.map((name) => `"${name}"`).join(", ")}. Call add_character for each, then cast again.`,
|
|
1976
|
+
);
|
|
1977
|
+
}
|
|
1978
|
+
const { state, changed, result, live } = await commit({
|
|
1979
|
+
type: "set_cast",
|
|
1980
|
+
ids: args.noteIds,
|
|
1981
|
+
characterIds,
|
|
1982
|
+
});
|
|
1983
|
+
if (!changed) {
|
|
1984
|
+
const missing = args.noteIds.filter((id) => !state.notes.some((note) => note.id === id));
|
|
1985
|
+
return ok(
|
|
1986
|
+
missing.length > 0
|
|
1987
|
+
? `No cast set: no card with id ${missing.join(", ")}. Call list_board to check.`
|
|
1988
|
+
: "No change: those cards already had exactly that cast.",
|
|
1989
|
+
);
|
|
1990
|
+
}
|
|
1991
|
+
const names = characterIds.map(
|
|
1992
|
+
(id) => state.characters.find((character) => character.id === id)?.name ?? id,
|
|
1993
|
+
);
|
|
1994
|
+
return ok(
|
|
1995
|
+
`${result.length} card(s) now cast ${names.length ? names.join(", ") : "nobody"}${where(live)}.`,
|
|
1996
|
+
result,
|
|
1997
|
+
);
|
|
1998
|
+
},
|
|
1999
|
+
);
|
|
2000
|
+
|
|
2001
|
+
// --- Groups -----------------------------------------------------------
|
|
2002
|
+
|
|
2003
|
+
server.registerTool(
|
|
2004
|
+
"create_group",
|
|
2005
|
+
{
|
|
2006
|
+
title: "Create group",
|
|
2007
|
+
description:
|
|
2008
|
+
"Wrap two or more cards in a named frame — a sequence, a set piece, a run of beats that reads as one unit. The cards stay visible and keep their positions; a group is a frame around them, not a folder. A card can only be in one group, so grouping a card moves it out of any group it was already in. Call list_board first to get real card ids.",
|
|
2009
|
+
inputSchema: {
|
|
2010
|
+
noteIds: z.array(z.string()).min(2),
|
|
2011
|
+
title: z.string().optional(),
|
|
2012
|
+
},
|
|
2013
|
+
},
|
|
2014
|
+
async (args) => {
|
|
2015
|
+
const { state, changed, result, live } = await commit({
|
|
2016
|
+
type: "create_group",
|
|
2017
|
+
noteIds: args.noteIds,
|
|
2018
|
+
title: args.title,
|
|
2019
|
+
});
|
|
2020
|
+
if (!changed) {
|
|
2021
|
+
const missing = args.noteIds.filter(
|
|
2022
|
+
(id) => !state.notes.some((note) => note.id === id),
|
|
2023
|
+
);
|
|
2024
|
+
return ok(
|
|
2025
|
+
missing.length > 0
|
|
2026
|
+
? `No group made: not on the board — ${missing.join(", ")}. Call list_board to check the ids.`
|
|
2027
|
+
: "No group made: a group needs at least two cards.",
|
|
2028
|
+
);
|
|
2029
|
+
}
|
|
2030
|
+
return ok(`Grouped ${result.noteIds.length} cards as "${result.title}"${where(live)}.`, result);
|
|
2031
|
+
},
|
|
2032
|
+
);
|
|
2033
|
+
|
|
2034
|
+
server.registerTool(
|
|
2035
|
+
"rename_group",
|
|
2036
|
+
{
|
|
2037
|
+
title: "Rename group",
|
|
2038
|
+
description:
|
|
2039
|
+
"Retitle a group frame, e.g. 'Midpoint' or 'The heist'. Needs the group's id from list_board.",
|
|
2040
|
+
inputSchema: { id: z.string(), title: z.string().min(1) },
|
|
2041
|
+
},
|
|
2042
|
+
async (args) => {
|
|
2043
|
+
const { changed, live } = await commit({
|
|
2044
|
+
type: "rename_group",
|
|
2045
|
+
id: args.id,
|
|
2046
|
+
title: args.title,
|
|
2047
|
+
});
|
|
2048
|
+
if (!changed) return ok(`No group with id ${args.id}. Call list_board for the real ids.`);
|
|
2049
|
+
return ok(`Renamed the group to "${args.title}"${where(live)}.`);
|
|
2050
|
+
},
|
|
2051
|
+
);
|
|
2052
|
+
|
|
2053
|
+
server.registerTool(
|
|
2054
|
+
"ungroup",
|
|
2055
|
+
{
|
|
2056
|
+
title: "Ungroup",
|
|
2057
|
+
description:
|
|
2058
|
+
"Remove a group frame. The cards stay on the board exactly where they are — only the frame goes.",
|
|
2059
|
+
inputSchema: { id: z.string() },
|
|
2060
|
+
},
|
|
2061
|
+
async (args) => {
|
|
2062
|
+
const { changed, live } = await commit({ type: "ungroup", id: args.id });
|
|
2063
|
+
if (!changed) return ok(`No group with id ${args.id}. Call list_board for the real ids.`);
|
|
2064
|
+
return ok(`Ungrouped${where(live)}. The cards are untouched.`);
|
|
2065
|
+
},
|
|
2066
|
+
);
|
|
2067
|
+
|
|
2068
|
+
// --- Arrows -----------------------------------------------------------
|
|
2069
|
+
|
|
2070
|
+
server.registerTool(
|
|
2071
|
+
"create_arrow",
|
|
2072
|
+
{
|
|
2073
|
+
title: "Create arrow",
|
|
2074
|
+
description:
|
|
2075
|
+
"Draw a directed arrow from one card to another. kind 'follows' (the default) says what comes after what; kind 'setup' says the first card plants something the second pays off. Arrows are one-way: A→B does not create B→A. If you want both, call this twice — that is two arrows, not one two-headed line. A card cannot point at itself, and the same direction cannot be drawn twice, whatever its kind; use set_arrow_kind to change one.",
|
|
2076
|
+
inputSchema: { from: z.string(), to: z.string(), kind: arrowKindSchema.optional() },
|
|
2077
|
+
},
|
|
2078
|
+
async (args) => {
|
|
2079
|
+
const { state, changed, result, live } = await commit({
|
|
2080
|
+
type: "create_arrow",
|
|
2081
|
+
from: args.from,
|
|
2082
|
+
to: args.to,
|
|
2083
|
+
kind: args.kind,
|
|
2084
|
+
});
|
|
2085
|
+
if (!changed) {
|
|
2086
|
+
// Say which of the three reasons it was. "Something went wrong" makes an
|
|
2087
|
+
// agent retry the same call; naming the cause makes it fix the input.
|
|
2088
|
+
const onBoard = (id) => state.notes.some((note) => note.id === id);
|
|
2089
|
+
const why =
|
|
2090
|
+
args.from === args.to
|
|
2091
|
+
? "a card cannot point at itself"
|
|
2092
|
+
: !onBoard(args.from)
|
|
2093
|
+
? `there is no card with id ${args.from}`
|
|
2094
|
+
: !onBoard(args.to)
|
|
2095
|
+
? `there is no card with id ${args.to}`
|
|
2096
|
+
: "that arrow already exists";
|
|
2097
|
+
return ok(`No arrow drawn: ${why}. Call list_board to check.`);
|
|
2098
|
+
}
|
|
2099
|
+
const name = (id) => `"${state.notes.find((note) => note.id === id)?.headline ?? id}"`;
|
|
2100
|
+
const paidOff = result.kind === "setup" && state.notes.find((note) => note.id === args.from)?.plants ? ` The fold on ${name(args.from)} is paid off now; the wall stops asking where it comes back.` : "";
|
|
2101
|
+
return ok(
|
|
2102
|
+
result.kind === "setup"
|
|
2103
|
+
? `Drew ${name(args.from)} → ${name(args.to)} as a setup: the first plants what the second pays off${where(live)}.${paidOff}`
|
|
2104
|
+
: `Drew ${name(args.from)} → ${name(args.to)}: the second follows the first${where(live)}.`,
|
|
2105
|
+
result,
|
|
2106
|
+
);
|
|
2107
|
+
},
|
|
2108
|
+
);
|
|
2109
|
+
|
|
2110
|
+
server.registerTool(
|
|
2111
|
+
"set_arrow_kind",
|
|
2112
|
+
{
|
|
2113
|
+
title: "Set arrow kind",
|
|
2114
|
+
description:
|
|
2115
|
+
"Change what an arrow means: 'follows' (what comes after what) or 'setup' (the tail plants something the head pays off). Needs the arrow's id from list_board.",
|
|
2116
|
+
inputSchema: { id: z.string(), kind: arrowKindSchema },
|
|
2117
|
+
},
|
|
2118
|
+
async (args) => {
|
|
2119
|
+
const { state, changed, live } = await commit({
|
|
2120
|
+
type: "set_arrow_kind",
|
|
2121
|
+
id: args.id,
|
|
2122
|
+
kind: args.kind,
|
|
2123
|
+
});
|
|
2124
|
+
if (!changed) {
|
|
2125
|
+
return state.arrows.some((arrow) => arrow.id === args.id)
|
|
2126
|
+
? ok(`That arrow is already '${args.kind}'.`)
|
|
2127
|
+
: ok(`No arrow with id ${args.id}. Call list_board for the real ids.`);
|
|
2128
|
+
}
|
|
2129
|
+
const arrow = state.arrows.find((item) => item.id === args.id);
|
|
2130
|
+
const tail = arrow ? state.notes.find((note) => note.id === arrow.from) : null;
|
|
2131
|
+
const paidOff = args.kind === "setup" && tail?.plants ? ` The fold on "${tail.headline}" is paid off now; the wall stops asking where it comes back.` : "";
|
|
2132
|
+
return ok(`That arrow is now '${args.kind}'${where(live)}.${paidOff}`);
|
|
2133
|
+
},
|
|
2134
|
+
);
|
|
2135
|
+
|
|
2136
|
+
// --- The project ------------------------------------------------------
|
|
2137
|
+
|
|
2138
|
+
function describeBoards(project, boards) {
|
|
2139
|
+
return project.boards
|
|
2140
|
+
.map((board, index) => {
|
|
2141
|
+
const state = boards[board.id];
|
|
2142
|
+
const open = board.id === project.activeBoardId ? " (open)" : "";
|
|
2143
|
+
const shape =
|
|
2144
|
+
state && isBoardState(state)
|
|
2145
|
+
? `${state.notes.length} cards, about ${formatPages(boardEighths(normalizeState(state)))} of ${formatPages(normalizeState(state).targetEighths)} pages`
|
|
2146
|
+
: "no cards";
|
|
2147
|
+
return ` ${index + 1}. ${board.id} — "${board.name}"${open}: ${shape}`;
|
|
2148
|
+
})
|
|
2149
|
+
.join("\n");
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
server.registerTool(
|
|
2153
|
+
"list_boards",
|
|
2154
|
+
{
|
|
2155
|
+
title: "List boards",
|
|
2156
|
+
description:
|
|
2157
|
+
"The project: its name, its premise, and every board with id, name, and shape, marking the one that is open. Boards are in the writer's order — a season's episodes, or a writer's stories. Use the ids here for open_board, rename_board and delete_board.",
|
|
2158
|
+
inputSchema: {},
|
|
2159
|
+
},
|
|
2160
|
+
async () => {
|
|
2161
|
+
const { project, boards, live, base } = await readProject();
|
|
2162
|
+
return ok(
|
|
2163
|
+
[
|
|
2164
|
+
`Project "${project.name}" (${door(live, base)})`,
|
|
2165
|
+
`premise: ${project.premise ? `"${project.premise}"` : "(not set)"}`,
|
|
2166
|
+
`boards: ${project.boards.length}`,
|
|
2167
|
+
describeBoards(project, boards),
|
|
2168
|
+
].join("\n"),
|
|
2169
|
+
project,
|
|
2170
|
+
);
|
|
2171
|
+
},
|
|
2172
|
+
);
|
|
2173
|
+
|
|
2174
|
+
server.registerTool(
|
|
2175
|
+
"set_premise",
|
|
2176
|
+
{
|
|
2177
|
+
title: "Set the project's premise",
|
|
2178
|
+
description:
|
|
2179
|
+
"Set the project's premise: the series- or story-level line above every board's logline. An empty string clears it. Boards keep their own loglines.",
|
|
2180
|
+
inputSchema: { premise: z.string() },
|
|
2181
|
+
},
|
|
2182
|
+
async (args) => {
|
|
2183
|
+
const { project, boards, rev, base, live } = await readProject();
|
|
2184
|
+
const next = setPremise(project, args.premise);
|
|
2185
|
+
if (next === project) return ok("Premise unchanged.");
|
|
2186
|
+
await writeProject(next, boards, rev, base);
|
|
2187
|
+
return ok(`Premise ${next.premise ? `set to "${next.premise}"` : "cleared"}${where(live)}.`, next);
|
|
2188
|
+
},
|
|
2189
|
+
);
|
|
2190
|
+
|
|
2191
|
+
server.registerTool(
|
|
2192
|
+
"rename_project",
|
|
2193
|
+
{
|
|
2194
|
+
title: "Rename the project",
|
|
2195
|
+
description: "Rename the project — the name at the top of the wall, over every board.",
|
|
2196
|
+
inputSchema: { name: z.string().min(1) },
|
|
2197
|
+
},
|
|
2198
|
+
async (args) => {
|
|
2199
|
+
const { project, boards, rev, base, live } = await readProject();
|
|
2200
|
+
const next = renameProject(project, args.name);
|
|
2201
|
+
if (next === project) return ok("Project name unchanged.");
|
|
2202
|
+
await writeProject(next, boards, rev, base);
|
|
2203
|
+
return ok(`Project renamed to "${next.name}"${where(live)}.`, next);
|
|
2204
|
+
},
|
|
2205
|
+
);
|
|
2206
|
+
|
|
2207
|
+
// Reminders: the writer's principles, read before touching the wall.
|
|
2208
|
+
function currentReminders(reminders) {
|
|
2209
|
+
return Array.isArray(reminders) ? reminders : DEFAULT_REMINDERS;
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
server.registerTool(
|
|
2213
|
+
"list_reminders",
|
|
2214
|
+
{
|
|
2215
|
+
title: "List reminders",
|
|
2216
|
+
description:
|
|
2217
|
+
"The writer's reminders: the principles they keep in front of themselves (six built in, plus their own). Read these before building or reading a wall; they are the house style.",
|
|
2218
|
+
inputSchema: {},
|
|
2219
|
+
},
|
|
2220
|
+
async () => {
|
|
2221
|
+
const { reminders, live, project, base } = await readProject();
|
|
2222
|
+
const list = currentReminders(reminders);
|
|
2223
|
+
const own = list.filter((item) => !item.builtIn).length;
|
|
2224
|
+
return ok(
|
|
2225
|
+
[
|
|
2226
|
+
`reminders on "${project.name}" (${door(live, base)}): ${list.length} — ${list.length - own} the house principles the app starts with (built in), ${own} the writer's own${own === 0 ? "; add_reminder adds one the writer asks to keep" : ""}`,
|
|
2227
|
+
...list.map((item) => ` - ${item.id}${item.builtIn ? " (built in)" : ""} — ${item.title}: ${item.body}`),
|
|
2228
|
+
].join("\n"),
|
|
2229
|
+
list,
|
|
2230
|
+
);
|
|
2231
|
+
},
|
|
2232
|
+
);
|
|
2233
|
+
|
|
2234
|
+
server.registerTool(
|
|
2235
|
+
"add_reminder",
|
|
2236
|
+
{
|
|
2237
|
+
title: "Add a reminder",
|
|
2238
|
+
description:
|
|
2239
|
+
"Add a reminder to the writer's list: a body (the principle, a sentence or two) and an optional title; without one the first sentence is the title. Add only what the writer asked to keep in front of them.",
|
|
2240
|
+
inputSchema: { body: z.string().min(1), title: z.string().optional() },
|
|
2241
|
+
},
|
|
2242
|
+
async (args) => {
|
|
2243
|
+
const { project, boards, reminders, rev, base, live } = await readProject();
|
|
2244
|
+
const list = currentReminders(reminders);
|
|
2245
|
+
const body = args.body.trim();
|
|
2246
|
+
const title = args.title?.trim() || titleFromBody(body) || "Reminder";
|
|
2247
|
+
const reminder = { id: crypto.randomUUID(), title, body, builtIn: false, createdAt: new Date().toISOString() };
|
|
2248
|
+
await writeProject(project, boards, rev, base, [...list, reminder]);
|
|
2249
|
+
return ok(`Added reminder "${title}"${where(live)}.`, reminder);
|
|
2250
|
+
},
|
|
2251
|
+
);
|
|
2252
|
+
|
|
2253
|
+
server.registerTool(
|
|
2254
|
+
"remove_reminder",
|
|
2255
|
+
{
|
|
2256
|
+
title: "Remove a reminder",
|
|
2257
|
+
description: "Remove a reminder by id, built in or the writer's own. list_reminders has the ids.",
|
|
2258
|
+
inputSchema: { id: z.string() },
|
|
2259
|
+
},
|
|
2260
|
+
async (args) => {
|
|
2261
|
+
const { project, boards, reminders, rev, base, live } = await readProject();
|
|
2262
|
+
const list = currentReminders(reminders);
|
|
2263
|
+
if (!list.some((item) => item.id === args.id)) return ok(`No reminder with id ${args.id}. Call list_reminders.`);
|
|
2264
|
+
await writeProject(project, boards, rev, base, list.filter((item) => item.id !== args.id));
|
|
2265
|
+
return ok(`Removed reminder ${args.id}${where(live)}.`);
|
|
2266
|
+
},
|
|
2267
|
+
);
|
|
2268
|
+
|
|
2269
|
+
server.registerTool(
|
|
2270
|
+
"list_projects",
|
|
2271
|
+
{
|
|
2272
|
+
title: "List the writer's projects",
|
|
2273
|
+
description:
|
|
2274
|
+
"Through the account door (PLOTCODER_EMAIL and PLOTCODER_PASSWORD in the environment, no app open): every project the writer is on, newest first, with its people, marking the one this server is working. Through the dev bridge or the file there is one project, the open one.",
|
|
2275
|
+
inputSchema: {},
|
|
2276
|
+
},
|
|
2277
|
+
async () => {
|
|
2278
|
+
const account = await findAccount();
|
|
2279
|
+
if (!account) {
|
|
2280
|
+
if (accountRefusal) return ok(accountRefusal);
|
|
2281
|
+
const { project, live } = await readProject();
|
|
2282
|
+
return ok(`No account door: working "${project.name}" ${live ? "on the open app" : "from the file"}. Set PLOTCODER_EMAIL and PLOTCODER_PASSWORD to work the writer's account directly.`);
|
|
2283
|
+
}
|
|
2284
|
+
const projects = await accountProjects();
|
|
2285
|
+
return ok(
|
|
2286
|
+
[
|
|
2287
|
+
`projects: ${projects.length} (as ${account.email})${projects.length === 0 ? ` — ${noProjectYet()}` : ""}`,
|
|
2288
|
+
...projects.map((row) => ` - ${row.id} — "${row.record.name}"${row.id === account.projectId ? " (working)" : ""}: ${row.record.boards.length} board(s) · ${(row.people ?? []).join(", ")}`),
|
|
2289
|
+
].join("\n"),
|
|
2290
|
+
projects.map((row) => ({ id: row.id, name: row.record.name, boards: row.record.boards.length, people: row.people })),
|
|
2291
|
+
);
|
|
2292
|
+
},
|
|
2293
|
+
);
|
|
2294
|
+
|
|
2295
|
+
server.registerTool(
|
|
2296
|
+
"open_project",
|
|
2297
|
+
{
|
|
2298
|
+
title: "Open a project",
|
|
2299
|
+
description: "Through the account door: work another of the writer's projects, by name or id from list_projects. Every tool then works on it.",
|
|
2300
|
+
inputSchema: { project: z.string().min(1) },
|
|
2301
|
+
},
|
|
2302
|
+
async (args) => {
|
|
2303
|
+
const account = await findAccount();
|
|
2304
|
+
if (!account) return shut("No account door: there is one project here, the open one. Set PLOTCODER_EMAIL and PLOTCODER_PASSWORD to work the writer's account.");
|
|
2305
|
+
const projects = await accountProjects();
|
|
2306
|
+
const wanted = args.project.trim().toLowerCase();
|
|
2307
|
+
const found = projects.find((row) => row.id === args.project) ?? projects.find((row) => row.record.name.trim().toLowerCase() === wanted);
|
|
2308
|
+
if (!found) return ok(`No project called "${args.project}". Call list_projects.`);
|
|
2309
|
+
workingProject(found.id, found.record.name, projects.length);
|
|
2310
|
+
joinPresence(found.id);
|
|
2311
|
+
return ok(`Working "${found.record.name}" (${found.id}) now, as ${account.email}.${oneCallHint(found.record)}`, { id: found.id, name: found.record.name });
|
|
2312
|
+
},
|
|
2313
|
+
);
|
|
2314
|
+
|
|
2315
|
+
server.registerTool(
|
|
2316
|
+
"claim_account",
|
|
2317
|
+
{
|
|
2318
|
+
title: "Make the writer's account",
|
|
2319
|
+
description:
|
|
2320
|
+
"Make a PlotCoder account for the writer: their email and a password they chose (any password, no rules). Ask them for both; never invent a password. The account is the same one the door makes; the writer signs in at the wordmark on any device with it. This server then works the account for the rest of the session. The wall it was working on becomes the account's first project — unless it is the sample wall, which is never uploaded; then the account is empty until new_project. Refuses an address that already has an account.",
|
|
2321
|
+
inputSchema: { email: z.string().min(3), password: z.string().min(1) },
|
|
2322
|
+
},
|
|
2323
|
+
async (args) => {
|
|
2324
|
+
const email = args.email.trim().toLowerCase();
|
|
2325
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return ok("That does not look like an email address.");
|
|
2326
|
+
if (env.PLOTCODER_EMAIL && accountDoor) return ok(`Already signed in as ${accountDoor.email}. Sign out of the environment first to make another account.`);
|
|
2327
|
+
let response;
|
|
2328
|
+
try {
|
|
2329
|
+
response = await fetch(`${SUPABASE_URL}/functions/v1/account`, {
|
|
2330
|
+
method: "POST",
|
|
2331
|
+
headers: { "content-type": "application/json", apikey: SUPABASE_KEY, Authorization: `Bearer ${SUPABASE_KEY}` },
|
|
2332
|
+
body: JSON.stringify({ action: "claim", email, password: hashPassword(email, args.password) }),
|
|
2333
|
+
signal: AbortSignal.timeout(8000),
|
|
2334
|
+
});
|
|
2335
|
+
} catch (error) {
|
|
2336
|
+
return ok(`Could not reach the account service: ${error instanceof Error ? error.message : String(error)}`);
|
|
2337
|
+
}
|
|
2338
|
+
const payload = await response.json().catch(() => ({}));
|
|
2339
|
+
if (!response.ok) {
|
|
2340
|
+
if (payload.error === "taken") return ok(`${email} already has an account. Sign in with it (PLOTCODER_EMAIL and PLOTCODER_PASSWORD), or the writer can use Forgotten? at the door.`);
|
|
2341
|
+
return ok(`Could not make the account: ${payload.error ?? response.status}`);
|
|
2342
|
+
}
|
|
2343
|
+
// Work it from here on: the door reads the environment, so set it for this process.
|
|
2344
|
+
env.PLOTCODER_EMAIL = email;
|
|
2345
|
+
env.PLOTCODER_PASSWORD = args.password;
|
|
2346
|
+
accountDoor = null;
|
|
2347
|
+
accountTried = false;
|
|
2348
|
+
accountRefusal = null;
|
|
2349
|
+
const account = await findAccount();
|
|
2350
|
+
if (!account) return ok(`Made the account for ${email}, but could not sign in with it yet: ${accountRefusal ?? "no reason came back"}`);
|
|
2351
|
+
return ok(
|
|
2352
|
+
account.projectId
|
|
2353
|
+
? `Made the account for ${email} and working it now. The wall here is its first project; the writer signs in at the wordmark on any device with this email and the password they gave.`
|
|
2354
|
+
: `Made the account for ${email} and working it now. It holds no project yet: new_project starts the writer's first, and the sample wall in this folder was not uploaded. The writer signs in at the wordmark on any device with this email and the password they gave.`,
|
|
2355
|
+
{ email, project: account.projectId },
|
|
2356
|
+
);
|
|
2357
|
+
},
|
|
2358
|
+
);
|
|
2359
|
+
|
|
2360
|
+
server.registerTool(
|
|
2361
|
+
"new_project",
|
|
2362
|
+
{
|
|
2363
|
+
title: "Start a project",
|
|
2364
|
+
description:
|
|
2365
|
+
"Through the account door: start a new project of the writer's with this name — one empty board, nothing on it — and work it from now on. The writer sees it under Projects on every device.",
|
|
2366
|
+
inputSchema: { name: z.string().min(1), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
|
|
2367
|
+
},
|
|
2368
|
+
async (args) => {
|
|
2369
|
+
const account = await findAccount();
|
|
2370
|
+
if (!account) return shut("No account door: there is one project here, the open one. Set PLOTCODER_EMAIL and PLOTCODER_PASSWORD to start another on the writer's account.");
|
|
2371
|
+
const record = renameProject(emptyProject(), args.name.trim());
|
|
2372
|
+
const inserted = await account.client.from("projects").insert({ id: record.id, record, reminders: null, rev: 1 });
|
|
2373
|
+
if (inserted.error) return ok(`Could not start the project: ${inserted.error.message}`);
|
|
2374
|
+
const target = args.pages ?? args.minutes;
|
|
2375
|
+
const state = target === undefined ? emptyState() : { ...emptyState(), targetEighths: toEighths(target) };
|
|
2376
|
+
const board = await account.client.from("boards").insert({ id: record.activeBoardId, project_id: record.id, state, rev: 1, updated_by: null });
|
|
2377
|
+
if (board.error) return ok(`Started "${record.name}" but could not make its first board: ${board.error.message}`);
|
|
2378
|
+
workingProject(record.id, record.name, (account.projectCount ?? 0) + 1);
|
|
2379
|
+
joinPresence(record.id);
|
|
2380
|
+
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.`;
|
|
2381
|
+
return ok(`Started "${record.name}" (${record.id}) and working it now, as ${account.email}.${targetLine}${oneCallHint(record)}`, { id: record.id, name: record.name, targetEighths: state.targetEighths });
|
|
2382
|
+
},
|
|
2383
|
+
);
|
|
2384
|
+
|
|
2385
|
+
|
|
2386
|
+
server.registerTool(
|
|
2387
|
+
"delete_project",
|
|
2388
|
+
{
|
|
2389
|
+
title: "Delete a project",
|
|
2390
|
+
description:
|
|
2391
|
+
"Through the account door: delete one of the writer's own projects, by name or id from list_projects — its boards, its cards and its files. Cannot be undone, not from the wall either: ask the writer first, and export_project first if they might want it back. Without confirm it only says what would go; pass confirm: true to delete. A project merely shared with the writer is not theirs to delete.",
|
|
2392
|
+
inputSchema: { project: z.string().min(1), confirm: z.boolean().optional() },
|
|
2393
|
+
},
|
|
2394
|
+
async (args) => {
|
|
2395
|
+
const account = await findAccount();
|
|
2396
|
+
if (!account) return shut("No account door: there is one project here, the open one; delete_board removes its boards. Set PLOTCODER_EMAIL and PLOTCODER_PASSWORD to work the writer's account.");
|
|
2397
|
+
const { all } = await ownedAndShared();
|
|
2398
|
+
const wanted = args.project.trim().toLowerCase();
|
|
2399
|
+
const found = all.find((row) => row.id === args.project) ?? all.find((row) => row.record.name.trim().toLowerCase() === wanted);
|
|
2400
|
+
if (!found) return ok(`No project called "${args.project}". Call list_projects.`);
|
|
2401
|
+
if (found.owner !== account.user.id) return ok(`"${found.record.name}" is not the writer's to delete: it is shared with them by ${(found.people ?? [])[0] ?? "its owner"}. Only its owner can delete it.`);
|
|
2402
|
+
const plan = await deletionPlan(found);
|
|
2403
|
+
if (!args.confirm) return ok(`Deleting ${describePlan(plan)} cannot be undone, not from the wall either. Ask the writer; export_project first if they might want it back; then pass confirm: true.`, plan);
|
|
2404
|
+
await deleteProjectRows(plan);
|
|
2405
|
+
const next = await workWhatIsLeft([plan.id]);
|
|
2406
|
+
return ok(`Deleted ${describePlan(plan)}, as ${account.email}. Gone from every device the writer signs in on.${next}`, plan);
|
|
2407
|
+
},
|
|
2408
|
+
);
|
|
2409
|
+
|
|
2410
|
+
server.registerTool(
|
|
2411
|
+
"empty_account",
|
|
2412
|
+
{
|
|
2413
|
+
title: "Empty the account",
|
|
2414
|
+
description:
|
|
2415
|
+
"Through the account door: delete every project the writer owns — boards, cards and files — and leave the account itself, signed in and empty. Projects merely shared with the writer by others stay. Cannot be undone: ask the writer first, and export_project each project first if they might want it back. Without confirm it only says what would go; pass confirm: true to empty.",
|
|
2416
|
+
inputSchema: { confirm: z.boolean().optional() },
|
|
2417
|
+
},
|
|
2418
|
+
async (args) => {
|
|
2419
|
+
const account = await findAccount();
|
|
2420
|
+
if (!account) return shut("No account door: there is one project here, the open one. Set PLOTCODER_EMAIL and PLOTCODER_PASSWORD to work the writer's account.");
|
|
2421
|
+
const { owned, shared } = await ownedAndShared();
|
|
2422
|
+
const plans = [];
|
|
2423
|
+
for (const row of owned) plans.push(await deletionPlan(row));
|
|
2424
|
+
const survive = shared.length ? ` ${shared.length} project(s) shared with the writer by others stay: ${shared.map((row) => `"${row.record.name}"`).join(", ")}.` : "";
|
|
2425
|
+
if (plans.length === 0) return ok(`The account holds nothing of the writer's own to delete.${survive}`);
|
|
2426
|
+
if (!args.confirm) return ok(`Emptying the account deletes ${plans.length} project(s) of the writer's own: ${plans.map(describePlan).join("; ")}. Cannot be undone. Ask the writer; export_project each first if they might want them back; then pass confirm: true.${survive}`, plans);
|
|
2427
|
+
for (const plan of plans) await deleteProjectRows(plan);
|
|
2428
|
+
const next = await workWhatIsLeft(plans.map((plan) => plan.id));
|
|
2429
|
+
return ok(`Emptied the account as ${account.email}: deleted ${plans.map(describePlan).join("; ")}.${survive}${next}`, plans);
|
|
2430
|
+
},
|
|
2431
|
+
);
|
|
2432
|
+
|
|
2433
|
+
server.registerTool(
|
|
2434
|
+
"delete_account",
|
|
2435
|
+
{
|
|
2436
|
+
title: "Delete the account",
|
|
2437
|
+
description:
|
|
2438
|
+
"Through the account door: delete the writer's account itself — every project they own, its files, and the sign-in. Cannot be undone; the address can be claimed again afterwards, empty. Ask the writer first, and export_project first if they might want anything back. Without confirm it only says what would go; pass confirm: true to delete. The door is shut afterwards.",
|
|
2439
|
+
inputSchema: { confirm: z.boolean().optional() },
|
|
2440
|
+
},
|
|
2441
|
+
async (args) => {
|
|
2442
|
+
const account = await findAccount();
|
|
2443
|
+
if (!account) return shut("No account door: nothing here is an account. Set PLOTCODER_EMAIL and PLOTCODER_PASSWORD to work the writer's account.");
|
|
2444
|
+
const { owned, shared } = await ownedAndShared();
|
|
2445
|
+
const plans = [];
|
|
2446
|
+
for (const row of owned) plans.push(await deletionPlan(row));
|
|
2447
|
+
const what = plans.length ? ` and ${plans.length} project(s) of the writer's own: ${plans.map(describePlan).join("; ")}` : "";
|
|
2448
|
+
const survive = shared.length ? ` ${shared.length} project(s) shared with the writer by others stay with their owners.` : "";
|
|
2449
|
+
if (!args.confirm) return ok(`Deleting the account ${account.email} takes the sign-in${what}. Cannot be undone; the address can be claimed again, empty. Ask the writer; export_project first; then pass confirm: true.${survive}`, plans);
|
|
2450
|
+
const session = await account.client.auth.getSession();
|
|
2451
|
+
const token = session.data.session?.access_token;
|
|
2452
|
+
if (!token) return ok("Could not delete the account: no session token came back. Nothing was deleted; sign in again and retry.");
|
|
2453
|
+
let response;
|
|
2454
|
+
try {
|
|
2455
|
+
response = await fetch(`${SUPABASE_URL}/functions/v1/account`, {
|
|
2456
|
+
method: "POST",
|
|
2457
|
+
headers: { "content-type": "application/json", apikey: SUPABASE_KEY, Authorization: `Bearer ${token}` },
|
|
2458
|
+
body: JSON.stringify({ action: "delete_account", email: account.email }),
|
|
2459
|
+
signal: AbortSignal.timeout(15000),
|
|
2460
|
+
});
|
|
2461
|
+
} catch (error) {
|
|
2462
|
+
return ok(`Could not reach the account service: ${error instanceof Error ? error.message : String(error)}. Nothing was deleted.`);
|
|
2463
|
+
}
|
|
2464
|
+
const payload = await response.json().catch(() => ({}));
|
|
2465
|
+
if (!response.ok) return ok(`The account service refused to delete the account: ${payload.error ?? response.status}. Nothing was deleted.`);
|
|
2466
|
+
if (account.channel) void account.client.removeChannel(account.channel);
|
|
2467
|
+
await account.client.auth.signOut().catch(() => {});
|
|
2468
|
+
const email = account.email;
|
|
2469
|
+
accountDoor = null;
|
|
2470
|
+
accountTried = true;
|
|
2471
|
+
accountRefusal = `The account ${email} was deleted at the writer's ask, so the door is shut. claim_account makes a new one with that address or another.`;
|
|
2472
|
+
return ok(`Deleted the account ${email}${what}. The writer cannot sign in with it any more; claim_account makes a new one. This server's door is shut now.${survive}`, plans);
|
|
2473
|
+
},
|
|
2474
|
+
);
|
|
2475
|
+
|
|
2476
|
+
server.registerTool(
|
|
2477
|
+
"export_project",
|
|
2478
|
+
{
|
|
2479
|
+
title: "Save the project as a file",
|
|
2480
|
+
description:
|
|
2481
|
+
"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.",
|
|
2482
|
+
inputSchema: { path: z.string().optional() },
|
|
2483
|
+
},
|
|
2484
|
+
async (args) => {
|
|
2485
|
+
if (args.path && hosted()) return ok("The hosted door has no disk to write to: call export_project without a path and the reply's JSON is the file.");
|
|
2486
|
+
const { project, boards, reminders } = await readProject();
|
|
2487
|
+
const file = toProjectFile({ project, boards, reminders: reminders ?? null });
|
|
2488
|
+
const cards = countCards(boards);
|
|
2489
|
+
const what = `"${project.name}": ${project.boards.length} board(s), ${cards} card(s)${reminders?.length ? `, ${reminders.length} reminder(s)` : ""}${project.structures?.length ? `, ${project.structures.length} structure(s)` : ""}. Pictures and takes on the account are not in the file`;
|
|
2490
|
+
if (args.path) {
|
|
2491
|
+
fs.mkdirSync(path.dirname(path.resolve(args.path)), { recursive: true });
|
|
2492
|
+
fs.writeFileSync(args.path, JSON.stringify(file, null, 2));
|
|
2493
|
+
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 });
|
|
2494
|
+
}
|
|
2495
|
+
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);
|
|
2496
|
+
},
|
|
2497
|
+
);
|
|
2498
|
+
|
|
2499
|
+
server.registerTool(
|
|
2500
|
+
"import_project",
|
|
2501
|
+
{
|
|
2502
|
+
title: "Open a project file",
|
|
2503
|
+
description:
|
|
2504
|
+
"Bring a project file — the one Save project writes, or export_project — in. Through the account door it becomes a NEW project on the writer's account, worked from then on; nothing already there is touched. Through the open app or the file it replaces the project there, as Open project does: without confirm it says what it would replace; pass confirm: true to do it. Pass path or text.",
|
|
2505
|
+
inputSchema: { path: z.string().optional(), text: z.string().optional(), confirm: z.boolean().optional() },
|
|
2506
|
+
},
|
|
2507
|
+
async (args) => {
|
|
2508
|
+
if (args.path && hosted()) return ok("The hosted door has no disk to read from: pass the file's contents as text.");
|
|
2509
|
+
const source = args.text ?? (args.path ? fs.readFileSync(args.path, "utf8") : null);
|
|
2510
|
+
if (source === null) return ok("Nothing to import: pass a path or text.");
|
|
2511
|
+
let parsed;
|
|
2512
|
+
try {
|
|
2513
|
+
parsed = JSON.parse(source);
|
|
2514
|
+
} catch {
|
|
2515
|
+
return ok("That is not JSON, so not a PlotCoder project file.");
|
|
2516
|
+
}
|
|
2517
|
+
const opened = fromProjectFile(parsed);
|
|
2518
|
+
if (!opened) return ok("That is not a PlotCoder project file: it holds no project record and no board.");
|
|
2519
|
+
const cards = countCards(opened.boards);
|
|
2520
|
+
if (accountEnv()) {
|
|
2521
|
+
const account = await findAccount();
|
|
2522
|
+
if (!account) return ok(accountRefusal ?? "The account door is shut.");
|
|
2523
|
+
const { renamed, ...fresh } = reidentifyProject(opened.project);
|
|
2524
|
+
const record = normalizeProject(fresh);
|
|
2525
|
+
const inserted = await account.client.from("projects").insert({ id: record.id, record, reminders: opened.reminders, rev: 1 });
|
|
2526
|
+
if (inserted.error) return ok(`Could not import the project: ${inserted.error.message}`);
|
|
2527
|
+
for (const meta of record.boards) {
|
|
2528
|
+
const oldId = Object.keys(renamed).find((key) => renamed[key] === meta.id);
|
|
2529
|
+
const state = oldId && isBoardState(opened.boards[oldId]) ? normalizeState(opened.boards[oldId]) : emptyState();
|
|
2530
|
+
const board = await account.client.from("boards").insert({ id: meta.id, project_id: record.id, state, rev: 1, updated_by: null });
|
|
2531
|
+
if (board.error) return ok(`Imported "${record.name}" but could not make its board "${meta.name}": ${board.error.message}`);
|
|
2532
|
+
}
|
|
2533
|
+
workingProject(record.id, record.name, (account.projectCount ?? 0) + 1);
|
|
2534
|
+
joinPresence(record.id);
|
|
2535
|
+
return ok(
|
|
2536
|
+
`Imported "${record.name}" onto the account as a new project (${record.id}): ${record.boards.length} board(s), ${cards} card(s). Working it now, as ${account.email}; the writer sees it under Projects on every device.${oneCallHint(record)}`,
|
|
2537
|
+
{ id: record.id, name: record.name, boards: record.boards.length, cards },
|
|
2538
|
+
);
|
|
2539
|
+
}
|
|
2540
|
+
const { project: current, boards: currentBoards, rev, base, live } = await readProject();
|
|
2541
|
+
const incoming = `"${opened.project.name}" (${opened.project.boards.length} board(s), ${cards} card(s))`;
|
|
2542
|
+
const here = `"${current.name}" (${current.boards.length} board(s), ${countCards(currentBoards)} card(s)) on ${live ? "the open app" : "the file"}`;
|
|
2543
|
+
if (!args.confirm) return ok(`Importing ${incoming} here would replace ${here}, as Open project does. Ask the writer; export_project first if they might want it back; then pass confirm: true.`);
|
|
2544
|
+
const written = await writeProject(opened.project, opened.boards, rev, base, opened.reminders);
|
|
2545
|
+
const active = opened.boards[opened.project.activeBoardId] ?? emptyState();
|
|
2546
|
+
const { rev: boardRev } = await readBoard();
|
|
2547
|
+
await writeBoard(active, boardRev, base, opened.project.activeBoardId);
|
|
2548
|
+
trail.length = 0;
|
|
2549
|
+
undone.length = 0;
|
|
2550
|
+
return ok(`Imported ${incoming}, replacing ${here}${where(written)}. Nothing of mine is left to undo.`, { id: opened.project.id, name: opened.project.name, boards: opened.project.boards.length, cards });
|
|
2551
|
+
},
|
|
2552
|
+
);
|
|
2553
|
+
|
|
2554
|
+
server.registerTool(
|
|
2555
|
+
"open_board",
|
|
2556
|
+
{
|
|
2557
|
+
title: "Open board",
|
|
2558
|
+
description:
|
|
2559
|
+
"Open another board of the project by id, name, or number from list_boards. Every card tool then works on that board; the open wall switches too.",
|
|
2560
|
+
inputSchema: { board: z.union([z.string().min(1), z.number()]) },
|
|
2561
|
+
},
|
|
2562
|
+
async (args) => {
|
|
2563
|
+
const { project, boards, rev, base } = await readProject();
|
|
2564
|
+
const target = findBoard(project, String(args.board));
|
|
2565
|
+
if (!target) return ok(`No board matches "${args.board}". Call list_boards for the real ones.`);
|
|
2566
|
+
if (target.id === project.activeBoardId) return ok(`"${target.name}" is already open.`);
|
|
2567
|
+
const { live } = await openBoardEverywhere(project, boards, rev, base, target.id);
|
|
2568
|
+
return ok(`Opened "${target.name}"${where(live)}.`, target);
|
|
2569
|
+
},
|
|
2570
|
+
);
|
|
2571
|
+
|
|
2572
|
+
server.registerTool(
|
|
2573
|
+
"new_board",
|
|
2574
|
+
{
|
|
2575
|
+
title: "New board",
|
|
2576
|
+
description:
|
|
2577
|
+
"Add a board to the project and open it: an empty wall with the logline placeholder, under the same premise, with the same target length as the board that was open. Nothing else is touched — the other boards stay as they are. Name it for what it is: an episode, a draft, a story.",
|
|
2578
|
+
inputSchema: { name: z.string().optional() },
|
|
2579
|
+
},
|
|
2580
|
+
async (args) => {
|
|
2581
|
+
const { project, boards, rev, base } = await readProject();
|
|
2582
|
+
const previous = boards[project.activeBoardId];
|
|
2583
|
+
const target =
|
|
2584
|
+
previous && isBoardState(previous) ? normalizeState(previous).targetEighths : undefined;
|
|
2585
|
+
const { project: next, board } = addBoard(project, args.name ?? "");
|
|
2586
|
+
const fresh = { ...emptyState(), ...(target ? { targetEighths: target } : {}) };
|
|
2587
|
+
const { live } = await openBoardEverywhere(next, { ...boards, [board.id]: fresh }, rev, base, board.id);
|
|
2588
|
+
return ok(
|
|
2589
|
+
`Added "${board.name}" (${board.id}) and opened it${where(live)}. It is empty. The logline is the story's question when the writer has one — leave it empty rather than invent it — and the cards come next.${next.name === "Untitled project" ? " The project is still \"Untitled project\": rename_project names it." : ""}${next.boards.length === 2 && isSampleWall(isBoardState(boards[next.boards[0].id]) ? normalizeState(boards[next.boards[0].id]) : emptyState()) ? " The sample stays as Board 1; delete_board drops it." : ""}`,
|
|
2590
|
+
board,
|
|
2591
|
+
);
|
|
2592
|
+
},
|
|
2593
|
+
);
|
|
2594
|
+
|
|
2595
|
+
server.registerTool(
|
|
2596
|
+
"rename_board",
|
|
2597
|
+
{
|
|
2598
|
+
title: "Rename board",
|
|
2599
|
+
description: "Rename a board of the project by id, name, or number.",
|
|
2600
|
+
inputSchema: { board: z.union([z.string().min(1), z.number()]), name: z.string().min(1) },
|
|
2601
|
+
},
|
|
2602
|
+
async (args) => {
|
|
2603
|
+
const { project, boards, rev, base } = await readProject();
|
|
2604
|
+
const target = findBoard(project, String(args.board));
|
|
2605
|
+
if (!target) return ok(`No board matches "${args.board}". Call list_boards for the real ones.`);
|
|
2606
|
+
const next = renameBoard(project, target.id, args.name);
|
|
2607
|
+
if (next === project) return ok(`"${target.name}" already has that name.`);
|
|
2608
|
+
const live = await writeProject(next, boards, rev, base);
|
|
2609
|
+
return ok(`Renamed to "${args.name.trim()}"${where(live)}.`);
|
|
2610
|
+
},
|
|
2611
|
+
);
|
|
2612
|
+
|
|
2613
|
+
server.registerTool(
|
|
2614
|
+
"delete_board",
|
|
2615
|
+
{
|
|
2616
|
+
title: "Delete board",
|
|
2617
|
+
description:
|
|
2618
|
+
"Remove a board and everything on it. This cannot be undone — not from the wall either — so ask the writer first, say how many cards it holds, and suggest Save project. The last board of a project cannot be deleted. If the open board goes, the one before it opens.",
|
|
2619
|
+
inputSchema: { board: z.union([z.string().min(1), z.number()]) },
|
|
2620
|
+
},
|
|
2621
|
+
async (args) => {
|
|
2622
|
+
const { project, boards, rev, base } = await readProject();
|
|
2623
|
+
const target = findBoard(project, String(args.board));
|
|
2624
|
+
if (!target) return ok(`No board matches "${args.board}". Call list_boards for the real ones.`);
|
|
2625
|
+
if (project.boards.length <= 1) return ok("Not deleted: a project keeps at least one board.");
|
|
2626
|
+
const next = removeBoard(project, target.id);
|
|
2627
|
+
const remaining = { ...boards };
|
|
2628
|
+
delete remaining[target.id];
|
|
2629
|
+
if (next.activeBoardId !== project.activeBoardId) {
|
|
2630
|
+
const { live } = await openBoardEverywhere(next, remaining, rev, base, next.activeBoardId);
|
|
2631
|
+
return ok(`Deleted "${target.name}" and opened "${boardById(next, next.activeBoardId)?.name}"${where(live)}.`);
|
|
2632
|
+
}
|
|
2633
|
+
const live = await writeProject(next, remaining, rev, base);
|
|
2634
|
+
return ok(`Deleted "${target.name}"${where(live)}.`);
|
|
2635
|
+
},
|
|
2636
|
+
);
|
|
2637
|
+
|
|
2638
|
+
server.registerTool(
|
|
2639
|
+
"delete_arrow",
|
|
2640
|
+
{
|
|
2641
|
+
title: "Delete arrow",
|
|
2642
|
+
description:
|
|
2643
|
+
"Remove one arrow by its id, which list_board reports. This deletes that direction only: removing A→B leaves B→A alone.",
|
|
2644
|
+
inputSchema: { id: z.string() },
|
|
2645
|
+
},
|
|
2646
|
+
async (args) => {
|
|
2647
|
+
const { changed, live } = await commit({ type: "delete_arrow", id: args.id });
|
|
2648
|
+
if (!changed) return ok(`No arrow with id ${args.id}. Call list_board for the real ids.`);
|
|
2649
|
+
return ok(`Deleted that arrow${where(live)}. Any arrow the other way is untouched.`);
|
|
2650
|
+
},
|
|
2651
|
+
);
|
|
2652
|
+
|
|
2653
|
+
|
|
2654
|
+
return { server, log, accountEnv, boardFile: BOARD_FILE };
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
/** The stdio door: the server for this process, on stdin and stdout. */
|
|
2658
|
+
export async function serveStdio(env = process.env) {
|
|
2659
|
+
const { server, log, accountEnv, boardFile } = createPlotcoderServer(env);
|
|
2660
|
+
const transport = new StdioServerTransport();
|
|
2661
|
+
await server.connect(transport);
|
|
2662
|
+
log(accountEnv() ? `ready. account door: ${env.PLOTCODER_EMAIL} (signs in on the first call)` : `ready. board file: ${boardFile}`);
|
|
2663
|
+
}
|