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.
- package/bin/suparank.js +85 -536
- package/credentials.example.json +36 -18
- package/mcp-client/config.js +37 -0
- package/mcp-client/handlers/action.js +33 -0
- package/mcp-client/handlers/backend.js +43 -0
- package/mcp-client/handlers/index.js +9 -0
- package/mcp-client/handlers/orchestrator.js +850 -0
- package/mcp-client/index.js +33 -0
- package/mcp-client/publishers/ghost.js +105 -0
- package/mcp-client/publishers/image.js +306 -0
- package/mcp-client/publishers/index.js +20 -0
- package/mcp-client/publishers/webhook.js +76 -0
- package/mcp-client/publishers/wordpress.js +220 -0
- package/mcp-client/server.js +220 -0
- package/mcp-client/services/api.js +101 -0
- package/mcp-client/services/credentials.js +149 -0
- package/mcp-client/services/index.js +11 -0
- package/mcp-client/services/project.js +40 -0
- package/mcp-client/services/session-state.js +201 -0
- package/mcp-client/services/stats.js +50 -0
- package/mcp-client/tools/definitions.js +679 -0
- package/mcp-client/tools/discovery.js +132 -0
- package/mcp-client/tools/index.js +22 -0
- package/mcp-client/utils/content.js +126 -0
- package/mcp-client/utils/formatting.js +71 -0
- package/mcp-client/utils/index.js +10 -0
- package/mcp-client/utils/logging.js +38 -0
- package/mcp-client/utils/paths.js +134 -0
- package/mcp-client/workflow/index.js +10 -0
- package/mcp-client/workflow/planner.js +513 -0
- package/package.json +8 -19
- package/mcp-client.js +0 -3693
|
@@ -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
|
+
}
|