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
package/README.md
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+

|
|
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
|
+
}
|