plotcoder-board 0.1.34 → 0.1.36

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 CHANGED
@@ -4,6 +4,8 @@ A storylining app for screenwriters, live at [plotcoder.com](http://plotcoder.co
4
4
 
5
5
  PlotCoder is a set of tools for building a storyline, covering the activities a writer does today in Final Draft. It is designed so that an **agent driven by a person** has every one of those tools: the person directs, the agent operates, and the board on screen is the person's window onto the same records. The first tool is the wall, a digital corkboard for breaking and rearranging plot before writing the script, because that is the part of the job Final Draft does worst. Pages come last. Once the tools exist, workflows get launched on top of them. The horizon, a long way off: a person makes a storyline they believe in, then the app helps them drive agents that build segments of the movie with video generation tools.
6
6
 
7
+ **Using it as a writer:** [plotcoder.com/writers.html](https://plotcoder.com/writers.html) says how, from the door to the script out, the agent first. Its source is `public/writers.html`, and a change to a gesture or a sheet changes it in the same pull request (R63).
8
+
7
9
  The full statement of purpose, every decision, and every requirement lives in [REQUIREMENTS.md](REQUIREMENTS.md). Read it before changing anything. It is the source of truth; this file is the front door.
8
10
 
9
11
  ## The method the tools serve
@@ -105,4 +107,4 @@ next round into a test of `claim_account` instead of the door it meant to test.
105
107
 
106
108
  ## Status
107
109
 
108
- Version 0.1.34. A project of boards; sign in with your email and a password from the PlotCoder mark and your projects follow you to every device, share one with another writer by email and write it together live, or stay signed out and work on this device as before. Pages sit beside the wall: a scene's text lives on its card, measures it, paginates to the industry's rules, prints, goes out and comes in as Fountain or Final Draft, and goes out as Markdown or plain text for a collaborator in Google Docs. It installs as a progressive web app and opens offline; plotcoder.com serves over HTTPS. The wall, beats, card length, groups, arrows, pan and zoom, save and open, and the agent surface are in use.
110
+ Version 0.1.36. A project of boards; sign in with your email and a password from the PlotCoder mark and your projects follow you to every device, share one with another writer by email and write it together live, or stay signed out and work on this device as before. Pages sit beside the wall: a scene's text lives on its card, measures it, paginates to the industry's rules, prints, goes out and comes in as Fountain or Final Draft, and goes out as Markdown or plain text for a collaborator in Google Docs. It installs as a progressive web app and opens offline; plotcoder.com serves over HTTPS. The wall, beats, card length, groups, arrows, pan and zoom, save and open, and the agent surface are in use.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plotcoder-board",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -71,6 +71,7 @@ import {
71
71
  setPremise,
72
72
  setPremiseOpen,
73
73
  setBoardNameOpen,
74
+ setProjectNameOpen,
74
75
  structureBeats,
75
76
  reidentifyProject,
76
77
  renameProject,
@@ -1065,7 +1066,7 @@ function summarize(state) {
1065
1066
  const pays = paidByHere.filter((item) => item.id === note.id).map((item) => `, pays off "${item.fromHeadline}" from "${item.fromBoardName}" (${episodeLabel(lastHeld.project, lastHeld.boards, item.fromBoardId, item.fromNoteId)})`).join("");
1066
1067
  const snap = state.revision?.snapshot?.[note.id];
1067
1068
  const revised = snap && (snap.headline !== note.headline || snap.change !== note.change || (snap.text ?? "") !== (note.text ?? "") || (snap.location ?? "") !== (note.location ?? "")) ? `, changed in ${state.revision.color}` : "";
1068
- const place = note.location ? `, at: ${note.location}` : "";
1069
+ const place = note.location ? `, at: ${note.location}` : note.locationOpen ? `, at: open, by the writer's word — "${note.locationOpen}"` : "";
1069
1070
  const when = note.when ? `, when: ${note.when}` : note.whenOpen ? `, when: open, by the writer's word — "${note.whenOpen}"` : "";
1070
1071
  const openWord = note.open ? `, open (the writer's words): "${note.open}"` : "";
1071
1072
  const count = formatPages(noteEighths(note));
@@ -1576,9 +1577,11 @@ server.registerTool(
1576
1577
  const beatEighths = reading.beats.reduce((sum, beat) => sum + noteEighths(state.notes.find((note) => note.id === beat.id) ?? {}), 0);
1577
1578
  // Fields left open by the writer's word (R61): the logline and the whens are the reading's; the premise and the board's name are the project's.
1578
1579
  const openFieldLines = [
1580
+ ...(projectForRead.nameOpen ? [` - the project's name — ${projectForRead.nameOpen}`] : []),
1579
1581
  ...reading.openFields.filter((field) => field.field === "logline").map((field) => ` - the logline — ${field.words}`),
1580
1582
  ...(projectForRead.premiseOpen ? [` - the premise — ${projectForRead.premiseOpen}`] : []),
1581
1583
  ...(readBoardMeta?.nameOpen ? [` - this board's name — ${readBoardMeta.nameOpen}`] : []),
1584
+ ...reading.openFields.filter((field) => field.field === "location").map((field) => ` - "${state.notes.find((note) => note.id === field.id)?.headline ?? field.id}" — where: ${field.words}`),
1582
1585
  // Whens left open in the same words are one line, not one per card (round twenty, entry 22).
1583
1586
  ...(() => {
1584
1587
  const byWords = new Map();
@@ -1599,6 +1602,7 @@ server.registerTool(
1599
1602
  `PlotCoder wall (${door(live, base)})`,
1600
1603
  ...(state.lock ? [`numbers: locked since ${String(state.lock.at).slice(0, 10)}; read_pages shows each scene's number`] : []),
1601
1604
  `board: "${readBoardMeta?.name ?? "?"}"${readBoardMeta?.nameOpen ? ` — its name is open, by the writer's word: "${readBoardMeta.nameOpen}"` : ""}${projectForRead.boards.length > 1 ? ` — board ${projectForRead.boards.findIndex((meta) => meta.id === readBoardMeta?.id) + 1} of ${projectForRead.boards.length} in the project "${projectForRead.name}"; open_board reads another` : ""}`,
1605
+ ...(projectForRead.nameOpen ? [`project: "${projectForRead.name}" — its name is open, by the writer's word: "${projectForRead.nameOpen}"`] : []),
1602
1606
  ...(projectForRead.premiseOpen ? [`premise: open, by the writer's word — "${projectForRead.premiseOpen}"`] : []),
1603
1607
  `logline: ${state.loglineOpen ? `open, by the writer's word — "${state.loglineOpen}"` : state.logline ? `"${state.logline}"` : "(none yet)"}`,
1604
1608
  "the cast and the places are list_board's, not the reading's",
@@ -3010,7 +3014,7 @@ server.registerTool(
3010
3014
  });
3011
3015
  const total = parts.reduce((sum, part) => sum + part.on.length, 0);
3012
3016
  // An open card reads as open on a person's page too (round eighteen, entry 53).
3013
- const where_ = (note) => [note.location ? `at ${note.location}` : "", note.when ? note.when : note.whenOpen ? `when open: "${note.whenOpen}"` : "", note.rank === "beat" ? "beat" : "", note.open ? `open: "${note.open}"` : ""].filter(Boolean).join(" · ");
3017
+ const where_ = (note) => [note.location ? `at ${note.location}` : note.locationOpen ? `where open: "${note.locationOpen}"` : "", note.when ? note.when : note.whenOpen ? `when open: "${note.whenOpen}"` : "", note.rank === "beat" ? "beat" : "", note.open ? `open: "${note.open}"` : ""].filter(Boolean).join(" · ");
3014
3018
  // A read opens with the door it came through, like every reading (round sixteen, entry 28); one scene a line (29).
3015
3019
  const lines = [
3016
3020
  `PlotCoder cast (${door(live, base)})`,
@@ -3087,14 +3091,16 @@ server.registerTool(
3087
3091
  {
3088
3092
  title: "Set where scenes happen",
3089
3093
  description:
3090
- "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: …'.",
3091
- inputSchema: { ids: z.array(z.string()).min(1), location: z.string() },
3094
+ "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. Or leave the place open: pass open with the writer's words for why it is not decided — \"where it happens\" — and the reading lists it under open, by the writer's word, and stops asking where, while the card's other questions stand; a place decides it, open \"\" leaves it blank. list_board shows each card's place as 'at: …'.",
3095
+ inputSchema: { ids: z.array(z.string()).min(1), location: z.string().optional(), open: z.string().optional() },
3092
3096
  },
3093
3097
  async (args) => {
3098
+ if (args.location === undefined && args.open === undefined) return ok("Say which: location (the place, or \"\" to clear it), or open (the writer's words for why it is not decided).");
3094
3099
  const { state, changed, result, live } = await commit({
3095
3100
  type: "set_location",
3096
3101
  ids: args.ids,
3097
- location: args.location,
3102
+ ...(args.location !== undefined ? { location: args.location } : {}),
3103
+ ...(args.open !== undefined ? { open: args.open } : {}),
3098
3104
  });
3099
3105
  if (!changed) {
3100
3106
  const known = args.ids.filter((id) => state.notes.some((note) => note.id === id));
@@ -3102,6 +3108,10 @@ server.registerTool(
3102
3108
  return ok("No place changed: those cards already read that way.");
3103
3109
  }
3104
3110
  const place = result[0].location;
3111
+ const placeOpen = result[0].locationOpen ?? "";
3112
+ if (placeOpen) {
3113
+ return ok(`${result.length} card(s) have their place left open, by the writer's word: "${placeOpen}"${where(live)}. The reading lists it and stops asking where; the heading prints the headline in its stead; set_location with a place decides it, open "" leaves it blank. The card is still asked about everything else.${stillOpen(result)}`, result);
3114
+ }
3105
3115
  return ok(
3106
3116
  `${result.length} card(s) now ${place ? `at ${place}` : "nowhere"}${where(live)}.${nearPlaces(state, place)}${stillOpen(result)}`,
3107
3117
  result,
@@ -3507,7 +3517,7 @@ server.registerTool(
3507
3517
  const { project, boards, live, base, changedAt } = await readProject();
3508
3518
  return ok(
3509
3519
  [
3510
- `Project "${project.name}" (${door(live, base)})`,
3520
+ `Project "${project.name}"${project.nameOpen ? ` — its name is open, by the writer's word: "${project.nameOpen}"` : ""} (${door(live, base)})`,
3511
3521
  `premise: ${project.premiseOpen ? `open, by the writer's word — "${project.premiseOpen}"` : project.premise ? `"${project.premise}"` : "(not set)"}`,
3512
3522
  `boards: ${project.boards.length}`,
3513
3523
  describeBoards(project, boards, changedAt),
@@ -3550,11 +3560,18 @@ server.registerTool(
3550
3560
  "rename_project",
3551
3561
  {
3552
3562
  title: "Rename the project",
3553
- description: "Rename the project — the name at the top of the wall, over every board.",
3554
- inputSchema: { name: z.string().min(1) },
3563
+ description: "Rename the project — the name at the top of the wall, over every board. Or leave its name open: pass open with the writer's words for why the title is not decided — \"The Allotments, or Plot 14\" — and the name stands as it is while the reading lists the words; a name decides it, open \"\" takes the words back.",
3564
+ inputSchema: { name: z.string().min(1).optional(), open: z.string().optional() },
3555
3565
  },
3556
3566
  async (args) => {
3567
+ if (args.name === undefined && args.open === undefined) return ok("Say which: name, or open (the writer's words for why the title is not decided).");
3557
3568
  const { project, boards, rev, base, live } = await readProject();
3569
+ if (args.name === undefined) {
3570
+ const opened = setProjectNameOpen(project, args.open);
3571
+ if (opened === project) return ok("Project name unchanged: it already reads that way.");
3572
+ await writeProject(opened, boards, rev, base);
3573
+ return ok(opened.nameOpen ? `The project keeps its name, "${opened.name}", and its name is left open, by the writer's word: "${opened.nameOpen}"${where(live)}. The reading lists it; rename_project with a name decides it.` : `The project's name, "${opened.name}", is no longer open${where(live)}.`, { name: opened.name, nameOpen: opened.nameOpen });
3574
+ }
3558
3575
  const next = renameProject(project, args.name);
3559
3576
  if (next === project) return ok("Project name unchanged.");
3560
3577
  await writeProject(next, boards, rev, base);
@@ -3562,7 +3579,7 @@ server.registerTool(
3562
3579
  // follow the rename (round fifteen, entry 40).
3563
3580
  if (accountDoor) workingProject(next.id, next.name);
3564
3581
  const several = (next.boards ?? []).length > 1;
3565
- return ok(`Project renamed to "${next.name}"${where(live)}. It shows at the head of every reply, in list_boards and read_wall, and as the title of every script out${several ? `, where each board follows it as an episode line (Episode 1 of ${next.boards.length} · ${next.boards[0].name})` : ""}. The boards keep their names.`, next);
3582
+ return ok(`Project renamed to "${next.name}"${where(live)}.${project.nameOpen ? " Its name is decided; the open words are gone." : ""} It shows at the head of every reply, in list_boards and read_wall, and as the title of every script out${several ? `, where each board follows it as an episode line (Episode 1 of ${next.boards.length} · ${next.boards[0].name})` : ""}. The boards keep their names.`, next);
3566
3583
  },
3567
3584
  );
3568
3585
 
@@ -3740,13 +3757,17 @@ server.registerTool(
3740
3757
  {
3741
3758
  title: "Start a project",
3742
3759
  description:
3743
- "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. board names the first board; boardOpen leaves its name open in the writer's words instead (\"the pilot, or the film\"), so a board born from a maybe is not silently \"Board 1\".",
3744
- inputSchema: { name: z.string().min(1), board: z.string().optional(), boardOpen: z.string().optional(), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
3760
+ "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. A title not decided: open with the writer's words (\"The Allotments, or Plot 14\") instead of a name, and the project starts as Untitled project with those words beside it. board names the first board; boardOpen leaves its name open in the writer's words instead (\"the pilot, or the film\"), so a board born from a maybe is not silently \"Board 1\".",
3761
+ inputSchema: { name: z.string().min(1).optional(), open: z.string().optional(), board: z.string().optional(), boardOpen: z.string().optional(), pages: pagesSchema.optional(), minutes: z.number().positive().optional() },
3745
3762
  },
3746
3763
  async (args) => {
3747
3764
  const account = await findAccount();
3748
3765
  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.");
3749
- let record = renameProject(emptyProject(), args.name.trim());
3766
+ if (!args.name?.trim() && !args.open?.trim()) return ok("Say which: name (the title), or open (the writer's words for why the title is not decided — the project starts as \"Untitled project\" with those words beside it).");
3767
+ let record = emptyProject();
3768
+ if (args.name?.trim()) record = renameProject(record, args.name.trim());
3769
+ // A project born from a maybe is born open on its name (R61's edge): open holds the writer's words.
3770
+ if (args.open?.trim()) record = setProjectNameOpen(record, args.open);
3750
3771
  if (args.board?.trim()) record = renameBoard(record, record.activeBoardId, args.board.trim());
3751
3772
  // A board born from a maybe is born open on its name (R61): boardOpen holds the writer's words.
3752
3773
  if (args.boardOpen?.trim()) record = setBoardNameOpen(record, record.activeBoardId, args.boardOpen);
@@ -3760,7 +3781,7 @@ server.registerTool(
3760
3781
  joinPresence(record.id);
3761
3782
  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.`;
3762
3783
  const first = record.boards[0];
3763
- const nameOpenLine = first.nameOpen ? ` The board's name is left open, by the writer's word: "${first.nameOpen}"; rename_board decides it.` : "";
3784
+ const nameOpenLine = `${record.nameOpen ? ` The project's name is left open, by the writer's word: "${record.nameOpen}"; rename_project decides it.` : ""}${first.nameOpen ? ` The board's name is left open, by the writer's word: "${first.nameOpen}"; rename_board decides it.` : ""}`;
3764
3785
  return ok(`Started "${record.name}" (${record.id}) with its first board "${first.name}" (${first.id}), and working it now, as ${account.email}.${targetLine}${nameOpenLine}${oneCallHint(record)}`, { id: record.id, name: record.name, boardId: first.id, boardName: first.name, boardNameOpen: first.nameOpen ?? "", targetEighths: state.targetEighths });
3765
3786
  },
3766
3787
  );
@@ -3856,6 +3877,63 @@ server.registerTool(
3856
3877
  },
3857
3878
  );
3858
3879
 
3880
+ // Help in the app (R64): the questions writers asked that the guide did not
3881
+ // answer. They are the app's, not a project's, so these two tools take the
3882
+ // maintainer's service role from the server's environment — the key never
3883
+ // ships, as the wipe script's does not — and every other door refuses them.
3884
+ async function questionsDoor() {
3885
+ const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.PLOTCODER_SERVICE_ROLE_KEY;
3886
+ if (!key) return null;
3887
+ const { createClient } = await import("@supabase/supabase-js");
3888
+ return createClient(process.env.SUPABASE_URL || SUPABASE_URL, key, { auth: { persistSession: false } });
3889
+ }
3890
+ const NO_QUESTIONS_DOOR = "The questions are the app's, not a project's: set SUPABASE_SERVICE_ROLE_KEY in the server's environment — the maintainer's key, never in the repo — and call again. A writer asks from the Help sheet; the answer goes into public/writers.html and back to them through answer_question.";
3891
+
3892
+ server.registerTool(
3893
+ "list_questions",
3894
+ {
3895
+ title: "The writers' questions",
3896
+ description:
3897
+ "The questions writers asked from the app's Help sheet that the guide did not answer, waiting first: who asked, when, the words. Maintainer only — needs the service role in the server's environment. Answer one with answer_question after the answer is in public/writers.html.",
3898
+ inputSchema: { all: z.boolean().optional() },
3899
+ },
3900
+ async (args) => {
3901
+ const db = await questionsDoor();
3902
+ if (!db) return ok(NO_QUESTIONS_DOOR);
3903
+ const { data, error } = await db.from("questions").select("id, email, question, asked_at, answered_at, answer, section").order("asked_at", { ascending: false });
3904
+ if (error) return ok(`Could not read the questions: ${error.message}`);
3905
+ const rows = args.all ? data : data.filter((row) => !row.answered_at);
3906
+ const waiting = data.filter((row) => !row.answered_at).length;
3907
+ const line = (row) => ` - ${row.id.slice(0, 8)} · ${String(row.asked_at).slice(0, 10)} · ${row.email} · "${row.question}" · ${row.answered_at ? `answered ${String(row.answered_at).slice(0, 10)}${row.section ? ` → ${row.section}` : ""}` : "waiting"}`;
3908
+ return ok(
3909
+ [`${waiting} waiting, ${data.length - waiting} answered.${args.all ? "" : " (all: true lists the answered ones too)"}`, ...(rows.length ? rows.map(line) : [" (none)"])].join("\n"),
3910
+ rows,
3911
+ );
3912
+ },
3913
+ );
3914
+
3915
+ server.registerTool(
3916
+ "answer_question",
3917
+ {
3918
+ title: "Answer a writer's question",
3919
+ description:
3920
+ "Mark a writer's question answered, with the answer in a sentence or two and the guide's section it went into (\"#s5\"), once the answer is in public/writers.html: the writer sees both under Your questions. Maintainer only — needs the service role in the server's environment. The guide is the answer; this is the promise kept.",
3921
+ inputSchema: { id: z.string().min(1), answer: z.string().min(1), section: z.string().optional() },
3922
+ },
3923
+ async (args) => {
3924
+ const db = await questionsDoor();
3925
+ if (!db) return ok(NO_QUESTIONS_DOOR);
3926
+ const { data: found, error: findError } = await db.from("questions").select("id, question, answered_at").ilike("id", `${args.id}%`);
3927
+ if (findError) return ok(`Could not read the questions: ${findError.message}`);
3928
+ if (!found?.length) return ok(`No question whose id starts "${args.id}". list_questions shows them.`);
3929
+ if (found.length > 1) return ok(`${found.length} questions start "${args.id}": say more of the id.`);
3930
+ const row = found[0];
3931
+ const { error } = await db.from("questions").update({ answered_at: new Date().toISOString(), answer: args.answer.trim(), section: args.section?.trim() || null }).eq("id", row.id);
3932
+ if (error) return ok(`Could not answer it: ${error.message}`);
3933
+ return ok(`Answered "${row.question}"${args.section ? ` and filed it under ${args.section.trim()}` : ""}${row.answered_at ? " (it had been answered before; this replaces that)" : ""}. The writer sees it under Your questions. If the answer is not yet in public/writers.html, put it there and open the pull request: the guide is the answer, this list is the promise.`, { id: row.id });
3934
+ },
3935
+ );
3936
+
3859
3937
  server.registerTool(
3860
3938
  "export_project",
3861
3939
  {
@@ -55,10 +55,10 @@ export const AGENTS = {
55
55
  "No opinions about how many beats there should be. Marking the turns a treatment plainly makes is reading it, not an opinion: mark them, say which, and let the writer strike or add.",
56
56
  "Page counts are estimates.",
57
57
  "Ask before delete_board, delete_project, empty_account, delete_account, unlock_numbers, remove_file, an import_project that replaces, or claim_account — the writer gives the email and the password; never invent one. export_project first, when something might be wanted back.",
58
- "Do not invent people or a logline. What the treatment states — an age, a job, a bad knee — is not invented: it goes in the person's notes. An unnamed person is named by their role — Dana's mother, the dispatcher — which is a name until the writer gives one. A scene is one place and one stretch of time; a new place or time is a new card. A beat is a whole card; a setup arrow lands on the scene's card, so a payoff never needs a card of its own. Acts are groups titled Act one, Act two, when the treatment has them; the wall never asks whether an act is a sequence. Paper colour means nothing to the app. Under target is a fact to report plainly, like over; neither is a verdict. A thing the writer has not decided is an open card in their words (set_open), or an open field — the logline, the premise, a when, a board's name take open beside the value — never a guess to fill the field; a thing whose far end the writer knows and not where it is first seen — the letter, the ring — is a thread (create_thread) with an open start, and the wall asks from that end.",
58
+ "Do not invent people or a logline. What the treatment states — an age, a job, a bad knee — is not invented: it goes in the person's notes. An unnamed person is named by their role — Dana's mother, the dispatcher — which is a name until the writer gives one. A scene is one place and one stretch of time; a new place or time is a new card. A beat is a whole card; a setup arrow lands on the scene's card, so a payoff never needs a card of its own. Acts are groups titled Act one, Act two, when the treatment has them; the wall never asks whether an act is a sequence. Paper colour means nothing to the app. Under target is a fact to report plainly, like over; neither is a verdict. A thing the writer has not decided is an open card in their words (set_open), or an open field — the logline, the premise, a card's place or when, a board's or the project's name take open beside the value — never a guess to fill the field; a thing whose far end the writer knows and not where it is first seen — the letter, the ring — is a thread (create_thread) with an open start, and the wall asks from that end.",
59
59
  ],
60
60
  person:
61
- "Give your agent the account door only on a machine you trust; it signs in as you and shows under People as “an agent, as you” while it runs. Wire the server before you start the agent's session, with the two sign-in lines beside it, and the agent has every tool from its first message; wired from inside a session, the server connects only on the next one. Your agent can also make your account: give it your email and a password of your choosing.",
61
+ "Give your agent the account door only on a machine you trust; it signs in as you and shows under People as “an agent, as you” while it runs. Wire the server before you start the agent's session, with the two sign-in lines beside it, and the agent has every tool from its first message; wired from inside a session, the server connects only on the next one. Your agent can also make your account: give it your email and a password of your choosing. Your own guide — how a writer uses PlotCoder, from the door to the script out, the agent first — is at https://plotcoder.com/writers.html.",
62
62
  guide: "https://plotcoder.com/guide.md",
63
63
  url: "https://plotcoder.com/llms.txt",
64
64
  };
@@ -124,6 +124,7 @@ export function toFountain(state, options = {}) {
124
124
  if (cast.length) marks.push(`with ${cast.join(", ")}`);
125
125
  if (note.plants) marks.push(note.plantsWhat ? `plants ${note.plantsWhat}` : "plants something to pay off later");
126
126
  if (note.open) marks.push(`open: ${note.open}`);
127
+ if (note.locationOpen) marks.push(`place open: ${note.locationOpen}`);
127
128
  if (note.whenOpen) marks.push(`when open: ${note.whenOpen}`);
128
129
  const onThreads = (state.threads ?? []).filter((thread) => thread.noteIds.includes(note.id)).map((thread) => thread.name);
129
130
  if (onThreads.length) marks.push(`thread: ${onThreads.join(", ")}`);
@@ -0,0 +1,11 @@
1
+ // Type surface for help.js — Help in the app (R64).
2
+
3
+ export type GuideParagraph = { id: string; title: string; sub: string; text: string };
4
+ export type HelpHit = { kind: "word" | "guide"; from: string; text: string; href: string | null };
5
+ export type HelpWord = { name: string; sentence: string };
6
+
7
+ export declare function helpWords(text: string): string[];
8
+ /** The guide's page as paragraphs under their section and sub-head. */
9
+ export declare function indexGuide(html: string): GuideParagraph[];
10
+ /** The hits for a question: the words first, then the guide, by how many of the question's words each holds. Empty when nothing matches. */
11
+ export declare function searchHelp(query: string, words: HelpWord[], guide: GuideParagraph[], limit?: number): HelpHit[];
@@ -0,0 +1,103 @@
1
+ // Help in the app (R64): the writer's guide and the words, searched by the
2
+ // writer's own words. Pure: the sheet fetches the guide's page and hands its
3
+ // HTML here; the maintainer's tools and the tests never touch a browser.
4
+
5
+ const FILLER = new Set(["a", "an", "the", "and", "or", "of", "to", "in", "on", "at", "is", "it", "i", "do", "how", "can", "my", "me", "we", "you", "your", "for", "with", "be", "what", "when", "where", "does", "did", "this", "that", "from", "as", "by", "are", "am", "if", "so", "up", "one", "two", "not", "no", "yes"]);
6
+
7
+ /** The words of a question or a paragraph, lower-cased, without the filler. */
8
+ export function helpWords(text) {
9
+ return (text ?? "")
10
+ .toLowerCase()
11
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
12
+ .split(/\s+/)
13
+ .filter((word) => word && !FILLER.has(word));
14
+ }
15
+
16
+ function stripTags(html) {
17
+ return html
18
+ .replace(/<[^>]+>/g, " ")
19
+ .replace(/&amp;/g, "&")
20
+ .replace(/&lt;/g, "<")
21
+ .replace(/&gt;/g, ">")
22
+ .replace(/&quot;/g, '"')
23
+ .replace(/&#39;/g, "'")
24
+ .replace(/\s+/g, " ")
25
+ .trim();
26
+ }
27
+
28
+ /**
29
+ * The guide's page as paragraphs, each under its section (an <h2 id="sN">)
30
+ * and its sub-head (an <h3>), so a hit can say where it comes from and link
31
+ * there. Anything before the first section — the title, the contents — is
32
+ * not indexed.
33
+ */
34
+ export function indexGuide(html) {
35
+ const paragraphs = [];
36
+ const sections = html.split(/(?=<h2 id="s\d+">)/g).slice(1);
37
+ for (const chunk of sections) {
38
+ const head = /^<h2 id="(s\d+)">([^<]*)<\/h2>/.exec(chunk);
39
+ if (!head) continue;
40
+ const id = head[1];
41
+ const title = stripTags(head[2]).replace(/^\d+\.\s*/, "");
42
+ let sub = "";
43
+ const parts = chunk.slice(head[0].length).split(/(?=<h3>)|(?=<p>)/g);
44
+ for (const part of parts) {
45
+ const h3 = /^<h3>([^<]*)<\/h3>/.exec(part);
46
+ if (h3) {
47
+ sub = stripTags(h3[1]);
48
+ continue;
49
+ }
50
+ const p = /^<p[^>]*>([\s\S]*?)<\/p>/.exec(part);
51
+ if (p) {
52
+ const text = stripTags(p[1]);
53
+ if (text) paragraphs.push({ id, title, sub, text });
54
+ }
55
+ }
56
+ }
57
+ return paragraphs;
58
+ }
59
+
60
+ /**
61
+ * The hits for a question: the words first, then the guide's paragraphs, each
62
+ * scored by how many of the question's words it holds (a word matches on its
63
+ * start, so "beats" finds "beat"). Nothing matched is an empty list, which is
64
+ * the sheet's cue to offer Ask.
65
+ */
66
+ export function searchHelp(query, words, guide, limit = 8) {
67
+ // A plural asks for its singular too: "beats" finds "beat", "writers" finds "writer".
68
+ const stem = (word) => (word.length > 3 && word.endsWith("s") ? word.slice(0, -1) : word);
69
+ const wanted = [...new Set(helpWords(query).map(stem))];
70
+ if (wanted.length === 0) return [];
71
+ // A short question is answered whole; a long one may miss a word.
72
+ const needed = wanted.length <= 3 ? wanted.length : wanted.length - 1;
73
+ const holds = (have, want) => have.some((word) => word === want || word.startsWith(want));
74
+ const count = (text) => {
75
+ const have = helpWords(text);
76
+ let hits = 0;
77
+ for (const want of wanted) if (holds(have, want)) hits += 1;
78
+ return hits;
79
+ };
80
+ const scored = [];
81
+ for (const word of words ?? []) {
82
+ const hits = count(`${word.name} ${word.sentence}`);
83
+ if (hits < needed) continue;
84
+ // The entry named for the thing asked about comes first: "A beat" before a sentence that mentions beats.
85
+ const named = count(word.name) > 0 ? 2 : 0;
86
+ scored.push({ kind: "word", from: `The words · ${word.name}`, text: word.sentence, href: null, rank: hits + named, order: scored.length });
87
+ }
88
+ for (const paragraph of guide ?? []) {
89
+ const hits = count(`${paragraph.title} ${paragraph.sub} ${paragraph.text}`);
90
+ if (hits < needed) continue;
91
+ const headed = count(`${paragraph.title} ${paragraph.sub}`) > 0 ? 1 : 0;
92
+ scored.push({
93
+ kind: "guide",
94
+ from: `The guide · ${paragraph.title}${paragraph.sub ? ` · ${paragraph.sub}` : ""}`,
95
+ text: paragraph.text,
96
+ href: `/writers.html#${paragraph.id}`,
97
+ rank: hits + headed,
98
+ order: scored.length,
99
+ });
100
+ }
101
+ scored.sort((a, b) => b.rank - a.rank || (a.kind === "word" ? -1 : b.kind === "word" ? 1 : 0) || a.order - b.order);
102
+ return scored.slice(0, limit).map(({ kind, from, text, href }) => ({ kind, from, text, href }));
103
+ }
@@ -19,6 +19,8 @@ export type ProjectRecord = {
19
19
  version: number;
20
20
  id: string;
21
21
  name: string;
22
+ /** The writer's words for why the project's name is not decided (R61's edge), or empty; the name stands meanwhile. */
23
+ nameOpen: string;
22
24
  premise: string;
23
25
  /** The writer's words for why there is no premise yet (R61), or empty. */
24
26
  premiseOpen: string;
@@ -75,6 +77,8 @@ export declare function setPremise(project: ProjectRecord, premise: string, now?
75
77
  export declare function setPremiseOpen(project: ProjectRecord, words: string, now?: string): ProjectRecord;
76
78
  /** The writer's words for why a board's name is not decided (R61); the name stands meanwhile. */
77
79
  export declare function setBoardNameOpen(project: ProjectRecord, id: string, words: string, now?: string): ProjectRecord;
80
+ /** The writer's words for why the project's name is not decided (R61's edge); the name stands meanwhile. */
81
+ export declare function setProjectNameOpen(project: ProjectRecord, words: string, now?: string): ProjectRecord;
78
82
  export declare function boardById(project: ProjectRecord, id: string): BoardMeta | null;
79
83
  /** What a script going out is called: a named project is the title, its board beside it only when the project has several. */
80
84
  export declare function scriptTitles(project: ProjectRecord, board: BoardMeta | null | undefined): { title: string; episode?: string };
@@ -33,6 +33,7 @@ export function emptyProject(now = nowIso()) {
33
33
  version: PROJECT_VERSION,
34
34
  id: newId(),
35
35
  name: DEFAULT_PROJECT_NAME,
36
+ nameOpen: "",
36
37
  premise: "",
37
38
  premiseOpen: "",
38
39
  boards: [board],
@@ -87,6 +88,8 @@ export function normalizeProject(value, now = nowIso()) {
87
88
  ...rest,
88
89
  version: PROJECT_VERSION,
89
90
  name: trimmed(value.name, DEFAULT_PROJECT_NAME),
91
+ // A project named before R61's edge has no open name; the name stands until the writer says it is not decided.
92
+ nameOpen: openWords(value.nameOpen),
90
93
  premise: typeof value.premise === "string" ? value.premise.trim() : "",
91
94
  // A project written before R61 has no open premise (R61).
92
95
  premiseOpen: openWords(value.premiseOpen),
@@ -342,8 +345,16 @@ export function setActiveBoard(project, id, now = nowIso()) {
342
345
 
343
346
  export function renameProject(project, name, now = nowIso()) {
344
347
  const next = trimmed(name, "");
345
- if (!next || next === project.name) return project;
346
- return touch(project, { name: next }, now);
348
+ if (!next || (next === project.name && !(project.nameOpen ?? ""))) return project;
349
+ // A name decides the field: the open words go (R61's edge).
350
+ return touch(project, { name: next, nameOpen: "" }, now);
351
+ }
352
+
353
+ /** The writer's words for why the project's name is not decided (R61's edge), or "" to take them back; the name stands meanwhile. */
354
+ export function setProjectNameOpen(project, words, now = nowIso()) {
355
+ const next = openWords(words);
356
+ if (next === (project.nameOpen ?? "")) return project;
357
+ return touch(project, { nameOpen: next }, now);
347
358
  }
348
359
 
349
360
  export function setPremise(project, premise, now = nowIso()) {
@@ -72,7 +72,7 @@ export type WallReading = {
72
72
  /** Open cards (R59): the writer's words for what is not decided, in story order; not asked about while they stand. */
73
73
  open: Array<{ id: string; words: string; hides: FindingKind[] }>;
74
74
  /** Fields left open by the writer's word (R61): the board's logline, and each card's when, in story order. Listed, not asked. */
75
- openFields: Array<{ field: "logline"; words: string } | { field: "when"; id: string; words: string }>;
75
+ openFields: Array<{ field: "logline"; words: string } | { field: "location"; id: string; words: string } | { field: "when"; id: string; words: string }>;
76
76
  /** Threads (R60): each named string with its cards in story order and which ends are open. */
77
77
  threads: Array<{ id: string; name: string; ids: string[]; startOpen: boolean; endOpen: boolean; /** Eighths from the first card's start to the last card's start; 0 with fewer than two cards. */ apart: number }>;
78
78
  /** Cards here that pay off a fold of another board (R58), composed by the door from the project. */
@@ -241,7 +241,8 @@ export function readWall(state, options = {}) {
241
241
  // A card that says no place, once the writer has started placing cards.
242
242
  // One question however many there are; a wall with no places at all is a
243
243
  // wall the writer has not placed yet, and is not asked.
244
- const unplaced = order.filter((note) => askable(note) && !(note.location ?? "").trim());
244
+ // A place left open in the writer's words (R61's edge) is listed, not asked.
245
+ const unplaced = order.filter((note) => askable(note) && !(note.location ?? "").trim() && !(note.locationOpen ?? "").trim());
245
246
  if (unplaced.length > 0 && unplaced.length < order.length) {
246
247
  findings.push({
247
248
  kind: "unplaced",
@@ -535,6 +536,7 @@ export function readWall(state, options = {}) {
535
536
  function describeOpenFields(state, order) {
536
537
  const fields = [];
537
538
  if ((state.loglineOpen ?? "").trim()) fields.push({ field: "logline", words: state.loglineOpen.trim() });
539
+ for (const note of order) if ((note.locationOpen ?? "").trim()) fields.push({ field: "location", id: note.id, words: note.locationOpen.trim() });
538
540
  for (const note of order) if ((note.whenOpen ?? "").trim()) fields.push({ field: "when", id: note.id, words: note.whenOpen.trim() });
539
541
  return fields;
540
542
  }
@@ -93,6 +93,8 @@ export type BoardNote = {
93
93
  when: string;
94
94
  /** The writer's words for why the when is not decided (R61), or empty; while they stand the when is blank and the reading lists them. */
95
95
  whenOpen: string;
96
+ /** The writer's words for why the place is not decided (R61's edge), or empty; while they stand the place is blank, listed, and not asked. */
97
+ locationOpen: string;
96
98
  /** The scene's text in Fountain (R23 b): action, cues, dialogue; empty until written. */
97
99
  text: string;
98
100
  createdAt: string;
@@ -186,6 +188,7 @@ export type Command =
186
188
  location?: string;
187
189
  when?: string;
188
190
  whenOpen?: string;
191
+ locationOpen?: string;
189
192
  open?: string;
190
193
  text?: string;
191
194
  }
@@ -216,7 +219,7 @@ export type Command =
216
219
  | { type: "set_plant"; ids: string[]; plants?: boolean; what?: string }
217
220
  | { type: "set_open"; ids: string[]; open: string }
218
221
  | { type: "set_payoff_board"; ids: string[]; boardId: string | null; noteId?: string | null }
219
- | { type: "set_location"; ids: string[]; location: string }
222
+ | { type: "set_location"; ids: string[]; location?: string; open?: string }
220
223
  | { type: "set_when"; ids: string[]; when?: string; open?: string }
221
224
  | { type: "apply_template"; template: string; beats?: Array<{ name: string; prompt: string; at: number }> }
222
225
  | { type: "set_text"; id: string; text: string }
@@ -247,6 +247,8 @@ export function seedState(now = nowIso()) {
247
247
  lengthEighths: null,
248
248
  characterIds,
249
249
  location: "",
250
+ // The writer's words for why the place is not decided (R61), or nothing.
251
+ locationOpen: "",
250
252
  when: "",
251
253
  // The writer's words for why the when is not decided (R61), or nothing.
252
254
  whenOpen: "",
@@ -368,6 +370,8 @@ export function normalizeState(value) {
368
370
  const open = typeof note?.open === "string" ? note.open : "";
369
371
  // Cards written before R37 have no place; a scene is nowhere until it is.
370
372
  const location = typeof note?.location === "string" ? note.location : "";
373
+ // Cards written before R61's edge have no open place; a place is decided or blank until the writer says otherwise.
374
+ const locationOpen = typeof note?.locationOpen === "string" ? note.locationOpen : "";
371
375
  // Cards written before R55 have no when; a scene is at no time until it is.
372
376
  const when = typeof note?.when === "string" ? note.when : "";
373
377
  // Cards written before R61 have no open when; a when is decided or blank until the writer says otherwise.
@@ -386,6 +390,7 @@ export function normalizeState(value) {
386
390
  note.payoffNoteId === payoffNoteId &&
387
391
  note.open === open &&
388
392
  note.location === location &&
393
+ note.locationOpen === locationOpen &&
389
394
  note.when === when &&
390
395
  note.whenOpen === whenOpen &&
391
396
  note.text === text
@@ -393,7 +398,7 @@ export function normalizeState(value) {
393
398
  return note;
394
399
  }
395
400
  patched = true;
396
- return { ...note, rank, lengthEighths, characterIds, plants, plantsWhat, payoffBoardId, payoffNoteId, open, location, when, whenOpen, text };
401
+ return { ...note, rank, lengthEighths, characterIds, plants, plantsWhat, payoffBoardId, payoffNoteId, open, location, locationOpen, when, whenOpen, text };
397
402
  });
398
403
 
399
404
  // Boards written before the production half (Roadmap 2, item 8) have no
@@ -636,7 +641,8 @@ export function applyCommand(state, command, now = nowIso()) {
636
641
  payoffBoardId: null,
637
642
  payoffNoteId: null,
638
643
  open: cleanOpen(command.open),
639
- location: cleanPlace(command.location),
644
+ location: cleanOpen(command.locationOpen) ? "" : cleanPlace(command.location),
645
+ locationOpen: cleanOpen(command.locationOpen),
640
646
  when: cleanOpen(command.whenOpen) ? "" : cleanWhen(command.when),
641
647
  whenOpen: cleanOpen(command.whenOpen),
642
648
  text: typeof command.text === "string" ? command.text : "",
@@ -1165,6 +1171,7 @@ export function applyCommand(state, command, now = nowIso()) {
1165
1171
  payoffNoteId: null,
1166
1172
  open: "",
1167
1173
  location: "",
1174
+ locationOpen: "",
1168
1175
  when: "",
1169
1176
  whenOpen: "",
1170
1177
  text: "",
@@ -1256,11 +1263,21 @@ export function applyCommand(state, command, now = nowIso()) {
1256
1263
  case "set_location": {
1257
1264
  const ids = new Set(command.ids);
1258
1265
  if (ids.size === 0) return { state, changed: false };
1259
- const location = cleanPlace(command.location);
1266
+ // The place, or the writer's words for why there is none yet (R61's
1267
+ // edge, round twenty entry 16): words clear the place, a place clears
1268
+ // the words, open "" with a place does both in one command.
1269
+ const hasOpen = typeof command.open === "string";
1270
+ const locationOpen = hasOpen ? cleanOpen(command.open) : null;
1271
+ const location = hasOpen ? (locationOpen ? "" : typeof command.location === "string" ? cleanPlace(command.location) : null) : cleanPlace(command.location);
1260
1272
  const touched = [];
1261
1273
  const notes = state.notes.map((note) => {
1262
- if (!ids.has(note.id) || note.location === location) return note;
1263
- const next = bump(note, { location }, now);
1274
+ if (!ids.has(note.id)) return note;
1275
+ const patch = {
1276
+ location: location === null ? (note.location ?? "") : location,
1277
+ locationOpen: locationOpen === null ? (location ? "" : (note.locationOpen ?? "")) : locationOpen,
1278
+ };
1279
+ if (patch.location === (note.location ?? "") && patch.locationOpen === (note.locationOpen ?? "")) return note;
1280
+ const next = bump(note, patch, now);
1264
1281
  touched.push(next);
1265
1282
  return next;
1266
1283
  });
@@ -66,13 +66,18 @@ export const WORD_GROUPS = [
66
66
  {
67
67
  id: "open",
68
68
  name: "Open",
69
- sentence: "A card the writer has not decided, in their words on its edge: the reading lists it and asks nothing else of it until the words are cleared. A field can be open the same way — the logline, the premise, a card's when, a board's name — the words where the value would be, listed and not asked.",
69
+ sentence: "A card the writer has not decided, in their words on its edge: the reading lists it and asks nothing else of it until the words are cleared. A field can be open the same way — the logline, the premise, a card's place or when, a board's or the project's name — the words where the value would be, listed and not asked.",
70
70
  },
71
71
  {
72
72
  id: "thread",
73
73
  name: "A thread",
74
74
  sentence: "A named string through the cards a thing runs through — the letter, the key, a subplot — with either end open until the writer ties it. Drawn on the wall as a dashed string, a ring where an end is loose; the reading asks where a loose thread is first seen, or where it comes out.",
75
75
  },
76
+ {
77
+ id: "help",
78
+ name: "Help",
79
+ sentence: "The button top right: type a question and the words and the writer's guide answer as you go; when nothing does, Ask sends it to the people who build PlotCoder, and the answer lands in the guide and under Your questions.",
80
+ },
76
81
  {
77
82
  id: "corner",
78
83
  name: "The folded corner",