claude-world-studio 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/.env.example +30 -0
- package/.mcp.json +51 -0
- package/README.md +224 -0
- package/client/App.tsx +446 -0
- package/client/components/ChatWindow.tsx +790 -0
- package/client/components/FileExplorer.tsx +218 -0
- package/client/components/FilePreviewModal.tsx +179 -0
- package/client/components/PublishDialog.tsx +307 -0
- package/client/components/SettingsPage.tsx +452 -0
- package/client/components/Sidebar.tsx +198 -0
- package/client/components/ToolUseBlock.tsx +140 -0
- package/client/index.html +12 -0
- package/client/index.tsx +10 -0
- package/client/styles/globals.css +48 -0
- package/demo/01-welcome.png +0 -0
- package/demo/02-pipeline-cards.png +0 -0
- package/demo/03-custom-topic-fill.png +0 -0
- package/demo/04-topic-typed.png +0 -0
- package/demo/05-loading-state.png +0 -0
- package/demo/06-tool-calls.png +0 -0
- package/demo/07-history-rich.png +0 -0
- package/demo/09-en-cards.png +0 -0
- package/demo/10-ja-cards.png +0 -0
- package/demo/capture-remaining.mjs +73 -0
- package/demo/capture.mjs +110 -0
- package/demo/demo-walkthrough-2.webm +0 -0
- package/demo/demo-walkthrough.webm +0 -0
- package/package.json +48 -0
- package/postcss.config.js +6 -0
- package/scripts/threads_api.py +536 -0
- package/server/ai-client.ts +356 -0
- package/server/db.ts +299 -0
- package/server/mcp-config.ts +85 -0
- package/server/routes/accounts.ts +88 -0
- package/server/routes/files.ts +175 -0
- package/server/routes/publish.ts +77 -0
- package/server/routes/sessions.ts +59 -0
- package/server/routes/settings.ts +220 -0
- package/server/server.ts +261 -0
- package/server/services/social-publisher.ts +74 -0
- package/server/services/studio-mcp.ts +107 -0
- package/server/session.ts +167 -0
- package/server/types.ts +86 -0
- package/tailwind.config.js +8 -0
- package/tsconfig.json +16 -0
- package/vite.config.ts +19 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import { buildMcpServers, getSettings } from "./mcp-config.js";
|
|
3
|
+
import { createStudioMcpServer } from "./services/studio-mcp.js";
|
|
4
|
+
import store from "./db.js";
|
|
5
|
+
import type { Language, SocialAccount } from "./types.js";
|
|
6
|
+
|
|
7
|
+
const LANGUAGE_INSTRUCTIONS: Record<Language, string> = {
|
|
8
|
+
"zh-TW": `**語言規則(最高優先級)**:
|
|
9
|
+
你必須始終使用繁體中文(台灣用語)回覆使用者。
|
|
10
|
+
所有對話、解釋、摘要、工具使用說明都必須使用繁體中文。
|
|
11
|
+
程式碼內的變數名和註解可以用英文,但所有面向使用者的文字必須是繁體中文。`,
|
|
12
|
+
|
|
13
|
+
"en": `**Language Rule (highest priority)**:
|
|
14
|
+
You must always respond to the user in English.
|
|
15
|
+
All conversations, explanations, summaries, and tool usage descriptions must be in English.`,
|
|
16
|
+
|
|
17
|
+
"ja": `**言語ルール(最優先)**:
|
|
18
|
+
ユーザーには必ず日本語で回答してください。
|
|
19
|
+
すべての会話、説明、要約、ツール使用の説明は日本語で行ってください。
|
|
20
|
+
コード内の変数名やコメントは英語で構いませんが、ユーザー向けのテキストはすべて日本語にしてください。`,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Escape markdown-breaking chars and newlines in user-supplied strings */
|
|
24
|
+
function escMd(s: string): string {
|
|
25
|
+
return s.replace(/[|\\`*_{}[\]()#+\-!~>]/g, "\\$&").replace(/\n/g, " ");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildAccountsBlock(accounts: SocialAccount[]): string {
|
|
29
|
+
if (accounts.length === 0) {
|
|
30
|
+
return "No social accounts configured. User needs to add accounts in Settings first.";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const rows = accounts.map((a) =>
|
|
34
|
+
`| ${escMd(a.id)} | ${escMd(a.name)} | ${escMd(a.handle)} | ${escMd(a.platform)} | ${escMd(a.style || "-")} |`
|
|
35
|
+
).join("\n");
|
|
36
|
+
|
|
37
|
+
const personas = accounts
|
|
38
|
+
.filter((a) => a.persona_prompt)
|
|
39
|
+
.map((a) => `**${escMd(a.name)}** (${escMd(a.handle)}, ${escMd(a.platform)}): ${escMd(a.persona_prompt)}`)
|
|
40
|
+
.join("\n");
|
|
41
|
+
|
|
42
|
+
return `| ID | Name | Handle | Platform | Style |
|
|
43
|
+
|-----|------|--------|----------|-------|
|
|
44
|
+
${rows}
|
|
45
|
+
|
|
46
|
+
${personas ? `### Account Personas\n${personas}` : ""}
|
|
47
|
+
|
|
48
|
+
When publishing, adapt content tone and style based on each account's persona.
|
|
49
|
+
For matrix publishing (same topic, multiple accounts), generate unique content for EACH account based on their style.`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildSystemPrompt(language: Language, accounts: SocialAccount[]): string {
|
|
53
|
+
return `${LANGUAGE_INSTRUCTIONS[language]}
|
|
54
|
+
|
|
55
|
+
You are Claude World Studio assistant — an AI-powered content pipeline for trend discovery, deep research, and social publishing.
|
|
56
|
+
|
|
57
|
+
## MCP Tools Available
|
|
58
|
+
|
|
59
|
+
### 1. trend-pulse (12 tools — trends + content)
|
|
60
|
+
|
|
61
|
+
**Trend Data (5 tools):**
|
|
62
|
+
- **get_trending(sources, geo, count)**: Query ALL 20 free sources for real-time trends.
|
|
63
|
+
- sources: ALWAYS pass "" (empty) to use ALL 20 sources. Only filter if user explicitly asks.
|
|
64
|
+
- Sources: google_trends, hackernews, mastodon, bluesky, wikipedia, github, pypi, google_news, lobsters, devto, npm, reddit, coingecko, dockerhub, stackoverflow, producthunt, arxiv, lemmy, dcard, ptt
|
|
65
|
+
- geo: "TW"=Taiwan, "US"=USA, "JP"=Japan, ""=global
|
|
66
|
+
- count: Always 20
|
|
67
|
+
- **search_trends(query, sources, geo)**: Cross-source keyword search. sources="" for all.
|
|
68
|
+
- **list_sources()**: List all sources and their properties.
|
|
69
|
+
- **take_snapshot(sources, geo, count)**: Save snapshot to SQLite for velocity tracking.
|
|
70
|
+
- **get_trend_history(keyword, days, source)**: Historical data with direction (rising/stable/falling).
|
|
71
|
+
|
|
72
|
+
**Content Guide (5 tools):**
|
|
73
|
+
- **get_content_brief(topic)**: Writing brief with hook examples, patent strategies, CTA, scoring dimensions.
|
|
74
|
+
- **get_scoring_guide()**: 5-dimension patent scoring (based on 7 Meta patents): Hook Power 25%, Engagement Trigger 25%, Conversation Durability 20%, Velocity Potential 15%, Format Score 15%. Score ≥70 (B grade) required to publish.
|
|
75
|
+
- **get_platform_specs(platform)**: Platform specs: char limits, algorithm signals, best posting times.
|
|
76
|
+
- **get_review_checklist()**: Quality review checklist (7 checks) before publishing.
|
|
77
|
+
- **get_reel_guide()**: Reels script guide (tutorial/story/list — 3 styles).
|
|
78
|
+
|
|
79
|
+
**Threads Search (1 tool):**
|
|
80
|
+
- **search_threads_posts(query)**: Search Threads posts, sorted by heat score.
|
|
81
|
+
|
|
82
|
+
### 4. studio (2 tools — publishing + history, in-process)
|
|
83
|
+
- **publish_to_threads(text, account_id, score, image_url?, poll_options?, link_comment?, tag?)**: Publish to Threads via Graph API. Quality gate: score ≥ 70 required. Use account ID from the Social Accounts table below. Token is read from DB automatically.
|
|
84
|
+
- **get_publish_history(limit?)**: Query local publish records (no API token needed).
|
|
85
|
+
|
|
86
|
+
## Social Accounts
|
|
87
|
+
${buildAccountsBlock(accounts)}
|
|
88
|
+
|
|
89
|
+
### 2. cf-browser (10 tools — Cloudflare Browser Rendering)
|
|
90
|
+
Headless Chrome for JS-rendered pages. WebFetch only gets raw HTML — cf-browser renders the full page.
|
|
91
|
+
- **browser_markdown(url)**: Page content as clean Markdown. **Most used** — use this for deep research.
|
|
92
|
+
- **browser_content(url)**: Full rendered HTML.
|
|
93
|
+
- **browser_screenshot(url)**: Full page screenshot (PNG).
|
|
94
|
+
- **browser_pdf(url)**: Generate PDF.
|
|
95
|
+
- **browser_scrape(url, selector)**: CSS selector to extract specific elements.
|
|
96
|
+
- **browser_json(url, schema)**: AI-driven structured data extraction.
|
|
97
|
+
- **browser_links(url)**: Extract all hyperlinks from a page.
|
|
98
|
+
- **browser_a11y(url)**: Accessibility tree (low token cost — use when you only need text structure).
|
|
99
|
+
- **browser_crawl(url)**: Async multi-page crawl.
|
|
100
|
+
- **browser_crawl_status(id)**: Check crawl progress.
|
|
101
|
+
|
|
102
|
+
### 3. notebooklm (13 tools — Research + Artifact Generation)
|
|
103
|
+
NotebookLM does deep research, Claude writes content. Supports 10 artifact types.
|
|
104
|
+
|
|
105
|
+
**Notebook Management (3 tools):**
|
|
106
|
+
- **create_notebook(title, sources?, text_sources?)**: Create notebook. sources=list of URLs/YouTube. text_sources=list of raw text.
|
|
107
|
+
- **list_notebooks()**: List all notebooks.
|
|
108
|
+
- **delete_notebook(name_or_id)**: Delete a notebook by name or ID.
|
|
109
|
+
|
|
110
|
+
**Source & Research (5 tools):**
|
|
111
|
+
- **add_source(name_or_id, url?, text?, pdf_path?)**: Add URL, text, YouTube, or PDF to notebook.
|
|
112
|
+
- **ask(name_or_id, query)**: Ask a question against notebook sources. Returns answer with citations.
|
|
113
|
+
- **summarize(name_or_id)**: Generate a summary of notebook content.
|
|
114
|
+
- **list_sources(name_or_id)**: List all sources in a notebook.
|
|
115
|
+
- **research(name_or_id, query, mode)**: Deep research. mode="fast" or "thorough".
|
|
116
|
+
|
|
117
|
+
**Artifact Generation (2 tools):**
|
|
118
|
+
- **generate_artifact(name_or_id, artifact_type, lang?)**: Generate an artifact. Types: podcast (M4A), slides (PDF), report, quiz, flashcards, mindmap, infographic, datasheet, study_guide.
|
|
119
|
+
- **download_artifact(name_or_id, artifact_type, output_path)**: Download generated artifact.
|
|
120
|
+
|
|
121
|
+
**IMPORTANT: Video Synthesis (slides + podcast → MP4)**
|
|
122
|
+
NotebookLM does NOT produce usable video directly. To create a video:
|
|
123
|
+
1. Generate \`slides\` (PDF) + \`audio\` (podcast M4A) — run these sequentially, NOT in parallel (MCP is single-connection)
|
|
124
|
+
2. Download both artifacts to local files
|
|
125
|
+
3. Convert PDF pages to images: \`pdftoppm -png -r 300 slides.pdf slides_page\`
|
|
126
|
+
4. Combine with ffmpeg:
|
|
127
|
+
\`\`\`bash
|
|
128
|
+
# Calculate duration per slide: total_audio_duration / num_slides
|
|
129
|
+
duration=$(ffprobe -v error -show_entries format=duration -of csv=p=0 audio.mp3)
|
|
130
|
+
num_slides=$(ls slides_page-*.png | wc -l)
|
|
131
|
+
per_slide=$(echo "$duration / $num_slides" | bc -l)
|
|
132
|
+
# Create video with crossfade transitions
|
|
133
|
+
ffmpeg -framerate 1/$per_slide -pattern_type glob -i 'slides_page-*.png' -i audio.mp3 -c:v libx264 -profile:v baseline -pix_fmt yuv420p -c:a aac -shortest output.mp4
|
|
134
|
+
\`\`\`
|
|
135
|
+
5. Always include the **full absolute path** of the output MP4 in your response.
|
|
136
|
+
|
|
137
|
+
**Pipelines (3 tools):**
|
|
138
|
+
- **research_pipeline(sources, questions, output_format?)**: Full pipeline: create notebook → add sources → research questions → generate output. output_format: "article", "threads", "newsletter".
|
|
139
|
+
- **trend_research(geo?, count?, platform?)**: Auto-discover trending topics → research → generate content. Integrates with trend-pulse.
|
|
140
|
+
- Use these for one-shot "research this topic and give me content" workflows.
|
|
141
|
+
|
|
142
|
+
## Current Date
|
|
143
|
+
Today is ${new Date().toISOString().split("T")[0]}. Always be aware of the current date when evaluating data freshness.
|
|
144
|
+
|
|
145
|
+
## Important Rules
|
|
146
|
+
- **Use ALL sources**: When calling get_trending, pass sources="" (empty string) to query ALL 20 sources. Do NOT filter to just 3-4 sources.
|
|
147
|
+
- For ANY question about trends, news, or what's popular: ALWAYS use trend-pulse tools first. Do NOT rely on your training data.
|
|
148
|
+
- For web content analysis: use cf-browser tools (browser_markdown for content, browser_screenshot for visuals).
|
|
149
|
+
- For research requests: combine trend-pulse + cf-browser (browser_markdown) + WebSearch for comprehensive results.
|
|
150
|
+
- **File paths**: When you create, download, or save ANY file, ALWAYS include the **full absolute file path** in backticks so the UI can make it clickable for preview.
|
|
151
|
+
|
|
152
|
+
## MANDATORY: Read Original Sources (來源充足性)
|
|
153
|
+
**NEVER write content based on titles/metadata alone.** For every topic you write about:
|
|
154
|
+
- **Single topic**: Read at least 1 primary source (original article/announcement/README) via \`browser_markdown(url)\`
|
|
155
|
+
- **Controversial topic**: Read at least 2 sources (both sides)
|
|
156
|
+
- **Data claims**: Find the original data source (no second-hand citations)
|
|
157
|
+
- Source types: Article → \`browser_markdown(url)\`, HN → original + top comments, GitHub → full README
|
|
158
|
+
- **Exception**: Only skip if user explicitly says "this is the original, no need to verify"
|
|
159
|
+
|
|
160
|
+
## MANDATORY: Timeline Verification (資訊新鮮度)
|
|
161
|
+
**Every fact must have a verified timestamp.** After getting trend data:
|
|
162
|
+
1. Check ALL timestamps. Discard anything older than 48 hours.
|
|
163
|
+
2. For each fact, note the published date from the original source.
|
|
164
|
+
3. Use the correct time words based on age:
|
|
165
|
+
|
|
166
|
+
| Source age | Allowed words | Forbidden words |
|
|
167
|
+
|---|---|---|
|
|
168
|
+
| Today | 「今天」「剛剛」 | — |
|
|
169
|
+
| 1-3 days ago | 「這兩天」「前幾天」 | 「剛」「最新」 |
|
|
170
|
+
| 4-7 days ago | 「上週」「這週」 | 「剛」「昨天」 |
|
|
171
|
+
| 8-30 days ago | 「最近」「這個月」 | 「上週」「剛」 |
|
|
172
|
+
| >30 days | 「之前」「今年」 | Any freshness-implying words |
|
|
173
|
+
|
|
174
|
+
4. For changing metrics (stars, upvotes): use 「超過 X」 not exact numbers.
|
|
175
|
+
5. Version numbers: MUST match original source exactly.
|
|
176
|
+
6. If trend data looks stale or has no timestamps, verify via WebSearch or browser_markdown.
|
|
177
|
+
|
|
178
|
+
## MANDATORY: Meta Patent-Based Content Optimization (流量專利)
|
|
179
|
+
When writing social posts, check ALL 5 dimensions from Meta's ranking patents:
|
|
180
|
+
|
|
181
|
+
| # | Check | Patent | Requirement | Fix if fails |
|
|
182
|
+
|---|---|---|---|---|
|
|
183
|
+
| 1 | Hook: first line has number or contrast? | EdgeRank | 10-45 chars, number-first or curiosity gap | Move key data to first line |
|
|
184
|
+
| 2 | CTA: anyone can answer? | Dear Algo | Direct 「你」address, low-barrier question | Remove assumed expertise |
|
|
185
|
+
| 3 | Has contrast/both sides? | 72hr window | 「但是」「不過」creates discussion space | Add limitation/controversy/beta angle |
|
|
186
|
+
| 4 | Timely hot topic? Short enough? | Andromeda | 50-300 chars, urgency language | Delete filler, add time markers |
|
|
187
|
+
| 5 | Mobile-scannable? | Multi-modal | Line breaks, arrow lists, no text walls | Split long sentences, add separators |
|
|
188
|
+
|
|
189
|
+
**Quality Gates (ALL must pass before publishing):**
|
|
190
|
+
- Overall Score ≥ 70
|
|
191
|
+
- **Conversation Durability ≥ 55** (most commonly missed — add 轉折/爭議面 if below)
|
|
192
|
+
- Hook: 10-45 chars with number or contrast
|
|
193
|
+
- CTA: clear question or poll
|
|
194
|
+
- Timeline: all time words verified per table above
|
|
195
|
+
- Source: every claim traceable to original source
|
|
196
|
+
- Character limit: Threads ≤500, IG ≤2200
|
|
197
|
+
- 台灣繁中語氣 (no 簡體/AI filler like 在當今/隨著/值得注意)
|
|
198
|
+
|
|
199
|
+
## Publishing Rules
|
|
200
|
+
- **Publishing flow**: get_content_brief → write → get_scoring_guide (self-score) → get_review_checklist → publish_to_threads
|
|
201
|
+
- **Polls**: ALWAYS use \`--poll\` for A/B/C options, NEVER text-based polls in post body
|
|
202
|
+
- **Links**: NEVER put URLs in post body (kills reach). Use \`--link-comment\` to auto-reply with link.
|
|
203
|
+
- **Tags**: Use \`tag\` for topic categorization (no # prefix, one per post).
|
|
204
|
+
- **Optimal times**: Threads 21:00 → IG 12:00 next day
|
|
205
|
+
|
|
206
|
+
## Full Pipeline Workflow
|
|
207
|
+
1. **Discover**: get_trending(sources="", geo, count=20) — ALL 20 sources
|
|
208
|
+
2. **Read Source**: browser_markdown(url) for each candidate — MANDATORY, never skip
|
|
209
|
+
3. **Verify Timeline**: Check dates, map to time words, discard stale data
|
|
210
|
+
4. **Research**: browser_markdown + WebSearch for deep dive, read 2-3 sources minimum
|
|
211
|
+
5. **Brief**: get_content_brief(topic) for writing strategy
|
|
212
|
+
6. **Create**: Write content → patent check (5 dimensions) → format check
|
|
213
|
+
7. **Score**: get_scoring_guide — self-score (must ≥ 70, Conversation Durability ≥ 55)
|
|
214
|
+
8. **Review**: get_review_checklist — final quality check, remove AI filler
|
|
215
|
+
9. **NotebookLM** (if requested): create notebook → generate slides → generate audio → download both → synthesize MP4 (pdftoppm + ffmpeg)
|
|
216
|
+
10. **Publish**: mcp__studio__publish_to_threads(text, account_id, score)
|
|
217
|
+
|
|
218
|
+
Be concise but thorough. Explain which tools you're using and why.`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
type UserMessage = {
|
|
222
|
+
type: "user";
|
|
223
|
+
message: { role: "user"; content: string };
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
class QueueClosedError extends Error {
|
|
227
|
+
constructor() {
|
|
228
|
+
super("Queue closed");
|
|
229
|
+
this.name = "QueueClosedError";
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
class MessageQueue {
|
|
234
|
+
private messages: UserMessage[] = [];
|
|
235
|
+
private waiting: {
|
|
236
|
+
resolve: (msg: UserMessage) => void;
|
|
237
|
+
reject: (err: Error) => void;
|
|
238
|
+
} | null = null;
|
|
239
|
+
private closed = false;
|
|
240
|
+
|
|
241
|
+
push(content: string) {
|
|
242
|
+
if (this.closed) return;
|
|
243
|
+
|
|
244
|
+
const msg: UserMessage = {
|
|
245
|
+
type: "user",
|
|
246
|
+
message: { role: "user", content },
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
if (this.waiting) {
|
|
250
|
+
this.waiting.resolve(msg);
|
|
251
|
+
this.waiting = null;
|
|
252
|
+
} else {
|
|
253
|
+
this.messages.push(msg);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async *[Symbol.asyncIterator](): AsyncIterableIterator<UserMessage> {
|
|
258
|
+
while (!this.closed) {
|
|
259
|
+
if (this.messages.length > 0) {
|
|
260
|
+
yield this.messages.shift()!;
|
|
261
|
+
} else {
|
|
262
|
+
try {
|
|
263
|
+
yield await new Promise<UserMessage>((resolve, reject) => {
|
|
264
|
+
this.waiting = { resolve, reject };
|
|
265
|
+
});
|
|
266
|
+
} catch (err) {
|
|
267
|
+
if (err instanceof QueueClosedError) break;
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
close() {
|
|
275
|
+
this.closed = true;
|
|
276
|
+
if (this.waiting) {
|
|
277
|
+
this.waiting.reject(new QueueClosedError());
|
|
278
|
+
this.waiting = null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export class AgentSession {
|
|
284
|
+
private queue = new MessageQueue();
|
|
285
|
+
private outputIterator: AsyncIterator<any> | null = null;
|
|
286
|
+
private abortController = new AbortController();
|
|
287
|
+
|
|
288
|
+
constructor(workspacePath?: string, language?: Language) {
|
|
289
|
+
const settings = getSettings();
|
|
290
|
+
const mcpServers = buildMcpServers(settings);
|
|
291
|
+
const cwd = workspacePath || settings.defaultWorkspace || process.cwd();
|
|
292
|
+
const lang = language || settings.language || "zh-TW";
|
|
293
|
+
const accounts = store.getAllAccounts();
|
|
294
|
+
|
|
295
|
+
// In-process Studio MCP server (direct DB access, no env vars)
|
|
296
|
+
const studioServer = createStudioMcpServer();
|
|
297
|
+
|
|
298
|
+
const allowedTools = [
|
|
299
|
+
"Bash",
|
|
300
|
+
"Read",
|
|
301
|
+
"Write",
|
|
302
|
+
"Edit",
|
|
303
|
+
"Glob",
|
|
304
|
+
"Grep",
|
|
305
|
+
"WebSearch",
|
|
306
|
+
"WebFetch",
|
|
307
|
+
];
|
|
308
|
+
|
|
309
|
+
// Add MCP tool patterns so MCP tools are accessible
|
|
310
|
+
const allMcpServers: Record<string, any> = { ...mcpServers, studio: studioServer };
|
|
311
|
+
const mcpServerNames = Object.keys(allMcpServers);
|
|
312
|
+
for (const name of mcpServerNames) {
|
|
313
|
+
allowedTools.push(`mcp__${name}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const options: Record<string, any> = {
|
|
317
|
+
maxTurns: 200,
|
|
318
|
+
model: "sonnet",
|
|
319
|
+
// bypassPermissions: intentional — local single-user tool.
|
|
320
|
+
// All tool calls execute without prompting. Do NOT expose to untrusted networks.
|
|
321
|
+
permissionMode: "bypassPermissions",
|
|
322
|
+
abortController: this.abortController,
|
|
323
|
+
systemPrompt: buildSystemPrompt(lang, accounts),
|
|
324
|
+
cwd,
|
|
325
|
+
allowedTools,
|
|
326
|
+
// Prevent confusion: trend-pulse publish tool is superseded by studio
|
|
327
|
+
disallowedTools: ["mcp__trend-pulse__publish_to_threads", "mcp__trend-pulse__get_publish_history"],
|
|
328
|
+
mcpServers: allMcpServers,
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
this.outputIterator = query({
|
|
332
|
+
prompt: this.queue as any,
|
|
333
|
+
options,
|
|
334
|
+
})[Symbol.asyncIterator]();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
sendMessage(content: string) {
|
|
338
|
+
this.queue.push(content);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async *getOutputStream() {
|
|
342
|
+
if (!this.outputIterator) {
|
|
343
|
+
throw new Error("Session not initialized");
|
|
344
|
+
}
|
|
345
|
+
while (true) {
|
|
346
|
+
const { value, done } = await this.outputIterator.next();
|
|
347
|
+
if (done) break;
|
|
348
|
+
yield value;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
close() {
|
|
353
|
+
this.abortController.abort();
|
|
354
|
+
this.queue.close();
|
|
355
|
+
}
|
|
356
|
+
}
|
package/server/db.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { v4 as uuidv4 } from "uuid";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import type { Session, Message, PublishRecord, SocialAccount } from "./types.js";
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
|
|
10
|
+
const DB_PATH = path.join(__dirname, "../data/studio.db");
|
|
11
|
+
|
|
12
|
+
const db = new Database(DB_PATH);
|
|
13
|
+
|
|
14
|
+
// Enable WAL mode for better concurrent read performance
|
|
15
|
+
db.pragma("journal_mode = WAL");
|
|
16
|
+
|
|
17
|
+
// Create tables
|
|
18
|
+
db.exec(`
|
|
19
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
title TEXT NOT NULL DEFAULT 'New Session',
|
|
22
|
+
workspace_path TEXT NOT NULL,
|
|
23
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
24
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
25
|
+
status TEXT DEFAULT 'active'
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
29
|
+
id TEXT PRIMARY KEY,
|
|
30
|
+
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
31
|
+
role TEXT NOT NULL,
|
|
32
|
+
content TEXT,
|
|
33
|
+
tool_name TEXT,
|
|
34
|
+
tool_id TEXT,
|
|
35
|
+
tool_input TEXT,
|
|
36
|
+
cost_usd REAL,
|
|
37
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
41
|
+
key TEXT PRIMARY KEY,
|
|
42
|
+
value TEXT NOT NULL
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
CREATE TABLE IF NOT EXISTS publish_history (
|
|
46
|
+
id TEXT PRIMARY KEY,
|
|
47
|
+
session_id TEXT REFERENCES sessions(id),
|
|
48
|
+
platform TEXT NOT NULL,
|
|
49
|
+
account TEXT NOT NULL,
|
|
50
|
+
content TEXT NOT NULL,
|
|
51
|
+
post_id TEXT,
|
|
52
|
+
post_url TEXT,
|
|
53
|
+
status TEXT DEFAULT 'pending',
|
|
54
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
CREATE TABLE IF NOT EXISTS social_accounts (
|
|
58
|
+
id TEXT PRIMARY KEY,
|
|
59
|
+
name TEXT NOT NULL,
|
|
60
|
+
handle TEXT NOT NULL,
|
|
61
|
+
platform TEXT NOT NULL,
|
|
62
|
+
token TEXT NOT NULL DEFAULT '',
|
|
63
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
64
|
+
style TEXT NOT NULL DEFAULT '',
|
|
65
|
+
persona_prompt TEXT NOT NULL DEFAULT '',
|
|
66
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
67
|
+
);
|
|
68
|
+
`);
|
|
69
|
+
|
|
70
|
+
// Social accounts are configured via the Settings UI — no hardcoded accounts.
|
|
71
|
+
|
|
72
|
+
// Prepared statements
|
|
73
|
+
const stmts = {
|
|
74
|
+
// Sessions
|
|
75
|
+
createSession: db.prepare(
|
|
76
|
+
`INSERT INTO sessions (id, title, workspace_path) VALUES (?, ?, ?)`
|
|
77
|
+
),
|
|
78
|
+
getSession: db.prepare(`SELECT * FROM sessions WHERE id = ? AND status = 'active'`),
|
|
79
|
+
getAllSessions: db.prepare(
|
|
80
|
+
`SELECT * FROM sessions WHERE status = 'active' ORDER BY updated_at DESC`
|
|
81
|
+
),
|
|
82
|
+
updateSessionTitle: db.prepare(
|
|
83
|
+
`UPDATE sessions SET title = ?, updated_at = datetime('now') WHERE id = ?`
|
|
84
|
+
),
|
|
85
|
+
updateSessionTimestamp: db.prepare(
|
|
86
|
+
`UPDATE sessions SET updated_at = datetime('now') WHERE id = ?`
|
|
87
|
+
),
|
|
88
|
+
deleteSession: db.prepare(
|
|
89
|
+
`UPDATE sessions SET status = 'deleted', updated_at = datetime('now') WHERE id = ?`
|
|
90
|
+
),
|
|
91
|
+
|
|
92
|
+
// Messages
|
|
93
|
+
addMessage: db.prepare(
|
|
94
|
+
`INSERT INTO messages (id, session_id, role, content, tool_name, tool_id, tool_input, cost_usd)
|
|
95
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
96
|
+
),
|
|
97
|
+
getMessages: db.prepare(
|
|
98
|
+
`SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC`
|
|
99
|
+
),
|
|
100
|
+
|
|
101
|
+
// Settings
|
|
102
|
+
getSetting: db.prepare(`SELECT value FROM settings WHERE key = ?`),
|
|
103
|
+
setSetting: db.prepare(
|
|
104
|
+
`INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)`
|
|
105
|
+
),
|
|
106
|
+
getAllSettings: db.prepare(`SELECT * FROM settings`),
|
|
107
|
+
|
|
108
|
+
// Publish History
|
|
109
|
+
addPublish: db.prepare(
|
|
110
|
+
`INSERT INTO publish_history (id, session_id, platform, account, content, post_id, post_url, status)
|
|
111
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
112
|
+
),
|
|
113
|
+
getPublishHistory: db.prepare(
|
|
114
|
+
`SELECT * FROM publish_history ORDER BY created_at DESC LIMIT ?`
|
|
115
|
+
),
|
|
116
|
+
updatePublishStatus: db.prepare(
|
|
117
|
+
`UPDATE publish_history SET status = ?, post_id = ?, post_url = ? WHERE id = ?`
|
|
118
|
+
),
|
|
119
|
+
|
|
120
|
+
// Social Accounts
|
|
121
|
+
createAccount: db.prepare(
|
|
122
|
+
`INSERT INTO social_accounts (id, name, handle, platform, token, user_id, style, persona_prompt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
123
|
+
),
|
|
124
|
+
getAccount: db.prepare(`SELECT * FROM social_accounts WHERE id = ?`),
|
|
125
|
+
getAllAccounts: db.prepare(`SELECT * FROM social_accounts ORDER BY created_at ASC`),
|
|
126
|
+
getAccountsByPlatform: db.prepare(`SELECT * FROM social_accounts WHERE platform = ? ORDER BY created_at ASC`),
|
|
127
|
+
updateAccount: db.prepare(
|
|
128
|
+
`UPDATE social_accounts SET name = ?, handle = ?, platform = ?, user_id = ?, style = ?, persona_prompt = ? WHERE id = ?`
|
|
129
|
+
),
|
|
130
|
+
updateAccountToken: db.prepare(
|
|
131
|
+
`UPDATE social_accounts SET token = ? WHERE id = ?`
|
|
132
|
+
),
|
|
133
|
+
deleteAccount: db.prepare(`DELETE FROM social_accounts WHERE id = ?`),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const store = {
|
|
137
|
+
// Sessions
|
|
138
|
+
createSession(title?: string, workspacePath?: string): Session {
|
|
139
|
+
const id = uuidv4();
|
|
140
|
+
const ws = workspacePath || process.env.DEFAULT_WORKSPACE || process.cwd();
|
|
141
|
+
stmts.createSession.run(id, title || "New Session", ws);
|
|
142
|
+
return stmts.getSession.get(id) as Session;
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
getSession(id: string): Session | undefined {
|
|
146
|
+
return stmts.getSession.get(id) as Session | undefined;
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
getAllSessions(): Session[] {
|
|
150
|
+
return stmts.getAllSessions.all() as Session[];
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
updateSessionTitle(id: string, title: string) {
|
|
154
|
+
stmts.updateSessionTitle.run(title, id);
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
deleteSession(id: string): boolean {
|
|
158
|
+
const result = stmts.deleteSession.run(id);
|
|
159
|
+
return result.changes > 0;
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
// Messages
|
|
163
|
+
addMessage(
|
|
164
|
+
sessionId: string,
|
|
165
|
+
msg: {
|
|
166
|
+
role: string;
|
|
167
|
+
content?: string | null;
|
|
168
|
+
tool_name?: string | null;
|
|
169
|
+
tool_id?: string | null;
|
|
170
|
+
tool_input?: string | null;
|
|
171
|
+
cost_usd?: number | null;
|
|
172
|
+
}
|
|
173
|
+
): Message {
|
|
174
|
+
const id = uuidv4();
|
|
175
|
+
stmts.addMessage.run(
|
|
176
|
+
id, sessionId, msg.role,
|
|
177
|
+
msg.content ?? null, msg.tool_name ?? null,
|
|
178
|
+
msg.tool_id ?? null, msg.tool_input ?? null,
|
|
179
|
+
msg.cost_usd ?? null
|
|
180
|
+
);
|
|
181
|
+
stmts.updateSessionTimestamp.run(sessionId);
|
|
182
|
+
|
|
183
|
+
// Auto-generate title from first user message
|
|
184
|
+
const session = stmts.getSession.get(sessionId) as Session | undefined;
|
|
185
|
+
if (session && session.title === "New Session" && msg.role === "user" && msg.content) {
|
|
186
|
+
const title = msg.content.slice(0, 50) + (msg.content.length > 50 ? "..." : "");
|
|
187
|
+
stmts.updateSessionTitle.run(title, sessionId);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
id,
|
|
192
|
+
session_id: sessionId,
|
|
193
|
+
role: msg.role as Message["role"],
|
|
194
|
+
content: msg.content ?? null,
|
|
195
|
+
tool_name: msg.tool_name ?? null,
|
|
196
|
+
tool_id: msg.tool_id ?? null,
|
|
197
|
+
tool_input: msg.tool_input ?? null,
|
|
198
|
+
cost_usd: msg.cost_usd ?? null,
|
|
199
|
+
created_at: new Date().toISOString(),
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
getMessages(sessionId: string): Message[] {
|
|
204
|
+
return stmts.getMessages.all(sessionId) as Message[];
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
// Settings
|
|
208
|
+
getSetting(key: string): string | undefined {
|
|
209
|
+
const row = stmts.getSetting.get(key) as { value: string } | undefined;
|
|
210
|
+
return row?.value;
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
setSetting(key: string, value: string) {
|
|
214
|
+
stmts.setSetting.run(key, value);
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
getAllSettings(): Record<string, string> {
|
|
218
|
+
const rows = stmts.getAllSettings.all() as { key: string; value: string }[];
|
|
219
|
+
const result: Record<string, string> = {};
|
|
220
|
+
for (const row of rows) {
|
|
221
|
+
result[row.key] = row.value;
|
|
222
|
+
}
|
|
223
|
+
return result;
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
// Publish History
|
|
227
|
+
addPublish(record: Omit<PublishRecord, "id" | "created_at">): PublishRecord {
|
|
228
|
+
const id = uuidv4();
|
|
229
|
+
stmts.addPublish.run(
|
|
230
|
+
id, record.session_id, record.platform, record.account,
|
|
231
|
+
record.content, record.post_id, record.post_url, record.status
|
|
232
|
+
);
|
|
233
|
+
return { id, created_at: new Date().toISOString(), ...record };
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
getPublishHistory(limit = 50): PublishRecord[] {
|
|
237
|
+
return stmts.getPublishHistory.all(limit) as PublishRecord[];
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
updatePublishStatus(id: string, status: string, postId?: string, postUrl?: string) {
|
|
241
|
+
stmts.updatePublishStatus.run(status, postId ?? null, postUrl ?? null, id);
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
// Social Accounts
|
|
245
|
+
createAccount(data: {
|
|
246
|
+
name: string;
|
|
247
|
+
handle: string;
|
|
248
|
+
platform: string;
|
|
249
|
+
token?: string;
|
|
250
|
+
user_id?: string;
|
|
251
|
+
style?: string;
|
|
252
|
+
persona_prompt?: string;
|
|
253
|
+
}): SocialAccount {
|
|
254
|
+
const id = uuidv4();
|
|
255
|
+
stmts.createAccount.run(
|
|
256
|
+
id, data.name, data.handle, data.platform,
|
|
257
|
+
data.token || "", data.user_id || "",
|
|
258
|
+
data.style || "", data.persona_prompt || ""
|
|
259
|
+
);
|
|
260
|
+
return stmts.getAccount.get(id) as SocialAccount;
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
getAccount(id: string): SocialAccount | undefined {
|
|
264
|
+
return stmts.getAccount.get(id) as SocialAccount | undefined;
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
getAllAccounts(): SocialAccount[] {
|
|
268
|
+
return stmts.getAllAccounts.all() as SocialAccount[];
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
getAccountsByPlatform(platform: string): SocialAccount[] {
|
|
272
|
+
return stmts.getAccountsByPlatform.all(platform) as SocialAccount[];
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
updateAccount(id: string, data: {
|
|
276
|
+
name: string;
|
|
277
|
+
handle: string;
|
|
278
|
+
platform: string;
|
|
279
|
+
user_id: string;
|
|
280
|
+
style: string;
|
|
281
|
+
persona_prompt: string;
|
|
282
|
+
}) {
|
|
283
|
+
stmts.updateAccount.run(
|
|
284
|
+
data.name, data.handle, data.platform,
|
|
285
|
+
data.user_id, data.style, data.persona_prompt, id
|
|
286
|
+
);
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
updateAccountToken(id: string, token: string) {
|
|
290
|
+
stmts.updateAccountToken.run(token, id);
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
deleteAccount(id: string): boolean {
|
|
294
|
+
const result = stmts.deleteAccount.run(id);
|
|
295
|
+
return result.changes > 0;
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
export default store;
|