suparank 1.0.0 → 1.2.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 (3) hide show
  1. package/bin/suparank.js +82 -0
  2. package/mcp-client.js +1043 -127
  3. package/package.json +1 -1
package/mcp-client.js CHANGED
@@ -47,12 +47,10 @@ if (!apiKey) {
47
47
  console.error('Usage: node mcp-client.js <project-slug> <api-key>')
48
48
  console.error('')
49
49
  console.error('To create an API key:')
50
- console.error('1. Sign in at https://suparank.io/dashboard')
50
+ console.error('1. Sign in to the dashboard at http://localhost:3001')
51
51
  console.error('2. Go to Settings > API Keys')
52
52
  console.error('3. Click "Create API Key"')
53
53
  console.error('4. Copy the key (shown only once!)')
54
- console.error('')
55
- console.error('Or run: npx suparank setup')
56
54
  process.exit(1)
57
55
  }
58
56
 
@@ -74,9 +72,16 @@ const progress = (step, message) => console.error(`[suparank] ${step}: ${message
74
72
  let localCredentials = null
75
73
 
76
74
  // Session state for orchestration - stores content between steps
75
+ // Supports multiple articles for batch content creation workflows
77
76
  const sessionState = {
78
77
  currentWorkflow: null,
79
78
  stepResults: {},
79
+
80
+ // Multi-article support: Array of saved articles
81
+ articles: [],
82
+
83
+ // Current working article (being edited/created)
84
+ // These fields are for the article currently being worked on
80
85
  article: null,
81
86
  title: null,
82
87
  imageUrl: null, // Cover image
@@ -85,9 +90,17 @@ const sessionState = {
85
90
  metadata: null,
86
91
  metaTitle: null,
87
92
  metaDescription: null,
93
+
88
94
  contentFolder: null // Path to saved content folder
89
95
  }
90
96
 
97
+ /**
98
+ * Generate a unique article ID
99
+ */
100
+ function generateArticleId() {
101
+ return `art_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`
102
+ }
103
+
91
104
  /**
92
105
  * Get the path to the Suparank config directory (~/.suparank/)
93
106
  */
@@ -219,6 +232,7 @@ async function fetchWithRetry(url, options = {}, maxRetries = 3, timeoutMs = 300
219
232
 
220
233
  /**
221
234
  * Load session state from file (survives MCP restarts)
235
+ * Supports both old single-article format and new multi-article format
222
236
  */
223
237
  function loadSession() {
224
238
  try {
@@ -239,6 +253,30 @@ function loadSession() {
239
253
  // Restore session state
240
254
  sessionState.currentWorkflow = saved.currentWorkflow || null
241
255
  sessionState.stepResults = saved.stepResults || {}
256
+
257
+ // Load articles array (new format)
258
+ sessionState.articles = saved.articles || []
259
+
260
+ // Backwards compatibility: migrate old single-article format to articles array
261
+ if (!saved.articles && saved.article && saved.title) {
262
+ const migratedArticle = {
263
+ id: generateArticleId(),
264
+ title: saved.title,
265
+ content: saved.article,
266
+ keywords: saved.keywords || [],
267
+ metaDescription: saved.metaDescription || '',
268
+ metaTitle: saved.metaTitle || saved.title,
269
+ imageUrl: saved.imageUrl || null,
270
+ inlineImages: saved.inlineImages || [],
271
+ savedAt: saved.savedAt,
272
+ published: false,
273
+ publishedTo: []
274
+ }
275
+ sessionState.articles = [migratedArticle]
276
+ log(`Migrated old session format to multi-article format`)
277
+ }
278
+
279
+ // Current working article fields (cleared after each save)
242
280
  sessionState.article = saved.article || null
243
281
  sessionState.title = saved.title || null
244
282
  sessionState.imageUrl = saved.imageUrl || null
@@ -250,12 +288,22 @@ function loadSession() {
250
288
  sessionState.contentFolder = saved.contentFolder || null
251
289
 
252
290
  log(`Restored session from ${sessionFile}`)
253
- if (sessionState.title) {
254
- log(` - Article: "${sessionState.title}" (${sessionState.article?.split(/\s+/).length || 0} words)`)
291
+
292
+ // Show all saved articles
293
+ if (sessionState.articles.length > 0) {
294
+ log(` - ${sessionState.articles.length} article(s) in session:`)
295
+ sessionState.articles.forEach((art, i) => {
296
+ const wordCount = art.content?.split(/\s+/).length || 0
297
+ const status = art.published ? `published to ${art.publishedTo.join(', ')}` : 'unpublished'
298
+ log(` ${i + 1}. "${art.title}" (${wordCount} words) - ${status}`)
299
+ })
255
300
  }
256
- if (sessionState.imageUrl) {
257
- log(` - Cover image: ${sessionState.imageUrl.substring(0, 50)}...`)
301
+
302
+ // Show current working article if different
303
+ if (sessionState.title && !sessionState.articles.find(a => a.title === sessionState.title)) {
304
+ log(` - Current working: "${sessionState.title}" (${sessionState.article?.split(/\s+/).length || 0} words)`)
258
305
  }
306
+
259
307
  if (sessionState.contentFolder) {
260
308
  log(` - Content folder: ${sessionState.contentFolder}`)
261
309
  }
@@ -279,6 +327,9 @@ function saveSession() {
279
327
  const toSave = {
280
328
  currentWorkflow: sessionState.currentWorkflow,
281
329
  stepResults: sessionState.stepResults,
330
+ // Multi-article support
331
+ articles: sessionState.articles,
332
+ // Current working article (for backwards compat and active editing)
282
333
  article: sessionState.article,
283
334
  title: sessionState.title,
284
335
  imageUrl: sessionState.imageUrl,
@@ -293,13 +344,63 @@ function saveSession() {
293
344
 
294
345
  // Atomic write to prevent corruption
295
346
  atomicWriteSync(sessionFile, JSON.stringify(toSave, null, 2))
296
- progress('Session', `Saved to ${sessionFile}`)
347
+ progress('Session', `Saved to ${sessionFile} (${sessionState.articles.length} articles)`)
297
348
  } catch (error) {
298
349
  log(`Warning: Failed to save session: ${error.message}`)
299
350
  progress('Session', `FAILED to save: ${error.message}`)
300
351
  }
301
352
  }
302
353
 
354
+ /**
355
+ * Extract image prompts from article content
356
+ * Uses H2 headings to create contextual image prompts
357
+ * @param {string} content - Article content in markdown
358
+ * @param {object} projectConfig - Project configuration from database
359
+ * @returns {Array<{heading: string, prompt: string}>} - Array of image prompts
360
+ */
361
+ function extractImagePromptsFromArticle(content, projectConfig) {
362
+ // Extract H2 headings from markdown
363
+ const headings = content.match(/^## .+$/gm) || []
364
+
365
+ // Get visual style from project config
366
+ const visualStyle = projectConfig?.visual_style?.image_aesthetic || 'professional minimalist'
367
+ const brandColors = projectConfig?.visual_style?.colors || []
368
+ const brandVoice = projectConfig?.brand?.voice || 'professional'
369
+ const niche = projectConfig?.site?.niche || ''
370
+
371
+ // Limit to 4 images (1 hero + 3 section images)
372
+ const selectedHeadings = headings.slice(0, 4)
373
+
374
+ return selectedHeadings.map((heading, index) => {
375
+ const topic = heading.replace(/^## /, '').trim()
376
+
377
+ // Create contextual prompt based on heading
378
+ let prompt = `${topic}`
379
+
380
+ // Add visual style
381
+ if (visualStyle) {
382
+ prompt += `, ${visualStyle} style`
383
+ }
384
+
385
+ // Add brand context for hero image
386
+ if (index === 0) {
387
+ prompt += `, hero image for article about ${niche}`
388
+ } else {
389
+ prompt += `, illustration for ${niche} article`
390
+ }
391
+
392
+ // Add quality modifiers
393
+ prompt += ', high quality, professional, clean composition, no text'
394
+
395
+ return {
396
+ heading: topic,
397
+ prompt: prompt,
398
+ type: index === 0 ? 'hero' : 'section',
399
+ aspectRatio: '16:9'
400
+ }
401
+ })
402
+ }
403
+
303
404
  /**
304
405
  * Save content to a dedicated folder with all assets
305
406
  * Creates: ~/.suparank/content/{date}-{slug}/
@@ -387,11 +488,12 @@ function clearSessionFile() {
387
488
  }
388
489
 
389
490
  /**
390
- * Reset session state for new workflow
491
+ * Reset session state for new workflow (clears everything including all articles)
391
492
  */
392
493
  function resetSession() {
393
494
  sessionState.currentWorkflow = null
394
495
  sessionState.stepResults = {}
496
+ sessionState.articles = [] // Clear all saved articles
395
497
  sessionState.article = null
396
498
  sessionState.title = null
397
499
  sessionState.imageUrl = null
@@ -406,6 +508,21 @@ function resetSession() {
406
508
  clearSessionFile()
407
509
  }
408
510
 
511
+ /**
512
+ * Clear current working article without removing saved articles
513
+ * Use this after saving an article to prepare for the next one
514
+ */
515
+ function clearCurrentArticle() {
516
+ sessionState.article = null
517
+ sessionState.title = null
518
+ sessionState.imageUrl = null
519
+ sessionState.inlineImages = []
520
+ sessionState.keywords = null
521
+ sessionState.metadata = null
522
+ sessionState.metaTitle = null
523
+ sessionState.metaDescription = null
524
+ }
525
+
409
526
  /**
410
527
  * Load credentials from ~/.suparank/credentials.json
411
528
  * Falls back to legacy .env.superwriter paths for backward compatibility
@@ -988,7 +1105,7 @@ No parameters needed - I use your project settings automatically!`,
988
1105
  },
989
1106
  {
990
1107
  name: 'publish_content',
991
- description: 'Publish saved content to configured CMS platforms. Automatically uses saved article and generated image.',
1108
+ description: 'Publish saved articles to configured CMS platforms. Publishes ALL unpublished articles in the session by default, or specific articles by index.',
992
1109
  inputSchema: {
993
1110
  type: 'object',
994
1111
  properties: {
@@ -1007,6 +1124,11 @@ No parameters needed - I use your project settings automatically!`,
1007
1124
  category: {
1008
1125
  type: 'string',
1009
1126
  description: 'WordPress category name - pick the most relevant one from available categories shown in save_content response'
1127
+ },
1128
+ article_numbers: {
1129
+ type: 'array',
1130
+ items: { type: 'number' },
1131
+ description: 'Optional: Publish specific articles by number (1, 2, 3...). If not specified, publishes ALL unpublished articles.'
1010
1132
  }
1011
1133
  }
1012
1134
  }
@@ -1018,6 +1140,63 @@ No parameters needed - I use your project settings automatically!`,
1018
1140
  type: 'object',
1019
1141
  properties: {}
1020
1142
  }
1143
+ },
1144
+ {
1145
+ name: 'remove_article',
1146
+ description: 'Remove specific article(s) from the session by number. Does not affect published articles.',
1147
+ inputSchema: {
1148
+ type: 'object',
1149
+ properties: {
1150
+ article_numbers: {
1151
+ type: 'array',
1152
+ items: { type: 'number' },
1153
+ description: 'Article numbers to remove (1, 2, 3...). Use get_session to see article numbers.'
1154
+ }
1155
+ },
1156
+ required: ['article_numbers']
1157
+ }
1158
+ },
1159
+ {
1160
+ name: 'clear_session',
1161
+ description: 'Clear all articles and reset session. Use with caution - this removes all unpublished content!',
1162
+ inputSchema: {
1163
+ type: 'object',
1164
+ properties: {
1165
+ confirm: {
1166
+ type: 'boolean',
1167
+ description: 'Must be true to confirm clearing all content'
1168
+ }
1169
+ },
1170
+ required: ['confirm']
1171
+ }
1172
+ },
1173
+ {
1174
+ name: 'list_content',
1175
+ description: 'List all previously saved content from ~/.suparank/content/. Shows articles that can be loaded back into session for further optimization.',
1176
+ inputSchema: {
1177
+ type: 'object',
1178
+ properties: {
1179
+ limit: {
1180
+ type: 'number',
1181
+ description: 'Max number of articles to show (default: 20)',
1182
+ default: 20
1183
+ }
1184
+ }
1185
+ }
1186
+ },
1187
+ {
1188
+ name: 'load_content',
1189
+ description: 'Load a previously saved article back into the session. Use list_content first to see available articles. Once loaded, you can run optimization tools like quality_check, geo_optimize, internal_links, schema_generate on it.',
1190
+ inputSchema: {
1191
+ type: 'object',
1192
+ properties: {
1193
+ folder_name: {
1194
+ type: 'string',
1195
+ description: 'Folder name from list_content (e.g., "2026-01-09-my-article-title")'
1196
+ }
1197
+ },
1198
+ required: ['folder_name']
1199
+ }
1021
1200
  }
1022
1201
  ]
1023
1202
 
@@ -1135,6 +1314,10 @@ function buildWorkflowPlan(request, count, publishTo, withImages, project) {
1135
1314
  }
1136
1315
  }
1137
1316
 
1317
+ // ═══════════════════════════════════════════════════════════
1318
+ // RESEARCH PHASE
1319
+ // ═══════════════════════════════════════════════════════════
1320
+
1138
1321
  stepNum++
1139
1322
  steps.push({
1140
1323
  step: stepNum,
@@ -1157,7 +1340,97 @@ ${mcpInstructions}
1157
1340
  store: 'keywords'
1158
1341
  })
1159
1342
 
1160
- // Step 2: Content Planning with SEO Meta
1343
+ // Step 2: SEO Strategy & Content Brief
1344
+ stepNum++
1345
+ steps.push({
1346
+ step: stepNum,
1347
+ type: 'llm_execute',
1348
+ action: 'seo_strategy',
1349
+ instruction: `Create SEO strategy and content brief for: "${request}"
1350
+
1351
+ **Using Keywords from Step 1:**
1352
+ - Use the primary keyword you identified
1353
+ - Incorporate secondary/LSI keywords naturally
1354
+
1355
+ **Project Context:**
1356
+ - Site: ${siteName}
1357
+ - Niche: ${niche}
1358
+ - Target audience: ${targetAudience || 'Not specified'}
1359
+ - Brand voice: ${brandVoice}
1360
+ - Geographic focus: ${geoFocus || 'Global'}
1361
+
1362
+ **Deliverables:**
1363
+ 1. **Search Intent Analysis** - What is the user trying to accomplish?
1364
+ 2. **Competitor Gap Analysis** - What are top 3 ranking pages missing?
1365
+ 3. **Content Brief:**
1366
+ - Recommended content type (guide/listicle/how-to/comparison)
1367
+ - Unique angle to differentiate from competitors
1368
+ - Key points to cover that competitors miss
1369
+ 4. **On-Page SEO Checklist:**
1370
+ - Title tag format
1371
+ - Meta description template
1372
+ - Header structure (H1, H2, H3)
1373
+ - Internal linking opportunities`,
1374
+ store: 'seo_strategy'
1375
+ })
1376
+
1377
+ // Step 3: Topical Map (Content Architecture)
1378
+ stepNum++
1379
+ steps.push({
1380
+ step: stepNum,
1381
+ type: 'llm_execute',
1382
+ action: 'topical_map',
1383
+ instruction: `Design content architecture for: "${request}"
1384
+
1385
+ **Build a Pillar-Cluster Structure:**
1386
+ - Main pillar topic (this article)
1387
+ - Supporting cluster articles (future content opportunities)
1388
+
1389
+ **Project Context:**
1390
+ - Site: ${siteName}
1391
+ - Niche: ${niche}
1392
+ - Primary keywords: ${keywordsDisplay}
1393
+
1394
+ **Deliverables:**
1395
+ 1. **Pillar Page Concept** - What should this main article establish?
1396
+ 2. **Cluster Topics** - 5-7 related subtopics for future articles
1397
+ 3. **Internal Linking Plan** - How these articles connect
1398
+ 4. **Content Gaps** - What topics are missing in this niche?
1399
+
1400
+ Note: Focus on the CURRENT article structure, but identify opportunities for a content cluster.`,
1401
+ store: 'topical_map'
1402
+ })
1403
+
1404
+ // Step 4: Content Calendar (only for multi-article requests)
1405
+ if (count > 1) {
1406
+ stepNum++
1407
+ steps.push({
1408
+ step: stepNum,
1409
+ type: 'llm_execute',
1410
+ action: 'content_calendar',
1411
+ instruction: `Plan content calendar for ${count} articles about: "${request}"
1412
+
1413
+ **Project Context:**
1414
+ - Site: ${siteName}
1415
+ - Niche: ${niche}
1416
+ - Articles to create: ${count}
1417
+
1418
+ **Deliverables:**
1419
+ 1. **Article Sequence** - Order to create articles (foundational → specific)
1420
+ 2. **Topic List** - ${count} specific titles/topics
1421
+ 3. **Keyword Assignment** - Primary keyword for each article
1422
+ 4. **Publishing Cadence** - Recommended frequency
1423
+
1424
+ Note: This guides the creation of all ${count} articles in this session.`,
1425
+ store: 'content_calendar'
1426
+ })
1427
+ }
1428
+
1429
+ // ═══════════════════════════════════════════════════════════
1430
+ // CREATION PHASE
1431
+ // ═══════════════════════════════════════════════════════════
1432
+
1433
+ // Step N: Content Planning with SEO Meta
1161
1434
  stepNum++
1162
1435
  steps.push({
1163
1436
  step: stepNum,
@@ -1221,7 +1494,93 @@ ${shouldGenerateImages ? '- Image placeholders: [IMAGE: description] where image
1221
1494
  store: 'article'
1222
1495
  })
1223
1496
 
1224
- // Step 4: Generate Images (if enabled in project settings AND credentials available)
1497
+ // ═══════════════════════════════════════════════════════════
1498
+ // OPTIMIZATION PHASE
1499
+ // ═══════════════════════════════════════════════════════════
1500
+
1501
+ // Quality Check - Pre-publish QA
1502
+ stepNum++
1503
+ steps.push({
1504
+ step: stepNum,
1505
+ type: 'llm_execute',
1506
+ action: 'quality_check',
1507
+ instruction: `Perform quality check on the article you just saved.
1508
+
1509
+ **Quality Checklist:**
1510
+
1511
+ 1. **SEO Check:**
1512
+ - ✓ Primary keyword in H1, first 100 words, URL slug
1513
+ - ✓ Secondary keywords distributed naturally
1514
+ - ✓ Meta title 50-60 characters
1515
+ - ✓ Meta description 150-160 characters
1516
+ - ✓ Proper header hierarchy (H1 → H2 → H3)
1517
+
1518
+ 2. **Content Quality:**
1519
+ - ✓ Word count meets requirement (${targetWordCount}+ words)
1520
+ - ✓ Reading level appropriate (${readingLevelDisplay})
1521
+ - ✓ No grammar or spelling errors
1522
+ - ✓ Factual accuracy (no made-up statistics)
1523
+
1524
+ 3. **Brand Consistency:**
1525
+ - ✓ Voice matches: ${brandVoice}
1526
+ - ✓ Speaks to: ${targetAudience || 'target audience'}
1527
+ - ✓ Aligns with ${siteName} brand
1528
+
1529
+ 4. **Engagement:**
1530
+ - ✓ Strong hook in introduction
1531
+ - ✓ Clear value proposition
1532
+ - ✓ Actionable takeaways
1533
+ - ✓ Compelling CTA in conclusion
1534
+
1535
+ **Report any issues found and suggest fixes. If major issues exist, fix them before proceeding.**`,
1536
+ store: 'quality_report'
1537
+ })
1538
+
1539
+ // GEO Optimize - AI Search Engine Optimization
1540
+ stepNum++
1541
+ steps.push({
1542
+ step: stepNum,
1543
+ type: 'llm_execute',
1544
+ action: 'geo_optimize',
1545
+ instruction: `Optimize article for AI search engines (ChatGPT, Perplexity, Google SGE, Claude).
1546
+
1547
+ **GEO (Generative Engine Optimization) Checklist:**
1548
+
1549
+ 1. **Structured Answers:**
1550
+ - ✓ Clear, direct answers to common questions
1551
+ - ✓ Definition boxes for key terms
1552
+ - ✓ TL;DR sections for complex topics
1553
+
1554
+ 2. **Citation-Worthy Content:**
1555
+ - ✓ Original statistics or data points
1556
+ - ✓ Expert quotes or authoritative sources
1557
+ - ✓ Unique insights not found elsewhere
1558
+
1559
+ 3. **LLM-Friendly Structure:**
1560
+ - ✓ Bulleted lists for easy extraction
1561
+ - ✓ Tables for comparisons
1562
+ - ✓ Step-by-step numbered processes
1563
+
1564
+ 4. **Semantic Clarity:**
1565
+ - ✓ Clear topic sentences per paragraph
1566
+ - ✓ Explicit cause-effect relationships
1567
+ - ✓ Avoid ambiguous pronouns
1568
+
1569
+ **Target AI Engines:**
1570
+ - ChatGPT (conversational answers)
1571
+ - Perplexity (citation-heavy)
1572
+ - Google SGE (structured snippets)
1573
+ - Claude (comprehensive analysis)
1574
+
1575
+ **Review the saved article and suggest specific improvements to make it more likely to be cited by AI search engines.**`,
1576
+ store: 'geo_report'
1577
+ })
1578
+
1579
+ // ═══════════════════════════════════════════════════════════
1580
+ // PUBLISHING PHASE
1581
+ // ═══════════════════════════════════════════════════════════
1582
+
1583
+ // Generate Images (if enabled in project settings AND credentials available)
1225
1584
  if (shouldGenerateImages) {
1226
1585
  // Format brand colors for image style guidance
1227
1586
  const colorsDisplay = brandColors.length > 0 ? brandColors.join(', ') : 'Not specified'
@@ -1362,8 +1721,19 @@ async function executeOrchestratorTool(toolName, args, project) {
1362
1721
  | **Include Images** | ${plan.settings.include_images ? 'Yes' : 'No'} |
1363
1722
  | **Images Required** | ${plan.settings.total_images} (1 cover + ${plan.settings.content_images} inline) |
1364
1723
 
1365
- ## Workflow Plan
1366
- ${plan.steps.map(s => `${s.step}. **${s.action}** ${s.type === 'action' ? '(automatic)' : '(you execute)'}`).join('\n')}
1724
+ ## Workflow Plan (4 Phases)
1725
+
1726
+ ### RESEARCH PHASE
1727
+ ${plan.steps.filter(s => ['keyword_research', 'seo_strategy', 'topical_map', 'content_calendar'].includes(s.action)).map(s => `${s.step}. **${s.action}**`).join('\n')}
1728
+
1729
+ ### CREATION PHASE
1730
+ ${plan.steps.filter(s => ['content_planning', 'content_write'].includes(s.action)).map(s => `${s.step}. **${s.action}**`).join('\n')}
1731
+
1732
+ ### OPTIMIZATION PHASE
1733
+ ${plan.steps.filter(s => ['quality_check', 'geo_optimize'].includes(s.action)).map(s => `${s.step}. **${s.action}**`).join('\n')}
1734
+
1735
+ ### PUBLISHING PHASE
1736
+ ${plan.steps.filter(s => ['generate_images', 'publish'].includes(s.action)).map(s => `${s.step}. **${s.action}**`).join('\n')}
1367
1737
 
1368
1738
  ## Available Integrations (from ~/.suparank/credentials.json)
1369
1739
  - External MCPs: ${mcpList}
@@ -1392,7 +1762,29 @@ ${plan.steps[0].instruction}
1392
1762
 
1393
1763
  case 'save_content': {
1394
1764
  const { title, content, keywords = [], meta_description = '' } = args
1765
+ const wordCount = content.split(/\s+/).length
1766
+
1767
+ // Create article object with unique ID
1768
+ const articleId = generateArticleId()
1769
+ const newArticle = {
1770
+ id: articleId,
1771
+ title,
1772
+ content,
1773
+ keywords,
1774
+ metaDescription: meta_description,
1775
+ metaTitle: title,
1776
+ imageUrl: sessionState.imageUrl || null, // Attach any generated cover image
1777
+ inlineImages: [...sessionState.inlineImages], // Copy current inline images
1778
+ savedAt: new Date().toISOString(),
1779
+ published: false,
1780
+ publishedTo: [],
1781
+ wordCount
1782
+ }
1395
1783
 
1784
+ // Add to articles array (not overwriting previous articles!)
1785
+ sessionState.articles.push(newArticle)
1786
+
1787
+ // Also keep in current working fields for backwards compatibility
1396
1788
  sessionState.title = title
1397
1789
  sessionState.article = content
1398
1790
  sessionState.keywords = keywords
@@ -1403,8 +1795,13 @@ ${plan.steps[0].instruction}
1403
1795
  saveSession()
1404
1796
  const contentFolder = saveContentToFolder()
1405
1797
 
1406
- const wordCount = content.split(/\s+/).length
1407
- progress('Content', `Saved "${title}" (${wordCount} words)${contentFolder ? ` → ${contentFolder}` : ''}`)
1798
+ progress('Content', `Saved "${title}" (${wordCount} words) as article #${sessionState.articles.length}${contentFolder ? ` → ${contentFolder}` : ''}`)
1799
+
1800
+ // Clear current working images so next article starts fresh
1801
+ // (images are already attached to the saved article)
1802
+ sessionState.imageUrl = null
1803
+ sessionState.inlineImages = []
1804
+
1408
1805
  const workflow = sessionState.currentWorkflow
1409
1806
  const targetWordCount = workflow?.settings?.target_word_count
1410
1807
  const wordCountOk = targetWordCount ? wordCount >= targetWordCount * 0.9 : true // Allow 10% tolerance
@@ -1431,131 +1828,232 @@ When calling \`publish_content\`, include the \`category\` parameter with your c
1431
1828
  }
1432
1829
  }
1433
1830
 
1831
+ // Show all articles in session
1832
+ const articlesListSection = sessionState.articles.length > 1 ? `
1833
+ ## Articles in Session (${sessionState.articles.length} total)
1834
+ ${sessionState.articles.map((art, i) => {
1835
+ const status = art.published ? `✅ published to ${art.publishedTo.join(', ')}` : '📝 unpublished'
1836
+ return `${i + 1}. **${art.title}** (${art.wordCount} words) - ${status}`
1837
+ }).join('\n')}
1838
+
1839
+ Use \`publish_content\` to publish all unpublished articles, or \`get_session\` to see full details.
1840
+ ` : ''
1841
+
1434
1842
  return {
1435
1843
  content: [{
1436
1844
  type: 'text',
1437
- text: `# ✅ Content Saved to Session
1845
+ text: `# ✅ Content Saved to Session (Article #${sessionState.articles.length})
1438
1846
 
1439
1847
  **Title:** ${title}
1848
+ **Article ID:** ${articleId}
1440
1849
  **Word Count:** ${wordCount} words ${targetWordCount ? (wordCountOk ? '✅' : `⚠️ (target: ${targetWordCount})`) : '(no target set)'}
1441
1850
  **Meta Description:** ${meta_description ? `${meta_description.length} chars ✅` : '❌ Missing!'}
1442
1851
  **Keywords:** ${keywords.join(', ') || 'none specified'}
1852
+ **Images:** ${newArticle.imageUrl ? '1 cover' : 'no cover'}${newArticle.inlineImages.length > 0 ? ` + ${newArticle.inlineImages.length} inline` : ''}
1443
1853
 
1444
1854
  ${targetWordCount && !wordCountOk ? `⚠️ **Warning:** Article is ${targetWordCount - wordCount} words short of the ${targetWordCount} word target.\n` : ''}
1445
1855
  ${!meta_description ? '⚠️ **Warning:** Meta description is missing. Add it for better SEO.\n' : ''}
1446
- ${categoriesSection}
1447
- ## Next Step${includeImages && imageStep ? ': Generate Images' : ': Publish'}
1856
+ ${articlesListSection}${categoriesSection}
1857
+ ## Next Step${includeImages && imageStep ? ': Generate Images' : ': Ready to Publish or Continue'}
1448
1858
  ${includeImages && imageStep ? `Generate **${totalImages} images** (1 cover + ${totalImages - 1} inline images).
1449
1859
 
1450
- Call \`generate_image\` ${totalImages} times with prompts based on your article sections.` : 'Proceed to publish with \`publish_content\`.'}`
1860
+ Call \`generate_image\` ${totalImages} times with prompts based on your article sections.` : `You can:
1861
+ - **Add more articles**: Continue creating content (each save_content adds to the batch)
1862
+ - **Publish all**: Call \`publish_content\` to publish all ${sessionState.articles.length} article(s)
1863
+ - **View session**: Call \`get_session\` to see all saved articles`}`
1451
1864
  }]
1452
1865
  }
1453
1866
  }
1454
1867
 
1455
1868
  case 'publish_content': {
1456
- const { platforms = ['all'], status = 'draft', category = '' } = args
1869
+ const { platforms = ['all'], status = 'draft', category = '', article_numbers = [] } = args
1457
1870
 
1458
- if (!sessionState.article || !sessionState.title) {
1871
+ // Determine which articles to publish
1872
+ let articlesToPublish = []
1873
+
1874
+ if (article_numbers && article_numbers.length > 0) {
1875
+ // Publish specific articles by number (1-indexed)
1876
+ articlesToPublish = article_numbers
1877
+ .map(num => sessionState.articles[num - 1])
1878
+ .filter(art => art && !art.published)
1879
+
1880
+ if (articlesToPublish.length === 0) {
1881
+ return {
1882
+ content: [{
1883
+ type: 'text',
1884
+ text: `❌ No valid unpublished articles found for numbers: ${article_numbers.join(', ')}
1885
+
1886
+ Use \`get_session\` to see available articles and their numbers.`
1887
+ }]
1888
+ }
1889
+ }
1890
+ } else {
1891
+ // Publish all unpublished articles
1892
+ articlesToPublish = sessionState.articles.filter(art => !art.published)
1893
+ }
1894
+
1895
+ // Fallback: Check if there's a current working article not yet saved
1896
+ if (articlesToPublish.length === 0 && sessionState.article && sessionState.title) {
1897
+ // Create temporary article from current working state for backwards compatibility
1898
+ articlesToPublish = [{
1899
+ id: 'current',
1900
+ title: sessionState.title,
1901
+ content: sessionState.article,
1902
+ keywords: sessionState.keywords || [],
1903
+ metaDescription: sessionState.metaDescription || '',
1904
+ imageUrl: sessionState.imageUrl,
1905
+ inlineImages: sessionState.inlineImages
1906
+ }]
1907
+ }
1908
+
1909
+ if (articlesToPublish.length === 0) {
1459
1910
  return {
1460
1911
  content: [{
1461
1912
  type: 'text',
1462
- text: '❌ No content saved. Please use save_content first to save your article.'
1913
+ text: `❌ No unpublished articles found in session.
1914
+
1915
+ Use \`save_content\` after writing an article, then call \`publish_content\`.
1916
+ Or use \`get_session\` to see current session state.`
1463
1917
  }]
1464
1918
  }
1465
1919
  }
1466
1920
 
1467
- // Inject inline images into content (replace [IMAGE: ...] placeholders)
1468
- let contentWithImages = sessionState.article
1469
- let imageIndex = 0
1470
- contentWithImages = contentWithImages.replace(/\[IMAGE:\s*([^\]]+)\]/gi, (match, description) => {
1471
- if (imageIndex < sessionState.inlineImages.length) {
1472
- const imgUrl = sessionState.inlineImages[imageIndex]
1473
- imageIndex++
1474
- return `![${description.trim()}](${imgUrl})`
1475
- }
1476
- return match // Keep placeholder if no image available
1477
- })
1478
-
1479
- const results = []
1480
1921
  const hasGhost = hasCredential('ghost')
1481
1922
  const hasWordPress = hasCredential('wordpress')
1482
-
1483
1923
  const shouldPublishGhost = hasGhost && (platforms.includes('all') || platforms.includes('ghost'))
1484
1924
  const shouldPublishWordPress = hasWordPress && (platforms.includes('all') || platforms.includes('wordpress'))
1485
1925
 
1486
- // Publish to Ghost
1487
- if (shouldPublishGhost) {
1488
- try {
1489
- const ghostResult = await executeGhostPublish({
1490
- title: sessionState.title,
1491
- content: contentWithImages,
1492
- status: status,
1493
- tags: sessionState.keywords || [],
1494
- featured_image_url: sessionState.imageUrl
1495
- })
1496
- results.push({ platform: 'Ghost', success: true, result: ghostResult })
1497
- } catch (e) {
1498
- results.push({ platform: 'Ghost', success: false, error: e.message })
1926
+ // Results for all articles
1927
+ const allResults = []
1928
+
1929
+ progress('Publishing', `Starting batch publish of ${articlesToPublish.length} article(s)`)
1930
+
1931
+ // Publish each article
1932
+ for (let i = 0; i < articlesToPublish.length; i++) {
1933
+ const article = articlesToPublish[i]
1934
+ progress('Publishing', `Article ${i + 1}/${articlesToPublish.length}: "${article.title}"`)
1935
+
1936
+ // Inject inline images into content (replace [IMAGE: ...] placeholders)
1937
+ let contentWithImages = article.content
1938
+ let imageIndex = 0
1939
+ const articleInlineImages = article.inlineImages || []
1940
+ contentWithImages = contentWithImages.replace(/\[IMAGE:\s*([^\]]+)\]/gi, (match, description) => {
1941
+ if (imageIndex < articleInlineImages.length) {
1942
+ const imgUrl = articleInlineImages[imageIndex]
1943
+ imageIndex++
1944
+ return `![${description.trim()}](${imgUrl})`
1945
+ }
1946
+ return match // Keep placeholder if no image available
1947
+ })
1948
+
1949
+ const articleResults = {
1950
+ article: article.title,
1951
+ articleId: article.id,
1952
+ wordCount: article.wordCount || contentWithImages.split(/\s+/).length,
1953
+ platforms: []
1499
1954
  }
1500
- }
1501
1955
 
1502
- // Publish to WordPress with intelligent category assignment
1503
- if (shouldPublishWordPress) {
1504
- try {
1505
- // Use selected category or empty array
1506
- const categories = category ? [category] : []
1507
- log(`Publishing to WordPress with category: ${category || '(none selected)'}`)
1508
-
1509
- const wpResult = await executeWordPressPublish({
1510
- title: sessionState.title,
1511
- content: contentWithImages,
1512
- status: status,
1513
- categories: categories,
1514
- tags: sessionState.keywords || [],
1515
- featured_image_url: sessionState.imageUrl
1516
- })
1517
- results.push({ platform: 'WordPress', success: true, result: wpResult, category: category || null })
1518
- } catch (e) {
1519
- results.push({ platform: 'WordPress', success: false, error: e.message })
1956
+ // Publish to Ghost
1957
+ if (shouldPublishGhost) {
1958
+ try {
1959
+ const ghostResult = await executeGhostPublish({
1960
+ title: article.title,
1961
+ content: contentWithImages,
1962
+ status: status,
1963
+ tags: article.keywords || [],
1964
+ featured_image_url: article.imageUrl
1965
+ })
1966
+ articleResults.platforms.push({ platform: 'Ghost', success: true, result: ghostResult })
1967
+ } catch (e) {
1968
+ articleResults.platforms.push({ platform: 'Ghost', success: false, error: e.message })
1969
+ }
1520
1970
  }
1971
+
1972
+ // Publish to WordPress
1973
+ if (shouldPublishWordPress) {
1974
+ try {
1975
+ const categories = category ? [category] : []
1976
+ const wpResult = await executeWordPressPublish({
1977
+ title: article.title,
1978
+ content: contentWithImages,
1979
+ status: status,
1980
+ categories: categories,
1981
+ tags: article.keywords || [],
1982
+ featured_image_url: article.imageUrl
1983
+ })
1984
+ articleResults.platforms.push({ platform: 'WordPress', success: true, result: wpResult })
1985
+ } catch (e) {
1986
+ articleResults.platforms.push({ platform: 'WordPress', success: false, error: e.message })
1987
+ }
1988
+ }
1989
+
1990
+ // Mark article as published if at least one platform succeeded
1991
+ const hasSuccess = articleResults.platforms.some(p => p.success)
1992
+ if (hasSuccess && article.id !== 'current') {
1993
+ const articleIndex = sessionState.articles.findIndex(a => a.id === article.id)
1994
+ if (articleIndex !== -1) {
1995
+ sessionState.articles[articleIndex].published = true
1996
+ sessionState.articles[articleIndex].publishedTo = articleResults.platforms
1997
+ .filter(p => p.success)
1998
+ .map(p => p.platform.toLowerCase())
1999
+ sessionState.articles[articleIndex].publishedAt = new Date().toISOString()
2000
+ }
2001
+ }
2002
+
2003
+ allResults.push(articleResults)
1521
2004
  }
1522
2005
 
1523
- // Format response
1524
- const wordCount = contentWithImages.split(/\s+/).length
1525
- const inlineImagesUsed = imageIndex
2006
+ // Save updated session state (with published flags)
2007
+ saveSession()
1526
2008
 
1527
- let response = `# 📤 Publishing Results
2009
+ // Build response
2010
+ const totalArticles = allResults.length
2011
+ const successfulArticles = allResults.filter(r => r.platforms.some(p => p.success)).length
2012
+ const totalWords = allResults.reduce((sum, r) => sum + r.wordCount, 0)
1528
2013
 
1529
- ## Content Summary
1530
- - **Title:** ${sessionState.title}
1531
- - **Word Count:** ${wordCount} words
1532
- - **Meta Description:** ${sessionState.metaDescription ? '✅ Included' : '❌ Missing'}
1533
- - **Cover Image:** ${sessionState.imageUrl ? '✅ Set' : '❌ Missing'}
1534
- - **Inline Images:** ${inlineImagesUsed} injected into content
1535
- - **Keywords/Tags:** ${sessionState.keywords?.join(', ') || 'None'}
1536
- - **Category:** ${category || 'Not specified'}
2014
+ let response = `# 📤 Batch Publishing Results
2015
+
2016
+ ## Summary
2017
+ - **Articles Published:** ${successfulArticles}/${totalArticles}
2018
+ - **Total Words:** ${totalWords.toLocaleString()}
2019
+ - **Status:** ${status}
2020
+ - **Platforms:** ${[shouldPublishGhost ? 'Ghost' : null, shouldPublishWordPress ? 'WordPress' : null].filter(Boolean).join(', ') || 'None'}
2021
+ ${category ? `- **Category:** ${category}` : ''}
2022
+
2023
+ ---
1537
2024
 
1538
2025
  `
1539
2026
 
1540
- for (const r of results) {
1541
- if (r.success) {
1542
- response += `## ✅ ${r.platform}\n${r.result.content[0].text}\n\n`
1543
- } else {
1544
- response += `## ${r.platform}\nError: ${r.error}\n\n`
2027
+ // Detail for each article
2028
+ for (const result of allResults) {
2029
+ const hasAnySuccess = result.platforms.some(p => p.success)
2030
+ response += `## ${hasAnySuccess ? '✅' : '❌'} ${result.article}\n`
2031
+ response += `**Words:** ${result.wordCount}\n\n`
2032
+
2033
+ for (const p of result.platforms) {
2034
+ if (p.success) {
2035
+ response += `**${p.platform}:** ✅ Published\n`
2036
+ // Extract URL if available
2037
+ const resultText = p.result?.content?.[0]?.text || ''
2038
+ const urlMatch = resultText.match(/https?:\/\/[^\s\)]+/)
2039
+ if (urlMatch) {
2040
+ response += `URL: ${urlMatch[0]}\n`
2041
+ }
2042
+ } else {
2043
+ response += `**${p.platform}:** ❌ ${p.error}\n`
2044
+ }
1545
2045
  }
2046
+ response += '\n'
1546
2047
  }
1547
2048
 
1548
- if (results.length === 0) {
1549
- response += `No platforms configured or selected for publishing.\n`
1550
- response += `Available: Ghost (${hasGhost ? 'yes' : 'no'}), WordPress (${hasWordPress ? 'yes' : 'no'})`
1551
- }
1552
-
1553
- // Clear session after successful publish (at least one success)
1554
- const hasSuccessfulPublish = results.some(r => r.success)
1555
- if (hasSuccessfulPublish) {
1556
- clearSessionFile()
1557
- resetSession()
1558
- response += '\n---\n✅ Session cleared. Ready for new content.'
2049
+ // Show remaining unpublished articles
2050
+ const remainingUnpublished = sessionState.articles.filter(a => !a.published)
2051
+ if (remainingUnpublished.length > 0) {
2052
+ response += `---\n\n**📝 ${remainingUnpublished.length} article(s) still unpublished** in session.\n`
2053
+ response += `Call \`publish_content\` again to publish remaining, or \`get_session\` to see details.\n`
2054
+ } else if (sessionState.articles.length > 0) {
2055
+ response += `---\n\n✅ **All ${sessionState.articles.length} articles published!**\n`
2056
+ response += `Session retained for reference. Start a new workflow to clear.\n`
1559
2057
  }
1560
2058
 
1561
2059
  return {
@@ -1571,34 +2069,394 @@ Call \`generate_image\` ${totalImages} times with prompts based on your article
1571
2069
  const imagesGenerated = (sessionState.imageUrl ? 1 : 0) + sessionState.inlineImages.length
1572
2070
  const workflow = sessionState.currentWorkflow
1573
2071
 
2072
+ // Count totals across all articles
2073
+ const totalArticles = sessionState.articles.length
2074
+ const unpublishedArticles = sessionState.articles.filter(a => !a.published)
2075
+ const publishedArticles = sessionState.articles.filter(a => a.published)
2076
+ const totalWords = sessionState.articles.reduce((sum, a) => sum + (a.wordCount || 0), 0)
2077
+ const totalImages = sessionState.articles.reduce((sum, a) => {
2078
+ return sum + (a.imageUrl ? 1 : 0) + (a.inlineImages?.length || 0)
2079
+ }, 0)
2080
+
2081
+ // Build articles list
2082
+ const articlesSection = sessionState.articles.length > 0 ? `
2083
+ ## 📚 Saved Articles (${totalArticles} total)
2084
+
2085
+ | # | Title | Words | Images | Status |
2086
+ |---|-------|-------|--------|--------|
2087
+ ${sessionState.articles.map((art, i) => {
2088
+ const imgCount = (art.imageUrl ? 1 : 0) + (art.inlineImages?.length || 0)
2089
+ const status = art.published ? `✅ ${art.publishedTo.join(', ')}` : '📝 Unpublished'
2090
+ return `| ${i + 1} | ${art.title.substring(0, 40)}${art.title.length > 40 ? '...' : ''} | ${art.wordCount} | ${imgCount} | ${status} |`
2091
+ }).join('\n')}
2092
+
2093
+ **Summary:** ${totalWords.toLocaleString()} total words, ${totalImages} total images
2094
+ **Unpublished:** ${unpublishedArticles.length} article(s) ready to publish
2095
+ ` : `
2096
+ ## 📚 Saved Articles
2097
+ No articles saved yet. Use \`save_content\` after writing an article.
2098
+ `
2099
+
2100
+ // Current working article (if any in progress)
2101
+ const currentWorkingSection = sessionState.title && sessionState.article ? `
2102
+ ## 🖊️ Current Working Article
2103
+ **Title:** ${sessionState.title}
2104
+ **Word Count:** ${sessionState.article.split(/\s+/).length} words
2105
+ **Meta Description:** ${sessionState.metaDescription || 'Not set'}
2106
+ **Cover Image:** ${sessionState.imageUrl ? '✅ Generated' : '❌ Not yet'}
2107
+ **Inline Images:** ${sessionState.inlineImages.length}
2108
+
2109
+ *This article is being edited. Call \`save_content\` to add it to the session.*
2110
+ ` : ''
2111
+
1574
2112
  return {
1575
2113
  content: [{
1576
2114
  type: 'text',
1577
- text: `# 📋 Current Session State
2115
+ text: `# 📋 Session State
1578
2116
 
1579
2117
  **Workflow:** ${workflow?.workflow_id || 'None active'}
1580
-
1581
- ## Content
1582
- **Title:** ${sessionState.title || 'Not set'}
1583
- **Article:** ${sessionState.article ? `${sessionState.article.split(/\s+/).length} words` : 'Not saved'}
1584
- **Meta Description:** ${sessionState.metaDescription || 'Not set'}
1585
- **Keywords:** ${sessionState.keywords?.join(', ') || 'None'}
1586
-
1587
- ## Images (${imagesGenerated}/${totalImagesNeeded})
2118
+ **Total Articles:** ${totalArticles}
2119
+ **Ready to Publish:** ${unpublishedArticles.length}
2120
+ **Already Published:** ${publishedArticles.length}
2121
+ ${articlesSection}${currentWorkingSection}
2122
+ ## 🖼️ Current Working Images (${imagesGenerated}/${totalImagesNeeded})
1588
2123
  **Cover Image:** ${sessionState.imageUrl || 'Not generated'}
1589
- **Inline Images:** ${sessionState.inlineImages.length > 0 ? sessionState.inlineImages.map((url, i) => `\n ${i+1}. ${url}`).join('') : 'None'}
2124
+ **Inline Images:** ${sessionState.inlineImages.length > 0 ? sessionState.inlineImages.map((url, i) => `\n ${i+1}. ${url.substring(0, 60)}...`).join('') : 'None'}
1590
2125
 
1591
2126
  ${workflow ? `
1592
- ## Project Settings (from database)
2127
+ ## ⚙️ Project Settings
1593
2128
  - **Project:** ${workflow.project_info?.name || 'Unknown'}
1594
2129
  - **Niche:** ${workflow.project_info?.niche || 'Unknown'}
1595
2130
  - **Word Count Target:** ${workflow.settings?.target_word_count || 'Not set'}
1596
2131
  - **Reading Level:** ${workflow.settings?.reading_level_display || 'Not set'}
1597
2132
  - **Brand Voice:** ${workflow.settings?.brand_voice || 'Not set'}
1598
- - **Visual Style:** ${workflow.settings?.visual_style || 'Not set'}
1599
2133
  - **Include Images:** ${workflow.settings?.include_images ? 'Yes' : 'No'}
1600
- - **Total Images:** ${totalImagesNeeded}
1601
- ` : ''}`
2134
+ ` : ''}
2135
+ ## 🚀 Actions
2136
+ - **Publish all unpublished:** Call \`publish_content\`
2137
+ - **Add more articles:** Use \`create_content\` or \`content_write\` then \`save_content\`
2138
+ - **Remove articles:** Call \`remove_article\` with article numbers
2139
+ - **Clear session:** Call \`clear_session\` with confirm: true`
2140
+ }]
2141
+ }
2142
+ }
2143
+
2144
+ case 'remove_article': {
2145
+ const { article_numbers } = args
2146
+
2147
+ if (!article_numbers || article_numbers.length === 0) {
2148
+ return {
2149
+ content: [{
2150
+ type: 'text',
2151
+ text: `❌ Please specify article numbers to remove. Use \`get_session\` to see article numbers.`
2152
+ }]
2153
+ }
2154
+ }
2155
+
2156
+ // Sort in descending order to avoid index shifting issues
2157
+ const sortedNumbers = [...article_numbers].sort((a, b) => b - a)
2158
+ const removed = []
2159
+ const skipped = []
2160
+
2161
+ for (const num of sortedNumbers) {
2162
+ const index = num - 1
2163
+ if (index < 0 || index >= sessionState.articles.length) {
2164
+ skipped.push({ num, reason: 'not found' })
2165
+ continue
2166
+ }
2167
+
2168
+ const article = sessionState.articles[index]
2169
+ if (article.published) {
2170
+ skipped.push({ num, reason: 'already published', title: article.title })
2171
+ continue
2172
+ }
2173
+
2174
+ // Remove the article
2175
+ const [removedArticle] = sessionState.articles.splice(index, 1)
2176
+ removed.push({ num, title: removedArticle.title })
2177
+ }
2178
+
2179
+ // Save session
2180
+ saveSession()
2181
+
2182
+ let response = `# 🗑️ Article Removal Results\n\n`
2183
+
2184
+ if (removed.length > 0) {
2185
+ response += `## ✅ Removed (${removed.length})\n`
2186
+ for (const r of removed) {
2187
+ response += `- #${r.num}: "${r.title}"\n`
2188
+ }
2189
+ response += '\n'
2190
+ }
2191
+
2192
+ if (skipped.length > 0) {
2193
+ response += `## ⚠️ Skipped (${skipped.length})\n`
2194
+ for (const s of skipped) {
2195
+ if (s.reason === 'already published') {
2196
+ response += `- #${s.num}: "${s.title}" (already published - cannot remove)\n`
2197
+ } else {
2198
+ response += `- #${s.num}: not found\n`
2199
+ }
2200
+ }
2201
+ response += '\n'
2202
+ }
2203
+
2204
+ response += `---\n\n**${sessionState.articles.length} article(s) remaining in session.**`
2205
+
2206
+ return {
2207
+ content: [{
2208
+ type: 'text',
2209
+ text: response
2210
+ }]
2211
+ }
2212
+ }
2213
+
2214
+ case 'clear_session': {
2215
+ const { confirm } = args
2216
+
2217
+ if (!confirm) {
2218
+ return {
2219
+ content: [{
2220
+ type: 'text',
2221
+ text: `⚠️ **Clear Session requires confirmation**
2222
+
2223
+ This will permanently remove:
2224
+ - ${sessionState.articles.length} saved article(s)
2225
+ - All generated images
2226
+ - Current workflow state
2227
+
2228
+ To confirm, call \`clear_session\` with \`confirm: true\``
2229
+ }]
2230
+ }
2231
+ }
2232
+
2233
+ const articleCount = sessionState.articles.length
2234
+ const unpublishedCount = sessionState.articles.filter(a => !a.published).length
2235
+
2236
+ // Clear everything
2237
+ resetSession()
2238
+
2239
+ return {
2240
+ content: [{
2241
+ type: 'text',
2242
+ text: `# ✅ Session Cleared
2243
+
2244
+ Removed:
2245
+ - ${articleCount} article(s) (${unpublishedCount} unpublished)
2246
+ - All workflow state
2247
+ - All generated images
2248
+
2249
+ Session is now empty. Ready for new content creation.`
2250
+ }]
2251
+ }
2252
+ }
2253
+
2254
+ case 'list_content': {
2255
+ const { limit = 20 } = args
2256
+ const contentDir = getContentDir()
2257
+
2258
+ if (!fs.existsSync(contentDir)) {
2259
+ return {
2260
+ content: [{
2261
+ type: 'text',
2262
+ text: `# 📂 Saved Content
2263
+
2264
+ No content directory found at \`${contentDir}\`.
2265
+
2266
+ Save articles using \`save_content\` and they will appear here.`
2267
+ }]
2268
+ }
2269
+ }
2270
+
2271
+ // Get all content folders
2272
+ const folders = fs.readdirSync(contentDir, { withFileTypes: true })
2273
+ .filter(dirent => dirent.isDirectory())
2274
+ .map(dirent => {
2275
+ const folderPath = path.join(contentDir, dirent.name)
2276
+ const metadataPath = path.join(folderPath, 'metadata.json')
2277
+
2278
+ let metadata = null
2279
+ if (fs.existsSync(metadataPath)) {
2280
+ try {
2281
+ metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'))
2282
+ } catch (e) {
2283
+ // Ignore parse errors
2284
+ }
2285
+ }
2286
+
2287
+ return {
2288
+ name: dirent.name,
2289
+ path: folderPath,
2290
+ metadata,
2291
+ mtime: fs.statSync(folderPath).mtime
2292
+ }
2293
+ })
2294
+ .sort((a, b) => b.mtime - a.mtime) // Most recent first
2295
+ .slice(0, limit)
2296
+
2297
+ if (folders.length === 0) {
2298
+ return {
2299
+ content: [{
2300
+ type: 'text',
2301
+ text: `# 📂 Saved Content
2302
+
2303
+ No saved articles found in \`${contentDir}\`.
2304
+
2305
+ Save articles using \`save_content\` and they will appear here.`
2306
+ }]
2307
+ }
2308
+ }
2309
+
2310
+ let response = `# 📂 Saved Content (${folders.length} articles)
2311
+
2312
+ | # | Date | Title | Words | Project |
2313
+ |---|------|-------|-------|---------|
2314
+ `
2315
+ folders.forEach((folder, i) => {
2316
+ const date = folder.name.split('-').slice(0, 3).join('-')
2317
+ const title = folder.metadata?.title || folder.name.split('-').slice(3).join('-')
2318
+ const words = folder.metadata?.wordCount || '?'
2319
+ const project = folder.metadata?.projectSlug || '-'
2320
+ response += `| ${i + 1} | ${date} | ${title.substring(0, 35)}${title.length > 35 ? '...' : ''} | ${words} | ${project} |\n`
2321
+ })
2322
+
2323
+ response += `
2324
+ ---
2325
+
2326
+ ## To Load an Article
2327
+
2328
+ Call \`load_content\` with the folder name:
2329
+ \`\`\`
2330
+ load_content({ folder_name: "${folders[0]?.name}" })
2331
+ \`\`\`
2332
+
2333
+ Once loaded, you can run optimization tools:
2334
+ - \`quality_check\` - Pre-publish quality assurance
2335
+ - \`geo_optimize\` - AI search engine optimization
2336
+ - \`internal_links\` - Internal linking suggestions
2337
+ - \`schema_generate\` - JSON-LD structured data
2338
+ - \`save_content\` - Re-save with changes
2339
+ - \`publish_content\` - Publish to CMS`
2340
+
2341
+ return {
2342
+ content: [{
2343
+ type: 'text',
2344
+ text: response
2345
+ }]
2346
+ }
2347
+ }
2348
+
2349
+ case 'load_content': {
2350
+ const { folder_name } = args
2351
+
2352
+ if (!folder_name) {
2353
+ return {
2354
+ content: [{
2355
+ type: 'text',
2356
+ text: `❌ Please specify a folder_name. Use \`list_content\` to see available articles.`
2357
+ }]
2358
+ }
2359
+ }
2360
+
2361
+ const contentDir = getContentDir()
2362
+ const folderPath = path.join(contentDir, folder_name)
2363
+
2364
+ if (!fs.existsSync(folderPath)) {
2365
+ return {
2366
+ content: [{
2367
+ type: 'text',
2368
+ text: `❌ Folder not found: \`${folder_name}\`
2369
+
2370
+ Use \`list_content\` to see available articles.`
2371
+ }]
2372
+ }
2373
+ }
2374
+
2375
+ // Load article and metadata
2376
+ const articlePath = path.join(folderPath, 'article.md')
2377
+ const metadataPath = path.join(folderPath, 'metadata.json')
2378
+
2379
+ if (!fs.existsSync(articlePath)) {
2380
+ return {
2381
+ content: [{
2382
+ type: 'text',
2383
+ text: `❌ No article.md found in \`${folder_name}\``
2384
+ }]
2385
+ }
2386
+ }
2387
+
2388
+ const articleContent = fs.readFileSync(articlePath, 'utf-8')
2389
+ let metadata = {}
2390
+ if (fs.existsSync(metadataPath)) {
2391
+ try {
2392
+ metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'))
2393
+ } catch (e) {
2394
+ log(`Warning: Failed to parse metadata.json: ${e.message}`)
2395
+ }
2396
+ }
2397
+
2398
+ // Load into session state
2399
+ sessionState.title = metadata.title || folder_name
2400
+ sessionState.article = articleContent
2401
+ sessionState.keywords = metadata.keywords || []
2402
+ sessionState.metaDescription = metadata.metaDescription || ''
2403
+ sessionState.metaTitle = metadata.metaTitle || metadata.title || folder_name
2404
+ sessionState.imageUrl = metadata.imageUrl || null
2405
+ sessionState.inlineImages = metadata.inlineImages || []
2406
+ sessionState.contentFolder = folderPath
2407
+
2408
+ // Also add to articles array if not already there
2409
+ const existingIndex = sessionState.articles.findIndex(a => a.title === sessionState.title)
2410
+ if (existingIndex === -1) {
2411
+ const loadedArticle = {
2412
+ id: generateArticleId(),
2413
+ title: sessionState.title,
2414
+ content: articleContent,
2415
+ keywords: sessionState.keywords,
2416
+ metaDescription: sessionState.metaDescription,
2417
+ metaTitle: sessionState.metaTitle,
2418
+ imageUrl: sessionState.imageUrl,
2419
+ inlineImages: sessionState.inlineImages,
2420
+ savedAt: metadata.createdAt || new Date().toISOString(),
2421
+ published: false,
2422
+ publishedTo: [],
2423
+ wordCount: articleContent.split(/\s+/).length,
2424
+ loadedFrom: folderPath
2425
+ }
2426
+ sessionState.articles.push(loadedArticle)
2427
+ }
2428
+
2429
+ // Save session
2430
+ saveSession()
2431
+
2432
+ const wordCount = articleContent.split(/\s+/).length
2433
+ progress('Content', `Loaded "${sessionState.title}" (${wordCount} words) from ${folder_name}`)
2434
+
2435
+ return {
2436
+ content: [{
2437
+ type: 'text',
2438
+ text: `# ✅ Content Loaded
2439
+
2440
+ **Title:** ${sessionState.title}
2441
+ **Word Count:** ${wordCount}
2442
+ **Keywords:** ${sessionState.keywords.join(', ') || 'None'}
2443
+ **Meta Description:** ${sessionState.metaDescription ? `${sessionState.metaDescription.length} chars` : 'None'}
2444
+ **Cover Image:** ${sessionState.imageUrl ? '✅' : '❌'}
2445
+ **Inline Images:** ${sessionState.inlineImages.length}
2446
+ **Source:** \`${folderPath}\`
2447
+
2448
+ ---
2449
+
2450
+ ## Now you can run optimization tools:
2451
+
2452
+ - **\`quality_check\`** - Pre-publish quality assurance
2453
+ - **\`geo_optimize\`** - Optimize for AI search engines (ChatGPT, Perplexity)
2454
+ - **\`internal_links\`** - Get internal linking suggestions
2455
+ - **\`schema_generate\`** - Generate JSON-LD structured data
2456
+ - **\`save_content\`** - Re-save after making changes
2457
+ - **\`publish_content\`** - Publish to WordPress/Ghost
2458
+
2459
+ Article is now in session (#${sessionState.articles.length}) and ready for further processing.`
1602
2460
  }]
1603
2461
  }
1604
2462
  }
@@ -2237,39 +3095,97 @@ async function executeSendWebhook(args) {
2237
3095
  }
2238
3096
  }
2239
3097
 
3098
+ /**
3099
+ * Essential tools shown in the tool list (5 tools for "stupid simple" UX)
3100
+ * Other tools still work when called directly by LLM, but aren't shown in list
3101
+ */
3102
+ const VISIBLE_TOOLS = [
3103
+ 'create_content', // Main entry point - creates & publishes automatically
3104
+ 'keyword_research', // Research keywords separately (on-demand)
3105
+ 'generate_image', // Generate/regenerate images (on-demand)
3106
+ 'publish_content', // Manual publish trigger (on-demand)
3107
+ 'get_session' // Check status (on-demand)
3108
+ ]
3109
+
2240
3110
  /**
2241
3111
  * Get all available tools based on configured credentials
3112
+ * Only shows essential tools to users (5 tools instead of 24)
3113
+ * Hidden tools still work when LLM calls them directly
2242
3114
  */
2243
3115
  function getAvailableTools() {
2244
- const tools = [...TOOLS]
2245
-
2246
- // Add orchestrator tools (always available)
2247
- for (const tool of ORCHESTRATOR_TOOLS) {
2248
- tools.push({
2249
- name: tool.name,
2250
- description: tool.description,
2251
- inputSchema: tool.inputSchema
2252
- })
2253
- }
3116
+ const tools = []
2254
3117
 
2255
- // Add action tools only if credentials are configured
2256
- for (const tool of ACTION_TOOLS) {
2257
- if (hasCredential(tool.requiresCredential)) {
3118
+ // Add visible TOOLS (keyword_research only from main tools)
3119
+ for (const tool of TOOLS) {
3120
+ if (VISIBLE_TOOLS.includes(tool.name)) {
2258
3121
  tools.push({
2259
3122
  name: tool.name,
2260
3123
  description: tool.description,
2261
3124
  inputSchema: tool.inputSchema
2262
3125
  })
2263
- } else {
2264
- // Add disabled version with note
3126
+ }
3127
+ }
3128
+
3129
+ // Add visible orchestrator tools
3130
+ for (const tool of ORCHESTRATOR_TOOLS) {
3131
+ if (VISIBLE_TOOLS.includes(tool.name)) {
2265
3132
  tools.push({
2266
3133
  name: tool.name,
2267
- description: `[DISABLED - requires ${tool.requiresCredential} credentials] ${tool.description}`,
3134
+ description: tool.description,
2268
3135
  inputSchema: tool.inputSchema
2269
3136
  })
2270
3137
  }
2271
3138
  }
2272
3139
 
3140
+ // Add visible action tools (only if credentials are configured)
3141
+ for (const tool of ACTION_TOOLS) {
3142
+ if (VISIBLE_TOOLS.includes(tool.name)) {
3143
+ if (hasCredential(tool.requiresCredential)) {
3144
+ tools.push({
3145
+ name: tool.name,
3146
+ description: tool.description,
3147
+ inputSchema: tool.inputSchema
3148
+ })
3149
+ } else {
3150
+ // Add disabled version with note
3151
+ tools.push({
3152
+ name: tool.name,
3153
+ description: `[DISABLED - requires ${tool.requiresCredential} credentials] ${tool.description}`,
3154
+ inputSchema: tool.inputSchema
3155
+ })
3156
+ }
3157
+ }
3158
+ }
3159
+
3160
+ return tools
3161
+ }
3162
+
3163
+ /**
3164
+ * Get ALL tools (visible + hidden) for tool execution
3165
+ * This is used by CallToolRequestSchema to find tools by name
3166
+ */
3167
+ function getAllTools() {
3168
+ const tools = [...TOOLS]
3169
+
3170
+ // Add all orchestrator tools
3171
+ for (const tool of ORCHESTRATOR_TOOLS) {
3172
+ tools.push({
3173
+ name: tool.name,
3174
+ description: tool.description,
3175
+ inputSchema: tool.inputSchema
3176
+ })
3177
+ }
3178
+
3179
+ // Add all action tools
3180
+ for (const tool of ACTION_TOOLS) {
3181
+ tools.push({
3182
+ name: tool.name,
3183
+ description: tool.description,
3184
+ inputSchema: tool.inputSchema,
3185
+ requiresCredential: tool.requiresCredential
3186
+ })
3187
+ }
3188
+
2273
3189
  return tools
2274
3190
  }
2275
3191