cowriter 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 +283 -0
- package/assets/cowriter-header.png +0 -0
- package/frontend/app/api/cowriter/codex/route.ts +65 -0
- package/frontend/app/api/cowriter/cover/route.ts +45 -0
- package/frontend/app/api/cowriter/events/hub.ts +24 -0
- package/frontend/app/api/cowriter/events/route.ts +77 -0
- package/frontend/app/api/cowriter/route.ts +83 -0
- package/frontend/app/api/cowriter/selection/route.ts +69 -0
- package/frontend/app/api/cowriter/selection/store.ts +27 -0
- package/frontend/app/globals.css +274 -0
- package/frontend/app/layout.tsx +14 -0
- package/frontend/app/page.tsx +1554 -0
- package/frontend/components/ui.tsx +66 -0
- package/frontend/lib/highlight.ts +53 -0
- package/frontend/lib/markdown.ts +47 -0
- package/frontend/lib/project.ts +335 -0
- package/frontend/lib/skills.ts +15 -0
- package/frontend/lib/turndown-plugin-gfm.d.ts +5 -0
- package/frontend/lib/types.ts +143 -0
- package/frontend/lib/utils.ts +6 -0
- package/frontend/lib/writing-skills.json +58 -0
- package/frontend/next-env.d.ts +6 -0
- package/frontend/next.config.js +10 -0
- package/frontend/package.json +44 -0
- package/frontend/postcss.config.mjs +7 -0
- package/frontend/tsconfig.json +22 -0
- package/package.json +62 -0
- package/scripts/cowriter-ai.mjs +1126 -0
- package/templates/init/.codex/skills/cowriter/SKILL.md +273 -0
- package/templates/init/.codex/skills/cowriter/references/actions.md +52 -0
- package/templates/init/.codex/skills/cowriter/references/character-voice.md +23 -0
- package/templates/init/.codex/skills/cowriter/references/context-priming.md +15 -0
- package/templates/init/.codex/skills/cowriter/references/continuity-review.md +22 -0
- package/templates/init/.codex/skills/cowriter/references/import-existing.md +16 -0
- package/templates/init/.codex/skills/cowriter/references/onboarding.md +45 -0
- package/templates/init/.codex/skills/cowriter/references/project-model.md +45 -0
- package/templates/init/.codex/skills/cowriter/references/prose-diagnostics.md +33 -0
- package/templates/init/.codex/skills/cowriter/references/prose-review.md +22 -0
- package/templates/init/.codex/skills/cowriter/references/scene-planning.md +28 -0
- package/templates/init/.codex/skills/cowriter/references/state-updates.md +22 -0
- package/templates/init/.codex/skills/cowriter/references/title-brainstorming.md +27 -0
- package/templates/init/.cowriter/project.yaml +3 -0
- package/templates/init/.cowriter/reports/.gitkeep +1 -0
- package/templates/init/AGENTS.md +79 -0
- package/templates/init/chapters/001-opening.md +0 -0
- package/templates/init/characters/primary-character.yaml +6 -0
- package/templates/init/outline.yaml +4 -0
- package/templates/init/story.yaml +8 -0
|
@@ -0,0 +1,1126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { createServer } from "node:net";
|
|
6
|
+
import { homedir, platform, userInfo } from "node:os";
|
|
7
|
+
import { dirname, extname, join, resolve } from "node:path";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
import YAML from "yaml";
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const invokedPath = process.argv[1] ? pathToFileURL(realpathSync(process.argv[1])).href : "";
|
|
13
|
+
const isCli = import.meta.url === invokedPath;
|
|
14
|
+
const repoRoot = resolve(new URL("..", import.meta.url).pathname);
|
|
15
|
+
const frontendRoot = join(repoRoot, "frontend");
|
|
16
|
+
const initTemplateRoot = join(repoRoot, "templates", "init");
|
|
17
|
+
const defaultPort = 47891;
|
|
18
|
+
const projectStateDir = ".cowriter";
|
|
19
|
+
const legacyProjectStateDir = ".ghostwriter";
|
|
20
|
+
const projectSkillDir = ".codex/skills/cowriter";
|
|
21
|
+
const starterSynopsis = "";
|
|
22
|
+
const legacyStarterChapterText =
|
|
23
|
+
"Opening\n\nThe first page waits here. Write into the book, select a passage, then ask Codex to revise, expand, or test it against the story bible.";
|
|
24
|
+
const legacyStarterSynopsis = "A rough promise for the book belongs here.";
|
|
25
|
+
const coverContentTypes = {
|
|
26
|
+
".png": "image/png",
|
|
27
|
+
".webp": "image/webp",
|
|
28
|
+
".jpg": "image/jpeg",
|
|
29
|
+
".jpeg": "image/jpeg",
|
|
30
|
+
".avif": "image/avif",
|
|
31
|
+
".gif": "image/gif",
|
|
32
|
+
};
|
|
33
|
+
const coverFilenamesByPreference = ["cover.png", "cover.webp", "cover.jpg", "cover.jpeg", "cover.avif", "cover.gif"];
|
|
34
|
+
const fillerTerms = [
|
|
35
|
+
"delve",
|
|
36
|
+
"utilize",
|
|
37
|
+
"leverage",
|
|
38
|
+
"facilitate",
|
|
39
|
+
"elucidate",
|
|
40
|
+
"embark",
|
|
41
|
+
"endeavor",
|
|
42
|
+
"encompass",
|
|
43
|
+
"multifaceted",
|
|
44
|
+
"tapestry",
|
|
45
|
+
"testament",
|
|
46
|
+
"paradigm",
|
|
47
|
+
"synergy",
|
|
48
|
+
"holistic",
|
|
49
|
+
"catalyze",
|
|
50
|
+
"juxtapose",
|
|
51
|
+
"nuanced",
|
|
52
|
+
"realm",
|
|
53
|
+
"landscape",
|
|
54
|
+
"myriad",
|
|
55
|
+
"plethora",
|
|
56
|
+
];
|
|
57
|
+
const repeatedWordStopWords = new Set([
|
|
58
|
+
"about",
|
|
59
|
+
"after",
|
|
60
|
+
"again",
|
|
61
|
+
"also",
|
|
62
|
+
"because",
|
|
63
|
+
"before",
|
|
64
|
+
"between",
|
|
65
|
+
"could",
|
|
66
|
+
"from",
|
|
67
|
+
"have",
|
|
68
|
+
"into",
|
|
69
|
+
"only",
|
|
70
|
+
"over",
|
|
71
|
+
"said",
|
|
72
|
+
"some",
|
|
73
|
+
"than",
|
|
74
|
+
"that",
|
|
75
|
+
"their",
|
|
76
|
+
"there",
|
|
77
|
+
"they",
|
|
78
|
+
"this",
|
|
79
|
+
"through",
|
|
80
|
+
"what",
|
|
81
|
+
"when",
|
|
82
|
+
"where",
|
|
83
|
+
"which",
|
|
84
|
+
"with",
|
|
85
|
+
"would",
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
function parseArgs(argv) {
|
|
89
|
+
if (argv[0] === "--") argv = argv.slice(1);
|
|
90
|
+
const [command, ...rest] = argv;
|
|
91
|
+
const flags = {};
|
|
92
|
+
const positionals = [];
|
|
93
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
94
|
+
const item = rest[index];
|
|
95
|
+
if (!item.startsWith("--") && item !== "-o") {
|
|
96
|
+
positionals.push(item);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const key = item === "-o" ? "output" : item.slice(2);
|
|
100
|
+
const next = rest[index + 1];
|
|
101
|
+
if (!next || next.startsWith("-")) {
|
|
102
|
+
flags[key] = true;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
flags[key] = next;
|
|
106
|
+
index += 1;
|
|
107
|
+
}
|
|
108
|
+
flags._ = positionals;
|
|
109
|
+
return { command, flags, positionals };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function output(value, flags) {
|
|
113
|
+
if (flags.output === "json" || flags.o === "json") {
|
|
114
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (typeof value.message === "string") {
|
|
118
|
+
process.stdout.write(`${value.message}\n`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function fail(error, flags) {
|
|
125
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
126
|
+
if (flags?.output === "json" || flags?.o === "json") {
|
|
127
|
+
process.stderr.write(`${JSON.stringify({ error: message }, null, 2)}\n`);
|
|
128
|
+
} else {
|
|
129
|
+
process.stderr.write(`${message}\n`);
|
|
130
|
+
}
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function expandPath(path) {
|
|
135
|
+
if (!path || typeof path !== "string") throw new Error("A project path is required.");
|
|
136
|
+
if (path === "~") return homedir();
|
|
137
|
+
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
|
138
|
+
return resolve(path);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function ensureDir(path) {
|
|
142
|
+
mkdirSync(path, { recursive: true });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readYaml(path, fallback) {
|
|
146
|
+
if (!existsSync(path)) return fallback;
|
|
147
|
+
return YAML.parse(readFileSync(path, "utf8")) ?? fallback;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function writeYaml(path, value) {
|
|
151
|
+
ensureDir(dirname(path));
|
|
152
|
+
writeFileSync(path, YAML.stringify(value), "utf8");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function listTemplateFiles(root, prefix = "") {
|
|
156
|
+
return readdirSync(join(root, prefix), { withFileTypes: true }).flatMap((entry) => {
|
|
157
|
+
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
158
|
+
if (entry.isDirectory()) return listTemplateFiles(root, relativePath);
|
|
159
|
+
if (!entry.isFile()) return [];
|
|
160
|
+
return [safeRelativePath(relativePath)];
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function renderTemplateValue(value, relativePath) {
|
|
165
|
+
const text = String(value ?? "");
|
|
166
|
+
return /\.ya?ml$/i.test(relativePath) ? JSON.stringify(text) : text;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function renderTemplate(content, context, relativePath) {
|
|
170
|
+
return content.replace(/\{\{([A-Za-z][A-Za-z0-9_]*)\}\}/g, (match, key) => {
|
|
171
|
+
if (!Object.hasOwn(context, key)) throw new Error(`Unknown template placeholder: ${key}`);
|
|
172
|
+
return renderTemplateValue(context[key], relativePath);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function writeRenderedTemplate(root, relativePath, context) {
|
|
177
|
+
const safePath = safeRelativePath(relativePath);
|
|
178
|
+
const source = join(initTemplateRoot, safePath);
|
|
179
|
+
if (!existsSync(source)) throw new Error(`Missing init template: ${safePath}`);
|
|
180
|
+
const rendered = renderTemplate(readFileSync(source, "utf8"), context, safePath);
|
|
181
|
+
const target = join(root, safePath);
|
|
182
|
+
ensureDir(dirname(target));
|
|
183
|
+
writeFileSync(target, rendered, "utf8");
|
|
184
|
+
return safePath;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function writeRenderedTemplates(root, relativePaths, context) {
|
|
188
|
+
return relativePaths.map((relativePath) => writeRenderedTemplate(root, relativePath, context));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function runGit(cwd, args) {
|
|
192
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function runGitStatus(cwd, args) {
|
|
196
|
+
return spawnSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function ensureGitRepo(root) {
|
|
200
|
+
if (!existsSync(join(root, ".git"))) runGit(root, ["init"]);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function commitBookFiles(root, changedPaths, message) {
|
|
204
|
+
const paths = Array.from(new Set((changedPaths ?? []).filter((path) => typeof path === "string" && path.trim())));
|
|
205
|
+
ensureGitRepo(root);
|
|
206
|
+
runGit(root, ["add", ...(paths.length ? paths : ["."])]);
|
|
207
|
+
|
|
208
|
+
const diff = runGitStatus(root, ["diff", "--cached", "--quiet"]);
|
|
209
|
+
if (diff.status === 0) return false;
|
|
210
|
+
if (diff.status !== 1) throw new Error(diff.stderr?.trim() || "Unable to inspect staged book changes.");
|
|
211
|
+
|
|
212
|
+
runGit(root, ["-c", "user.name=Cowriter", "-c", "user.email=cowriter@localhost", "commit", "-m", message]);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function cleanAuthorName(value) {
|
|
217
|
+
return String(value ?? "").replace(/\s+/g, " ").trim();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function systemUsername() {
|
|
221
|
+
try {
|
|
222
|
+
return cleanAuthorName(userInfo().username);
|
|
223
|
+
} catch {
|
|
224
|
+
return cleanAuthorName(process.env.USER ?? process.env.USERNAME);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function fullNameFromOperatingSystem(username) {
|
|
229
|
+
if (platform() === "darwin") {
|
|
230
|
+
const result = spawnSync("id", ["-F"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
231
|
+
return cleanAuthorName(result.stdout);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (platform() !== "win32" && username) {
|
|
235
|
+
const result = spawnSync("getent", ["passwd", username], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
236
|
+
const gecos = result.stdout.split(":")[4]?.split(",")[0];
|
|
237
|
+
return cleanAuthorName(gecos);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return "";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function defaultAuthorName() {
|
|
244
|
+
const username = systemUsername();
|
|
245
|
+
return fullNameFromOperatingSystem(username) || username || "Local Writer";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function starterProject(root, title = "Untitled Book", synopsis = "") {
|
|
249
|
+
const now = new Date().toISOString();
|
|
250
|
+
const cleanTitle = String(title ?? "").trim();
|
|
251
|
+
const metadataTitle = cleanTitle || "Untitled Book";
|
|
252
|
+
return {
|
|
253
|
+
path: root,
|
|
254
|
+
metadata: { title: metadataTitle, author: defaultAuthorName(), createdAt: now },
|
|
255
|
+
activeChapterId: "001-opening",
|
|
256
|
+
chapters: [
|
|
257
|
+
{
|
|
258
|
+
id: "001-opening",
|
|
259
|
+
title: "Opening",
|
|
260
|
+
path: "chapters/001-opening.md",
|
|
261
|
+
markdown: "",
|
|
262
|
+
updatedAt: now,
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
story: {
|
|
266
|
+
title: cleanTitle,
|
|
267
|
+
synopsis: String(synopsis ?? "").trim(),
|
|
268
|
+
setting: "",
|
|
269
|
+
themes: "",
|
|
270
|
+
genre: "",
|
|
271
|
+
tone: "",
|
|
272
|
+
perspective: "",
|
|
273
|
+
continuity: "",
|
|
274
|
+
},
|
|
275
|
+
characters: [
|
|
276
|
+
{
|
|
277
|
+
id: "primary-character",
|
|
278
|
+
name: "",
|
|
279
|
+
role: "",
|
|
280
|
+
desire: "",
|
|
281
|
+
conflict: "",
|
|
282
|
+
notes: "",
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
outline: [
|
|
286
|
+
{
|
|
287
|
+
id: "opening-beat",
|
|
288
|
+
title: "",
|
|
289
|
+
summary: "",
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
cover: null,
|
|
293
|
+
git: { dirty: false, changedPaths: [] },
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function initTemplateContext(project) {
|
|
298
|
+
return {
|
|
299
|
+
title: project.story.title,
|
|
300
|
+
metadataTitle: project.metadata.title,
|
|
301
|
+
synopsis: project.story.synopsis,
|
|
302
|
+
author: project.metadata.author,
|
|
303
|
+
createdAt: project.metadata.createdAt,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function titleFromId(id) {
|
|
308
|
+
return id
|
|
309
|
+
.replace(/^\d+-/, "")
|
|
310
|
+
.replace(/\.md$/, "")
|
|
311
|
+
.split("-")
|
|
312
|
+
.map((word) => word.slice(0, 1).toUpperCase() + word.slice(1))
|
|
313
|
+
.join(" ");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function safeId(value, label) {
|
|
317
|
+
const id = String(value ?? "").trim();
|
|
318
|
+
if (!/^[A-Za-z0-9._-]+$/.test(id)) throw new Error(`Invalid ${label}.`);
|
|
319
|
+
return id;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function safeRelativePath(path) {
|
|
323
|
+
const clean = String(path ?? "").trim();
|
|
324
|
+
if (!clean || clean.startsWith("/") || clean.split(/[\\/]/).includes("..")) throw new Error("Unsafe book path.");
|
|
325
|
+
return clean;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function safeChapterPath(path) {
|
|
329
|
+
const clean = safeRelativePath(path);
|
|
330
|
+
if (clean.includes("\\") || !clean.startsWith("chapters/") || !clean.endsWith(".md")) throw new Error("Chapter path must be a Markdown file under chapters/.");
|
|
331
|
+
return clean;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function safeReportPath(path) {
|
|
335
|
+
const clean = safeRelativePath(path);
|
|
336
|
+
const reportPrefix = reportPathPrefix(clean);
|
|
337
|
+
if (clean.includes("\\") || !reportPrefix || !clean.endsWith(".md")) {
|
|
338
|
+
throw new Error("Report path must be a Markdown file under .cowriter/reports/.");
|
|
339
|
+
}
|
|
340
|
+
const name = clean.slice(reportPrefix.length);
|
|
341
|
+
if (!name || name.includes("/")) throw new Error("Report path must point directly under .cowriter/reports/.");
|
|
342
|
+
return clean;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function reportPathPrefix(path) {
|
|
346
|
+
for (const prefix of [".cowriter/reports/", ".ghostwriter/reports/"]) {
|
|
347
|
+
if (path.startsWith(prefix)) return prefix;
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function projectMetadataPath(root) {
|
|
353
|
+
const current = join(root, projectStateDir, "project.yaml");
|
|
354
|
+
if (existsSync(current)) return current;
|
|
355
|
+
const legacy = join(root, legacyProjectStateDir, "project.yaml");
|
|
356
|
+
return existsSync(legacy) ? legacy : current;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function gitStatus(root) {
|
|
360
|
+
try {
|
|
361
|
+
const status = runGit(root, ["status", "--short"]);
|
|
362
|
+
const changedPaths = status.split("\n").map((line) => line.slice(3).trim()).filter(Boolean);
|
|
363
|
+
let lastCommit;
|
|
364
|
+
let lastCommitAt;
|
|
365
|
+
try {
|
|
366
|
+
lastCommit = runGit(root, ["log", "-1", "--pretty=%s"]);
|
|
367
|
+
lastCommitAt = runGit(root, ["log", "-1", "--pretty=%cI"]);
|
|
368
|
+
} catch {}
|
|
369
|
+
return { dirty: changedPaths.length > 0, changedPaths, lastCommit, lastCommitAt };
|
|
370
|
+
} catch {
|
|
371
|
+
return { dirty: true, changedPaths: [], lastCommit: "Git status unavailable" };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function materializeProject(project) {
|
|
376
|
+
const templatePaths = [
|
|
377
|
+
"chapters/001-opening.md",
|
|
378
|
+
"story.yaml",
|
|
379
|
+
"characters/primary-character.yaml",
|
|
380
|
+
"outline.yaml",
|
|
381
|
+
`${projectStateDir}/project.yaml`,
|
|
382
|
+
`${projectStateDir}/reports/.gitkeep`,
|
|
383
|
+
];
|
|
384
|
+
writeRenderedTemplates(project.path, templatePaths, initTemplateContext(project));
|
|
385
|
+
commitBookFiles(project.path, ["chapters", "characters", "story.yaml", "outline.yaml", `${projectStateDir}/project.yaml`, `${projectStateDir}/reports/.gitkeep`], "Create Cowriter book");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function projectInstructionBlock() {
|
|
389
|
+
return `<!-- cowriter-instructions:start -->
|
|
390
|
+
## Cowriter Runtime Instructions
|
|
391
|
+
|
|
392
|
+
Codex should edit Cowriter book files directly:
|
|
393
|
+
|
|
394
|
+
- \`chapters/*.md\` for manuscript chapters.
|
|
395
|
+
- \`story.yaml\` for title, synopsis, setting, themes, genre, tone, perspective, and continuity.
|
|
396
|
+
- \`characters/*.yaml\` for character records.
|
|
397
|
+
- \`outline.yaml\` for outline beats.
|
|
398
|
+
|
|
399
|
+
Save approved changes as you go. Do not wait until the end of the conversation to write accepted manuscript or story-material changes. The localhost Cowriter workspace watches these files and refreshes automatically.
|
|
400
|
+
|
|
401
|
+
Ask one onboarding question at a time. Wait for the writer's answer before asking the next question; do not bundle premise, protagonist, setting, ending, and folder questions into one turn unless the writer explicitly asks for a checklist.
|
|
402
|
+
|
|
403
|
+
If the localhost Cowriter workspace is not open in Codex Browser, or it is open on a different port, tell the writer to run \`npx cowriter serve --open\` from the book folder and open the reported localhost URL. Do not start the long-running serve process yourself unless the writer explicitly asks.
|
|
404
|
+
|
|
405
|
+
The workspace publishes current manuscript selection context for the served book. If the writer asks about selected text and that text is not already available in chat or file context, use the selection API when it would materially improve the response:
|
|
406
|
+
|
|
407
|
+
\`\`\`sh
|
|
408
|
+
curl -sS "$COWRITER_URL/api/cowriter/selection"
|
|
409
|
+
\`\`\`
|
|
410
|
+
|
|
411
|
+
The response includes \`selection.selectedText\`, chapter metadata, range, surrounding text, and \`updatedAt\`, or \`selection: null\` when nothing useful is selected. Treat the endpoint as optional context, not a required step for every request.
|
|
412
|
+
|
|
413
|
+
When discussing a specific manuscript passage with the writer, call the Cowriter highlight API so the web UI selects that passage. Derive \`COWRITER_URL\` from \`cowriter info -o json\` at \`app.url\`, then call:
|
|
414
|
+
|
|
415
|
+
\`\`\`sh
|
|
416
|
+
curl -sS -X POST "$COWRITER_URL/api/cowriter/codex" \\
|
|
417
|
+
-H 'content-type: application/json' \\
|
|
418
|
+
--data '{"command":"highlight","text":"exact passage text","chapterPath":"chapters/001-opening.md"}'
|
|
419
|
+
\`\`\`
|
|
420
|
+
|
|
421
|
+
Use \`occurrence\` when the same passage appears more than once. If the UI is not open or the passage is not found, continue the writing conversation normally.
|
|
422
|
+
|
|
423
|
+
Commit at sensible creative checkpoints: after accepted onboarding material, a scene/chapter draft or revision pass, a stable continuity/story-bible update, before switching task types, and before pausing or ending a work session. Before committing, run \`git status --short\`, review the relevant diff, and stage only Cowriter book files for the completed writing unit.
|
|
424
|
+
|
|
425
|
+
Serve the localhost workspace with:
|
|
426
|
+
|
|
427
|
+
\`\`\`sh
|
|
428
|
+
npx cowriter serve --open
|
|
429
|
+
\`\`\`
|
|
430
|
+
|
|
431
|
+
Refresh installed Cowriter skills with:
|
|
432
|
+
|
|
433
|
+
\`\`\`sh
|
|
434
|
+
npx cowriter upgrade
|
|
435
|
+
\`\`\`
|
|
436
|
+
<!-- cowriter-instructions:end -->`;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function installProjectSkill(root) {
|
|
440
|
+
const templatePrefix = projectSkillDir;
|
|
441
|
+
const templatePaths = listTemplateFiles(join(initTemplateRoot, templatePrefix)).map((path) => `${templatePrefix}/${path}`);
|
|
442
|
+
writeRenderedTemplates(root, templatePaths, {});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function writeProjectInstructions(root, { updateExisting = false } = {}) {
|
|
446
|
+
const agentsPath = join(root, "AGENTS.md");
|
|
447
|
+
if (!existsSync(agentsPath)) {
|
|
448
|
+
writeRenderedTemplate(root, "AGENTS.md", {});
|
|
449
|
+
} else if (updateExisting) {
|
|
450
|
+
const current = readFileSync(agentsPath, "utf8");
|
|
451
|
+
const block = projectInstructionBlock();
|
|
452
|
+
const start = "<!-- cowriter-instructions:start -->";
|
|
453
|
+
const end = "<!-- cowriter-instructions:end -->";
|
|
454
|
+
const legacyStart = "<!-- ghostwriter-instructions:start -->";
|
|
455
|
+
const legacyEnd = "<!-- ghostwriter-instructions:end -->";
|
|
456
|
+
const next = current.includes(start) && current.includes(end)
|
|
457
|
+
? current.replace(new RegExp(`${start}[\\s\\S]*?${end}`), block)
|
|
458
|
+
: current.includes(legacyStart) && current.includes(legacyEnd)
|
|
459
|
+
? current.replace(new RegExp(`${legacyStart}[\\s\\S]*?${legacyEnd}`), block)
|
|
460
|
+
: `${current.trimEnd()}\n\n${block}\n`;
|
|
461
|
+
writeFileSync(agentsPath, next, "utf8");
|
|
462
|
+
}
|
|
463
|
+
installProjectSkill(root);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function reportFallback(root, baseDir, name) {
|
|
467
|
+
const path = `${baseDir}/${name}`;
|
|
468
|
+
let createdAt = new Date().toISOString();
|
|
469
|
+
try {
|
|
470
|
+
createdAt = statSync(join(root, path)).mtime.toISOString();
|
|
471
|
+
} catch {}
|
|
472
|
+
return {
|
|
473
|
+
id: name.replace(/\.md$/, ""),
|
|
474
|
+
path,
|
|
475
|
+
type: "report",
|
|
476
|
+
title: titleFromId(name),
|
|
477
|
+
status: "unknown",
|
|
478
|
+
createdAt,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function parseReportFrontmatter(markdown) {
|
|
483
|
+
const text = String(markdown ?? "");
|
|
484
|
+
if (!text.startsWith("---\n") && !text.startsWith("---\r\n")) return {};
|
|
485
|
+
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
486
|
+
if (!match) return {};
|
|
487
|
+
const parsed = YAML.parse(match[1]);
|
|
488
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function listReports(root) {
|
|
492
|
+
return [
|
|
493
|
+
{ absolute: join(root, projectStateDir, "reports"), relative: `${projectStateDir}/reports` },
|
|
494
|
+
{ absolute: join(root, legacyProjectStateDir, "reports"), relative: `${legacyProjectStateDir}/reports` },
|
|
495
|
+
].flatMap(({ absolute, relative }) => {
|
|
496
|
+
if (!existsSync(absolute)) return [];
|
|
497
|
+
return readdirSync(absolute, { withFileTypes: true })
|
|
498
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".md") && !/[\\/]/.test(entry.name))
|
|
499
|
+
.flatMap((entry) => {
|
|
500
|
+
const fallback = reportFallback(root, relative, entry.name);
|
|
501
|
+
try {
|
|
502
|
+
const frontmatter = parseReportFrontmatter(readFileSync(join(absolute, entry.name), "utf8"));
|
|
503
|
+
return [{
|
|
504
|
+
...fallback,
|
|
505
|
+
type: typeof frontmatter.type === "string" && frontmatter.type.trim() ? frontmatter.type : fallback.type,
|
|
506
|
+
title: typeof frontmatter.title === "string" && frontmatter.title.trim() ? frontmatter.title : fallback.title,
|
|
507
|
+
status: typeof frontmatter.status === "string" && frontmatter.status.trim() ? frontmatter.status : fallback.status,
|
|
508
|
+
createdAt: typeof frontmatter.createdAt === "string" && frontmatter.createdAt.trim() ? frontmatter.createdAt : fallback.createdAt,
|
|
509
|
+
...(typeof frontmatter.chapter === "string" && frontmatter.chapter.trim() ? { chapter: frontmatter.chapter } : {}),
|
|
510
|
+
}];
|
|
511
|
+
} catch {
|
|
512
|
+
return [fallback];
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
})
|
|
516
|
+
.sort((left, right) => {
|
|
517
|
+
const dateDelta = (Date.parse(right.createdAt) || 0) - (Date.parse(left.createdAt) || 0);
|
|
518
|
+
return dateDelta || left.path.localeCompare(right.path);
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function wordsFromText(text) {
|
|
523
|
+
return String(text ?? "").toLowerCase().match(/[a-z0-9]+(?:'[a-z0-9]+)?/g) ?? [];
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function sentencesFromText(text) {
|
|
527
|
+
return String(text ?? "")
|
|
528
|
+
.replace(/\s+/g, " ")
|
|
529
|
+
.split(/(?<=[.!?])\s+/)
|
|
530
|
+
.map((sentence) => sentence.trim())
|
|
531
|
+
.filter((sentence) => wordsFromText(sentence).length > 0);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function paragraphsFromMarkdown(markdown) {
|
|
535
|
+
return String(markdown ?? "")
|
|
536
|
+
.split(/\n\s*\n/)
|
|
537
|
+
.map((paragraph) => plainTextFromMarkdown(paragraph))
|
|
538
|
+
.filter(Boolean);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function roundMetric(value) {
|
|
542
|
+
return Number.isFinite(value) ? Number(value.toFixed(2)) : 0;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function dialogueWordCount(text) {
|
|
546
|
+
const matches = String(text ?? "").match(/["“][^"”]+["”]/g) ?? [];
|
|
547
|
+
return matches.reduce((count, quote) => count + wordsFromText(quote).length, 0);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function addFinding(findings, severity, code, message, details = {}) {
|
|
551
|
+
findings.push({ severity, code, message, ...details });
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function diagnoseProse(chapterPath, markdown) {
|
|
555
|
+
const plainText = plainTextFromMarkdown(markdown);
|
|
556
|
+
if (!plainText) {
|
|
557
|
+
return {
|
|
558
|
+
chapterPath,
|
|
559
|
+
metrics: {
|
|
560
|
+
wordCount: 0,
|
|
561
|
+
paragraphCount: 0,
|
|
562
|
+
sentenceCount: 0,
|
|
563
|
+
dialoguePercent: 0,
|
|
564
|
+
averageSentenceWords: 0,
|
|
565
|
+
emDashCount: 0,
|
|
566
|
+
},
|
|
567
|
+
findings: [],
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const words = wordsFromText(plainText);
|
|
572
|
+
const paragraphs = paragraphsFromMarkdown(markdown);
|
|
573
|
+
const sentences = sentencesFromText(plainText);
|
|
574
|
+
const sentenceWordCounts = sentences.map((sentence) => wordsFromText(sentence).length).filter((count) => count > 0);
|
|
575
|
+
const wordCount = words.length;
|
|
576
|
+
const sentenceCount = sentenceWordCounts.length;
|
|
577
|
+
const averageSentenceWords = sentenceCount ? sentenceWordCounts.reduce((sum, count) => sum + count, 0) / sentenceCount : 0;
|
|
578
|
+
const emDashCount = (String(markdown ?? "").match(/—/g) ?? []).length;
|
|
579
|
+
const metrics = {
|
|
580
|
+
wordCount,
|
|
581
|
+
paragraphCount: paragraphs.length,
|
|
582
|
+
sentenceCount,
|
|
583
|
+
dialoguePercent: wordCount ? roundMetric((dialogueWordCount(markdown) / wordCount) * 100) : 0,
|
|
584
|
+
averageSentenceWords: roundMetric(averageSentenceWords),
|
|
585
|
+
emDashCount,
|
|
586
|
+
};
|
|
587
|
+
const findings = [];
|
|
588
|
+
|
|
589
|
+
paragraphs.forEach((paragraph, index) => {
|
|
590
|
+
const paragraphWordCount = wordsFromText(paragraph).length;
|
|
591
|
+
if (paragraphWordCount > 120) {
|
|
592
|
+
addFinding(findings, "warning", "long-paragraph", `Paragraph ${index + 1} is ${paragraphWordCount} words.`, { paragraph: index + 1, wordCount: paragraphWordCount });
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
const repeated = new Map();
|
|
597
|
+
for (let index = 0; index < words.length; index += 1) {
|
|
598
|
+
const word = words[index];
|
|
599
|
+
if (word.length < 4 || repeatedWordStopWords.has(word)) continue;
|
|
600
|
+
for (let lookback = Math.max(0, index - 8); lookback < index; lookback += 1) {
|
|
601
|
+
if (words[lookback] === word) {
|
|
602
|
+
repeated.set(word, (repeated.get(word) ?? 0) + 1);
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
Array.from(repeated.entries())
|
|
608
|
+
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
|
|
609
|
+
.slice(0, 10)
|
|
610
|
+
.forEach(([word, count]) => addFinding(findings, "info", "repeated-word", `"${word}" repeats within nearby wording ${count} time${count === 1 ? "" : "s"}.`, { word, count }));
|
|
611
|
+
|
|
612
|
+
if (sentenceWordCounts.length >= 5) {
|
|
613
|
+
const variance = sentenceWordCounts.reduce((sum, count) => sum + ((count - averageSentenceWords) ** 2), 0) / sentenceWordCounts.length;
|
|
614
|
+
const coefficient = Math.sqrt(variance) / averageSentenceWords;
|
|
615
|
+
if (coefficient < 0.18) {
|
|
616
|
+
addFinding(findings, "info", "uniform-sentence-rhythm", "Sentence lengths are unusually uniform across this passage.", { variation: roundMetric(coefficient) });
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (wordCount >= 300 && metrics.dialoguePercent < 10) {
|
|
621
|
+
addFinding(findings, "info", "low-dialogue-ratio", `Dialogue is ${metrics.dialoguePercent}% of the chapter.`, { dialoguePercent: metrics.dialoguePercent });
|
|
622
|
+
}
|
|
623
|
+
if (wordCount >= 300 && metrics.dialoguePercent > 70) {
|
|
624
|
+
addFinding(findings, "warning", "high-dialogue-ratio", `Dialogue is ${metrics.dialoguePercent}% of the chapter.`, { dialoguePercent: metrics.dialoguePercent });
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const lowerText = plainText.toLowerCase();
|
|
628
|
+
for (const term of fillerTerms) {
|
|
629
|
+
const matches = lowerText.match(new RegExp(`\\b${term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"));
|
|
630
|
+
if (matches?.length) addFinding(findings, "info", "filler-term", `"${term}" appears ${matches.length} time${matches.length === 1 ? "" : "s"}.`, { term, count: matches.length });
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const hasStraightQuotes = /"/.test(markdown);
|
|
634
|
+
const hasCurlyQuotes = /[“”]/.test(markdown);
|
|
635
|
+
if (hasStraightQuotes && hasCurlyQuotes) {
|
|
636
|
+
addFinding(findings, "warning", "mixed-quotes", "Straight and curly double quotes are both present.");
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (wordCount > 0 && (emDashCount / wordCount) * 1000 > 4) {
|
|
640
|
+
addFinding(findings, "warning", "em-dash-density", `${emDashCount} em dashes appear in ${wordCount} words.`, { emDashCount, perThousandWords: roundMetric((emDashCount / wordCount) * 1000) });
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return { chapterPath, metrics, findings };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function reportStatus(findings) {
|
|
647
|
+
if (findings.some((finding) => finding.severity === "issue")) return "issues";
|
|
648
|
+
if (findings.some((finding) => finding.severity === "warning")) return "warnings";
|
|
649
|
+
return "info";
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function compactTimestamp(date) {
|
|
653
|
+
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "").replace("T", "-");
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function renderProseDiagnosticsReport(diagnostics, createdAt) {
|
|
657
|
+
const status = reportStatus(diagnostics.findings);
|
|
658
|
+
const title = `Prose diagnostics: ${titleFromId(diagnostics.chapterPath.split("/").at(-1) ?? "chapter")}`;
|
|
659
|
+
const lines = [
|
|
660
|
+
"---",
|
|
661
|
+
"type: prose-diagnostics",
|
|
662
|
+
`title: ${JSON.stringify(title)}`,
|
|
663
|
+
`status: ${status}`,
|
|
664
|
+
`createdAt: ${createdAt}`,
|
|
665
|
+
`chapter: ${diagnostics.chapterPath}`,
|
|
666
|
+
"---",
|
|
667
|
+
"",
|
|
668
|
+
`# ${title}`,
|
|
669
|
+
"",
|
|
670
|
+
"## Metrics",
|
|
671
|
+
"",
|
|
672
|
+
`- Word count: ${diagnostics.metrics.wordCount}`,
|
|
673
|
+
`- Paragraph count: ${diagnostics.metrics.paragraphCount}`,
|
|
674
|
+
`- Sentence count: ${diagnostics.metrics.sentenceCount}`,
|
|
675
|
+
`- Dialogue: ${diagnostics.metrics.dialoguePercent}%`,
|
|
676
|
+
`- Average sentence length: ${diagnostics.metrics.averageSentenceWords} words`,
|
|
677
|
+
`- Em dashes: ${diagnostics.metrics.emDashCount}`,
|
|
678
|
+
"",
|
|
679
|
+
"## Findings",
|
|
680
|
+
"",
|
|
681
|
+
diagnostics.findings.length ? diagnostics.findings.map((finding) => `- **${finding.severity}** \`${finding.code}\`: ${finding.message}`).join("\n") : "- No findings.",
|
|
682
|
+
"",
|
|
683
|
+
];
|
|
684
|
+
return lines.join("\n");
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function writeProseDiagnosticsReport(root, diagnostics) {
|
|
688
|
+
const now = new Date();
|
|
689
|
+
const createdAt = now.toISOString();
|
|
690
|
+
const reportsRoot = join(root, projectStateDir, "reports");
|
|
691
|
+
ensureDir(reportsRoot);
|
|
692
|
+
const chapterId = diagnostics.chapterPath.split("/").at(-1).replace(/\.md$/, "");
|
|
693
|
+
const filename = `${compactTimestamp(now)}-prose-${chapterId}.md`;
|
|
694
|
+
const reportPath = `${projectStateDir}/reports/${filename}`;
|
|
695
|
+
writeFileSync(join(root, reportPath), renderProseDiagnosticsReport(diagnostics, createdAt), "utf8");
|
|
696
|
+
return reportPath;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function diagnoseProseChapter(root, flags) {
|
|
700
|
+
const chapterPath = safeChapterPath(flags.chapter);
|
|
701
|
+
const absoluteChapterPath = join(root, chapterPath);
|
|
702
|
+
if (!existsSync(absoluteChapterPath)) throw new Error(`Chapter does not exist: ${chapterPath}`);
|
|
703
|
+
const diagnostics = diagnoseProse(chapterPath, readFileSync(absoluteChapterPath, "utf8"));
|
|
704
|
+
const result = { diagnostics };
|
|
705
|
+
if (flags["write-report"] || flags.writeReport) result.reportPath = writeProseDiagnosticsReport(root, diagnostics);
|
|
706
|
+
return result;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function findCoverImage(root) {
|
|
710
|
+
if (!existsSync(root)) return null;
|
|
711
|
+
const names = new Map(readdirSync(root).map((name) => [name.toLowerCase(), name]));
|
|
712
|
+
for (const preferredName of coverFilenamesByPreference) {
|
|
713
|
+
const path = names.get(preferredName);
|
|
714
|
+
if (!path) continue;
|
|
715
|
+
|
|
716
|
+
const extension = extname(path).toLowerCase();
|
|
717
|
+
const contentType = coverContentTypes[extension];
|
|
718
|
+
if (!contentType) continue;
|
|
719
|
+
|
|
720
|
+
const stats = statSync(join(root, path));
|
|
721
|
+
if (!stats.isFile()) continue;
|
|
722
|
+
|
|
723
|
+
return {
|
|
724
|
+
path,
|
|
725
|
+
contentType,
|
|
726
|
+
updatedAt: new Date(stats.mtimeMs).toISOString(),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function loadProject(root) {
|
|
733
|
+
const metadata = readYaml(projectMetadataPath(root), {
|
|
734
|
+
title: "Untitled Book",
|
|
735
|
+
author: defaultAuthorName(),
|
|
736
|
+
createdAt: new Date().toISOString(),
|
|
737
|
+
});
|
|
738
|
+
const story = readYaml(join(root, "story.yaml"), starterProject(root, metadata.title).story);
|
|
739
|
+
const outlineYaml = readYaml(join(root, "outline.yaml"), { beats: starterProject(root, metadata.title).outline });
|
|
740
|
+
const chapterNames = existsSync(join(root, "chapters"))
|
|
741
|
+
? readdirSync(join(root, "chapters")).filter((name) => name.endsWith(".md")).sort()
|
|
742
|
+
: [];
|
|
743
|
+
const chapters = chapterNames.map((name) => {
|
|
744
|
+
const id = name.replace(/\.md$/, "");
|
|
745
|
+
return {
|
|
746
|
+
id,
|
|
747
|
+
title: titleFromId(id),
|
|
748
|
+
path: `chapters/${name}`,
|
|
749
|
+
markdown: readFileSync(join(root, "chapters", name), "utf8"),
|
|
750
|
+
updatedAt: new Date().toISOString(),
|
|
751
|
+
};
|
|
752
|
+
});
|
|
753
|
+
const characterNames = existsSync(join(root, "characters"))
|
|
754
|
+
? readdirSync(join(root, "characters")).filter((name) => name.endsWith(".yaml")).sort()
|
|
755
|
+
: [];
|
|
756
|
+
const characters = characterNames.map((name) => readYaml(join(root, "characters", name), {}));
|
|
757
|
+
return {
|
|
758
|
+
path: root,
|
|
759
|
+
metadata,
|
|
760
|
+
activeChapterId: chapters[0]?.id ?? "001-opening",
|
|
761
|
+
chapters: chapters.length ? chapters : starterProject(root, metadata.title).chapters,
|
|
762
|
+
story,
|
|
763
|
+
characters: characters.length ? characters : starterProject(root, metadata.title).characters,
|
|
764
|
+
outline: outlineYaml.beats ?? [],
|
|
765
|
+
cover: findCoverImage(root),
|
|
766
|
+
reports: listReports(root),
|
|
767
|
+
git: gitStatus(root),
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function readReport(root, reportPath) {
|
|
772
|
+
const path = safeReportPath(reportPath);
|
|
773
|
+
const absolutePath = join(root, path);
|
|
774
|
+
if (!existsSync(absolutePath)) throw new Error(`Report does not exist: ${path}`);
|
|
775
|
+
return {
|
|
776
|
+
path,
|
|
777
|
+
markdown: readFileSync(absolutePath, "utf8"),
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function writeChapter(root, chapter) {
|
|
782
|
+
const id = safeId(chapter.id, "chapter id");
|
|
783
|
+
const path = `chapters/${id}.md`;
|
|
784
|
+
ensureDir(join(root, "chapters"));
|
|
785
|
+
writeFileSync(join(root, path), String(chapter.markdown ?? ""), "utf8");
|
|
786
|
+
return path;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function writeStory(root, story) {
|
|
790
|
+
const project = loadProject(root);
|
|
791
|
+
writeYaml(join(root, "story.yaml"), { ...project.story, ...validateStoryFields(story) });
|
|
792
|
+
return "story.yaml";
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function writeCharacters(root, characters) {
|
|
796
|
+
ensureDir(join(root, "characters"));
|
|
797
|
+
const paths = [];
|
|
798
|
+
for (const character of characters ?? []) {
|
|
799
|
+
const id = safeId(character.id, "character id");
|
|
800
|
+
writeYaml(join(root, "characters", `${id}.yaml`), {
|
|
801
|
+
id,
|
|
802
|
+
name: String(character.name ?? ""),
|
|
803
|
+
role: String(character.role ?? ""),
|
|
804
|
+
desire: String(character.desire ?? ""),
|
|
805
|
+
conflict: String(character.conflict ?? ""),
|
|
806
|
+
notes: String(character.notes ?? ""),
|
|
807
|
+
});
|
|
808
|
+
paths.push(`characters/${id}.yaml`);
|
|
809
|
+
}
|
|
810
|
+
return paths;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function writeOutline(root, outline) {
|
|
814
|
+
const beats = (outline ?? []).map((beat) => ({
|
|
815
|
+
id: safeId(beat.id, "outline beat id"),
|
|
816
|
+
title: String(beat.title ?? ""),
|
|
817
|
+
summary: String(beat.summary ?? ""),
|
|
818
|
+
}));
|
|
819
|
+
writeYaml(join(root, "outline.yaml"), { beats });
|
|
820
|
+
return "outline.yaml";
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function plainTextFromMarkdown(markdown) {
|
|
824
|
+
return String(markdown ?? "")
|
|
825
|
+
.replace(/^[ \t]*\|?[ \t]*:?-{3,}:?[ \t]*(\|[ \t]*:?-{3,}:?[ \t]*)+\|?[ \t]*$(?:\r?\n)?/gm, "")
|
|
826
|
+
.replace(/^[ \t]*\|(.+)\|[ \t]*$/gm, (_row, cells) => cells.split("|").map((cell) => cell.trim()).filter(Boolean).join(" "))
|
|
827
|
+
.replace(/^#{1,6}\s+/gm, "")
|
|
828
|
+
.replace(/^>[ \t]?/gm, "")
|
|
829
|
+
.replace(/[*_`>#|-]/g, "")
|
|
830
|
+
.replace(/\[(.*?)\]\(.*?\)/g, "$1")
|
|
831
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
832
|
+
.trim();
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function isBookEmpty(project) {
|
|
836
|
+
const hasManuscript = project.chapters.some((chapter) => {
|
|
837
|
+
const text = plainTextFromMarkdown(chapter.markdown);
|
|
838
|
+
return text.length > 0 && text !== legacyStarterChapterText;
|
|
839
|
+
});
|
|
840
|
+
const synopsis = String(project.story.synopsis ?? "").trim();
|
|
841
|
+
const hasSynopsis = synopsis.length > 0 && synopsis !== starterSynopsis && synopsis !== legacyStarterSynopsis;
|
|
842
|
+
return !hasManuscript && !hasSynopsis;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function nextWritingTaskPrompt(project) {
|
|
846
|
+
if (isBookEmpty(project)) return "";
|
|
847
|
+
const synopsis = String(project.story.synopsis ?? "").trim();
|
|
848
|
+
if (!synopsis || synopsis === legacyStarterSynopsis) return "Write a rough synopsis so the manuscript has a clear dramatic promise.";
|
|
849
|
+
const hasManuscript = project.chapters.some((chapter) => plainTextFromMarkdown(chapter.markdown).length > 0);
|
|
850
|
+
if (!hasManuscript) return "Draft the opening page from the synopsis.";
|
|
851
|
+
return "Choose the next passage, scene beat, or story-bible note to develop.";
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function summarizeProject(project) {
|
|
855
|
+
const activeChapter = project.chapters.find((chapter) => chapter.id === project.activeChapterId) ?? project.chapters[0];
|
|
856
|
+
return {
|
|
857
|
+
path: project.path,
|
|
858
|
+
title: project.metadata.title,
|
|
859
|
+
bookIsEmpty: isBookEmpty(project),
|
|
860
|
+
nextWritingTask: nextWritingTaskPrompt(project),
|
|
861
|
+
story: project.story,
|
|
862
|
+
cover: project.cover,
|
|
863
|
+
chapters: project.chapters.map((chapter) => ({
|
|
864
|
+
id: chapter.id,
|
|
865
|
+
title: chapter.title,
|
|
866
|
+
path: chapter.path,
|
|
867
|
+
wordCount: plainTextFromMarkdown(chapter.markdown).split(/\s+/).filter(Boolean).length,
|
|
868
|
+
updatedAt: chapter.updatedAt,
|
|
869
|
+
})),
|
|
870
|
+
activeChapter: activeChapter
|
|
871
|
+
? {
|
|
872
|
+
id: activeChapter.id,
|
|
873
|
+
title: activeChapter.title,
|
|
874
|
+
path: activeChapter.path,
|
|
875
|
+
excerpt: plainTextFromMarkdown(activeChapter.markdown).slice(0, 1200),
|
|
876
|
+
}
|
|
877
|
+
: null,
|
|
878
|
+
characterCount: project.characters.length,
|
|
879
|
+
outlineBeatCount: project.outline.length,
|
|
880
|
+
reports: project.reports,
|
|
881
|
+
git: project.git,
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function validateStoryFields(fields) {
|
|
886
|
+
const allowed = new Set(["title", "synopsis", "setting", "themes", "genre", "tone", "perspective", "continuity"]);
|
|
887
|
+
return Object.fromEntries(Object.entries(fields ?? {}).filter(([key, value]) => allowed.has(key) && typeof value === "string"));
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function samePath(left, right) {
|
|
891
|
+
if (!left || !right) return false;
|
|
892
|
+
try {
|
|
893
|
+
return realpathSync(left) === realpathSync(right);
|
|
894
|
+
} catch {
|
|
895
|
+
return resolve(left) === resolve(right);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
async function cowriterServerInfo(port) {
|
|
900
|
+
try {
|
|
901
|
+
const response = await fetch(`http://localhost:${port}/api/cowriter`, {
|
|
902
|
+
method: "GET",
|
|
903
|
+
signal: AbortSignal.timeout(500),
|
|
904
|
+
});
|
|
905
|
+
if (!response.ok) return null;
|
|
906
|
+
const json = await response.json();
|
|
907
|
+
return {
|
|
908
|
+
projectPath: typeof json?.activeProject?.path === "string" ? json.activeProject.path : null,
|
|
909
|
+
};
|
|
910
|
+
} catch {
|
|
911
|
+
return null;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function portAvailable(port) {
|
|
916
|
+
return new Promise((resolvePromise) => {
|
|
917
|
+
const server = createServer();
|
|
918
|
+
server.unref();
|
|
919
|
+
server.on("error", () => resolvePromise(false));
|
|
920
|
+
server.listen({ port, host: "127.0.0.1" }, () => {
|
|
921
|
+
server.close(() => resolvePromise(true));
|
|
922
|
+
});
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
async function chooseServePort(preferredPort, projectPath) {
|
|
927
|
+
for (let offset = 0; offset < 100; offset += 1) {
|
|
928
|
+
const port = preferredPort + offset;
|
|
929
|
+
const runningServer = await cowriterServerInfo(port);
|
|
930
|
+
if (runningServer) {
|
|
931
|
+
if (samePath(runningServer.projectPath, projectPath)) return { port, alreadyRunning: true };
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
if (await portAvailable(port)) return { port, alreadyRunning: false };
|
|
935
|
+
}
|
|
936
|
+
throw new Error(`No available Cowriter port found starting at ${preferredPort}.`);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
async function waitForServer(port, projectPath, timeoutMs = 12000) {
|
|
940
|
+
const deadline = Date.now() + timeoutMs;
|
|
941
|
+
while (Date.now() < deadline) {
|
|
942
|
+
const runningServer = await cowriterServerInfo(port);
|
|
943
|
+
if (runningServer && samePath(runningServer.projectPath, projectPath)) return true;
|
|
944
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 350));
|
|
945
|
+
}
|
|
946
|
+
return false;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
async function findRunningProjectPort(preferredPort, projectPath) {
|
|
950
|
+
for (let offset = 0; offset < 100; offset += 1) {
|
|
951
|
+
const port = preferredPort + offset;
|
|
952
|
+
const runningServer = await cowriterServerInfo(port);
|
|
953
|
+
if (runningServer && samePath(runningServer.projectPath, projectPath)) return port;
|
|
954
|
+
}
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function nextBinPath() {
|
|
959
|
+
if (process.env.COWRITER_NEXT_BIN) return process.env.COWRITER_NEXT_BIN;
|
|
960
|
+
if (process.env.GHOSTWRITER_NEXT_BIN) return process.env.GHOSTWRITER_NEXT_BIN;
|
|
961
|
+
return require.resolve("next/dist/bin/next");
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function runForegroundServer(port, root) {
|
|
965
|
+
const child = spawn(process.execPath, [nextBinPath(), "dev", "--port", String(port)], {
|
|
966
|
+
cwd: frontendRoot,
|
|
967
|
+
detached: false,
|
|
968
|
+
env: { ...process.env, COWRITER_PROJECT_PATH: root, GHOSTWRITER_PROJECT_PATH: root },
|
|
969
|
+
stdio: "inherit",
|
|
970
|
+
});
|
|
971
|
+
return new Promise((resolvePromise, reject) => {
|
|
972
|
+
child.once("error", reject);
|
|
973
|
+
child.once("exit", (code, signal) => {
|
|
974
|
+
if (typeof code === "number") {
|
|
975
|
+
process.exitCode = code;
|
|
976
|
+
} else if (signal === "SIGINT") {
|
|
977
|
+
process.exitCode = 130;
|
|
978
|
+
} else if (signal) {
|
|
979
|
+
process.exitCode = 1;
|
|
980
|
+
}
|
|
981
|
+
resolvePromise();
|
|
982
|
+
});
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function attachForegroundServer(result, port, root) {
|
|
987
|
+
Object.defineProperty(result, "runForegroundServer", {
|
|
988
|
+
enumerable: false,
|
|
989
|
+
value: () => runForegroundServer(port, root),
|
|
990
|
+
});
|
|
991
|
+
return result;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function preferredPort(flags) {
|
|
995
|
+
if (flags.port) return Number(flags.port);
|
|
996
|
+
return defaultPort;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function serveMessage(project) {
|
|
1000
|
+
const base = "now open codex at this project: open -a Codex .";
|
|
1001
|
+
return project.bookIsEmpty ? base : `${base} and tell it: how should we begin?`;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function openCodexProject(root) {
|
|
1005
|
+
if (process.env.COWRITER_SKIP_CODEX_OPEN === "1" || process.env.GHOSTWRITER_SKIP_CODEX_OPEN === "1") return { requested: true, opened: false, skipped: true };
|
|
1006
|
+
const result = spawnSync("open", ["-a", "Codex", "."], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
1007
|
+
if (result.status !== 0) {
|
|
1008
|
+
throw new Error(result.stderr?.trim() || "Unable to open Codex.");
|
|
1009
|
+
}
|
|
1010
|
+
return { requested: true, opened: true, skipped: false };
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
async function handle(command, flags) {
|
|
1014
|
+
const port = preferredPort(flags);
|
|
1015
|
+
|
|
1016
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
1017
|
+
return {
|
|
1018
|
+
message: "cowriter commands: info, serve, init, upgrade, inspect, diagnose",
|
|
1019
|
+
commands: ["info", "serve", "init", "upgrade", "inspect", "diagnose"],
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if (command === "info") {
|
|
1024
|
+
const root = expandPath(flags.path ?? flags._?.[0] ?? process.cwd());
|
|
1025
|
+
if (!existsSync(root)) throw new Error(`Project does not exist: ${root}`);
|
|
1026
|
+
const runningPort = await findRunningProjectPort(port, root);
|
|
1027
|
+
const appPort = runningPort ?? port;
|
|
1028
|
+
return {
|
|
1029
|
+
cli: { name: "cowriter", version: "0.1.0" },
|
|
1030
|
+
app: { url: `http://localhost:${appPort}`, port: appPort, running: runningPort !== null },
|
|
1031
|
+
activeProject: summarizeProject(loadProject(root)),
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if (command === "serve") {
|
|
1036
|
+
const root = expandPath(flags.path ?? flags._?.[0] ?? process.cwd());
|
|
1037
|
+
if (root && !existsSync(root)) throw new Error(`Project does not exist: ${root}`);
|
|
1038
|
+
const servePort = await chooseServePort(port, root);
|
|
1039
|
+
const serveUrl = `http://localhost:${servePort.port}`;
|
|
1040
|
+
const skipServer = process.env.COWRITER_SKIP_SERVER === "1" || process.env.GHOSTWRITER_SKIP_SERVER === "1";
|
|
1041
|
+
const running = !skipServer && servePort.alreadyRunning;
|
|
1042
|
+
const project = root ? summarizeProject(loadProject(root)) : null;
|
|
1043
|
+
const codexOpen = root && flags.open ? openCodexProject(root) : { requested: false, opened: false, skipped: false };
|
|
1044
|
+
const result = {
|
|
1045
|
+
app: { url: serveUrl, port: servePort.port, running },
|
|
1046
|
+
project,
|
|
1047
|
+
codex: {
|
|
1048
|
+
command: "open -a Codex .",
|
|
1049
|
+
prompt: project && !project.bookIsEmpty ? "how should we begin?" : null,
|
|
1050
|
+
...codexOpen,
|
|
1051
|
+
},
|
|
1052
|
+
message: project ? serveMessage(project) : running ? `Cowriter is running at ${serveUrl}` : `Cowriter is starting at ${serveUrl}`,
|
|
1053
|
+
};
|
|
1054
|
+
return !skipServer && !servePort.alreadyRunning ? attachForegroundServer(result, servePort.port, root) : result;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
if (command === "init") {
|
|
1058
|
+
const root = expandPath(flags.path ?? flags._?.[0] ?? process.cwd());
|
|
1059
|
+
const title = flags.title ?? titleFromId(root.split(/[\\/]/).filter(Boolean).at(-1) ?? "Untitled Book");
|
|
1060
|
+
const project = starterProject(root, title, flags.synopsis);
|
|
1061
|
+
materializeProject(project);
|
|
1062
|
+
writeProjectInstructions(root);
|
|
1063
|
+
commitBookFiles(root, ["AGENTS.md", ".codex"], "Install Cowriter Codex skills");
|
|
1064
|
+
return {
|
|
1065
|
+
project: summarizeProject(loadProject(root)),
|
|
1066
|
+
skillPath: join(root, ".codex", "skills", "cowriter", "SKILL.md"),
|
|
1067
|
+
message: `Initialized Cowriter book at ${root}`,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
if (command === "upgrade") {
|
|
1072
|
+
const root = expandPath(flags.path ?? flags._?.[0] ?? process.cwd());
|
|
1073
|
+
if (!existsSync(root)) throw new Error(`Project does not exist: ${root}`);
|
|
1074
|
+
writeProjectInstructions(root, { updateExisting: true });
|
|
1075
|
+
const committed = commitBookFiles(root, ["AGENTS.md", ".codex"], "Upgrade Cowriter Codex skills");
|
|
1076
|
+
return {
|
|
1077
|
+
committed,
|
|
1078
|
+
project: summarizeProject(loadProject(root)),
|
|
1079
|
+
skillPath: join(root, ".codex", "skills", "cowriter", "SKILL.md"),
|
|
1080
|
+
message: `Updated Cowriter skills at ${root}`,
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (command === "inspect") {
|
|
1085
|
+
const root = expandPath(flags.path ?? flags._?.[0] ?? process.cwd());
|
|
1086
|
+
if (!existsSync(root)) throw new Error(`Project does not exist: ${root}`);
|
|
1087
|
+
return { project: summarizeProject(loadProject(root)) };
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
if (command === "diagnose") {
|
|
1091
|
+
const [subcommand] = flags._ ?? [];
|
|
1092
|
+
if (subcommand !== "prose") throw new Error("Unknown diagnose command. Expected: diagnose prose");
|
|
1093
|
+
const root = expandPath(flags.path ?? process.cwd());
|
|
1094
|
+
if (!existsSync(root)) throw new Error(`Project does not exist: ${root}`);
|
|
1095
|
+
return diagnoseProseChapter(root, flags);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
throw new Error(`Unknown command: ${command}`);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
export {
|
|
1102
|
+
findCoverImage,
|
|
1103
|
+
gitStatus,
|
|
1104
|
+
handle,
|
|
1105
|
+
isBookEmpty,
|
|
1106
|
+
loadProject,
|
|
1107
|
+
materializeProject,
|
|
1108
|
+
nextWritingTaskPrompt,
|
|
1109
|
+
readReport,
|
|
1110
|
+
starterProject,
|
|
1111
|
+
summarizeProject,
|
|
1112
|
+
writeCharacters,
|
|
1113
|
+
writeChapter,
|
|
1114
|
+
writeOutline,
|
|
1115
|
+
writeStory,
|
|
1116
|
+
};
|
|
1117
|
+
|
|
1118
|
+
if (isCli) {
|
|
1119
|
+
const { command, flags } = parseArgs(process.argv.slice(2));
|
|
1120
|
+
handle(command, flags)
|
|
1121
|
+
.then(async (value) => {
|
|
1122
|
+
output(value, flags);
|
|
1123
|
+
if (typeof value?.runForegroundServer === "function") await value.runForegroundServer();
|
|
1124
|
+
})
|
|
1125
|
+
.catch((error) => fail(error, flags));
|
|
1126
|
+
}
|