plotcoder-board 0.1.35 → 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 +3 -1
- package/package.json +1 -1
- package/scripts/plotcoder-mcp-server.mjs +57 -0
- package/src/board/agents.js +1 -1
- package/src/board/help.d.ts +11 -0
- package/src/board/help.js +103 -0
- package/src/board/words.js +5 -0
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.
|
|
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
|
@@ -3877,6 +3877,63 @@ server.registerTool(
|
|
|
3877
3877
|
},
|
|
3878
3878
|
);
|
|
3879
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
|
+
|
|
3880
3937
|
server.registerTool(
|
|
3881
3938
|
"export_project",
|
|
3882
3939
|
{
|
package/src/board/agents.js
CHANGED
|
@@ -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
|
};
|
|
@@ -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(/&/g, "&")
|
|
20
|
+
.replace(/</g, "<")
|
|
21
|
+
.replace(/>/g, ">")
|
|
22
|
+
.replace(/"/g, '"')
|
|
23
|
+
.replace(/'/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
|
+
}
|
package/src/board/words.js
CHANGED
|
@@ -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",
|