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,66 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
export function Button({
|
|
5
|
+
className,
|
|
6
|
+
variant = "default",
|
|
7
|
+
...props
|
|
8
|
+
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "default" | "ghost" | "line" }) {
|
|
9
|
+
return (
|
|
10
|
+
<button
|
|
11
|
+
className={cn(
|
|
12
|
+
"inline-flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition duration-200 ease-out active:translate-y-px disabled:pointer-events-none disabled:opacity-45",
|
|
13
|
+
variant === "default" && "border border-stone-300 bg-[#fffdf8] text-stone-900 shadow-[0_12px_28px_-24px_rgba(87,83,78,0.55)] hover:border-stone-400 hover:bg-stone-100",
|
|
14
|
+
variant === "ghost" && "text-stone-600 hover:bg-stone-100 hover:text-stone-950",
|
|
15
|
+
variant === "line" && "border border-stone-300 bg-white text-stone-800 hover:border-stone-400 hover:bg-stone-50",
|
|
16
|
+
className,
|
|
17
|
+
)}
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
|
|
24
|
+
return (
|
|
25
|
+
<input
|
|
26
|
+
className={cn(
|
|
27
|
+
"h-10 w-full rounded-md border border-stone-300 bg-[#fffdf8] px-3 text-sm text-stone-900 outline-none transition placeholder:text-stone-400 focus:border-amber-700",
|
|
28
|
+
className,
|
|
29
|
+
)}
|
|
30
|
+
{...props}
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
|
|
36
|
+
function Textarea({ className, ...props }, ref) {
|
|
37
|
+
return (
|
|
38
|
+
<textarea
|
|
39
|
+
ref={ref}
|
|
40
|
+
className={cn(
|
|
41
|
+
"min-h-28 w-full resize-y rounded-md border border-stone-300 bg-[#fffdf8] px-3 py-2 text-sm leading-6 text-stone-900 outline-none transition placeholder:text-stone-400 focus:border-amber-700",
|
|
42
|
+
className,
|
|
43
|
+
)}
|
|
44
|
+
{...props}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
export function Field({
|
|
51
|
+
label,
|
|
52
|
+
helper,
|
|
53
|
+
children,
|
|
54
|
+
}: {
|
|
55
|
+
label: string;
|
|
56
|
+
helper?: string;
|
|
57
|
+
children: React.ReactNode;
|
|
58
|
+
}) {
|
|
59
|
+
return (
|
|
60
|
+
<label className="grid gap-2">
|
|
61
|
+
<span className="text-xs font-medium uppercase text-stone-500">{label}</span>
|
|
62
|
+
{children}
|
|
63
|
+
{helper ? <span className="text-xs text-stone-500">{helper}</span> : null}
|
|
64
|
+
</label>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type TextMatch = {
|
|
2
|
+
start: number;
|
|
3
|
+
end: number;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
function normalizedTextWithMap(value: string) {
|
|
7
|
+
let text = "";
|
|
8
|
+
const map: number[] = [];
|
|
9
|
+
let previousWasWhitespace = false;
|
|
10
|
+
|
|
11
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
12
|
+
const character = value[index];
|
|
13
|
+
if (/\s/.test(character)) {
|
|
14
|
+
if (!previousWasWhitespace) {
|
|
15
|
+
text += " ";
|
|
16
|
+
map.push(index);
|
|
17
|
+
previousWasWhitespace = true;
|
|
18
|
+
}
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
text += character;
|
|
23
|
+
map.push(index);
|
|
24
|
+
previousWasWhitespace = false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { text: text.trim(), map, leadingTrimmed: text.length - text.trimStart().length };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function findNormalizedTextMatch(source: string, target: string, occurrence = 1): TextMatch | null {
|
|
31
|
+
const requestedOccurrence = Math.max(1, Math.trunc(occurrence));
|
|
32
|
+
const normalizedSource = normalizedTextWithMap(source);
|
|
33
|
+
const normalizedTarget = target.trim().replace(/\s+/g, " ");
|
|
34
|
+
if (!normalizedSource.text || !normalizedTarget) return null;
|
|
35
|
+
|
|
36
|
+
let searchFrom = 0;
|
|
37
|
+
let seen = 0;
|
|
38
|
+
while (searchFrom <= normalizedSource.text.length) {
|
|
39
|
+
const index = normalizedSource.text.indexOf(normalizedTarget, searchFrom);
|
|
40
|
+
if (index === -1) return null;
|
|
41
|
+
seen += 1;
|
|
42
|
+
if (seen === requestedOccurrence) {
|
|
43
|
+
const sourceMapStart = index + normalizedSource.leadingTrimmed;
|
|
44
|
+
const sourceMapEnd = sourceMapStart + normalizedTarget.length - 1;
|
|
45
|
+
const start = normalizedSource.map[sourceMapStart];
|
|
46
|
+
const end = normalizedSource.map[sourceMapEnd] + 1;
|
|
47
|
+
return typeof start === "number" && typeof end === "number" ? { start, end } : null;
|
|
48
|
+
}
|
|
49
|
+
searchFrom = index + normalizedTarget.length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { marked } from "marked";
|
|
2
|
+
import TurndownService from "turndown";
|
|
3
|
+
import { gfm } from "turndown-plugin-gfm";
|
|
4
|
+
|
|
5
|
+
const turndown = new TurndownService({
|
|
6
|
+
headingStyle: "atx",
|
|
7
|
+
bulletListMarker: "-",
|
|
8
|
+
codeBlockStyle: "fenced",
|
|
9
|
+
});
|
|
10
|
+
turndown.use(gfm);
|
|
11
|
+
|
|
12
|
+
export function markdownToHtml(markdown: string) {
|
|
13
|
+
return marked.parse(markdown, { async: false, breaks: false }) as string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function htmlToMarkdown(html: string) {
|
|
17
|
+
return turndown.turndown(html).trim() + "\n";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function plainTextFromMarkdown(markdown: string) {
|
|
21
|
+
return markdown
|
|
22
|
+
.replace(/^[ \t]*\|?[ \t]*:?-{3,}:?[ \t]*(\|[ \t]*:?-{3,}:?[ \t]*)+\|?[ \t]*$(?:\r?\n)?/gm, "")
|
|
23
|
+
.replace(/^[ \t]*\|(.+)\|[ \t]*$/gm, (_row, cells: string) => cells.split("|").map((cell) => cell.trim()).filter(Boolean).join(" "))
|
|
24
|
+
.replace(/^#{1,6}\s+/gm, "")
|
|
25
|
+
.replace(/^>[ \t]?/gm, "")
|
|
26
|
+
.replace(/[*_`>#|-]/g, "")
|
|
27
|
+
.replace(/\[(.*?)\]\(.*?\)/g, "$1")
|
|
28
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
29
|
+
.trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function paginateText(text: string, pageSize = 1750) {
|
|
33
|
+
const clean = text.trim();
|
|
34
|
+
if (!clean) return [""];
|
|
35
|
+
const pages: string[] = [];
|
|
36
|
+
let cursor = 0;
|
|
37
|
+
while (cursor < clean.length) {
|
|
38
|
+
let end = Math.min(cursor + pageSize, clean.length);
|
|
39
|
+
if (end < clean.length) {
|
|
40
|
+
const breakAt = Math.max(clean.lastIndexOf("\n\n", end), clean.lastIndexOf(". ", end));
|
|
41
|
+
if (breakAt > cursor + pageSize * 0.55) end = breakAt + 1;
|
|
42
|
+
}
|
|
43
|
+
pages.push(clean.slice(cursor, end).trim());
|
|
44
|
+
cursor = end;
|
|
45
|
+
}
|
|
46
|
+
return pages.length ? pages : [""];
|
|
47
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import YAML from "yaml";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type {
|
|
4
|
+
Chapter,
|
|
5
|
+
CharacterRecord,
|
|
6
|
+
CodexAction,
|
|
7
|
+
CodexChatRequest,
|
|
8
|
+
CodexChatResponse,
|
|
9
|
+
CowriterProject,
|
|
10
|
+
CowriterReportContent,
|
|
11
|
+
OutlineBeat,
|
|
12
|
+
StoryBible,
|
|
13
|
+
} from "./types";
|
|
14
|
+
|
|
15
|
+
const starterChapterText = "";
|
|
16
|
+
const starterSynopsis = "";
|
|
17
|
+
const legacyStarterChapterText =
|
|
18
|
+
"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.";
|
|
19
|
+
const legacyStarterSynopsis = "A rough promise for the book belongs here.";
|
|
20
|
+
|
|
21
|
+
export const storySchema = z.object({
|
|
22
|
+
title: z.string(),
|
|
23
|
+
synopsis: z.string(),
|
|
24
|
+
setting: z.string(),
|
|
25
|
+
themes: z.string(),
|
|
26
|
+
genre: z.string(),
|
|
27
|
+
tone: z.string(),
|
|
28
|
+
perspective: z.string(),
|
|
29
|
+
continuity: z.string(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const characterSchema = z.object({
|
|
33
|
+
id: z.string(),
|
|
34
|
+
name: z.string(),
|
|
35
|
+
role: z.string(),
|
|
36
|
+
desire: z.string(),
|
|
37
|
+
conflict: z.string(),
|
|
38
|
+
notes: z.string(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const outlineBeatSchema = z.object({
|
|
42
|
+
id: z.string(),
|
|
43
|
+
title: z.string(),
|
|
44
|
+
summary: z.string(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const reportSchema = z.object({
|
|
48
|
+
id: z.string(),
|
|
49
|
+
path: z.string(),
|
|
50
|
+
type: z.string(),
|
|
51
|
+
title: z.string(),
|
|
52
|
+
status: z.string(),
|
|
53
|
+
createdAt: z.string(),
|
|
54
|
+
chapter: z.string().optional(),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const reportContentSchema = z.object({
|
|
58
|
+
path: z.string(),
|
|
59
|
+
markdown: z.string(),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export const coverSchema = z.object({
|
|
63
|
+
path: z.string(),
|
|
64
|
+
contentType: z.string(),
|
|
65
|
+
updatedAt: z.string(),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const replaceSelectionActionSchema = z.object({
|
|
69
|
+
type: z.literal("replace-selection"),
|
|
70
|
+
markdown: z.string(),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const insertAtCursorActionSchema = z.object({
|
|
74
|
+
type: z.literal("insert-at-cursor"),
|
|
75
|
+
markdown: z.string(),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const replaceChapterActionSchema = z.object({
|
|
79
|
+
type: z.literal("replace-chapter"),
|
|
80
|
+
chapterId: z.string(),
|
|
81
|
+
markdown: z.string(),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const updateStoryFieldsActionSchema = z.object({
|
|
85
|
+
type: z.literal("update-story-fields"),
|
|
86
|
+
fields: storySchema.partial(),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const upsertCharacterActionSchema = z.object({
|
|
90
|
+
type: z.literal("upsert-character"),
|
|
91
|
+
character: characterSchema,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const updateOutlineBeatActionSchema = z.object({
|
|
95
|
+
type: z.literal("update-outline-beat"),
|
|
96
|
+
beat: outlineBeatSchema,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export const codexActionSchema = z.discriminatedUnion("type", [
|
|
100
|
+
replaceSelectionActionSchema,
|
|
101
|
+
insertAtCursorActionSchema,
|
|
102
|
+
replaceChapterActionSchema,
|
|
103
|
+
updateStoryFieldsActionSchema,
|
|
104
|
+
upsertCharacterActionSchema,
|
|
105
|
+
updateOutlineBeatActionSchema,
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
export const codexChatResponseSchema = z.object({
|
|
109
|
+
message: z.string(),
|
|
110
|
+
actions: z.array(codexActionSchema).optional(),
|
|
111
|
+
skillIdsUsed: z.array(z.string()).optional(),
|
|
112
|
+
changedPaths: z.array(z.string()).optional(),
|
|
113
|
+
project: z.custom<CowriterProject>().optional(),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
export function createStarterProject(path: string, title = "Untitled Book"): CowriterProject {
|
|
117
|
+
const now = new Date().toISOString();
|
|
118
|
+
const cleanTitle = title.trim();
|
|
119
|
+
const metadataTitle = cleanTitle || "Untitled Book";
|
|
120
|
+
return {
|
|
121
|
+
path,
|
|
122
|
+
metadata: { title: metadataTitle, author: "Local Writer", createdAt: now },
|
|
123
|
+
activeChapterId: "001-opening",
|
|
124
|
+
chapters: [
|
|
125
|
+
{
|
|
126
|
+
id: "001-opening",
|
|
127
|
+
title: "Opening",
|
|
128
|
+
path: "chapters/001-opening.md",
|
|
129
|
+
markdown: "",
|
|
130
|
+
updatedAt: now,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
story: {
|
|
134
|
+
title: cleanTitle,
|
|
135
|
+
synopsis: "",
|
|
136
|
+
setting: "",
|
|
137
|
+
themes: "",
|
|
138
|
+
genre: "",
|
|
139
|
+
tone: "",
|
|
140
|
+
perspective: "",
|
|
141
|
+
continuity: "",
|
|
142
|
+
},
|
|
143
|
+
characters: [
|
|
144
|
+
{
|
|
145
|
+
id: "primary-character",
|
|
146
|
+
name: "",
|
|
147
|
+
role: "",
|
|
148
|
+
desire: "",
|
|
149
|
+
conflict: "",
|
|
150
|
+
notes: "",
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
outline: [
|
|
154
|
+
{
|
|
155
|
+
id: "opening-beat",
|
|
156
|
+
title: "",
|
|
157
|
+
summary: "",
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
cover: null,
|
|
161
|
+
reports: [],
|
|
162
|
+
git: { dirty: false, changedPaths: [] },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function encodeSidecars(project: CowriterProject) {
|
|
167
|
+
return {
|
|
168
|
+
"story.yaml": YAML.stringify(project.story),
|
|
169
|
+
"outline.yaml": YAML.stringify({ beats: project.outline }),
|
|
170
|
+
".cowriter/project.yaml": YAML.stringify(project.metadata),
|
|
171
|
+
characters: Object.fromEntries(
|
|
172
|
+
project.characters.map((character) => [`characters/${character.id}.yaml`, YAML.stringify(character)]),
|
|
173
|
+
),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function validateProject(project: CowriterProject) {
|
|
178
|
+
storySchema.parse(project.story);
|
|
179
|
+
project.characters.forEach((character) => characterSchema.parse(character));
|
|
180
|
+
project.outline.forEach((beat) => outlineBeatSchema.parse(beat));
|
|
181
|
+
if (project.cover) coverSchema.parse(project.cover);
|
|
182
|
+
project.reports.forEach((report) => reportSchema.parse(report));
|
|
183
|
+
return project;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function validateCodexChatResponse(response: CodexChatResponse) {
|
|
187
|
+
const parsed = codexChatResponseSchema.parse(response);
|
|
188
|
+
if (parsed.project) validateProject(parsed.project);
|
|
189
|
+
return parsed;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function isBookEmpty(project: Pick<CowriterProject, "chapters" | "story">) {
|
|
193
|
+
const hasManuscript = project.chapters.some((chapter) => {
|
|
194
|
+
const text = plainTextFromMarkdown(chapter.markdown);
|
|
195
|
+
return text.length > 0 && text !== starterChapterText && text !== legacyStarterChapterText;
|
|
196
|
+
});
|
|
197
|
+
const synopsis = project.story.synopsis.trim();
|
|
198
|
+
const hasSynopsis = synopsis.length > 0 && synopsis !== starterSynopsis && synopsis !== legacyStarterSynopsis;
|
|
199
|
+
return !hasManuscript && !hasSynopsis;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function nextWritingTaskPrompt(project: Pick<CowriterProject, "chapters" | "story">) {
|
|
203
|
+
if (isBookEmpty(project)) {
|
|
204
|
+
return "";
|
|
205
|
+
}
|
|
206
|
+
const synopsis = project.story.synopsis.trim();
|
|
207
|
+
if (!synopsis || synopsis === starterSynopsis || synopsis === legacyStarterSynopsis) {
|
|
208
|
+
return "Next writing task: write a rough synopsis so the manuscript has a clear dramatic promise.";
|
|
209
|
+
}
|
|
210
|
+
const hasManuscript = project.chapters.some((chapter) => {
|
|
211
|
+
const text = plainTextFromMarkdown(chapter.markdown);
|
|
212
|
+
return text.length > 0 && text !== starterChapterText && text !== legacyStarterChapterText;
|
|
213
|
+
});
|
|
214
|
+
if (!hasManuscript) return "Next writing task: draft the opening page from the synopsis.";
|
|
215
|
+
return "Next writing task: choose the next passage, scene beat, or story-bible note to develop.";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function looksLikeSynopsis(message: string) {
|
|
219
|
+
return message.trim().split(/\s+/).filter(Boolean).length >= 18;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function cowriterApi<T>(body?: unknown): Promise<T | null> {
|
|
223
|
+
if (typeof window === "undefined") return null;
|
|
224
|
+
try {
|
|
225
|
+
const response = await fetch("/api/cowriter", body ? {
|
|
226
|
+
method: "POST",
|
|
227
|
+
headers: { "content-type": "application/json" },
|
|
228
|
+
body: JSON.stringify(body),
|
|
229
|
+
} : { cache: "no-store" });
|
|
230
|
+
if (!response.ok) return null;
|
|
231
|
+
const json = await response.json();
|
|
232
|
+
if (json?.error) return null;
|
|
233
|
+
return json as T;
|
|
234
|
+
} catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function readActiveProject() {
|
|
240
|
+
const response = await cowriterApi<{ activeProject: CowriterProject | null }>();
|
|
241
|
+
if (response?.activeProject) {
|
|
242
|
+
return validateProject(response.activeProject);
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function readReport(path: string): Promise<CowriterReportContent> {
|
|
248
|
+
const response = await cowriterApi<{ report: CowriterReportContent }>({ command: "read-report", path });
|
|
249
|
+
if (!response?.report) throw new Error("Cowriter report API unavailable.");
|
|
250
|
+
return reportContentSchema.parse(response.report);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function requireProject(response: { project?: CowriterProject } | null) {
|
|
254
|
+
if (!response?.project) throw new Error("Cowriter project API unavailable.");
|
|
255
|
+
return validateProject(response.project);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function writeChapter(project: CowriterProject, chapter: Chapter) {
|
|
259
|
+
const response = await cowriterApi<{ project: CowriterProject }>({ command: "write-chapter", chapter });
|
|
260
|
+
return requireProject(response);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function writeStory(project: CowriterProject, story: StoryBible) {
|
|
264
|
+
const response = await cowriterApi<{ project: CowriterProject }>({ command: "write-story", story });
|
|
265
|
+
return requireProject(response);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function writeCharacters(project: CowriterProject, characters: CharacterRecord[]) {
|
|
269
|
+
const response = await cowriterApi<{ project: CowriterProject }>({ command: "write-characters", characters });
|
|
270
|
+
return requireProject(response);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function writeOutline(project: CowriterProject, outline: OutlineBeat[]) {
|
|
274
|
+
const response = await cowriterApi<{ project: CowriterProject }>({ command: "write-outline", outline });
|
|
275
|
+
return requireProject(response);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function sendCodexChat(request: CodexChatRequest, projectPath?: string): Promise<CodexChatResponse> {
|
|
279
|
+
const lowerMessage = request.message.toLowerCase();
|
|
280
|
+
const selected = request.context.selectedText?.trim();
|
|
281
|
+
const actions: CodexAction[] = [];
|
|
282
|
+
const contextProject = {
|
|
283
|
+
chapters: request.context.activeChapter ? [request.context.activeChapter] : [],
|
|
284
|
+
story: request.context.story,
|
|
285
|
+
};
|
|
286
|
+
const emptyBook = isBookEmpty(contextProject);
|
|
287
|
+
const nextProject = {
|
|
288
|
+
...contextProject,
|
|
289
|
+
story: { ...contextProject.story, synopsis: request.message.trim() },
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
if (selected && /\b(rewrite|tighten|polish|dialogue|tone|expand)\b/.test(lowerMessage)) {
|
|
293
|
+
actions.push({
|
|
294
|
+
type: "replace-selection",
|
|
295
|
+
markdown: `${selected}\n\nThe passage can be revised here once the Cowriter skill is connected through Codex.app.`,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (emptyBook && looksLikeSynopsis(request.message)) {
|
|
300
|
+
actions.push({
|
|
301
|
+
type: "update-story-fields",
|
|
302
|
+
fields: { synopsis: request.message.trim() },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const target = selected
|
|
307
|
+
? "the selected text"
|
|
308
|
+
: request.context.activeStoryTab === "characters"
|
|
309
|
+
? "the character bible"
|
|
310
|
+
: request.context.activeStoryTab === "outline"
|
|
311
|
+
? "the outline"
|
|
312
|
+
: request.context.activeStoryTab === "story"
|
|
313
|
+
? "the story bible"
|
|
314
|
+
: "the current page";
|
|
315
|
+
|
|
316
|
+
return validateCodexChatResponse({
|
|
317
|
+
message: `${emptyBook && looksLikeSynopsis(request.message)
|
|
318
|
+
? "This reads like a workable synopsis. Ask Codex to save it to story.yaml in the project folder."
|
|
319
|
+
: emptyBook
|
|
320
|
+
? "This book is still empty. Share a one-paragraph synopsis first, even if it is rough."
|
|
321
|
+
: `Cowriter packaged your request for ${target}. In Codex.app, the Cowriter skill can inspect the local project and edit manuscript or story-bible files directly.`} ${nextWritingTaskPrompt(emptyBook && looksLikeSynopsis(request.message) ? nextProject : contextProject)}`,
|
|
322
|
+
actions,
|
|
323
|
+
skillIdsUsed: emptyBook && looksLikeSynopsis(request.message) ? ["synopsis-expansion"] : selected ? ["rewrite-selection"] : ["continuity-review"],
|
|
324
|
+
changedPaths: emptyBook && looksLikeSynopsis(request.message) ? ["story.yaml"] : [],
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function plainTextFromMarkdown(markdown: string) {
|
|
329
|
+
return markdown
|
|
330
|
+
.replace(/^#{1,6}\s+/gm, "")
|
|
331
|
+
.replace(/[*_`>#-]/g, "")
|
|
332
|
+
.replace(/\[(.*?)\]\(.*?\)/g, "$1")
|
|
333
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
334
|
+
.trim();
|
|
335
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import skills from "./writing-skills.json";
|
|
2
|
+
|
|
3
|
+
export type WritingSkill = {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
target: "selection" | "story-bible" | "chat";
|
|
7
|
+
description: string;
|
|
8
|
+
instruction: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const writingSkills = skills as WritingSkill[];
|
|
12
|
+
|
|
13
|
+
export function skillById(id: string) {
|
|
14
|
+
return writingSkills.find((skill) => skill.id === id) ?? writingSkills[0];
|
|
15
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
export type Chapter = {
|
|
2
|
+
id: string;
|
|
3
|
+
title: string;
|
|
4
|
+
path: string;
|
|
5
|
+
markdown: string;
|
|
6
|
+
updatedAt: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type CharacterRecord = {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
role: string;
|
|
13
|
+
desire: string;
|
|
14
|
+
conflict: string;
|
|
15
|
+
notes: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type StoryBible = {
|
|
19
|
+
title: string;
|
|
20
|
+
synopsis: string;
|
|
21
|
+
setting: string;
|
|
22
|
+
themes: string;
|
|
23
|
+
genre: string;
|
|
24
|
+
tone: string;
|
|
25
|
+
perspective: string;
|
|
26
|
+
continuity: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type OutlineBeat = {
|
|
30
|
+
id: string;
|
|
31
|
+
title: string;
|
|
32
|
+
summary: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type GitStatus = {
|
|
36
|
+
dirty: boolean;
|
|
37
|
+
changedPaths: string[];
|
|
38
|
+
lastCommit?: string;
|
|
39
|
+
lastCommitAt?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type CowriterCover = {
|
|
43
|
+
path: string;
|
|
44
|
+
contentType: string;
|
|
45
|
+
updatedAt: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type CowriterReport = {
|
|
49
|
+
id: string;
|
|
50
|
+
path: string;
|
|
51
|
+
type: string;
|
|
52
|
+
title: string;
|
|
53
|
+
status: string;
|
|
54
|
+
createdAt: string;
|
|
55
|
+
chapter?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type CowriterReportContent = {
|
|
59
|
+
path: string;
|
|
60
|
+
markdown: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type CowriterProject = {
|
|
64
|
+
path: string;
|
|
65
|
+
metadata: {
|
|
66
|
+
title: string;
|
|
67
|
+
author: string;
|
|
68
|
+
createdAt: string;
|
|
69
|
+
};
|
|
70
|
+
activeChapterId: string;
|
|
71
|
+
chapters: Chapter[];
|
|
72
|
+
story: StoryBible;
|
|
73
|
+
characters: CharacterRecord[];
|
|
74
|
+
outline: OutlineBeat[];
|
|
75
|
+
cover: CowriterCover | null;
|
|
76
|
+
reports: CowriterReport[];
|
|
77
|
+
git: GitStatus;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type ChatMessage = { role: "writer" | "codex"; content: string };
|
|
81
|
+
|
|
82
|
+
export type CodexChatContext = {
|
|
83
|
+
activeChapter?: Chapter;
|
|
84
|
+
activePage?: string;
|
|
85
|
+
selectedText?: string;
|
|
86
|
+
activeStoryTab?: "story" | "characters" | "outline" | "reports";
|
|
87
|
+
story: StoryBible;
|
|
88
|
+
characters: CharacterRecord[];
|
|
89
|
+
outline: OutlineBeat[];
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type ReplaceSelectionAction = {
|
|
93
|
+
type: "replace-selection";
|
|
94
|
+
markdown: string;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export type InsertAtCursorAction = {
|
|
98
|
+
type: "insert-at-cursor";
|
|
99
|
+
markdown: string;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export type ReplaceChapterAction = {
|
|
103
|
+
type: "replace-chapter";
|
|
104
|
+
chapterId: string;
|
|
105
|
+
markdown: string;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export type UpdateStoryFieldsAction = {
|
|
109
|
+
type: "update-story-fields";
|
|
110
|
+
fields: Partial<StoryBible>;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export type UpsertCharacterAction = {
|
|
114
|
+
type: "upsert-character";
|
|
115
|
+
character: CharacterRecord;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export type UpdateOutlineBeatAction = {
|
|
119
|
+
type: "update-outline-beat";
|
|
120
|
+
beat: OutlineBeat;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export type CodexAction =
|
|
124
|
+
| ReplaceSelectionAction
|
|
125
|
+
| InsertAtCursorAction
|
|
126
|
+
| ReplaceChapterAction
|
|
127
|
+
| UpdateStoryFieldsAction
|
|
128
|
+
| UpsertCharacterAction
|
|
129
|
+
| UpdateOutlineBeatAction;
|
|
130
|
+
|
|
131
|
+
export type CodexChatRequest = {
|
|
132
|
+
message: string;
|
|
133
|
+
history: ChatMessage[];
|
|
134
|
+
context: CodexChatContext;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export type CodexChatResponse = {
|
|
138
|
+
message: string;
|
|
139
|
+
actions?: CodexAction[];
|
|
140
|
+
skillIdsUsed?: string[];
|
|
141
|
+
changedPaths?: string[];
|
|
142
|
+
project?: CowriterProject;
|
|
143
|
+
};
|