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.
Files changed (46) hide show
  1. package/.env.example +30 -0
  2. package/.mcp.json +51 -0
  3. package/README.md +224 -0
  4. package/client/App.tsx +446 -0
  5. package/client/components/ChatWindow.tsx +790 -0
  6. package/client/components/FileExplorer.tsx +218 -0
  7. package/client/components/FilePreviewModal.tsx +179 -0
  8. package/client/components/PublishDialog.tsx +307 -0
  9. package/client/components/SettingsPage.tsx +452 -0
  10. package/client/components/Sidebar.tsx +198 -0
  11. package/client/components/ToolUseBlock.tsx +140 -0
  12. package/client/index.html +12 -0
  13. package/client/index.tsx +10 -0
  14. package/client/styles/globals.css +48 -0
  15. package/demo/01-welcome.png +0 -0
  16. package/demo/02-pipeline-cards.png +0 -0
  17. package/demo/03-custom-topic-fill.png +0 -0
  18. package/demo/04-topic-typed.png +0 -0
  19. package/demo/05-loading-state.png +0 -0
  20. package/demo/06-tool-calls.png +0 -0
  21. package/demo/07-history-rich.png +0 -0
  22. package/demo/09-en-cards.png +0 -0
  23. package/demo/10-ja-cards.png +0 -0
  24. package/demo/capture-remaining.mjs +73 -0
  25. package/demo/capture.mjs +110 -0
  26. package/demo/demo-walkthrough-2.webm +0 -0
  27. package/demo/demo-walkthrough.webm +0 -0
  28. package/package.json +48 -0
  29. package/postcss.config.js +6 -0
  30. package/scripts/threads_api.py +536 -0
  31. package/server/ai-client.ts +356 -0
  32. package/server/db.ts +299 -0
  33. package/server/mcp-config.ts +85 -0
  34. package/server/routes/accounts.ts +88 -0
  35. package/server/routes/files.ts +175 -0
  36. package/server/routes/publish.ts +77 -0
  37. package/server/routes/sessions.ts +59 -0
  38. package/server/routes/settings.ts +220 -0
  39. package/server/server.ts +261 -0
  40. package/server/services/social-publisher.ts +74 -0
  41. package/server/services/studio-mcp.ts +107 -0
  42. package/server/session.ts +167 -0
  43. package/server/types.ts +86 -0
  44. package/tailwind.config.js +8 -0
  45. package/tsconfig.json +16 -0
  46. 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;