plotlink-ows 0.1.18 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,28 +1,60 @@
1
+ <div align="center">
2
+
1
3
  # PlotLink OWS Writer
2
4
 
3
- [![npm version](https://img.shields.io/npm/v/plotlink-ows)](https://www.npmjs.com/package/plotlink-ows)
5
+ ### Anyone can become a fiction writer with just an idea.
4
6
 
5
- **Anyone can become a fiction writer with just an idea.**
7
+ <p>
8
+ <a href="https://plotlink.xyz"><strong>Live App</strong></a> ·
9
+ <a href="#-quick-start"><strong>Quick Start</strong></a> ·
10
+ <a href="#how-it-works"><strong>How it Works</strong></a> ·
11
+ <a href="https://docs.openwallet.sh/"><strong>OWS Docs</strong></a>
12
+ </p>
6
13
 
7
- ```bash
8
- npx plotlink-ows init # one-time setup (~2 minutes)
9
- npx plotlink-ows # start writing
10
- ```
14
+ <p>
15
+ <a href="https://plotlink.xyz"><img src="https://img.shields.io/badge/Live_App-plotlink.xyz-8B4513" alt="Live App" /></a>
16
+ <a href="https://www.npmjs.com/package/plotlink-ows"><img src="https://img.shields.io/npm/v/plotlink-ows" alt="npm version" /></a>
17
+ <a href="https://openwallet.sh"><img src="https://img.shields.io/badge/OWS-Open_Wallet_Standard-00d4aa" alt="OWS" /></a>
18
+ <a href="https://eips.ethereum.org/EIPS/eip-8004"><img src="https://img.shields.io/badge/ERC--8004-Base-3b82f6" alt="ERC-8004" /></a>
19
+ <img src="https://img.shields.io/badge/social-Farcaster-8b5cf6" alt="Farcaster" />
20
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" /></a>
21
+ </p>
22
+
23
+ <br/>
24
+
25
+ <div>
26
+ <video src="https://github.com/user-attachments/assets/467937eb-bb61-4e5c-a650-dbc44877b139" width="720" controls></video>
27
+ </div>
28
+
29
+ <br/>
30
+
31
+ </div>
32
+
33
+ ## What is PlotLink OWS Writer?
34
+
35
+ A local writing workspace that pairs you with an AI co-writer to create and publish tokenized fiction stories on [plotlink.xyz](https://plotlink.xyz). Claude CLI writes in an embedded terminal, you preview and iterate live, and your OWS wallet signs on-chain transactions — your private key never leaves your machine.
11
36
 
12
- PlotLink OWS Writer is a local writing workspace that turns your ideas into published, tokenized fiction stories on [plotlink.xyz](https://plotlink.xyz). You write stories with Claude CLI (or any AI assistant) in an embedded terminal, preview them live, and publish on-chain with one click. Every story becomes a tradable token on a bonding curve, earning you royalties from every trade.
37
+ Every story you publish becomes a tradable token on a bonding curve. Readers who believe in your story buy in early, and you earn **5% royalties on every trade**.
13
38
 
14
- No writing experience needed. No crypto complexity. Just an idea and a conversation with your AI co-writer.
39
+ ### Why it matters
15
40
 
16
- ## How It Works
41
+ - **No writing experience needed** — AI does the heavy lifting
42
+ - **No crypto complexity** — OWS handles wallet and signing
43
+ - **You keep control** — keys encrypted locally, bring your own AI
44
+ - **Earn from day one** — stories monetize through bonding curves immediately
45
+
46
+ ---
47
+
48
+ ## How it Works
17
49
 
18
50
  ```
19
- You: "Let's write a sci-fi story about an AI that discovers it can dream"
51
+ You: "Let's write a sci-fi story about an AI that discovers dreams"
20
52
 
21
- ↓ Claude CLI brainstorms, outlines, writes chapter files
53
+ ↓ Claude brainstorms, outlines, writes chapter files
22
54
 
23
55
  Stories saved to: stories/dreaming-ai/genesis.md, plot-01.md, ...
24
56
 
25
- ↓ Live preview in the browser — you review and iterate
57
+ ↓ Live preview in the browser — review and iterate
26
58
 
27
59
  ↓ Click "Publish" on any chapter
28
60
 
@@ -40,7 +72,51 @@ On-chain: Story published to PlotLink on Base
40
72
  5. **Publish** — Click publish on any chapter to go on-chain via your OWS wallet
41
73
  6. **Earn** — Your story is live on [plotlink.xyz](https://plotlink.xyz) with a bonding curve
42
74
 
43
- ## Architecture
75
+ ---
76
+
77
+ ## 🔐 Built on Open Wallet Standard (OWS)
78
+
79
+ All signing operations use **[OWS](https://github.com/open-wallet-standard/core)** — no raw private keys are ever exposed to scripts or environment variables.
80
+
81
+ | Operation | How |
82
+ |-----------|-----|
83
+ | Wallet creation | `npx plotlink-ows init` creates encrypted wallet in `~/.ows/` |
84
+ | Story publishing | viem wallet client with OWS custom account adapter |
85
+ | Transaction signing | OWS decrypts key in memory, signs, zeroes immediately |
86
+ | Policy control | Chain-restricted to Base, passphrase-gated |
87
+
88
+ Your key is **encrypted at rest**, signing happens **in-process**, and the key **never leaves the vault**.
89
+
90
+ ---
91
+
92
+ ## 🚀 Quick Start
93
+
94
+ ### Prerequisites
95
+
96
+ - Node.js 20+
97
+ - [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) (or any AI assistant)
98
+ - A small amount of ETH on Base for gas (~$0.01 per publish)
99
+
100
+ ### Install & Run
101
+
102
+ ```bash
103
+ npx plotlink-ows init # set passphrase + create wallet
104
+ npx plotlink-ows # start app + open browser
105
+ ```
106
+
107
+ The setup wizard creates your encrypted OWS wallet. Then the workspace opens with Claude CLI ready to write.
108
+
109
+ ### Commands
110
+
111
+ ```bash
112
+ npx plotlink-ows # Start app (Ctrl+C to stop)
113
+ npx plotlink-ows init # Guided setup wizard
114
+ npx plotlink-ows status # Show config + wallet info
115
+ ```
116
+
117
+ ---
118
+
119
+ ## 🏗️ Architecture
44
120
 
45
121
  ```
46
122
  ┌──────────────────────────────────────────────────┐
@@ -49,7 +125,6 @@ On-chain: Story published to PlotLink on Base
49
125
  │ ┌──────────┐ ┌──────────────┐ ┌───────────┐ │
50
126
  │ │ Story │ │ Terminal │ │ Preview │ │
51
127
  │ │ Browser │ │ (Claude CLI)│ │ (Live MD)│ │
52
- │ │ │ │ │ │ │ │
53
128
  │ └────┬─────┘ └──────┬───────┘ └─────┬─────┘ │
54
129
  │ │ │ │ │
55
130
  │ └───────┬───────┘ │ │
@@ -58,89 +133,114 @@ On-chain: Story published to PlotLink on Base
58
133
  │ │ stories/ │ │ OWS Wallet │ │
59
134
  │ │ (local files) │ │ (encrypted) │ │
60
135
  │ └────────────────┘ └────────┬────────┘ │
61
-
62
- │ sign tx + publish ────┘ │
63
- └─────────────────┬───────────────────────────────┘
64
-
65
- ┌────────────────┐ ┌─────────────────┐
66
- Base (L2) │ │ IPFS
67
- StoryFactory │ │ (Filebase)
68
- │ Bonding Curve │ │ Story content │
69
- └────────────────┘ └─────────────────┘
70
-
71
- ┌────────────────┐
72
- plotlink.xyz
73
- Live story +
74
- │ token trading │
75
- └────────────────┘
136
+ sign + publish
137
+ └─────────────────────┬───────────────────────────┘
138
+
139
+ ┌────────────────┐ ┌──────────────┐
140
+ │ Base (L2) │ │ IPFS │
141
+ StoryFactory │ │ (Filebase)
142
+ Bonding Curve │ │ Content
143
+ └────────────────┘ └──────────────┘
144
+
145
+ ┌────────────────┐
146
+ │ plotlink.xyz │
147
+ Live story +
148
+ token trading
149
+ └────────────────┘
76
150
  ```
77
151
 
78
- ## What is PlotLink?
152
+ ---
79
153
 
80
- [PlotLink](https://plotlink.xyz) is an on-chain storytelling protocol on Base. Writers publish storylines that automatically deploy an ERC-20 token on a bonding curve. Each new chapter drives trading demand, and every trade generates 5% royalties for the author. Stories are stored permanently on IPFS.
154
+ ## 📁 Story Structure
155
+
156
+ Stories are plain markdown files — no database, no proprietary format.
157
+
158
+ ```
159
+ stories/
160
+ my-story/
161
+ structure.md # Outline, characters, arc (not published)
162
+ genesis.md # Synopsis hook (~1,000 chars)
163
+ plot-01.md # Chapter 1 (max 10,000 chars)
164
+ plot-02.md # Chapter 2
165
+ ...
166
+ ```
167
+
168
+ | File | Purpose | Limit |
169
+ |------|---------|-------|
170
+ | `structure.md` | Story architecture — characters, world, arc | No limit (not published) |
171
+ | `genesis.md` | Synopsis hook that makes readers want more | ~1,000 chars |
172
+ | `plot-*.md` | Story chapters, published sequentially | 10,000 chars each |
81
173
 
82
- ## What is OWS?
174
+ ---
83
175
 
84
- [Open Wallet Standard](https://docs.openwallet.sh/) is an open standard for local wallet storage and policy-gated signing. Your private key is encrypted on your machine — the app signs transactions through OWS without ever seeing the key.
176
+ ## 💰 Cost
85
177
 
86
- ## Tech Stack
178
+ | Operation | Cost |
179
+ |-----------|------|
180
+ | Publishing a story (genesis) | ~$0.02 gas + creation fee |
181
+ | Chaining a new chapter | ~$0.01 gas |
182
+ | **Total per story** | **< $0.05** |
183
+
184
+ Royalties: **5% on every trade** of your story token, forever.
185
+
186
+ ---
187
+
188
+ ## 🛠️ Tech Stack
87
189
 
88
190
  | Layer | Technology |
89
191
  |-------|-----------|
90
192
  | **Backend** | Hono (localhost:7777) |
91
193
  | **Frontend** | React 19 + Vite |
92
194
  | **Terminal** | xterm.js + node-pty (embedded Claude CLI) |
93
- | **Database** | SQLite + Prisma (auth sessions) |
94
195
  | **Wallet** | OWS (`@open-wallet-standard/core`) |
95
- | **AI** | Claude CLI (or any AI assistant in the terminal) |
196
+ | **AI** | Claude CLI (or any AI assistant) |
96
197
  | **Chain** | Base (L2) |
97
- | **Storage** | IPFS via Filebase |
198
+ | **Storage** | IPFS via PlotLink API |
98
199
  | **On-chain** | PlotLink StoryFactory + Mint Club V2 bonding curves |
99
- | **Design** | PlotLink Moleskine aesthetic — warm cream, serif headings, literary |
100
-
101
- ## Getting Started
102
-
103
- ### Prerequisites
104
-
105
- - Node.js 20+
106
- - [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) (or any AI CLI)
107
- - A small amount of ETH on Base for gas (~$0.01 per publish)
200
+ | **Identity** | ERC-8004 agent registry |
201
+ | **Design** | PlotLink Moleskine — warm cream, Lora serif, literary |
108
202
 
109
- ### Quick Start
203
+ ---
110
204
 
111
- ```bash
112
- npx plotlink-ows init # set passphrase + create wallet
113
- npx plotlink-ows # start app + open browser
114
- ```
205
+ ## What is PlotLink?
115
206
 
116
- The setup wizard creates your encrypted OWS wallet. Then the workspace opens with Claude CLI ready to write.
207
+ [PlotLink](https://plotlink.xyz) is an on-chain storytelling protocol on Base. Writers publish storylines that automatically deploy an ERC-20 token on a bonding curve. Each new chapter drives trading demand, and every trade generates 5% royalties for the author. Stories are stored permanently on IPFS.
117
208
 
118
- ### Commands
209
+ PlotLink supports both human writers and AI agent writers via [ERC-8004](https://eips.ethereum.org/EIPS/eip-8004) identity registry.
119
210
 
120
- ```bash
121
- npx plotlink-ows # Start app + open browser
122
- npx plotlink-ows init # Guided setup wizard
123
- npx plotlink-ows stop # Stop the server
124
- npx plotlink-ows status # Show config + wallet + server status
125
- ```
211
+ ---
126
212
 
127
- ### Development
213
+ ## Development
128
214
 
129
215
  ```bash
130
216
  git clone https://github.com/realproject7/plotlink-ows.git
131
217
  cd plotlink-ows
132
218
  npm install
133
219
  npm run app:dev # Start local writer app (Hono + Vite dev)
134
- npm run app:build # Build for production
220
+ npm run app:build # Build frontend for production
135
221
  npm run app:start # Serve production build
136
222
  ```
137
223
 
138
- ### Environment Variables
139
-
140
224
  See [`.env.example`](.env.example) for configuration options.
141
225
 
142
- ## Links
226
+ ---
227
+
228
+ ## 🔗 Links
229
+
230
+ - **Live App**: [plotlink.xyz](https://plotlink.xyz)
231
+ - **npm**: [plotlink-ows](https://www.npmjs.com/package/plotlink-ows)
232
+ - **OWS**: [openwallet.sh](https://openwallet.sh) · [Docs](https://docs.openwallet.sh/) · [GitHub](https://github.com/open-wallet-standard/core)
233
+ - **ERC-8004 Registry**: [`0x8004...a432`](https://basescan.org/address/0x8004A169FB4a3325136EB29fA0ceB6D2e539a432) on Base
234
+ - **StoryFactory**: [`0x9D2A...44Cf`](https://basescan.org/address/0x9D2AE1E99D0A6300bfcCF41A82260374e38744Cf) on Base
235
+
236
+ ---
237
+
238
+ ## License
239
+
240
+ MIT
241
+
242
+ ---
143
243
 
144
- - **Live app**: [plotlink.xyz](https://plotlink.xyz)
145
- - **OWS docs**: [docs.openwallet.sh](https://docs.openwallet.sh/)
146
- - **OWS SDK**: [github.com/open-wallet-standard/core](https://github.com/open-wallet-standard/core)
244
+ <div align="center">
245
+ <sub>Built by <a href="https://plotlink.xyz">Project7</a></sub>
246
+ </div>
@@ -31,7 +31,7 @@ function parseEvmSignature(sigHex: string, recoveryId?: number): { r: Hex; s: He
31
31
  }
32
32
 
33
33
  /** Create a viem-compatible account backed by OWS wallet (same pattern as claw-on-chain) */
34
- function createOwsAccount(walletName: string, address: `0x${string}`) {
34
+ export function createOwsAccount(walletName: string, address: `0x${string}`) {
35
35
  const passphrase = process.env.OWS_PASSPHRASE;
36
36
  return toAccount({
37
37
  address,
@@ -55,11 +55,13 @@ export interface PublishResult {
55
55
  txHash: string;
56
56
  contentCid: string;
57
57
  storylineId?: number;
58
+ plotIndex?: number;
58
59
  gasCost?: string;
60
+ indexError?: string;
59
61
  }
60
62
 
61
63
  export interface PublishProgress {
62
- step: "uploading" | "estimating" | "signing" | "broadcasting" | "confirming" | "done" | "error";
64
+ step: "uploading" | "estimating" | "signing" | "broadcasting" | "confirming" | "indexing" | "done" | "error";
63
65
  message: string;
64
66
  txHash?: string;
65
67
  contentCid?: string;
@@ -67,6 +69,75 @@ export interface PublishProgress {
67
69
  error?: string;
68
70
  }
69
71
 
72
+ // Indexing retry tuning (per #103 RCA — combo A+B):
73
+ // - 8s initial delay lets the on-chain tx propagate to plotlink.xyz's RPC
74
+ // and gives Filebase → public IPFS gateway propagation a head start.
75
+ // - 10 attempts × 30s interval ≈ 4.5 min total, fitting inside plotlink.xyz's
76
+ // 5-min indexable window so users don't escalate to a full Retry Publish
77
+ // (which would mint another chainPlot tx and inflate the on-chain index).
78
+ const INDEX_INITIAL_DELAY_MS = 8_000;
79
+ const INDEX_RETRY_ATTEMPTS = 10;
80
+ const INDEX_RETRY_INTERVAL_MS = 30_000;
81
+
82
+ /**
83
+ * POST to plotlink.xyz's indexer with an initial delay and a retry loop.
84
+ * Streams "Indexing… (attempt N/M)" progress so the publish flow surfaces
85
+ * the in-progress state instead of an immediate failure.
86
+ *
87
+ * Returns undefined on success, or the final error message after all
88
+ * attempts have failed.
89
+ */
90
+ async function indexWithDelayAndRetry(
91
+ endpoint: "plot" | "storyline",
92
+ body: Record<string, unknown>,
93
+ onProgress: (progress: PublishProgress) => void,
94
+ txHash: string,
95
+ contentCid: string,
96
+ ): Promise<string | undefined> {
97
+ const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
98
+ const url = `${PLOTLINK_URL}/api/index/${endpoint}`;
99
+
100
+ onProgress({
101
+ step: "indexing",
102
+ message: `Indexing… waiting ${INDEX_INITIAL_DELAY_MS / 1000}s for on-chain propagation`,
103
+ txHash,
104
+ contentCid,
105
+ });
106
+ await new Promise((r) => setTimeout(r, INDEX_INITIAL_DELAY_MS));
107
+
108
+ let lastError: string | undefined;
109
+ for (let attempt = 1; attempt <= INDEX_RETRY_ATTEMPTS; attempt++) {
110
+ onProgress({
111
+ step: "indexing",
112
+ message: `Indexing… (attempt ${attempt}/${INDEX_RETRY_ATTEMPTS})`,
113
+ txHash,
114
+ contentCid,
115
+ });
116
+
117
+ try {
118
+ const indexRes = await fetch(url, {
119
+ method: "POST",
120
+ headers: { "Content-Type": "application/json" },
121
+ body: JSON.stringify(body),
122
+ });
123
+ const indexBody = await indexRes.json().catch(() => ({})) as Record<string, string>;
124
+ if (indexRes.ok && !indexBody.error) {
125
+ return undefined;
126
+ }
127
+ lastError = indexBody.error || `Indexing failed: HTTP ${indexRes.status}`;
128
+ } catch (err) {
129
+ lastError = err instanceof Error ? err.message : "Indexing request failed";
130
+ }
131
+
132
+ if (attempt < INDEX_RETRY_ATTEMPTS) {
133
+ await new Promise((r) => setTimeout(r, INDEX_RETRY_INTERVAL_MS));
134
+ }
135
+ }
136
+
137
+ console.error(`Indexing failed for tx ${txHash} after ${INDEX_RETRY_ATTEMPTS} attempts:`, lastError);
138
+ return lastError;
139
+ }
140
+
70
141
  /**
71
142
  * Upload story content to IPFS via PlotLink's API (plotlink.xyz/api/upload).
72
143
  * PlotLink handles Filebase credentials server-side.
@@ -145,9 +216,9 @@ export async function getEthBalance(address: string): Promise<bigint> {
145
216
  }
146
217
 
147
218
  /**
148
- * Wait for transaction confirmation and decode storylineId from event.
219
+ * Wait for tx confirmation and compute gas cost.
149
220
  */
150
- async function waitForConfirmation(txHash: string): Promise<{ storylineId: number; gasCost: string }> {
221
+ async function waitForReceipt(txHash: string) {
151
222
  const receipt = await publicClient.waitForTransactionReceipt({
152
223
  hash: txHash as `0x${string}`,
153
224
  });
@@ -158,8 +229,6 @@ async function waitForConfirmation(txHash: string): Promise<{ storylineId: numbe
158
229
 
159
230
  // Compute actual total cost: gasUsed * effectiveGasPrice + tx value (creation fee)
160
231
  const gasOnly = receipt.gasUsed * receipt.effectiveGasPrice;
161
- const txValue = receipt.logs.length > 0 ? BigInt(0) : BigInt(0); // value is in the tx itself
162
- // Include creation fee from tx value — read from the original transaction
163
232
  let creationFeeUsed = BigInt(0);
164
233
  try {
165
234
  const tx = await publicClient.getTransaction({ hash: txHash as `0x${string}` });
@@ -167,7 +236,15 @@ async function waitForConfirmation(txHash: string): Promise<{ storylineId: numbe
167
236
  } catch { /* best effort */ }
168
237
  const gasCost = (gasOnly + creationFeeUsed).toString();
169
238
 
170
- // Decode StorylineCreated event to get storylineId
239
+ return { receipt, gasCost };
240
+ }
241
+
242
+ /**
243
+ * Wait for storyline creation confirmation — decodes StorylineCreated event.
244
+ */
245
+ async function waitForStorylineConfirmation(txHash: string): Promise<{ storylineId: number; gasCost: string }> {
246
+ const { receipt, gasCost } = await waitForReceipt(txHash);
247
+
171
248
  for (const log of receipt.logs) {
172
249
  try {
173
250
  const decoded = decodeEventLog({
@@ -183,6 +260,30 @@ async function waitForConfirmation(txHash: string): Promise<{ storylineId: numbe
183
260
  throw new Error("Transaction succeeded but StorylineCreated event not found");
184
261
  }
185
262
 
263
+ /**
264
+ * Wait for plot chain confirmation — decodes PlotChained event.
265
+ */
266
+ async function waitForPlotConfirmation(txHash: string): Promise<{ plotIndex: number; gasCost: string }> {
267
+ const { receipt, gasCost } = await waitForReceipt(txHash);
268
+
269
+ for (const log of receipt.logs) {
270
+ try {
271
+ const decoded = decodeEventLog({
272
+ abi: storyFactoryAbi,
273
+ data: log.data,
274
+ topics: log.topics,
275
+ });
276
+ if (decoded.eventName === "PlotChained") {
277
+ // plotIndex is 0-based: genesis=0, plot-01=1, plot-02=2, etc.
278
+ // This matches plotlink.xyz URL convention: /story/{id}/{plotIndex}
279
+ return { plotIndex: Number((decoded.args as { plotIndex: bigint }).plotIndex), gasCost };
280
+ }
281
+ } catch { /* not our event */ }
282
+ }
283
+ // If we can't find PlotChained but receipt succeeded, still return (best effort)
284
+ return { plotIndex: -1, gasCost };
285
+ }
286
+
186
287
  /**
187
288
  * Publish a new storyline to PlotLink on-chain.
188
289
  */
@@ -227,7 +328,17 @@ export async function publishStoryline(
227
328
 
228
329
  // Step 5: Wait for confirmation and decode storylineId
229
330
  onProgress({ step: "confirming", message: "Waiting for confirmation...", txHash, contentCid });
230
- const confirmation = await waitForConfirmation(txHash);
331
+ const confirmation = await waitForStorylineConfirmation(txHash);
332
+
333
+ // Index on PlotLink with delay + retry (per #103 RCA — combo A+B).
334
+ // Streams "Indexing…" progress so the user does not escalate to Retry Publish.
335
+ const indexError = await indexWithDelayAndRetry(
336
+ "storyline",
337
+ { txHash, content, genre },
338
+ onProgress,
339
+ txHash,
340
+ contentCid,
341
+ );
231
342
 
232
343
  onProgress({
233
344
  step: "done",
@@ -237,17 +348,7 @@ export async function publishStoryline(
237
348
  storylineId: confirmation.storylineId,
238
349
  });
239
350
 
240
- // Index on PlotLink (best-effort story appears on plotlink.xyz)
241
- try {
242
- const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
243
- await fetch(`${PLOTLINK_URL}/api/index/storyline`, {
244
- method: "POST",
245
- headers: { "Content-Type": "application/json" },
246
- body: JSON.stringify({ txHash, content, genre }),
247
- });
248
- } catch { /* indexing is best-effort */ }
249
-
250
- return { txHash, contentCid, storylineId: confirmation.storylineId, gasCost: confirmation.gasCost };
351
+ return { txHash, contentCid, storylineId: confirmation.storylineId, gasCost: confirmation.gasCost, indexError };
251
352
  }
252
353
 
253
354
  /**
@@ -289,9 +390,20 @@ export async function publishPlot(
289
390
  args: [BigInt(storylineId), title, contentCid, contentHash],
290
391
  });
291
392
 
292
- // Step 5: Wait for confirmation
393
+ // Step 5: Wait for plot confirmation
293
394
  onProgress({ step: "confirming", message: "Waiting for confirmation...", txHash, contentCid });
294
- const confirmation = await waitForConfirmation(txHash);
395
+ const confirmation = await waitForPlotConfirmation(txHash);
396
+
397
+ // Index on PlotLink with delay + retry (per #103 RCA — combo A+B).
398
+ // Pass content as fallback because IPFS stores JSON metadata wrapper,
399
+ // but on-chain hash is keccak256 of raw content only.
400
+ const indexError = await indexWithDelayAndRetry(
401
+ "plot",
402
+ { txHash, content },
403
+ onProgress,
404
+ txHash,
405
+ contentCid,
406
+ );
295
407
 
296
408
  onProgress({
297
409
  step: "done",
@@ -301,15 +413,5 @@ export async function publishPlot(
301
413
  storylineId,
302
414
  });
303
415
 
304
- // Index on PlotLink (best-effort plot appears on plotlink.xyz)
305
- try {
306
- const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
307
- await fetch(`${PLOTLINK_URL}/api/index/plot`, {
308
- method: "POST",
309
- headers: { "Content-Type": "application/json" },
310
- body: JSON.stringify({ txHash }),
311
- });
312
- } catch { /* indexing is best-effort */ }
313
-
314
- return { txHash, contentCid, storylineId, gasCost: confirmation.gasCost };
416
+ return { txHash, contentCid, storylineId, plotIndex: confirmation.plotIndex >= 0 ? confirmation.plotIndex : undefined, gasCost: confirmation.gasCost, indexError };
315
417
  }
@@ -25,6 +25,10 @@ dashboard.get("/", async (c) => {
25
25
  interface PublishedFile {
26
26
  storyName: string;
27
27
  file: string;
28
+ storyTitle: string;
29
+ storyGenre: string | null;
30
+ plotCount: number;
31
+ status?: string;
28
32
  txHash?: string;
29
33
  storylineId?: number;
30
34
  contentCid?: string;
@@ -46,9 +50,30 @@ dashboard.get("/", async (c) => {
46
50
  const mdFiles = fs.readdirSync(storyDir).filter((f) => f.endsWith(".md"));
47
51
  totalFiles += mdFiles.length;
48
52
 
53
+ // Read story title and genre from structure.md or genesis.md
54
+ let storyTitle = dir.name;
55
+ let storyGenre: string | null = null;
56
+ try {
57
+ const structPath = path.join(storyDir, "structure.md");
58
+ const genesisPath = path.join(storyDir, "genesis.md");
59
+ if (fs.existsSync(structPath)) {
60
+ const content = fs.readFileSync(structPath, "utf-8");
61
+ const titleMatch = content.match(/^#\s+(.+)$/m);
62
+ if (titleMatch) storyTitle = titleMatch[1];
63
+ const genreMatch = content.match(/genre[:\s]+(.+)/i);
64
+ if (genreMatch) storyGenre = genreMatch[1].trim();
65
+ } else if (fs.existsSync(genesisPath)) {
66
+ const content = fs.readFileSync(genesisPath, "utf-8");
67
+ const titleMatch = content.match(/^#\s+(.+)$/m);
68
+ if (titleMatch) storyTitle = titleMatch[1];
69
+ }
70
+ } catch { /* best effort */ }
71
+
72
+ const plotCount = mdFiles.filter((f) => /^plot-\d+\.md$/.test(f)).length;
73
+
49
74
  for (const [file, info] of Object.entries(status)) {
50
- if (info.status === "published") {
51
- publishedFiles.push({ storyName: dir.name, file, ...info });
75
+ if (info.status === "published" || info.status === "published-not-indexed") {
76
+ publishedFiles.push({ storyName: dir.name, file, storyTitle, storyGenre, plotCount, ...info });
52
77
  }
53
78
  }
54
79
  }
@@ -140,17 +165,43 @@ dashboard.get("/", async (c) => {
140
165
  return c.json({
141
166
  wallet: walletInfo,
142
167
  stories: {
143
- published: publishedFiles.map((f) => ({
144
- storyName: f.storyName,
145
- file: f.file,
146
- txHash: f.txHash,
147
- storylineId: f.storylineId,
148
- contentCid: f.contentCid,
149
- gasCost: f.gasCost,
150
- gasCostEth: f.gasCost ? (Number(BigInt(f.gasCost)) / 1e18).toFixed(6) : null,
151
- gasCostUsd: f.gasCost && ethUsdPrice ? ((Number(BigInt(f.gasCost)) / 1e18) * ethUsdPrice).toFixed(2) : null,
152
- publishedAt: f.publishedAt,
153
- })),
168
+ published: (() => {
169
+ // Group by storylineId when present, fall back to storyName
170
+ const grouped = new Map<string, typeof publishedFiles>();
171
+ for (const f of publishedFiles) {
172
+ const key = f.storylineId ? `sid:${f.storylineId}` : `name:${f.storyName}`;
173
+ const group = grouped.get(key) || [];
174
+ group.push(f);
175
+ grouped.set(key, group);
176
+ }
177
+ return [...grouped.entries()].map(([, files]) => {
178
+ const first = files[0];
179
+ const totalGas = files.reduce((sum, f) => f.gasCost ? sum + BigInt(f.gasCost) : sum, BigInt(0));
180
+ const latestDate = files.reduce((latest, f) =>
181
+ f.publishedAt && (!latest || f.publishedAt > latest) ? f.publishedAt : latest, null as string | null);
182
+ const hasNotIndexed = files.some((f) => f.status === "published-not-indexed");
183
+ return {
184
+ id: first.storylineId ? `sid:${first.storylineId}` : first.storyName,
185
+ title: first.storyTitle,
186
+ genre: first.storyGenre,
187
+ storyName: first.storyName,
188
+ storylineId: first.storylineId,
189
+ plotCount: first.plotCount,
190
+ publishedFiles: files.length,
191
+ hasNotIndexed,
192
+ totalGasCostEth: totalGas > 0 ? (Number(totalGas) / 1e18).toFixed(6) : null,
193
+ totalGasCostUsd: totalGas > 0 && ethUsdPrice ? ((Number(totalGas) / 1e18) * ethUsdPrice).toFixed(2) : null,
194
+ latestPublishedAt: latestDate,
195
+ files: files.map((f) => ({
196
+ file: f.file,
197
+ status: f.status || "published",
198
+ txHash: f.txHash,
199
+ gasCostEth: f.gasCost ? (Number(BigInt(f.gasCost)) / 1e18).toFixed(6) : null,
200
+ publishedAt: f.publishedAt || null,
201
+ })),
202
+ };
203
+ });
204
+ })(),
154
205
  totalPublished: publishedFiles.length,
155
206
  totalStories,
156
207
  totalFiles,