plotlink-ows 1.0.32 → 1.2.94

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 (145) hide show
  1. package/README.md +4 -0
  2. package/app/lib/agent-command.ts +85 -0
  3. package/app/lib/agent-readiness.ts +133 -0
  4. package/app/lib/apply-schema.ts +55 -0
  5. package/app/lib/bubble-text.ts +160 -0
  6. package/app/lib/cartoon-coach.ts +198 -0
  7. package/app/lib/cartoon-markdown.ts +83 -0
  8. package/app/lib/cartoon-prompt.ts +122 -0
  9. package/app/lib/cartoon-readiness.ts +811 -0
  10. package/app/lib/clean-image-sync.ts +245 -0
  11. package/app/lib/codex-images.ts +152 -0
  12. package/app/lib/cut-asset-diagnostics.ts +120 -0
  13. package/app/lib/cuts.ts +302 -0
  14. package/app/lib/fonts.ts +109 -0
  15. package/app/lib/generate-claude-md.ts +10 -3
  16. package/app/lib/generate-story-instructions.ts +731 -0
  17. package/app/lib/image-asset-validate.ts +123 -0
  18. package/app/lib/lettering-status.ts +133 -0
  19. package/app/lib/overlays.ts +637 -0
  20. package/app/lib/paths.ts +10 -0
  21. package/app/lib/public-title.ts +65 -0
  22. package/app/lib/publish.ts +16 -2
  23. package/app/lib/story-progress.ts +243 -0
  24. package/app/lib/terminal-protocol.ts +16 -0
  25. package/app/lib/terminal-redact.ts +50 -0
  26. package/app/prisma/schema.sql +25 -0
  27. package/app/routes/agent.ts +42 -0
  28. package/app/routes/codex-images.ts +67 -0
  29. package/app/routes/publish.ts +209 -28
  30. package/app/routes/stories.ts +961 -5
  31. package/app/routes/terminal.ts +383 -31
  32. package/app/server.ts +47 -12
  33. package/app/vite.config.ts +6 -0
  34. package/app/web/components/CartoonPreview.tsx +267 -0
  35. package/app/web/components/CartoonPublishPage.tsx +407 -0
  36. package/app/web/components/CartoonPublishPreview.tsx +121 -0
  37. package/app/web/components/CartoonStepGuide.tsx +90 -0
  38. package/app/web/components/CartoonWorkflowNav.tsx +68 -0
  39. package/app/web/components/CodexImportPicker.tsx +230 -0
  40. package/app/web/components/CutListPanel.tsx +1299 -0
  41. package/app/web/components/EpisodesPage.tsx +80 -0
  42. package/app/web/components/FinishEpisodePanel.tsx +151 -0
  43. package/app/web/components/Layout.tsx +7 -4
  44. package/app/web/components/LetteringEditor.tsx +1141 -0
  45. package/app/web/components/PreviewPanel.tsx +1017 -144
  46. package/app/web/components/Settings.tsx +63 -0
  47. package/app/web/components/StoriesPage.tsx +710 -33
  48. package/app/web/components/StoryBrowser.tsx +22 -14
  49. package/app/web/components/StoryInfoPage.tsx +266 -0
  50. package/app/web/components/StoryProgressPanel.tsx +516 -0
  51. package/app/web/components/TerminalPanel.tsx +233 -11
  52. package/app/web/components/WorkflowCoach.tsx +128 -0
  53. package/app/web/components/asset-image.tsx +114 -0
  54. package/app/web/components/asset-test-utils.ts +44 -0
  55. package/app/web/components/export-cut.ts +320 -0
  56. package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
  57. package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
  58. package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
  59. package/app/web/dist/index.html +2 -2
  60. package/app/web/lib/cartoon-publish-summary.ts +43 -0
  61. package/app/web/lib/codex-import.ts +94 -0
  62. package/app/web/lib/image-compress.ts +53 -0
  63. package/app/web/lib/import-image.ts +58 -0
  64. package/app/web/lib/publish-helpers.ts +385 -0
  65. package/app/web/lib/upload-retry.ts +130 -0
  66. package/app/web/lib/verify-public-title.ts +105 -0
  67. package/app/web/styles.css +9 -0
  68. package/bin/plotlink-ows.js +53 -16
  69. package/bin/startup-plan.cjs +58 -0
  70. package/lib/genres.ts +92 -0
  71. package/package.json +60 -20
  72. package/scripts/gen-schema-sql.mjs +49 -0
  73. package/scripts/package-hygiene.mjs +116 -0
  74. package/scripts/preflight.mjs +173 -0
  75. package/scripts/start-smoke.mjs +128 -0
  76. package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
  77. package/app/node_modules/.prisma/local-client/client.js +0 -5
  78. package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
  79. package/app/node_modules/.prisma/local-client/default.js +0 -5
  80. package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
  81. package/app/node_modules/.prisma/local-client/edge.js +0 -184
  82. package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
  83. package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
  84. package/app/node_modules/.prisma/local-client/index.js +0 -207
  85. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  86. package/app/node_modules/.prisma/local-client/package.json +0 -183
  87. package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
  88. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  89. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
  90. package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
  91. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
  92. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
  93. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
  94. package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
  95. package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
  96. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
  97. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
  98. package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
  99. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
  100. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
  101. package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
  102. package/app/node_modules/.prisma/local-client/wasm.js +0 -191
  103. package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
  104. package/app/web/dist/assets/index-BFw-v-OZ.js +0 -134
  105. package/packages/cli/node_modules/commander/LICENSE +0 -22
  106. package/packages/cli/node_modules/commander/Readme.md +0 -1149
  107. package/packages/cli/node_modules/commander/esm.mjs +0 -16
  108. package/packages/cli/node_modules/commander/index.js +0 -24
  109. package/packages/cli/node_modules/commander/lib/argument.js +0 -149
  110. package/packages/cli/node_modules/commander/lib/command.js +0 -2662
  111. package/packages/cli/node_modules/commander/lib/error.js +0 -39
  112. package/packages/cli/node_modules/commander/lib/help.js +0 -709
  113. package/packages/cli/node_modules/commander/lib/option.js +0 -367
  114. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
  115. package/packages/cli/node_modules/commander/package-support.json +0 -16
  116. package/packages/cli/node_modules/commander/package.json +0 -82
  117. package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
  118. package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
  119. package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
  120. package/packages/cli/node_modules/resolve-from/index.js +0 -47
  121. package/packages/cli/node_modules/resolve-from/license +0 -9
  122. package/packages/cli/node_modules/resolve-from/package.json +0 -36
  123. package/packages/cli/node_modules/resolve-from/readme.md +0 -72
  124. package/packages/cli/node_modules/tsup/LICENSE +0 -21
  125. package/packages/cli/node_modules/tsup/README.md +0 -75
  126. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
  127. package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
  128. package/packages/cli/node_modules/tsup/assets/package.json +0 -3
  129. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
  130. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
  131. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
  132. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
  133. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
  134. package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
  135. package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
  136. package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
  137. package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
  138. package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
  139. package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
  140. package/packages/cli/node_modules/tsup/package.json +0 -99
  141. package/packages/cli/node_modules/tsup/schema.json +0 -362
  142. package/public/screenshot-1.png +0 -0
  143. package/public/screenshot-2.png +0 -0
  144. package/public/screenshot-3.png +0 -0
  145. package/scripts/e2e-verify.ts +0 -1100
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Parse the public title PlotLink renders into a story/plot page's metadata
3
+ * (#379). There is no public JSON read endpoint (`/api/storyline/<id>` 404s), so
4
+ * the reliable source for the INDEXED public title is the rendered page's
5
+ * `<meta property="og:title">` (mirrored by `<title> … — PlotLink`):
6
+ *
7
+ * /story/<id> → og:title "<storylineTitle>" (e.g. "genesis")
8
+ * /story/<id>/<plotIdx> → og:title "<plotTitle> — <storylineTitle>" (e.g. "plot-01 — genesis")
9
+ *
10
+ * These helpers are pure so they can be unit-tested against the real page shape;
11
+ * the OWS server does the page fetch (no CORS) in the publish route.
12
+ */
13
+
14
+ // PlotLink joins title segments with a spaced em dash, and suffixes <title> with
15
+ // the site name.
16
+ const TITLE_SEP = " — ";
17
+ const SITE_SUFFIX = /\s*—\s*PlotLink\s*$/i;
18
+
19
+ function decodeEntities(s: string): string {
20
+ return s
21
+ .replace(/&amp;/g, "&")
22
+ .replace(/&lt;/g, "<")
23
+ .replace(/&gt;/g, ">")
24
+ .replace(/&#0?39;/g, "'")
25
+ .replace(/&#x27;/gi, "'")
26
+ .replace(/&quot;/g, '"');
27
+ }
28
+
29
+ /**
30
+ * The page's public title text: `og:title` when present, else `<title>` with the
31
+ * " — PlotLink" site suffix stripped. Returns null when neither is present.
32
+ */
33
+ export function extractOgTitle(html: string): string | null {
34
+ const og =
35
+ html.match(/<meta[^>]+property=["']og:title["'][^>]*\scontent=["']([^"']*)["']/i) ||
36
+ html.match(/<meta[^>]+\scontent=["']([^"']*)["'][^>]*property=["']og:title["']/i);
37
+ const ogVal = og?.[1]?.trim();
38
+ if (ogVal) return decodeEntities(ogVal);
39
+
40
+ const t = html.match(/<title>([^<]*)<\/title>/i);
41
+ const tVal = t?.[1] ? decodeEntities(t[1].trim()).replace(SITE_SUFFIX, "").trim() : "";
42
+ return tVal || null;
43
+ }
44
+
45
+ /**
46
+ * The plot-title portion of a plot page title, where og:title is
47
+ * "<plotTitle> — <storylineTitle>". Strip only the FINAL storyline segment,
48
+ * not the first separator, because a real episode title may itself contain
49
+ * " — " (e.g. "Episode 1 — The Couple Coupon — Coupon Crush"). Returns the
50
+ * whole value when there is no separator. Null for empty/missing input.
51
+ */
52
+ export function leadingTitleSegment(title: string | null, storylineTitle?: string | null): string | null {
53
+ if (!title) return null;
54
+ const story = storylineTitle?.trim();
55
+ if (story) {
56
+ const suffix = `${TITLE_SEP}${story}`;
57
+ if (title.endsWith(suffix)) {
58
+ const seg = title.slice(0, -suffix.length).trim();
59
+ if (seg) return seg;
60
+ }
61
+ }
62
+ const idx = title.lastIndexOf(TITLE_SEP);
63
+ const seg = (idx === -1 ? title : title.slice(0, idx)).trim();
64
+ return seg || null;
65
+ }
@@ -9,6 +9,7 @@ import {
9
9
  signTransaction as owsSignTx,
10
10
  signMessage as owsSignMsg,
11
11
  } from "@open-wallet-standard/core";
12
+ import { canonicalizeGenre } from "../../lib/genres";
12
13
 
13
14
  // Contract addresses (Base mainnet)
14
15
  const STORY_FACTORY = "0x9D2AE1E99D0A6300bfcCF41A82260374e38744Cf" as const;
@@ -295,6 +296,7 @@ export async function publishStoryline(
295
296
  onProgress: (progress: PublishProgress) => void,
296
297
  language?: string,
297
298
  isNsfw?: boolean,
299
+ contentType?: string,
298
300
  ): Promise<PublishResult> {
299
301
  // Normalize optional fields to backwards-compatible defaults
300
302
  const normalizedLanguage = language || "English";
@@ -340,7 +342,7 @@ export async function publishStoryline(
340
342
  // Streams "Indexing…" progress so the user does not escalate to Retry Publish.
341
343
  const indexError = await indexWithDelayAndRetry(
342
344
  "storyline",
343
- { txHash, content, genre, language: normalizedLanguage, isNsfw: normalizedIsNsfw },
345
+ { txHash, content, genre, language: normalizedLanguage, isNsfw: normalizedIsNsfw, ...(contentType ? { contentType } : {}) },
344
346
  onProgress,
345
347
  txHash,
346
348
  contentCid,
@@ -510,6 +512,18 @@ export async function updateStoryline(
510
512
  const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
511
513
  const account = createOwsAccount(walletName, walletAddress);
512
514
 
515
+ // Defense-in-depth (#412): even if a caller bypassed the route's canonicalization,
516
+ // map the genre to a canonical PlotLink value before signing — a non-empty genre
517
+ // that can't be mapped is rejected here rather than leaving the story uncategorized.
518
+ const normalizedUpdates = { ...updates };
519
+ if (updates.genre !== undefined) {
520
+ const canonical = canonicalizeGenre(updates.genre);
521
+ if (updates.genre.trim() && !canonical) {
522
+ throw new Error(`Invalid genre "${updates.genre}". Use a canonical PlotLink genre.`);
523
+ }
524
+ normalizedUpdates.genre = canonical ?? undefined;
525
+ }
526
+
513
527
  const timestamp = Date.now();
514
528
  const message = `PlotLink: Update storyline #${storylineId}\nTimestamp: ${timestamp}`;
515
529
  const signature = await account.signMessage({ message });
@@ -521,7 +535,7 @@ export async function updateStoryline(
521
535
  storylineId,
522
536
  signature,
523
537
  message,
524
- ...updates,
538
+ ...normalizedUpdates,
525
539
  }),
526
540
  });
527
541
 
@@ -0,0 +1,243 @@
1
+ // Story-level production progress model (#418).
2
+ //
3
+ // After creating a cartoon story the writer is dropped into files + terminal
4
+ // output with no product-level view of what's done and what's next. This builds
5
+ // a single workflow map — story metadata, setup, cover, and per-episode state —
6
+ // from already-available data (story meta, per-episode markdown + cuts, cover
7
+ // detection), reusing the cartoon readiness helpers so the overview agrees with
8
+ // the per-file publish UI. Pure + framework-free so it's unit-testable; the
9
+ // route reads the files and the panel just renders the result.
10
+
11
+ import type { Cut } from "./cuts";
12
+ import type { CartoonCoach } from "./cartoon-coach";
13
+ import {
14
+ classifyCartoonReadiness,
15
+ summarizeCutProgress,
16
+ cartoonChecklist,
17
+ type CartoonChecklistStep,
18
+ } from "./cartoon-readiness";
19
+
20
+ export type EpisodeState =
21
+ | "placeholder" // cartoon: no cuts planned yet (a future-episode stub)
22
+ | "planning" // cartoon: cut plan set, publish layout not built
23
+ | "in-progress" // cartoon: building images / awaiting uploads
24
+ | "ready" // ready to publish
25
+ | "blocked" // needs fixes
26
+ | "draft" // fiction: written, not published
27
+ | "published";
28
+
29
+ export interface EpisodeProgress {
30
+ /** File this episode maps to, e.g. "genesis.md" | "plot-01.md". */
31
+ file: string;
32
+ /** Reader-facing label: "Episode 1 / Genesis", "Episode 2", "Chapter 1". */
33
+ label: string;
34
+ kind: "genesis" | "plot";
35
+ title: string | null;
36
+ state: EpisodeState;
37
+ /** One concise line — no raw validator text. */
38
+ summary: string;
39
+ published: boolean;
40
+ /**
41
+ * Cartoon cut progress; null for fiction. `needClean`/`withText` count IMAGE
42
+ * cuts only (text panels are excluded), so the workflow coach (#429) can tell
43
+ * the clean-image stage from the lettering stage.
44
+ */
45
+ cuts: { total: number; needClean: number; withClean: number; withText: number; exported: number; uploaded: number } | null;
46
+ /**
47
+ * Per-step production checklist (plan → clean → letter → export → upload →
48
+ * publish) for the cartoon workflow map (#438), reusing the same `cartoonChecklist`
49
+ * the per-file workflow guide uses so the progress page and the file view agree.
50
+ * Null for fiction; an empty array for a not-started cartoon episode (no cuts
51
+ * planned yet), which the map renders as a "not started" stub.
52
+ */
53
+ checklist: CartoonChecklistStep[] | null;
54
+ }
55
+
56
+ export interface StoryProgress {
57
+ name: string;
58
+ contentType: "fiction" | "cartoon";
59
+ metadata: {
60
+ title: string | null;
61
+ language: string | null;
62
+ genre: string | null;
63
+ isNsfw: boolean | null;
64
+ contentType: "fiction" | "cartoon";
65
+ };
66
+ setup: { hasStructure: boolean; hasGenesis: boolean };
67
+ /** Cover state (meaningful for cartoon; fiction may ignore). */
68
+ cover: "missing" | "present" | "invalid";
69
+ episodes: EpisodeProgress[];
70
+ summary: {
71
+ episodes: number;
72
+ published: number;
73
+ readyToPublish: number;
74
+ placeholders: number;
75
+ blocked: number;
76
+ };
77
+ /** Single product-level next step in plain language, or null if all done. */
78
+ nextAction: string | null;
79
+ /** A copy-paste prompt the writer can hand to the agent for the next step
80
+ * (#423), or null when the next step is a UI action (cover/publish) not an
81
+ * agent task. */
82
+ nextPrompt: string | null;
83
+ /**
84
+ * Persistent workflow coach (#429): the single next action derived from the
85
+ * current state, typed as an agent prompt or an in-app UI action. Attached by
86
+ * the route (it needs the focused file + on-disk asset hints); null for
87
+ * fiction. Absent when not computed (e.g. the pure builder), so existing
88
+ * consumers reading only nextAction/nextPrompt are unaffected.
89
+ */
90
+ coach?: CartoonCoach | null;
91
+ }
92
+
93
+ export interface EpisodeInput {
94
+ /** "genesis.md" | "plot-01.md". */
95
+ file: string;
96
+ status: "published" | "published-not-indexed" | "pending" | "draft";
97
+ /** Publish-facing markdown content. */
98
+ markdown: string;
99
+ /** Parsed cuts (cartoon); null when there's no cuts.json (fiction or none). */
100
+ cuts: Cut[] | null;
101
+ /** Episode title from cuts.json, if any. */
102
+ title: string | null;
103
+ }
104
+
105
+ export interface StoryProgressInput {
106
+ name: string;
107
+ contentType: "fiction" | "cartoon";
108
+ title: string | null;
109
+ language?: string | null;
110
+ genre?: string | null;
111
+ isNsfw?: boolean | null;
112
+ hasStructure: boolean;
113
+ hasGenesis: boolean;
114
+ cover: "missing" | "present" | "invalid";
115
+ /** Ordered: genesis first, then plot-01, plot-02, … */
116
+ episodes: EpisodeInput[];
117
+ }
118
+
119
+ function isPublished(status: EpisodeInput["status"]): boolean {
120
+ return status === "published" || status === "published-not-indexed";
121
+ }
122
+
123
+ /** "Episode 2" for plot-01 (genesis is Episode 1), "Chapter 1" for fiction. */
124
+ function episodeLabel(file: string, kind: "genesis" | "plot", contentType: "fiction" | "cartoon"): string {
125
+ if (kind === "genesis") return contentType === "cartoon" ? "Episode 1 / Genesis" : "Genesis";
126
+ const n = parseInt(file.match(/^plot-(\d+)\.md$/)?.[1] ?? "0", 10);
127
+ return contentType === "cartoon" ? `Episode ${n + 1}` : `Chapter ${n}`;
128
+ }
129
+
130
+ function cartoonEpisode(ep: EpisodeInput, contentType: "fiction" | "cartoon"): EpisodeProgress {
131
+ const kind = ep.file === "genesis.md" ? "genesis" : "plot";
132
+ const label = episodeLabel(ep.file, kind, contentType);
133
+ const cuts = ep.cuts ?? [];
134
+ const p = summarizeCutProgress(cuts);
135
+ const published = isPublished(ep.status);
136
+ const checklist = cartoonChecklist({ cuts, published }).steps;
137
+ const base = { file: ep.file, label, kind, title: ep.title, published, checklist,
138
+ cuts: { total: p.total, needClean: p.needClean, withClean: p.withClean, withText: p.withText, exported: p.exported, uploaded: p.uploaded } } as const;
139
+
140
+ if (published) return { ...base, state: "published", summary: "Published to PlotLink" };
141
+
142
+ const stage = classifyCartoonReadiness(ep.markdown, cuts).stage;
143
+ switch (stage) {
144
+ case "not-started":
145
+ return { ...base, state: "placeholder", summary: "Not started — no cuts planned yet" };
146
+ case "planning":
147
+ return { ...base, state: "planning", summary: `Cut plan set (${p.total} cut${p.total === 1 ? "" : "s"}) — prepare for publish` };
148
+ case "awaiting-upload":
149
+ return { ...base, state: "in-progress", summary: `${p.uploaded} / ${p.total} cuts have uploaded images` };
150
+ case "ready":
151
+ return { ...base, state: "ready", summary: "Ready to publish" };
152
+ case "error":
153
+ default:
154
+ return { ...base, state: "blocked", summary: "Needs fixes before publishing" };
155
+ }
156
+ }
157
+
158
+ function fictionEpisode(ep: EpisodeInput): EpisodeProgress {
159
+ const kind = ep.file === "genesis.md" ? "genesis" : "plot";
160
+ const label = episodeLabel(ep.file, kind, "fiction");
161
+ const published = isPublished(ep.status);
162
+ return {
163
+ file: ep.file, label, kind, title: ep.title, published, cuts: null, checklist: null,
164
+ state: published ? "published" : "draft",
165
+ summary: published ? "Published to PlotLink" : "Drafted — ready to review and publish",
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Build the story-level progress map. Cartoon episodes reuse the readiness
171
+ * classifier so a placeholder plot reads as "placeholder", never publish-ready;
172
+ * fiction gets a simpler written/published view. `nextAction` is the single
173
+ * plain-language step the writer should take next.
174
+ */
175
+ export function buildStoryProgress(input: StoryProgressInput): StoryProgress {
176
+ const cartoon = input.contentType === "cartoon";
177
+ const episodes = input.episodes.map((ep) => (cartoon ? cartoonEpisode(ep, "cartoon") : fictionEpisode(ep)));
178
+
179
+ const published = episodes.filter((e) => e.published).length;
180
+ const readyToPublish = episodes.filter((e) => e.state === "ready").length;
181
+ const placeholders = episodes.filter((e) => e.state === "placeholder").length;
182
+ const blocked = episodes.filter((e) => e.state === "blocked").length;
183
+
184
+ let nextAction: string | null;
185
+ // A paste-ready agent prompt for the agent-driven stages; null for UI-only
186
+ // steps (cover/publish). Worded for the writer to copy verbatim (#423).
187
+ let nextPrompt: string | null = null;
188
+ if (!input.hasStructure) {
189
+ nextAction = "Ask the agent to write the story bible (structure.md).";
190
+ nextPrompt = cartoon
191
+ ? "Let's start this cartoon. Write the story bible (structure.md) — visual style, character bible, and episode format — then the Genesis (Episode 1) opening. Don't generate images, letter, upload, or publish yet."
192
+ : "Let's start this story. Write the structure (outline, characters, arc), then the Genesis hook.";
193
+ } else if (!input.hasGenesis) {
194
+ nextAction = cartoon
195
+ ? "Ask the agent to write the Genesis (Episode 1) opening."
196
+ : "Ask the agent to write the Genesis (story hook).";
197
+ nextPrompt = cartoon
198
+ ? "Write the Genesis (Episode 1) opening for this cartoon, then plan its cuts in genesis.cuts.json. Don't generate images yet."
199
+ : "Write the Genesis (story hook) for this story.";
200
+ } else {
201
+ const ready = episodes.find((e) => !e.published && e.state === "ready");
202
+ const working = episodes.find((e) => !e.published && (e.state === "planning" || e.state === "in-progress"));
203
+ const draft = episodes.find((e) => !e.published && e.state === "draft");
204
+ const placeholder = episodes.find((e) => !e.published && e.state === "placeholder");
205
+ // #462: a missing cover is a publish-readiness recommendation, not the
206
+ // primary step. It leads only once the active episode's production is
207
+ // complete (the `ready` case, or nothing pending) — never while an episode is
208
+ // mid-production. So episode production leads over a missing cover.
209
+ const coverMissing = cartoon && input.cover === "missing";
210
+ if (ready) nextAction = coverMissing ? "Create or import a cover image for the story." : `Publish ${ready.label}.`;
211
+ else if (working) nextAction = cartoon
212
+ ? `Continue ${working.label}: ${working.summary.toLowerCase()}.`
213
+ : `Review and publish ${working.label}.`;
214
+ else if (draft) nextAction = `Review and publish ${draft.label}.`;
215
+ else if (placeholder) {
216
+ nextAction = `Plan the cuts for ${placeholder.label} to start it.`;
217
+ nextPrompt = `Plan the cuts for ${placeholder.label} in its cuts.json. Don't generate images, letter, upload, or publish yet.`;
218
+ } else if (coverMissing) nextAction = "Create or import a cover image for the story.";
219
+ else if (episodes.length > 0 && published === episodes.length) nextAction = null; // all published
220
+ else {
221
+ nextAction = cartoon ? "Plan the next episode's cuts." : "Write the next chapter.";
222
+ nextPrompt = cartoon ? "Plan the cuts for the next episode in a new cuts.json. Don't generate images yet." : "Write the next chapter.";
223
+ }
224
+ }
225
+
226
+ return {
227
+ name: input.name,
228
+ contentType: input.contentType,
229
+ metadata: {
230
+ title: input.title,
231
+ language: input.language ?? null,
232
+ genre: input.genre ?? null,
233
+ isNsfw: input.isNsfw ?? null,
234
+ contentType: input.contentType,
235
+ },
236
+ setup: { hasStructure: input.hasStructure, hasGenesis: input.hasGenesis },
237
+ cover: input.cover,
238
+ episodes,
239
+ summary: { episodes: episodes.length, published, readyToPublish, placeholders, blocked },
240
+ nextAction,
241
+ nextPrompt,
242
+ };
243
+ }
@@ -0,0 +1,16 @@
1
+ // Shared control signals for the terminal WebSocket relay (#453).
2
+ //
3
+ // The relay normally carries raw PTY bytes both ways. When the server has to
4
+ // SPAWN A FRESH agent process for a story (the previous PTY exited / the server
5
+ // restarted) and the user asked to resume, that process reprints its own startup
6
+ // banner and conversation. The client, meanwhile, has already restored the prior
7
+ // session's scrollback from IndexedDB — so the banner would appear twice.
8
+ //
9
+ // To avoid that, the server sends FRESH_SPAWN_SIGNAL as the FIRST frame on a
10
+ // fresh spawn. The client treats only the first frame of a connection as a
11
+ // possible control signal: on FRESH_SPAWN_SIGNAL it drops the restored scrollback
12
+ // (so just the fresh reprint shows); a live-PTY RECONNECT sends no signal, so the
13
+ // client keeps its scrollback (the only copy of the prior output). It is a plain
14
+ // ASCII sentinel (no control bytes) that a real PTY never emits as a standalone
15
+ // first frame.
16
+ export const FRESH_SPAWN_SIGNAL = "__OWS_FRESH_SESSION__";
@@ -0,0 +1,50 @@
1
+ // Display/log-safety redaction for the story terminal (#454).
2
+ //
3
+ // The central terminal relays raw agent/PTY output. If an agent (or a command a
4
+ // writer runs) prints auth material — an Authorization/Bearer header, a session
5
+ // token, or an OWS passphrase / login command — it would otherwise be shown in
6
+ // plain text and persisted to the scrollback. This masks the obvious shapes on
7
+ // the way to the terminal so a secret isn't rendered or stored.
8
+ //
9
+ // It is best-effort DISPLAY hardening only: it never changes what the server
10
+ // sends, the wallet, PlotLink auth, or any request. It replaces only the secret
11
+ // VALUE and keeps the surrounding key/word, so the line still reads sensibly
12
+ // (e.g. `Authorization: Bearer [REDACTED]`). A token split across two streamed
13
+ // frames may slip through — this reduces accidental exposure, it is not a
14
+ // guarantee, which is why the agent guidance also tells agents not to print
15
+ // secrets into the terminal.
16
+
17
+ export const REDACTION_PLACEHOLDER = "[REDACTED]";
18
+
19
+ // Each rule keeps capture group 1 (the key/prefix) and masks the value after it.
20
+ // Value classes deliberately exclude whitespace, quotes, and `&` so a redaction
21
+ // stops at the end of the token and never eats following text or ANSI escapes.
22
+ // The value classes exclude whitespace, quotes, `&`, AND the ESC byte (\x1b) so a
23
+ // redaction stops at the end of the token and never swallows a trailing ANSI
24
+ // escape sequence (which would corrupt terminal colors/cursor state).
25
+ const RULES: ReadonlyArray<readonly [RegExp, string]> = [
26
+ // `Authorization: Bearer <token>` (HTTP header form).
27
+ [/(authorization\s*:\s*bearer\s+)[^\s'"\x1b]+/gi, `$1${REDACTION_PLACEHOLDER}`],
28
+ // A standalone `Bearer <token>` — min length so the word "Bearer" in prose
29
+ // isn't masked.
30
+ [/(\bbearer\s+)[A-Za-z0-9._-]{12,}/gi, `$1${REDACTION_PLACEHOLDER}`],
31
+ // `token=<value>` / `?token=<value>` (e.g. the WS/login token in a URL).
32
+ [/(\btoken=)[^\s'"&\x1b]+/gi, `$1${REDACTION_PLACEHOLDER}`],
33
+ // The OWS passphrase env/var: `OWS_PASSPHRASE=<value>` or `OWS_PASSPHRASE: <value>`.
34
+ [/(OWS_PASSPHRASE\s*[=:]\s*)[^\s'"\x1b]+/gi, `$1${REDACTION_PLACEHOLDER}`],
35
+ // A `--passphrase <value>` / `--passphrase=<value>` login command fragment.
36
+ [/(--passphrase[=\s]+)[^\s'"\x1b]+/gi, `$1${REDACTION_PLACEHOLDER}`],
37
+ // A generic `passphrase: "<value>"` / `"passphrase":"<value>"` (quoted or not).
38
+ [/(passphrase["']?\s*[:=]\s*["']?)[^\s'"\x1b]+/gi, `$1${REDACTION_PLACEHOLDER}`],
39
+ ];
40
+
41
+ /**
42
+ * Mask obvious auth secrets in a chunk of terminal output. Pure; returns the
43
+ * input unchanged when nothing matches (the common case), so normal terminal
44
+ * rendering and ANSI control sequences are untouched.
45
+ */
46
+ export function redactTerminalSecrets(text: string): string {
47
+ let out = text;
48
+ for (const [re, repl] of RULES) out = out.replace(re, repl);
49
+ return out;
50
+ }
@@ -0,0 +1,25 @@
1
+ -- Canonical SQLite DDL for the local writer database.
2
+ -- GENERATED from app/prisma/schema.prisma — do not edit by hand.
3
+ -- Regenerate after any schema change: npm run prisma:sql
4
+ --
5
+ -- Applied idempotently at startup via the Prisma client's library query engine
6
+ -- (app/lib/apply-schema.ts) so the installed package never invokes the native
7
+ -- Prisma schema-engine (`prisma db push`), which fails to spawn in some packed
8
+ -- prod-only environments (#484, EPIC #465).
9
+
10
+ -- CreateTable
11
+ CREATE TABLE "Session" (
12
+ "id" TEXT NOT NULL PRIMARY KEY,
13
+ "token" TEXT NOT NULL,
14
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
15
+ "expiresAt" DATETIME NOT NULL
16
+ );
17
+
18
+ -- CreateTable
19
+ CREATE TABLE "Setting" (
20
+ "key" TEXT NOT NULL PRIMARY KEY,
21
+ "value" TEXT NOT NULL
22
+ );
23
+
24
+ -- CreateIndex
25
+ CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
@@ -0,0 +1,42 @@
1
+ import { Hono } from "hono";
2
+ import { execFile } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ import { probeAgentReadiness } from "../lib/agent-readiness";
5
+
6
+ const execFileP = promisify(execFile);
7
+
8
+ const agent = new Hono();
9
+
10
+ /** GET /api/agent/readiness — probe local agent CLIs (detection only) */
11
+ agent.get("/readiness", async (c) => {
12
+ try {
13
+ // Probe through a login shell so PATH matches the terminal's binary
14
+ // resolution (terminal.ts spawns `process.env.SHELL -l -c <cmd>`).
15
+ const shell = process.env.SHELL || "/bin/zsh";
16
+ const run = async (cmd: string) => {
17
+ try {
18
+ const { stdout } = await execFileP(shell, ["-l", "-c", cmd], {
19
+ timeout: 5000,
20
+ });
21
+ return { ok: true, stdout: stdout ?? "" };
22
+ } catch (e: unknown) {
23
+ const stdout =
24
+ e && typeof e === "object" && "stdout" in e
25
+ ? String((e as { stdout: unknown }).stdout ?? "")
26
+ : "";
27
+ return { ok: false, stdout };
28
+ }
29
+ };
30
+
31
+ const readiness = {
32
+ ...(await probeAgentReadiness(run)),
33
+ checkedAt: Date.now(),
34
+ };
35
+ return c.json(readiness);
36
+ } catch (error) {
37
+ console.error("Agent readiness error:", error);
38
+ return c.json({ error: "Failed to probe agent readiness" }, 500);
39
+ }
40
+ });
41
+
42
+ export { agent as agentRoutes };
@@ -0,0 +1,67 @@
1
+ import { Hono } from "hono";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { CODEX_IMAGES_DIR } from "../lib/paths";
5
+ import { sniffImageType } from "../lib/clean-image-sync";
6
+ import {
7
+ listCodexImages,
8
+ resolveCodexImagePath,
9
+ CODEX_MAX_RAW_BYTES,
10
+ } from "../lib/codex-images";
11
+
12
+ /**
13
+ * Codex generated-image cache handoff (#403). Read-only, authenticated routes
14
+ * that let the OWS UI surface the Codex image cache so a writer can import a
15
+ * generated PNG into a cut in one click — instead of hunting through a hidden
16
+ * `~/.codex/generated_images/…` folder in an OS file dialog. The browser does the
17
+ * PNG→WebP conversion and posts to the existing per-cut upload-clean route, so
18
+ * the manual upload path and its validation are unchanged.
19
+ */
20
+ const codexImages = new Hono();
21
+
22
+ const SNIFF_MIME: Record<string, string> = {
23
+ png: "image/png",
24
+ jpeg: "image/jpeg",
25
+ webp: "image/webp",
26
+ };
27
+
28
+ // GET /api/codex/images — recent Codex-generated cache images, newest first.
29
+ // A missing cache directory simply lists empty (no error).
30
+ codexImages.get("/images", (c) => {
31
+ return c.json({ images: listCodexImages(CODEX_IMAGES_DIR) });
32
+ });
33
+
34
+ // GET /api/codex/images/:token — raw bytes of one cache image (for the import
35
+ // thumbnail and the import fetch). Path-safe, image-only, size-capped.
36
+ codexImages.get("/images/:token", (c) => {
37
+ const resolved = resolveCodexImagePath(CODEX_IMAGES_DIR, c.req.param("token"));
38
+ if (!resolved) return c.json({ error: "Invalid image reference" }, 400);
39
+
40
+ try {
41
+ // Defense in depth against a symlinked cache entry escaping the root:
42
+ // resolveCodexImagePath only does logical path math, so re-check the
43
+ // boundary on the realpath (which follows symlinks) before reading.
44
+ const rootReal = fs.realpathSync(path.resolve(CODEX_IMAGES_DIR));
45
+ const fileReal = fs.realpathSync(resolved.abs);
46
+ if (fileReal !== rootReal && !fileReal.startsWith(rootReal + path.sep)) {
47
+ return c.json({ error: "Invalid image reference" }, 400);
48
+ }
49
+
50
+ const st = fs.statSync(fileReal);
51
+ if (!st.isFile()) return c.json({ error: "Not found" }, 404);
52
+ if (st.size > CODEX_MAX_RAW_BYTES) return c.json({ error: "Image too large" }, 413);
53
+
54
+ const buf = fs.readFileSync(fileReal);
55
+ const kind = sniffImageType(new Uint8Array(buf));
56
+ if (kind === "unknown") return c.json({ error: "Not an image" }, 415);
57
+
58
+ c.header("Content-Type", SNIFF_MIME[kind]);
59
+ c.header("Cache-Control", "no-store");
60
+ return c.body(new Uint8Array(buf));
61
+ } catch {
62
+ // Missing file, broken symlink, or unreadable entry — treat as not found.
63
+ return c.json({ error: "Not found" }, 404);
64
+ }
65
+ });
66
+
67
+ export { codexImages as codexImagesRoutes };