suparank 1.2.5 → 1.2.7

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.
@@ -0,0 +1,850 @@
1
+ /**
2
+ * Suparank MCP - Orchestrator Tool Handler
3
+ *
4
+ * Handles workflow management tools (create_content, save_content, publish_content, etc.)
5
+ */
6
+
7
+ import * as fs from 'fs'
8
+ import * as path from 'path'
9
+ import { log, progress } from '../utils/logging.js'
10
+ import {
11
+ getContentDir,
12
+ getContentFolderSafe
13
+ } from '../utils/paths.js'
14
+ import { saveContentToFolder, injectImagesIntoContent } from '../utils/content.js'
15
+ import {
16
+ sessionState,
17
+ saveSession,
18
+ resetSession,
19
+ generateArticleId
20
+ } from '../services/session-state.js'
21
+ import { incrementStat } from '../services/stats.js'
22
+ import { hasCredential } from '../services/credentials.js'
23
+ import { buildWorkflowPlan } from '../workflow/planner.js'
24
+ import {
25
+ executeGhostPublish,
26
+ executeWordPressPublish,
27
+ fetchWordPressCategories
28
+ } from '../publishers/index.js'
29
+
30
+ /**
31
+ * Execute an orchestrator tool
32
+ * @param {string} toolName - Name of the orchestrator tool
33
+ * @param {object} args - Tool arguments
34
+ * @param {object} project - Project configuration from database
35
+ * @returns {Promise<object>} MCP response
36
+ */
37
+ export async function executeOrchestratorTool(toolName, args, project) {
38
+ switch (toolName) {
39
+ case 'create_content':
40
+ return handleCreateContent(args, project)
41
+
42
+ case 'save_content':
43
+ return handleSaveContent(args)
44
+
45
+ case 'publish_content':
46
+ return handlePublishContent(args)
47
+
48
+ case 'get_session':
49
+ return handleGetSession()
50
+
51
+ case 'remove_article':
52
+ return handleRemoveArticle(args)
53
+
54
+ case 'clear_session':
55
+ return handleClearSession(args)
56
+
57
+ case 'list_content':
58
+ return handleListContent(args)
59
+
60
+ case 'load_content':
61
+ return handleLoadContent(args)
62
+
63
+ default:
64
+ throw new Error(`Unknown orchestrator tool: ${toolName}`)
65
+ }
66
+ }
67
+
68
+ // ============================================================================
69
+ // Individual Tool Handlers
70
+ // ============================================================================
71
+
72
+ async function handleCreateContent(args, project) {
73
+ resetSession()
74
+ const { request = '', count = 1, publish_to = [], with_images = true } = args
75
+
76
+ const plan = buildWorkflowPlan(
77
+ request || `content about ${project?.niche || 'the project topic'}`,
78
+ count,
79
+ publish_to,
80
+ with_images,
81
+ project
82
+ )
83
+
84
+ sessionState.currentWorkflow = plan
85
+ saveSession()
86
+
87
+ // Build response with clear instructions
88
+ const mcpList = plan.available_integrations.external_mcps.length > 0
89
+ ? plan.available_integrations.external_mcps.join(', ')
90
+ : 'None configured'
91
+
92
+ let response = `# Content Creation Workflow Started
93
+
94
+ ## PROJECT REQUIREMENTS (from Supabase database)
95
+ - Word Count: ${plan.settings.target_word_count} words (MINIMUM - strictly enforced!)
96
+ - Brand Voice: ${plan.settings.brand_voice || 'Not set'}
97
+ - Target Audience: ${plan.settings.target_audience || 'Not set'}
98
+
99
+ ## Your Request
100
+ "${plan.request}"
101
+
102
+ ## Project: ${plan.project_info.name}
103
+ - **URL:** ${plan.project_info.url}
104
+ - **Niche:** ${plan.project_info.niche}
105
+
106
+ ## Content Settings (from database - DO NOT USE DEFAULTS)
107
+ | Setting | Value |
108
+ |---------|-------|
109
+ | **Word Count** | ${plan.settings.target_word_count} words |
110
+ | **Reading Level** | ${plan.settings.reading_level_display} |
111
+ | **Brand Voice** | ${plan.settings.brand_voice} |
112
+ | **Target Audience** | ${plan.settings.target_audience || 'Not specified'} |
113
+ | **Primary Keywords** | ${plan.settings.primary_keywords?.join(', ') || 'Not set'} |
114
+ | **Geographic Focus** | ${plan.settings.geo_focus || 'Global'} |
115
+ | **Visual Style** | ${plan.settings.visual_style || 'Not specified'} |
116
+ | **Include Images** | ${plan.settings.include_images ? 'Yes' : 'No'} |
117
+ | **Images Required** | ${plan.settings.total_images} (1 cover + ${plan.settings.content_images} inline) |
118
+
119
+ ## Workflow Plan (4 Phases)
120
+
121
+ ### RESEARCH PHASE
122
+ ${plan.steps.filter(s => ['keyword_research', 'seo_strategy', 'topical_map', 'content_calendar'].includes(s.action)).map(s => `${s.step}. **${s.action}**`).join('\n')}
123
+
124
+ ### CREATION PHASE
125
+ ${plan.steps.filter(s => ['content_planning', 'content_write'].includes(s.action)).map(s => `${s.step}. **${s.action}**`).join('\n')}
126
+
127
+ ### OPTIMIZATION PHASE
128
+ ${plan.steps.filter(s => ['quality_check', 'geo_optimize'].includes(s.action)).map(s => `${s.step}. **${s.action}**`).join('\n')}
129
+
130
+ ### PUBLISHING PHASE
131
+ ${plan.steps.filter(s => ['generate_images', 'publish'].includes(s.action)).map(s => `${s.step}. **${s.action}**`).join('\n')}
132
+
133
+ ## Available Integrations (from ~/.suparank/credentials.json)
134
+ - External MCPs: ${mcpList}
135
+ - Image Generation: ${plan.available_integrations.image_generation ? 'Ready' : 'Not configured'}
136
+ - Ghost CMS: ${plan.available_integrations.ghost ? 'Ready' : 'Not configured'}
137
+ - WordPress: ${plan.available_integrations.wordpress ? 'Ready' : 'Not configured'}
138
+
139
+ ---
140
+
141
+ ## Step 1 of ${plan.total_steps}: ${plan.steps[0].action.toUpperCase()}
142
+
143
+ ${plan.steps[0].instruction}
144
+
145
+ ---
146
+
147
+ **When you complete this step, move to Step 2.**
148
+ `
149
+
150
+ return {
151
+ content: [{
152
+ type: 'text',
153
+ text: response
154
+ }]
155
+ }
156
+ }
157
+
158
+ async function handleSaveContent(args) {
159
+ const { title, content, keywords = [], meta_description = '' } = args
160
+ const wordCount = content.split(/\s+/).length
161
+
162
+ // Create article object with unique ID
163
+ const articleId = generateArticleId()
164
+ const newArticle = {
165
+ id: articleId,
166
+ title,
167
+ content,
168
+ keywords,
169
+ metaDescription: meta_description,
170
+ metaTitle: title,
171
+ imageUrl: sessionState.imageUrl || null,
172
+ inlineImages: [...sessionState.inlineImages],
173
+ savedAt: new Date().toISOString(),
174
+ published: false,
175
+ publishedTo: [],
176
+ wordCount
177
+ }
178
+
179
+ // Add to articles array
180
+ sessionState.articles.push(newArticle)
181
+
182
+ // Track stats
183
+ incrementStat('articles_created')
184
+ incrementStat('words_written', wordCount)
185
+
186
+ // Also keep in current working fields for backwards compatibility
187
+ sessionState.title = title
188
+ sessionState.article = content
189
+ sessionState.keywords = keywords
190
+ sessionState.metaDescription = meta_description
191
+ sessionState.metadata = { meta_description }
192
+
193
+ // Persist session and save to folder
194
+ saveSession()
195
+ const contentFolder = saveContentToFolder()
196
+
197
+ progress('Content', `Saved "${title}" (${wordCount} words) as article #${sessionState.articles.length}${contentFolder ? ` → ${contentFolder}` : ''}`)
198
+
199
+ // Clear current working images for next article
200
+ sessionState.imageUrl = null
201
+ sessionState.inlineImages = []
202
+
203
+ const workflow = sessionState.currentWorkflow
204
+ const targetWordCount = workflow?.settings?.target_word_count
205
+ const wordCountOk = targetWordCount ? wordCount >= targetWordCount * 0.95 : true
206
+ const shortfall = targetWordCount ? targetWordCount - wordCount : 0
207
+
208
+ log(`Word count check: ${wordCount} words (target: ${targetWordCount}, ok: ${wordCountOk})`)
209
+
210
+ // Find next step info
211
+ const imageStep = workflow?.steps?.find(s => s.action === 'generate_images')
212
+ const totalImages = workflow?.settings?.total_images || 0
213
+ const includeImages = workflow?.settings?.include_images
214
+
215
+ // Fetch WordPress categories
216
+ let categoriesSection = ''
217
+ if (hasCredential('wordpress')) {
218
+ const wpCategories = await fetchWordPressCategories()
219
+ if (wpCategories && wpCategories.length > 0) {
220
+ const categoryList = wpCategories
221
+ .slice(0, 15)
222
+ .map(c => `- **${c.name}** (${c.count} posts)${c.description ? `: ${c.description}` : ''}`)
223
+ .join('\n')
224
+ categoriesSection = `\n## WordPress Categories Available
225
+ Pick the most relevant category when publishing:
226
+ ${categoryList}
227
+
228
+ When calling \`publish_content\`, include the \`category\` parameter with your choice.\n`
229
+ }
230
+ }
231
+
232
+ // Show all articles in session
233
+ const articlesListSection = sessionState.articles.length > 1 ? `
234
+ ## Articles in Session (${sessionState.articles.length} total)
235
+ ${sessionState.articles.map((art, i) => {
236
+ const status = art.published ? `published to ${art.publishedTo.join(', ')}` : 'unpublished'
237
+ return `${i + 1}. **${art.title}** (${art.wordCount} words) - ${status}`
238
+ }).join('\n')}
239
+
240
+ Use \`publish_content\` to publish all unpublished articles, or \`get_session\` to see full details.
241
+ ` : ''
242
+
243
+ return {
244
+ content: [{
245
+ type: 'text',
246
+ text: `# Content Saved to Session (Article #${sessionState.articles.length})
247
+
248
+ **Title:** ${title}
249
+ **Article ID:** ${articleId}
250
+ **Word Count:** ${wordCount} words ${targetWordCount ? (wordCountOk ? '(ok)' : `(target: ${targetWordCount})`) : '(no target set)'}
251
+ **Meta Description:** ${meta_description ? `${meta_description.length} chars` : 'Missing!'}
252
+ **Keywords:** ${keywords.join(', ') || 'none specified'}
253
+ **Images:** ${newArticle.imageUrl ? '1 cover' : 'no cover'}${newArticle.inlineImages.length > 0 ? ` + ${newArticle.inlineImages.length} inline` : ''}
254
+
255
+ ${targetWordCount && !wordCountOk ? `
256
+ **WORD COUNT NOT MET - ${shortfall} WORDS SHORT!**
257
+ Target: ${targetWordCount} words | Actual: ${wordCount} words
258
+
259
+ The article does not meet the project's word count requirement.
260
+ Please EXPAND the content before publishing.
261
+ ` : ''}
262
+ ${!meta_description ? '**Warning:** Meta description is missing. Add it for better SEO.\n' : ''}
263
+ ${articlesListSection}${categoriesSection}
264
+ ## Next Step${includeImages && imageStep ? ': Generate Images' : ': Ready to Publish or Continue'}
265
+ ${includeImages && imageStep ? `Generate **${totalImages} images** (1 cover + ${totalImages - 1} inline images).
266
+
267
+ Call \`generate_image\` ${totalImages} times with prompts based on your article sections.` : `You can:
268
+ - **Add more articles**: Continue creating content (each save_content adds to the batch)
269
+ - **Publish all**: Call \`publish_content\` to publish all ${sessionState.articles.length} article(s)
270
+ - **View session**: Call \`get_session\` to see all saved articles`}`
271
+ }]
272
+ }
273
+ }
274
+
275
+ async function handlePublishContent(args) {
276
+ const { platforms = ['all'], status = 'draft', category = '', article_numbers = [] } = args
277
+
278
+ // Determine which articles to publish
279
+ let articlesToPublish = []
280
+
281
+ if (article_numbers && article_numbers.length > 0) {
282
+ articlesToPublish = article_numbers
283
+ .map(num => sessionState.articles[num - 1])
284
+ .filter(art => art && !art.published)
285
+
286
+ if (articlesToPublish.length === 0) {
287
+ return {
288
+ content: [{
289
+ type: 'text',
290
+ text: `No valid unpublished articles found for numbers: ${article_numbers.join(', ')}
291
+
292
+ Use \`get_session\` to see available articles and their numbers.`
293
+ }]
294
+ }
295
+ }
296
+ } else {
297
+ articlesToPublish = sessionState.articles.filter(art => !art.published)
298
+ }
299
+
300
+ // Fallback: Check if there's a current working article not yet saved
301
+ if (articlesToPublish.length === 0 && sessionState.article && sessionState.title) {
302
+ articlesToPublish = [{
303
+ id: 'current',
304
+ title: sessionState.title,
305
+ content: sessionState.article,
306
+ keywords: sessionState.keywords || [],
307
+ metaDescription: sessionState.metaDescription || '',
308
+ imageUrl: sessionState.imageUrl,
309
+ inlineImages: sessionState.inlineImages
310
+ }]
311
+ }
312
+
313
+ if (articlesToPublish.length === 0) {
314
+ return {
315
+ content: [{
316
+ type: 'text',
317
+ text: `No unpublished articles found in session.
318
+
319
+ Use \`save_content\` after writing an article, then call \`publish_content\`.
320
+ Or use \`get_session\` to see current session state.`
321
+ }]
322
+ }
323
+ }
324
+
325
+ const hasGhost = hasCredential('ghost')
326
+ const hasWordPress = hasCredential('wordpress')
327
+ const shouldPublishGhost = hasGhost && (platforms.includes('all') || platforms.includes('ghost'))
328
+ const shouldPublishWordPress = hasWordPress && (platforms.includes('all') || platforms.includes('wordpress'))
329
+
330
+ const allResults = []
331
+ progress('Publishing', `Starting batch publish of ${articlesToPublish.length} article(s)`)
332
+
333
+ for (let i = 0; i < articlesToPublish.length; i++) {
334
+ const article = articlesToPublish[i]
335
+ progress('Publishing', `Article ${i + 1}/${articlesToPublish.length}: "${article.title}"`)
336
+
337
+ // Inject inline images
338
+ const contentWithImages = injectImagesIntoContent(
339
+ article.content,
340
+ article.inlineImages || []
341
+ )
342
+
343
+ const articleResults = {
344
+ article: article.title,
345
+ articleId: article.id,
346
+ wordCount: article.wordCount || contentWithImages.split(/\s+/).length,
347
+ platforms: []
348
+ }
349
+
350
+ // Publish to Ghost
351
+ if (shouldPublishGhost) {
352
+ try {
353
+ const ghostResult = await executeGhostPublish({
354
+ title: article.title,
355
+ content: contentWithImages,
356
+ status: status,
357
+ tags: article.keywords || [],
358
+ featured_image_url: article.imageUrl
359
+ })
360
+ articleResults.platforms.push({ platform: 'Ghost', success: true, result: ghostResult })
361
+ } catch (e) {
362
+ articleResults.platforms.push({ platform: 'Ghost', success: false, error: e.message })
363
+ }
364
+ }
365
+
366
+ // Publish to WordPress
367
+ if (shouldPublishWordPress) {
368
+ try {
369
+ const categories = category ? [category] : []
370
+ const wpResult = await executeWordPressPublish({
371
+ title: article.title,
372
+ content: contentWithImages,
373
+ status: status,
374
+ categories: categories,
375
+ tags: article.keywords || [],
376
+ featured_image_url: article.imageUrl
377
+ })
378
+ articleResults.platforms.push({ platform: 'WordPress', success: true, result: wpResult })
379
+ } catch (e) {
380
+ articleResults.platforms.push({ platform: 'WordPress', success: false, error: e.message })
381
+ }
382
+ }
383
+
384
+ // Mark article as published
385
+ const hasSuccess = articleResults.platforms.some(p => p.success)
386
+ if (hasSuccess && article.id !== 'current') {
387
+ const articleIndex = sessionState.articles.findIndex(a => a.id === article.id)
388
+ if (articleIndex !== -1) {
389
+ sessionState.articles[articleIndex].published = true
390
+ sessionState.articles[articleIndex].publishedTo = articleResults.platforms
391
+ .filter(p => p.success)
392
+ .map(p => p.platform.toLowerCase())
393
+ sessionState.articles[articleIndex].publishedAt = new Date().toISOString()
394
+ }
395
+ }
396
+
397
+ allResults.push(articleResults)
398
+ }
399
+
400
+ saveSession()
401
+
402
+ // Build response
403
+ const totalArticles = allResults.length
404
+ const successfulArticles = allResults.filter(r => r.platforms.some(p => p.success)).length
405
+ const totalWords = allResults.reduce((sum, r) => sum + r.wordCount, 0)
406
+
407
+ let response = `# Batch Publishing Results
408
+
409
+ ## Summary
410
+ - **Articles Published:** ${successfulArticles}/${totalArticles}
411
+ - **Total Words:** ${totalWords.toLocaleString()}
412
+ - **Status:** ${status}
413
+ - **Platforms:** ${[shouldPublishGhost ? 'Ghost' : null, shouldPublishWordPress ? 'WordPress' : null].filter(Boolean).join(', ') || 'None'}
414
+ ${category ? `- **Category:** ${category}` : ''}
415
+
416
+ ---
417
+
418
+ `
419
+
420
+ for (const result of allResults) {
421
+ const hasAnySuccess = result.platforms.some(p => p.success)
422
+ response += `## ${hasAnySuccess ? '[OK]' : '[FAILED]'} ${result.article}\n`
423
+ response += `**Words:** ${result.wordCount}\n\n`
424
+
425
+ for (const p of result.platforms) {
426
+ if (p.success) {
427
+ response += `**${p.platform}:** Published\n`
428
+ const resultText = p.result?.content?.[0]?.text || ''
429
+ const urlMatch = resultText.match(/https?:\/\/[^\s\)]+/)
430
+ if (urlMatch) {
431
+ response += `URL: ${urlMatch[0]}\n`
432
+ }
433
+ } else {
434
+ response += `**${p.platform}:** Failed - ${p.error}\n`
435
+ }
436
+ }
437
+ response += '\n'
438
+ }
439
+
440
+ const remainingUnpublished = sessionState.articles.filter(a => !a.published)
441
+ if (remainingUnpublished.length > 0) {
442
+ response += `---\n\n**${remainingUnpublished.length} article(s) still unpublished** in session.\n`
443
+ } else if (sessionState.articles.length > 0) {
444
+ response += `---\n\n**All ${sessionState.articles.length} articles published!**\n`
445
+ }
446
+
447
+ return {
448
+ content: [{
449
+ type: 'text',
450
+ text: response
451
+ }]
452
+ }
453
+ }
454
+
455
+ function handleGetSession() {
456
+ const totalImagesNeeded = sessionState.currentWorkflow?.settings?.total_images || 0
457
+ const imagesGenerated = (sessionState.imageUrl ? 1 : 0) + sessionState.inlineImages.length
458
+ const workflow = sessionState.currentWorkflow
459
+
460
+ const totalArticles = sessionState.articles.length
461
+ const unpublishedArticles = sessionState.articles.filter(a => !a.published)
462
+ const publishedArticles = sessionState.articles.filter(a => a.published)
463
+ const totalWords = sessionState.articles.reduce((sum, a) => sum + (a.wordCount || 0), 0)
464
+ const totalImages = sessionState.articles.reduce((sum, a) => {
465
+ return sum + (a.imageUrl ? 1 : 0) + (a.inlineImages?.length || 0)
466
+ }, 0)
467
+
468
+ const articlesSection = sessionState.articles.length > 0 ? `
469
+ ## Saved Articles (${totalArticles} total)
470
+
471
+ | # | Title | Words | Images | Status |
472
+ |---|-------|-------|--------|--------|
473
+ ${sessionState.articles.map((art, i) => {
474
+ const imgCount = (art.imageUrl ? 1 : 0) + (art.inlineImages?.length || 0)
475
+ const status = art.published ? `${art.publishedTo.join(', ')}` : 'Unpublished'
476
+ return `| ${i + 1} | ${art.title.substring(0, 40)}${art.title.length > 40 ? '...' : ''} | ${art.wordCount} | ${imgCount} | ${status} |`
477
+ }).join('\n')}
478
+
479
+ **Summary:** ${totalWords.toLocaleString()} total words, ${totalImages} total images
480
+ **Unpublished:** ${unpublishedArticles.length} article(s) ready to publish
481
+ ` : `
482
+ ## Saved Articles
483
+ No articles saved yet. Use \`save_content\` after writing an article.
484
+ `
485
+
486
+ const currentWorkingSection = sessionState.title && sessionState.article ? `
487
+ ## Current Working Article
488
+ **Title:** ${sessionState.title}
489
+ **Word Count:** ${sessionState.article.split(/\s+/).length} words
490
+ **Meta Description:** ${sessionState.metaDescription || 'Not set'}
491
+ **Cover Image:** ${sessionState.imageUrl ? 'Generated' : 'Not yet'}
492
+ **Inline Images:** ${sessionState.inlineImages.length}
493
+
494
+ *This article is being edited. Call \`save_content\` to add it to the session.*
495
+ ` : ''
496
+
497
+ return {
498
+ content: [{
499
+ type: 'text',
500
+ text: `# Session State
501
+
502
+ **Workflow:** ${workflow?.workflow_id || 'None active'}
503
+ **Total Articles:** ${totalArticles}
504
+ **Ready to Publish:** ${unpublishedArticles.length}
505
+ **Already Published:** ${publishedArticles.length}
506
+ ${articlesSection}${currentWorkingSection}
507
+ ## Current Working Images (${imagesGenerated}/${totalImagesNeeded})
508
+ **Cover Image:** ${sessionState.imageUrl || 'Not generated'}
509
+ **Inline Images:** ${sessionState.inlineImages.length > 0 ? sessionState.inlineImages.map((url, i) => `\n ${i+1}. ${url.substring(0, 60)}...`).join('') : 'None'}
510
+
511
+ ${workflow ? `
512
+ ## Project Settings
513
+ - **Project:** ${workflow.project_info?.name || 'Unknown'}
514
+ - **Niche:** ${workflow.project_info?.niche || 'Unknown'}
515
+ - **Word Count Target:** ${workflow.settings?.target_word_count || 'Not set'}
516
+ - **Reading Level:** ${workflow.settings?.reading_level_display || 'Not set'}
517
+ - **Brand Voice:** ${workflow.settings?.brand_voice || 'Not set'}
518
+ - **Include Images:** ${workflow.settings?.include_images ? 'Yes' : 'No'}
519
+ ` : ''}
520
+ ## Actions
521
+ - **Publish all unpublished:** Call \`publish_content\`
522
+ - **Add more articles:** Use \`create_content\` or \`content_write\` then \`save_content\`
523
+ - **Remove articles:** Call \`remove_article\` with article numbers
524
+ - **Clear session:** Call \`clear_session\` with confirm: true`
525
+ }]
526
+ }
527
+ }
528
+
529
+ function handleRemoveArticle(args) {
530
+ const { article_numbers } = args
531
+
532
+ if (!article_numbers || article_numbers.length === 0) {
533
+ return {
534
+ content: [{
535
+ type: 'text',
536
+ text: `Please specify article numbers to remove. Use \`get_session\` to see article numbers.`
537
+ }]
538
+ }
539
+ }
540
+
541
+ const sortedNumbers = [...article_numbers].sort((a, b) => b - a)
542
+ const removed = []
543
+ const skipped = []
544
+
545
+ for (const num of sortedNumbers) {
546
+ const index = num - 1
547
+ if (index < 0 || index >= sessionState.articles.length) {
548
+ skipped.push({ num, reason: 'not found' })
549
+ continue
550
+ }
551
+
552
+ const article = sessionState.articles[index]
553
+ if (article.published) {
554
+ skipped.push({ num, reason: 'already published', title: article.title })
555
+ continue
556
+ }
557
+
558
+ const [removedArticle] = sessionState.articles.splice(index, 1)
559
+ removed.push({ num, title: removedArticle.title })
560
+ }
561
+
562
+ saveSession()
563
+
564
+ let response = `# Article Removal Results\n\n`
565
+
566
+ if (removed.length > 0) {
567
+ response += `## Removed (${removed.length})\n`
568
+ for (const r of removed) {
569
+ response += `- #${r.num}: "${r.title}"\n`
570
+ }
571
+ response += '\n'
572
+ }
573
+
574
+ if (skipped.length > 0) {
575
+ response += `## Skipped (${skipped.length})\n`
576
+ for (const s of skipped) {
577
+ if (s.reason === 'already published') {
578
+ response += `- #${s.num}: "${s.title}" (already published - cannot remove)\n`
579
+ } else {
580
+ response += `- #${s.num}: not found\n`
581
+ }
582
+ }
583
+ response += '\n'
584
+ }
585
+
586
+ response += `---\n\n**${sessionState.articles.length} article(s) remaining in session.**`
587
+
588
+ return {
589
+ content: [{
590
+ type: 'text',
591
+ text: response
592
+ }]
593
+ }
594
+ }
595
+
596
+ function handleClearSession(args) {
597
+ const { confirm } = args
598
+
599
+ if (!confirm) {
600
+ return {
601
+ content: [{
602
+ type: 'text',
603
+ text: `**Clear Session requires confirmation**
604
+
605
+ This will permanently remove:
606
+ - ${sessionState.articles.length} saved article(s)
607
+ - All generated images
608
+ - Current workflow state
609
+
610
+ To confirm, call \`clear_session\` with \`confirm: true\``
611
+ }]
612
+ }
613
+ }
614
+
615
+ const articleCount = sessionState.articles.length
616
+ const unpublishedCount = sessionState.articles.filter(a => !a.published).length
617
+
618
+ resetSession()
619
+
620
+ return {
621
+ content: [{
622
+ type: 'text',
623
+ text: `# Session Cleared
624
+
625
+ Removed:
626
+ - ${articleCount} article(s) (${unpublishedCount} unpublished)
627
+ - All workflow state
628
+ - All generated images
629
+
630
+ Session is now empty. Ready for new content creation.`
631
+ }]
632
+ }
633
+ }
634
+
635
+ function handleListContent(args) {
636
+ const { limit = 20 } = args
637
+ const contentDir = getContentDir()
638
+
639
+ if (!fs.existsSync(contentDir)) {
640
+ return {
641
+ content: [{
642
+ type: 'text',
643
+ text: `# Saved Content
644
+
645
+ No content directory found at \`${contentDir}\`.
646
+
647
+ Save articles using \`save_content\` and they will appear here.`
648
+ }]
649
+ }
650
+ }
651
+
652
+ const folders = fs.readdirSync(contentDir, { withFileTypes: true })
653
+ .filter(dirent => dirent.isDirectory())
654
+ .map(dirent => {
655
+ const folderPath = path.join(contentDir, dirent.name)
656
+ const metadataPath = path.join(folderPath, 'metadata.json')
657
+
658
+ let metadata = null
659
+ if (fs.existsSync(metadataPath)) {
660
+ try {
661
+ metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'))
662
+ } catch (e) {
663
+ // Ignore parse errors
664
+ }
665
+ }
666
+
667
+ return {
668
+ name: dirent.name,
669
+ path: folderPath,
670
+ metadata,
671
+ mtime: fs.statSync(folderPath).mtime
672
+ }
673
+ })
674
+ .sort((a, b) => b.mtime - a.mtime)
675
+ .slice(0, limit)
676
+
677
+ if (folders.length === 0) {
678
+ return {
679
+ content: [{
680
+ type: 'text',
681
+ text: `# Saved Content
682
+
683
+ No saved articles found in \`${contentDir}\`.
684
+
685
+ Save articles using \`save_content\` and they will appear here.`
686
+ }]
687
+ }
688
+ }
689
+
690
+ let response = `# Saved Content (${folders.length} articles)
691
+
692
+ | # | Date | Title | Words | Project |
693
+ |---|------|-------|-------|---------|
694
+ `
695
+ folders.forEach((folder, i) => {
696
+ const date = folder.name.split('-').slice(0, 3).join('-')
697
+ const title = folder.metadata?.title || folder.name.split('-').slice(3).join('-')
698
+ const words = folder.metadata?.wordCount || '?'
699
+ const project = folder.metadata?.projectSlug || '-'
700
+ response += `| ${i + 1} | ${date} | ${title.substring(0, 35)}${title.length > 35 ? '...' : ''} | ${words} | ${project} |\n`
701
+ })
702
+
703
+ response += `
704
+ ---
705
+
706
+ ## To Load an Article
707
+
708
+ Call \`load_content\` with the folder name:
709
+ \`\`\`
710
+ load_content({ folder_name: "${folders[0]?.name}" })
711
+ \`\`\`
712
+
713
+ Once loaded, you can run optimization tools:
714
+ - \`quality_check\` - Pre-publish quality assurance
715
+ - \`geo_optimize\` - AI search engine optimization
716
+ - \`internal_links\` - Internal linking suggestions
717
+ - \`schema_generate\` - JSON-LD structured data
718
+ - \`save_content\` - Re-save with changes
719
+ - \`publish_content\` - Publish to CMS`
720
+
721
+ return {
722
+ content: [{
723
+ type: 'text',
724
+ text: response
725
+ }]
726
+ }
727
+ }
728
+
729
+ function handleLoadContent(args) {
730
+ const { folder_name } = args
731
+
732
+ if (!folder_name) {
733
+ return {
734
+ content: [{
735
+ type: 'text',
736
+ text: `Please specify a folder_name. Use \`list_content\` to see available articles.`
737
+ }]
738
+ }
739
+ }
740
+
741
+ // Sanitize folder_name to prevent path traversal
742
+ let folderPath
743
+ try {
744
+ folderPath = getContentFolderSafe(folder_name)
745
+ } catch (error) {
746
+ return {
747
+ content: [{
748
+ type: 'text',
749
+ text: `Invalid folder name: ${error.message}`
750
+ }]
751
+ }
752
+ }
753
+
754
+ if (!fs.existsSync(folderPath)) {
755
+ return {
756
+ content: [{
757
+ type: 'text',
758
+ text: `Folder not found: \`${folder_name}\`
759
+
760
+ Use \`list_content\` to see available articles.`
761
+ }]
762
+ }
763
+ }
764
+
765
+ const articlePath = path.join(folderPath, 'article.md')
766
+ const metadataPath = path.join(folderPath, 'metadata.json')
767
+
768
+ if (!fs.existsSync(articlePath)) {
769
+ return {
770
+ content: [{
771
+ type: 'text',
772
+ text: `No article.md found in \`${folder_name}\``
773
+ }]
774
+ }
775
+ }
776
+
777
+ const articleContent = fs.readFileSync(articlePath, 'utf-8')
778
+ let metadata = {}
779
+ if (fs.existsSync(metadataPath)) {
780
+ try {
781
+ metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'))
782
+ } catch (e) {
783
+ log(`Warning: Failed to parse metadata.json: ${e.message}`)
784
+ }
785
+ }
786
+
787
+ // Load into session state
788
+ sessionState.title = metadata.title || folder_name
789
+ sessionState.article = articleContent
790
+ sessionState.keywords = metadata.keywords || []
791
+ sessionState.metaDescription = metadata.metaDescription || ''
792
+ sessionState.metaTitle = metadata.metaTitle || metadata.title || folder_name
793
+ sessionState.imageUrl = metadata.imageUrl || null
794
+ sessionState.inlineImages = metadata.inlineImages || []
795
+ sessionState.contentFolder = folderPath
796
+
797
+ // Add to articles array if not already there
798
+ const existingIndex = sessionState.articles.findIndex(a => a.title === sessionState.title)
799
+ if (existingIndex === -1) {
800
+ const loadedArticle = {
801
+ id: generateArticleId(),
802
+ title: sessionState.title,
803
+ content: articleContent,
804
+ keywords: sessionState.keywords,
805
+ metaDescription: sessionState.metaDescription,
806
+ metaTitle: sessionState.metaTitle,
807
+ imageUrl: sessionState.imageUrl,
808
+ inlineImages: sessionState.inlineImages,
809
+ savedAt: metadata.createdAt || new Date().toISOString(),
810
+ published: false,
811
+ publishedTo: [],
812
+ wordCount: articleContent.split(/\s+/).length,
813
+ loadedFrom: folderPath
814
+ }
815
+ sessionState.articles.push(loadedArticle)
816
+ }
817
+
818
+ saveSession()
819
+
820
+ const wordCount = articleContent.split(/\s+/).length
821
+ progress('Content', `Loaded "${sessionState.title}" (${wordCount} words) from ${folder_name}`)
822
+
823
+ return {
824
+ content: [{
825
+ type: 'text',
826
+ text: `# Content Loaded
827
+
828
+ **Title:** ${sessionState.title}
829
+ **Word Count:** ${wordCount}
830
+ **Keywords:** ${sessionState.keywords.join(', ') || 'None'}
831
+ **Meta Description:** ${sessionState.metaDescription ? `${sessionState.metaDescription.length} chars` : 'None'}
832
+ **Cover Image:** ${sessionState.imageUrl ? 'Yes' : 'No'}
833
+ **Inline Images:** ${sessionState.inlineImages.length}
834
+ **Source:** \`${folderPath}\`
835
+
836
+ ---
837
+
838
+ ## Now you can run optimization tools:
839
+
840
+ - **\`quality_check\`** - Pre-publish quality assurance
841
+ - **\`geo_optimize\`** - Optimize for AI search engines (ChatGPT, Perplexity)
842
+ - **\`internal_links\`** - Get internal linking suggestions
843
+ - **\`schema_generate\`** - Generate JSON-LD structured data
844
+ - **\`save_content\`** - Re-save after making changes
845
+ - **\`publish_content\`** - Publish to WordPress/Ghost
846
+
847
+ Article is now in session (#${sessionState.articles.length}) and ready for further processing.`
848
+ }]
849
+ }
850
+ }