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
package/mcp-client.js
DELETED
|
@@ -1,3693 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Suparank MCP - Stdio Client
|
|
5
|
-
*
|
|
6
|
-
* Local MCP client that connects to the Suparank backend API.
|
|
7
|
-
* Works with Claude Desktop and Cursor via stdio transport.
|
|
8
|
-
*
|
|
9
|
-
* Usage:
|
|
10
|
-
* npx suparank
|
|
11
|
-
* node mcp-client.js <project-slug> <api-key>
|
|
12
|
-
*
|
|
13
|
-
* Credentials:
|
|
14
|
-
* Local credentials are loaded from ~/.suparank/credentials.json
|
|
15
|
-
* These enable additional tools: image generation, CMS publishing, webhooks
|
|
16
|
-
*
|
|
17
|
-
* Note: API keys are more secure than JWT tokens for MCP connections
|
|
18
|
-
* because they don't expire and can be revoked individually.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
22
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
23
|
-
import {
|
|
24
|
-
CallToolRequestSchema,
|
|
25
|
-
ListToolsRequestSchema,
|
|
26
|
-
InitializeRequestSchema
|
|
27
|
-
} from '@modelcontextprotocol/sdk/types.js'
|
|
28
|
-
import * as fs from 'fs'
|
|
29
|
-
import { marked } from 'marked'
|
|
30
|
-
import * as path from 'path'
|
|
31
|
-
import * as os from 'os'
|
|
32
|
-
|
|
33
|
-
// Parse command line arguments
|
|
34
|
-
const projectSlug = process.argv[2]
|
|
35
|
-
const apiKey = process.argv[3]
|
|
36
|
-
const apiUrl = process.env.SUPARANK_API_URL || 'https://api.suparank.io'
|
|
37
|
-
|
|
38
|
-
// ============================================================================
|
|
39
|
-
// EXTERNAL API ENDPOINTS - Configurable via environment variables
|
|
40
|
-
// ============================================================================
|
|
41
|
-
const API_ENDPOINTS = {
|
|
42
|
-
// Image generation providers
|
|
43
|
-
fal: process.env.FAL_API_URL || 'https://fal.run/fal-ai/nano-banana-pro',
|
|
44
|
-
gemini: process.env.GEMINI_API_URL || 'https://generativelanguage.googleapis.com/v1beta/models',
|
|
45
|
-
wiro: process.env.WIRO_API_URL || 'https://api.wiro.ai/v1',
|
|
46
|
-
wiroTaskDetail: process.env.WIRO_TASK_URL || 'https://api.wiro.ai/v1/Task/Detail'
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (!projectSlug) {
|
|
50
|
-
console.error('Error: Project slug is required')
|
|
51
|
-
console.error('Usage: node mcp-client.js <project-slug> <api-key>')
|
|
52
|
-
console.error('Example: node mcp-client.js my-project sk_live_abc123...')
|
|
53
|
-
process.exit(1)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (!apiKey) {
|
|
57
|
-
console.error('Error: API key is required')
|
|
58
|
-
console.error('Usage: node mcp-client.js <project-slug> <api-key>')
|
|
59
|
-
console.error('')
|
|
60
|
-
console.error('To create an API key:')
|
|
61
|
-
console.error('1. Sign in to the dashboard at http://localhost:3001')
|
|
62
|
-
console.error('2. Go to Settings > API Keys')
|
|
63
|
-
console.error('3. Click "Create API Key"')
|
|
64
|
-
console.error('4. Copy the key (shown only once!)')
|
|
65
|
-
process.exit(1)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Validate API key format
|
|
69
|
-
if (!apiKey.startsWith('sk_live_') && !apiKey.startsWith('sk_test_')) {
|
|
70
|
-
console.error('Error: Invalid API key format')
|
|
71
|
-
console.error('API keys must start with "sk_live_" or "sk_test_"')
|
|
72
|
-
console.error('Example: sk_live_abc123...')
|
|
73
|
-
process.exit(1)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Log to stderr (stdout is used for MCP protocol)
|
|
77
|
-
const log = (...args) => console.error('[suparank]', ...args)
|
|
78
|
-
|
|
79
|
-
// Structured progress logging for user visibility
|
|
80
|
-
const progress = (step, message) => console.error(`[suparank] ${step}: ${message}`)
|
|
81
|
-
|
|
82
|
-
// Local credentials storage
|
|
83
|
-
let localCredentials = null
|
|
84
|
-
|
|
85
|
-
// Session state for orchestration - stores content between steps
|
|
86
|
-
// Supports multiple articles for batch content creation workflows
|
|
87
|
-
const sessionState = {
|
|
88
|
-
currentWorkflow: null,
|
|
89
|
-
stepResults: {},
|
|
90
|
-
|
|
91
|
-
// Multi-article support: Array of saved articles
|
|
92
|
-
articles: [],
|
|
93
|
-
|
|
94
|
-
// Current working article (being edited/created)
|
|
95
|
-
// These fields are for the article currently being worked on
|
|
96
|
-
article: null,
|
|
97
|
-
title: null,
|
|
98
|
-
imageUrl: null, // Cover image
|
|
99
|
-
inlineImages: [], // Array of inline image URLs
|
|
100
|
-
keywords: null,
|
|
101
|
-
metadata: null,
|
|
102
|
-
metaTitle: null,
|
|
103
|
-
metaDescription: null,
|
|
104
|
-
|
|
105
|
-
contentFolder: null // Path to saved content folder
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Generate a unique article ID
|
|
110
|
-
*/
|
|
111
|
-
function generateArticleId() {
|
|
112
|
-
return `art_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Get the path to the Suparank config directory (~/.suparank/)
|
|
117
|
-
*/
|
|
118
|
-
function getSuparankDir() {
|
|
119
|
-
return path.join(os.homedir(), '.suparank')
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Get the path to the session file (~/.suparank/session.json)
|
|
124
|
-
*/
|
|
125
|
-
function getSessionFilePath() {
|
|
126
|
-
return path.join(getSuparankDir(), 'session.json')
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Get the path to the content directory (~/.suparank/content/)
|
|
131
|
-
*/
|
|
132
|
-
function getContentDir() {
|
|
133
|
-
return path.join(getSuparankDir(), 'content')
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Ensure the Suparank config directory exists
|
|
138
|
-
*/
|
|
139
|
-
function ensureSuparankDir() {
|
|
140
|
-
const dir = getSuparankDir()
|
|
141
|
-
if (!fs.existsSync(dir)) {
|
|
142
|
-
fs.mkdirSync(dir, { recursive: true })
|
|
143
|
-
log(`Created config directory: ${dir}`)
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Ensure content directory exists
|
|
149
|
-
*/
|
|
150
|
-
function ensureContentDir() {
|
|
151
|
-
const dir = getContentDir()
|
|
152
|
-
if (!fs.existsSync(dir)) {
|
|
153
|
-
fs.mkdirSync(dir, { recursive: true })
|
|
154
|
-
}
|
|
155
|
-
return dir
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Get the path to the stats file (~/.suparank/stats.json)
|
|
160
|
-
*/
|
|
161
|
-
function getStatsFile() {
|
|
162
|
-
return path.join(getSuparankDir(), 'stats.json')
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Load usage stats
|
|
167
|
-
*/
|
|
168
|
-
function loadStats() {
|
|
169
|
-
try {
|
|
170
|
-
const file = getStatsFile()
|
|
171
|
-
if (fs.existsSync(file)) {
|
|
172
|
-
return JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
173
|
-
}
|
|
174
|
-
} catch (e) {
|
|
175
|
-
log(`Warning: Could not load stats: ${e.message}`)
|
|
176
|
-
}
|
|
177
|
-
return { tool_calls: 0, images_generated: 0, articles_created: 0, words_written: 0 }
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Save usage stats
|
|
182
|
-
*/
|
|
183
|
-
function saveStats(stats) {
|
|
184
|
-
try {
|
|
185
|
-
ensureSuparankDir()
|
|
186
|
-
fs.writeFileSync(getStatsFile(), JSON.stringify(stats, null, 2))
|
|
187
|
-
} catch (e) {
|
|
188
|
-
log(`Error saving stats: ${e.message}`)
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Increment a stat counter
|
|
194
|
-
*/
|
|
195
|
-
function incrementStat(key, amount = 1) {
|
|
196
|
-
const stats = loadStats()
|
|
197
|
-
stats[key] = (stats[key] || 0) + amount
|
|
198
|
-
saveStats(stats)
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Generate a slug from title for folder naming
|
|
203
|
-
*/
|
|
204
|
-
function slugify(text) {
|
|
205
|
-
return text
|
|
206
|
-
.toLowerCase()
|
|
207
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
208
|
-
.replace(/^-|-$/g, '')
|
|
209
|
-
.substring(0, 50)
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// ============================================================================
|
|
213
|
-
// PATH SANITIZATION - Prevents path traversal attacks
|
|
214
|
-
// ============================================================================
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Sanitize and validate a path to prevent traversal attacks
|
|
218
|
-
* @param {string} userPath - User-provided path segment
|
|
219
|
-
* @param {string} allowedBase - Base directory that paths must stay within
|
|
220
|
-
* @returns {string} - Resolved safe path
|
|
221
|
-
* @throws {Error} - If path would escape the allowed base
|
|
222
|
-
*/
|
|
223
|
-
function sanitizePath(userPath, allowedBase) {
|
|
224
|
-
// Remove any null bytes (common attack vector)
|
|
225
|
-
const cleanPath = userPath.replace(/\0/g, '')
|
|
226
|
-
|
|
227
|
-
// Resolve to absolute path
|
|
228
|
-
const resolved = path.resolve(allowedBase, cleanPath)
|
|
229
|
-
|
|
230
|
-
// Ensure the resolved path starts with the allowed base
|
|
231
|
-
// Adding path.sep ensures we don't match partial directory names
|
|
232
|
-
const normalizedBase = path.normalize(allowedBase + path.sep)
|
|
233
|
-
const normalizedResolved = path.normalize(resolved + path.sep)
|
|
234
|
-
|
|
235
|
-
if (!normalizedResolved.startsWith(normalizedBase)) {
|
|
236
|
-
throw new Error(`Path traversal detected: "${userPath}" would escape allowed directory`)
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
return resolved
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Get content folder path safely
|
|
244
|
-
* @param {string} folderName - Folder name (article ID or title slug)
|
|
245
|
-
* @returns {string} - Safe path to content folder
|
|
246
|
-
*/
|
|
247
|
-
function getContentFolderSafe(folderName) {
|
|
248
|
-
const contentDir = path.join(getSuparankDir(), 'content')
|
|
249
|
-
return sanitizePath(folderName, contentDir)
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Atomic file write - prevents corruption on concurrent writes
|
|
254
|
-
*/
|
|
255
|
-
function atomicWriteSync(filePath, data) {
|
|
256
|
-
const tmpFile = filePath + '.tmp.' + process.pid
|
|
257
|
-
try {
|
|
258
|
-
fs.writeFileSync(tmpFile, data)
|
|
259
|
-
fs.renameSync(tmpFile, filePath) // Atomic on POSIX
|
|
260
|
-
} catch (error) {
|
|
261
|
-
// Clean up temp file if rename failed
|
|
262
|
-
try { fs.unlinkSync(tmpFile) } catch (e) { /* ignore */ }
|
|
263
|
-
throw error
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Fetch with timeout - prevents hanging requests
|
|
269
|
-
*/
|
|
270
|
-
async function fetchWithTimeout(url, options = {}, timeoutMs = 30000) {
|
|
271
|
-
const controller = new AbortController()
|
|
272
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
|
273
|
-
|
|
274
|
-
try {
|
|
275
|
-
const response = await fetch(url, {
|
|
276
|
-
...options,
|
|
277
|
-
signal: controller.signal
|
|
278
|
-
})
|
|
279
|
-
return response
|
|
280
|
-
} finally {
|
|
281
|
-
clearTimeout(timeout)
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Fetch with retry - handles transient failures
|
|
287
|
-
*/
|
|
288
|
-
async function fetchWithRetry(url, options = {}, maxRetries = 3, timeoutMs = 30000) {
|
|
289
|
-
let lastError
|
|
290
|
-
|
|
291
|
-
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
292
|
-
try {
|
|
293
|
-
const response = await fetchWithTimeout(url, options, timeoutMs)
|
|
294
|
-
|
|
295
|
-
// Retry on 5xx errors or rate limiting
|
|
296
|
-
if (response.status >= 500 || response.status === 429) {
|
|
297
|
-
const retryAfter = response.headers.get('retry-after')
|
|
298
|
-
const delay = retryAfter ? parseInt(retryAfter) * 1000 : Math.pow(2, attempt) * 1000
|
|
299
|
-
|
|
300
|
-
if (attempt < maxRetries) {
|
|
301
|
-
log(`Request failed (${response.status}), retrying in ${delay}ms... (attempt ${attempt}/${maxRetries})`)
|
|
302
|
-
await new Promise(resolve => setTimeout(resolve, delay))
|
|
303
|
-
continue
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
return response
|
|
308
|
-
} catch (error) {
|
|
309
|
-
lastError = error
|
|
310
|
-
|
|
311
|
-
// Retry on network errors
|
|
312
|
-
if (error.name === 'AbortError') {
|
|
313
|
-
lastError = new Error(`Request timeout after ${timeoutMs}ms`)
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
if (attempt < maxRetries) {
|
|
317
|
-
const delay = Math.pow(2, attempt) * 1000
|
|
318
|
-
log(`Request error: ${lastError.message}, retrying in ${delay}ms... (attempt ${attempt}/${maxRetries})`)
|
|
319
|
-
await new Promise(resolve => setTimeout(resolve, delay))
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
throw lastError
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Load session state from file (survives MCP restarts)
|
|
329
|
-
* Supports both old single-article format and new multi-article format
|
|
330
|
-
*/
|
|
331
|
-
function loadSession() {
|
|
332
|
-
try {
|
|
333
|
-
const sessionFile = getSessionFilePath()
|
|
334
|
-
if (fs.existsSync(sessionFile)) {
|
|
335
|
-
const content = fs.readFileSync(sessionFile, 'utf-8')
|
|
336
|
-
const saved = JSON.parse(content)
|
|
337
|
-
|
|
338
|
-
// Check if session is stale (older than 24 hours)
|
|
339
|
-
const savedAt = new Date(saved.savedAt)
|
|
340
|
-
const hoursSinceSave = (Date.now() - savedAt.getTime()) / (1000 * 60 * 60)
|
|
341
|
-
if (hoursSinceSave > 24) {
|
|
342
|
-
log(`Session expired (${Math.round(hoursSinceSave)} hours old), starting fresh`)
|
|
343
|
-
clearSessionFile()
|
|
344
|
-
return false
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// Restore session state
|
|
348
|
-
sessionState.currentWorkflow = saved.currentWorkflow || null
|
|
349
|
-
sessionState.stepResults = saved.stepResults || {}
|
|
350
|
-
|
|
351
|
-
// Load articles array (new format)
|
|
352
|
-
sessionState.articles = saved.articles || []
|
|
353
|
-
|
|
354
|
-
// Backwards compatibility: migrate old single-article format to articles array
|
|
355
|
-
if (!saved.articles && saved.article && saved.title) {
|
|
356
|
-
const migratedArticle = {
|
|
357
|
-
id: generateArticleId(),
|
|
358
|
-
title: saved.title,
|
|
359
|
-
content: saved.article,
|
|
360
|
-
keywords: saved.keywords || [],
|
|
361
|
-
metaDescription: saved.metaDescription || '',
|
|
362
|
-
metaTitle: saved.metaTitle || saved.title,
|
|
363
|
-
imageUrl: saved.imageUrl || null,
|
|
364
|
-
inlineImages: saved.inlineImages || [],
|
|
365
|
-
savedAt: saved.savedAt,
|
|
366
|
-
published: false,
|
|
367
|
-
publishedTo: []
|
|
368
|
-
}
|
|
369
|
-
sessionState.articles = [migratedArticle]
|
|
370
|
-
log(`Migrated old session format to multi-article format`)
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// Current working article fields (cleared after each save)
|
|
374
|
-
sessionState.article = saved.article || null
|
|
375
|
-
sessionState.title = saved.title || null
|
|
376
|
-
sessionState.imageUrl = saved.imageUrl || null
|
|
377
|
-
sessionState.inlineImages = saved.inlineImages || []
|
|
378
|
-
sessionState.keywords = saved.keywords || null
|
|
379
|
-
sessionState.metadata = saved.metadata || null
|
|
380
|
-
sessionState.metaTitle = saved.metaTitle || null
|
|
381
|
-
sessionState.metaDescription = saved.metaDescription || null
|
|
382
|
-
sessionState.contentFolder = saved.contentFolder || null
|
|
383
|
-
|
|
384
|
-
log(`Restored session from ${sessionFile}`)
|
|
385
|
-
|
|
386
|
-
// Show all saved articles
|
|
387
|
-
if (sessionState.articles.length > 0) {
|
|
388
|
-
log(` - ${sessionState.articles.length} article(s) in session:`)
|
|
389
|
-
sessionState.articles.forEach((art, i) => {
|
|
390
|
-
const wordCount = art.content?.split(/\s+/).length || 0
|
|
391
|
-
const status = art.published ? `published to ${art.publishedTo.join(', ')}` : 'unpublished'
|
|
392
|
-
log(` ${i + 1}. "${art.title}" (${wordCount} words) - ${status}`)
|
|
393
|
-
})
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// Show current working article if different
|
|
397
|
-
if (sessionState.title && !sessionState.articles.find(a => a.title === sessionState.title)) {
|
|
398
|
-
log(` - Current working: "${sessionState.title}" (${sessionState.article?.split(/\s+/).length || 0} words)`)
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
if (sessionState.contentFolder) {
|
|
402
|
-
log(` - Content folder: ${sessionState.contentFolder}`)
|
|
403
|
-
}
|
|
404
|
-
return true
|
|
405
|
-
}
|
|
406
|
-
} catch (error) {
|
|
407
|
-
log(`Warning: Failed to load session: ${error.message}`)
|
|
408
|
-
}
|
|
409
|
-
return false
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// ============================================================================
|
|
413
|
-
// SESSION MUTEX - Prevents race conditions on concurrent session writes
|
|
414
|
-
// ============================================================================
|
|
415
|
-
|
|
416
|
-
let sessionLock = false
|
|
417
|
-
const sessionLockQueue = []
|
|
418
|
-
|
|
419
|
-
/**
|
|
420
|
-
* Acquire session lock for safe concurrent access
|
|
421
|
-
* @returns {Promise<void>}
|
|
422
|
-
*/
|
|
423
|
-
async function acquireSessionLock() {
|
|
424
|
-
if (!sessionLock) {
|
|
425
|
-
sessionLock = true
|
|
426
|
-
return
|
|
427
|
-
}
|
|
428
|
-
return new Promise(resolve => {
|
|
429
|
-
sessionLockQueue.push(resolve)
|
|
430
|
-
})
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* Release session lock, allowing next queued operation
|
|
435
|
-
*/
|
|
436
|
-
function releaseSessionLock() {
|
|
437
|
-
if (sessionLockQueue.length > 0) {
|
|
438
|
-
const next = sessionLockQueue.shift()
|
|
439
|
-
next()
|
|
440
|
-
} else {
|
|
441
|
-
sessionLock = false
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
/**
|
|
446
|
-
* Safe session save with mutex - use this for concurrent operations
|
|
447
|
-
* @returns {Promise<void>}
|
|
448
|
-
*/
|
|
449
|
-
async function saveSessionSafe() {
|
|
450
|
-
await acquireSessionLock()
|
|
451
|
-
try {
|
|
452
|
-
saveSession()
|
|
453
|
-
} finally {
|
|
454
|
-
releaseSessionLock()
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
* Save session state to file (persists across MCP restarts)
|
|
460
|
-
* Uses atomic write to prevent corruption
|
|
461
|
-
* NOTE: For concurrent operations, use saveSessionSafe() instead
|
|
462
|
-
*/
|
|
463
|
-
function saveSession() {
|
|
464
|
-
try {
|
|
465
|
-
ensureSuparankDir()
|
|
466
|
-
const sessionFile = getSessionFilePath()
|
|
467
|
-
|
|
468
|
-
const toSave = {
|
|
469
|
-
currentWorkflow: sessionState.currentWorkflow,
|
|
470
|
-
stepResults: sessionState.stepResults,
|
|
471
|
-
// Multi-article support
|
|
472
|
-
articles: sessionState.articles,
|
|
473
|
-
// Current working article (for backwards compat and active editing)
|
|
474
|
-
article: sessionState.article,
|
|
475
|
-
title: sessionState.title,
|
|
476
|
-
imageUrl: sessionState.imageUrl,
|
|
477
|
-
inlineImages: sessionState.inlineImages,
|
|
478
|
-
keywords: sessionState.keywords,
|
|
479
|
-
metadata: sessionState.metadata,
|
|
480
|
-
metaTitle: sessionState.metaTitle,
|
|
481
|
-
metaDescription: sessionState.metaDescription,
|
|
482
|
-
contentFolder: sessionState.contentFolder,
|
|
483
|
-
savedAt: new Date().toISOString()
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// Atomic write to prevent corruption
|
|
487
|
-
atomicWriteSync(sessionFile, JSON.stringify(toSave, null, 2))
|
|
488
|
-
progress('Session', `Saved to ${sessionFile} (${sessionState.articles.length} articles)`)
|
|
489
|
-
} catch (error) {
|
|
490
|
-
log(`Warning: Failed to save session: ${error.message}`)
|
|
491
|
-
progress('Session', `FAILED to save: ${error.message}`)
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
/**
|
|
496
|
-
* Extract image prompts from article content
|
|
497
|
-
* Uses H2 headings to create contextual image prompts
|
|
498
|
-
* @param {string} content - Article content in markdown
|
|
499
|
-
* @param {object} projectConfig - Project configuration from database
|
|
500
|
-
* @returns {Array<{heading: string, prompt: string}>} - Array of image prompts
|
|
501
|
-
*/
|
|
502
|
-
function extractImagePromptsFromArticle(content, projectConfig) {
|
|
503
|
-
// Extract H2 headings from markdown
|
|
504
|
-
const headings = content.match(/^## .+$/gm) || []
|
|
505
|
-
|
|
506
|
-
// Get visual style from project config
|
|
507
|
-
const visualStyle = projectConfig?.visual_style?.image_aesthetic || 'professional minimalist'
|
|
508
|
-
const brandColors = projectConfig?.visual_style?.colors || []
|
|
509
|
-
const brandVoice = projectConfig?.brand?.voice || 'professional'
|
|
510
|
-
const niche = projectConfig?.site?.niche || ''
|
|
511
|
-
|
|
512
|
-
// Limit to 4 images (1 hero + 3 section images)
|
|
513
|
-
const selectedHeadings = headings.slice(0, 4)
|
|
514
|
-
|
|
515
|
-
return selectedHeadings.map((heading, index) => {
|
|
516
|
-
const topic = heading.replace(/^## /, '').trim()
|
|
517
|
-
|
|
518
|
-
// Create contextual prompt based on heading
|
|
519
|
-
let prompt = `${topic}`
|
|
520
|
-
|
|
521
|
-
// Add visual style
|
|
522
|
-
if (visualStyle) {
|
|
523
|
-
prompt += `, ${visualStyle} style`
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
// Add brand context for hero image
|
|
527
|
-
if (index === 0) {
|
|
528
|
-
prompt += `, hero image for article about ${niche}`
|
|
529
|
-
} else {
|
|
530
|
-
prompt += `, illustration for ${niche} article`
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
// Add quality modifiers
|
|
534
|
-
prompt += ', high quality, professional, clean composition, no text'
|
|
535
|
-
|
|
536
|
-
return {
|
|
537
|
-
heading: topic,
|
|
538
|
-
prompt: prompt,
|
|
539
|
-
type: index === 0 ? 'hero' : 'section',
|
|
540
|
-
aspectRatio: '16:9'
|
|
541
|
-
}
|
|
542
|
-
})
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
/**
|
|
546
|
-
* Save content to a dedicated folder with all assets
|
|
547
|
-
* Creates: ~/.suparank/content/{date}-{slug}/
|
|
548
|
-
* - article.md (markdown content)
|
|
549
|
-
* - metadata.json (title, keywords, etc.)
|
|
550
|
-
* - workflow.json (workflow state for resuming)
|
|
551
|
-
*/
|
|
552
|
-
function saveContentToFolder() {
|
|
553
|
-
if (!sessionState.title || !sessionState.article) {
|
|
554
|
-
return null
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
try {
|
|
558
|
-
ensureContentDir()
|
|
559
|
-
|
|
560
|
-
// Create folder name: YYYY-MM-DD-slug (slugify removes dangerous characters)
|
|
561
|
-
const date = new Date().toISOString().split('T')[0]
|
|
562
|
-
const slug = slugify(sessionState.title)
|
|
563
|
-
const folderName = `${date}-${slug}`
|
|
564
|
-
|
|
565
|
-
// Use safe path function to prevent any path traversal
|
|
566
|
-
const folderPath = getContentFolderSafe(folderName)
|
|
567
|
-
|
|
568
|
-
// Create folder if doesn't exist
|
|
569
|
-
if (!fs.existsSync(folderPath)) {
|
|
570
|
-
fs.mkdirSync(folderPath, { recursive: true })
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
// Save markdown article
|
|
574
|
-
atomicWriteSync(
|
|
575
|
-
path.join(folderPath, 'article.md'),
|
|
576
|
-
sessionState.article
|
|
577
|
-
)
|
|
578
|
-
|
|
579
|
-
// Save metadata
|
|
580
|
-
const metadata = {
|
|
581
|
-
title: sessionState.title,
|
|
582
|
-
keywords: sessionState.keywords || [],
|
|
583
|
-
metaDescription: sessionState.metaDescription || '',
|
|
584
|
-
metaTitle: sessionState.metaTitle || sessionState.title,
|
|
585
|
-
imageUrl: sessionState.imageUrl,
|
|
586
|
-
inlineImages: sessionState.inlineImages || [],
|
|
587
|
-
wordCount: sessionState.article.split(/\s+/).length,
|
|
588
|
-
createdAt: new Date().toISOString(),
|
|
589
|
-
projectSlug: projectSlug
|
|
590
|
-
}
|
|
591
|
-
atomicWriteSync(
|
|
592
|
-
path.join(folderPath, 'metadata.json'),
|
|
593
|
-
JSON.stringify(metadata, null, 2)
|
|
594
|
-
)
|
|
595
|
-
|
|
596
|
-
// Save workflow state for resuming
|
|
597
|
-
if (sessionState.currentWorkflow) {
|
|
598
|
-
atomicWriteSync(
|
|
599
|
-
path.join(folderPath, 'workflow.json'),
|
|
600
|
-
JSON.stringify({
|
|
601
|
-
workflow: sessionState.currentWorkflow,
|
|
602
|
-
stepResults: sessionState.stepResults,
|
|
603
|
-
savedAt: new Date().toISOString()
|
|
604
|
-
}, null, 2)
|
|
605
|
-
)
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// Store folder path in session
|
|
609
|
-
sessionState.contentFolder = folderPath
|
|
610
|
-
|
|
611
|
-
progress('Content', `Saved to folder: ${folderPath}`)
|
|
612
|
-
return folderPath
|
|
613
|
-
} catch (error) {
|
|
614
|
-
log(`Warning: Failed to save content to folder: ${error.message}`)
|
|
615
|
-
return null
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
/**
|
|
620
|
-
* Clear session file (called after successful publish or on reset)
|
|
621
|
-
*/
|
|
622
|
-
function clearSessionFile() {
|
|
623
|
-
try {
|
|
624
|
-
const sessionFile = getSessionFilePath()
|
|
625
|
-
if (fs.existsSync(sessionFile)) {
|
|
626
|
-
fs.unlinkSync(sessionFile)
|
|
627
|
-
}
|
|
628
|
-
} catch (error) {
|
|
629
|
-
log(`Warning: Failed to clear session file: ${error.message}`)
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
/**
|
|
634
|
-
* Reset session state for new workflow (clears everything including all articles)
|
|
635
|
-
*/
|
|
636
|
-
function resetSession() {
|
|
637
|
-
sessionState.currentWorkflow = null
|
|
638
|
-
sessionState.stepResults = {}
|
|
639
|
-
sessionState.articles = [] // Clear all saved articles
|
|
640
|
-
sessionState.article = null
|
|
641
|
-
sessionState.title = null
|
|
642
|
-
sessionState.imageUrl = null
|
|
643
|
-
sessionState.inlineImages = []
|
|
644
|
-
sessionState.keywords = null
|
|
645
|
-
sessionState.metadata = null
|
|
646
|
-
sessionState.metaTitle = null
|
|
647
|
-
sessionState.metaDescription = null
|
|
648
|
-
sessionState.contentFolder = null
|
|
649
|
-
|
|
650
|
-
// Clear persisted session file when starting fresh
|
|
651
|
-
clearSessionFile()
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
/**
|
|
655
|
-
* Clear current working article without removing saved articles
|
|
656
|
-
* Use this after saving an article to prepare for the next one
|
|
657
|
-
*/
|
|
658
|
-
function clearCurrentArticle() {
|
|
659
|
-
sessionState.article = null
|
|
660
|
-
sessionState.title = null
|
|
661
|
-
sessionState.imageUrl = null
|
|
662
|
-
sessionState.inlineImages = []
|
|
663
|
-
sessionState.keywords = null
|
|
664
|
-
sessionState.metadata = null
|
|
665
|
-
sessionState.metaTitle = null
|
|
666
|
-
sessionState.metaDescription = null
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
/**
|
|
670
|
-
* Load credentials from ~/.suparank/credentials.json
|
|
671
|
-
* Falls back to legacy .env.superwriter paths for backward compatibility
|
|
672
|
-
*/
|
|
673
|
-
function loadLocalCredentials() {
|
|
674
|
-
const searchPaths = [
|
|
675
|
-
path.join(os.homedir(), '.suparank', 'credentials.json'),
|
|
676
|
-
path.join(process.cwd(), '.env.superwriter'), // Legacy support
|
|
677
|
-
path.join(os.homedir(), '.env.superwriter') // Legacy support
|
|
678
|
-
]
|
|
679
|
-
|
|
680
|
-
for (const filePath of searchPaths) {
|
|
681
|
-
if (fs.existsSync(filePath)) {
|
|
682
|
-
try {
|
|
683
|
-
const content = fs.readFileSync(filePath, 'utf-8')
|
|
684
|
-
const parsed = JSON.parse(content)
|
|
685
|
-
log(`Loaded credentials from: ${filePath}`)
|
|
686
|
-
return parsed
|
|
687
|
-
} catch (e) {
|
|
688
|
-
log(`Warning: Failed to parse ${filePath}: ${e.message}`)
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
log('No credentials found. Run "npx suparank setup" to configure. Action tools will be limited.')
|
|
694
|
-
return null
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
/**
|
|
698
|
-
* Check if a credential type is available
|
|
699
|
-
*/
|
|
700
|
-
function hasCredential(type) {
|
|
701
|
-
if (!localCredentials) return false
|
|
702
|
-
|
|
703
|
-
switch (type) {
|
|
704
|
-
case 'wordpress':
|
|
705
|
-
return !!localCredentials.wordpress?.secret_key || !!localCredentials.wordpress?.app_password
|
|
706
|
-
case 'ghost':
|
|
707
|
-
return !!localCredentials.ghost?.admin_api_key
|
|
708
|
-
case 'fal':
|
|
709
|
-
return !!localCredentials.fal?.api_key
|
|
710
|
-
case 'gemini':
|
|
711
|
-
return !!localCredentials.gemini?.api_key
|
|
712
|
-
case 'wiro':
|
|
713
|
-
return !!localCredentials.wiro?.api_key
|
|
714
|
-
case 'image':
|
|
715
|
-
const provider = localCredentials.image_provider
|
|
716
|
-
return provider && hasCredential(provider)
|
|
717
|
-
case 'webhooks':
|
|
718
|
-
return !!localCredentials.webhooks && Object.values(localCredentials.webhooks).some(Boolean)
|
|
719
|
-
default:
|
|
720
|
-
return false
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
/**
|
|
725
|
-
* Get composition hints for a tool from local credentials
|
|
726
|
-
*/
|
|
727
|
-
function getCompositionHints(toolName) {
|
|
728
|
-
if (!localCredentials?.tool_instructions) return null
|
|
729
|
-
|
|
730
|
-
const instruction = localCredentials.tool_instructions.find(t => t.tool_name === toolName)
|
|
731
|
-
return instruction?.composition_hints || null
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
/**
|
|
735
|
-
* Get list of external MCPs configured
|
|
736
|
-
*/
|
|
737
|
-
function getExternalMCPs() {
|
|
738
|
-
return localCredentials?.external_mcps || []
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
// Fetch project config from API
|
|
742
|
-
async function fetchProjectConfig() {
|
|
743
|
-
try {
|
|
744
|
-
const response = await fetchWithRetry(`${apiUrl}/projects/${projectSlug}`, {
|
|
745
|
-
headers: {
|
|
746
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
747
|
-
'Content-Type': 'application/json'
|
|
748
|
-
}
|
|
749
|
-
}, 3, 15000) // 3 retries, 15s timeout
|
|
750
|
-
|
|
751
|
-
if (!response.ok) {
|
|
752
|
-
const error = await response.text()
|
|
753
|
-
|
|
754
|
-
if (response.status === 401) {
|
|
755
|
-
throw new Error(`Invalid or expired API key. Please create a new one in the dashboard.`)
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
throw new Error(`Failed to fetch project: ${error}`)
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
const data = await response.json()
|
|
762
|
-
return data.project
|
|
763
|
-
} catch (error) {
|
|
764
|
-
log('Error fetching project config:', error.message)
|
|
765
|
-
throw error
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
// Call backend API to execute tool
|
|
770
|
-
async function callBackendTool(toolName, args) {
|
|
771
|
-
try {
|
|
772
|
-
const response = await fetch(`${apiUrl}/tools/${projectSlug}/${toolName}`, {
|
|
773
|
-
method: 'POST',
|
|
774
|
-
headers: {
|
|
775
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
776
|
-
'Content-Type': 'application/json'
|
|
777
|
-
},
|
|
778
|
-
body: JSON.stringify({ arguments: args })
|
|
779
|
-
})
|
|
780
|
-
|
|
781
|
-
if (!response.ok) {
|
|
782
|
-
const error = await response.text()
|
|
783
|
-
|
|
784
|
-
if (response.status === 401) {
|
|
785
|
-
throw new Error(`Invalid or expired API key. Please create a new one in the dashboard.`)
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
throw new Error(`Tool execution failed: ${error}`)
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
const result = await response.json()
|
|
792
|
-
return result
|
|
793
|
-
} catch (error) {
|
|
794
|
-
log('Error calling tool:', error.message)
|
|
795
|
-
throw error
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
// Tool definitions (synced with backend)
|
|
800
|
-
const TOOLS = [
|
|
801
|
-
{
|
|
802
|
-
name: 'keyword_research',
|
|
803
|
-
description: `Research keywords for SEO. Use ONLY when user specifically asks for keyword research WITHOUT wanting full article creation.
|
|
804
|
-
|
|
805
|
-
TRIGGERS - Use when user says:
|
|
806
|
-
- "find keywords for..."
|
|
807
|
-
- "research keywords about..."
|
|
808
|
-
- "what keywords should I target for..."
|
|
809
|
-
- "keyword ideas for..."
|
|
810
|
-
- "analyze keywords for..."
|
|
811
|
-
|
|
812
|
-
DO NOT USE when user wants to write/create content - use create_content instead (it includes keyword research automatically).
|
|
813
|
-
|
|
814
|
-
OUTCOME: List of keywords with search volume, difficulty, and recommendations.`,
|
|
815
|
-
inputSchema: {
|
|
816
|
-
type: 'object',
|
|
817
|
-
properties: {
|
|
818
|
-
seed_keyword: {
|
|
819
|
-
type: 'string',
|
|
820
|
-
description: 'Starting keyword or topic to research (optional - uses project primary keywords if not specified)'
|
|
821
|
-
},
|
|
822
|
-
content_goal: {
|
|
823
|
-
type: 'string',
|
|
824
|
-
enum: ['traffic', 'conversions', 'brand-awareness'],
|
|
825
|
-
description: 'Primary goal for the content strategy (optional - defaults to traffic)'
|
|
826
|
-
},
|
|
827
|
-
competitor_domain: {
|
|
828
|
-
type: 'string',
|
|
829
|
-
description: 'Optional: Competitor domain to analyze'
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
},
|
|
834
|
-
{
|
|
835
|
-
name: 'seo_strategy',
|
|
836
|
-
description: 'Create comprehensive SEO strategy and content brief. Works with project keywords automatically if none specified.',
|
|
837
|
-
inputSchema: {
|
|
838
|
-
type: 'object',
|
|
839
|
-
properties: {
|
|
840
|
-
target_keyword: {
|
|
841
|
-
type: 'string',
|
|
842
|
-
description: 'Main keyword to target (optional - uses project primary keywords if not specified)'
|
|
843
|
-
},
|
|
844
|
-
content_type: {
|
|
845
|
-
type: 'string',
|
|
846
|
-
enum: ['guide', 'listicle', 'how-to', 'comparison', 'review'],
|
|
847
|
-
description: 'Type of content to create (optional - defaults to guide)'
|
|
848
|
-
},
|
|
849
|
-
search_intent: {
|
|
850
|
-
type: 'string',
|
|
851
|
-
enum: ['informational', 'commercial', 'transactional', 'navigational'],
|
|
852
|
-
description: 'Primary search intent to target (optional - auto-detected)'
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
},
|
|
857
|
-
{
|
|
858
|
-
name: 'topical_map',
|
|
859
|
-
description: 'Design pillar-cluster content architecture for topical authority. Uses project niche and keywords automatically.',
|
|
860
|
-
inputSchema: {
|
|
861
|
-
type: 'object',
|
|
862
|
-
properties: {
|
|
863
|
-
core_topic: {
|
|
864
|
-
type: 'string',
|
|
865
|
-
description: 'Main topic for the content cluster (optional - uses project niche if not specified)'
|
|
866
|
-
},
|
|
867
|
-
depth: {
|
|
868
|
-
type: 'number',
|
|
869
|
-
enum: [1, 2, 3],
|
|
870
|
-
description: 'Depth of content cluster: 1 (pillar + 5 articles), 2 (+ subtopics), 3 (full hierarchy)',
|
|
871
|
-
default: 2
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
},
|
|
876
|
-
{
|
|
877
|
-
name: 'content_calendar',
|
|
878
|
-
description: 'Create editorial calendar and publication schedule. Uses project keywords and niche automatically.',
|
|
879
|
-
inputSchema: {
|
|
880
|
-
type: 'object',
|
|
881
|
-
properties: {
|
|
882
|
-
time_period: {
|
|
883
|
-
type: 'string',
|
|
884
|
-
enum: ['week', 'month', 'quarter'],
|
|
885
|
-
description: 'Planning period for the content calendar (optional - defaults to month)',
|
|
886
|
-
default: 'month'
|
|
887
|
-
},
|
|
888
|
-
content_types: {
|
|
889
|
-
type: 'array',
|
|
890
|
-
items: { type: 'string' },
|
|
891
|
-
description: 'Types of content to include (optional - defaults to blog)'
|
|
892
|
-
},
|
|
893
|
-
priority_keywords: {
|
|
894
|
-
type: 'array',
|
|
895
|
-
items: { type: 'string' },
|
|
896
|
-
description: 'Keywords to prioritize (optional - uses project keywords)'
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
},
|
|
901
|
-
{
|
|
902
|
-
name: 'content_write',
|
|
903
|
-
description: 'Write comprehensive, SEO-optimized blog articles. Creates engaging content with proper structure, internal links, and semantic optimization. Uses project brand voice and keywords automatically.',
|
|
904
|
-
inputSchema: {
|
|
905
|
-
type: 'object',
|
|
906
|
-
properties: {
|
|
907
|
-
title: {
|
|
908
|
-
type: 'string',
|
|
909
|
-
description: 'Article title or headline (optional - can be generated from topic)'
|
|
910
|
-
},
|
|
911
|
-
target_keyword: {
|
|
912
|
-
type: 'string',
|
|
913
|
-
description: 'Primary keyword to optimize for (optional - uses project keywords)'
|
|
914
|
-
},
|
|
915
|
-
outline: {
|
|
916
|
-
type: 'string',
|
|
917
|
-
description: 'Optional: Article outline or structure (H2/H3 headings)'
|
|
918
|
-
},
|
|
919
|
-
tone: {
|
|
920
|
-
type: 'string',
|
|
921
|
-
enum: ['professional', 'casual', 'conversational', 'technical'],
|
|
922
|
-
description: 'Writing tone (optional - uses project brand voice)'
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
},
|
|
927
|
-
{
|
|
928
|
-
name: 'image_prompt',
|
|
929
|
-
description: 'Create optimized prompts for AI image generation. Designs prompts for blog hero images, section illustrations, and branded visuals. Uses project visual style and brand automatically.',
|
|
930
|
-
inputSchema: {
|
|
931
|
-
type: 'object',
|
|
932
|
-
properties: {
|
|
933
|
-
image_purpose: {
|
|
934
|
-
type: 'string',
|
|
935
|
-
enum: ['hero', 'section', 'diagram', 'comparison', 'infographic'],
|
|
936
|
-
description: 'Purpose of the image (optional - defaults to hero)',
|
|
937
|
-
default: 'hero'
|
|
938
|
-
},
|
|
939
|
-
subject: {
|
|
940
|
-
type: 'string',
|
|
941
|
-
description: 'Main subject or concept for the image (optional - uses project niche)'
|
|
942
|
-
},
|
|
943
|
-
mood: {
|
|
944
|
-
type: 'string',
|
|
945
|
-
description: 'Optional: Desired mood (uses project visual style if not specified)'
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
},
|
|
950
|
-
{
|
|
951
|
-
name: 'internal_links',
|
|
952
|
-
description: 'Develop strategic internal linking plan. Analyzes existing content and identifies linking opportunities for improved site architecture. Works with project content automatically.',
|
|
953
|
-
inputSchema: {
|
|
954
|
-
type: 'object',
|
|
955
|
-
properties: {
|
|
956
|
-
current_page: {
|
|
957
|
-
type: 'string',
|
|
958
|
-
description: 'URL or title of the page to optimize (optional - can work with last created content)'
|
|
959
|
-
},
|
|
960
|
-
available_pages: {
|
|
961
|
-
type: 'array',
|
|
962
|
-
items: { type: 'string' },
|
|
963
|
-
description: 'List of existing pages to consider (optional - can analyze site automatically)'
|
|
964
|
-
},
|
|
965
|
-
link_goal: {
|
|
966
|
-
type: 'string',
|
|
967
|
-
enum: ['authority-building', 'user-navigation', 'conversion'],
|
|
968
|
-
description: 'Primary goal for internal linking (optional - defaults to authority-building)'
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
},
|
|
973
|
-
{
|
|
974
|
-
name: 'schema_generate',
|
|
975
|
-
description: 'Implement Schema.org structured data markup. Analyzes content to recommend and generate appropriate JSON-LD schemas for enhanced search visibility. Auto-detects page type if not specified.',
|
|
976
|
-
inputSchema: {
|
|
977
|
-
type: 'object',
|
|
978
|
-
properties: {
|
|
979
|
-
page_type: {
|
|
980
|
-
type: 'string',
|
|
981
|
-
enum: ['article', 'product', 'how-to', 'faq', 'review', 'organization'],
|
|
982
|
-
description: 'Type of page to generate schema for (optional - auto-detected from content)'
|
|
983
|
-
},
|
|
984
|
-
content_summary: {
|
|
985
|
-
type: 'string',
|
|
986
|
-
description: 'Brief summary of the page content (optional - can analyze content)'
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
},
|
|
991
|
-
{
|
|
992
|
-
name: 'geo_optimize',
|
|
993
|
-
description: 'Optimize content for AI search engines and Google SGE. Implements GEO (Generative Engine Optimization) best practices for LLM-friendly content. Works with project content automatically.',
|
|
994
|
-
inputSchema: {
|
|
995
|
-
type: 'object',
|
|
996
|
-
properties: {
|
|
997
|
-
content_url: {
|
|
998
|
-
type: 'string',
|
|
999
|
-
description: 'URL or title of content to optimize (optional - can work with last created content)'
|
|
1000
|
-
},
|
|
1001
|
-
target_engines: {
|
|
1002
|
-
type: 'array',
|
|
1003
|
-
items: {
|
|
1004
|
-
type: 'string',
|
|
1005
|
-
enum: ['chatgpt', 'perplexity', 'claude', 'gemini', 'google-sge']
|
|
1006
|
-
},
|
|
1007
|
-
description: 'AI search engines to optimize for (optional - defaults to all)',
|
|
1008
|
-
default: ['chatgpt', 'google-sge']
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
},
|
|
1013
|
-
{
|
|
1014
|
-
name: 'quality_check',
|
|
1015
|
-
description: 'Perform comprehensive pre-publish quality assurance. Checks grammar, SEO requirements, brand consistency, accessibility, and technical accuracy. Can review last created content automatically.',
|
|
1016
|
-
inputSchema: {
|
|
1017
|
-
type: 'object',
|
|
1018
|
-
properties: {
|
|
1019
|
-
content: {
|
|
1020
|
-
type: 'string',
|
|
1021
|
-
description: 'Full content to review (optional - can review last created content)'
|
|
1022
|
-
},
|
|
1023
|
-
check_type: {
|
|
1024
|
-
type: 'string',
|
|
1025
|
-
enum: ['full', 'seo-only', 'grammar-only', 'brand-only'],
|
|
1026
|
-
description: 'Type of quality check to perform (optional - defaults to full)',
|
|
1027
|
-
default: 'full'
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
},
|
|
1032
|
-
{
|
|
1033
|
-
name: 'full_pipeline',
|
|
1034
|
-
description: 'Execute complete 5-phase content creation pipeline. Orchestrates research, planning, creation, optimization, and quality checking in one workflow. Works with project configuration automatically - just describe what you need!',
|
|
1035
|
-
inputSchema: {
|
|
1036
|
-
type: 'object',
|
|
1037
|
-
properties: {
|
|
1038
|
-
seed_keyword: {
|
|
1039
|
-
type: 'string',
|
|
1040
|
-
description: 'Starting keyword for the pipeline (optional - uses project primary keywords and niche)'
|
|
1041
|
-
},
|
|
1042
|
-
content_type: {
|
|
1043
|
-
type: 'string',
|
|
1044
|
-
enum: ['guide', 'listicle', 'how-to', 'comparison', 'review'],
|
|
1045
|
-
description: 'Type of content to create (optional - defaults to guide)',
|
|
1046
|
-
default: 'guide'
|
|
1047
|
-
},
|
|
1048
|
-
skip_phases: {
|
|
1049
|
-
type: 'array',
|
|
1050
|
-
items: {
|
|
1051
|
-
type: 'string',
|
|
1052
|
-
enum: ['research', 'planning', 'creation', 'optimization', 'quality']
|
|
1053
|
-
},
|
|
1054
|
-
description: 'Optional: Phases to skip in the pipeline'
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
]
|
|
1060
|
-
|
|
1061
|
-
// Action tools that require local credentials
|
|
1062
|
-
const ACTION_TOOLS = [
|
|
1063
|
-
{
|
|
1064
|
-
name: 'generate_image',
|
|
1065
|
-
description: `Generate AI images. Use when user wants to create, generate, or regenerate images.
|
|
1066
|
-
|
|
1067
|
-
TRIGGERS - Use when user says:
|
|
1068
|
-
- "create an image for..."
|
|
1069
|
-
- "generate image of..."
|
|
1070
|
-
- "make a picture of..."
|
|
1071
|
-
- "I need an image for..."
|
|
1072
|
-
- "regenerate the image"
|
|
1073
|
-
- "new hero image"
|
|
1074
|
-
- "create thumbnail for..."
|
|
1075
|
-
|
|
1076
|
-
NOTE: create_content automatically generates images. Use this tool for:
|
|
1077
|
-
- Regenerating/replacing images
|
|
1078
|
-
- Creating standalone images
|
|
1079
|
-
- Custom image requests outside content workflow
|
|
1080
|
-
|
|
1081
|
-
OUTCOME: AI-generated image URL ready for use.`,
|
|
1082
|
-
inputSchema: {
|
|
1083
|
-
type: 'object',
|
|
1084
|
-
properties: {
|
|
1085
|
-
prompt: {
|
|
1086
|
-
type: 'string',
|
|
1087
|
-
description: 'Detailed prompt for image generation'
|
|
1088
|
-
},
|
|
1089
|
-
style: {
|
|
1090
|
-
type: 'string',
|
|
1091
|
-
description: 'Style guidance (e.g., "minimalist", "photorealistic", "illustration")'
|
|
1092
|
-
},
|
|
1093
|
-
aspect_ratio: {
|
|
1094
|
-
type: 'string',
|
|
1095
|
-
enum: ['1:1', '16:9', '9:16', '4:3', '3:4'],
|
|
1096
|
-
description: 'Image aspect ratio',
|
|
1097
|
-
default: '16:9'
|
|
1098
|
-
}
|
|
1099
|
-
},
|
|
1100
|
-
required: ['prompt']
|
|
1101
|
-
},
|
|
1102
|
-
requiresCredential: 'image'
|
|
1103
|
-
},
|
|
1104
|
-
{
|
|
1105
|
-
name: 'publish_wordpress',
|
|
1106
|
-
description: 'Publish content directly to WordPress (supports .com and .org). Requires WordPress credentials in ~/.suparank/credentials.json',
|
|
1107
|
-
inputSchema: {
|
|
1108
|
-
type: 'object',
|
|
1109
|
-
properties: {
|
|
1110
|
-
title: {
|
|
1111
|
-
type: 'string',
|
|
1112
|
-
description: 'Post title'
|
|
1113
|
-
},
|
|
1114
|
-
content: {
|
|
1115
|
-
type: 'string',
|
|
1116
|
-
description: 'Full post content (HTML or Markdown)'
|
|
1117
|
-
},
|
|
1118
|
-
status: {
|
|
1119
|
-
type: 'string',
|
|
1120
|
-
enum: ['draft', 'publish'],
|
|
1121
|
-
description: 'Publication status',
|
|
1122
|
-
default: 'draft'
|
|
1123
|
-
},
|
|
1124
|
-
categories: {
|
|
1125
|
-
type: 'array',
|
|
1126
|
-
items: { type: 'string' },
|
|
1127
|
-
description: 'Category names'
|
|
1128
|
-
},
|
|
1129
|
-
tags: {
|
|
1130
|
-
type: 'array',
|
|
1131
|
-
items: { type: 'string' },
|
|
1132
|
-
description: 'Tag names'
|
|
1133
|
-
},
|
|
1134
|
-
featured_image_url: {
|
|
1135
|
-
type: 'string',
|
|
1136
|
-
description: 'URL of featured image to upload'
|
|
1137
|
-
}
|
|
1138
|
-
},
|
|
1139
|
-
required: ['title', 'content']
|
|
1140
|
-
},
|
|
1141
|
-
requiresCredential: 'wordpress'
|
|
1142
|
-
},
|
|
1143
|
-
{
|
|
1144
|
-
name: 'publish_ghost',
|
|
1145
|
-
description: 'Publish content to Ghost CMS. Requires Ghost Admin API key in ~/.suparank/credentials.json',
|
|
1146
|
-
inputSchema: {
|
|
1147
|
-
type: 'object',
|
|
1148
|
-
properties: {
|
|
1149
|
-
title: {
|
|
1150
|
-
type: 'string',
|
|
1151
|
-
description: 'Post title'
|
|
1152
|
-
},
|
|
1153
|
-
content: {
|
|
1154
|
-
type: 'string',
|
|
1155
|
-
description: 'Full post content (HTML or Markdown)'
|
|
1156
|
-
},
|
|
1157
|
-
status: {
|
|
1158
|
-
type: 'string',
|
|
1159
|
-
enum: ['draft', 'published'],
|
|
1160
|
-
description: 'Publication status',
|
|
1161
|
-
default: 'draft'
|
|
1162
|
-
},
|
|
1163
|
-
tags: {
|
|
1164
|
-
type: 'array',
|
|
1165
|
-
items: { type: 'string' },
|
|
1166
|
-
description: 'Tag names'
|
|
1167
|
-
},
|
|
1168
|
-
featured_image_url: {
|
|
1169
|
-
type: 'string',
|
|
1170
|
-
description: 'URL of featured image'
|
|
1171
|
-
}
|
|
1172
|
-
},
|
|
1173
|
-
required: ['title', 'content']
|
|
1174
|
-
},
|
|
1175
|
-
requiresCredential: 'ghost'
|
|
1176
|
-
},
|
|
1177
|
-
{
|
|
1178
|
-
name: 'send_webhook',
|
|
1179
|
-
description: 'Send data to configured webhooks (Make.com, n8n, Zapier, Slack). Requires webhook URLs in ~/.suparank/credentials.json',
|
|
1180
|
-
inputSchema: {
|
|
1181
|
-
type: 'object',
|
|
1182
|
-
properties: {
|
|
1183
|
-
webhook_type: {
|
|
1184
|
-
type: 'string',
|
|
1185
|
-
enum: ['default', 'make', 'n8n', 'zapier', 'slack'],
|
|
1186
|
-
description: 'Which webhook to use',
|
|
1187
|
-
default: 'default'
|
|
1188
|
-
},
|
|
1189
|
-
payload: {
|
|
1190
|
-
type: 'object',
|
|
1191
|
-
description: 'Data to send in the webhook'
|
|
1192
|
-
},
|
|
1193
|
-
message: {
|
|
1194
|
-
type: 'string',
|
|
1195
|
-
description: 'For Slack: formatted message text'
|
|
1196
|
-
}
|
|
1197
|
-
},
|
|
1198
|
-
required: ['webhook_type']
|
|
1199
|
-
},
|
|
1200
|
-
requiresCredential: 'webhooks'
|
|
1201
|
-
}
|
|
1202
|
-
]
|
|
1203
|
-
|
|
1204
|
-
// Orchestrator tools for automated workflows
|
|
1205
|
-
const ORCHESTRATOR_TOOLS = [
|
|
1206
|
-
{
|
|
1207
|
-
name: 'create_content',
|
|
1208
|
-
description: `PRIMARY TOOL for content creation. Use this when user wants to write, create, or generate any content.
|
|
1209
|
-
|
|
1210
|
-
TRIGGERS - Use when user says:
|
|
1211
|
-
- "write a blog post about..."
|
|
1212
|
-
- "create an article about..."
|
|
1213
|
-
- "I need content for..."
|
|
1214
|
-
- "help me write about..."
|
|
1215
|
-
- "generate a post on..."
|
|
1216
|
-
- "make content about..."
|
|
1217
|
-
- any request involving writing/creating/generating articles or blog posts
|
|
1218
|
-
|
|
1219
|
-
WORKFLOW (automatic 4-phase):
|
|
1220
|
-
1. RESEARCH: Keywords, SEO strategy, content structure
|
|
1221
|
-
2. CREATION: Outline, write full article, save to session
|
|
1222
|
-
3. OPTIMIZATION: Quality check, GEO optimization for AI search
|
|
1223
|
-
4. PUBLISHING: Generate images, publish to WordPress/Ghost
|
|
1224
|
-
|
|
1225
|
-
OUTCOME: Complete article written, optimized, and published to CMS.`,
|
|
1226
|
-
inputSchema: {
|
|
1227
|
-
type: 'object',
|
|
1228
|
-
properties: {
|
|
1229
|
-
request: {
|
|
1230
|
-
type: 'string',
|
|
1231
|
-
description: 'What content do you want? (e.g., "write a blog post about AI", "create 5 articles")'
|
|
1232
|
-
},
|
|
1233
|
-
count: {
|
|
1234
|
-
type: 'number',
|
|
1235
|
-
description: 'Number of articles to create (default: 1)',
|
|
1236
|
-
default: 1
|
|
1237
|
-
},
|
|
1238
|
-
publish_to: {
|
|
1239
|
-
type: 'array',
|
|
1240
|
-
items: { type: 'string', enum: ['ghost', 'wordpress', 'none'] },
|
|
1241
|
-
description: 'Where to publish (default: all configured CMS)',
|
|
1242
|
-
default: []
|
|
1243
|
-
},
|
|
1244
|
-
with_images: {
|
|
1245
|
-
type: 'boolean',
|
|
1246
|
-
description: 'Generate hero images (default: true)',
|
|
1247
|
-
default: true
|
|
1248
|
-
}
|
|
1249
|
-
}
|
|
1250
|
-
}
|
|
1251
|
-
},
|
|
1252
|
-
{
|
|
1253
|
-
name: 'save_content',
|
|
1254
|
-
description: `Save written article to session. Use after manually writing content outside create_content workflow.
|
|
1255
|
-
|
|
1256
|
-
TRIGGERS - Use when:
|
|
1257
|
-
- You wrote an article manually and need to save it
|
|
1258
|
-
- User says "save this article" / "save my content"
|
|
1259
|
-
- Saving edited/revised content
|
|
1260
|
-
|
|
1261
|
-
NOTE: create_content saves automatically. Only use this for manual saves.
|
|
1262
|
-
|
|
1263
|
-
OUTCOME: Article saved to session, ready for publishing.`,
|
|
1264
|
-
inputSchema: {
|
|
1265
|
-
type: 'object',
|
|
1266
|
-
properties: {
|
|
1267
|
-
title: {
|
|
1268
|
-
type: 'string',
|
|
1269
|
-
description: 'Article title'
|
|
1270
|
-
},
|
|
1271
|
-
content: {
|
|
1272
|
-
type: 'string',
|
|
1273
|
-
description: 'Full article content (markdown)'
|
|
1274
|
-
},
|
|
1275
|
-
keywords: {
|
|
1276
|
-
type: 'array',
|
|
1277
|
-
items: { type: 'string' },
|
|
1278
|
-
description: 'Target keywords used'
|
|
1279
|
-
},
|
|
1280
|
-
meta_description: {
|
|
1281
|
-
type: 'string',
|
|
1282
|
-
description: 'SEO meta description'
|
|
1283
|
-
}
|
|
1284
|
-
},
|
|
1285
|
-
required: ['title', 'content']
|
|
1286
|
-
}
|
|
1287
|
-
},
|
|
1288
|
-
{
|
|
1289
|
-
name: 'publish_content',
|
|
1290
|
-
description: `Publish articles to WordPress/Ghost. Use when user wants to publish saved content.
|
|
1291
|
-
|
|
1292
|
-
TRIGGERS - Use when user says:
|
|
1293
|
-
- "publish my article"
|
|
1294
|
-
- "post this to WordPress/Ghost"
|
|
1295
|
-
- "publish to my blog"
|
|
1296
|
-
- "make it live"
|
|
1297
|
-
- "publish as draft"
|
|
1298
|
-
|
|
1299
|
-
NOTE: create_content publishes automatically. Use this for:
|
|
1300
|
-
- Manual publishing control
|
|
1301
|
-
- Re-publishing edited content
|
|
1302
|
-
- Publishing specific articles from session
|
|
1303
|
-
|
|
1304
|
-
OUTCOME: Article published to configured CMS platforms.`,
|
|
1305
|
-
inputSchema: {
|
|
1306
|
-
type: 'object',
|
|
1307
|
-
properties: {
|
|
1308
|
-
platforms: {
|
|
1309
|
-
type: 'array',
|
|
1310
|
-
items: { type: 'string', enum: ['ghost', 'wordpress', 'all'] },
|
|
1311
|
-
description: 'Platforms to publish to (default: all configured)',
|
|
1312
|
-
default: ['all']
|
|
1313
|
-
},
|
|
1314
|
-
status: {
|
|
1315
|
-
type: 'string',
|
|
1316
|
-
enum: ['draft', 'publish'],
|
|
1317
|
-
description: 'Publication status',
|
|
1318
|
-
default: 'draft'
|
|
1319
|
-
},
|
|
1320
|
-
category: {
|
|
1321
|
-
type: 'string',
|
|
1322
|
-
description: 'WordPress category name - pick the most relevant one from available categories shown in save_content response'
|
|
1323
|
-
},
|
|
1324
|
-
article_numbers: {
|
|
1325
|
-
type: 'array',
|
|
1326
|
-
items: { type: 'number' },
|
|
1327
|
-
description: 'Optional: Publish specific articles by number (1, 2, 3...). If not specified, publishes ALL unpublished articles.'
|
|
1328
|
-
}
|
|
1329
|
-
}
|
|
1330
|
-
}
|
|
1331
|
-
},
|
|
1332
|
-
{
|
|
1333
|
-
name: 'get_session',
|
|
1334
|
-
description: `View current session status. Shows saved articles, images, and publishing state.
|
|
1335
|
-
|
|
1336
|
-
TRIGGERS - Use when user says:
|
|
1337
|
-
- "what's in my session"
|
|
1338
|
-
- "show my articles"
|
|
1339
|
-
- "what have I created"
|
|
1340
|
-
- "session status"
|
|
1341
|
-
- "list my saved content"
|
|
1342
|
-
|
|
1343
|
-
OUTCOME: List of all articles in session with their publish status.`,
|
|
1344
|
-
inputSchema: {
|
|
1345
|
-
type: 'object',
|
|
1346
|
-
properties: {}
|
|
1347
|
-
}
|
|
1348
|
-
},
|
|
1349
|
-
{
|
|
1350
|
-
name: 'remove_article',
|
|
1351
|
-
description: `Remove article(s) from session. Does NOT delete published content.
|
|
1352
|
-
|
|
1353
|
-
TRIGGERS - Use when user says:
|
|
1354
|
-
- "remove article 2"
|
|
1355
|
-
- "delete the second article"
|
|
1356
|
-
- "remove that article"
|
|
1357
|
-
- "discard article..."
|
|
1358
|
-
|
|
1359
|
-
OUTCOME: Specified article(s) removed from session.`,
|
|
1360
|
-
inputSchema: {
|
|
1361
|
-
type: 'object',
|
|
1362
|
-
properties: {
|
|
1363
|
-
article_numbers: {
|
|
1364
|
-
type: 'array',
|
|
1365
|
-
items: { type: 'number' },
|
|
1366
|
-
description: 'Article numbers to remove (1, 2, 3...). Use get_session to see article numbers.'
|
|
1367
|
-
}
|
|
1368
|
-
},
|
|
1369
|
-
required: ['article_numbers']
|
|
1370
|
-
}
|
|
1371
|
-
},
|
|
1372
|
-
{
|
|
1373
|
-
name: 'clear_session',
|
|
1374
|
-
description: `Clear ALL content from session. DESTRUCTIVE - removes all unpublished articles!
|
|
1375
|
-
|
|
1376
|
-
TRIGGERS - Use when user says:
|
|
1377
|
-
- "clear my session"
|
|
1378
|
-
- "start fresh"
|
|
1379
|
-
- "remove all articles"
|
|
1380
|
-
- "reset everything"
|
|
1381
|
-
- "clear all content"
|
|
1382
|
-
|
|
1383
|
-
WARNING: Requires confirm: true. Does NOT affect already-published content.
|
|
1384
|
-
|
|
1385
|
-
OUTCOME: Empty session, ready for new content creation.`,
|
|
1386
|
-
inputSchema: {
|
|
1387
|
-
type: 'object',
|
|
1388
|
-
properties: {
|
|
1389
|
-
confirm: {
|
|
1390
|
-
type: 'boolean',
|
|
1391
|
-
description: 'Must be true to confirm clearing all content'
|
|
1392
|
-
}
|
|
1393
|
-
},
|
|
1394
|
-
required: ['confirm']
|
|
1395
|
-
}
|
|
1396
|
-
},
|
|
1397
|
-
{
|
|
1398
|
-
name: 'list_content',
|
|
1399
|
-
description: `List all saved content from disk. Shows past articles that can be loaded back.
|
|
1400
|
-
|
|
1401
|
-
TRIGGERS - Use when user says:
|
|
1402
|
-
- "show my past articles"
|
|
1403
|
-
- "list saved content"
|
|
1404
|
-
- "what articles do I have"
|
|
1405
|
-
- "show previous content"
|
|
1406
|
-
- "find my old articles"
|
|
1407
|
-
|
|
1408
|
-
NOTE: Different from get_session - this shows DISK storage, not current session.
|
|
1409
|
-
|
|
1410
|
-
OUTCOME: List of saved article folders with titles and dates.`,
|
|
1411
|
-
inputSchema: {
|
|
1412
|
-
type: 'object',
|
|
1413
|
-
properties: {
|
|
1414
|
-
limit: {
|
|
1415
|
-
type: 'number',
|
|
1416
|
-
description: 'Max number of articles to show (default: 20)',
|
|
1417
|
-
default: 20
|
|
1418
|
-
}
|
|
1419
|
-
}
|
|
1420
|
-
}
|
|
1421
|
-
},
|
|
1422
|
-
{
|
|
1423
|
-
name: 'load_content',
|
|
1424
|
-
description: `Load a saved article back into session for editing or re-publishing.
|
|
1425
|
-
|
|
1426
|
-
TRIGGERS - Use when user says:
|
|
1427
|
-
- "load my article about..."
|
|
1428
|
-
- "open the previous article"
|
|
1429
|
-
- "bring back that article"
|
|
1430
|
-
- "edit my old post about..."
|
|
1431
|
-
- "reload article..."
|
|
1432
|
-
|
|
1433
|
-
WORKFLOW: Run list_content first to see available articles, then load by folder name.
|
|
1434
|
-
|
|
1435
|
-
OUTCOME: Article loaded into session, ready for optimization or re-publishing.`,
|
|
1436
|
-
inputSchema: {
|
|
1437
|
-
type: 'object',
|
|
1438
|
-
properties: {
|
|
1439
|
-
folder_name: {
|
|
1440
|
-
type: 'string',
|
|
1441
|
-
description: 'Folder name from list_content (e.g., "2026-01-09-my-article-title")'
|
|
1442
|
-
}
|
|
1443
|
-
},
|
|
1444
|
-
required: ['folder_name']
|
|
1445
|
-
}
|
|
1446
|
-
}
|
|
1447
|
-
]
|
|
1448
|
-
|
|
1449
|
-
/**
|
|
1450
|
-
* Build workflow plan based on user request and available credentials
|
|
1451
|
-
*
|
|
1452
|
-
* ALL data comes from project.config (Supabase database) - NO HARDCODED DEFAULTS
|
|
1453
|
-
*/
|
|
1454
|
-
/**
|
|
1455
|
-
* Validate project configuration with helpful error messages
|
|
1456
|
-
*/
|
|
1457
|
-
function validateProjectConfig(config) {
|
|
1458
|
-
const errors = []
|
|
1459
|
-
|
|
1460
|
-
if (!config) {
|
|
1461
|
-
throw new Error('Project configuration not found. Please configure your project in the dashboard.')
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
// Check required fields
|
|
1465
|
-
if (!config.content?.default_word_count) {
|
|
1466
|
-
errors.push('Word count: Not set → Dashboard → Project Settings → Content')
|
|
1467
|
-
} else if (typeof config.content.default_word_count !== 'number' || config.content.default_word_count < 100) {
|
|
1468
|
-
errors.push('Word count: Must be at least 100 words')
|
|
1469
|
-
} else if (config.content.default_word_count > 10000) {
|
|
1470
|
-
errors.push('Word count: Maximum 10,000 words supported')
|
|
1471
|
-
}
|
|
1472
|
-
|
|
1473
|
-
if (!config.brand?.voice) {
|
|
1474
|
-
errors.push('Brand voice: Not set → Dashboard → Project Settings → Brand')
|
|
1475
|
-
}
|
|
1476
|
-
|
|
1477
|
-
if (!config.site?.niche) {
|
|
1478
|
-
errors.push('Niche: Not set → Dashboard → Project Settings → Site')
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
|
-
// Warnings (non-blocking but helpful)
|
|
1482
|
-
const warnings = []
|
|
1483
|
-
if (!config.seo?.primary_keywords?.length) {
|
|
1484
|
-
warnings.push('No primary keywords set - content may lack SEO focus')
|
|
1485
|
-
}
|
|
1486
|
-
if (!config.brand?.target_audience) {
|
|
1487
|
-
warnings.push('No target audience set - content may be too generic')
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
if (errors.length > 0) {
|
|
1491
|
-
throw new Error(`Project configuration incomplete:\n${errors.map(e => ` • ${e}`).join('\n')}`)
|
|
1492
|
-
}
|
|
1493
|
-
|
|
1494
|
-
return { warnings }
|
|
1495
|
-
}
|
|
1496
|
-
|
|
1497
|
-
function buildWorkflowPlan(request, count, publishTo, withImages, project) {
|
|
1498
|
-
const steps = []
|
|
1499
|
-
const hasGhost = hasCredential('ghost')
|
|
1500
|
-
const hasWordPress = hasCredential('wordpress')
|
|
1501
|
-
const hasImageGen = hasCredential('image')
|
|
1502
|
-
|
|
1503
|
-
// Get project config from database - MUST be dynamic, no hardcoding
|
|
1504
|
-
const config = project?.config
|
|
1505
|
-
|
|
1506
|
-
// Validate configuration with helpful messages
|
|
1507
|
-
const { warnings } = validateProjectConfig(config)
|
|
1508
|
-
if (warnings.length > 0) {
|
|
1509
|
-
log(`Config warnings: ${warnings.join('; ')}`)
|
|
1510
|
-
}
|
|
1511
|
-
|
|
1512
|
-
// Extract all settings from project.config (database schema)
|
|
1513
|
-
const targetWordCount = config.content?.default_word_count
|
|
1514
|
-
|
|
1515
|
-
// LOG ALL CONFIG VALUES FOR DEBUGGING
|
|
1516
|
-
log('=== PROJECT CONFIG VALUES ===')
|
|
1517
|
-
log(`Word Count Target: ${targetWordCount}`)
|
|
1518
|
-
log(`Reading Level: ${config.content?.reading_level}`)
|
|
1519
|
-
log(`Brand Voice: ${config.brand?.voice}`)
|
|
1520
|
-
log(`Target Audience: ${config.brand?.target_audience}`)
|
|
1521
|
-
log(`Primary Keywords: ${config.seo?.primary_keywords?.join(', ')}`)
|
|
1522
|
-
log(`Include Images: ${config.content?.include_images}`)
|
|
1523
|
-
log('=============================')
|
|
1524
|
-
|
|
1525
|
-
// CRITICAL: Validate word count is set
|
|
1526
|
-
if (!targetWordCount || targetWordCount < 100) {
|
|
1527
|
-
log(`WARNING: Word count not properly set! Got: ${targetWordCount}`)
|
|
1528
|
-
}
|
|
1529
|
-
const readingLevel = config.content?.reading_level
|
|
1530
|
-
const includeImages = config.content?.include_images
|
|
1531
|
-
const brandVoice = config.brand?.voice
|
|
1532
|
-
const targetAudience = config.brand?.target_audience
|
|
1533
|
-
const differentiators = config.brand?.differentiators || []
|
|
1534
|
-
const visualStyle = config.visual_style?.image_aesthetic
|
|
1535
|
-
const brandColors = config.visual_style?.colors || []
|
|
1536
|
-
const primaryKeywords = config.seo?.primary_keywords || []
|
|
1537
|
-
const geoFocus = config.seo?.geo_focus
|
|
1538
|
-
const niche = config.site?.niche
|
|
1539
|
-
const siteName = config.site?.name
|
|
1540
|
-
const siteUrl = config.site?.url
|
|
1541
|
-
const siteDescription = config.site?.description
|
|
1542
|
-
|
|
1543
|
-
// Calculate required images: 1 cover + 1 per 300 words (only if includeImages is true)
|
|
1544
|
-
const shouldGenerateImages = withImages && includeImages && hasImageGen
|
|
1545
|
-
const contentImageCount = shouldGenerateImages ? Math.floor(targetWordCount / 300) : 0
|
|
1546
|
-
const totalImages = shouldGenerateImages ? 1 + contentImageCount : 0 // cover + inline images
|
|
1547
|
-
|
|
1548
|
-
// Format reading level for display (stored as number, display as "Grade X")
|
|
1549
|
-
const readingLevelDisplay = readingLevel ? `Grade ${readingLevel}` : 'Not set'
|
|
1550
|
-
|
|
1551
|
-
// Format keywords for display
|
|
1552
|
-
const keywordsDisplay = primaryKeywords.length > 0 ? primaryKeywords.join(', ') : 'No keywords set'
|
|
1553
|
-
|
|
1554
|
-
// Determine publish targets
|
|
1555
|
-
let targets = publishTo || []
|
|
1556
|
-
if (targets.length === 0 || targets.includes('all')) {
|
|
1557
|
-
targets = []
|
|
1558
|
-
if (hasGhost) targets.push('ghost')
|
|
1559
|
-
if (hasWordPress) targets.push('wordpress')
|
|
1560
|
-
}
|
|
1561
|
-
|
|
1562
|
-
let stepNum = 0
|
|
1563
|
-
|
|
1564
|
-
// Step 1: Keyword Research
|
|
1565
|
-
// Build dynamic MCP hints from local credentials (user-configured in credentials.json)
|
|
1566
|
-
const externalMcps = getExternalMCPs()
|
|
1567
|
-
const keywordResearchHints = getCompositionHints('keyword_research')
|
|
1568
|
-
|
|
1569
|
-
let mcpInstructions = ''
|
|
1570
|
-
if (externalMcps.length > 0) {
|
|
1571
|
-
const mcpList = externalMcps.map(m => `- **${m.name}**: ${m.available_tools?.join(', ') || 'tools available'}`).join('\n')
|
|
1572
|
-
mcpInstructions = `\n💡 **External MCPs Available (from your credentials.json):**\n${mcpList}`
|
|
1573
|
-
if (keywordResearchHints) {
|
|
1574
|
-
mcpInstructions += `\n\n**Integration Hint:** ${keywordResearchHints}`
|
|
1575
|
-
}
|
|
1576
|
-
}
|
|
1577
|
-
|
|
1578
|
-
// ═══════════════════════════════════════════════════════════
|
|
1579
|
-
// RESEARCH PHASE
|
|
1580
|
-
// ═══════════════════════════════════════════════════════════
|
|
1581
|
-
|
|
1582
|
-
stepNum++
|
|
1583
|
-
steps.push({
|
|
1584
|
-
step: stepNum,
|
|
1585
|
-
type: 'llm_execute',
|
|
1586
|
-
action: 'keyword_research',
|
|
1587
|
-
instruction: `Research keywords for: "${request}"
|
|
1588
|
-
|
|
1589
|
-
**Project Context (from database):**
|
|
1590
|
-
- Site: ${siteName} (${siteUrl})
|
|
1591
|
-
- Niche: ${niche}
|
|
1592
|
-
- Description: ${siteDescription || 'Not set'}
|
|
1593
|
-
- Primary keywords: ${keywordsDisplay}
|
|
1594
|
-
- Geographic focus: ${geoFocus || 'Global'}
|
|
1595
|
-
${mcpInstructions}
|
|
1596
|
-
|
|
1597
|
-
**Deliverables:**
|
|
1598
|
-
- 1 primary keyword to target (lower difficulty preferred)
|
|
1599
|
-
- 3-5 secondary/LSI keywords
|
|
1600
|
-
- 2-3 question-based keywords for FAQ section`,
|
|
1601
|
-
store: 'keywords'
|
|
1602
|
-
})
|
|
1603
|
-
|
|
1604
|
-
// Step 2: SEO Strategy & Content Brief
|
|
1605
|
-
stepNum++
|
|
1606
|
-
steps.push({
|
|
1607
|
-
step: stepNum,
|
|
1608
|
-
type: 'llm_execute',
|
|
1609
|
-
action: 'seo_strategy',
|
|
1610
|
-
instruction: `Create SEO strategy and content brief for: "${request}"
|
|
1611
|
-
|
|
1612
|
-
**Using Keywords from Step 1:**
|
|
1613
|
-
- Use the primary keyword you identified
|
|
1614
|
-
- Incorporate secondary/LSI keywords naturally
|
|
1615
|
-
|
|
1616
|
-
**Project Context:**
|
|
1617
|
-
- Site: ${siteName}
|
|
1618
|
-
- Niche: ${niche}
|
|
1619
|
-
- Target audience: ${targetAudience || 'Not specified'}
|
|
1620
|
-
- Brand voice: ${brandVoice}
|
|
1621
|
-
- Geographic focus: ${geoFocus || 'Global'}
|
|
1622
|
-
|
|
1623
|
-
**Deliverables:**
|
|
1624
|
-
1. **Search Intent Analysis** - What is the user trying to accomplish?
|
|
1625
|
-
2. **Competitor Gap Analysis** - What are top 3 ranking pages missing?
|
|
1626
|
-
3. **Content Brief:**
|
|
1627
|
-
- Recommended content type (guide/listicle/how-to/comparison)
|
|
1628
|
-
- Unique angle to differentiate from competitors
|
|
1629
|
-
- Key points to cover that competitors miss
|
|
1630
|
-
4. **On-Page SEO Checklist:**
|
|
1631
|
-
- Title tag format
|
|
1632
|
-
- Meta description template
|
|
1633
|
-
- Header structure (H1, H2, H3)
|
|
1634
|
-
- Internal linking opportunities`,
|
|
1635
|
-
store: 'seo_strategy'
|
|
1636
|
-
})
|
|
1637
|
-
|
|
1638
|
-
// Step 3: Topical Map (Content Architecture)
|
|
1639
|
-
stepNum++
|
|
1640
|
-
steps.push({
|
|
1641
|
-
step: stepNum,
|
|
1642
|
-
type: 'llm_execute',
|
|
1643
|
-
action: 'topical_map',
|
|
1644
|
-
instruction: `Design content architecture for: "${request}"
|
|
1645
|
-
|
|
1646
|
-
**Build a Pillar-Cluster Structure:**
|
|
1647
|
-
- Main pillar topic (this article)
|
|
1648
|
-
- Supporting cluster articles (future content opportunities)
|
|
1649
|
-
|
|
1650
|
-
**Project Context:**
|
|
1651
|
-
- Site: ${siteName}
|
|
1652
|
-
- Niche: ${niche}
|
|
1653
|
-
- Primary keywords: ${keywordsDisplay}
|
|
1654
|
-
|
|
1655
|
-
**Deliverables:**
|
|
1656
|
-
1. **Pillar Page Concept** - What should this main article establish?
|
|
1657
|
-
2. **Cluster Topics** - 5-7 related subtopics for future articles
|
|
1658
|
-
3. **Internal Linking Plan** - How these articles connect
|
|
1659
|
-
4. **Content Gaps** - What topics are missing in this niche?
|
|
1660
|
-
|
|
1661
|
-
Note: Focus on the CURRENT article structure, but identify opportunities for a content cluster.`,
|
|
1662
|
-
store: 'topical_map'
|
|
1663
|
-
})
|
|
1664
|
-
|
|
1665
|
-
// Step 4: Content Calendar (only for multi-article requests)
|
|
1666
|
-
if (count > 1) {
|
|
1667
|
-
stepNum++
|
|
1668
|
-
steps.push({
|
|
1669
|
-
step: stepNum,
|
|
1670
|
-
type: 'llm_execute',
|
|
1671
|
-
action: 'content_calendar',
|
|
1672
|
-
instruction: `Plan content calendar for ${count} articles about: "${request}"
|
|
1673
|
-
|
|
1674
|
-
**Project Context:**
|
|
1675
|
-
- Site: ${siteName}
|
|
1676
|
-
- Niche: ${niche}
|
|
1677
|
-
- Articles to create: ${count}
|
|
1678
|
-
|
|
1679
|
-
**Deliverables:**
|
|
1680
|
-
1. **Article Sequence** - Order to create articles (foundational → specific)
|
|
1681
|
-
2. **Topic List** - ${count} specific titles/topics
|
|
1682
|
-
3. **Keyword Assignment** - Primary keyword for each article
|
|
1683
|
-
4. **Publishing Cadence** - Recommended frequency
|
|
1684
|
-
|
|
1685
|
-
Note: This guides the creation of all ${count} articles in this session.`,
|
|
1686
|
-
store: 'content_calendar'
|
|
1687
|
-
})
|
|
1688
|
-
}
|
|
1689
|
-
|
|
1690
|
-
// ═══════════════════════════════════════════════════════════
|
|
1691
|
-
// CREATION PHASE
|
|
1692
|
-
// ═══════════════════════════════════════════════════════════
|
|
1693
|
-
|
|
1694
|
-
// Step N: Content Planning with SEO Meta
|
|
1695
|
-
stepNum++
|
|
1696
|
-
steps.push({
|
|
1697
|
-
step: stepNum,
|
|
1698
|
-
type: 'llm_execute',
|
|
1699
|
-
action: 'content_planning',
|
|
1700
|
-
instruction: `Create a detailed content outline with SEO meta:
|
|
1701
|
-
|
|
1702
|
-
**Project Requirements (from database):**
|
|
1703
|
-
- Site: ${siteName}
|
|
1704
|
-
- Target audience: ${targetAudience || 'Not specified'}
|
|
1705
|
-
- Brand voice: ${brandVoice}
|
|
1706
|
-
- Brand differentiators: ${differentiators.length > 0 ? differentiators.join(', ') : 'Not set'}
|
|
1707
|
-
- Word count: **${targetWordCount} words MINIMUM** (this is required!)
|
|
1708
|
-
- Reading level: **${readingLevelDisplay}** (use simple sentences, avoid jargon)
|
|
1709
|
-
|
|
1710
|
-
**You MUST create:**
|
|
1711
|
-
|
|
1712
|
-
1. **SEO Meta Title** (50-60 characters, include primary keyword)
|
|
1713
|
-
2. **SEO Meta Description** (150-160 characters, compelling, include keyword)
|
|
1714
|
-
3. **URL Slug** (lowercase, hyphens, keyword-rich)
|
|
1715
|
-
4. **Content Outline:**
|
|
1716
|
-
- H1: Main title
|
|
1717
|
-
- 6-8 H2 sections (to achieve ${targetWordCount} words)
|
|
1718
|
-
- H3 subsections where needed
|
|
1719
|
-
- FAQ section with 4-5 questions
|
|
1720
|
-
|
|
1721
|
-
${shouldGenerateImages ? `**Image Placeholders:** Mark where ${contentImageCount} inline images should go (1 every ~300 words)
|
|
1722
|
-
Use format: [IMAGE: description of what image should show]` : '**Note:** Images disabled for this project.'}`,
|
|
1723
|
-
store: 'outline'
|
|
1724
|
-
})
|
|
1725
|
-
|
|
1726
|
-
// Step 3: Write Content
|
|
1727
|
-
stepNum++
|
|
1728
|
-
steps.push({
|
|
1729
|
-
step: stepNum,
|
|
1730
|
-
type: 'llm_execute',
|
|
1731
|
-
action: 'content_write',
|
|
1732
|
-
instruction: `Write the COMPLETE article following your outline.
|
|
1733
|
-
|
|
1734
|
-
╔══════════════════════════════════════════════════════════════════╗
|
|
1735
|
-
║ 🚨 MANDATORY WORD COUNT: ${targetWordCount} WORDS MINIMUM 🚨 ║
|
|
1736
|
-
║ This is a strict requirement from the project settings. ║
|
|
1737
|
-
║ The article will be REJECTED if under ${targetWordCount} words. ║
|
|
1738
|
-
╚══════════════════════════════════════════════════════════════════╝
|
|
1739
|
-
|
|
1740
|
-
**Project Requirements (from Supabase database - DO NOT IGNORE):**
|
|
1741
|
-
- Word count: **${targetWordCount} words** (MINIMUM - not a suggestion!)
|
|
1742
|
-
- Reading level: **${readingLevelDisplay}** - Simple sentences, short paragraphs
|
|
1743
|
-
- Brand voice: ${brandVoice}
|
|
1744
|
-
- Target audience: ${targetAudience || 'General readers'}
|
|
1745
|
-
|
|
1746
|
-
**To reach ${targetWordCount} words, you MUST:**
|
|
1747
|
-
- Write 8-10 substantial H2 sections (each 200-400 words)
|
|
1748
|
-
- Include detailed examples, statistics, and actionable advice
|
|
1749
|
-
- Add comprehensive FAQ section (5-8 questions)
|
|
1750
|
-
- Expand each point with thorough explanations
|
|
1751
|
-
|
|
1752
|
-
**Content Structure:**
|
|
1753
|
-
- Engaging hook in first 2 sentences
|
|
1754
|
-
- All H2/H3 sections from your outline (expand each thoroughly!)
|
|
1755
|
-
- Statistics, examples, and actionable tips in EVERY section
|
|
1756
|
-
${shouldGenerateImages ? '- Image placeholders: [IMAGE: description] where images should go' : ''}
|
|
1757
|
-
- FAQ section with 5-8 Q&As (detailed answers, not one-liners)
|
|
1758
|
-
- Strong conclusion with clear CTA
|
|
1759
|
-
|
|
1760
|
-
**After writing ${targetWordCount}+ words, call 'save_content' with:**
|
|
1761
|
-
- title: Your SEO-optimized title
|
|
1762
|
-
- content: The full article (markdown)
|
|
1763
|
-
- keywords: Array of target keywords
|
|
1764
|
-
- meta_description: Your 150-160 char meta description
|
|
1765
|
-
|
|
1766
|
-
⛔ STOP! Before calling save_content, verify you have ${targetWordCount}+ words.
|
|
1767
|
-
Count the words. If under ${targetWordCount}, ADD MORE CONTENT.`,
|
|
1768
|
-
store: 'article'
|
|
1769
|
-
})
|
|
1770
|
-
|
|
1771
|
-
// ═══════════════════════════════════════════════════════════
|
|
1772
|
-
// OPTIMIZATION PHASE
|
|
1773
|
-
// ═══════════════════════════════════════════════════════════
|
|
1774
|
-
|
|
1775
|
-
// Quality Check - Pre-publish QA
|
|
1776
|
-
stepNum++
|
|
1777
|
-
steps.push({
|
|
1778
|
-
step: stepNum,
|
|
1779
|
-
type: 'llm_execute',
|
|
1780
|
-
action: 'quality_check',
|
|
1781
|
-
instruction: `Perform quality check on the article you just saved.
|
|
1782
|
-
|
|
1783
|
-
**Quality Checklist:**
|
|
1784
|
-
|
|
1785
|
-
1. **SEO Check:**
|
|
1786
|
-
- ✓ Primary keyword in H1, first 100 words, URL slug
|
|
1787
|
-
- ✓ Secondary keywords distributed naturally
|
|
1788
|
-
- ✓ Meta title 50-60 characters
|
|
1789
|
-
- ✓ Meta description 150-160 characters
|
|
1790
|
-
- ✓ Proper header hierarchy (H1 → H2 → H3)
|
|
1791
|
-
|
|
1792
|
-
2. **Content Quality:**
|
|
1793
|
-
- ✓ Word count meets requirement (${targetWordCount}+ words)
|
|
1794
|
-
- ✓ Reading level appropriate (${readingLevelDisplay})
|
|
1795
|
-
- ✓ No grammar or spelling errors
|
|
1796
|
-
- ✓ Factual accuracy (no made-up statistics)
|
|
1797
|
-
|
|
1798
|
-
3. **Brand Consistency:**
|
|
1799
|
-
- ✓ Voice matches: ${brandVoice}
|
|
1800
|
-
- ✓ Speaks to: ${targetAudience || 'target audience'}
|
|
1801
|
-
- ✓ Aligns with ${siteName} brand
|
|
1802
|
-
|
|
1803
|
-
4. **Engagement:**
|
|
1804
|
-
- ✓ Strong hook in introduction
|
|
1805
|
-
- ✓ Clear value proposition
|
|
1806
|
-
- ✓ Actionable takeaways
|
|
1807
|
-
- ✓ Compelling CTA in conclusion
|
|
1808
|
-
|
|
1809
|
-
**Report any issues found and suggest fixes. If major issues exist, fix them before proceeding.**`,
|
|
1810
|
-
store: 'quality_report'
|
|
1811
|
-
})
|
|
1812
|
-
|
|
1813
|
-
// GEO Optimize - AI Search Engine Optimization
|
|
1814
|
-
stepNum++
|
|
1815
|
-
steps.push({
|
|
1816
|
-
step: stepNum,
|
|
1817
|
-
type: 'llm_execute',
|
|
1818
|
-
action: 'geo_optimize',
|
|
1819
|
-
instruction: `Optimize article for AI search engines (ChatGPT, Perplexity, Google SGE, Claude).
|
|
1820
|
-
|
|
1821
|
-
**GEO (Generative Engine Optimization) Checklist:**
|
|
1822
|
-
|
|
1823
|
-
1. **Structured Answers:**
|
|
1824
|
-
- ✓ Clear, direct answers to common questions
|
|
1825
|
-
- ✓ Definition boxes for key terms
|
|
1826
|
-
- ✓ TL;DR sections for complex topics
|
|
1827
|
-
|
|
1828
|
-
2. **Citation-Worthy Content:**
|
|
1829
|
-
- ✓ Original statistics or data points
|
|
1830
|
-
- ✓ Expert quotes or authoritative sources
|
|
1831
|
-
- ✓ Unique insights not found elsewhere
|
|
1832
|
-
|
|
1833
|
-
3. **LLM-Friendly Structure:**
|
|
1834
|
-
- ✓ Bulleted lists for easy extraction
|
|
1835
|
-
- ✓ Tables for comparisons
|
|
1836
|
-
- ✓ Step-by-step numbered processes
|
|
1837
|
-
|
|
1838
|
-
4. **Semantic Clarity:**
|
|
1839
|
-
- ✓ Clear topic sentences per paragraph
|
|
1840
|
-
- ✓ Explicit cause-effect relationships
|
|
1841
|
-
- ✓ Avoid ambiguous pronouns
|
|
1842
|
-
|
|
1843
|
-
**Target AI Engines:**
|
|
1844
|
-
- ChatGPT (conversational answers)
|
|
1845
|
-
- Perplexity (citation-heavy)
|
|
1846
|
-
- Google SGE (structured snippets)
|
|
1847
|
-
- Claude (comprehensive analysis)
|
|
1848
|
-
|
|
1849
|
-
**Review the saved article and suggest specific improvements to make it more likely to be cited by AI search engines.**`,
|
|
1850
|
-
store: 'geo_report'
|
|
1851
|
-
})
|
|
1852
|
-
|
|
1853
|
-
// ═══════════════════════════════════════════════════════════
|
|
1854
|
-
// PUBLISHING PHASE
|
|
1855
|
-
// ═══════════════════════════════════════════════════════════
|
|
1856
|
-
|
|
1857
|
-
// Generate Images (if enabled in project settings AND credentials available)
|
|
1858
|
-
if (shouldGenerateImages) {
|
|
1859
|
-
// Format brand colors for image style guidance
|
|
1860
|
-
const colorsDisplay = brandColors.length > 0 ? brandColors.join(', ') : 'Not specified'
|
|
1861
|
-
|
|
1862
|
-
stepNum++
|
|
1863
|
-
steps.push({
|
|
1864
|
-
step: stepNum,
|
|
1865
|
-
type: 'llm_execute',
|
|
1866
|
-
action: 'generate_images',
|
|
1867
|
-
instruction: `Generate ${totalImages} images for the article:
|
|
1868
|
-
|
|
1869
|
-
**Required Images:**
|
|
1870
|
-
1. **Cover/Hero Image** - Main article header (16:9 aspect ratio)
|
|
1871
|
-
${Array.from({length: contentImageCount}, (_, i) => `${i + 2}. **Section Image ${i + 1}** - For content section ${i + 1} (16:9 aspect ratio)`).join('\n')}
|
|
1872
|
-
|
|
1873
|
-
**For each image, call 'generate_image' tool with:**
|
|
1874
|
-
- prompt: Detailed description based on article content
|
|
1875
|
-
- style: ${visualStyle || 'professional minimalist'}
|
|
1876
|
-
- aspect_ratio: 16:9
|
|
1877
|
-
|
|
1878
|
-
**Visual Style (from project database):**
|
|
1879
|
-
- Image aesthetic: ${visualStyle || 'Not specified'}
|
|
1880
|
-
- Brand colors: ${colorsDisplay}
|
|
1881
|
-
- Keep consistent with ${siteName} brand identity
|
|
1882
|
-
|
|
1883
|
-
**Image Style Guide:**
|
|
1884
|
-
- Professional, clean aesthetic
|
|
1885
|
-
- Relevant to the section topic
|
|
1886
|
-
- No text in images
|
|
1887
|
-
- Consistent style across all images
|
|
1888
|
-
|
|
1889
|
-
After generating, note the URLs - they will be saved automatically for publishing.`,
|
|
1890
|
-
image_count: totalImages,
|
|
1891
|
-
store: 'images'
|
|
1892
|
-
})
|
|
1893
|
-
}
|
|
1894
|
-
|
|
1895
|
-
// Step 5: Publish
|
|
1896
|
-
if (targets.length > 0) {
|
|
1897
|
-
stepNum++
|
|
1898
|
-
steps.push({
|
|
1899
|
-
step: stepNum,
|
|
1900
|
-
type: 'action',
|
|
1901
|
-
action: 'publish',
|
|
1902
|
-
instruction: `Publish the article to: ${targets.join(', ')}
|
|
1903
|
-
|
|
1904
|
-
Call 'publish_content' tool - it will automatically use:
|
|
1905
|
-
- Saved article title and content
|
|
1906
|
-
- SEO meta description
|
|
1907
|
-
- Generated images (cover + inline)
|
|
1908
|
-
- Target keywords as tags`,
|
|
1909
|
-
targets: targets
|
|
1910
|
-
})
|
|
1911
|
-
}
|
|
1912
|
-
|
|
1913
|
-
return {
|
|
1914
|
-
workflow_id: `wf_${Date.now()}`,
|
|
1915
|
-
request: request,
|
|
1916
|
-
total_articles: count,
|
|
1917
|
-
current_article: 1,
|
|
1918
|
-
total_steps: steps.length,
|
|
1919
|
-
current_step: 1,
|
|
1920
|
-
// All settings come from project.config (database) - no hardcoded values
|
|
1921
|
-
project_info: {
|
|
1922
|
-
name: siteName,
|
|
1923
|
-
url: siteUrl,
|
|
1924
|
-
niche: niche
|
|
1925
|
-
},
|
|
1926
|
-
settings: {
|
|
1927
|
-
target_word_count: targetWordCount,
|
|
1928
|
-
reading_level: readingLevel,
|
|
1929
|
-
reading_level_display: readingLevelDisplay,
|
|
1930
|
-
brand_voice: brandVoice,
|
|
1931
|
-
target_audience: targetAudience,
|
|
1932
|
-
include_images: includeImages,
|
|
1933
|
-
total_images: totalImages,
|
|
1934
|
-
content_images: contentImageCount,
|
|
1935
|
-
visual_style: visualStyle,
|
|
1936
|
-
primary_keywords: primaryKeywords,
|
|
1937
|
-
geo_focus: geoFocus
|
|
1938
|
-
},
|
|
1939
|
-
available_integrations: {
|
|
1940
|
-
external_mcps: externalMcps.map(m => m.name),
|
|
1941
|
-
ghost: hasGhost,
|
|
1942
|
-
wordpress: hasWordPress,
|
|
1943
|
-
image_generation: hasImageGen
|
|
1944
|
-
},
|
|
1945
|
-
steps: steps
|
|
1946
|
-
}
|
|
1947
|
-
}
|
|
1948
|
-
|
|
1949
|
-
/**
|
|
1950
|
-
* Execute orchestrator tools
|
|
1951
|
-
*/
|
|
1952
|
-
async function executeOrchestratorTool(toolName, args, project) {
|
|
1953
|
-
switch (toolName) {
|
|
1954
|
-
case 'create_content': {
|
|
1955
|
-
resetSession()
|
|
1956
|
-
const { request = '', count = 1, publish_to = [], with_images = true } = args
|
|
1957
|
-
|
|
1958
|
-
const plan = buildWorkflowPlan(
|
|
1959
|
-
request || `content about ${project?.niche || 'the project topic'}`,
|
|
1960
|
-
count,
|
|
1961
|
-
publish_to,
|
|
1962
|
-
with_images,
|
|
1963
|
-
project
|
|
1964
|
-
)
|
|
1965
|
-
|
|
1966
|
-
sessionState.currentWorkflow = plan
|
|
1967
|
-
|
|
1968
|
-
// Persist session to file for workflow continuity
|
|
1969
|
-
saveSession()
|
|
1970
|
-
|
|
1971
|
-
// Build response with clear instructions - all data from database
|
|
1972
|
-
const mcpList = plan.available_integrations.external_mcps.length > 0
|
|
1973
|
-
? plan.available_integrations.external_mcps.join(', ')
|
|
1974
|
-
: 'None configured'
|
|
1975
|
-
|
|
1976
|
-
let response = `# 🚀 Content Creation Workflow Started
|
|
1977
|
-
|
|
1978
|
-
╔══════════════════════════════════════════════════════════════════════════════╗
|
|
1979
|
-
║ 📊 PROJECT REQUIREMENTS (from Supabase database) ║
|
|
1980
|
-
║ Word Count: ${String(plan.settings.target_word_count).padEnd(6)} words (MINIMUM - strictly enforced!) ║
|
|
1981
|
-
║ Brand Voice: ${String(plan.settings.brand_voice || 'Not set').substring(0, 50).padEnd(50)} ║
|
|
1982
|
-
║ Target Audience: ${String(plan.settings.target_audience || 'Not set').substring(0, 45).padEnd(45)} ║
|
|
1983
|
-
╚══════════════════════════════════════════════════════════════════════════════╝
|
|
1984
|
-
|
|
1985
|
-
## Your Request
|
|
1986
|
-
"${plan.request}"
|
|
1987
|
-
|
|
1988
|
-
## Project: ${plan.project_info.name}
|
|
1989
|
-
- **URL:** ${plan.project_info.url}
|
|
1990
|
-
- **Niche:** ${plan.project_info.niche}
|
|
1991
|
-
|
|
1992
|
-
## Content Settings (from database - DO NOT USE DEFAULTS)
|
|
1993
|
-
| Setting | Value |
|
|
1994
|
-
|---------|-------|
|
|
1995
|
-
| **Word Count** | ${plan.settings.target_word_count} words |
|
|
1996
|
-
| **Reading Level** | ${plan.settings.reading_level_display} |
|
|
1997
|
-
| **Brand Voice** | ${plan.settings.brand_voice} |
|
|
1998
|
-
| **Target Audience** | ${plan.settings.target_audience || 'Not specified'} |
|
|
1999
|
-
| **Primary Keywords** | ${plan.settings.primary_keywords?.join(', ') || 'Not set'} |
|
|
2000
|
-
| **Geographic Focus** | ${plan.settings.geo_focus || 'Global'} |
|
|
2001
|
-
| **Visual Style** | ${plan.settings.visual_style || 'Not specified'} |
|
|
2002
|
-
| **Include Images** | ${plan.settings.include_images ? 'Yes' : 'No'} |
|
|
2003
|
-
| **Images Required** | ${plan.settings.total_images} (1 cover + ${plan.settings.content_images} inline) |
|
|
2004
|
-
|
|
2005
|
-
## Workflow Plan (4 Phases)
|
|
2006
|
-
|
|
2007
|
-
### RESEARCH PHASE
|
|
2008
|
-
${plan.steps.filter(s => ['keyword_research', 'seo_strategy', 'topical_map', 'content_calendar'].includes(s.action)).map(s => `${s.step}. **${s.action}**`).join('\n')}
|
|
2009
|
-
|
|
2010
|
-
### CREATION PHASE
|
|
2011
|
-
${plan.steps.filter(s => ['content_planning', 'content_write'].includes(s.action)).map(s => `${s.step}. **${s.action}**`).join('\n')}
|
|
2012
|
-
|
|
2013
|
-
### OPTIMIZATION PHASE
|
|
2014
|
-
${plan.steps.filter(s => ['quality_check', 'geo_optimize'].includes(s.action)).map(s => `${s.step}. **${s.action}**`).join('\n')}
|
|
2015
|
-
|
|
2016
|
-
### PUBLISHING PHASE
|
|
2017
|
-
${plan.steps.filter(s => ['generate_images', 'publish'].includes(s.action)).map(s => `${s.step}. **${s.action}**`).join('\n')}
|
|
2018
|
-
|
|
2019
|
-
## Available Integrations (from ~/.suparank/credentials.json)
|
|
2020
|
-
- External MCPs: ${mcpList}
|
|
2021
|
-
- Image Generation: ${plan.available_integrations.image_generation ? '✅ Ready' : '❌ Not configured'}
|
|
2022
|
-
- Ghost CMS: ${plan.available_integrations.ghost ? '✅ Ready' : '❌ Not configured'}
|
|
2023
|
-
- WordPress: ${plan.available_integrations.wordpress ? '✅ Ready' : '❌ Not configured'}
|
|
2024
|
-
|
|
2025
|
-
---
|
|
2026
|
-
|
|
2027
|
-
## Step 1 of ${plan.total_steps}: ${plan.steps[0].action.toUpperCase()}
|
|
2028
|
-
|
|
2029
|
-
${plan.steps[0].instruction}
|
|
2030
|
-
|
|
2031
|
-
---
|
|
2032
|
-
|
|
2033
|
-
**When you complete this step, move to Step 2.**
|
|
2034
|
-
`
|
|
2035
|
-
|
|
2036
|
-
return {
|
|
2037
|
-
content: [{
|
|
2038
|
-
type: 'text',
|
|
2039
|
-
text: response
|
|
2040
|
-
}]
|
|
2041
|
-
}
|
|
2042
|
-
}
|
|
2043
|
-
|
|
2044
|
-
case 'save_content': {
|
|
2045
|
-
const { title, content, keywords = [], meta_description = '' } = args
|
|
2046
|
-
const wordCount = content.split(/\s+/).length
|
|
2047
|
-
|
|
2048
|
-
// Create article object with unique ID
|
|
2049
|
-
const articleId = generateArticleId()
|
|
2050
|
-
const newArticle = {
|
|
2051
|
-
id: articleId,
|
|
2052
|
-
title,
|
|
2053
|
-
content,
|
|
2054
|
-
keywords,
|
|
2055
|
-
metaDescription: meta_description,
|
|
2056
|
-
metaTitle: title,
|
|
2057
|
-
imageUrl: sessionState.imageUrl || null, // Attach any generated cover image
|
|
2058
|
-
inlineImages: [...sessionState.inlineImages], // Copy current inline images
|
|
2059
|
-
savedAt: new Date().toISOString(),
|
|
2060
|
-
published: false,
|
|
2061
|
-
publishedTo: [],
|
|
2062
|
-
wordCount
|
|
2063
|
-
}
|
|
2064
|
-
|
|
2065
|
-
// Add to articles array (not overwriting previous articles!)
|
|
2066
|
-
sessionState.articles.push(newArticle)
|
|
2067
|
-
|
|
2068
|
-
// Track stats
|
|
2069
|
-
incrementStat('articles_created')
|
|
2070
|
-
incrementStat('words_written', wordCount)
|
|
2071
|
-
|
|
2072
|
-
// Also keep in current working fields for backwards compatibility
|
|
2073
|
-
sessionState.title = title
|
|
2074
|
-
sessionState.article = content
|
|
2075
|
-
sessionState.keywords = keywords
|
|
2076
|
-
sessionState.metaDescription = meta_description
|
|
2077
|
-
sessionState.metadata = { meta_description }
|
|
2078
|
-
|
|
2079
|
-
// Persist session to file and save to content folder
|
|
2080
|
-
saveSession()
|
|
2081
|
-
const contentFolder = saveContentToFolder()
|
|
2082
|
-
|
|
2083
|
-
progress('Content', `Saved "${title}" (${wordCount} words) as article #${sessionState.articles.length}${contentFolder ? ` → ${contentFolder}` : ''}`)
|
|
2084
|
-
|
|
2085
|
-
// Clear current working images so next article starts fresh
|
|
2086
|
-
// (images are already attached to the saved article)
|
|
2087
|
-
sessionState.imageUrl = null
|
|
2088
|
-
sessionState.inlineImages = []
|
|
2089
|
-
|
|
2090
|
-
const workflow = sessionState.currentWorkflow
|
|
2091
|
-
const targetWordCount = workflow?.settings?.target_word_count
|
|
2092
|
-
// Only 5% tolerance - 2500 word target means minimum 2375 words
|
|
2093
|
-
const wordCountOk = targetWordCount ? wordCount >= targetWordCount * 0.95 : true
|
|
2094
|
-
const shortfall = targetWordCount ? targetWordCount - wordCount : 0
|
|
2095
|
-
|
|
2096
|
-
// Log word count check
|
|
2097
|
-
log(`Word count check: ${wordCount} words (target: ${targetWordCount}, ok: ${wordCountOk})`)
|
|
2098
|
-
|
|
2099
|
-
// Find next step
|
|
2100
|
-
const imageStep = workflow?.steps?.find(s => s.action === 'generate_images')
|
|
2101
|
-
const totalImages = workflow?.settings?.total_images || 0
|
|
2102
|
-
const includeImages = workflow?.settings?.include_images
|
|
2103
|
-
|
|
2104
|
-
// Fetch WordPress categories for intelligent assignment
|
|
2105
|
-
let categoriesSection = ''
|
|
2106
|
-
if (hasCredential('wordpress')) {
|
|
2107
|
-
const wpCategories = await fetchWordPressCategories()
|
|
2108
|
-
if (wpCategories && wpCategories.length > 0) {
|
|
2109
|
-
const categoryList = wpCategories
|
|
2110
|
-
.slice(0, 15) // Show top 15 by post count
|
|
2111
|
-
.map(c => `- **${c.name}** (${c.count} posts)${c.description ? `: ${c.description}` : ''}`)
|
|
2112
|
-
.join('\n')
|
|
2113
|
-
categoriesSection = `\n## WordPress Categories Available
|
|
2114
|
-
Pick the most relevant category when publishing:
|
|
2115
|
-
${categoryList}
|
|
2116
|
-
|
|
2117
|
-
When calling \`publish_content\`, include the \`category\` parameter with your choice.\n`
|
|
2118
|
-
}
|
|
2119
|
-
}
|
|
2120
|
-
|
|
2121
|
-
// Show all articles in session
|
|
2122
|
-
const articlesListSection = sessionState.articles.length > 1 ? `
|
|
2123
|
-
## Articles in Session (${sessionState.articles.length} total)
|
|
2124
|
-
${sessionState.articles.map((art, i) => {
|
|
2125
|
-
const status = art.published ? `✅ published to ${art.publishedTo.join(', ')}` : '📝 unpublished'
|
|
2126
|
-
return `${i + 1}. **${art.title}** (${art.wordCount} words) - ${status}`
|
|
2127
|
-
}).join('\n')}
|
|
2128
|
-
|
|
2129
|
-
Use \`publish_content\` to publish all unpublished articles, or \`get_session\` to see full details.
|
|
2130
|
-
` : ''
|
|
2131
|
-
|
|
2132
|
-
return {
|
|
2133
|
-
content: [{
|
|
2134
|
-
type: 'text',
|
|
2135
|
-
text: `# ✅ Content Saved to Session (Article #${sessionState.articles.length})
|
|
2136
|
-
|
|
2137
|
-
**Title:** ${title}
|
|
2138
|
-
**Article ID:** ${articleId}
|
|
2139
|
-
**Word Count:** ${wordCount} words ${targetWordCount ? (wordCountOk ? '✅' : `⚠️ (target: ${targetWordCount})`) : '(no target set)'}
|
|
2140
|
-
**Meta Description:** ${meta_description ? `${meta_description.length} chars ✅` : '❌ Missing!'}
|
|
2141
|
-
**Keywords:** ${keywords.join(', ') || 'none specified'}
|
|
2142
|
-
**Images:** ${newArticle.imageUrl ? '1 cover' : 'no cover'}${newArticle.inlineImages.length > 0 ? ` + ${newArticle.inlineImages.length} inline` : ''}
|
|
2143
|
-
|
|
2144
|
-
${targetWordCount && !wordCountOk ? `
|
|
2145
|
-
╔══════════════════════════════════════════════════════════════════════════╗
|
|
2146
|
-
║ ⛔ WORD COUNT NOT MET - ${shortfall} WORDS SHORT! ║
|
|
2147
|
-
║ Target: ${targetWordCount} words | Actual: ${wordCount} words ║
|
|
2148
|
-
║ ║
|
|
2149
|
-
║ The article does not meet the project's word count requirement. ║
|
|
2150
|
-
║ Please EXPAND the content before publishing: ║
|
|
2151
|
-
║ - Add more detailed explanations to each section ║
|
|
2152
|
-
║ - Include additional examples and statistics ║
|
|
2153
|
-
║ - Expand the FAQ section with more questions ║
|
|
2154
|
-
║ - Add more H2 sections if needed ║
|
|
2155
|
-
╚══════════════════════════════════════════════════════════════════════════╝
|
|
2156
|
-
` : ''}
|
|
2157
|
-
${!meta_description ? '⚠️ **Warning:** Meta description is missing. Add it for better SEO.\n' : ''}
|
|
2158
|
-
${articlesListSection}${categoriesSection}
|
|
2159
|
-
## Next Step${includeImages && imageStep ? ': Generate Images' : ': Ready to Publish or Continue'}
|
|
2160
|
-
${includeImages && imageStep ? `Generate **${totalImages} images** (1 cover + ${totalImages - 1} inline images).
|
|
2161
|
-
|
|
2162
|
-
Call \`generate_image\` ${totalImages} times with prompts based on your article sections.` : `You can:
|
|
2163
|
-
- **Add more articles**: Continue creating content (each save_content adds to the batch)
|
|
2164
|
-
- **Publish all**: Call \`publish_content\` to publish all ${sessionState.articles.length} article(s)
|
|
2165
|
-
- **View session**: Call \`get_session\` to see all saved articles`}`
|
|
2166
|
-
}]
|
|
2167
|
-
}
|
|
2168
|
-
}
|
|
2169
|
-
|
|
2170
|
-
case 'publish_content': {
|
|
2171
|
-
const { platforms = ['all'], status = 'draft', category = '', article_numbers = [] } = args
|
|
2172
|
-
|
|
2173
|
-
// Determine which articles to publish
|
|
2174
|
-
let articlesToPublish = []
|
|
2175
|
-
|
|
2176
|
-
if (article_numbers && article_numbers.length > 0) {
|
|
2177
|
-
// Publish specific articles by number (1-indexed)
|
|
2178
|
-
articlesToPublish = article_numbers
|
|
2179
|
-
.map(num => sessionState.articles[num - 1])
|
|
2180
|
-
.filter(art => art && !art.published)
|
|
2181
|
-
|
|
2182
|
-
if (articlesToPublish.length === 0) {
|
|
2183
|
-
return {
|
|
2184
|
-
content: [{
|
|
2185
|
-
type: 'text',
|
|
2186
|
-
text: `❌ No valid unpublished articles found for numbers: ${article_numbers.join(', ')}
|
|
2187
|
-
|
|
2188
|
-
Use \`get_session\` to see available articles and their numbers.`
|
|
2189
|
-
}]
|
|
2190
|
-
}
|
|
2191
|
-
}
|
|
2192
|
-
} else {
|
|
2193
|
-
// Publish all unpublished articles
|
|
2194
|
-
articlesToPublish = sessionState.articles.filter(art => !art.published)
|
|
2195
|
-
}
|
|
2196
|
-
|
|
2197
|
-
// Fallback: Check if there's a current working article not yet saved
|
|
2198
|
-
if (articlesToPublish.length === 0 && sessionState.article && sessionState.title) {
|
|
2199
|
-
// Create temporary article from current working state for backwards compatibility
|
|
2200
|
-
articlesToPublish = [{
|
|
2201
|
-
id: 'current',
|
|
2202
|
-
title: sessionState.title,
|
|
2203
|
-
content: sessionState.article,
|
|
2204
|
-
keywords: sessionState.keywords || [],
|
|
2205
|
-
metaDescription: sessionState.metaDescription || '',
|
|
2206
|
-
imageUrl: sessionState.imageUrl,
|
|
2207
|
-
inlineImages: sessionState.inlineImages
|
|
2208
|
-
}]
|
|
2209
|
-
}
|
|
2210
|
-
|
|
2211
|
-
if (articlesToPublish.length === 0) {
|
|
2212
|
-
return {
|
|
2213
|
-
content: [{
|
|
2214
|
-
type: 'text',
|
|
2215
|
-
text: `❌ No unpublished articles found in session.
|
|
2216
|
-
|
|
2217
|
-
Use \`save_content\` after writing an article, then call \`publish_content\`.
|
|
2218
|
-
Or use \`get_session\` to see current session state.`
|
|
2219
|
-
}]
|
|
2220
|
-
}
|
|
2221
|
-
}
|
|
2222
|
-
|
|
2223
|
-
const hasGhost = hasCredential('ghost')
|
|
2224
|
-
const hasWordPress = hasCredential('wordpress')
|
|
2225
|
-
const shouldPublishGhost = hasGhost && (platforms.includes('all') || platforms.includes('ghost'))
|
|
2226
|
-
const shouldPublishWordPress = hasWordPress && (platforms.includes('all') || platforms.includes('wordpress'))
|
|
2227
|
-
|
|
2228
|
-
// Results for all articles
|
|
2229
|
-
const allResults = []
|
|
2230
|
-
|
|
2231
|
-
progress('Publishing', `Starting batch publish of ${articlesToPublish.length} article(s)`)
|
|
2232
|
-
|
|
2233
|
-
// Publish each article
|
|
2234
|
-
for (let i = 0; i < articlesToPublish.length; i++) {
|
|
2235
|
-
const article = articlesToPublish[i]
|
|
2236
|
-
progress('Publishing', `Article ${i + 1}/${articlesToPublish.length}: "${article.title}"`)
|
|
2237
|
-
|
|
2238
|
-
// Inject inline images into content (replace [IMAGE: ...] placeholders)
|
|
2239
|
-
let contentWithImages = article.content
|
|
2240
|
-
let imageIndex = 0
|
|
2241
|
-
const articleInlineImages = article.inlineImages || []
|
|
2242
|
-
contentWithImages = contentWithImages.replace(/\[IMAGE:\s*([^\]]+)\]/gi, (match, description) => {
|
|
2243
|
-
if (imageIndex < articleInlineImages.length) {
|
|
2244
|
-
const imgUrl = articleInlineImages[imageIndex]
|
|
2245
|
-
imageIndex++
|
|
2246
|
-
return ``
|
|
2247
|
-
}
|
|
2248
|
-
return match // Keep placeholder if no image available
|
|
2249
|
-
})
|
|
2250
|
-
|
|
2251
|
-
const articleResults = {
|
|
2252
|
-
article: article.title,
|
|
2253
|
-
articleId: article.id,
|
|
2254
|
-
wordCount: article.wordCount || contentWithImages.split(/\s+/).length,
|
|
2255
|
-
platforms: []
|
|
2256
|
-
}
|
|
2257
|
-
|
|
2258
|
-
// Publish to Ghost
|
|
2259
|
-
if (shouldPublishGhost) {
|
|
2260
|
-
try {
|
|
2261
|
-
const ghostResult = await executeGhostPublish({
|
|
2262
|
-
title: article.title,
|
|
2263
|
-
content: contentWithImages,
|
|
2264
|
-
status: status,
|
|
2265
|
-
tags: article.keywords || [],
|
|
2266
|
-
featured_image_url: article.imageUrl
|
|
2267
|
-
})
|
|
2268
|
-
articleResults.platforms.push({ platform: 'Ghost', success: true, result: ghostResult })
|
|
2269
|
-
} catch (e) {
|
|
2270
|
-
articleResults.platforms.push({ platform: 'Ghost', success: false, error: e.message })
|
|
2271
|
-
}
|
|
2272
|
-
}
|
|
2273
|
-
|
|
2274
|
-
// Publish to WordPress
|
|
2275
|
-
if (shouldPublishWordPress) {
|
|
2276
|
-
try {
|
|
2277
|
-
const categories = category ? [category] : []
|
|
2278
|
-
const wpResult = await executeWordPressPublish({
|
|
2279
|
-
title: article.title,
|
|
2280
|
-
content: contentWithImages,
|
|
2281
|
-
status: status,
|
|
2282
|
-
categories: categories,
|
|
2283
|
-
tags: article.keywords || [],
|
|
2284
|
-
featured_image_url: article.imageUrl
|
|
2285
|
-
})
|
|
2286
|
-
articleResults.platforms.push({ platform: 'WordPress', success: true, result: wpResult })
|
|
2287
|
-
} catch (e) {
|
|
2288
|
-
articleResults.platforms.push({ platform: 'WordPress', success: false, error: e.message })
|
|
2289
|
-
}
|
|
2290
|
-
}
|
|
2291
|
-
|
|
2292
|
-
// Mark article as published if at least one platform succeeded
|
|
2293
|
-
const hasSuccess = articleResults.platforms.some(p => p.success)
|
|
2294
|
-
if (hasSuccess && article.id !== 'current') {
|
|
2295
|
-
const articleIndex = sessionState.articles.findIndex(a => a.id === article.id)
|
|
2296
|
-
if (articleIndex !== -1) {
|
|
2297
|
-
sessionState.articles[articleIndex].published = true
|
|
2298
|
-
sessionState.articles[articleIndex].publishedTo = articleResults.platforms
|
|
2299
|
-
.filter(p => p.success)
|
|
2300
|
-
.map(p => p.platform.toLowerCase())
|
|
2301
|
-
sessionState.articles[articleIndex].publishedAt = new Date().toISOString()
|
|
2302
|
-
}
|
|
2303
|
-
}
|
|
2304
|
-
|
|
2305
|
-
allResults.push(articleResults)
|
|
2306
|
-
}
|
|
2307
|
-
|
|
2308
|
-
// Save updated session state (with published flags)
|
|
2309
|
-
saveSession()
|
|
2310
|
-
|
|
2311
|
-
// Build response
|
|
2312
|
-
const totalArticles = allResults.length
|
|
2313
|
-
const successfulArticles = allResults.filter(r => r.platforms.some(p => p.success)).length
|
|
2314
|
-
const totalWords = allResults.reduce((sum, r) => sum + r.wordCount, 0)
|
|
2315
|
-
|
|
2316
|
-
let response = `# 📤 Batch Publishing Results
|
|
2317
|
-
|
|
2318
|
-
## Summary
|
|
2319
|
-
- **Articles Published:** ${successfulArticles}/${totalArticles}
|
|
2320
|
-
- **Total Words:** ${totalWords.toLocaleString()}
|
|
2321
|
-
- **Status:** ${status}
|
|
2322
|
-
- **Platforms:** ${[shouldPublishGhost ? 'Ghost' : null, shouldPublishWordPress ? 'WordPress' : null].filter(Boolean).join(', ') || 'None'}
|
|
2323
|
-
${category ? `- **Category:** ${category}` : ''}
|
|
2324
|
-
|
|
2325
|
-
---
|
|
2326
|
-
|
|
2327
|
-
`
|
|
2328
|
-
|
|
2329
|
-
// Detail for each article
|
|
2330
|
-
for (const result of allResults) {
|
|
2331
|
-
const hasAnySuccess = result.platforms.some(p => p.success)
|
|
2332
|
-
response += `## ${hasAnySuccess ? '✅' : '❌'} ${result.article}\n`
|
|
2333
|
-
response += `**Words:** ${result.wordCount}\n\n`
|
|
2334
|
-
|
|
2335
|
-
for (const p of result.platforms) {
|
|
2336
|
-
if (p.success) {
|
|
2337
|
-
response += `**${p.platform}:** ✅ Published\n`
|
|
2338
|
-
// Extract URL if available
|
|
2339
|
-
const resultText = p.result?.content?.[0]?.text || ''
|
|
2340
|
-
const urlMatch = resultText.match(/https?:\/\/[^\s\)]+/)
|
|
2341
|
-
if (urlMatch) {
|
|
2342
|
-
response += `URL: ${urlMatch[0]}\n`
|
|
2343
|
-
}
|
|
2344
|
-
} else {
|
|
2345
|
-
response += `**${p.platform}:** ❌ ${p.error}\n`
|
|
2346
|
-
}
|
|
2347
|
-
}
|
|
2348
|
-
response += '\n'
|
|
2349
|
-
}
|
|
2350
|
-
|
|
2351
|
-
// Show remaining unpublished articles
|
|
2352
|
-
const remainingUnpublished = sessionState.articles.filter(a => !a.published)
|
|
2353
|
-
if (remainingUnpublished.length > 0) {
|
|
2354
|
-
response += `---\n\n**📝 ${remainingUnpublished.length} article(s) still unpublished** in session.\n`
|
|
2355
|
-
response += `Call \`publish_content\` again to publish remaining, or \`get_session\` to see details.\n`
|
|
2356
|
-
} else if (sessionState.articles.length > 0) {
|
|
2357
|
-
response += `---\n\n✅ **All ${sessionState.articles.length} articles published!**\n`
|
|
2358
|
-
response += `Session retained for reference. Start a new workflow to clear.\n`
|
|
2359
|
-
}
|
|
2360
|
-
|
|
2361
|
-
return {
|
|
2362
|
-
content: [{
|
|
2363
|
-
type: 'text',
|
|
2364
|
-
text: response
|
|
2365
|
-
}]
|
|
2366
|
-
}
|
|
2367
|
-
}
|
|
2368
|
-
|
|
2369
|
-
case 'get_session': {
|
|
2370
|
-
const totalImagesNeeded = sessionState.currentWorkflow?.settings?.total_images || 0
|
|
2371
|
-
const imagesGenerated = (sessionState.imageUrl ? 1 : 0) + sessionState.inlineImages.length
|
|
2372
|
-
const workflow = sessionState.currentWorkflow
|
|
2373
|
-
|
|
2374
|
-
// Count totals across all articles
|
|
2375
|
-
const totalArticles = sessionState.articles.length
|
|
2376
|
-
const unpublishedArticles = sessionState.articles.filter(a => !a.published)
|
|
2377
|
-
const publishedArticles = sessionState.articles.filter(a => a.published)
|
|
2378
|
-
const totalWords = sessionState.articles.reduce((sum, a) => sum + (a.wordCount || 0), 0)
|
|
2379
|
-
const totalImages = sessionState.articles.reduce((sum, a) => {
|
|
2380
|
-
return sum + (a.imageUrl ? 1 : 0) + (a.inlineImages?.length || 0)
|
|
2381
|
-
}, 0)
|
|
2382
|
-
|
|
2383
|
-
// Build articles list
|
|
2384
|
-
const articlesSection = sessionState.articles.length > 0 ? `
|
|
2385
|
-
## 📚 Saved Articles (${totalArticles} total)
|
|
2386
|
-
|
|
2387
|
-
| # | Title | Words | Images | Status |
|
|
2388
|
-
|---|-------|-------|--------|--------|
|
|
2389
|
-
${sessionState.articles.map((art, i) => {
|
|
2390
|
-
const imgCount = (art.imageUrl ? 1 : 0) + (art.inlineImages?.length || 0)
|
|
2391
|
-
const status = art.published ? `✅ ${art.publishedTo.join(', ')}` : '📝 Unpublished'
|
|
2392
|
-
return `| ${i + 1} | ${art.title.substring(0, 40)}${art.title.length > 40 ? '...' : ''} | ${art.wordCount} | ${imgCount} | ${status} |`
|
|
2393
|
-
}).join('\n')}
|
|
2394
|
-
|
|
2395
|
-
**Summary:** ${totalWords.toLocaleString()} total words, ${totalImages} total images
|
|
2396
|
-
**Unpublished:** ${unpublishedArticles.length} article(s) ready to publish
|
|
2397
|
-
` : `
|
|
2398
|
-
## 📚 Saved Articles
|
|
2399
|
-
No articles saved yet. Use \`save_content\` after writing an article.
|
|
2400
|
-
`
|
|
2401
|
-
|
|
2402
|
-
// Current working article (if any in progress)
|
|
2403
|
-
const currentWorkingSection = sessionState.title && sessionState.article ? `
|
|
2404
|
-
## 🖊️ Current Working Article
|
|
2405
|
-
**Title:** ${sessionState.title}
|
|
2406
|
-
**Word Count:** ${sessionState.article.split(/\s+/).length} words
|
|
2407
|
-
**Meta Description:** ${sessionState.metaDescription || 'Not set'}
|
|
2408
|
-
**Cover Image:** ${sessionState.imageUrl ? '✅ Generated' : '❌ Not yet'}
|
|
2409
|
-
**Inline Images:** ${sessionState.inlineImages.length}
|
|
2410
|
-
|
|
2411
|
-
*This article is being edited. Call \`save_content\` to add it to the session.*
|
|
2412
|
-
` : ''
|
|
2413
|
-
|
|
2414
|
-
return {
|
|
2415
|
-
content: [{
|
|
2416
|
-
type: 'text',
|
|
2417
|
-
text: `# 📋 Session State
|
|
2418
|
-
|
|
2419
|
-
**Workflow:** ${workflow?.workflow_id || 'None active'}
|
|
2420
|
-
**Total Articles:** ${totalArticles}
|
|
2421
|
-
**Ready to Publish:** ${unpublishedArticles.length}
|
|
2422
|
-
**Already Published:** ${publishedArticles.length}
|
|
2423
|
-
${articlesSection}${currentWorkingSection}
|
|
2424
|
-
## 🖼️ Current Working Images (${imagesGenerated}/${totalImagesNeeded})
|
|
2425
|
-
**Cover Image:** ${sessionState.imageUrl || 'Not generated'}
|
|
2426
|
-
**Inline Images:** ${sessionState.inlineImages.length > 0 ? sessionState.inlineImages.map((url, i) => `\n ${i+1}. ${url.substring(0, 60)}...`).join('') : 'None'}
|
|
2427
|
-
|
|
2428
|
-
${workflow ? `
|
|
2429
|
-
## ⚙️ Project Settings
|
|
2430
|
-
- **Project:** ${workflow.project_info?.name || 'Unknown'}
|
|
2431
|
-
- **Niche:** ${workflow.project_info?.niche || 'Unknown'}
|
|
2432
|
-
- **Word Count Target:** ${workflow.settings?.target_word_count || 'Not set'}
|
|
2433
|
-
- **Reading Level:** ${workflow.settings?.reading_level_display || 'Not set'}
|
|
2434
|
-
- **Brand Voice:** ${workflow.settings?.brand_voice || 'Not set'}
|
|
2435
|
-
- **Include Images:** ${workflow.settings?.include_images ? 'Yes' : 'No'}
|
|
2436
|
-
` : ''}
|
|
2437
|
-
## 🚀 Actions
|
|
2438
|
-
- **Publish all unpublished:** Call \`publish_content\`
|
|
2439
|
-
- **Add more articles:** Use \`create_content\` or \`content_write\` then \`save_content\`
|
|
2440
|
-
- **Remove articles:** Call \`remove_article\` with article numbers
|
|
2441
|
-
- **Clear session:** Call \`clear_session\` with confirm: true`
|
|
2442
|
-
}]
|
|
2443
|
-
}
|
|
2444
|
-
}
|
|
2445
|
-
|
|
2446
|
-
case 'remove_article': {
|
|
2447
|
-
const { article_numbers } = args
|
|
2448
|
-
|
|
2449
|
-
if (!article_numbers || article_numbers.length === 0) {
|
|
2450
|
-
return {
|
|
2451
|
-
content: [{
|
|
2452
|
-
type: 'text',
|
|
2453
|
-
text: `❌ Please specify article numbers to remove. Use \`get_session\` to see article numbers.`
|
|
2454
|
-
}]
|
|
2455
|
-
}
|
|
2456
|
-
}
|
|
2457
|
-
|
|
2458
|
-
// Sort in descending order to avoid index shifting issues
|
|
2459
|
-
const sortedNumbers = [...article_numbers].sort((a, b) => b - a)
|
|
2460
|
-
const removed = []
|
|
2461
|
-
const skipped = []
|
|
2462
|
-
|
|
2463
|
-
for (const num of sortedNumbers) {
|
|
2464
|
-
const index = num - 1
|
|
2465
|
-
if (index < 0 || index >= sessionState.articles.length) {
|
|
2466
|
-
skipped.push({ num, reason: 'not found' })
|
|
2467
|
-
continue
|
|
2468
|
-
}
|
|
2469
|
-
|
|
2470
|
-
const article = sessionState.articles[index]
|
|
2471
|
-
if (article.published) {
|
|
2472
|
-
skipped.push({ num, reason: 'already published', title: article.title })
|
|
2473
|
-
continue
|
|
2474
|
-
}
|
|
2475
|
-
|
|
2476
|
-
// Remove the article
|
|
2477
|
-
const [removedArticle] = sessionState.articles.splice(index, 1)
|
|
2478
|
-
removed.push({ num, title: removedArticle.title })
|
|
2479
|
-
}
|
|
2480
|
-
|
|
2481
|
-
// Save session
|
|
2482
|
-
saveSession()
|
|
2483
|
-
|
|
2484
|
-
let response = `# 🗑️ Article Removal Results\n\n`
|
|
2485
|
-
|
|
2486
|
-
if (removed.length > 0) {
|
|
2487
|
-
response += `## ✅ Removed (${removed.length})\n`
|
|
2488
|
-
for (const r of removed) {
|
|
2489
|
-
response += `- #${r.num}: "${r.title}"\n`
|
|
2490
|
-
}
|
|
2491
|
-
response += '\n'
|
|
2492
|
-
}
|
|
2493
|
-
|
|
2494
|
-
if (skipped.length > 0) {
|
|
2495
|
-
response += `## ⚠️ Skipped (${skipped.length})\n`
|
|
2496
|
-
for (const s of skipped) {
|
|
2497
|
-
if (s.reason === 'already published') {
|
|
2498
|
-
response += `- #${s.num}: "${s.title}" (already published - cannot remove)\n`
|
|
2499
|
-
} else {
|
|
2500
|
-
response += `- #${s.num}: not found\n`
|
|
2501
|
-
}
|
|
2502
|
-
}
|
|
2503
|
-
response += '\n'
|
|
2504
|
-
}
|
|
2505
|
-
|
|
2506
|
-
response += `---\n\n**${sessionState.articles.length} article(s) remaining in session.**`
|
|
2507
|
-
|
|
2508
|
-
return {
|
|
2509
|
-
content: [{
|
|
2510
|
-
type: 'text',
|
|
2511
|
-
text: response
|
|
2512
|
-
}]
|
|
2513
|
-
}
|
|
2514
|
-
}
|
|
2515
|
-
|
|
2516
|
-
case 'clear_session': {
|
|
2517
|
-
const { confirm } = args
|
|
2518
|
-
|
|
2519
|
-
if (!confirm) {
|
|
2520
|
-
return {
|
|
2521
|
-
content: [{
|
|
2522
|
-
type: 'text',
|
|
2523
|
-
text: `⚠️ **Clear Session requires confirmation**
|
|
2524
|
-
|
|
2525
|
-
This will permanently remove:
|
|
2526
|
-
- ${sessionState.articles.length} saved article(s)
|
|
2527
|
-
- All generated images
|
|
2528
|
-
- Current workflow state
|
|
2529
|
-
|
|
2530
|
-
To confirm, call \`clear_session\` with \`confirm: true\``
|
|
2531
|
-
}]
|
|
2532
|
-
}
|
|
2533
|
-
}
|
|
2534
|
-
|
|
2535
|
-
const articleCount = sessionState.articles.length
|
|
2536
|
-
const unpublishedCount = sessionState.articles.filter(a => !a.published).length
|
|
2537
|
-
|
|
2538
|
-
// Clear everything
|
|
2539
|
-
resetSession()
|
|
2540
|
-
|
|
2541
|
-
return {
|
|
2542
|
-
content: [{
|
|
2543
|
-
type: 'text',
|
|
2544
|
-
text: `# ✅ Session Cleared
|
|
2545
|
-
|
|
2546
|
-
Removed:
|
|
2547
|
-
- ${articleCount} article(s) (${unpublishedCount} unpublished)
|
|
2548
|
-
- All workflow state
|
|
2549
|
-
- All generated images
|
|
2550
|
-
|
|
2551
|
-
Session is now empty. Ready for new content creation.`
|
|
2552
|
-
}]
|
|
2553
|
-
}
|
|
2554
|
-
}
|
|
2555
|
-
|
|
2556
|
-
case 'list_content': {
|
|
2557
|
-
const { limit = 20 } = args
|
|
2558
|
-
const contentDir = getContentDir()
|
|
2559
|
-
|
|
2560
|
-
if (!fs.existsSync(contentDir)) {
|
|
2561
|
-
return {
|
|
2562
|
-
content: [{
|
|
2563
|
-
type: 'text',
|
|
2564
|
-
text: `# 📂 Saved Content
|
|
2565
|
-
|
|
2566
|
-
No content directory found at \`${contentDir}\`.
|
|
2567
|
-
|
|
2568
|
-
Save articles using \`save_content\` and they will appear here.`
|
|
2569
|
-
}]
|
|
2570
|
-
}
|
|
2571
|
-
}
|
|
2572
|
-
|
|
2573
|
-
// Get all content folders
|
|
2574
|
-
const folders = fs.readdirSync(contentDir, { withFileTypes: true })
|
|
2575
|
-
.filter(dirent => dirent.isDirectory())
|
|
2576
|
-
.map(dirent => {
|
|
2577
|
-
const folderPath = path.join(contentDir, dirent.name)
|
|
2578
|
-
const metadataPath = path.join(folderPath, 'metadata.json')
|
|
2579
|
-
|
|
2580
|
-
let metadata = null
|
|
2581
|
-
if (fs.existsSync(metadataPath)) {
|
|
2582
|
-
try {
|
|
2583
|
-
metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'))
|
|
2584
|
-
} catch (e) {
|
|
2585
|
-
// Ignore parse errors
|
|
2586
|
-
}
|
|
2587
|
-
}
|
|
2588
|
-
|
|
2589
|
-
return {
|
|
2590
|
-
name: dirent.name,
|
|
2591
|
-
path: folderPath,
|
|
2592
|
-
metadata,
|
|
2593
|
-
mtime: fs.statSync(folderPath).mtime
|
|
2594
|
-
}
|
|
2595
|
-
})
|
|
2596
|
-
.sort((a, b) => b.mtime - a.mtime) // Most recent first
|
|
2597
|
-
.slice(0, limit)
|
|
2598
|
-
|
|
2599
|
-
if (folders.length === 0) {
|
|
2600
|
-
return {
|
|
2601
|
-
content: [{
|
|
2602
|
-
type: 'text',
|
|
2603
|
-
text: `# 📂 Saved Content
|
|
2604
|
-
|
|
2605
|
-
No saved articles found in \`${contentDir}\`.
|
|
2606
|
-
|
|
2607
|
-
Save articles using \`save_content\` and they will appear here.`
|
|
2608
|
-
}]
|
|
2609
|
-
}
|
|
2610
|
-
}
|
|
2611
|
-
|
|
2612
|
-
let response = `# 📂 Saved Content (${folders.length} articles)
|
|
2613
|
-
|
|
2614
|
-
| # | Date | Title | Words | Project |
|
|
2615
|
-
|---|------|-------|-------|---------|
|
|
2616
|
-
`
|
|
2617
|
-
folders.forEach((folder, i) => {
|
|
2618
|
-
const date = folder.name.split('-').slice(0, 3).join('-')
|
|
2619
|
-
const title = folder.metadata?.title || folder.name.split('-').slice(3).join('-')
|
|
2620
|
-
const words = folder.metadata?.wordCount || '?'
|
|
2621
|
-
const project = folder.metadata?.projectSlug || '-'
|
|
2622
|
-
response += `| ${i + 1} | ${date} | ${title.substring(0, 35)}${title.length > 35 ? '...' : ''} | ${words} | ${project} |\n`
|
|
2623
|
-
})
|
|
2624
|
-
|
|
2625
|
-
response += `
|
|
2626
|
-
---
|
|
2627
|
-
|
|
2628
|
-
## To Load an Article
|
|
2629
|
-
|
|
2630
|
-
Call \`load_content\` with the folder name:
|
|
2631
|
-
\`\`\`
|
|
2632
|
-
load_content({ folder_name: "${folders[0]?.name}" })
|
|
2633
|
-
\`\`\`
|
|
2634
|
-
|
|
2635
|
-
Once loaded, you can run optimization tools:
|
|
2636
|
-
- \`quality_check\` - Pre-publish quality assurance
|
|
2637
|
-
- \`geo_optimize\` - AI search engine optimization
|
|
2638
|
-
- \`internal_links\` - Internal linking suggestions
|
|
2639
|
-
- \`schema_generate\` - JSON-LD structured data
|
|
2640
|
-
- \`save_content\` - Re-save with changes
|
|
2641
|
-
- \`publish_content\` - Publish to CMS`
|
|
2642
|
-
|
|
2643
|
-
return {
|
|
2644
|
-
content: [{
|
|
2645
|
-
type: 'text',
|
|
2646
|
-
text: response
|
|
2647
|
-
}]
|
|
2648
|
-
}
|
|
2649
|
-
}
|
|
2650
|
-
|
|
2651
|
-
case 'load_content': {
|
|
2652
|
-
const { folder_name } = args
|
|
2653
|
-
|
|
2654
|
-
if (!folder_name) {
|
|
2655
|
-
return {
|
|
2656
|
-
content: [{
|
|
2657
|
-
type: 'text',
|
|
2658
|
-
text: `❌ Please specify a folder_name. Use \`list_content\` to see available articles.`
|
|
2659
|
-
}]
|
|
2660
|
-
}
|
|
2661
|
-
}
|
|
2662
|
-
|
|
2663
|
-
// Sanitize folder_name to prevent path traversal attacks
|
|
2664
|
-
let folderPath
|
|
2665
|
-
try {
|
|
2666
|
-
folderPath = getContentFolderSafe(folder_name)
|
|
2667
|
-
} catch (error) {
|
|
2668
|
-
return {
|
|
2669
|
-
content: [{
|
|
2670
|
-
type: 'text',
|
|
2671
|
-
text: `❌ Invalid folder name: ${error.message}`
|
|
2672
|
-
}]
|
|
2673
|
-
}
|
|
2674
|
-
}
|
|
2675
|
-
|
|
2676
|
-
if (!fs.existsSync(folderPath)) {
|
|
2677
|
-
return {
|
|
2678
|
-
content: [{
|
|
2679
|
-
type: 'text',
|
|
2680
|
-
text: `❌ Folder not found: \`${folder_name}\`
|
|
2681
|
-
|
|
2682
|
-
Use \`list_content\` to see available articles.`
|
|
2683
|
-
}]
|
|
2684
|
-
}
|
|
2685
|
-
}
|
|
2686
|
-
|
|
2687
|
-
// Load article and metadata
|
|
2688
|
-
const articlePath = path.join(folderPath, 'article.md')
|
|
2689
|
-
const metadataPath = path.join(folderPath, 'metadata.json')
|
|
2690
|
-
|
|
2691
|
-
if (!fs.existsSync(articlePath)) {
|
|
2692
|
-
return {
|
|
2693
|
-
content: [{
|
|
2694
|
-
type: 'text',
|
|
2695
|
-
text: `❌ No article.md found in \`${folder_name}\``
|
|
2696
|
-
}]
|
|
2697
|
-
}
|
|
2698
|
-
}
|
|
2699
|
-
|
|
2700
|
-
const articleContent = fs.readFileSync(articlePath, 'utf-8')
|
|
2701
|
-
let metadata = {}
|
|
2702
|
-
if (fs.existsSync(metadataPath)) {
|
|
2703
|
-
try {
|
|
2704
|
-
metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'))
|
|
2705
|
-
} catch (e) {
|
|
2706
|
-
log(`Warning: Failed to parse metadata.json: ${e.message}`)
|
|
2707
|
-
}
|
|
2708
|
-
}
|
|
2709
|
-
|
|
2710
|
-
// Load into session state
|
|
2711
|
-
sessionState.title = metadata.title || folder_name
|
|
2712
|
-
sessionState.article = articleContent
|
|
2713
|
-
sessionState.keywords = metadata.keywords || []
|
|
2714
|
-
sessionState.metaDescription = metadata.metaDescription || ''
|
|
2715
|
-
sessionState.metaTitle = metadata.metaTitle || metadata.title || folder_name
|
|
2716
|
-
sessionState.imageUrl = metadata.imageUrl || null
|
|
2717
|
-
sessionState.inlineImages = metadata.inlineImages || []
|
|
2718
|
-
sessionState.contentFolder = folderPath
|
|
2719
|
-
|
|
2720
|
-
// Also add to articles array if not already there
|
|
2721
|
-
const existingIndex = sessionState.articles.findIndex(a => a.title === sessionState.title)
|
|
2722
|
-
if (existingIndex === -1) {
|
|
2723
|
-
const loadedArticle = {
|
|
2724
|
-
id: generateArticleId(),
|
|
2725
|
-
title: sessionState.title,
|
|
2726
|
-
content: articleContent,
|
|
2727
|
-
keywords: sessionState.keywords,
|
|
2728
|
-
metaDescription: sessionState.metaDescription,
|
|
2729
|
-
metaTitle: sessionState.metaTitle,
|
|
2730
|
-
imageUrl: sessionState.imageUrl,
|
|
2731
|
-
inlineImages: sessionState.inlineImages,
|
|
2732
|
-
savedAt: metadata.createdAt || new Date().toISOString(),
|
|
2733
|
-
published: false,
|
|
2734
|
-
publishedTo: [],
|
|
2735
|
-
wordCount: articleContent.split(/\s+/).length,
|
|
2736
|
-
loadedFrom: folderPath
|
|
2737
|
-
}
|
|
2738
|
-
sessionState.articles.push(loadedArticle)
|
|
2739
|
-
}
|
|
2740
|
-
|
|
2741
|
-
// Save session
|
|
2742
|
-
saveSession()
|
|
2743
|
-
|
|
2744
|
-
const wordCount = articleContent.split(/\s+/).length
|
|
2745
|
-
progress('Content', `Loaded "${sessionState.title}" (${wordCount} words) from ${folder_name}`)
|
|
2746
|
-
|
|
2747
|
-
return {
|
|
2748
|
-
content: [{
|
|
2749
|
-
type: 'text',
|
|
2750
|
-
text: `# ✅ Content Loaded
|
|
2751
|
-
|
|
2752
|
-
**Title:** ${sessionState.title}
|
|
2753
|
-
**Word Count:** ${wordCount}
|
|
2754
|
-
**Keywords:** ${sessionState.keywords.join(', ') || 'None'}
|
|
2755
|
-
**Meta Description:** ${sessionState.metaDescription ? `${sessionState.metaDescription.length} chars` : 'None'}
|
|
2756
|
-
**Cover Image:** ${sessionState.imageUrl ? '✅' : '❌'}
|
|
2757
|
-
**Inline Images:** ${sessionState.inlineImages.length}
|
|
2758
|
-
**Source:** \`${folderPath}\`
|
|
2759
|
-
|
|
2760
|
-
---
|
|
2761
|
-
|
|
2762
|
-
## Now you can run optimization tools:
|
|
2763
|
-
|
|
2764
|
-
- **\`quality_check\`** - Pre-publish quality assurance
|
|
2765
|
-
- **\`geo_optimize\`** - Optimize for AI search engines (ChatGPT, Perplexity)
|
|
2766
|
-
- **\`internal_links\`** - Get internal linking suggestions
|
|
2767
|
-
- **\`schema_generate\`** - Generate JSON-LD structured data
|
|
2768
|
-
- **\`save_content\`** - Re-save after making changes
|
|
2769
|
-
- **\`publish_content\`** - Publish to WordPress/Ghost
|
|
2770
|
-
|
|
2771
|
-
Article is now in session (#${sessionState.articles.length}) and ready for further processing.`
|
|
2772
|
-
}]
|
|
2773
|
-
}
|
|
2774
|
-
}
|
|
2775
|
-
|
|
2776
|
-
default:
|
|
2777
|
-
throw new Error(`Unknown orchestrator tool: ${toolName}`)
|
|
2778
|
-
}
|
|
2779
|
-
}
|
|
2780
|
-
|
|
2781
|
-
/**
|
|
2782
|
-
* Execute an action tool locally using credentials
|
|
2783
|
-
*/
|
|
2784
|
-
async function executeActionTool(toolName, args) {
|
|
2785
|
-
switch (toolName) {
|
|
2786
|
-
case 'generate_image':
|
|
2787
|
-
return await executeImageGeneration(args)
|
|
2788
|
-
case 'publish_wordpress':
|
|
2789
|
-
return await executeWordPressPublish(args)
|
|
2790
|
-
case 'publish_ghost':
|
|
2791
|
-
return await executeGhostPublish(args)
|
|
2792
|
-
case 'send_webhook':
|
|
2793
|
-
return await executeSendWebhook(args)
|
|
2794
|
-
default:
|
|
2795
|
-
throw new Error(`Unknown action tool: ${toolName}`)
|
|
2796
|
-
}
|
|
2797
|
-
}
|
|
2798
|
-
|
|
2799
|
-
/**
|
|
2800
|
-
* Generate image using configured provider
|
|
2801
|
-
*/
|
|
2802
|
-
async function executeImageGeneration(args) {
|
|
2803
|
-
const provider = localCredentials.image_provider
|
|
2804
|
-
const config = localCredentials[provider]
|
|
2805
|
-
|
|
2806
|
-
if (!config?.api_key) {
|
|
2807
|
-
throw new Error(`${provider} API key not configured`)
|
|
2808
|
-
}
|
|
2809
|
-
|
|
2810
|
-
progress('Image', `Generating with ${provider}...`)
|
|
2811
|
-
|
|
2812
|
-
const { prompt, style, aspect_ratio = '16:9' } = args
|
|
2813
|
-
const fullPrompt = style ? `${prompt}, ${style}` : prompt
|
|
2814
|
-
|
|
2815
|
-
log(`Generating image with ${provider}: ${fullPrompt.substring(0, 50)}...`)
|
|
2816
|
-
|
|
2817
|
-
switch (provider) {
|
|
2818
|
-
case 'fal': {
|
|
2819
|
-
// fal.ai Nano Banana Pro (gemini-3-pro-image)
|
|
2820
|
-
const response = await fetchWithRetry(API_ENDPOINTS.fal, {
|
|
2821
|
-
method: 'POST',
|
|
2822
|
-
headers: {
|
|
2823
|
-
'Authorization': `Key ${config.api_key}`,
|
|
2824
|
-
'Content-Type': 'application/json'
|
|
2825
|
-
},
|
|
2826
|
-
body: JSON.stringify({
|
|
2827
|
-
prompt: fullPrompt,
|
|
2828
|
-
aspect_ratio: aspect_ratio,
|
|
2829
|
-
output_format: 'png',
|
|
2830
|
-
resolution: '1K',
|
|
2831
|
-
num_images: 1
|
|
2832
|
-
})
|
|
2833
|
-
}, 2, 60000) // 2 retries, 60s timeout for image generation
|
|
2834
|
-
|
|
2835
|
-
if (!response.ok) {
|
|
2836
|
-
const error = await response.text()
|
|
2837
|
-
throw new Error(`fal.ai error: ${error}`)
|
|
2838
|
-
}
|
|
2839
|
-
|
|
2840
|
-
const result = await response.json()
|
|
2841
|
-
const imageUrl = result.images?.[0]?.url
|
|
2842
|
-
|
|
2843
|
-
// Store in session for orchestrated workflows
|
|
2844
|
-
// First image is cover, subsequent are inline
|
|
2845
|
-
if (!sessionState.imageUrl) {
|
|
2846
|
-
sessionState.imageUrl = imageUrl
|
|
2847
|
-
} else {
|
|
2848
|
-
sessionState.inlineImages.push(imageUrl)
|
|
2849
|
-
}
|
|
2850
|
-
|
|
2851
|
-
// Persist session to file
|
|
2852
|
-
saveSession()
|
|
2853
|
-
|
|
2854
|
-
// Track stats
|
|
2855
|
-
incrementStat('images_generated')
|
|
2856
|
-
|
|
2857
|
-
const imageNumber = 1 + sessionState.inlineImages.length
|
|
2858
|
-
const totalImages = sessionState.currentWorkflow?.settings?.total_images || 1
|
|
2859
|
-
const imageType = imageNumber === 1 ? 'Cover Image' : `Inline Image ${imageNumber - 1}`
|
|
2860
|
-
|
|
2861
|
-
return {
|
|
2862
|
-
content: [{
|
|
2863
|
-
type: 'text',
|
|
2864
|
-
text: `# ✅ ${imageType} Generated (${imageNumber}/${totalImages})
|
|
2865
|
-
|
|
2866
|
-
**URL:** ${imageUrl}
|
|
2867
|
-
|
|
2868
|
-
**Prompt:** ${fullPrompt}
|
|
2869
|
-
**Provider:** fal.ai (nano-banana-pro)
|
|
2870
|
-
**Aspect Ratio:** ${aspect_ratio}
|
|
2871
|
-
|
|
2872
|
-
${imageNumber < totalImages ? `\n**Next:** Generate ${totalImages - imageNumber} more image(s).` : '\n**All images generated!** Proceed to publish.'}`
|
|
2873
|
-
}]
|
|
2874
|
-
}
|
|
2875
|
-
}
|
|
2876
|
-
|
|
2877
|
-
case 'gemini': {
|
|
2878
|
-
// Google Gemini 3 Pro Image (Nano Banana Pro) - generateContent API
|
|
2879
|
-
const model = config.model || 'gemini-3-pro-image-preview'
|
|
2880
|
-
const response = await fetch(
|
|
2881
|
-
`${API_ENDPOINTS.gemini}/${model}:generateContent`,
|
|
2882
|
-
{
|
|
2883
|
-
method: 'POST',
|
|
2884
|
-
headers: {
|
|
2885
|
-
'Content-Type': 'application/json',
|
|
2886
|
-
'x-goog-api-key': config.api_key
|
|
2887
|
-
},
|
|
2888
|
-
body: JSON.stringify({
|
|
2889
|
-
contents: [{
|
|
2890
|
-
parts: [{ text: fullPrompt }]
|
|
2891
|
-
}],
|
|
2892
|
-
generationConfig: {
|
|
2893
|
-
responseModalities: ['IMAGE'],
|
|
2894
|
-
imageConfig: {
|
|
2895
|
-
aspectRatio: aspect_ratio,
|
|
2896
|
-
imageSize: '1K'
|
|
2897
|
-
}
|
|
2898
|
-
}
|
|
2899
|
-
})
|
|
2900
|
-
}
|
|
2901
|
-
)
|
|
2902
|
-
|
|
2903
|
-
if (!response.ok) {
|
|
2904
|
-
const error = await response.text()
|
|
2905
|
-
throw new Error(`Gemini error: ${error}`)
|
|
2906
|
-
}
|
|
2907
|
-
|
|
2908
|
-
const result = await response.json()
|
|
2909
|
-
const imagePart = result.candidates?.[0]?.content?.parts?.find(p => p.inlineData)
|
|
2910
|
-
const imageData = imagePart?.inlineData?.data
|
|
2911
|
-
const mimeType = imagePart?.inlineData?.mimeType || 'image/png'
|
|
2912
|
-
|
|
2913
|
-
if (!imageData) {
|
|
2914
|
-
throw new Error('No image data in Gemini response')
|
|
2915
|
-
}
|
|
2916
|
-
|
|
2917
|
-
// Return base64 data URI
|
|
2918
|
-
const dataUri = `data:${mimeType};base64,${imageData}`
|
|
2919
|
-
|
|
2920
|
-
// Track stats
|
|
2921
|
-
incrementStat('images_generated')
|
|
2922
|
-
|
|
2923
|
-
return {
|
|
2924
|
-
content: [{
|
|
2925
|
-
type: 'text',
|
|
2926
|
-
text: `Image generated successfully!\n\n**Format:** Base64 Data URI\n**Prompt:** ${fullPrompt}\n**Provider:** Google Gemini (${model})\n**Aspect Ratio:** ${aspect_ratio}\n\n**Data URI:** ${dataUri.substring(0, 100)}...\n\n[Full base64 data: ${imageData.length} chars]`
|
|
2927
|
-
}]
|
|
2928
|
-
}
|
|
2929
|
-
}
|
|
2930
|
-
|
|
2931
|
-
case 'wiro': {
|
|
2932
|
-
// wiro.ai API with HMAC signature authentication
|
|
2933
|
-
const crypto = await import('crypto')
|
|
2934
|
-
const apiKey = config.api_key
|
|
2935
|
-
const apiSecret = config.api_secret
|
|
2936
|
-
|
|
2937
|
-
if (!apiSecret) {
|
|
2938
|
-
throw new Error('Wiro API secret not configured. Add api_secret to wiro config in ~/.suparank/credentials.json')
|
|
2939
|
-
}
|
|
2940
|
-
|
|
2941
|
-
// Generate nonce and signature
|
|
2942
|
-
const nonce = Math.floor(Date.now() / 1000).toString()
|
|
2943
|
-
const signatureData = `${apiSecret}${nonce}`
|
|
2944
|
-
const signature = crypto.createHmac('sha256', apiKey)
|
|
2945
|
-
.update(signatureData)
|
|
2946
|
-
.digest('hex')
|
|
2947
|
-
|
|
2948
|
-
const model = config.model || 'google/nano-banana-pro'
|
|
2949
|
-
|
|
2950
|
-
// Submit task
|
|
2951
|
-
log(`Submitting wiro.ai task for model: ${model}`)
|
|
2952
|
-
const submitResponse = await fetch(`${API_ENDPOINTS.wiro}/Run/${model}`, {
|
|
2953
|
-
method: 'POST',
|
|
2954
|
-
headers: {
|
|
2955
|
-
'Content-Type': 'application/json',
|
|
2956
|
-
'x-api-key': apiKey,
|
|
2957
|
-
'x-nonce': nonce,
|
|
2958
|
-
'x-signature': signature
|
|
2959
|
-
},
|
|
2960
|
-
body: JSON.stringify({
|
|
2961
|
-
prompt: fullPrompt,
|
|
2962
|
-
aspectRatio: aspect_ratio,
|
|
2963
|
-
resolution: '1K',
|
|
2964
|
-
safetySetting: 'BLOCK_ONLY_HIGH'
|
|
2965
|
-
})
|
|
2966
|
-
})
|
|
2967
|
-
|
|
2968
|
-
if (!submitResponse.ok) {
|
|
2969
|
-
const error = await submitResponse.text()
|
|
2970
|
-
throw new Error(`wiro.ai submit error: ${error}`)
|
|
2971
|
-
}
|
|
2972
|
-
|
|
2973
|
-
const submitResult = await submitResponse.json()
|
|
2974
|
-
if (!submitResult.result || !submitResult.taskid) {
|
|
2975
|
-
throw new Error(`wiro.ai task submission failed: ${JSON.stringify(submitResult.errors)}`)
|
|
2976
|
-
}
|
|
2977
|
-
|
|
2978
|
-
const taskId = submitResult.taskid
|
|
2979
|
-
log(`wiro.ai task submitted: ${taskId}`)
|
|
2980
|
-
|
|
2981
|
-
// Poll for completion
|
|
2982
|
-
const maxAttempts = 60 // 60 seconds max
|
|
2983
|
-
const pollInterval = 2000 // 2 seconds
|
|
2984
|
-
|
|
2985
|
-
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
2986
|
-
await new Promise(resolve => setTimeout(resolve, pollInterval))
|
|
2987
|
-
|
|
2988
|
-
// Generate new signature for poll request
|
|
2989
|
-
const pollNonce = Math.floor(Date.now() / 1000).toString()
|
|
2990
|
-
const pollSignatureData = `${apiSecret}${pollNonce}`
|
|
2991
|
-
const pollSignature = crypto.createHmac('sha256', apiKey)
|
|
2992
|
-
.update(pollSignatureData)
|
|
2993
|
-
.digest('hex')
|
|
2994
|
-
|
|
2995
|
-
const pollResponse = await fetch(API_ENDPOINTS.wiroTaskDetail, {
|
|
2996
|
-
method: 'POST',
|
|
2997
|
-
headers: {
|
|
2998
|
-
'Content-Type': 'application/json',
|
|
2999
|
-
'x-api-key': apiKey,
|
|
3000
|
-
'x-nonce': pollNonce,
|
|
3001
|
-
'x-signature': pollSignature
|
|
3002
|
-
},
|
|
3003
|
-
body: JSON.stringify({ taskid: taskId })
|
|
3004
|
-
})
|
|
3005
|
-
|
|
3006
|
-
if (!pollResponse.ok) {
|
|
3007
|
-
log(`wiro.ai poll error: ${await pollResponse.text()}`)
|
|
3008
|
-
continue
|
|
3009
|
-
}
|
|
3010
|
-
|
|
3011
|
-
const pollResult = await pollResponse.json()
|
|
3012
|
-
const task = pollResult.tasklist?.[0]
|
|
3013
|
-
|
|
3014
|
-
if (!task) continue
|
|
3015
|
-
|
|
3016
|
-
const status = task.status
|
|
3017
|
-
log(`wiro.ai task status: ${status}`)
|
|
3018
|
-
|
|
3019
|
-
// Check for completion
|
|
3020
|
-
if (status === 'task_postprocess_end') {
|
|
3021
|
-
const imageUrl = task.outputs?.[0]?.url
|
|
3022
|
-
if (!imageUrl) {
|
|
3023
|
-
throw new Error('wiro.ai task completed but no output URL')
|
|
3024
|
-
}
|
|
3025
|
-
|
|
3026
|
-
// Store in session for orchestrated workflows
|
|
3027
|
-
// First image is cover, subsequent are inline
|
|
3028
|
-
if (!sessionState.imageUrl) {
|
|
3029
|
-
sessionState.imageUrl = imageUrl
|
|
3030
|
-
} else {
|
|
3031
|
-
sessionState.inlineImages.push(imageUrl)
|
|
3032
|
-
}
|
|
3033
|
-
|
|
3034
|
-
// Persist session to file
|
|
3035
|
-
saveSession()
|
|
3036
|
-
|
|
3037
|
-
// Track stats
|
|
3038
|
-
incrementStat('images_generated')
|
|
3039
|
-
|
|
3040
|
-
const imageNumber = 1 + sessionState.inlineImages.length
|
|
3041
|
-
const totalImages = sessionState.currentWorkflow?.settings?.total_images || 1
|
|
3042
|
-
const imageType = imageNumber === 1 ? 'Cover Image' : `Inline Image ${imageNumber - 1}`
|
|
3043
|
-
|
|
3044
|
-
return {
|
|
3045
|
-
content: [{
|
|
3046
|
-
type: 'text',
|
|
3047
|
-
text: `# ✅ ${imageType} Generated (${imageNumber}/${totalImages})
|
|
3048
|
-
|
|
3049
|
-
**URL:** ${imageUrl}
|
|
3050
|
-
|
|
3051
|
-
**Prompt:** ${fullPrompt}
|
|
3052
|
-
**Provider:** wiro.ai (${model})
|
|
3053
|
-
**Aspect Ratio:** ${aspect_ratio}
|
|
3054
|
-
**Processing Time:** ${task.elapsedseconds}s
|
|
3055
|
-
|
|
3056
|
-
${imageNumber < totalImages ? `\n**Next:** Generate ${totalImages - imageNumber} more image(s).` : '\n**All images generated!** Proceed to publish.'}`
|
|
3057
|
-
}]
|
|
3058
|
-
}
|
|
3059
|
-
}
|
|
3060
|
-
|
|
3061
|
-
// Check for failure
|
|
3062
|
-
if (status === 'task_cancel') {
|
|
3063
|
-
throw new Error('wiro.ai task was cancelled')
|
|
3064
|
-
}
|
|
3065
|
-
}
|
|
3066
|
-
|
|
3067
|
-
throw new Error('wiro.ai task timed out after 60 seconds')
|
|
3068
|
-
}
|
|
3069
|
-
|
|
3070
|
-
default:
|
|
3071
|
-
throw new Error(`Unknown image provider: ${provider}`)
|
|
3072
|
-
}
|
|
3073
|
-
}
|
|
3074
|
-
|
|
3075
|
-
/**
|
|
3076
|
-
* Convert aspect ratio string to fal.ai image size
|
|
3077
|
-
*/
|
|
3078
|
-
function aspectRatioToSize(ratio) {
|
|
3079
|
-
const sizes = {
|
|
3080
|
-
'1:1': 'square',
|
|
3081
|
-
'16:9': 'landscape_16_9',
|
|
3082
|
-
'9:16': 'portrait_16_9',
|
|
3083
|
-
'4:3': 'landscape_4_3',
|
|
3084
|
-
'3:4': 'portrait_4_3'
|
|
3085
|
-
}
|
|
3086
|
-
return sizes[ratio] || 'landscape_16_9'
|
|
3087
|
-
}
|
|
3088
|
-
|
|
3089
|
-
/**
|
|
3090
|
-
* Convert markdown to HTML using marked library
|
|
3091
|
-
* Configured for WordPress/Ghost CMS compatibility
|
|
3092
|
-
*/
|
|
3093
|
-
function markdownToHtml(markdown) {
|
|
3094
|
-
// Configure marked for CMS compatibility
|
|
3095
|
-
marked.setOptions({
|
|
3096
|
-
gfm: true, // GitHub Flavored Markdown
|
|
3097
|
-
breaks: true, // Convert line breaks to <br>
|
|
3098
|
-
pedantic: false,
|
|
3099
|
-
silent: true // Don't throw on errors
|
|
3100
|
-
})
|
|
3101
|
-
|
|
3102
|
-
try {
|
|
3103
|
-
return marked.parse(markdown)
|
|
3104
|
-
} catch (error) {
|
|
3105
|
-
log(`Markdown conversion error: ${error.message}`)
|
|
3106
|
-
// Fallback: return markdown wrapped in <p> tags
|
|
3107
|
-
return `<p>${markdown.replace(/\n\n+/g, '</p><p>')}</p>`
|
|
3108
|
-
}
|
|
3109
|
-
}
|
|
3110
|
-
|
|
3111
|
-
/**
|
|
3112
|
-
* Fetch available categories from WordPress
|
|
3113
|
-
*/
|
|
3114
|
-
async function fetchWordPressCategories() {
|
|
3115
|
-
const wpConfig = localCredentials?.wordpress
|
|
3116
|
-
if (!wpConfig?.secret_key || !wpConfig?.site_url) {
|
|
3117
|
-
return null
|
|
3118
|
-
}
|
|
3119
|
-
|
|
3120
|
-
try {
|
|
3121
|
-
log('Fetching WordPress categories...')
|
|
3122
|
-
|
|
3123
|
-
// Try new Suparank endpoint first, then fall back to legacy
|
|
3124
|
-
const endpoints = [
|
|
3125
|
-
{ url: `${wpConfig.site_url}/wp-json/suparank/v1/categories`, header: 'X-Suparank-Key' },
|
|
3126
|
-
{ url: `${wpConfig.site_url}/wp-json/writer-mcp/v1/categories`, header: 'X-Writer-MCP-Key' }
|
|
3127
|
-
]
|
|
3128
|
-
|
|
3129
|
-
for (const endpoint of endpoints) {
|
|
3130
|
-
try {
|
|
3131
|
-
const response = await fetchWithTimeout(endpoint.url, {
|
|
3132
|
-
method: 'GET',
|
|
3133
|
-
headers: {
|
|
3134
|
-
[endpoint.header]: wpConfig.secret_key
|
|
3135
|
-
}
|
|
3136
|
-
}, 10000) // 10s timeout
|
|
3137
|
-
|
|
3138
|
-
if (response.ok) {
|
|
3139
|
-
const result = await response.json()
|
|
3140
|
-
if (result.success && result.categories) {
|
|
3141
|
-
log(`Found ${result.categories.length} WordPress categories`)
|
|
3142
|
-
return result.categories
|
|
3143
|
-
}
|
|
3144
|
-
}
|
|
3145
|
-
} catch (e) {
|
|
3146
|
-
// Try next endpoint
|
|
3147
|
-
}
|
|
3148
|
-
}
|
|
3149
|
-
|
|
3150
|
-
log('Failed to fetch categories from any endpoint')
|
|
3151
|
-
return null
|
|
3152
|
-
} catch (error) {
|
|
3153
|
-
log(`Error fetching categories: ${error.message}`)
|
|
3154
|
-
return null
|
|
3155
|
-
}
|
|
3156
|
-
}
|
|
3157
|
-
|
|
3158
|
-
/**
|
|
3159
|
-
* Publish to WordPress using REST API or custom plugin
|
|
3160
|
-
*/
|
|
3161
|
-
async function executeWordPressPublish(args) {
|
|
3162
|
-
const wpConfig = localCredentials.wordpress
|
|
3163
|
-
const { title, content, status = 'draft', categories = [], tags = [], featured_image_url } = args
|
|
3164
|
-
|
|
3165
|
-
progress('Publish', `Publishing to WordPress: "${title}"`)
|
|
3166
|
-
log(`Publishing to WordPress: ${title}`)
|
|
3167
|
-
|
|
3168
|
-
// Convert markdown to HTML for WordPress
|
|
3169
|
-
const htmlContent = markdownToHtml(content)
|
|
3170
|
-
|
|
3171
|
-
// Method 1: Use Suparank Connector plugin (secret_key auth)
|
|
3172
|
-
if (wpConfig.secret_key) {
|
|
3173
|
-
log('Using Suparank/Writer MCP Connector plugin')
|
|
3174
|
-
|
|
3175
|
-
// Try new Suparank endpoint first, then fall back to legacy
|
|
3176
|
-
const endpoints = [
|
|
3177
|
-
{ url: `${wpConfig.site_url}/wp-json/suparank/v1/publish`, header: 'X-Suparank-Key' },
|
|
3178
|
-
{ url: `${wpConfig.site_url}/wp-json/writer-mcp/v1/publish`, header: 'X-Writer-MCP-Key' }
|
|
3179
|
-
]
|
|
3180
|
-
|
|
3181
|
-
const postBody = JSON.stringify({
|
|
3182
|
-
title,
|
|
3183
|
-
content: htmlContent,
|
|
3184
|
-
status,
|
|
3185
|
-
categories,
|
|
3186
|
-
tags,
|
|
3187
|
-
featured_image_url,
|
|
3188
|
-
excerpt: sessionState.metaDescription || ''
|
|
3189
|
-
})
|
|
3190
|
-
|
|
3191
|
-
let lastError = null
|
|
3192
|
-
for (const endpoint of endpoints) {
|
|
3193
|
-
try {
|
|
3194
|
-
const response = await fetchWithRetry(endpoint.url, {
|
|
3195
|
-
method: 'POST',
|
|
3196
|
-
headers: {
|
|
3197
|
-
'Content-Type': 'application/json',
|
|
3198
|
-
[endpoint.header]: wpConfig.secret_key
|
|
3199
|
-
},
|
|
3200
|
-
body: postBody
|
|
3201
|
-
}, 2, 30000) // 2 retries, 30s timeout
|
|
3202
|
-
|
|
3203
|
-
if (response.ok) {
|
|
3204
|
-
const result = await response.json()
|
|
3205
|
-
|
|
3206
|
-
if (result.success) {
|
|
3207
|
-
const categoriesInfo = result.post.categories?.length
|
|
3208
|
-
? `\n**Categories:** ${result.post.categories.join(', ')}`
|
|
3209
|
-
: ''
|
|
3210
|
-
const tagsInfo = result.post.tags?.length
|
|
3211
|
-
? `\n**Tags:** ${result.post.tags.join(', ')}`
|
|
3212
|
-
: ''
|
|
3213
|
-
const imageInfo = result.post.featured_image
|
|
3214
|
-
? `\n**Featured Image:** ✅ Uploaded`
|
|
3215
|
-
: ''
|
|
3216
|
-
|
|
3217
|
-
return {
|
|
3218
|
-
content: [{
|
|
3219
|
-
type: 'text',
|
|
3220
|
-
text: `Post published to WordPress!\n\n**Title:** ${result.post.title}\n**Status:** ${result.post.status}\n**URL:** ${result.post.url}\n**Edit:** ${result.post.edit_url}\n**ID:** ${result.post.id}${categoriesInfo}${tagsInfo}${imageInfo}\n\n${status === 'draft' ? 'The post is saved as a draft. Edit and publish from WordPress dashboard.' : 'The post is now live!'}`
|
|
3221
|
-
}]
|
|
3222
|
-
}
|
|
3223
|
-
}
|
|
3224
|
-
}
|
|
3225
|
-
lastError = await response.text()
|
|
3226
|
-
} catch (e) {
|
|
3227
|
-
lastError = e.message
|
|
3228
|
-
}
|
|
3229
|
-
}
|
|
3230
|
-
|
|
3231
|
-
throw new Error(`WordPress error: ${lastError}`)
|
|
3232
|
-
}
|
|
3233
|
-
|
|
3234
|
-
// Method 2: Use standard REST API with application password
|
|
3235
|
-
if (wpConfig.app_password && wpConfig.username) {
|
|
3236
|
-
log('Using WordPress REST API with application password')
|
|
3237
|
-
|
|
3238
|
-
const auth = Buffer.from(`${wpConfig.username}:${wpConfig.app_password}`).toString('base64')
|
|
3239
|
-
const postData = {
|
|
3240
|
-
title,
|
|
3241
|
-
content: htmlContent,
|
|
3242
|
-
status,
|
|
3243
|
-
categories: [],
|
|
3244
|
-
tags: []
|
|
3245
|
-
}
|
|
3246
|
-
|
|
3247
|
-
const response = await fetch(`${wpConfig.site_url}/wp-json/wp/v2/posts`, {
|
|
3248
|
-
method: 'POST',
|
|
3249
|
-
headers: {
|
|
3250
|
-
'Authorization': `Basic ${auth}`,
|
|
3251
|
-
'Content-Type': 'application/json'
|
|
3252
|
-
},
|
|
3253
|
-
body: JSON.stringify(postData)
|
|
3254
|
-
})
|
|
3255
|
-
|
|
3256
|
-
if (!response.ok) {
|
|
3257
|
-
const error = await response.text()
|
|
3258
|
-
throw new Error(`WordPress error: ${error}`)
|
|
3259
|
-
}
|
|
3260
|
-
|
|
3261
|
-
const post = await response.json()
|
|
3262
|
-
|
|
3263
|
-
return {
|
|
3264
|
-
content: [{
|
|
3265
|
-
type: 'text',
|
|
3266
|
-
text: `Post published to WordPress!\n\n**Title:** ${post.title.rendered}\n**Status:** ${post.status}\n**URL:** ${post.link}\n**ID:** ${post.id}\n\n${status === 'draft' ? 'The post is saved as a draft. Edit and publish from WordPress dashboard.' : 'The post is now live!'}`
|
|
3267
|
-
}]
|
|
3268
|
-
}
|
|
3269
|
-
}
|
|
3270
|
-
|
|
3271
|
-
throw new Error('WordPress credentials not configured. Add either secret_key (with plugin) or username + app_password to ~/.suparank/credentials.json')
|
|
3272
|
-
}
|
|
3273
|
-
|
|
3274
|
-
/**
|
|
3275
|
-
* Publish to Ghost using Admin API
|
|
3276
|
-
*/
|
|
3277
|
-
async function executeGhostPublish(args) {
|
|
3278
|
-
const { api_url, admin_api_key } = localCredentials.ghost
|
|
3279
|
-
const { title, content, status = 'draft', tags = [], featured_image_url } = args
|
|
3280
|
-
|
|
3281
|
-
progress('Publish', `Publishing to Ghost: "${title}"`)
|
|
3282
|
-
log(`Publishing to Ghost: ${title}`)
|
|
3283
|
-
|
|
3284
|
-
// Create JWT for Ghost Admin API
|
|
3285
|
-
const [id, secret] = admin_api_key.split(':')
|
|
3286
|
-
const token = await createGhostJWT(id, secret)
|
|
3287
|
-
|
|
3288
|
-
// Convert markdown to HTML for proper element separation
|
|
3289
|
-
const htmlContent = markdownToHtml(content)
|
|
3290
|
-
|
|
3291
|
-
// Use HTML card for proper rendering (each element separate)
|
|
3292
|
-
const mobiledoc = JSON.stringify({
|
|
3293
|
-
version: '0.3.1',
|
|
3294
|
-
atoms: [],
|
|
3295
|
-
cards: [['html', { html: htmlContent }]],
|
|
3296
|
-
markups: [],
|
|
3297
|
-
sections: [[10, 0]]
|
|
3298
|
-
})
|
|
3299
|
-
|
|
3300
|
-
const postData = {
|
|
3301
|
-
posts: [{
|
|
3302
|
-
title,
|
|
3303
|
-
mobiledoc,
|
|
3304
|
-
status,
|
|
3305
|
-
tags: tags.map(name => ({ name })),
|
|
3306
|
-
feature_image: featured_image_url
|
|
3307
|
-
}]
|
|
3308
|
-
}
|
|
3309
|
-
|
|
3310
|
-
const response = await fetchWithRetry(`${api_url}/ghost/api/admin/posts/`, {
|
|
3311
|
-
method: 'POST',
|
|
3312
|
-
headers: {
|
|
3313
|
-
'Authorization': `Ghost ${token}`,
|
|
3314
|
-
'Content-Type': 'application/json'
|
|
3315
|
-
},
|
|
3316
|
-
body: JSON.stringify(postData)
|
|
3317
|
-
}, 2, 30000) // 2 retries, 30s timeout
|
|
3318
|
-
|
|
3319
|
-
if (!response.ok) {
|
|
3320
|
-
const error = await response.text()
|
|
3321
|
-
throw new Error(`Ghost error: ${error}`)
|
|
3322
|
-
}
|
|
3323
|
-
|
|
3324
|
-
const result = await response.json()
|
|
3325
|
-
const post = result.posts[0]
|
|
3326
|
-
|
|
3327
|
-
return {
|
|
3328
|
-
content: [{
|
|
3329
|
-
type: 'text',
|
|
3330
|
-
text: `Post published to Ghost!\n\n**Title:** ${post.title}\n**Status:** ${post.status}\n**URL:** ${post.url}\n**ID:** ${post.id}\n\n${status === 'draft' ? 'The post is saved as a draft. Edit and publish from Ghost dashboard.' : 'The post is now live!'}`
|
|
3331
|
-
}]
|
|
3332
|
-
}
|
|
3333
|
-
}
|
|
3334
|
-
|
|
3335
|
-
/**
|
|
3336
|
-
* Create JWT for Ghost Admin API
|
|
3337
|
-
*/
|
|
3338
|
-
async function createGhostJWT(id, secret) {
|
|
3339
|
-
// Simple JWT creation for Ghost
|
|
3340
|
-
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT', kid: id })).toString('base64url')
|
|
3341
|
-
const now = Math.floor(Date.now() / 1000)
|
|
3342
|
-
const payload = Buffer.from(JSON.stringify({
|
|
3343
|
-
iat: now,
|
|
3344
|
-
exp: now + 300, // 5 minutes
|
|
3345
|
-
aud: '/admin/'
|
|
3346
|
-
})).toString('base64url')
|
|
3347
|
-
|
|
3348
|
-
// Create signature using crypto
|
|
3349
|
-
const crypto = await import('crypto')
|
|
3350
|
-
const key = Buffer.from(secret, 'hex')
|
|
3351
|
-
const signature = crypto.createHmac('sha256', key)
|
|
3352
|
-
.update(`${header}.${payload}`)
|
|
3353
|
-
.digest('base64url')
|
|
3354
|
-
|
|
3355
|
-
return `${header}.${payload}.${signature}`
|
|
3356
|
-
}
|
|
3357
|
-
|
|
3358
|
-
/**
|
|
3359
|
-
* Send data to webhook
|
|
3360
|
-
*/
|
|
3361
|
-
async function executeSendWebhook(args) {
|
|
3362
|
-
const { webhook_type = 'default', payload = {}, message } = args
|
|
3363
|
-
const webhooks = localCredentials.webhooks
|
|
3364
|
-
|
|
3365
|
-
// Get webhook URL
|
|
3366
|
-
const urlMap = {
|
|
3367
|
-
default: webhooks.default_url,
|
|
3368
|
-
make: webhooks.make_url,
|
|
3369
|
-
n8n: webhooks.n8n_url,
|
|
3370
|
-
zapier: webhooks.zapier_url,
|
|
3371
|
-
slack: webhooks.slack_url
|
|
3372
|
-
}
|
|
3373
|
-
|
|
3374
|
-
const url = urlMap[webhook_type]
|
|
3375
|
-
if (!url) {
|
|
3376
|
-
throw new Error(`No ${webhook_type} webhook URL configured`)
|
|
3377
|
-
}
|
|
3378
|
-
|
|
3379
|
-
log(`Sending webhook to ${webhook_type}: ${url}`)
|
|
3380
|
-
|
|
3381
|
-
// Format payload based on type
|
|
3382
|
-
let body
|
|
3383
|
-
let headers = { 'Content-Type': 'application/json' }
|
|
3384
|
-
|
|
3385
|
-
if (webhook_type === 'slack') {
|
|
3386
|
-
body = JSON.stringify({
|
|
3387
|
-
text: message || 'Message from Writer MCP',
|
|
3388
|
-
...payload
|
|
3389
|
-
})
|
|
3390
|
-
} else {
|
|
3391
|
-
body = JSON.stringify({
|
|
3392
|
-
source: 'suparank',
|
|
3393
|
-
timestamp: new Date().toISOString(),
|
|
3394
|
-
project: projectSlug,
|
|
3395
|
-
data: payload,
|
|
3396
|
-
message
|
|
3397
|
-
})
|
|
3398
|
-
}
|
|
3399
|
-
|
|
3400
|
-
const response = await fetch(url, {
|
|
3401
|
-
method: 'POST',
|
|
3402
|
-
headers,
|
|
3403
|
-
body
|
|
3404
|
-
})
|
|
3405
|
-
|
|
3406
|
-
if (!response.ok) {
|
|
3407
|
-
const error = await response.text()
|
|
3408
|
-
throw new Error(`Webhook error (${response.status}): ${error}`)
|
|
3409
|
-
}
|
|
3410
|
-
|
|
3411
|
-
return {
|
|
3412
|
-
content: [{
|
|
3413
|
-
type: 'text',
|
|
3414
|
-
text: `Webhook sent successfully!\n\n**Type:** ${webhook_type}\n**URL:** ${url.substring(0, 50)}...\n**Status:** ${response.status}\n\nThe data has been sent to your ${webhook_type} webhook.`
|
|
3415
|
-
}]
|
|
3416
|
-
}
|
|
3417
|
-
}
|
|
3418
|
-
|
|
3419
|
-
/**
|
|
3420
|
-
* Essential tools shown in the tool list
|
|
3421
|
-
* MCP protocol requires tools to be listed for clients to call them
|
|
3422
|
-
*/
|
|
3423
|
-
const VISIBLE_TOOLS = [
|
|
3424
|
-
// Essential (5) - Main workflow
|
|
3425
|
-
'create_content', // Main entry point - creates & publishes automatically
|
|
3426
|
-
'keyword_research', // Research keywords separately (on-demand)
|
|
3427
|
-
'generate_image', // Generate/regenerate images (on-demand)
|
|
3428
|
-
'publish_content', // Manual publish trigger (on-demand)
|
|
3429
|
-
'get_session', // Check status (on-demand)
|
|
3430
|
-
|
|
3431
|
-
// Session Management (5) - Content lifecycle
|
|
3432
|
-
'save_content', // Save article to session
|
|
3433
|
-
'list_content', // List saved content
|
|
3434
|
-
'load_content', // Load past content into session
|
|
3435
|
-
'remove_article', // Remove article from session
|
|
3436
|
-
'clear_session' // Clear all session content
|
|
3437
|
-
]
|
|
3438
|
-
|
|
3439
|
-
/**
|
|
3440
|
-
* Get all available tools based on configured credentials
|
|
3441
|
-
* Shows 10 essential tools (instead of 24) for cleaner UX
|
|
3442
|
-
*/
|
|
3443
|
-
function getAvailableTools() {
|
|
3444
|
-
const tools = []
|
|
3445
|
-
|
|
3446
|
-
// Add visible TOOLS (keyword_research only from main tools)
|
|
3447
|
-
for (const tool of TOOLS) {
|
|
3448
|
-
if (VISIBLE_TOOLS.includes(tool.name)) {
|
|
3449
|
-
tools.push({
|
|
3450
|
-
name: tool.name,
|
|
3451
|
-
description: tool.description,
|
|
3452
|
-
inputSchema: tool.inputSchema
|
|
3453
|
-
})
|
|
3454
|
-
}
|
|
3455
|
-
}
|
|
3456
|
-
|
|
3457
|
-
// Add visible orchestrator tools
|
|
3458
|
-
for (const tool of ORCHESTRATOR_TOOLS) {
|
|
3459
|
-
if (VISIBLE_TOOLS.includes(tool.name)) {
|
|
3460
|
-
tools.push({
|
|
3461
|
-
name: tool.name,
|
|
3462
|
-
description: tool.description,
|
|
3463
|
-
inputSchema: tool.inputSchema
|
|
3464
|
-
})
|
|
3465
|
-
}
|
|
3466
|
-
}
|
|
3467
|
-
|
|
3468
|
-
// Add visible action tools (only if credentials are configured)
|
|
3469
|
-
for (const tool of ACTION_TOOLS) {
|
|
3470
|
-
if (VISIBLE_TOOLS.includes(tool.name)) {
|
|
3471
|
-
if (hasCredential(tool.requiresCredential)) {
|
|
3472
|
-
tools.push({
|
|
3473
|
-
name: tool.name,
|
|
3474
|
-
description: tool.description,
|
|
3475
|
-
inputSchema: tool.inputSchema
|
|
3476
|
-
})
|
|
3477
|
-
} else {
|
|
3478
|
-
// Add disabled version with note
|
|
3479
|
-
tools.push({
|
|
3480
|
-
name: tool.name,
|
|
3481
|
-
description: `[DISABLED - requires ${tool.requiresCredential} credentials] ${tool.description}`,
|
|
3482
|
-
inputSchema: tool.inputSchema
|
|
3483
|
-
})
|
|
3484
|
-
}
|
|
3485
|
-
}
|
|
3486
|
-
}
|
|
3487
|
-
|
|
3488
|
-
return tools
|
|
3489
|
-
}
|
|
3490
|
-
|
|
3491
|
-
/**
|
|
3492
|
-
* Get ALL tools (visible + hidden) for tool execution
|
|
3493
|
-
* This is used by CallToolRequestSchema to find tools by name
|
|
3494
|
-
*/
|
|
3495
|
-
function getAllTools() {
|
|
3496
|
-
const tools = [...TOOLS]
|
|
3497
|
-
|
|
3498
|
-
// Add all orchestrator tools
|
|
3499
|
-
for (const tool of ORCHESTRATOR_TOOLS) {
|
|
3500
|
-
tools.push({
|
|
3501
|
-
name: tool.name,
|
|
3502
|
-
description: tool.description,
|
|
3503
|
-
inputSchema: tool.inputSchema
|
|
3504
|
-
})
|
|
3505
|
-
}
|
|
3506
|
-
|
|
3507
|
-
// Add all action tools
|
|
3508
|
-
for (const tool of ACTION_TOOLS) {
|
|
3509
|
-
tools.push({
|
|
3510
|
-
name: tool.name,
|
|
3511
|
-
description: tool.description,
|
|
3512
|
-
inputSchema: tool.inputSchema,
|
|
3513
|
-
requiresCredential: tool.requiresCredential
|
|
3514
|
-
})
|
|
3515
|
-
}
|
|
3516
|
-
|
|
3517
|
-
return tools
|
|
3518
|
-
}
|
|
3519
|
-
|
|
3520
|
-
// Main function
|
|
3521
|
-
async function main() {
|
|
3522
|
-
log(`Starting MCP client for project: ${projectSlug}`)
|
|
3523
|
-
log(`API URL: ${apiUrl}`)
|
|
3524
|
-
|
|
3525
|
-
// Load local credentials
|
|
3526
|
-
localCredentials = loadLocalCredentials()
|
|
3527
|
-
if (localCredentials) {
|
|
3528
|
-
const configured = []
|
|
3529
|
-
if (hasCredential('wordpress')) configured.push('wordpress')
|
|
3530
|
-
if (hasCredential('ghost')) configured.push('ghost')
|
|
3531
|
-
if (hasCredential('image')) configured.push(`image:${localCredentials.image_provider}`)
|
|
3532
|
-
if (hasCredential('webhooks')) configured.push('webhooks')
|
|
3533
|
-
if (localCredentials.external_mcps?.length) {
|
|
3534
|
-
configured.push(`mcps:${localCredentials.external_mcps.map(m => m.name).join(',')}`)
|
|
3535
|
-
}
|
|
3536
|
-
if (configured.length > 0) {
|
|
3537
|
-
log(`Configured integrations: ${configured.join(', ')}`)
|
|
3538
|
-
}
|
|
3539
|
-
}
|
|
3540
|
-
|
|
3541
|
-
// Restore session state from previous run
|
|
3542
|
-
if (loadSession()) {
|
|
3543
|
-
progress('Session', 'Restored previous workflow state')
|
|
3544
|
-
}
|
|
3545
|
-
|
|
3546
|
-
// Fetch project configuration
|
|
3547
|
-
progress('Init', 'Connecting to platform...')
|
|
3548
|
-
let project
|
|
3549
|
-
try {
|
|
3550
|
-
project = await fetchProjectConfig()
|
|
3551
|
-
progress('Init', `Connected to project: ${project.name}`)
|
|
3552
|
-
} catch (error) {
|
|
3553
|
-
log('Failed to load project config. Exiting.')
|
|
3554
|
-
process.exit(1)
|
|
3555
|
-
}
|
|
3556
|
-
|
|
3557
|
-
// Create MCP server
|
|
3558
|
-
const server = new Server(
|
|
3559
|
-
{
|
|
3560
|
-
name: 'suparank',
|
|
3561
|
-
version: '1.0.0'
|
|
3562
|
-
},
|
|
3563
|
-
{
|
|
3564
|
-
capabilities: {
|
|
3565
|
-
tools: {}
|
|
3566
|
-
}
|
|
3567
|
-
}
|
|
3568
|
-
)
|
|
3569
|
-
|
|
3570
|
-
// Handle initialization
|
|
3571
|
-
server.setRequestHandler(InitializeRequestSchema, async (request) => {
|
|
3572
|
-
log('Received initialize request')
|
|
3573
|
-
return {
|
|
3574
|
-
protocolVersion: '2024-11-05',
|
|
3575
|
-
capabilities: {
|
|
3576
|
-
tools: {}
|
|
3577
|
-
},
|
|
3578
|
-
serverInfo: {
|
|
3579
|
-
name: 'suparank',
|
|
3580
|
-
version: '1.0.0'
|
|
3581
|
-
}
|
|
3582
|
-
}
|
|
3583
|
-
})
|
|
3584
|
-
|
|
3585
|
-
// Handle tools list
|
|
3586
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
3587
|
-
log('Received list tools request')
|
|
3588
|
-
const tools = getAvailableTools()
|
|
3589
|
-
log(`Returning ${tools.length} tools (${ACTION_TOOLS.length} action tools)`)
|
|
3590
|
-
return { tools }
|
|
3591
|
-
})
|
|
3592
|
-
|
|
3593
|
-
// Handle tool calls
|
|
3594
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
3595
|
-
const { name, arguments: args } = request.params
|
|
3596
|
-
progress('Tool', `Executing ${name}`)
|
|
3597
|
-
log(`Executing tool: ${name}`)
|
|
3598
|
-
|
|
3599
|
-
// Track tool call stats
|
|
3600
|
-
incrementStat('tool_calls')
|
|
3601
|
-
|
|
3602
|
-
// Check if this is an orchestrator tool
|
|
3603
|
-
const orchestratorTool = ORCHESTRATOR_TOOLS.find(t => t.name === name)
|
|
3604
|
-
|
|
3605
|
-
if (orchestratorTool) {
|
|
3606
|
-
try {
|
|
3607
|
-
const result = await executeOrchestratorTool(name, args || {}, project)
|
|
3608
|
-
log(`Orchestrator tool ${name} completed successfully`)
|
|
3609
|
-
return result
|
|
3610
|
-
} catch (error) {
|
|
3611
|
-
log(`Orchestrator tool ${name} failed:`, error.message)
|
|
3612
|
-
return {
|
|
3613
|
-
content: [{
|
|
3614
|
-
type: 'text',
|
|
3615
|
-
text: `Error executing ${name}: ${error.message}`
|
|
3616
|
-
}]
|
|
3617
|
-
}
|
|
3618
|
-
}
|
|
3619
|
-
}
|
|
3620
|
-
|
|
3621
|
-
// Check if this is an action tool
|
|
3622
|
-
const actionTool = ACTION_TOOLS.find(t => t.name === name)
|
|
3623
|
-
|
|
3624
|
-
if (actionTool) {
|
|
3625
|
-
// Check credentials
|
|
3626
|
-
if (!hasCredential(actionTool.requiresCredential)) {
|
|
3627
|
-
return {
|
|
3628
|
-
content: [{
|
|
3629
|
-
type: 'text',
|
|
3630
|
-
text: `Error: ${name} requires ${actionTool.requiresCredential} credentials.\n\nTo enable this tool:\n1. Run: npx suparank setup\n2. Add your ${actionTool.requiresCredential} credentials to ~/.suparank/credentials.json\n3. Restart the MCP server\n\nSee dashboard Settings > Credentials for setup instructions.`
|
|
3631
|
-
}]
|
|
3632
|
-
}
|
|
3633
|
-
}
|
|
3634
|
-
|
|
3635
|
-
// Execute action tool locally
|
|
3636
|
-
try {
|
|
3637
|
-
const result = await executeActionTool(name, args || {})
|
|
3638
|
-
log(`Action tool ${name} completed successfully`)
|
|
3639
|
-
return result
|
|
3640
|
-
} catch (error) {
|
|
3641
|
-
log(`Action tool ${name} failed:`, error.message)
|
|
3642
|
-
return {
|
|
3643
|
-
content: [{
|
|
3644
|
-
type: 'text',
|
|
3645
|
-
text: `Error executing ${name}: ${error.message}`
|
|
3646
|
-
}]
|
|
3647
|
-
}
|
|
3648
|
-
}
|
|
3649
|
-
}
|
|
3650
|
-
|
|
3651
|
-
// Regular tool - call backend
|
|
3652
|
-
try {
|
|
3653
|
-
// Add composition hints if configured
|
|
3654
|
-
const hints = getCompositionHints(name)
|
|
3655
|
-
const externalMcps = getExternalMCPs()
|
|
3656
|
-
|
|
3657
|
-
const result = await callBackendTool(name, args || {})
|
|
3658
|
-
|
|
3659
|
-
// Inject composition hints into response if available
|
|
3660
|
-
if (hints && result.content && result.content[0]?.text) {
|
|
3661
|
-
const mcpList = externalMcps.length > 0
|
|
3662
|
-
? `\n\n## External MCPs Available\n${externalMcps.map(m => `- **${m.name}**: ${m.available_tools.join(', ')}`).join('\n')}`
|
|
3663
|
-
: ''
|
|
3664
|
-
|
|
3665
|
-
result.content[0].text = result.content[0].text +
|
|
3666
|
-
`\n\n---\n## Integration Hints\n${hints}${mcpList}`
|
|
3667
|
-
}
|
|
3668
|
-
|
|
3669
|
-
log(`Tool ${name} completed successfully`)
|
|
3670
|
-
return result
|
|
3671
|
-
} catch (error) {
|
|
3672
|
-
log(`Tool ${name} failed:`, error.message)
|
|
3673
|
-
throw error
|
|
3674
|
-
}
|
|
3675
|
-
})
|
|
3676
|
-
|
|
3677
|
-
// Error handler
|
|
3678
|
-
server.onerror = (error) => {
|
|
3679
|
-
log('Server error:', error)
|
|
3680
|
-
}
|
|
3681
|
-
|
|
3682
|
-
// Connect to stdio transport
|
|
3683
|
-
const transport = new StdioServerTransport()
|
|
3684
|
-
await server.connect(transport)
|
|
3685
|
-
|
|
3686
|
-
log('MCP server ready and listening on stdio')
|
|
3687
|
-
}
|
|
3688
|
-
|
|
3689
|
-
// Run
|
|
3690
|
-
main().catch((error) => {
|
|
3691
|
-
log('Fatal error:', error)
|
|
3692
|
-
process.exit(1)
|
|
3693
|
-
})
|