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.
Files changed (48) hide show
  1. package/README.md +283 -0
  2. package/assets/cowriter-header.png +0 -0
  3. package/frontend/app/api/cowriter/codex/route.ts +65 -0
  4. package/frontend/app/api/cowriter/cover/route.ts +45 -0
  5. package/frontend/app/api/cowriter/events/hub.ts +24 -0
  6. package/frontend/app/api/cowriter/events/route.ts +77 -0
  7. package/frontend/app/api/cowriter/route.ts +83 -0
  8. package/frontend/app/api/cowriter/selection/route.ts +69 -0
  9. package/frontend/app/api/cowriter/selection/store.ts +27 -0
  10. package/frontend/app/globals.css +274 -0
  11. package/frontend/app/layout.tsx +14 -0
  12. package/frontend/app/page.tsx +1554 -0
  13. package/frontend/components/ui.tsx +66 -0
  14. package/frontend/lib/highlight.ts +53 -0
  15. package/frontend/lib/markdown.ts +47 -0
  16. package/frontend/lib/project.ts +335 -0
  17. package/frontend/lib/skills.ts +15 -0
  18. package/frontend/lib/turndown-plugin-gfm.d.ts +5 -0
  19. package/frontend/lib/types.ts +143 -0
  20. package/frontend/lib/utils.ts +6 -0
  21. package/frontend/lib/writing-skills.json +58 -0
  22. package/frontend/next-env.d.ts +6 -0
  23. package/frontend/next.config.js +10 -0
  24. package/frontend/package.json +44 -0
  25. package/frontend/postcss.config.mjs +7 -0
  26. package/frontend/tsconfig.json +22 -0
  27. package/package.json +62 -0
  28. package/scripts/cowriter-ai.mjs +1126 -0
  29. package/templates/init/.codex/skills/cowriter/SKILL.md +273 -0
  30. package/templates/init/.codex/skills/cowriter/references/actions.md +52 -0
  31. package/templates/init/.codex/skills/cowriter/references/character-voice.md +23 -0
  32. package/templates/init/.codex/skills/cowriter/references/context-priming.md +15 -0
  33. package/templates/init/.codex/skills/cowriter/references/continuity-review.md +22 -0
  34. package/templates/init/.codex/skills/cowriter/references/import-existing.md +16 -0
  35. package/templates/init/.codex/skills/cowriter/references/onboarding.md +45 -0
  36. package/templates/init/.codex/skills/cowriter/references/project-model.md +45 -0
  37. package/templates/init/.codex/skills/cowriter/references/prose-diagnostics.md +33 -0
  38. package/templates/init/.codex/skills/cowriter/references/prose-review.md +22 -0
  39. package/templates/init/.codex/skills/cowriter/references/scene-planning.md +28 -0
  40. package/templates/init/.codex/skills/cowriter/references/state-updates.md +22 -0
  41. package/templates/init/.codex/skills/cowriter/references/title-brainstorming.md +27 -0
  42. package/templates/init/.cowriter/project.yaml +3 -0
  43. package/templates/init/.cowriter/reports/.gitkeep +1 -0
  44. package/templates/init/AGENTS.md +79 -0
  45. package/templates/init/chapters/001-opening.md +0 -0
  46. package/templates/init/characters/primary-character.yaml +6 -0
  47. package/templates/init/outline.yaml +4 -0
  48. package/templates/init/story.yaml +8 -0
package/README.md ADDED
@@ -0,0 +1,283 @@
1
+ ![Cowriter header](assets/cowriter-header.png)
2
+
3
+ # `cowriter`
4
+
5
+ A local Codex.app workspace for writing long-form stories in Markdown, YAML, and git.
6
+
7
+ > [!NOTE]
8
+ > Codex.app owns the conversation. Cowriter serves the book: manuscript pages,
9
+ > story materials, selection context, and local files Codex can edit directly.
10
+
11
+ ## Quickstart
12
+
13
+ ```sh
14
+ $ mkdir my-book
15
+ $ cd my-book
16
+ $ npx cowriter@latest init
17
+ Initialized Cowriter book at /Users/you/my-book
18
+
19
+ $ npx cowriter@latest serve --open
20
+ now open codex at this project: open -a Codex .
21
+ ▲ Next.js ...
22
+ - Local: http://localhost:47891
23
+ ```
24
+
25
+ `serve --open` hosts the current book folder and runs `open -a Codex .` from
26
+ that folder. In Codex, ask for the next writing move, a synopsis pass, a
27
+ selected-text rewrite, a continuity check, or whatever the book actually needs.
28
+
29
+ > [!IMPORTANT]
30
+ > `--open` is intentionally macOS/Codex.app shaped. Without Codex.app, you still
31
+ > have a local book folder and localhost UI, but not the intended collaborator.
32
+
33
+ ## Start With a Synopsis
34
+
35
+ Cowriter does not make you fill out a giant story bible before you can write.
36
+ Give it a rough promise for the book and start from there.
37
+
38
+ ```sh
39
+ $ npx cowriter init \
40
+ --path /Users/you/the-local-draft \
41
+ --title "The Local Draft" \
42
+ --synopsis "A cartographer returns to a city that has erased her street." \
43
+ -o json
44
+ {
45
+ "project": {
46
+ "title": "The Local Draft",
47
+ "bookIsEmpty": false,
48
+ "nextWritingTask": "Draft the opening page from the synopsis."
49
+ },
50
+ "message": "Initialized Cowriter book at /Users/you/the-local-draft"
51
+ }
52
+ ```
53
+
54
+ The first useful thing is usually a one-paragraph synopsis. Characters,
55
+ continuity, outline beats, tone, setting, and perspective can grow from the
56
+ draft instead of blocking it.
57
+
58
+ ## The Book Folder Is the API
59
+
60
+ `cowriter init` creates a normal local project:
61
+
62
+ ```txt
63
+ my-book/
64
+ AGENTS.md
65
+ chapters/
66
+ 001-opening.md
67
+ characters/
68
+ primary-character.yaml
69
+ story.yaml
70
+ outline.yaml
71
+ .cowriter/
72
+ project.yaml
73
+ reports/
74
+ .gitkeep
75
+ .codex/
76
+ skills/
77
+ cowriter/
78
+ SKILL.md
79
+ ```
80
+
81
+ The durable writing state is plain files:
82
+
83
+ - `chapters/*.md` is the manuscript.
84
+ - `story.yaml` is title, synopsis, setting, themes, genre, tone, perspective,
85
+ and continuity.
86
+ - `characters/*.yaml` is character role, desire, conflict, and notes.
87
+ - `outline.yaml` is scene and structure planning.
88
+ - `.cowriter/project.yaml` is project metadata.
89
+ - `.cowriter/reports/*.md` is optional review output, not manuscript source.
90
+
91
+ Codex edits those files. The web workspace watches them and refreshes. This is
92
+ the whole trick, and it is a good trick.
93
+
94
+ ## Serve One Book
95
+
96
+ ```sh
97
+ $ npx cowriter serve --path /Users/you/Writing/my-book --open -o json
98
+ {
99
+ "app": {
100
+ "url": "http://localhost:47891",
101
+ "port": 47891,
102
+ "running": false
103
+ },
104
+ "codex": {
105
+ "command": "open -a Codex .",
106
+ "prompt": "how should we begin?"
107
+ },
108
+ "message": "now open codex at this project: open -a Codex . and tell it: how should we begin?"
109
+ }
110
+ ```
111
+
112
+ Each `serve` process is bound to one book folder. It chooses an available port
113
+ starting at `47891`; if another Cowriter project is already there, it moves
114
+ to the next port.
115
+
116
+ Run one server per book. No global daemon. No hidden cloud state.
117
+
118
+ ## Ask Codex From the Book
119
+
120
+ Every initialized book includes project-local Codex instructions and the
121
+ Cowriter skill under `.codex/skills/cowriter`.
122
+
123
+ Useful prompts look like this:
124
+
125
+ ```text
126
+ $cowriter inspect this book and suggest the next writing task
127
+ $cowriter help me turn this synopsis into a sharper story bible
128
+ $cowriter rewrite the selected passage with stronger rhythm
129
+ $cowriter review continuity for the current chapter
130
+ $cowriter plan the next scene beat
131
+ ```
132
+
133
+ The skill prefers direct file edits once you approve a change. If you select
134
+ manuscript text in the web workspace, Codex can read the selection context from
135
+ localhost and highlight discussed passages back in the UI.
136
+
137
+ ## Inspect the Project
138
+
139
+ Use JSON when scripting or when Codex needs exact context.
140
+
141
+ ```sh
142
+ $ npx cowriter inspect --path /Users/you/Writing/my-book -o json
143
+ {
144
+ "project": {
145
+ "title": "The Local Draft",
146
+ "bookIsEmpty": false,
147
+ "nextWritingTask": "Draft the opening page from the synopsis.",
148
+ "chapters": [
149
+ {
150
+ "title": "Opening",
151
+ "path": "chapters/001-opening.md",
152
+ "wordCount": 0
153
+ }
154
+ ],
155
+ "characterCount": 1,
156
+ "outlineBeatCount": 1,
157
+ "reports": []
158
+ }
159
+ }
160
+ ```
161
+
162
+ `info` is similar, but also tells you which localhost URL should represent the
163
+ book:
164
+
165
+ ```sh
166
+ $ npx cowriter info -o json
167
+ {
168
+ "cli": { "name": "cowriter", "version": "0.1.0" },
169
+ "app": { "url": "http://localhost:47891", "running": false }
170
+ }
171
+ ```
172
+
173
+ ## Local Prose Diagnostics
174
+
175
+ Diagnostics are deterministic and chapter-level. They do not rewrite your prose.
176
+
177
+ ```sh
178
+ $ npx cowriter diagnose prose \
179
+ --path /Users/you/Writing/my-book \
180
+ --chapter chapters/001-opening.md \
181
+ -o json
182
+ {
183
+ "diagnostics": {
184
+ "metrics": {
185
+ "wordCount": 0,
186
+ "paragraphCount": 0,
187
+ "sentenceCount": 0,
188
+ "dialoguePercent": 0,
189
+ "averageSentenceWords": 0,
190
+ "emDashCount": 0
191
+ },
192
+ "findings": []
193
+ }
194
+ }
195
+ ```
196
+
197
+ It checks repetition, long paragraphs, sentence rhythm, dialogue percentage,
198
+ filler or AI-ish terms, mixed quote styles, and em dash density.
199
+
200
+ Save a report only when you want an artifact:
201
+
202
+ ```sh
203
+ $ npx cowriter diagnose prose \
204
+ --path /Users/you/Writing/my-book \
205
+ --chapter chapters/001-opening.md \
206
+ --write-report \
207
+ -o json
208
+ {
209
+ "reportPath": ".cowriter/reports/20260518-092627-prose-001-opening.md"
210
+ }
211
+ ```
212
+
213
+ ## Commands
214
+
215
+ ```sh
216
+ $ npx cowriter help -o json
217
+ {
218
+ "message": "cowriter commands: info, serve, init, upgrade, inspect, diagnose",
219
+ "commands": ["info", "serve", "init", "upgrade", "inspect", "diagnose"]
220
+ }
221
+ ```
222
+
223
+ The common forms:
224
+
225
+ ```sh
226
+ npx cowriter init
227
+ npx cowriter init --path /absolute/path/to/book --title "Working Title"
228
+ npx cowriter --help
229
+ npx cowriter serve --path /absolute/path/to/book --open
230
+ npx cowriter info --path /absolute/path/to/book -o json
231
+ npx cowriter inspect --path /absolute/path/to/book -o json
232
+ npx cowriter upgrade --path /absolute/path/to/book -o json
233
+ npx cowriter diagnose prose --path /absolute/path/to/book --chapter chapters/001-opening.md -o json
234
+ ```
235
+
236
+ ## Package Shape
237
+
238
+ The npm package ships the CLI, the bundled Next.js frontend, and the init
239
+ templates. The intended public path is:
240
+
241
+ ```sh
242
+ npx cowriter init
243
+ npx cowriter serve --open
244
+ ```
245
+
246
+ For repo development:
247
+
248
+ ```sh
249
+ $ pnpm install
250
+ $ pnpm test
251
+ $ pnpm build
252
+ $ pnpm pack:dry
253
+ ```
254
+
255
+ Before changing publish-facing behavior, test the packed package, not just the
256
+ checkout:
257
+
258
+ ```sh
259
+ $ npm pack
260
+ $ tmp=$(mktemp -d)
261
+ $ cd "$tmp"
262
+ $ npm exec --yes --package=/absolute/path/to/cowriter-0.1.0.tgz -- cowriter init
263
+ $ npm exec --yes --package=/absolute/path/to/cowriter-0.1.0.tgz -- cowriter serve
264
+ ```
265
+
266
+ Remove generated `cowriter-*.tgz` files after smoke tests. They are not a
267
+ souvenir.
268
+
269
+ ## Caveats
270
+
271
+ > [!WARNING]
272
+ > This is local writing-workspace software. Keep the book in git, review diffs,
273
+ > and do not treat generated prose or story changes as automatically correct.
274
+
275
+ - Cowriter serves one book folder per process.
276
+ - The localhost app is not a chat sidebar. Codex.app is the chat surface.
277
+ - Book data is Markdown and YAML on disk. If Codex changes it, git can show it.
278
+ - `serve` is a foreground dev server. Stop it with `Ctrl-C`.
279
+ - Real Codex-backed workflows require Codex.app with localhost access.
280
+
281
+ ## License
282
+
283
+ MIT
Binary file
@@ -0,0 +1,65 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { NextRequest, NextResponse } from "next/server";
3
+ import { publishCodexCommand, type CodexHighlightCommand } from "../events/hub";
4
+
5
+ export const runtime = "nodejs";
6
+ export const dynamic = "force-dynamic";
7
+
8
+ const commandDescriptions = [
9
+ {
10
+ command: "highlight",
11
+ description: "Highlight a manuscript passage in the Cowriter workspace UI.",
12
+ request: {
13
+ command: "highlight",
14
+ text: "exact passage text",
15
+ chapterPath: "chapters/001-opening.md",
16
+ occurrence: 1,
17
+ },
18
+ required: ["command", "text"],
19
+ optional: ["chapterPath", "occurrence"],
20
+ },
21
+ ];
22
+
23
+ function parseHighlightCommand(body: unknown): CodexHighlightCommand {
24
+ if (!body || typeof body !== "object") throw new Error("Request body must be a JSON object.");
25
+ const value = body as Record<string, unknown>;
26
+ if (value.command !== "highlight") throw new Error(`Unsupported command: ${String(value.command)}`);
27
+ if (typeof value.text !== "string" || !value.text.trim()) throw new Error("Highlight command requires non-empty text.");
28
+ if (value.chapterPath !== undefined && typeof value.chapterPath !== "string") throw new Error("chapterPath must be a string when provided.");
29
+ const occurrence = value.occurrence;
30
+ if (occurrence !== undefined && (typeof occurrence !== "number" || !Number.isInteger(occurrence) || occurrence < 1)) {
31
+ throw new Error("occurrence must be a positive integer when provided.");
32
+ }
33
+
34
+ return {
35
+ id: randomUUID(),
36
+ command: "highlight",
37
+ text: value.text,
38
+ chapterPath: value.chapterPath,
39
+ occurrence: typeof occurrence === "number" ? occurrence : 1,
40
+ createdAt: Date.now(),
41
+ };
42
+ }
43
+
44
+ export async function GET() {
45
+ return NextResponse.json({
46
+ commands: commandDescriptions,
47
+ context: [
48
+ {
49
+ endpoint: "/api/cowriter/selection",
50
+ method: "GET",
51
+ description: "Returns the current manuscript selection context published by the Cowriter workspace, or null when nothing is selected.",
52
+ },
53
+ ],
54
+ });
55
+ }
56
+
57
+ export async function POST(request: NextRequest) {
58
+ try {
59
+ const command = parseHighlightCommand(await request.json());
60
+ const deliveredTo = publishCodexCommand(command);
61
+ return NextResponse.json({ queued: true, deliveredTo, command });
62
+ } catch (error) {
63
+ return NextResponse.json({ error: error instanceof Error ? error.message : "Codex command failed" }, { status: 400 });
64
+ }
65
+ }
@@ -0,0 +1,45 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { NextResponse } from "next/server";
5
+
6
+ export const runtime = "nodejs";
7
+ export const dynamic = "force-dynamic";
8
+
9
+ const cliPath = join(process.cwd(), "../scripts/cowriter-ai.mjs");
10
+
11
+ type CowriterModule = {
12
+ findCoverImage(path: string): { path: string; contentType: string; updatedAt: string } | null;
13
+ };
14
+
15
+ type CowriterImporter = (specifier: string) => Promise<CowriterModule>;
16
+ const testImporter = (globalThis as { __cowriterImport?: CowriterImporter }).__cowriterImport;
17
+ const nativeImporter = new Function("specifier", "return import(specifier)") as CowriterImporter;
18
+
19
+ async function cowriter() {
20
+ return (testImporter ?? nativeImporter)(pathToFileURL(cliPath).href);
21
+ }
22
+
23
+ function projectPath() {
24
+ return process.env.COWRITER_PROJECT_PATH ?? process.env.GHOSTWRITER_PROJECT_PATH;
25
+ }
26
+
27
+ export async function GET() {
28
+ try {
29
+ const path = projectPath();
30
+ if (!path) return NextResponse.json({ error: "Cowriter was not started with a project path." }, { status: 404 });
31
+
32
+ const cowriterModule = await cowriter();
33
+ const cover = cowriterModule.findCoverImage(path);
34
+ if (!cover) return NextResponse.json({ error: "No root cover image found." }, { status: 404 });
35
+
36
+ return new NextResponse(new Uint8Array(readFileSync(join(path, cover.path))), {
37
+ headers: {
38
+ "cache-control": "no-store",
39
+ "content-type": cover.contentType,
40
+ },
41
+ });
42
+ } catch (error) {
43
+ return NextResponse.json({ error: error instanceof Error ? error.message : "Unable to read cover image" }, { status: 500 });
44
+ }
45
+ }
@@ -0,0 +1,24 @@
1
+ export type CodexHighlightCommand = {
2
+ id: string;
3
+ command: "highlight";
4
+ text: string;
5
+ chapterPath?: string;
6
+ occurrence: number;
7
+ createdAt: number;
8
+ };
9
+
10
+ export type CodexUiCommand = CodexHighlightCommand;
11
+
12
+ type Listener = (command: CodexUiCommand) => void;
13
+
14
+ const listeners = new Set<Listener>();
15
+
16
+ export function subscribeToCodexCommands(listener: Listener) {
17
+ listeners.add(listener);
18
+ return () => listeners.delete(listener);
19
+ }
20
+
21
+ export function publishCodexCommand(command: CodexUiCommand) {
22
+ for (const listener of listeners) listener(command);
23
+ return listeners.size;
24
+ }
@@ -0,0 +1,77 @@
1
+ import { existsSync, watch, type FSWatcher } from "node:fs";
2
+ import { subscribeToCodexCommands } from "./hub";
3
+
4
+ export const runtime = "nodejs";
5
+ export const dynamic = "force-dynamic";
6
+
7
+ function serverProjectPath() {
8
+ const projectPath = process.env.COWRITER_PROJECT_PATH ?? process.env.GHOSTWRITER_PROJECT_PATH;
9
+ return typeof projectPath === "string" && existsSync(projectPath) ? projectPath : null;
10
+ }
11
+
12
+ function shouldIgnore(filename: string | null) {
13
+ if (!filename) return false;
14
+ return filename.includes(".git/") || filename.startsWith(".git") || filename.includes("node_modules/");
15
+ }
16
+
17
+ export async function GET(request: Request) {
18
+ const encoder = new TextEncoder();
19
+ const projectPath = serverProjectPath();
20
+ let watcher: FSWatcher | null = null;
21
+ let heartbeat: ReturnType<typeof setInterval> | null = null;
22
+ let unsubscribeFromCodexCommands: (() => void) | null = null;
23
+ let isClosed = false;
24
+ const cleanup = () => {
25
+ if (isClosed) return;
26
+ isClosed = true;
27
+ watcher?.close();
28
+ unsubscribeFromCodexCommands?.();
29
+ if (heartbeat) clearInterval(heartbeat);
30
+ };
31
+
32
+ const stream = new ReadableStream({
33
+ start(controller) {
34
+ const send = (payload: unknown) => {
35
+ if (isClosed) return;
36
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`));
37
+ };
38
+
39
+ send({ type: "connected", projectPath });
40
+ unsubscribeFromCodexCommands = subscribeToCodexCommands((command) => {
41
+ send({ type: "codex-command", command });
42
+ });
43
+
44
+ if (projectPath) {
45
+ try {
46
+ watcher = watch(projectPath, { recursive: true }, (_event, filename) => {
47
+ const changedPath = filename?.toString() ?? null;
48
+ if (shouldIgnore(changedPath)) return;
49
+ send({ type: "change", path: changedPath, at: Date.now() });
50
+ });
51
+ } catch (error) {
52
+ send({ type: "error", message: error instanceof Error ? error.message : "Unable to watch project files" });
53
+ }
54
+ }
55
+
56
+ heartbeat = setInterval(() => send({ type: "heartbeat", at: Date.now() }), 25000);
57
+
58
+ request.signal.addEventListener("abort", () => {
59
+ cleanup();
60
+ try {
61
+ controller.close();
62
+ } catch {}
63
+ });
64
+ },
65
+ cancel() {
66
+ cleanup();
67
+ },
68
+ });
69
+
70
+ return new Response(stream, {
71
+ headers: {
72
+ "content-type": "text/event-stream",
73
+ "cache-control": "no-cache, no-transform",
74
+ connection: "keep-alive",
75
+ },
76
+ });
77
+ }
@@ -0,0 +1,83 @@
1
+ import { join } from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import { NextRequest, NextResponse } from "next/server";
4
+
5
+ export const runtime = "nodejs";
6
+ export const dynamic = "force-dynamic";
7
+
8
+ const cliPath = join(process.cwd(), "../scripts/cowriter-ai.mjs");
9
+
10
+ type CowriterModule = {
11
+ findCoverImage(path: string): { path: string; contentType: string; updatedAt: string } | null;
12
+ loadProject(path: string): unknown;
13
+ readReport(path: string, reportPath: string): unknown;
14
+ writeChapter(path: string, chapter: unknown): unknown;
15
+ writeCharacters(path: string, characters: unknown[]): unknown;
16
+ writeOutline(path: string, outline: unknown[]): unknown;
17
+ writeStory(path: string, story: unknown): unknown;
18
+ };
19
+
20
+ type CowriterImporter = (specifier: string) => Promise<CowriterModule>;
21
+ const testImporter = (globalThis as { __cowriterImport?: CowriterImporter }).__cowriterImport;
22
+ const nativeImporter = new Function("specifier", "return import(specifier)") as CowriterImporter;
23
+
24
+ async function cowriter() {
25
+ return (testImporter ?? nativeImporter)(pathToFileURL(cliPath).href);
26
+ }
27
+
28
+ function projectPath() {
29
+ return process.env.COWRITER_PROJECT_PATH ?? process.env.GHOSTWRITER_PROJECT_PATH;
30
+ }
31
+
32
+ export async function GET() {
33
+ try {
34
+ const path = projectPath();
35
+ if (!path) return NextResponse.json({ activeProject: null, error: "Cowriter was not started with a project path." }, { status: 200 });
36
+ const cowriterModule = await cowriter();
37
+ return NextResponse.json({ activeProject: cowriterModule.loadProject(path) });
38
+ } catch (error) {
39
+ return NextResponse.json({ activeProject: null, error: error instanceof Error ? error.message : "Unable to read Cowriter project" }, { status: 200 });
40
+ }
41
+ }
42
+
43
+ export async function POST(request: NextRequest) {
44
+ try {
45
+ const body = await request.json();
46
+ const command = body.command;
47
+ const path = projectPath();
48
+ if (!path) return NextResponse.json({ error: "Cowriter was not started with a project path." }, { status: 400 });
49
+ const cowriterModule = await cowriter();
50
+
51
+ if (command === "write-chapter") {
52
+ cowriterModule.writeChapter(path, body.chapter);
53
+ return NextResponse.json({ project: cowriterModule.loadProject(path) });
54
+ }
55
+
56
+ if (command === "write-story") {
57
+ cowriterModule.writeStory(path, body.story);
58
+ return NextResponse.json({ project: cowriterModule.loadProject(path) });
59
+ }
60
+
61
+ if (command === "write-characters") {
62
+ cowriterModule.writeCharacters(path, Array.isArray(body.characters) ? body.characters : []);
63
+ return NextResponse.json({ project: cowriterModule.loadProject(path) });
64
+ }
65
+
66
+ if (command === "write-outline") {
67
+ cowriterModule.writeOutline(path, Array.isArray(body.outline) ? body.outline : []);
68
+ return NextResponse.json({ project: cowriterModule.loadProject(path) });
69
+ }
70
+
71
+ if (command === "read-report") {
72
+ try {
73
+ return NextResponse.json({ report: cowriterModule.readReport(path, body.path) });
74
+ } catch (error) {
75
+ return NextResponse.json({ error: error instanceof Error ? error.message : "Unable to read report" }, { status: 400 });
76
+ }
77
+ }
78
+
79
+ return NextResponse.json({ error: `Unsupported command: ${command}` }, { status: 400 });
80
+ } catch (error) {
81
+ return NextResponse.json({ error: error instanceof Error ? error.message : "Cowriter command failed" }, { status: 500 });
82
+ }
83
+ }
@@ -0,0 +1,69 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { readSelectionContext, writeSelectionContext, type CowriterSelectionContext } from "./store";
3
+
4
+ export const runtime = "nodejs";
5
+ export const dynamic = "force-dynamic";
6
+
7
+ type SelectionBody = {
8
+ selection?: unknown;
9
+ };
10
+
11
+ function projectPath() {
12
+ return process.env.COWRITER_PROJECT_PATH ?? process.env.GHOSTWRITER_PROJECT_PATH ?? null;
13
+ }
14
+
15
+ function parseSelection(body: SelectionBody, boundProjectPath: string): CowriterSelectionContext | null {
16
+ if (body.selection === null) return null;
17
+ if (!body.selection || typeof body.selection !== "object") throw new Error("selection must be an object or null.");
18
+
19
+ const value = body.selection as Record<string, unknown>;
20
+ if (value.source !== "manuscript") throw new Error("selection.source must be manuscript.");
21
+ if (typeof value.selectedText !== "string" || !value.selectedText.trim()) return null;
22
+ if (typeof value.chapterId !== "string" || !value.chapterId.trim()) throw new Error("selection.chapterId must be a string.");
23
+ if (typeof value.chapterPath !== "string" || !value.chapterPath.trim()) throw new Error("selection.chapterPath must be a string.");
24
+ if (typeof value.chapterTitle !== "string") throw new Error("selection.chapterTitle must be a string.");
25
+ if (typeof value.surroundingText !== "string") throw new Error("selection.surroundingText must be a string.");
26
+ if (typeof value.activePage !== "number" || !Number.isInteger(value.activePage) || value.activePage < 0) {
27
+ throw new Error("selection.activePage must be a non-negative integer.");
28
+ }
29
+
30
+ const range = value.range;
31
+ if (!range || typeof range !== "object") throw new Error("selection.range must be an object.");
32
+ const from = (range as Record<string, unknown>).from;
33
+ const to = (range as Record<string, unknown>).to;
34
+ if (typeof from !== "number" || typeof to !== "number" || !Number.isInteger(from) || !Number.isInteger(to) || from < 0 || to <= from) {
35
+ throw new Error("selection.range must contain valid from and to offsets.");
36
+ }
37
+
38
+ return {
39
+ projectPath: boundProjectPath,
40
+ source: "manuscript",
41
+ selectedText: value.selectedText,
42
+ chapterId: value.chapterId,
43
+ chapterPath: value.chapterPath,
44
+ chapterTitle: value.chapterTitle,
45
+ range: { from, to },
46
+ activePage: value.activePage,
47
+ surroundingText: value.surroundingText,
48
+ updatedAt: Date.now(),
49
+ };
50
+ }
51
+
52
+ export async function GET() {
53
+ const boundProjectPath = projectPath();
54
+ return NextResponse.json({
55
+ activeProjectPath: boundProjectPath,
56
+ selection: readSelectionContext(boundProjectPath),
57
+ });
58
+ }
59
+
60
+ export async function POST(request: NextRequest) {
61
+ try {
62
+ const boundProjectPath = projectPath();
63
+ if (!boundProjectPath) return NextResponse.json({ error: "Cowriter was not started with a project path." }, { status: 400 });
64
+ const selection = parseSelection(await request.json(), boundProjectPath);
65
+ return NextResponse.json({ selection: writeSelectionContext(selection) });
66
+ } catch (error) {
67
+ return NextResponse.json({ error: error instanceof Error ? error.message : "Unable to update selection context" }, { status: 400 });
68
+ }
69
+ }
@@ -0,0 +1,27 @@
1
+ export type CowriterSelectionContext = {
2
+ projectPath: string;
3
+ source: "manuscript";
4
+ selectedText: string;
5
+ chapterId: string;
6
+ chapterPath: string;
7
+ chapterTitle: string;
8
+ range: {
9
+ from: number;
10
+ to: number;
11
+ };
12
+ activePage: number;
13
+ surroundingText: string;
14
+ updatedAt: number;
15
+ };
16
+
17
+ let latestSelection: CowriterSelectionContext | null = null;
18
+
19
+ export function readSelectionContext(projectPath: string | null) {
20
+ if (!projectPath || latestSelection?.projectPath !== projectPath) return null;
21
+ return latestSelection;
22
+ }
23
+
24
+ export function writeSelectionContext(selection: CowriterSelectionContext | null) {
25
+ latestSelection = selection;
26
+ return latestSelection;
27
+ }