openwriter 0.15.0 → 0.17.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 (40) hide show
  1. package/dist/client/assets/index-0ttVnjRp.css +1 -0
  2. package/dist/client/assets/{index-B5MXw2pg.js → index-BZ7LCzrR.js} +64 -64
  3. package/dist/client/index.html +2 -2
  4. package/dist/plugins/authors-voice/dist/index.d.ts +41 -0
  5. package/dist/plugins/authors-voice/dist/index.js +206 -0
  6. package/dist/plugins/authors-voice/package.json +23 -0
  7. package/dist/plugins/image-gen/dist/index.d.ts +35 -0
  8. package/dist/plugins/image-gen/dist/index.js +141 -0
  9. package/dist/plugins/image-gen/package.json +26 -0
  10. package/dist/plugins/publish/dist/helpers.d.ts +66 -0
  11. package/dist/plugins/publish/dist/helpers.js +199 -0
  12. package/dist/plugins/publish/dist/index.d.ts +3 -0
  13. package/dist/plugins/publish/dist/index.js +1130 -0
  14. package/dist/plugins/publish/dist/newsletter-tools.d.ts +2 -0
  15. package/dist/plugins/publish/dist/newsletter-tools.js +394 -0
  16. package/dist/plugins/publish/package.json +31 -0
  17. package/dist/plugins/x-api/dist/index.d.ts +27 -0
  18. package/dist/plugins/x-api/dist/index.js +240 -0
  19. package/dist/plugins/x-api/package.json +27 -0
  20. package/dist/server/compact.js +28 -2
  21. package/dist/server/documents.js +234 -3
  22. package/dist/server/enrichment.js +125 -0
  23. package/dist/server/export-routes.js +2 -0
  24. package/dist/server/install-skill.js +15 -0
  25. package/dist/server/markdown-parse.js +153 -14
  26. package/dist/server/markdown-serialize.js +100 -17
  27. package/dist/server/mcp.js +291 -25
  28. package/dist/server/node-blocks.js +41 -1
  29. package/dist/server/node-fingerprint.js +347 -73
  30. package/dist/server/node-matcher.js +19 -44
  31. package/dist/server/pending-overlay.js +21 -4
  32. package/dist/server/state.js +225 -41
  33. package/dist/server/workspaces.js +27 -5
  34. package/dist/server/ws.js +10 -0
  35. package/package.json +2 -1
  36. package/skill/SKILL.md +38 -7
  37. package/skill/agents/openwriter-enrichment-minion.md +177 -0
  38. package/skill/docs/enrichment.md +179 -0
  39. package/skill/docs/footnotes.md +178 -0
  40. package/dist/client/assets/index-B3iORmCT.css +0 -1
@@ -0,0 +1,1130 @@
1
+ import { getServerModules, publishFetch } from './helpers.js';
2
+ import { newsletterTools } from './newsletter-tools.js';
3
+ import { readFileSync, existsSync } from 'fs';
4
+ import { join, extname } from 'path';
5
+ /** Extract docId from raw markdown frontmatter (JSON or YAML) */
6
+ function extractDocId(rawContent) {
7
+ // JSON frontmatter: "docId":"abc123"
8
+ const jsonMatch = rawContent.match(/"docId"\s*:\s*"([^"]+)"/);
9
+ if (jsonMatch)
10
+ return jsonMatch[1];
11
+ // YAML frontmatter: docId: abc123
12
+ const yamlMatch = rawContent.match(/^docId:\s*["']?(\S+?)["']?\s*$/m);
13
+ if (yamlMatch)
14
+ return yamlMatch[1];
15
+ return null;
16
+ }
17
+ /** Map transform action to variant content type */
18
+ const ACTION_VARIANT_TYPE = {
19
+ vary: 'document',
20
+ shrinkify: 'document',
21
+ expandify: 'document',
22
+ threadify: 'tweet',
23
+ storify: 'document',
24
+ emailify: 'newsletter',
25
+ postify: 'tweet',
26
+ };
27
+ /** Simple HTML → markdown conversion for document creation */
28
+ function htmlToMarkdown(html) {
29
+ let md = html;
30
+ md = md.replace(/<hr\s*\/?>/gi, '\n---\n');
31
+ md = md.replace(/<br\s*\/?>/gi, '\n');
32
+ md = md.replace(/<(strong|b)>([\s\S]*?)<\/\1>/gi, '**$2**');
33
+ md = md.replace(/<(em|i)>([\s\S]*?)<\/\1>/gi, '*$2*');
34
+ md = md.replace(/<p[^>]*>/gi, '');
35
+ md = md.replace(/<\/p>/gi, '\n\n');
36
+ md = md.replace(/<[^>]+>/g, '');
37
+ md = md.replace(/\n{3,}/g, '\n\n');
38
+ return md.trim();
39
+ }
40
+ const plugin = {
41
+ name: '@openwriter/plugin-publish',
42
+ version: '0.1.0',
43
+ description: 'OpenWriter Publish — newsletter, custom domains, publishing',
44
+ category: 'publishing',
45
+ configSchema: {
46
+ 'api-url': {
47
+ type: 'string',
48
+ description: 'Publish API URL',
49
+ env: 'PUBLISH_API_URL',
50
+ },
51
+ 'api-key': {
52
+ type: 'string',
53
+ required: true,
54
+ description: 'API key (ow_live_...)',
55
+ env: 'PUBLISH_API_KEY',
56
+ },
57
+ },
58
+ mcpTools(config) {
59
+ const baseUrl = config['api-url'] || 'https://publish.openwriter.io';
60
+ return [
61
+ {
62
+ name: 'request_login_code',
63
+ description: 'Request a 6-digit verification code sent to the given email. First step of authentication — works for both new signups and key recovery.',
64
+ inputSchema: {
65
+ type: 'object',
66
+ properties: {
67
+ email: { type: 'string', description: 'Email address to send the verification code to' },
68
+ },
69
+ required: ['email'],
70
+ },
71
+ handler: async (params) => {
72
+ const email = params.email;
73
+ const res = await fetch(`${baseUrl}/auth/request-code`, {
74
+ method: 'POST',
75
+ headers: { 'Content-Type': 'application/json' },
76
+ body: JSON.stringify({ email }),
77
+ });
78
+ if (!res.ok) {
79
+ const err = await res.json().catch(() => ({}));
80
+ return { error: `Failed to request code: ${err.error || res.statusText}` };
81
+ }
82
+ const data = await res.json();
83
+ return {
84
+ success: true,
85
+ email: data.email,
86
+ expires_in_seconds: data.expires_in_seconds,
87
+ message: `Verification code sent to ${data.email}. Ask the user for the 6-digit code from their inbox, then call verify_login.`,
88
+ };
89
+ },
90
+ },
91
+ {
92
+ name: 'verify_login',
93
+ description: 'Verify a 6-digit code and obtain an API key. Second step of authentication. On success, the API key is automatically saved to plugin config.',
94
+ inputSchema: {
95
+ type: 'object',
96
+ properties: {
97
+ email: { type: 'string', description: 'Email address the code was sent to' },
98
+ code: { type: 'string', description: '6-digit verification code from email' },
99
+ },
100
+ required: ['email', 'code'],
101
+ },
102
+ handler: async (params) => {
103
+ const email = params.email;
104
+ const code = params.code;
105
+ const res = await fetch(`${baseUrl}/auth/verify-code`, {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/json' },
108
+ body: JSON.stringify({ email, code }),
109
+ });
110
+ if (!res.ok) {
111
+ const err = await res.json().catch(() => ({}));
112
+ return { error: `Verification failed: ${err.error || res.statusText}` };
113
+ }
114
+ const data = await res.json();
115
+ try {
116
+ const configRes = await fetch('http://127.0.0.1:5050/api/plugins/config', {
117
+ method: 'POST',
118
+ headers: { 'Content-Type': 'application/json' },
119
+ body: JSON.stringify({
120
+ name: '@openwriter/plugin-publish',
121
+ config: { 'api-key': data.apiKey },
122
+ }),
123
+ });
124
+ if (configRes.ok) {
125
+ return {
126
+ success: true,
127
+ userId: data.userId,
128
+ message: 'Authenticated and API key saved to plugin config. You can now use all Publish tools.',
129
+ };
130
+ }
131
+ }
132
+ catch {
133
+ // Config save failed — fall back to returning key
134
+ }
135
+ return {
136
+ success: true,
137
+ userId: data.userId,
138
+ apiKey: data.apiKey,
139
+ message: 'Authenticated but could not auto-save API key. Please save this key to the plugin config manually.',
140
+ };
141
+ },
142
+ },
143
+ // Newsletter tools (send, subscribers, issues, analytics)
144
+ ...newsletterTools(config),
145
+ // --- Domain tools ---
146
+ {
147
+ name: 'setup_custom_domain',
148
+ description: 'Set up a custom sending domain for newsletters. Automatically handles SendGrid domain auth, DNS records (auto-added for Cloudflare domains), and sender verification. Follow the next_action field in the response.',
149
+ inputSchema: {
150
+ type: 'object',
151
+ properties: {
152
+ domain: { type: 'string', description: 'Domain to set up (e.g. "yourdomain.com")' },
153
+ from_email: {
154
+ type: 'string',
155
+ description: 'From email address (e.g. "newsletter@yourdomain.com")',
156
+ },
157
+ from_name: {
158
+ type: 'string',
159
+ description: 'From display name (e.g. "Your Newsletter")',
160
+ },
161
+ physical_address: {
162
+ type: 'string',
163
+ description: 'Physical mailing address for CAN-SPAM compliance (required before sending). E.g. "123 Main St, Portland, OR 97201"',
164
+ },
165
+ },
166
+ required: ['domain', 'from_email'],
167
+ },
168
+ handler: async (params) => {
169
+ const res = await publishFetch(config, '/newsletter/domains', {
170
+ method: 'POST',
171
+ body: JSON.stringify({
172
+ domain: params.domain,
173
+ from_email: params.from_email,
174
+ from_name: params.from_name || undefined,
175
+ physical_address: params.physical_address || undefined,
176
+ }),
177
+ });
178
+ if (!res.ok) {
179
+ const err = await res.json().catch(() => ({}));
180
+ return { error: `Failed to setup domain: ${err.error || res.statusText}` };
181
+ }
182
+ const data = await res.json();
183
+ return {
184
+ success: true,
185
+ domainId: data.domain.id,
186
+ domain: data.domain.domain,
187
+ dnsRecords: data.dnsRecords,
188
+ cloudflare_managed: data.cloudflare_managed,
189
+ dns_auto_added: data.dns_auto_added,
190
+ sender_verification_sent: data.sender_verification_sent,
191
+ next_action: data.next_action,
192
+ };
193
+ },
194
+ },
195
+ {
196
+ name: 'check_domain_status',
197
+ description: 'Check if a custom domain is fully ready (DNS verified + sender verified). If domain_id omitted, lists all domains with their status.',
198
+ inputSchema: {
199
+ type: 'object',
200
+ properties: {
201
+ domain_id: {
202
+ type: 'string',
203
+ description: 'Domain ID to check (from setup_custom_domain). Omit to list all domains.',
204
+ },
205
+ },
206
+ },
207
+ handler: async (params) => {
208
+ if (!params.domain_id) {
209
+ const res = await publishFetch(config, '/newsletter/domains');
210
+ if (!res.ok) {
211
+ const err = await res.json().catch(() => ({}));
212
+ return { error: `Failed to list domains: ${err.error || res.statusText}` };
213
+ }
214
+ const data = await res.json();
215
+ return {
216
+ domains: data.domains.map((d) => ({
217
+ id: d.id,
218
+ domain: d.domain,
219
+ fromEmail: d.from_email,
220
+ status: d.status,
221
+ senderStatus: d.sender_status,
222
+ cloudflareManaged: d.cloudflare_managed,
223
+ hasPhysicalAddress: !!d.physical_address,
224
+ })),
225
+ };
226
+ }
227
+ const res = await publishFetch(config, `/newsletter/domains/${params.domain_id}/verify`, {
228
+ method: 'POST',
229
+ });
230
+ if (!res.ok) {
231
+ const err = await res.json().catch(() => ({}));
232
+ return { error: `Status check failed: ${err.error || res.statusText}` };
233
+ }
234
+ const data = await res.json();
235
+ return {
236
+ domain: data.domain,
237
+ dns_verified: data.dns_verified,
238
+ sender_verified: data.sender_verified,
239
+ fully_ready: data.fully_ready,
240
+ next_action: data.next_action,
241
+ };
242
+ },
243
+ },
244
+ {
245
+ name: 'resend_domain_verification',
246
+ description: 'Resend the SendGrid sender verification email for a custom domain. Use when the user did not receive or cannot find the original verification email.',
247
+ inputSchema: {
248
+ type: 'object',
249
+ properties: {
250
+ domain_id: {
251
+ type: 'string',
252
+ description: 'Domain ID to resend verification for.',
253
+ },
254
+ },
255
+ required: ['domain_id'],
256
+ },
257
+ handler: async (params) => {
258
+ const res = await publishFetch(config, `/newsletter/domains/${params.domain_id}/resend-verification`, {
259
+ method: 'POST',
260
+ });
261
+ if (!res.ok) {
262
+ const err = await res.json().catch(() => ({}));
263
+ return { error: `Resend failed: ${err.error || res.statusText}` };
264
+ }
265
+ const data = await res.json();
266
+ return {
267
+ success: true,
268
+ message: data.message,
269
+ };
270
+ },
271
+ },
272
+ // --- Connection tools ---
273
+ {
274
+ name: 'list_connections',
275
+ description: 'List all connected accounts (X, LinkedIn, newsletter domains) for the active profile.',
276
+ inputSchema: { type: 'object', properties: {} },
277
+ handler: async () => {
278
+ const server = await getServerModules();
279
+ const res = await server.platformFetch('/connections/unified');
280
+ if (!res.ok) {
281
+ const err = await res.json().catch(() => ({}));
282
+ return { error: `Failed to list connections: ${err.error || res.statusText}` };
283
+ }
284
+ const data = await res.json();
285
+ return {
286
+ connections: data.connections.map((c) => ({
287
+ id: c.id,
288
+ provider: c.provider,
289
+ display_name: c.display_name,
290
+ status: c.status,
291
+ })),
292
+ };
293
+ },
294
+ },
295
+ {
296
+ name: 'post_to_x',
297
+ description: 'Post content to X (Twitter) via a connected account. Requires an active X connection.',
298
+ inputSchema: {
299
+ type: 'object',
300
+ properties: {
301
+ content: { type: 'string', description: 'Tweet text (max 280 characters)' },
302
+ connection_id: { type: 'string', description: 'X connection ID. If omitted, uses the first active X connection.' },
303
+ },
304
+ required: ['content'],
305
+ },
306
+ handler: async (params) => {
307
+ const connectionId = params.connection_id;
308
+ let id = connectionId;
309
+ if (!id) {
310
+ const server = await getServerModules();
311
+ const listRes = await server.platformFetch('/connections');
312
+ if (listRes.ok) {
313
+ const data = await listRes.json();
314
+ const xConn = data.connections.find((c) => c.provider === 'x' && c.status === 'active');
315
+ if (xConn)
316
+ id = xConn.id;
317
+ }
318
+ }
319
+ if (!id)
320
+ return { error: 'No active X connection found. Connect an X account first.' };
321
+ const server = await getServerModules();
322
+ const res = await server.platformFetch(`/connections/${id}/post`, {
323
+ method: 'POST',
324
+ body: JSON.stringify({ content: params.content }),
325
+ });
326
+ const data = await res.json();
327
+ if (!res.ok)
328
+ return { error: `Post failed: ${data.error || res.statusText}` };
329
+ return data;
330
+ },
331
+ },
332
+ {
333
+ name: 'post_to_linkedin',
334
+ description: 'Post content to LinkedIn via a connected account. Requires an active LinkedIn connection.',
335
+ inputSchema: {
336
+ type: 'object',
337
+ properties: {
338
+ content: { type: 'string', description: 'Post text' },
339
+ connection_id: { type: 'string', description: 'LinkedIn connection ID. If omitted, uses the first active LinkedIn connection.' },
340
+ },
341
+ required: ['content'],
342
+ },
343
+ handler: async (params) => {
344
+ const connectionId = params.connection_id;
345
+ let id = connectionId;
346
+ if (!id) {
347
+ const server = await getServerModules();
348
+ const listRes = await server.platformFetch('/connections');
349
+ if (listRes.ok) {
350
+ const data = await listRes.json();
351
+ const liConn = data.connections.find((c) => c.provider === 'linkedin' && c.status === 'active');
352
+ if (liConn)
353
+ id = liConn.id;
354
+ }
355
+ }
356
+ if (!id)
357
+ return { error: 'No active LinkedIn connection found. Connect a LinkedIn account first.' };
358
+ const server = await getServerModules();
359
+ const res = await server.platformFetch(`/connections/${id}/post`, {
360
+ method: 'POST',
361
+ body: JSON.stringify({ content: params.content }),
362
+ });
363
+ const data = await res.json();
364
+ if (!res.ok)
365
+ return { error: `Post failed: ${data.error || res.statusText}` };
366
+ return data;
367
+ },
368
+ },
369
+ // --- Scheduler tools ---
370
+ {
371
+ name: 'schedule_post',
372
+ description: 'Schedule the current document for posting. Reads content and content_type from the active document automatically. Default mode: queue (next available slot).',
373
+ inputSchema: {
374
+ type: 'object',
375
+ properties: {
376
+ connection_id: { type: 'string', description: 'Target connection ID (use list_connections to find). If omitted, infers from content_type.' },
377
+ mode: { type: 'string', enum: ['queue', 'now', 'custom'], description: 'Scheduling mode (default: queue)' },
378
+ scheduled_at: { type: 'string', description: 'ISO datetime for custom mode' },
379
+ slot_id: { type: 'string', description: 'Specific slot ID to target (overrides automatic slot selection)' },
380
+ },
381
+ },
382
+ handler: async (params) => {
383
+ const server = await getServerModules();
384
+ const doc = server.getDocument();
385
+ const metadata = server.getMetadata();
386
+ const docId = server.getDocId();
387
+ if (!doc || !doc.content)
388
+ return { error: 'No active document. Switch to a document first.' };
389
+ // --- Helpers ---
390
+ const extractText = (nodes) => {
391
+ const walk = (node) => {
392
+ if (node.type === 'text')
393
+ return node.text || '';
394
+ if (node.type === 'hardBreak')
395
+ return '\n';
396
+ if (!node.content)
397
+ return '';
398
+ const inner = node.content.map(walk).join('');
399
+ if (node.type === 'paragraph')
400
+ return inner + '\n\n';
401
+ return inner;
402
+ };
403
+ return nodes.map(walk).join('').trim();
404
+ };
405
+ const extractImages = (nodes) => {
406
+ const srcs = [];
407
+ const walk = (node) => {
408
+ if (node.type === 'image' && node.attrs?.src)
409
+ srcs.push(node.attrs.src);
410
+ if (node.content)
411
+ node.content.forEach(walk);
412
+ };
413
+ nodes.forEach(walk);
414
+ return srcs.slice(0, 4); // X limit: 4 images per tweet
415
+ };
416
+ const mimeMap = {
417
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
418
+ '.png': 'image/png', '.webp': 'image/webp',
419
+ '.gif': 'image/gif', '.bmp': 'image/bmp',
420
+ };
421
+ const uploadImages = async (srcs, connId) => {
422
+ const ids = [];
423
+ const dataDir = server.getDataDir();
424
+ for (const src of srcs) {
425
+ if (!src.startsWith('/_images/'))
426
+ continue;
427
+ const filename = src.replace('/_images/', '');
428
+ const filePath = join(dataDir, '_images', filename);
429
+ if (!existsSync(filePath))
430
+ continue;
431
+ const ext = extname(filename).toLowerCase();
432
+ const mediaType = mimeMap[ext] || 'image/jpeg';
433
+ const mediaBase64 = readFileSync(filePath).toString('base64');
434
+ const res = await server.platformFetch(`/connections/${connId}/upload-media`, {
435
+ method: 'POST',
436
+ body: JSON.stringify({ media_base64: mediaBase64, media_type: mediaType }),
437
+ });
438
+ if (res.ok) {
439
+ const data = await res.json();
440
+ if (data.mediaId)
441
+ ids.push(data.mediaId);
442
+ }
443
+ }
444
+ return ids;
445
+ };
446
+ // --- Split doc at horizontalRule nodes (thread detection) ---
447
+ const docNodes = doc.content || [];
448
+ const tweetGroups = [[]];
449
+ for (const node of docNodes) {
450
+ if (node.type === 'horizontalRule') {
451
+ tweetGroups.push([]);
452
+ }
453
+ else {
454
+ tweetGroups[tweetGroups.length - 1].push(node);
455
+ }
456
+ }
457
+ // Filter empty groups
458
+ const tweets = tweetGroups.filter(g => g.length > 0);
459
+ const isThread = tweets.length > 1;
460
+ // Derive content_type from metadata
461
+ const contentType = metadata?.content_type || 'tweet';
462
+ // Auto-find connection
463
+ let connectionId = params.connection_id;
464
+ if (!connectionId) {
465
+ const provider = contentType === 'linkedin' ? 'linkedin' : 'x';
466
+ const listRes = await server.platformFetch('/connections');
467
+ if (listRes.ok) {
468
+ const data = await listRes.json();
469
+ const conn = data.connections.find((c) => c.provider === provider && c.status === 'active');
470
+ if (conn)
471
+ connectionId = conn.id;
472
+ }
473
+ if (!connectionId)
474
+ return { error: `No active ${provider} connection found.` };
475
+ }
476
+ // --- Build content: single tweet or thread ---
477
+ let queueContent;
478
+ let totalMedia = 0;
479
+ if (isThread) {
480
+ const tweetData = [];
481
+ for (const group of tweets) {
482
+ const text = extractText(group);
483
+ if (!text)
484
+ continue;
485
+ const images = extractImages(group);
486
+ const mediaIds = images.length > 0 ? await uploadImages(images, connectionId) : [];
487
+ totalMedia += mediaIds.length;
488
+ tweetData.push(mediaIds.length > 0 ? { text, mediaIds } : { text });
489
+ }
490
+ if (tweetData.length === 0)
491
+ return { error: 'Document is empty.' };
492
+ queueContent = { tweets: tweetData };
493
+ }
494
+ else {
495
+ const text = extractText(docNodes);
496
+ if (!text)
497
+ return { error: 'Document is empty.' };
498
+ const images = extractImages(docNodes);
499
+ const mediaIds = images.length > 0 ? await uploadImages(images, connectionId) : [];
500
+ totalMedia = mediaIds.length;
501
+ queueContent = mediaIds.length > 0 ? { text, mediaIds } : { text };
502
+ }
503
+ // --- Queue it ---
504
+ const body = {
505
+ content: queueContent,
506
+ content_type: isThread ? 'thread' : contentType,
507
+ connection_id: connectionId,
508
+ mode: params.mode || 'queue',
509
+ doc_id: docId,
510
+ };
511
+ if (params.scheduled_at)
512
+ body.scheduled_at = params.scheduled_at;
513
+ if (params.slot_id)
514
+ body.slot_id = params.slot_id;
515
+ const res = await publishFetch(config, '/scheduler/queue', {
516
+ method: 'POST',
517
+ body: JSON.stringify(body),
518
+ });
519
+ const data = await res.json();
520
+ if (!res.ok)
521
+ return { error: `Schedule failed: ${data.error || res.statusText}` };
522
+ return {
523
+ success: true,
524
+ docId,
525
+ scheduled_at: data.item?.scheduled_at,
526
+ connection: connectionId,
527
+ mode: params.mode || 'queue',
528
+ type: isThread ? 'thread' : 'tweet',
529
+ tweetCount: isThread ? tweets.length : 1,
530
+ mediaCount: totalMedia,
531
+ };
532
+ },
533
+ },
534
+ {
535
+ name: 'list_schedule',
536
+ description: 'Show upcoming queued items with slot times.',
537
+ inputSchema: { type: 'object', properties: {} },
538
+ handler: async () => {
539
+ const res = await publishFetch(config, '/scheduler/queue');
540
+ const data = await res.json();
541
+ if (!res.ok)
542
+ return { error: `Failed: ${data.error || res.statusText}` };
543
+ return data;
544
+ },
545
+ },
546
+ {
547
+ name: 'manage_schedule',
548
+ description: 'Cancel or reschedule a queued item.',
549
+ inputSchema: {
550
+ type: 'object',
551
+ properties: {
552
+ item_id: { type: 'string', description: 'Queue item ID' },
553
+ action: { type: 'string', enum: ['cancel', 'reschedule'], description: 'Action to take' },
554
+ scheduled_at: { type: 'string', description: 'New ISO datetime (for reschedule)' },
555
+ },
556
+ required: ['item_id', 'action'],
557
+ },
558
+ handler: async (params) => {
559
+ const itemId = params.item_id;
560
+ const action = params.action;
561
+ if (action === 'cancel') {
562
+ const res = await publishFetch(config, `/scheduler/queue/${itemId}`, { method: 'DELETE' });
563
+ const data = await res.json();
564
+ if (!res.ok)
565
+ return { error: `Cancel failed: ${data.error || res.statusText}` };
566
+ return { success: true, message: 'Item cancelled' };
567
+ }
568
+ if (action === 'reschedule') {
569
+ if (!params.scheduled_at)
570
+ return { error: 'scheduled_at required for reschedule' };
571
+ const res = await publishFetch(config, `/scheduler/queue/${itemId}`, {
572
+ method: 'PATCH',
573
+ body: JSON.stringify({ scheduled_at: params.scheduled_at }),
574
+ });
575
+ const data = await res.json();
576
+ if (!res.ok)
577
+ return { error: `Reschedule failed: ${data.error || res.statusText}` };
578
+ return { success: true, item: data.item };
579
+ }
580
+ return { error: `Unknown action: ${action}` };
581
+ },
582
+ },
583
+ {
584
+ name: 'list_slots',
585
+ description: 'Show all slot templates with filters.',
586
+ inputSchema: { type: 'object', properties: {} },
587
+ handler: async () => {
588
+ const res = await publishFetch(config, '/scheduler/slots');
589
+ const data = await res.json();
590
+ if (!res.ok)
591
+ return { error: `Failed: ${data.error || res.statusText}` };
592
+ return data;
593
+ },
594
+ },
595
+ {
596
+ name: 'create_slot',
597
+ description: 'Create a scheduling slot template.',
598
+ inputSchema: {
599
+ type: 'object',
600
+ properties: {
601
+ time: { type: 'string', description: 'Time in HH:MM format' },
602
+ days: { type: 'array', items: { type: 'string' }, description: 'Days array (mon, tue, etc.) or ["default"] for every day' },
603
+ filter_type: { type: 'string', enum: ['any', 'content_type', 'connection', 'category'], description: 'Slot filter type (default: any)' },
604
+ filter_value: { type: 'string', description: 'Filter value (content type name or connection ID)' },
605
+ timezone: { type: 'string', description: 'IANA timezone (default: America/New_York)' },
606
+ },
607
+ required: ['time', 'days'],
608
+ },
609
+ handler: async (params) => {
610
+ const body = { ...params };
611
+ if (typeof body.days === 'string') {
612
+ try {
613
+ body.days = JSON.parse(body.days);
614
+ }
615
+ catch { /* leave as-is */ }
616
+ }
617
+ const res = await publishFetch(config, '/scheduler/slots', {
618
+ method: 'POST',
619
+ body: JSON.stringify(body),
620
+ });
621
+ const data = await res.json();
622
+ if (!res.ok)
623
+ return { error: `Failed: ${data.error || res.statusText}` };
624
+ return { success: true, slot: data.slot };
625
+ },
626
+ },
627
+ {
628
+ name: 'edit_slot',
629
+ description: 'Edit a slot template.',
630
+ inputSchema: {
631
+ type: 'object',
632
+ properties: {
633
+ slot_id: { type: 'string', description: 'Slot ID to edit' },
634
+ time: { type: 'string', description: 'New time in HH:MM format' },
635
+ days: { type: 'array', items: { type: 'string' }, description: 'New days array' },
636
+ filter_type: { type: 'string', enum: ['any', 'content_type', 'connection', 'category'] },
637
+ filter_value: { type: 'string' },
638
+ timezone: { type: 'string' },
639
+ },
640
+ required: ['slot_id'],
641
+ },
642
+ handler: async (params) => {
643
+ const { slot_id, ...changes } = params;
644
+ const res = await publishFetch(config, `/scheduler/slots/${slot_id}`, {
645
+ method: 'PATCH',
646
+ body: JSON.stringify(changes),
647
+ });
648
+ const data = await res.json();
649
+ if (!res.ok)
650
+ return { error: `Failed: ${data.error || res.statusText}` };
651
+ return { success: true, ...data };
652
+ },
653
+ },
654
+ // --- Autoplug tools ---
655
+ {
656
+ name: 'manage_autoplugs',
657
+ description: 'Manage autoplug goals, rules, and pool messages. Autoplugs automatically reply to your tweets when they hit engagement thresholds, promoting your content.',
658
+ inputSchema: {
659
+ type: 'object',
660
+ properties: {
661
+ action: {
662
+ type: 'string',
663
+ enum: ['list_goals', 'create_goal', 'update_goal', 'delete_goal', 'list_pool', 'add_pool_message', 'delete_pool_message', 'create_rule', 'update_rule', 'delete_rule', 'sync_av_key'],
664
+ description: 'Action to perform',
665
+ },
666
+ goal_id: { type: 'string', description: 'Goal ID (for pool/rule operations, update_goal, delete_goal)' },
667
+ rule_id: { type: 'string', description: 'Rule ID (for update_rule, delete_rule)' },
668
+ message_id: { type: 'string', description: 'Pool message ID (for delete_pool_message)' },
669
+ name: { type: 'string', description: 'Goal name (for create_goal, update_goal)' },
670
+ link: { type: 'string', description: 'Promo link (for create_goal, update_goal)' },
671
+ description: { type: 'string', description: 'Goal description for LLM context (for create_goal, update_goal)' },
672
+ enabled: { type: 'boolean', description: 'Enable/disable (for update_goal, update_rule)' },
673
+ content: { type: 'string', description: 'Pool message text (for add_pool_message). Use {{link}} as placeholder.' },
674
+ metric: { type: 'string', enum: ['likes', 'retweets', 'views'], description: 'Trigger metric (for create_rule, update_rule)' },
675
+ threshold: { type: 'number', description: 'Trigger threshold (for create_rule, update_rule)' },
676
+ delay_minutes: { type: 'number', description: 'Delay after threshold met (for create_rule, update_rule)' },
677
+ mode: { type: 'string', enum: ['static', 'llm', 'hybrid'], description: 'Reply mode (for create_rule, update_rule)' },
678
+ av_api_key: { type: 'string', description: 'Author\'s Voice API key (for sync_av_key)' },
679
+ },
680
+ required: ['action'],
681
+ },
682
+ handler: async (params) => {
683
+ const action = params.action;
684
+ if (action === 'list_goals') {
685
+ const res = await publishFetch(config, '/scheduler/autoplugs/goals');
686
+ const data = await res.json();
687
+ if (!res.ok)
688
+ return { error: `Failed: ${data.error || res.statusText}` };
689
+ return data;
690
+ }
691
+ if (action === 'create_goal') {
692
+ if (!params.name)
693
+ return { error: 'name is required' };
694
+ const res = await publishFetch(config, '/scheduler/autoplugs/goals', {
695
+ method: 'POST',
696
+ body: JSON.stringify({ name: params.name, link: params.link, description: params.description }),
697
+ });
698
+ const data = await res.json();
699
+ if (!res.ok)
700
+ return { error: `Failed: ${data.error || res.statusText}` };
701
+ return { success: true, goal: data.goal };
702
+ }
703
+ if (action === 'update_goal') {
704
+ if (!params.goal_id)
705
+ return { error: 'goal_id is required' };
706
+ const changes = {};
707
+ if (params.name !== undefined)
708
+ changes.name = params.name;
709
+ if (params.link !== undefined)
710
+ changes.link = params.link;
711
+ if (params.description !== undefined)
712
+ changes.description = params.description;
713
+ if (params.enabled !== undefined)
714
+ changes.enabled = params.enabled;
715
+ const res = await publishFetch(config, `/scheduler/autoplugs/goals/${params.goal_id}`, {
716
+ method: 'PATCH',
717
+ body: JSON.stringify(changes),
718
+ });
719
+ const data = await res.json();
720
+ if (!res.ok)
721
+ return { error: `Failed: ${data.error || res.statusText}` };
722
+ return { success: true, goal: data.goal };
723
+ }
724
+ if (action === 'delete_goal') {
725
+ if (!params.goal_id)
726
+ return { error: 'goal_id is required' };
727
+ const res = await publishFetch(config, `/scheduler/autoplugs/goals/${params.goal_id}`, { method: 'DELETE' });
728
+ const data = await res.json();
729
+ if (!res.ok)
730
+ return { error: `Failed: ${data.error || res.statusText}` };
731
+ return { success: true, message: 'Goal deleted' };
732
+ }
733
+ if (action === 'list_pool') {
734
+ if (!params.goal_id)
735
+ return { error: 'goal_id is required' };
736
+ const res = await publishFetch(config, `/scheduler/autoplugs/goals/${params.goal_id}/pool`);
737
+ const data = await res.json();
738
+ if (!res.ok)
739
+ return { error: `Failed: ${data.error || res.statusText}` };
740
+ return data;
741
+ }
742
+ if (action === 'add_pool_message') {
743
+ if (!params.goal_id)
744
+ return { error: 'goal_id is required' };
745
+ if (!params.content)
746
+ return { error: 'content is required' };
747
+ const res = await publishFetch(config, `/scheduler/autoplugs/goals/${params.goal_id}/pool`, {
748
+ method: 'POST',
749
+ body: JSON.stringify({ content: params.content }),
750
+ });
751
+ const data = await res.json();
752
+ if (!res.ok)
753
+ return { error: `Failed: ${data.error || res.statusText}` };
754
+ return { success: true, message: data.message };
755
+ }
756
+ if (action === 'delete_pool_message') {
757
+ if (!params.message_id)
758
+ return { error: 'message_id is required' };
759
+ const res = await publishFetch(config, `/scheduler/autoplugs/pool/${params.message_id}`, { method: 'DELETE' });
760
+ const data = await res.json();
761
+ if (!res.ok)
762
+ return { error: `Failed: ${data.error || res.statusText}` };
763
+ return { success: true, message: 'Pool message deleted' };
764
+ }
765
+ if (action === 'create_rule') {
766
+ if (!params.goal_id)
767
+ return { error: 'goal_id is required' };
768
+ if (!params.metric)
769
+ return { error: 'metric is required' };
770
+ if (!params.threshold)
771
+ return { error: 'threshold is required' };
772
+ const res = await publishFetch(config, '/scheduler/autoplugs/rules', {
773
+ method: 'POST',
774
+ body: JSON.stringify({
775
+ goal_id: params.goal_id,
776
+ metric: params.metric,
777
+ threshold: params.threshold,
778
+ delay_minutes: params.delay_minutes ?? 30,
779
+ mode: params.mode || 'static',
780
+ }),
781
+ });
782
+ const data = await res.json();
783
+ if (!res.ok)
784
+ return { error: `Failed: ${data.error || res.statusText}` };
785
+ return { success: true, rule: data.rule };
786
+ }
787
+ if (action === 'update_rule') {
788
+ if (!params.rule_id)
789
+ return { error: 'rule_id is required' };
790
+ const changes = {};
791
+ if (params.metric !== undefined)
792
+ changes.metric = params.metric;
793
+ if (params.threshold !== undefined)
794
+ changes.threshold = params.threshold;
795
+ if (params.delay_minutes !== undefined)
796
+ changes.delay_minutes = params.delay_minutes;
797
+ if (params.mode !== undefined)
798
+ changes.mode = params.mode;
799
+ if (params.enabled !== undefined)
800
+ changes.enabled = params.enabled;
801
+ const res = await publishFetch(config, `/scheduler/autoplugs/rules/${params.rule_id}`, {
802
+ method: 'PATCH',
803
+ body: JSON.stringify(changes),
804
+ });
805
+ const data = await res.json();
806
+ if (!res.ok)
807
+ return { error: `Failed: ${data.error || res.statusText}` };
808
+ return { success: true, rule: data.rule };
809
+ }
810
+ if (action === 'delete_rule') {
811
+ if (!params.rule_id)
812
+ return { error: 'rule_id is required' };
813
+ const res = await publishFetch(config, `/scheduler/autoplugs/rules/${params.rule_id}`, { method: 'DELETE' });
814
+ const data = await res.json();
815
+ if (!res.ok)
816
+ return { error: `Failed: ${data.error || res.statusText}` };
817
+ return { success: true, message: 'Rule deleted' };
818
+ }
819
+ if (action === 'sync_av_key') {
820
+ if (!params.av_api_key)
821
+ return { error: 'av_api_key is required' };
822
+ const res = await publishFetch(config, '/scheduler/autoplugs/av-key', {
823
+ method: 'POST',
824
+ body: JSON.stringify({ av_api_key: params.av_api_key }),
825
+ });
826
+ const data = await res.json();
827
+ if (!res.ok)
828
+ return { error: `Failed: ${data.error || res.statusText}` };
829
+ return { success: true, message: 'AV API key synced to platform for LLM autoplugs' };
830
+ }
831
+ return { error: `Unknown action: ${action}` };
832
+ },
833
+ },
834
+ {
835
+ name: 'list_autoplug_tracking',
836
+ description: 'List tracked tweets with engagement metrics and autoplug status. Shows which tweets are being monitored, their current metrics, and whether autoplugs have fired.',
837
+ inputSchema: { type: 'object', properties: {} },
838
+ handler: async () => {
839
+ const res = await publishFetch(config, '/scheduler/autoplugs/tracking');
840
+ const data = await res.json();
841
+ if (!res.ok)
842
+ return { error: `Failed: ${data.error || res.statusText}` };
843
+ return data;
844
+ },
845
+ },
846
+ {
847
+ name: 'delete_slot',
848
+ description: 'Delete a slot template.',
849
+ inputSchema: {
850
+ type: 'object',
851
+ properties: {
852
+ slot_id: { type: 'string', description: 'Slot ID to delete' },
853
+ },
854
+ required: ['slot_id'],
855
+ },
856
+ handler: async (params) => {
857
+ const res = await publishFetch(config, `/scheduler/slots/${params.slot_id}`, { method: 'DELETE' });
858
+ const data = await res.json();
859
+ if (!res.ok)
860
+ return { error: `Failed: ${data.error || res.statusText}` };
861
+ return { success: true, ...data };
862
+ },
863
+ },
864
+ {
865
+ name: 'naturalize_slots',
866
+ description: 'Scramble slot times so posts don\'t look scheduled. Splits multi-day slots into per-day slots with jittered times (+/- 15 min). Each day gets a slightly different minute offset. Run after creating slots at clean macro times.',
867
+ inputSchema: {
868
+ type: 'object',
869
+ properties: {
870
+ jitter_minutes: { type: 'number', description: 'Max jitter in minutes (default: 15). Each slot shifts by a random amount within this range.' },
871
+ },
872
+ },
873
+ handler: async (params) => {
874
+ const jitter = params.jitter_minutes || 15;
875
+ const ALL_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
876
+ // Fetch current slots
877
+ const listRes = await publishFetch(config, '/scheduler/slots');
878
+ const listData = await listRes.json();
879
+ if (!listRes.ok)
880
+ return { error: `Failed to list slots: ${listData.error || listRes.statusText}` };
881
+ const slots = listData.slots || [];
882
+ if (!slots.length)
883
+ return { error: 'No slots to naturalize' };
884
+ // Parse HH:MM:SS or HH:MM to total minutes
885
+ const parseTime = (t) => {
886
+ const [h, m] = t.split(':').map(Number);
887
+ return h * 60 + m;
888
+ };
889
+ // Format total minutes back to HH:MM
890
+ const formatTime = (mins) => {
891
+ const clamped = ((mins % 1440) + 1440) % 1440;
892
+ const h = Math.floor(clamped / 60);
893
+ const m = clamped % 60;
894
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
895
+ };
896
+ // Deterministic-ish jitter per day index and slot index
897
+ const jitterFor = (dayIdx, slotIdx) => {
898
+ const seed = (dayIdx * 7 + slotIdx * 13 + 3) % (jitter * 2 + 1);
899
+ return seed - jitter;
900
+ };
901
+ let created = 0;
902
+ let deleted = 0;
903
+ const results = [];
904
+ for (let si = 0; si < slots.length; si++) {
905
+ const slot = slots[si];
906
+ const days = slot.days || ['default'];
907
+ const expandedDays = days.includes('default') ? ALL_DAYS : days;
908
+ // Skip already single-day slots (already naturalized)
909
+ if (expandedDays.length === 1 && !days.includes('default')) {
910
+ // Still jitter the time if it's on a round number
911
+ const mins = parseTime(slot.time);
912
+ if (mins % 5 === 0) {
913
+ const dayIdx = ALL_DAYS.indexOf(expandedDays[0]);
914
+ const newMins = mins + jitterFor(dayIdx, si);
915
+ const editRes = await publishFetch(config, `/scheduler/slots/${slot.id}`, {
916
+ method: 'PATCH',
917
+ body: JSON.stringify({ time: formatTime(newMins) }),
918
+ });
919
+ if (editRes.ok) {
920
+ results.push(`${expandedDays[0]}: ${slot.time} → ${formatTime(newMins)}`);
921
+ }
922
+ }
923
+ continue;
924
+ }
925
+ // Multi-day slot: delete and create per-day replacements
926
+ const baseMinutes = parseTime(slot.time);
927
+ const delRes = await publishFetch(config, `/scheduler/slots/${slot.id}`, { method: 'DELETE' });
928
+ if (delRes.ok)
929
+ deleted++;
930
+ for (let di = 0; di < expandedDays.length; di++) {
931
+ const day = expandedDays[di];
932
+ const dayIdx = ALL_DAYS.indexOf(day);
933
+ const offset = jitterFor(dayIdx, si);
934
+ const newTime = formatTime(baseMinutes + offset);
935
+ const createRes = await publishFetch(config, '/scheduler/slots', {
936
+ method: 'POST',
937
+ body: JSON.stringify({
938
+ time: newTime,
939
+ days: [day],
940
+ filter_type: slot.filter_type || 'any',
941
+ filter_value: slot.filter_value || null,
942
+ timezone: slot.timezone || 'America/Los_Angeles',
943
+ }),
944
+ });
945
+ if (createRes.ok) {
946
+ created++;
947
+ results.push(`${day}: ${slot.time} → ${newTime}`);
948
+ }
949
+ }
950
+ }
951
+ return {
952
+ success: true,
953
+ deleted,
954
+ created,
955
+ summary: `Naturalized ${deleted} multi-day slots into ${created} per-day slots`,
956
+ details: results,
957
+ };
958
+ },
959
+ },
960
+ // --- Billing tools ---
961
+ {
962
+ name: 'get_billing',
963
+ description: 'Get current subscription plan, feature limits, and billing status. Shows plan tier, post/subscriber limits, and whether payment is active.',
964
+ inputSchema: { type: 'object', properties: {} },
965
+ handler: async () => {
966
+ const res = await publishFetch(config, '/billing');
967
+ if (!res.ok) {
968
+ const err = await res.json().catch(() => ({}));
969
+ return { error: `Failed to fetch billing: ${err.error || res.statusText}` };
970
+ }
971
+ return await res.json();
972
+ },
973
+ },
974
+ {
975
+ name: 'upgrade_plan',
976
+ description: 'Get a Stripe Checkout URL to subscribe or upgrade. Opens in browser for payment. Plans: creator ($19/mo), growth ($49/mo), publisher ($79/mo). Period: monthly or annual (2 months free).',
977
+ inputSchema: {
978
+ type: 'object',
979
+ properties: {
980
+ plan: { type: 'string', enum: ['creator', 'growth', 'publisher'], description: 'Plan to subscribe to' },
981
+ period: { type: 'string', enum: ['monthly', 'annual'], description: 'Billing period (default: monthly). Annual saves 2 months.' },
982
+ },
983
+ required: ['plan'],
984
+ },
985
+ handler: async (params) => {
986
+ const res = await publishFetch(config, '/billing/checkout', {
987
+ method: 'POST',
988
+ body: JSON.stringify({
989
+ plan: params.plan,
990
+ period: params.period || 'monthly',
991
+ }),
992
+ });
993
+ if (!res.ok) {
994
+ const err = await res.json().catch(() => ({}));
995
+ return { error: `Checkout failed: ${err.error || res.statusText}` };
996
+ }
997
+ const data = await res.json();
998
+ return {
999
+ url: data.url,
1000
+ message: `Open this URL to complete your ${params.plan} subscription: ${data.url}`,
1001
+ };
1002
+ },
1003
+ },
1004
+ {
1005
+ name: 'manage_billing',
1006
+ description: 'Get a Stripe Customer Portal URL to manage subscription — update payment method, view invoices, change plan, or cancel.',
1007
+ inputSchema: { type: 'object', properties: {} },
1008
+ handler: async () => {
1009
+ const res = await publishFetch(config, '/billing/portal', {
1010
+ method: 'POST',
1011
+ body: JSON.stringify({}),
1012
+ });
1013
+ if (!res.ok) {
1014
+ const err = await res.json().catch(() => ({}));
1015
+ return { error: `Portal failed: ${err.error || res.statusText}` };
1016
+ }
1017
+ const data = await res.json();
1018
+ return {
1019
+ url: data.url,
1020
+ message: `Open this URL to manage your billing: ${data.url}`,
1021
+ };
1022
+ },
1023
+ },
1024
+ ];
1025
+ },
1026
+ registerRoutes(ctx) {
1027
+ // Sidebar action handler for document transforms
1028
+ ctx.app.post('/api/publish/sidebar-action', async (req, res) => {
1029
+ try {
1030
+ const { action, filename, title, instructions, content } = req.body;
1031
+ console.log(`[Publish Plugin] Sidebar action: ${action} on "${title}"`);
1032
+ if (!content) {
1033
+ res.status(400).json({ error: 'Document content is required' });
1034
+ return;
1035
+ }
1036
+ // Call publish worker /transforms endpoint
1037
+ const transformRes = await publishFetch(ctx.config, '/transforms', {
1038
+ method: 'POST',
1039
+ headers: { 'Content-Type': 'application/json' },
1040
+ body: JSON.stringify({ action, content, title, instructions }),
1041
+ });
1042
+ if (!transformRes.ok) {
1043
+ const errData = await transformRes.json().catch(() => ({}));
1044
+ console.error('[Publish Plugin] Transform failed:', transformRes.status, errData);
1045
+ res.status(transformRes.status).json(errData);
1046
+ return;
1047
+ }
1048
+ const transformResult = await transformRes.json();
1049
+ // Convert HTML output to markdown for document creation
1050
+ let markdownContent = htmlToMarkdown(transformResult.html);
1051
+ // Extract source doc's docId for variant relationship
1052
+ const masterDocId = extractDocId(content);
1053
+ const variantType = ACTION_VARIANT_TYPE[action] || 'document';
1054
+ // Build document creation payload
1055
+ const createBody = {
1056
+ title: transformResult.newTitle,
1057
+ content: markdownContent,
1058
+ markPending: true,
1059
+ agentCreated: true,
1060
+ ...(masterDocId ? { masterDocId, variantType } : {}),
1061
+ };
1062
+ if (action === 'threadify') {
1063
+ // Build TipTap JSON directly — markdown parser converts "- item" lines
1064
+ // to bulletList nodes the tweet editor can't render. Using paragraph +
1065
+ // hardBreak nodes keeps all tweet text as plain text.
1066
+ if (transformResult.thread?.tweets?.length) {
1067
+ const docContent = [];
1068
+ transformResult.thread.tweets.forEach((t, i) => {
1069
+ const lines = t.text.split('\n');
1070
+ const nodes = [];
1071
+ lines.forEach((line, j) => {
1072
+ if (j > 0)
1073
+ nodes.push({ type: 'hardBreak' });
1074
+ if (line)
1075
+ nodes.push({ type: 'text', text: line });
1076
+ });
1077
+ if (nodes.length) {
1078
+ docContent.push({ type: 'paragraph', content: nodes });
1079
+ }
1080
+ if (i < transformResult.thread.tweets.length - 1) {
1081
+ docContent.push({ type: 'horizontalRule' });
1082
+ }
1083
+ });
1084
+ createBody.content = { type: 'doc', content: docContent };
1085
+ }
1086
+ createBody.metadata = { tweetContext: { mode: 'tweet' } };
1087
+ }
1088
+ // Create new document via internal HTTP call
1089
+ const host = req.get('host') || 'localhost:5050';
1090
+ const protocol = req.protocol || 'http';
1091
+ const createUrl = `${protocol}://${host}/api/documents`;
1092
+ const createRes = await fetch(createUrl, {
1093
+ method: 'POST',
1094
+ headers: { 'Content-Type': 'application/json' },
1095
+ body: JSON.stringify(createBody),
1096
+ });
1097
+ if (!createRes.ok) {
1098
+ const errData = await createRes.json().catch(() => ({}));
1099
+ console.error('[Publish Plugin] Document creation failed:', errData);
1100
+ res.status(500).json({ error: 'Failed to create result document' });
1101
+ return;
1102
+ }
1103
+ const docResult = await createRes.json();
1104
+ res.json({
1105
+ success: true,
1106
+ action,
1107
+ filename: docResult.filename,
1108
+ title: transformResult.newTitle,
1109
+ metadata: transformResult.metadata,
1110
+ });
1111
+ }
1112
+ catch (err) {
1113
+ console.error('[Publish Plugin] Sidebar action error:', err?.message || err);
1114
+ res.status(500).json({ error: 'Sidebar action failed' });
1115
+ }
1116
+ });
1117
+ },
1118
+ sidebarMenuItems() {
1119
+ return [
1120
+ { label: 'Vary', action: 'publish:vary', promptForFocus: true },
1121
+ { label: 'Shrinkify', action: 'publish:shrinkify', promptForFocus: true },
1122
+ { label: 'Expandify', action: 'publish:expandify', promptForFocus: true },
1123
+ { label: 'Threadify', action: 'publish:threadify', promptForFocus: true },
1124
+ { label: 'Storify', action: 'publish:storify', promptForFocus: true },
1125
+ { label: 'Emailify', action: 'publish:emailify', promptForFocus: true },
1126
+ { label: 'Postify', action: 'publish:postify', promptForFocus: true },
1127
+ ];
1128
+ },
1129
+ };
1130
+ export default plugin;