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.
- package/README.md +4 -0
- package/app/lib/agent-command.ts +85 -0
- package/app/lib/agent-readiness.ts +133 -0
- package/app/lib/apply-schema.ts +55 -0
- package/app/lib/bubble-text.ts +160 -0
- package/app/lib/cartoon-coach.ts +198 -0
- package/app/lib/cartoon-markdown.ts +83 -0
- package/app/lib/cartoon-prompt.ts +122 -0
- package/app/lib/cartoon-readiness.ts +811 -0
- package/app/lib/clean-image-sync.ts +245 -0
- package/app/lib/codex-images.ts +152 -0
- package/app/lib/cut-asset-diagnostics.ts +120 -0
- package/app/lib/cuts.ts +302 -0
- package/app/lib/fonts.ts +109 -0
- package/app/lib/generate-claude-md.ts +10 -3
- package/app/lib/generate-story-instructions.ts +731 -0
- package/app/lib/image-asset-validate.ts +123 -0
- package/app/lib/lettering-status.ts +133 -0
- package/app/lib/overlays.ts +637 -0
- package/app/lib/paths.ts +10 -0
- package/app/lib/public-title.ts +65 -0
- package/app/lib/publish.ts +16 -2
- package/app/lib/story-progress.ts +243 -0
- package/app/lib/terminal-protocol.ts +16 -0
- package/app/lib/terminal-redact.ts +50 -0
- package/app/prisma/schema.sql +25 -0
- package/app/routes/agent.ts +42 -0
- package/app/routes/codex-images.ts +67 -0
- package/app/routes/publish.ts +209 -28
- package/app/routes/stories.ts +961 -5
- package/app/routes/terminal.ts +383 -31
- package/app/server.ts +47 -12
- package/app/vite.config.ts +6 -0
- package/app/web/components/CartoonPreview.tsx +267 -0
- package/app/web/components/CartoonPublishPage.tsx +407 -0
- package/app/web/components/CartoonPublishPreview.tsx +121 -0
- package/app/web/components/CartoonStepGuide.tsx +90 -0
- package/app/web/components/CartoonWorkflowNav.tsx +68 -0
- package/app/web/components/CodexImportPicker.tsx +230 -0
- package/app/web/components/CutListPanel.tsx +1299 -0
- package/app/web/components/EpisodesPage.tsx +80 -0
- package/app/web/components/FinishEpisodePanel.tsx +151 -0
- package/app/web/components/Layout.tsx +7 -4
- package/app/web/components/LetteringEditor.tsx +1141 -0
- package/app/web/components/PreviewPanel.tsx +1017 -144
- package/app/web/components/Settings.tsx +63 -0
- package/app/web/components/StoriesPage.tsx +710 -33
- package/app/web/components/StoryBrowser.tsx +22 -14
- package/app/web/components/StoryInfoPage.tsx +266 -0
- package/app/web/components/StoryProgressPanel.tsx +516 -0
- package/app/web/components/TerminalPanel.tsx +233 -11
- package/app/web/components/WorkflowCoach.tsx +128 -0
- package/app/web/components/asset-image.tsx +114 -0
- package/app/web/components/asset-test-utils.ts +44 -0
- package/app/web/components/export-cut.ts +320 -0
- package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
- package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
- package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
- package/app/web/dist/index.html +2 -2
- package/app/web/lib/cartoon-publish-summary.ts +43 -0
- package/app/web/lib/codex-import.ts +94 -0
- package/app/web/lib/image-compress.ts +53 -0
- package/app/web/lib/import-image.ts +58 -0
- package/app/web/lib/publish-helpers.ts +385 -0
- package/app/web/lib/upload-retry.ts +130 -0
- package/app/web/lib/verify-public-title.ts +105 -0
- package/app/web/styles.css +9 -0
- package/bin/plotlink-ows.js +53 -16
- package/bin/startup-plan.cjs +58 -0
- package/lib/genres.ts +92 -0
- package/package.json +60 -20
- package/scripts/gen-schema-sql.mjs +49 -0
- package/scripts/package-hygiene.mjs +116 -0
- package/scripts/preflight.mjs +173 -0
- package/scripts/start-smoke.mjs +128 -0
- package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/client.js +0 -5
- package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/default.js +0 -5
- package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/edge.js +0 -184
- package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
- package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
- package/app/node_modules/.prisma/local-client/index.js +0 -207
- package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
- package/app/node_modules/.prisma/local-client/package.json +0 -183
- package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
- package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
- package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
- package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
- package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
- package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
- package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
- package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
- package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
- package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
- package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/wasm.js +0 -191
- package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
- package/app/web/dist/assets/index-BFw-v-OZ.js +0 -134
- package/packages/cli/node_modules/commander/LICENSE +0 -22
- package/packages/cli/node_modules/commander/Readme.md +0 -1149
- package/packages/cli/node_modules/commander/esm.mjs +0 -16
- package/packages/cli/node_modules/commander/index.js +0 -24
- package/packages/cli/node_modules/commander/lib/argument.js +0 -149
- package/packages/cli/node_modules/commander/lib/command.js +0 -2662
- package/packages/cli/node_modules/commander/lib/error.js +0 -39
- package/packages/cli/node_modules/commander/lib/help.js +0 -709
- package/packages/cli/node_modules/commander/lib/option.js +0 -367
- package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
- package/packages/cli/node_modules/commander/package-support.json +0 -16
- package/packages/cli/node_modules/commander/package.json +0 -82
- package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
- package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
- package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
- package/packages/cli/node_modules/resolve-from/index.js +0 -47
- package/packages/cli/node_modules/resolve-from/license +0 -9
- package/packages/cli/node_modules/resolve-from/package.json +0 -36
- package/packages/cli/node_modules/resolve-from/readme.md +0 -72
- package/packages/cli/node_modules/tsup/LICENSE +0 -21
- package/packages/cli/node_modules/tsup/README.md +0 -75
- package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
- package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
- package/packages/cli/node_modules/tsup/assets/package.json +0 -3
- package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
- package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
- package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
- package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
- package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
- package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
- package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
- package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
- package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
- package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
- package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
- package/packages/cli/node_modules/tsup/package.json +0 -99
- package/packages/cli/node_modules/tsup/schema.json +0 -362
- package/public/screenshot-1.png +0 -0
- package/public/screenshot-2.png +0 -0
- package/public/screenshot-3.png +0 -0
- 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(/&/g, "&")
|
|
22
|
+
.replace(/</g, "<")
|
|
23
|
+
.replace(/>/g, ">")
|
|
24
|
+
.replace(/�?39;/g, "'")
|
|
25
|
+
.replace(/'/gi, "'")
|
|
26
|
+
.replace(/"/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
|
+
}
|
package/app/lib/publish.ts
CHANGED
|
@@ -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
|
-
...
|
|
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 };
|