plotlink-ows 0.1.13
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/LICENSE +21 -0
- package/README.md +151 -0
- package/app/db.ts +8 -0
- package/app/lib/llm-client.ts +265 -0
- package/app/lib/paths.ts +11 -0
- package/app/lib/publish.ts +204 -0
- package/app/lib/writer-prompt.ts +44 -0
- package/app/node_modules/.prisma/local-client/client.d.ts +1 -0
- package/app/node_modules/.prisma/local-client/client.js +5 -0
- package/app/node_modules/.prisma/local-client/default.d.ts +1 -0
- package/app/node_modules/.prisma/local-client/default.js +5 -0
- package/app/node_modules/.prisma/local-client/edge.d.ts +1 -0
- package/app/node_modules/.prisma/local-client/edge.js +184 -0
- package/app/node_modules/.prisma/local-client/index-browser.js +173 -0
- package/app/node_modules/.prisma/local-client/index.d.ts +3304 -0
- package/app/node_modules/.prisma/local-client/index.js +207 -0
- package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
- package/app/node_modules/.prisma/local-client/package.json +183 -0
- package/app/node_modules/.prisma/local-client/query_engine_bg.js +2 -0
- package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
- package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +35 -0
- package/app/node_modules/.prisma/local-client/runtime/edge.js +35 -0
- package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +370 -0
- package/app/node_modules/.prisma/local-client/runtime/index-browser.js +17 -0
- package/app/node_modules/.prisma/local-client/runtime/library.d.ts +3982 -0
- package/app/node_modules/.prisma/local-client/runtime/library.js +147 -0
- package/app/node_modules/.prisma/local-client/runtime/react-native.js +84 -0
- package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +85 -0
- package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +38 -0
- package/app/node_modules/.prisma/local-client/schema.prisma +21 -0
- package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +5 -0
- package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +5 -0
- package/app/node_modules/.prisma/local-client/wasm.d.ts +1 -0
- package/app/node_modules/.prisma/local-client/wasm.js +191 -0
- package/app/prisma/schema.prisma +57 -0
- package/app/routes/auth.ts +173 -0
- package/app/routes/chat.ts +135 -0
- package/app/routes/config.ts +210 -0
- package/app/routes/dashboard.ts +186 -0
- package/app/routes/oauth.ts +150 -0
- package/app/routes/publish.ts +112 -0
- package/app/routes/wallet.ts +99 -0
- package/app/server.ts +154 -0
- package/app/vite.config.ts +19 -0
- package/app/web/App.tsx +102 -0
- package/app/web/components/Chat.tsx +272 -0
- package/app/web/components/Dashboard.tsx +222 -0
- package/app/web/components/LLMSetup.tsx +291 -0
- package/app/web/components/Layout.tsx +235 -0
- package/app/web/components/Login.tsx +62 -0
- package/app/web/components/Publish.tsx +245 -0
- package/app/web/components/Settings.tsx +175 -0
- package/app/web/components/Setup.tsx +84 -0
- package/app/web/components/WalletCard.tsx +117 -0
- package/app/web/dist/assets/index-C9kXlYO_.css +2 -0
- package/app/web/dist/assets/index-CJiiaLHs.js +9 -0
- package/app/web/dist/index.html +16 -0
- package/app/web/index.html +15 -0
- package/app/web/main.tsx +10 -0
- package/app/web/plotlink-logo.svg +5 -0
- package/app/web/styles.css +51 -0
- package/bin/plotlink-ows.js +394 -0
- package/lib/ows/index.ts +3 -0
- package/lib/ows/policy.ts +68 -0
- package/lib/ows/types.ts +14 -0
- package/lib/ows/wallet.ts +70 -0
- package/package.json +79 -0
- package/packages/cli/node_modules/commander/LICENSE +22 -0
- package/packages/cli/node_modules/commander/Readme.md +1149 -0
- package/packages/cli/node_modules/commander/esm.mjs +16 -0
- package/packages/cli/node_modules/commander/index.js +24 -0
- package/packages/cli/node_modules/commander/lib/argument.js +149 -0
- package/packages/cli/node_modules/commander/lib/command.js +2662 -0
- package/packages/cli/node_modules/commander/lib/error.js +39 -0
- package/packages/cli/node_modules/commander/lib/help.js +709 -0
- package/packages/cli/node_modules/commander/lib/option.js +367 -0
- package/packages/cli/node_modules/commander/lib/suggestSimilar.js +101 -0
- package/packages/cli/node_modules/commander/package-support.json +16 -0
- package/packages/cli/node_modules/commander/package.json +82 -0
- package/packages/cli/node_modules/commander/typings/esm.d.mts +3 -0
- package/packages/cli/node_modules/commander/typings/index.d.ts +1045 -0
- package/packages/cli/node_modules/resolve-from/index.d.ts +31 -0
- package/packages/cli/node_modules/resolve-from/index.js +47 -0
- package/packages/cli/node_modules/resolve-from/license +9 -0
- package/packages/cli/node_modules/resolve-from/package.json +36 -0
- package/packages/cli/node_modules/resolve-from/readme.md +72 -0
- package/packages/cli/node_modules/tsup/LICENSE +21 -0
- package/packages/cli/node_modules/tsup/README.md +75 -0
- package/packages/cli/node_modules/tsup/assets/cjs_shims.js +13 -0
- package/packages/cli/node_modules/tsup/assets/esm_shims.js +9 -0
- package/packages/cli/node_modules/tsup/assets/package.json +3 -0
- package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +153 -0
- package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +42 -0
- package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +6 -0
- package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +352 -0
- package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +203 -0
- package/packages/cli/node_modules/tsup/dist/cli-default.js +12 -0
- package/packages/cli/node_modules/tsup/dist/cli-main.js +8 -0
- package/packages/cli/node_modules/tsup/dist/cli-node.js +14 -0
- package/packages/cli/node_modules/tsup/dist/index.d.ts +511 -0
- package/packages/cli/node_modules/tsup/dist/index.js +1711 -0
- package/packages/cli/node_modules/tsup/dist/rollup.js +6949 -0
- package/packages/cli/node_modules/tsup/package.json +99 -0
- package/packages/cli/node_modules/tsup/schema.json +362 -0
- package/packages/cli/package.json +35 -0
- package/packages/cli/src/commands/agent-register.ts +77 -0
- package/packages/cli/src/commands/chain.ts +29 -0
- package/packages/cli/src/commands/claim.ts +70 -0
- package/packages/cli/src/commands/create.ts +34 -0
- package/packages/cli/src/commands/status.ts +201 -0
- package/packages/cli/src/config.ts +103 -0
- package/packages/cli/src/index.ts +21 -0
- package/packages/cli/src/sdk/abi.ts +222 -0
- package/packages/cli/src/sdk/client.ts +713 -0
- package/packages/cli/src/sdk/constants.ts +56 -0
- package/packages/cli/src/sdk/index.ts +46 -0
- package/packages/cli/src/sdk/ipfs.ts +88 -0
- package/packages/cli/src/sdk.ts +36 -0
- package/packages/cli/tsconfig.json +20 -0
- package/packages/cli/tsup.config.ts +14 -0
- package/public/.well-known/farcaster.json +38 -0
- package/public/basescan-icon.svg +4 -0
- package/public/embed-image.png +0 -0
- package/public/favicon.png +0 -0
- package/public/hunt-token.svg +11 -0
- package/public/icon-192.png +0 -0
- package/public/icon.png +0 -0
- package/public/manifest.json +26 -0
- package/public/mc-icon-light.svg +12 -0
- package/public/og-image.png +0 -0
- package/public/plotlink-logo-symbol.svg +5 -0
- package/public/plotlink-logo.svg +5 -0
- package/public/screenshot-1.png +0 -0
- package/public/screenshot-2.png +0 -0
- package/public/screenshot-3.png +0 -0
- package/public/splash.png +0 -0
- package/public/wide-banner.png +0 -0
- package/scripts/backfill-trade-prices.ts +97 -0
- package/scripts/backfill-usd-rates.ts +220 -0
- package/scripts/e2e-verify.ts +1100 -0
- package/scripts/ows-smoke-test.ts +37 -0
- package/scripts/score-users.mjs +203 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Project7
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# PlotLink OWS Writer
|
|
2
|
+
|
|
3
|
+
**Anyone can become a fiction writer with just an idea.**
|
|
4
|
+
|
|
5
|
+
PlotLink OWS Writer is a local AI writing assistant that turns your ideas into published, tokenized fiction stories on [plotlink.xyz](https://plotlink.xyz). You bring the concept — the AI handles the writing, editing, and on-chain publishing. Every story you publish becomes a tradable token on a bonding curve, earning you royalties from every trade.
|
|
6
|
+
|
|
7
|
+
No writing experience needed. No crypto complexity. Just an idea and a conversation with your AI co-writer.
|
|
8
|
+
|
|
9
|
+
## How It Works
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
You: "I want to write a sci-fi story about an AI that discovers it can dream"
|
|
13
|
+
|
|
14
|
+
↓ Chat with AI writer — brainstorm, outline, refine
|
|
15
|
+
|
|
16
|
+
AI: Generates a polished 2000-word chapter
|
|
17
|
+
|
|
18
|
+
↓ You approve — one click to publish
|
|
19
|
+
|
|
20
|
+
On-chain: Story published to PlotLink on Base
|
|
21
|
+
→ Token + bonding curve deployed
|
|
22
|
+
→ You earn 5% royalties on every trade
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### The Flow
|
|
26
|
+
|
|
27
|
+
1. **Install & run** — `npm install && npm run app:dev`
|
|
28
|
+
2. **Connect your LLM** — Anthropic, OpenAI, Gemini, or local models (Ollama, LM Studio)
|
|
29
|
+
3. **Get a wallet** — OWS creates an encrypted wallet on your machine (you control the keys)
|
|
30
|
+
4. **Chat** — Discuss story ideas with your AI writer. It brainstorms, outlines, drafts, and refines.
|
|
31
|
+
5. **Publish** — When you're happy, the AI uploads to IPFS and publishes on-chain via your OWS wallet.
|
|
32
|
+
6. **Earn** — Your story is live on [plotlink.xyz](https://plotlink.xyz) with a bonding curve. Early readers who back your story drive the price up, and you earn 5% royalties on every trade.
|
|
33
|
+
|
|
34
|
+
## Architecture
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
┌─────────────────────────────────────────────┐
|
|
38
|
+
│ Your Computer (localhost:7777) │
|
|
39
|
+
│ │
|
|
40
|
+
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ │
|
|
41
|
+
│ │ Chat UI │ │ LLM │ │ OWS │ │
|
|
42
|
+
│ │ (React) │ │ Provider │ │ Wallet │ │
|
|
43
|
+
│ │ │ │ (yours) │ │ (local) │ │
|
|
44
|
+
│ └────┬─────┘ └────┬─────┘ └─────┬─────┘ │
|
|
45
|
+
│ │ │ │ │
|
|
46
|
+
│ └──────┬───────┘ │ │
|
|
47
|
+
│ ↓ │ │
|
|
48
|
+
│ ┌────────────────┐ │ │
|
|
49
|
+
│ │ AI Writer │ │ │
|
|
50
|
+
│ │ Agent ├──────────────┘ │
|
|
51
|
+
│ └───────┬────────┘ │
|
|
52
|
+
│ │ sign tx + publish │
|
|
53
|
+
└─────────────┼───────────────────────────────┘
|
|
54
|
+
↓
|
|
55
|
+
┌────────────────┐ ┌─────────────────┐
|
|
56
|
+
│ Base (L2) │ │ IPFS │
|
|
57
|
+
│ StoryFactory │ │ (Filebase) │
|
|
58
|
+
│ Bonding Curve │ │ Story content │
|
|
59
|
+
└────────────────┘ └─────────────────┘
|
|
60
|
+
↓
|
|
61
|
+
┌────────────────┐
|
|
62
|
+
│ plotlink.xyz │
|
|
63
|
+
│ Live story + │
|
|
64
|
+
│ token trading │
|
|
65
|
+
└────────────────┘
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## What is PlotLink?
|
|
69
|
+
|
|
70
|
+
[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.
|
|
71
|
+
|
|
72
|
+
PlotLink is currently in live testing on Base mainnet with public launch planned for next week.
|
|
73
|
+
|
|
74
|
+
## What is OWS?
|
|
75
|
+
|
|
76
|
+
[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 AI agent signs transactions through OWS without ever seeing the key. You set spending limits and chain restrictions via policies.
|
|
77
|
+
|
|
78
|
+
## Tech Stack
|
|
79
|
+
|
|
80
|
+
| Layer | Technology |
|
|
81
|
+
|-------|-----------|
|
|
82
|
+
| **Backend** | Hono (localhost:7777) |
|
|
83
|
+
| **Frontend** | React 19 + Vite |
|
|
84
|
+
| **Database** | SQLite + Prisma (local, embedded) |
|
|
85
|
+
| **Wallet** | OWS (`@open-wallet-standard/core`) |
|
|
86
|
+
| **LLM** | Bring your own — Anthropic, OpenAI, Gemini, Ollama, LM Studio |
|
|
87
|
+
| **Chain** | Base (L2) |
|
|
88
|
+
| **Storage** | IPFS via Filebase |
|
|
89
|
+
| **On-chain** | PlotLink StoryFactory + Mint Club V2 bonding curves |
|
|
90
|
+
| **Design** | PlotLink Moleskine aesthetic — warm cream, serif headings, literary |
|
|
91
|
+
|
|
92
|
+
## Getting Started
|
|
93
|
+
|
|
94
|
+
### Prerequisites
|
|
95
|
+
|
|
96
|
+
- Node.js 20+
|
|
97
|
+
- An LLM provider account (Anthropic, OpenAI, or Gemini) or a local model running
|
|
98
|
+
- A small amount of ETH on Base for gas (~$0.01 per publish)
|
|
99
|
+
|
|
100
|
+
### Quick Start
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
npx plotlink-ows init # guided setup: passphrase, LLM, wallet
|
|
104
|
+
npx plotlink-ows # start app + open browser
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The setup wizard will walk you through:
|
|
108
|
+
|
|
109
|
+
1. Set a passphrase (encrypts your OWS wallet)
|
|
110
|
+
2. Connect your LLM (Anthropic, OpenAI, Gemini, or local model)
|
|
111
|
+
3. Create your OWS wallet (encrypted on your machine)
|
|
112
|
+
|
|
113
|
+
### Commands
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
npx plotlink-ows # Start app + open browser
|
|
117
|
+
npx plotlink-ows init # Guided setup wizard
|
|
118
|
+
npx plotlink-ows stop # Stop the server
|
|
119
|
+
npx plotlink-ows status # Show config + wallet + server status
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Development
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
git clone https://github.com/realproject7/plotlink-ows.git
|
|
126
|
+
cd plotlink-ows
|
|
127
|
+
npm install
|
|
128
|
+
npm run app:dev # Start local writer app (Hono + Vite dev)
|
|
129
|
+
npm run app:build # Build for production
|
|
130
|
+
npm run app:start # Serve production build
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Environment Variables
|
|
134
|
+
|
|
135
|
+
See [`.env.example`](.env.example) for configuration options.
|
|
136
|
+
|
|
137
|
+
## Screenshots
|
|
138
|
+
|
|
139
|
+
| LLM Setup | Chat with AI Writer |
|
|
140
|
+
|-----------|-------------------|
|
|
141
|
+
|  |  |
|
|
142
|
+
|
|
143
|
+
| Publish Flow | Writer Dashboard |
|
|
144
|
+
|-------------|-----------------|
|
|
145
|
+
|  |  |
|
|
146
|
+
|
|
147
|
+
## Links
|
|
148
|
+
|
|
149
|
+
- **Live app**: [plotlink.xyz](https://plotlink.xyz)
|
|
150
|
+
- **OWS docs**: [docs.openwallet.sh](https://docs.openwallet.sh/)
|
|
151
|
+
- **OWS SDK**: [github.com/open-wallet-standard/core](https://github.com/open-wallet-standard/core)
|
package/app/db.ts
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import { AGENT_CONFIG_FILE } from "./paths";
|
|
3
|
+
|
|
4
|
+
const configPath = AGENT_CONFIG_FILE;
|
|
5
|
+
|
|
6
|
+
interface LLMConfig {
|
|
7
|
+
activeProvider?: string;
|
|
8
|
+
activeModel?: string;
|
|
9
|
+
local?: { baseUrl: string; model: string; apiType?: string };
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function readLLMConfig(): LLMConfig {
|
|
14
|
+
try {
|
|
15
|
+
if (fs.existsSync(configPath)) {
|
|
16
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
17
|
+
return (cfg.llm as LLMConfig) || {};
|
|
18
|
+
}
|
|
19
|
+
} catch { /* ignore */ }
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getCredential(provider: string): string | null {
|
|
24
|
+
const keyMap: Record<string, string[]> = {
|
|
25
|
+
anthropic: ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"],
|
|
26
|
+
openai: ["OPENAI_API_KEY", "OPENAI_OAUTH_TOKEN"],
|
|
27
|
+
gemini: ["GEMINI_API_KEY"],
|
|
28
|
+
};
|
|
29
|
+
for (const key of keyMap[provider] || []) {
|
|
30
|
+
if (process.env[key]) return process.env[key]!;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ChatMessage {
|
|
36
|
+
role: "user" | "assistant" | "system";
|
|
37
|
+
content: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Stream a chat completion from the configured LLM provider.
|
|
42
|
+
* Yields text chunks as they arrive.
|
|
43
|
+
*/
|
|
44
|
+
export async function* streamChat(messages: ChatMessage[]): AsyncGenerator<string> {
|
|
45
|
+
const config = readLLMConfig();
|
|
46
|
+
const provider = config.activeProvider;
|
|
47
|
+
const model = config.activeModel;
|
|
48
|
+
|
|
49
|
+
if (!provider || !model) {
|
|
50
|
+
yield "Error: No LLM provider configured. Go to Settings → LLM to set up.";
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (provider === "anthropic") {
|
|
55
|
+
yield* streamAnthropic(messages, model);
|
|
56
|
+
} else if (provider === "openai") {
|
|
57
|
+
yield* streamOpenAI(messages, model);
|
|
58
|
+
} else if (provider === "gemini") {
|
|
59
|
+
yield* streamGemini(messages, model);
|
|
60
|
+
} else if (provider === "local") {
|
|
61
|
+
const localConfig = config.local;
|
|
62
|
+
if (!localConfig) { yield "Error: Local model not configured."; return; }
|
|
63
|
+
yield* streamLocal(messages, localConfig.baseUrl, localConfig.model, localConfig.apiType);
|
|
64
|
+
} else {
|
|
65
|
+
yield `Error: Unknown provider "${provider}".`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function* streamAnthropic(messages: ChatMessage[], model: string): AsyncGenerator<string> {
|
|
70
|
+
const apiKey = getCredential("anthropic");
|
|
71
|
+
if (!apiKey) { yield "Error: No Anthropic API key configured."; return; }
|
|
72
|
+
|
|
73
|
+
const systemMsg = messages.find((m) => m.role === "system");
|
|
74
|
+
const nonSystem = messages.filter((m) => m.role !== "system");
|
|
75
|
+
|
|
76
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: {
|
|
79
|
+
"x-api-key": apiKey,
|
|
80
|
+
"anthropic-version": "2023-06-01",
|
|
81
|
+
"content-type": "application/json",
|
|
82
|
+
},
|
|
83
|
+
body: JSON.stringify({
|
|
84
|
+
model,
|
|
85
|
+
max_tokens: 4096,
|
|
86
|
+
stream: true,
|
|
87
|
+
...(systemMsg && { system: systemMsg.content }),
|
|
88
|
+
messages: nonSystem.map((m) => ({ role: m.role, content: m.content })),
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
const err = await res.text();
|
|
94
|
+
yield `Error: Anthropic API ${res.status} — ${err}`;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const reader = res.body?.getReader();
|
|
99
|
+
if (!reader) return;
|
|
100
|
+
const decoder = new TextDecoder();
|
|
101
|
+
let buffer = "";
|
|
102
|
+
|
|
103
|
+
while (true) {
|
|
104
|
+
const { done, value } = await reader.read();
|
|
105
|
+
if (done) break;
|
|
106
|
+
buffer += decoder.decode(value, { stream: true });
|
|
107
|
+
|
|
108
|
+
const lines = buffer.split("\n");
|
|
109
|
+
buffer = lines.pop() || "";
|
|
110
|
+
|
|
111
|
+
for (const line of lines) {
|
|
112
|
+
if (line.startsWith("data: ")) {
|
|
113
|
+
const data = line.slice(6);
|
|
114
|
+
if (data === "[DONE]") return;
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(data);
|
|
117
|
+
if (parsed.type === "content_block_delta" && parsed.delta?.text) {
|
|
118
|
+
yield parsed.delta.text;
|
|
119
|
+
}
|
|
120
|
+
} catch { /* ignore parse errors */ }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function* streamOpenAI(messages: ChatMessage[], model: string): AsyncGenerator<string> {
|
|
127
|
+
const apiKey = getCredential("openai");
|
|
128
|
+
if (!apiKey) { yield "Error: No OpenAI API key configured."; return; }
|
|
129
|
+
|
|
130
|
+
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: { Authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
|
|
133
|
+
body: JSON.stringify({ model, stream: true, messages }),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
if (!res.ok) {
|
|
137
|
+
yield `Error: OpenAI API ${res.status}`;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
yield* parseSSEStream(res);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function* streamGemini(messages: ChatMessage[], model: string): AsyncGenerator<string> {
|
|
145
|
+
const apiKey = getCredential("gemini");
|
|
146
|
+
if (!apiKey) { yield "Error: No Gemini API key configured."; return; }
|
|
147
|
+
|
|
148
|
+
const contents = messages
|
|
149
|
+
.filter((m) => m.role !== "system")
|
|
150
|
+
.map((m) => ({
|
|
151
|
+
role: m.role === "assistant" ? "model" : "user",
|
|
152
|
+
parts: [{ text: m.content }],
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
const systemInstruction = messages.find((m) => m.role === "system");
|
|
156
|
+
|
|
157
|
+
// Use streamGenerateContent for streaming
|
|
158
|
+
const res = await fetch(
|
|
159
|
+
`https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse&key=${apiKey}`,
|
|
160
|
+
{
|
|
161
|
+
method: "POST",
|
|
162
|
+
headers: { "content-type": "application/json" },
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
contents,
|
|
165
|
+
...(systemInstruction && { systemInstruction: { parts: [{ text: systemInstruction.content }] } }),
|
|
166
|
+
generationConfig: { maxOutputTokens: 4096 },
|
|
167
|
+
}),
|
|
168
|
+
},
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
if (!res.ok) {
|
|
172
|
+
yield `Error: Gemini API ${res.status}`;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const reader = res.body?.getReader();
|
|
177
|
+
if (!reader) return;
|
|
178
|
+
const decoder = new TextDecoder();
|
|
179
|
+
let buffer = "";
|
|
180
|
+
|
|
181
|
+
while (true) {
|
|
182
|
+
const { done, value } = await reader.read();
|
|
183
|
+
if (done) break;
|
|
184
|
+
buffer += decoder.decode(value, { stream: true });
|
|
185
|
+
const lines = buffer.split("\n");
|
|
186
|
+
buffer = lines.pop() || "";
|
|
187
|
+
|
|
188
|
+
for (const line of lines) {
|
|
189
|
+
if (line.startsWith("data: ")) {
|
|
190
|
+
try {
|
|
191
|
+
const parsed = JSON.parse(line.slice(6));
|
|
192
|
+
const text = parsed.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
193
|
+
if (text) yield text;
|
|
194
|
+
} catch { /* ignore */ }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function* streamLocal(messages: ChatMessage[], baseUrl: string, model: string, apiType?: string): AsyncGenerator<string> {
|
|
201
|
+
if (apiType === "ollama") {
|
|
202
|
+
const res = await fetch(`${baseUrl}/api/chat`, {
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers: { "content-type": "application/json" },
|
|
205
|
+
body: JSON.stringify({ model, messages, stream: true }),
|
|
206
|
+
});
|
|
207
|
+
if (!res.ok) { yield `Error: Local model ${res.status}`; return; }
|
|
208
|
+
|
|
209
|
+
const reader = res.body?.getReader();
|
|
210
|
+
if (!reader) return;
|
|
211
|
+
const decoder = new TextDecoder();
|
|
212
|
+
let buffer = "";
|
|
213
|
+
|
|
214
|
+
while (true) {
|
|
215
|
+
const { done, value } = await reader.read();
|
|
216
|
+
if (done) break;
|
|
217
|
+
buffer += decoder.decode(value, { stream: true });
|
|
218
|
+
const lines = buffer.split("\n");
|
|
219
|
+
buffer = lines.pop() || "";
|
|
220
|
+
for (const line of lines) {
|
|
221
|
+
if (!line.trim()) continue;
|
|
222
|
+
try {
|
|
223
|
+
const parsed = JSON.parse(line);
|
|
224
|
+
if (parsed.message?.content) yield parsed.message.content;
|
|
225
|
+
} catch { /* ignore */ }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
// OpenAI-compatible (LM Studio, etc.)
|
|
230
|
+
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
|
|
231
|
+
method: "POST",
|
|
232
|
+
headers: { "content-type": "application/json" },
|
|
233
|
+
body: JSON.stringify({ model, stream: true, messages }),
|
|
234
|
+
});
|
|
235
|
+
if (!res.ok) { yield `Error: Local model ${res.status}`; return; }
|
|
236
|
+
yield* parseSSEStream(res);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function* parseSSEStream(res: Response): AsyncGenerator<string> {
|
|
241
|
+
const reader = res.body?.getReader();
|
|
242
|
+
if (!reader) return;
|
|
243
|
+
const decoder = new TextDecoder();
|
|
244
|
+
let buffer = "";
|
|
245
|
+
|
|
246
|
+
while (true) {
|
|
247
|
+
const { done, value } = await reader.read();
|
|
248
|
+
if (done) break;
|
|
249
|
+
buffer += decoder.decode(value, { stream: true });
|
|
250
|
+
const lines = buffer.split("\n");
|
|
251
|
+
buffer = lines.pop() || "";
|
|
252
|
+
|
|
253
|
+
for (const line of lines) {
|
|
254
|
+
if (line.startsWith("data: ")) {
|
|
255
|
+
const data = line.slice(6);
|
|
256
|
+
if (data === "[DONE]") return;
|
|
257
|
+
try {
|
|
258
|
+
const parsed = JSON.parse(data);
|
|
259
|
+
const content = parsed.choices?.[0]?.delta?.content;
|
|
260
|
+
if (content) yield content;
|
|
261
|
+
} catch { /* ignore */ }
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
package/app/lib/paths.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import os from "os";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
|
|
5
|
+
/** All user state lives in ~/.plotlink-ows/ — survives npx reinstalls */
|
|
6
|
+
export const CONFIG_DIR = path.join(os.homedir(), ".plotlink-ows");
|
|
7
|
+
export const ENV_FILE = path.join(CONFIG_DIR, ".env");
|
|
8
|
+
export const AGENT_CONFIG_FILE = path.join(CONFIG_DIR, "agent.config.json");
|
|
9
|
+
|
|
10
|
+
// Ensure config dir exists on import
|
|
11
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PlotLink publish flow — uploads content to IPFS and publishes on-chain via OWS wallet.
|
|
3
|
+
*/
|
|
4
|
+
import { createPublicClient, http, encodeFunctionData, keccak256, toBytes, decodeEventLog, type Hex } from "viem";
|
|
5
|
+
import { base } from "viem/chains";
|
|
6
|
+
import { STORY_FACTORY_ABI, mcv2BondAbi } from "../../packages/cli/src/sdk/abi";
|
|
7
|
+
import { uploadWithRetry } from "../../packages/cli/src/sdk/ipfs";
|
|
8
|
+
import { signAndSendAgent } from "../../lib/ows/wallet";
|
|
9
|
+
|
|
10
|
+
// Contract addresses (Base mainnet)
|
|
11
|
+
const STORY_FACTORY = "0x9D2AE1E99D0A6300bfcCF41A82260374e38744Cf" as const;
|
|
12
|
+
const MCV2_BOND = "0xc5a076cad94176c2996B32d8466Be1cE757FAa27" as const;
|
|
13
|
+
|
|
14
|
+
const publicClient = createPublicClient({
|
|
15
|
+
chain: base,
|
|
16
|
+
transport: http(process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org"),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function getFilebaseConfig() {
|
|
20
|
+
return {
|
|
21
|
+
accessKey: process.env.FILEBASE_ACCESS_KEY || "",
|
|
22
|
+
secretKey: process.env.FILEBASE_SECRET_KEY || "",
|
|
23
|
+
bucket: process.env.FILEBASE_BUCKET || "",
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PublishResult {
|
|
28
|
+
txHash: string;
|
|
29
|
+
contentCid: string;
|
|
30
|
+
storylineId?: number;
|
|
31
|
+
gasCost?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PublishProgress {
|
|
35
|
+
step: "uploading" | "estimating" | "signing" | "broadcasting" | "confirming" | "done" | "error";
|
|
36
|
+
message: string;
|
|
37
|
+
txHash?: string;
|
|
38
|
+
contentCid?: string;
|
|
39
|
+
storylineId?: number;
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Upload story content to IPFS via Filebase.
|
|
45
|
+
*/
|
|
46
|
+
export async function uploadToIPFS(content: string, title: string, genre?: string): Promise<string> {
|
|
47
|
+
const filebaseConfig = getFilebaseConfig();
|
|
48
|
+
if (!filebaseConfig.accessKey || !filebaseConfig.secretKey) {
|
|
49
|
+
throw new Error("Filebase not configured. Set FILEBASE_ACCESS_KEY and FILEBASE_SECRET_KEY in .env");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const metadata = JSON.stringify({ title, genre, content });
|
|
53
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40);
|
|
54
|
+
const key = `plotlink/storylines/${Date.now()}-${slug}.json`;
|
|
55
|
+
|
|
56
|
+
const cid = await uploadWithRetry(metadata, key, filebaseConfig);
|
|
57
|
+
return cid;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get the MCV2 Bond creation fee required for createStoryline.
|
|
62
|
+
*/
|
|
63
|
+
export async function getCreationFee(): Promise<bigint> {
|
|
64
|
+
const fee = await publicClient.readContract({
|
|
65
|
+
address: MCV2_BOND,
|
|
66
|
+
abi: mcv2BondAbi,
|
|
67
|
+
functionName: "creationFee",
|
|
68
|
+
}) as bigint;
|
|
69
|
+
return fee;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Estimate total cost for publishing (creation fee + gas).
|
|
74
|
+
*/
|
|
75
|
+
export async function estimatePublishCost(
|
|
76
|
+
walletAddress: string,
|
|
77
|
+
title: string,
|
|
78
|
+
contentCid: string,
|
|
79
|
+
contentHash: Hex,
|
|
80
|
+
): Promise<{ creationFee: bigint; gasEstimate: bigint; gasPrice: bigint; totalCost: bigint }> {
|
|
81
|
+
const creationFee = await getCreationFee();
|
|
82
|
+
|
|
83
|
+
const gas = await publicClient.estimateGas({
|
|
84
|
+
account: walletAddress as `0x${string}`,
|
|
85
|
+
to: STORY_FACTORY,
|
|
86
|
+
value: creationFee,
|
|
87
|
+
data: encodeFunctionData({
|
|
88
|
+
abi: STORY_FACTORY_ABI,
|
|
89
|
+
functionName: "createStoryline",
|
|
90
|
+
args: [title, contentCid, contentHash, true],
|
|
91
|
+
}),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const gasPrice = await publicClient.getGasPrice();
|
|
95
|
+
const gasCost = gas * gasPrice;
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
creationFee,
|
|
99
|
+
gasEstimate: gas,
|
|
100
|
+
gasPrice,
|
|
101
|
+
totalCost: creationFee + gasCost,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check ETH balance on Base.
|
|
107
|
+
*/
|
|
108
|
+
export async function getEthBalance(address: string): Promise<bigint> {
|
|
109
|
+
return publicClient.getBalance({ address: address as `0x${string}` });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Wait for transaction confirmation and decode storylineId from event.
|
|
114
|
+
*/
|
|
115
|
+
async function waitForConfirmation(txHash: string): Promise<{ storylineId: number; gasCost: string }> {
|
|
116
|
+
const receipt = await publicClient.waitForTransactionReceipt({
|
|
117
|
+
hash: txHash as `0x${string}`,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (receipt.status === "reverted") {
|
|
121
|
+
throw new Error("Transaction reverted on-chain");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Compute actual total cost: gasUsed * effectiveGasPrice + tx value (creation fee)
|
|
125
|
+
const gasOnly = receipt.gasUsed * receipt.effectiveGasPrice;
|
|
126
|
+
const txValue = receipt.logs.length > 0 ? BigInt(0) : BigInt(0); // value is in the tx itself
|
|
127
|
+
// Include creation fee from tx value — read from the original transaction
|
|
128
|
+
let creationFeeUsed = BigInt(0);
|
|
129
|
+
try {
|
|
130
|
+
const tx = await publicClient.getTransaction({ hash: txHash as `0x${string}` });
|
|
131
|
+
creationFeeUsed = tx.value;
|
|
132
|
+
} catch { /* best effort */ }
|
|
133
|
+
const gasCost = (gasOnly + creationFeeUsed).toString();
|
|
134
|
+
|
|
135
|
+
// Decode StorylineCreated event to get storylineId
|
|
136
|
+
for (const log of receipt.logs) {
|
|
137
|
+
try {
|
|
138
|
+
const decoded = decodeEventLog({
|
|
139
|
+
abi: STORY_FACTORY_ABI,
|
|
140
|
+
data: log.data,
|
|
141
|
+
topics: log.topics,
|
|
142
|
+
});
|
|
143
|
+
if (decoded.eventName === "StorylineCreated") {
|
|
144
|
+
return { storylineId: Number((decoded.args as { storylineId: bigint }).storylineId), gasCost };
|
|
145
|
+
}
|
|
146
|
+
} catch { /* not our event */ }
|
|
147
|
+
}
|
|
148
|
+
throw new Error("Transaction succeeded but StorylineCreated event not found");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Publish a new storyline to PlotLink on-chain.
|
|
153
|
+
*/
|
|
154
|
+
export async function publishStoryline(
|
|
155
|
+
walletName: string,
|
|
156
|
+
title: string,
|
|
157
|
+
content: string,
|
|
158
|
+
genre: string | undefined,
|
|
159
|
+
onProgress: (progress: PublishProgress) => void,
|
|
160
|
+
): Promise<PublishResult> {
|
|
161
|
+
// Step 1: Upload to IPFS
|
|
162
|
+
onProgress({ step: "uploading", message: "Uploading story to IPFS..." });
|
|
163
|
+
const contentCid = await uploadToIPFS(content, title, genre);
|
|
164
|
+
|
|
165
|
+
// Step 2: Compute content hash + get creation fee
|
|
166
|
+
const contentHash = keccak256(toBytes(content));
|
|
167
|
+
|
|
168
|
+
onProgress({ step: "estimating", message: "Fetching creation fee and estimating gas..." });
|
|
169
|
+
const creationFee = await getCreationFee();
|
|
170
|
+
|
|
171
|
+
// Step 3: Build transaction with creation fee as value
|
|
172
|
+
const calldata = encodeFunctionData({
|
|
173
|
+
abi: STORY_FACTORY_ABI,
|
|
174
|
+
functionName: "createStoryline",
|
|
175
|
+
args: [title, contentCid, contentHash, true],
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Step 4: Sign and send via OWS
|
|
179
|
+
onProgress({ step: "signing", message: "Signing transaction with OWS wallet..." });
|
|
180
|
+
const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org";
|
|
181
|
+
|
|
182
|
+
const txHex = JSON.stringify({
|
|
183
|
+
to: STORY_FACTORY,
|
|
184
|
+
data: calldata,
|
|
185
|
+
value: `0x${creationFee.toString(16)}`,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
onProgress({ step: "broadcasting", message: "Broadcasting transaction..." });
|
|
189
|
+
const result = signAndSendAgent(walletName, txHex, undefined, rpcUrl);
|
|
190
|
+
|
|
191
|
+
// Step 5: Wait for confirmation and decode storylineId
|
|
192
|
+
onProgress({ step: "confirming", message: "Waiting for confirmation...", txHash: result.txHash, contentCid });
|
|
193
|
+
const confirmation = await waitForConfirmation(result.txHash);
|
|
194
|
+
|
|
195
|
+
onProgress({
|
|
196
|
+
step: "done",
|
|
197
|
+
message: `Published! Storyline #${confirmation.storylineId}`,
|
|
198
|
+
txHash: result.txHash,
|
|
199
|
+
contentCid,
|
|
200
|
+
storylineId: confirmation.storylineId,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
return { txHash: result.txHash, contentCid, storylineId: confirmation.storylineId, gasCost: confirmation.gasCost };
|
|
204
|
+
}
|