plotlink-ows 1.0.26 → 1.0.30

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.
@@ -0,0 +1,139 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { CONFIG_DIR } from "./paths";
4
+
5
+ const CLAUDE_MD_PATH = path.join(CONFIG_DIR, "CLAUDE.md");
6
+
7
+ /** Read the installed package version from package.json */
8
+ function getVersion(): string {
9
+ const pkgPath = path.join(__dirname, "..", "..", "package.json");
10
+ try {
11
+ return JSON.parse(fs.readFileSync(pkgPath, "utf-8")).version;
12
+ } catch {
13
+ return "unknown";
14
+ }
15
+ }
16
+
17
+ function generateContent(version: string, port: number): string {
18
+ return `# PlotLink OWS — Local Writer API (v${version})
19
+
20
+ > Auto-generated by PlotLink OWS on startup. Do not edit manually.
21
+
22
+ Local writer app running at \`http://localhost:${port}\`.
23
+ All endpoints except auth use \`Authorization: Bearer {token}\` headers.
24
+
25
+ ## Authentication
26
+
27
+ The OWS passphrase is stored in \`~/.plotlink-ows/.env\` as \`OWS_PASSPHRASE\`.
28
+ For login, the passphrase is hashed with HMAC-SHA256 and compared against the stored hash.
29
+
30
+ | Endpoint | Method | Auth | Purpose |
31
+ |----------|--------|------|---------|
32
+ | \`/api/auth/status\` | GET | No | Check if passphrase is configured |
33
+ | \`/api/auth/setup\` | POST | No | First-run passphrase setup (≥4 chars) → returns \`{ token }\` |
34
+ | \`/api/auth/login\` | POST | No | Login with passphrase → returns \`{ token }\` (24h TTL) |
35
+ | \`/api/auth/verify\` | GET | Bearer | Check token validity |
36
+ | \`/api/auth/reset-passphrase\` | POST | Bearer | Update passphrase |
37
+
38
+ ## Publishing
39
+
40
+ | Endpoint | Method | Purpose |
41
+ |----------|--------|---------|
42
+ | \`/api/publish/preflight\` | GET | Check wallet balance, Filebase config |
43
+ | \`/api/publish/file\` | POST | Publish story on-chain (SSE stream of progress events) |
44
+ | \`/api/publish/retry-index\` | POST | Retry indexing for a published file |
45
+ | \`/api/publish/upload-cover\` | POST | Upload cover image — FormData \`file\` field, **WebP or JPEG only**, max 500KB → returns \`{ cid }\` |
46
+ | \`/api/publish/upload-plot-image\` | POST | Upload plot illustration — FormData \`file\` field, **WebP or JPEG only**, max 500KB → returns \`{ cid, url }\` |
47
+ | \`/api/publish/update-storyline\` | POST | Update storyline metadata (coverCid, genre, language, isNsfw) |
48
+
49
+ **Publish flow:** Upload to IPFS → estimate gas → sign with OWS wallet → broadcast → confirm → index on plotlink.xyz (8s delay + 10 retries × 30s). Genesis files call \`createStoryline\`, plot files (\`plot-*.md\`) call \`chainPlot\`. Content limit: 10K chars.
50
+
51
+ **Cover update workflow:**
52
+ 1. \`POST /api/publish/upload-cover\` with image file → get \`cid\`
53
+ 2. \`POST /api/publish/update-storyline\` with \`{ storylineId, coverCid: cid }\` → updates on plotlink.xyz
54
+
55
+ **Metadata update workflow:**
56
+ 1. \`POST /api/publish/update-storyline\` with \`{ storylineId, genre?, language?, isNsfw? }\`
57
+
58
+ Both upload-cover and update-storyline sign messages with the OWS wallet.
59
+
60
+ ## Stories
61
+
62
+ | Endpoint | Method | Purpose |
63
+ |----------|--------|---------|
64
+ | \`/api/stories\` | GET | List all stories |
65
+ | \`/api/stories/archived\` | GET | List archived stories |
66
+ | \`/api/stories/archive\` | POST | Archive a story \`{ name }\` |
67
+ | \`/api/stories/restore\` | POST | Restore archived story \`{ name }\` |
68
+ | \`/api/stories/:name\` | GET | Story detail with file contents |
69
+ | \`/api/stories/:name/:file\` | GET | Single file content and publish status |
70
+ | \`/api/stories/:name/:file\` | PUT | Update file content \`{ content }\` |
71
+ | \`/api/stories/:name/:file/publish-status\` | POST | Record publish result (txHash, storylineId, etc.) |
72
+ | \`/api/stories/:name/:file/mark-not-indexed\` | POST | Mark file as not indexed \`{ indexError? }\` |
73
+
74
+ ## Terminal
75
+
76
+ | Endpoint | Method | Purpose |
77
+ |----------|--------|---------|
78
+ | \`/api/terminal/spawn\` | POST | Spawn Claude CLI session for a story \`{ storyName?, resume? }\` |
79
+ | \`/api/terminal/session/:storyName\` | GET | Get stored session ID for a story |
80
+ | \`/api/terminal/status\` | GET | List all active terminal sessions |
81
+ | \`/api/terminal/rename\` | POST | Rename session \`{ oldName, newName }\` |
82
+ | \`/api/terminal/stop\` | POST | Kill default PTY (legacy) |
83
+ | \`/api/terminal/:storyName\` | DELETE | Kill a story's PTY |
84
+ | \`/api/terminal/:storyName/discard\` | DELETE | Kill PTY and clean metadata |
85
+ | \`/ws/terminal\` | WebSocket | Live PTY relay \`?token={token}&story={name}&resume={bool}\` |
86
+
87
+ ## Other Endpoints
88
+
89
+ | Endpoint | Method | Purpose |
90
+ |----------|--------|---------|
91
+ | \`/api/wallet\` | GET | Wallet info and balances (ETH, USDC, PLOT) |
92
+ | \`/api/wallet/create\` | POST | Create OWS wallet if not exists |
93
+ | \`/api/dashboard\` | GET | Writer dashboard stats (stories, costs, royalties) |
94
+ | \`/api/settings/register-agent\` | POST | Register wallet on ERC-8004 |
95
+ | \`/api/settings/generate-binding\` | POST | Generate wallet binding proof |
96
+ | \`/api/settings/link-status\` | GET | Check ERC-8004 registration status |
97
+ | \`/api/health\` | GET | Health check |
98
+
99
+ ## Story File Structure
100
+
101
+ Stories live in \`~/.plotlink-ows/stories/{story-name}/\`:
102
+
103
+ \`\`\`
104
+ stories/{story-name}/
105
+ structure.md # Outline, characters, arc
106
+ genesis.md # Synopsis hook (~1000 chars)
107
+ plot-01.md # Chapter 1 (max 10K chars)
108
+ ...
109
+ \`\`\`
110
+
111
+ ## Supported Genres (21)
112
+
113
+ Romance, Fantasy, Science Fiction, Mystery, Thriller, Horror, Adventure, Historical Fiction, Contemporary Lit, Humor, Poetry, Non-Fiction, Fanfiction, Short Story, Paranormal, Werewolf, LGBTQ+, New Adult, Teen Fiction, Diverse Lit, Others
114
+
115
+ ## Supported Languages (11)
116
+
117
+ English, Chinese, Korean, Japanese, Spanish, French, Hindi, Arabic, Portuguese, Russian, Others
118
+ `;
119
+ }
120
+
121
+ /**
122
+ * Write/update ~/.plotlink-ows/CLAUDE.md on startup.
123
+ * Skips the write if the file already contains the current version stamp.
124
+ */
125
+ export function generateClaudeMd(): void {
126
+ const version = getVersion();
127
+ const port = Number(process.env.APP_PORT) || 7777;
128
+
129
+ // Check if the file already matches the current version
130
+ if (fs.existsSync(CLAUDE_MD_PATH)) {
131
+ try {
132
+ const firstLine = fs.readFileSync(CLAUDE_MD_PATH, "utf-8").split("\n")[0];
133
+ if (firstLine.includes(`(v${version})`)) return;
134
+ } catch { /* regenerate on read error */ }
135
+ }
136
+
137
+ fs.writeFileSync(CLAUDE_MD_PATH, generateContent(version, port), "utf-8");
138
+ console.log(` Updated ~/.plotlink-ows/CLAUDE.md (v${version})`);
139
+ }
@@ -461,6 +461,41 @@ export async function uploadCoverImage(
461
461
  return data.cid;
462
462
  }
463
463
 
464
+ /**
465
+ * Upload a plot illustration image to PlotLink via signed API call.
466
+ * Returns the IPFS CID and URL of the uploaded image.
467
+ */
468
+ export async function uploadPlotImage(
469
+ walletName: string,
470
+ walletAddress: `0x${string}`,
471
+ imageFile: File,
472
+ ): Promise<{ cid: string; url: string }> {
473
+ const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
474
+ const account = createOwsAccount(walletName, walletAddress);
475
+
476
+ const timestamp = Date.now();
477
+ const message = `PlotLink: Upload plot image\nTimestamp: ${timestamp}`;
478
+ const signature = await account.signMessage({ message });
479
+
480
+ const formData = new FormData();
481
+ formData.append("file", imageFile);
482
+ formData.append("message", message);
483
+ formData.append("signature", signature);
484
+
485
+ const res = await fetch(`${PLOTLINK_URL}/api/upload-plot-image`, {
486
+ method: "POST",
487
+ body: formData,
488
+ });
489
+
490
+ if (!res.ok) {
491
+ const err = await res.json().catch(() => ({})) as Record<string, string>;
492
+ throw new Error(err.error || `Plot image upload failed: HTTP ${res.status}`);
493
+ }
494
+
495
+ const data = await res.json() as { cid: string; url: string };
496
+ return data;
497
+ }
498
+
464
499
  /**
465
500
  * Update storyline metadata (cover, genre, language, NSFW) on PlotLink via signed API call.
466
501
  * Uses createOwsAccount for signing (not raw owsSignMsg).
@@ -1,6 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import { streamSSE } from "hono/streaming";
3
- import { publishStoryline, publishPlot, getEthBalance, estimatePublishCost, uploadCoverImage, updateStoryline } from "../lib/publish";
3
+ import { publishStoryline, publishPlot, getEthBalance, estimatePublishCost, uploadCoverImage, uploadPlotImage, updateStoryline } from "../lib/publish";
4
4
  import { keccak256, toBytes } from "viem";
5
5
  import { listAgentWallets, getBaseAddress } from "../../lib/ows/wallet";
6
6
 
@@ -217,9 +217,10 @@ publish.post("/upload-cover", async (c) => {
217
217
  return c.json({ error: "Image exceeds 500KB limit" }, 400);
218
218
  }
219
219
 
220
- // Validate file type
221
- if (!file.type.startsWith("image/")) {
222
- return c.json({ error: "File must be an image (WebP or JPEG recommended)" }, 400);
220
+ // Validate file type — only WebP and JPEG accepted by the plotlink server
221
+ const allowedTypes = ["image/webp", "image/jpeg"];
222
+ if (!allowedTypes.includes(file.type)) {
223
+ return c.json({ error: "Only WebP and JPEG images are accepted" }, 400);
223
224
  }
224
225
 
225
226
  const cid = await uploadCoverImage(wallet.name, address as `0x${string}`, file);
@@ -230,6 +231,41 @@ publish.post("/upload-cover", async (c) => {
230
231
  }
231
232
  });
232
233
 
234
+ /** POST /api/publish/upload-plot-image — upload plot illustration with wallet signature */
235
+ publish.post("/upload-plot-image", async (c) => {
236
+ try {
237
+ const wallets = listAgentWallets();
238
+ const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
239
+ if (!wallet) return c.json({ error: "No OWS wallet" }, 400);
240
+
241
+ const address = getBaseAddress(wallet);
242
+ if (!address) return c.json({ error: "No EVM address on wallet" }, 400);
243
+
244
+ const formData = await c.req.formData();
245
+ const file = formData.get("file");
246
+ if (!file || !(file instanceof File)) {
247
+ return c.json({ error: "No image file provided" }, 400);
248
+ }
249
+
250
+ // Validate file size (500KB max)
251
+ if (file.size > 500 * 1024) {
252
+ return c.json({ error: "Image exceeds 500KB limit" }, 400);
253
+ }
254
+
255
+ // Validate file type — only WebP and JPEG accepted by the plotlink server
256
+ const allowedTypes = ["image/webp", "image/jpeg"];
257
+ if (!allowedTypes.includes(file.type)) {
258
+ return c.json({ error: "Only WebP and JPEG images are accepted" }, 400);
259
+ }
260
+
261
+ const result = await uploadPlotImage(wallet.name, address as `0x${string}`, file);
262
+ return c.json(result);
263
+ } catch (err: unknown) {
264
+ const message = err instanceof Error ? err.message : "Plot image upload failed";
265
+ return c.json({ error: message }, 500);
266
+ }
267
+ });
268
+
233
269
  /** POST /api/publish/update-storyline — update storyline metadata with wallet signature */
234
270
  publish.post("/update-storyline", async (c) => {
235
271
  try {
package/app/server.ts CHANGED
@@ -23,6 +23,7 @@ import { terminalRoutes, attachTerminalWs } from "./routes/terminal";
23
23
  import { storiesRoutes } from "./routes/stories";
24
24
  import { settingsRoutes } from "./routes/settings";
25
25
  import { initDb } from "./db";
26
+ import { generateClaudeMd } from "./lib/generate-claude-md";
26
27
  import { execSync } from "child_process";
27
28
  import fs from "fs";
28
29
 
@@ -117,6 +118,9 @@ async function start() {
117
118
  // Auto-migrate from old package-relative paths
118
119
  migrateOldData();
119
120
 
121
+ // Generate/update ~/.plotlink-ows/CLAUDE.md for agent discovery
122
+ generateClaudeMd();
123
+
120
124
  // Run Prisma db push to ensure schema is up to date
121
125
  const schemaPath = path.join(__dirname, "prisma", "schema.prisma");
122
126
  execSync(`npx prisma db push --schema ${schemaPath} --skip-generate`, {
@@ -56,6 +56,14 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
56
56
  const [editSuccess, setEditSuccess] = useState(false);
57
57
  const coverInputRef = useRef<HTMLInputElement>(null);
58
58
 
59
+ // Inline illustration state
60
+ const [showIllustrations, setShowIllustrations] = useState(false);
61
+ const [illustrationUploading, setIllustrationUploading] = useState(false);
62
+ const [illustrationError, setIllustrationError] = useState<string | null>(null);
63
+ const [uploadedImages, setUploadedImages] = useState<Array<{ cid: string; url: string }>>([]);
64
+ const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
65
+ const illustrationInputRef = useRef<HTMLInputElement>(null);
66
+
59
67
  const prevFileRef = useRef<string | null>(null);
60
68
 
61
69
  const loadFile = useCallback(async () => {
@@ -153,6 +161,45 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
153
161
  setEditError(null);
154
162
  }, []);
155
163
 
164
+ // Handle illustration image upload from File object
165
+ const uploadIllustration = useCallback(async (file: File) => {
166
+ if (file.size > 500 * 1024) {
167
+ setIllustrationError("Image exceeds 500KB limit");
168
+ return;
169
+ }
170
+ const allowedTypes = ["image/webp", "image/jpeg"];
171
+ if (!allowedTypes.includes(file.type)) {
172
+ setIllustrationError("Only WebP and JPEG images are accepted");
173
+ return;
174
+ }
175
+ setIllustrationUploading(true);
176
+ setIllustrationError(null);
177
+ try {
178
+ const formData = new FormData();
179
+ formData.append("file", file);
180
+ const res = await authFetch("/api/publish/upload-plot-image", {
181
+ method: "POST",
182
+ body: formData,
183
+ });
184
+ if (!res.ok) {
185
+ const err = await res.json();
186
+ throw new Error(err.error || "Upload failed");
187
+ }
188
+ const data = await res.json();
189
+ setUploadedImages((prev) => [...prev, { cid: data.cid, url: data.url }]);
190
+ } catch (err) {
191
+ setIllustrationError(err instanceof Error ? err.message : "Upload failed");
192
+ } finally {
193
+ setIllustrationUploading(false);
194
+ if (illustrationInputRef.current) illustrationInputRef.current.value = "";
195
+ }
196
+ }, [authFetch]);
197
+
198
+ const handleIllustrationInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
199
+ const file = e.target.files?.[0];
200
+ if (file) uploadIllustration(file);
201
+ }, [uploadIllustration]);
202
+
156
203
  // Save storyline edits (cover upload + metadata update)
157
204
  const handleEditSave = useCallback(async () => {
158
205
  if (!fileData?.storylineId) return;
@@ -215,6 +262,9 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
215
262
  setEditError(null);
216
263
  setEditSuccess(false);
217
264
  setEditMetaLoaded(false);
265
+ setShowIllustrations(false);
266
+ setUploadedImages([]);
267
+ setIllustrationError(null);
218
268
  }, [storyName, fileName]);
219
269
 
220
270
  // Fetch current storyline metadata when edit panel opens
@@ -407,6 +457,70 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
407
457
  {saving ? "Saving..." : "Save"}
408
458
  </button>
409
459
  </div>
460
+ {/* Inline illustration upload for plot files */}
461
+ {isPlot && (
462
+ <div className="px-3 py-1.5 border-t border-border">
463
+ <label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
464
+ <input
465
+ type="checkbox"
466
+ checked={showIllustrations}
467
+ onChange={(e) => setShowIllustrations(e.target.checked)}
468
+ className="rounded border-border"
469
+ />
470
+ Add illustrations in the plot
471
+ </label>
472
+ {showIllustrations && (
473
+ <div className="mt-2 flex flex-col gap-2">
474
+ <div
475
+ className="border-2 border-dashed border-border rounded p-3 flex flex-col items-center gap-1.5 cursor-pointer hover:border-accent transition-colors"
476
+ onClick={() => illustrationInputRef.current?.click()}
477
+ onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
478
+ onDrop={(e) => {
479
+ e.preventDefault();
480
+ e.stopPropagation();
481
+ const file = e.dataTransfer.files?.[0];
482
+ if (file) uploadIllustration(file);
483
+ }}
484
+ >
485
+ <input
486
+ ref={illustrationInputRef}
487
+ type="file"
488
+ accept="image/webp,image/jpeg"
489
+ onChange={handleIllustrationInput}
490
+ className="hidden"
491
+ />
492
+ <span className="text-xs text-muted">
493
+ {illustrationUploading ? "Uploading..." : "Drop image here or click to browse"}
494
+ </span>
495
+ <span className="text-xs text-muted">WebP/JPEG, max 500KB</span>
496
+ </div>
497
+ {illustrationError && (
498
+ <span className="text-error text-xs">{illustrationError}</span>
499
+ )}
500
+ {uploadedImages.map((img, i) => (
501
+ <div key={img.cid} className="border border-border rounded p-2 flex flex-col gap-1 bg-surface">
502
+ <span className="text-xs text-green-700">Image uploaded! Copy the markdown below and paste it where you want the illustration to appear in your plot:</span>
503
+ <div className="flex items-center gap-1.5">
504
+ <code className="flex-1 text-xs bg-background px-2 py-1 rounded font-mono break-all">
505
+ ![Scene description]({img.url})
506
+ </code>
507
+ <button
508
+ onClick={() => {
509
+ navigator.clipboard.writeText(`![Scene description](${img.url})`);
510
+ setCopiedIndex(i);
511
+ setTimeout(() => setCopiedIndex(null), 2000);
512
+ }}
513
+ className="px-2 py-1 text-xs border border-border rounded hover:bg-surface shrink-0"
514
+ >
515
+ {copiedIndex === i ? "Copied!" : "Copy"}
516
+ </button>
517
+ </div>
518
+ </div>
519
+ ))}
520
+ </div>
521
+ )}
522
+ </div>
523
+ )}
410
524
  </div>
411
525
  )}
412
526