plotcoder-board 0.1.35 → 0.1.37

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
@@ -51,7 +53,7 @@ An agent should call the tools, never fake mouse drags. The skill in `.cursor/sk
51
53
  A **blind run** is a fresh agent given the on-ramp and a treatment and nothing
52
54
  else, asked to build a wall and to keep a log of everything that made the job
53
55
  harder than it should have been. The friction log is the product; the wall is
54
- just what produces it. Twenty rounds have been run, the first three through a
56
+ just what produces it. Twenty-one rounds have been run, the first three through a
55
57
  repo checkout and the rest through the account door; every finding from the
56
58
  first seventeen is fixed or decided. Round seventeen, the first to start from
57
59
  a page of notes instead of a treatment, found the wall has no way to hold a
@@ -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.35. 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.37. 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.35",
3
+ "version": "0.1.37",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -982,7 +982,7 @@ const CHECK_WORDS = {
982
982
  sag: "no run out of proportion",
983
983
  empty: "no beats back to back",
984
984
  unwritten: "no card without a headline or change line",
985
- unlinked: "no card without an arrow",
985
+ unlinked: "no card without a follows arrow (a setup arrow is a claim, not a place in the story)",
986
986
  duplicate: "no two headlines alike",
987
987
  sequence: "no group too long for one sequence (act groups are not asked)",
988
988
  uncast: "nobody in the cast on no card of the project",
@@ -1345,11 +1345,13 @@ server.registerTool(
1345
1345
  rank: rankSchema.optional(),
1346
1346
  pages: pagesSchema.optional(),
1347
1347
  plants: z.boolean().optional(),
1348
- plantsWhat: z.string().optional(),
1348
+ plantsWhat: z.string().optional().describe("What the folded corner plants, in the writer's words; naming it folds the card."),
1349
1349
  after: z.string().optional().describe("Wire the new scene into the story after this card (id or headline): one call, one number under a lock. On a wall with no follows arrows yet this draws the first, so a wall can be built in order from its second card."),
1350
1350
  before: z.string().optional().describe("Or before this card (id or headline)."),
1351
1351
  location: z.string().optional(),
1352
1352
  when: z.string().optional().describe('When the scene happens, as the writer says it — "night", "day four, dawn" — printed after the place on the scene heading.'),
1353
+ locationOpen: z.string().optional().describe("The writer's words for why the place is not decided (R61's edge): the card is born with its place open, listed and not asked where, while its other questions stand."),
1354
+ whenOpen: z.string().optional().describe("The writer's words for why the when is not decided: the card is born with its when open, listed and not asked."),
1353
1355
  open: z.string().optional().describe("The writer's words for what is not decided about this card — \"whether Tom knows\" — so the card is born open: the reading lists it and asks nothing else of it until the words are cleared."),
1354
1356
  characters: z.array(z.string().min(1)).optional(),
1355
1357
  x: z.number().optional(),
@@ -1389,6 +1391,8 @@ server.registerTool(
1389
1391
  plants: args.plants,
1390
1392
  plantsWhat: args.plantsWhat,
1391
1393
  location: args.location,
1394
+ locationOpen: args.locationOpen,
1395
+ whenOpen: args.whenOpen,
1392
1396
  when: args.when,
1393
1397
  open: args.open,
1394
1398
  x: landing.x,
@@ -1433,8 +1437,8 @@ server.registerTool(
1433
1437
  result?.lengthEighths === null ? "about a page (unsized: the writer's guess until set_length)" : `${formatPages(noteEighths(result))} ${formatPages(noteEighths(result)) === "1" ? "page" : "pages"}`,
1434
1438
  result?.color ? `${result.color} paper${args.color ? "" : once("paper", " (pass color to choose)")}` : null,
1435
1439
  result?.plants ? `corner folded${result.plantsWhat ? ` — plants ${result.plantsWhat}` : ""}` : null,
1436
- result?.location ? `at ${result.location}` : `no place yet${once("place", " (location here, or set_location)")}`,
1437
- result?.when ? `when: ${result.when}` : null,
1440
+ result?.location ? `at ${result.location}` : result?.locationOpen ? `place open: "${result.locationOpen}" (listed, not asked where)` : `no place yet${once("place", " (location here, or set_location)")}`,
1441
+ result?.when ? `when: ${result.when}` : result?.whenOpen ? `when open: "${result.whenOpen}" (listed, not asked)` : null,
1438
1442
  result?.open ? `open: "${result.open}" (listed, not asked about)` : null,
1439
1443
  ].filter(Boolean).join(", ");
1440
1444
  // Where it landed matters only until the tidy, so the reply says the rule once and never the coordinates (round fourteen, entry 11).
@@ -1669,7 +1673,10 @@ server.registerTool(
1669
1673
  if (kind === "sequence" && state.groups.length === 0) return "no group too long for one sequence (not asked: no groups)";
1670
1674
  // A kind clean only because an open card is not asked says so (round eighteen, entry 18).
1671
1675
  const hiddenBy = reading.open.filter((item) => item.hides.includes(kind)).length;
1672
- if (hiddenBy) return `${CHECK_WORDS[kind]} (except ${hiddenBy} open card${hiddenBy === 1 ? "" : "s"}, not asked)`;
1676
+ // A place left open is not an open card (round twenty-one, entry 26): say which.
1677
+ const placesOpen = kind === "unplaced" ? reading.openFields.filter((field) => field.field === "location").length : 0;
1678
+ const except = [hiddenBy ? `${hiddenBy} open card${hiddenBy === 1 ? "" : "s"}` : "", placesOpen ? `${placesOpen} with ${placesOpen === 1 ? "its" : "their"} place open` : ""].filter(Boolean).join(" and ");
1679
+ if (except) return `${CHECK_WORDS[kind]} (except ${except}, not asked)`;
1673
1680
  return CHECK_WORDS[kind];
1674
1681
  }).join("; ") || "(nothing — every check found something)"}`,
1675
1682
  ];
@@ -2286,7 +2293,7 @@ server.registerTool(
2286
2293
  if (/^\.(?!\.)/.test(line) && index < ids.length) {
2287
2294
  const note = state.notes.find((item) => item.id === ids[index]);
2288
2295
  index += 1;
2289
- const standIn = note && !(note.location ?? "").trim() ? `${note.open ? " · open card" : ""} · no place: the headline stands in for the heading${note.open ? ", not a place" : ""}` : "";
2296
+ const standIn = note && !(note.location ?? "").trim() ? ((note.locationOpen ?? "").trim() ? ` · place open: the writer's words head the scene, marked, not a place` : `${note.open ? " · open card" : ""} · no place: the headline stands in for the heading${note.open ? ", not a place" : ""}`) : "";
2290
2297
  const numbered = note && pageNumbers?.get(note.id) ? ` · locked no. ${pageNumbers.get(note.id)}` : "";
2291
2298
  const mark = note ? marks.get(note.id) : null;
2292
2299
  const sourceLines = (note?.text ?? "").split("\n");
@@ -3877,6 +3884,63 @@ server.registerTool(
3877
3884
  },
3878
3885
  );
3879
3886
 
3887
+ // Help in the app (R64): the questions writers asked that the guide did not
3888
+ // answer. They are the app's, not a project's, so these two tools take the
3889
+ // maintainer's service role from the server's environment — the key never
3890
+ // ships, as the wipe script's does not — and every other door refuses them.
3891
+ async function questionsDoor() {
3892
+ const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.PLOTCODER_SERVICE_ROLE_KEY;
3893
+ if (!key) return null;
3894
+ const { createClient } = await import("@supabase/supabase-js");
3895
+ return createClient(process.env.SUPABASE_URL || SUPABASE_URL, key, { auth: { persistSession: false } });
3896
+ }
3897
+ 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.";
3898
+
3899
+ server.registerTool(
3900
+ "list_questions",
3901
+ {
3902
+ title: "The writers' questions",
3903
+ description:
3904
+ "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.",
3905
+ inputSchema: { all: z.boolean().optional() },
3906
+ },
3907
+ async (args) => {
3908
+ const db = await questionsDoor();
3909
+ if (!db) return ok(NO_QUESTIONS_DOOR);
3910
+ const { data, error } = await db.from("questions").select("id, email, question, asked_at, answered_at, answer, section").order("asked_at", { ascending: false });
3911
+ if (error) return ok(`Could not read the questions: ${error.message}`);
3912
+ const rows = args.all ? data : data.filter((row) => !row.answered_at);
3913
+ const waiting = data.filter((row) => !row.answered_at).length;
3914
+ 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"}`;
3915
+ return ok(
3916
+ [`${waiting} waiting, ${data.length - waiting} answered.${args.all ? "" : " (all: true lists the answered ones too)"}`, ...(rows.length ? rows.map(line) : [" (none)"])].join("\n"),
3917
+ rows,
3918
+ );
3919
+ },
3920
+ );
3921
+
3922
+ server.registerTool(
3923
+ "answer_question",
3924
+ {
3925
+ title: "Answer a writer's question",
3926
+ description:
3927
+ "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.",
3928
+ inputSchema: { id: z.string().min(1), answer: z.string().min(1), section: z.string().optional() },
3929
+ },
3930
+ async (args) => {
3931
+ const db = await questionsDoor();
3932
+ if (!db) return ok(NO_QUESTIONS_DOOR);
3933
+ const { data: found, error: findError } = await db.from("questions").select("id, question, answered_at").ilike("id", `${args.id}%`);
3934
+ if (findError) return ok(`Could not read the questions: ${findError.message}`);
3935
+ if (!found?.length) return ok(`No question whose id starts "${args.id}". list_questions shows them.`);
3936
+ if (found.length > 1) return ok(`${found.length} questions start "${args.id}": say more of the id.`);
3937
+ const row = found[0];
3938
+ 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);
3939
+ if (error) return ok(`Could not answer it: ${error.message}`);
3940
+ 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 });
3941
+ },
3942
+ );
3943
+
3880
3944
  server.registerTool(
3881
3945
  "export_project",
3882
3946
  {
@@ -45,7 +45,7 @@ export const AGENTS = {
45
45
  firstNote: "Make these five before anything else; none depends on another, so any order is fine. list_words and list_workflows are the app's and read no project, and their first line says so; every other reading opens with the door it came through — the project it read and how many the account holds — and a write's tail says where it landed; list_projects lists the projects. read_wall, list_reminders and list_board are about the wall you will work, so after open_project or open_board make those three again, and after new_project read the wall once it holds cards. An emptied account has nothing to read: go straight to new_project: a name is enough; pages sets the target and board names the first board when the writer has them, and set_target and rename_board set them later. On an account with no project yet, read_wall has nothing to read and says so, and list_reminders gives the house principles every project starts with. No server in front of you, and no shell to take the shell door? Nothing gets you in from inside the session: say so, and ask the person to wire the server and start a new session.",
46
46
  first: [
47
47
  { tool: "list_words", why: "the room's words, the app's meaning." },
48
- { tool: "read_wall", why: "the reading: the beats, the runs, the setups, and what the wall asks. The records — every card, the cast, the places, the rows — are list_board's. A fresh folder holds a sample wall (Maya, Tom, the letter) and the reading says so only when it is the sample; it is not the writer's." },
48
+ { tool: "read_wall", why: "the reading: the beats, the runs, the setups, and what the wall asks. The records — every card, the cast, the places, the rows — are list_board's. A fresh folder holds a sample wall (Maya, Tom, the letter) and the reading says so only when it is the sample; it is not the writer's. An account can hold a wall from earlier work that is neither the sample nor what the writer means to work now: every reading names the project it read and list_projects names who is on it; ask the writer before touching it, and new_project starts theirs beside it." },
49
49
  { tool: "list_workflows", why: "what a writer can ask you for." },
50
50
  { tool: "list_reminders", why: "the house principles the app starts with, and the writer's own; read them before you change anything. Reminders live on the project and go with it." },
51
51
  { tool: "list_board", why: "the records — every card, the cast, the places, the rows — with their ids, which the reading does not carry and every write needs." },
@@ -58,7 +58,7 @@ export const AGENTS = {
58
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
  };
@@ -24,10 +24,16 @@ function upper(text) {
24
24
  * A forced scene heading: the place, or the headline when the card has none,
25
25
  * and the card's when after a dash — THE PIER AT FENIT - NIGHT (R55).
26
26
  */
27
+ /** The heading's mark for a place the writer has left open (R61's edge): the words print, and never read back as a place. */
28
+ export const OPEN_PLACE_HEADING = "PLACE NOT DECIDED:";
29
+
27
30
  export function sceneHeading(note) {
28
31
  const place = typeof note.location === "string" ? note.location.trim() : "";
32
+ const placeOpen = typeof note.locationOpen === "string" ? note.locationOpen.trim() : "";
29
33
  const when = typeof note.when === "string" ? note.when.trim() : "";
30
- const words = place || note.headline || "UNTITLED";
34
+ // No place but the writer's words for why: the words head the scene, marked,
35
+ // so a reader never takes them for a place and the headline is not a slugline.
36
+ const words = place || (placeOpen ? `${OPEN_PLACE_HEADING} ${placeOpen}` : "") || note.headline || "UNTITLED";
31
37
  return `.${upper(words)}${when ? ` - ${upper(when)}` : ""}`;
32
38
  }
33
39
 
@@ -266,6 +272,8 @@ export function mergeFountain(state, parsed) {
266
272
  const headline = scene.synopsis || titleCase(scene.heading);
267
273
  const isPlace = scene.forced && Boolean(scene.synopsis);
268
274
  const parts = splitHeading(scene.heading);
275
+ // A heading that says the place is not decided comes back as an open place, not a place named that.
276
+ const openPlace = parts.place.toUpperCase().startsWith(OPEN_PLACE_HEADING) ? parts.place.slice(OPEN_PLACE_HEADING.length).trim().toLowerCase() : "";
269
277
  const id = `scene-${Math.random().toString(36).slice(2, 8)}`;
270
278
  // A marked body is an unwritten scene: its words are the change line, not a page.
271
279
  const body = unmark(scene.text);
@@ -274,7 +282,8 @@ export function mergeFountain(state, parsed) {
274
282
  id,
275
283
  headline,
276
284
  change: body.marked ? body.text || "What changes?" : scene.text ? firstSentence(scene.text) : "What changes?",
277
- location: isPlace ? titleCase(parts.place) : "",
285
+ location: isPlace && !openPlace ? titleCase(parts.place) : "",
286
+ locationOpen: isPlace && openPlace ? openPlace : "",
278
287
  when: isPlace ? parts.when.toLowerCase() : "",
279
288
  text: body.marked ? "" : scene.text,
280
289
  x: anchor ? anchor.x + 40 : 140,
@@ -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
+ }
@@ -73,6 +73,11 @@ export const WORD_GROUPS = [
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",