backend-manager 5.8.2 → 5.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -211,6 +211,8 @@ async function generateStructure({ sources, brand, newsletterConfig, ai, assista
211
211
  function buildClassicSystemPrompt(brand, config) {
212
212
  const tone = config?.tone || 'professional';
213
213
  const instructions = config?.instructions || '';
214
+ const links = config?.links || [];
215
+ const keywords = config?.keywords || [];
214
216
  const taglineLine = brand?.tagline ? `\nTagline: ${brand.tagline}` : '';
215
217
  const descriptionLine = brand?.description ? `\nDescription: ${brand.description}` : '';
216
218
 
@@ -218,6 +220,8 @@ function buildClassicSystemPrompt(brand, config) {
218
220
  `You are a newsletter writer for ${brand?.name || 'a tech company'}.${taglineLine}${descriptionLine}`,
219
221
  instructions ? `\nBrand instructions:\n${instructions}` : '',
220
222
  `\nTone: ${tone}`,
223
+ keywords.length ? `\nKeywords to weave in naturally: ${keywords.join(', ')}` : '',
224
+ links.length ? `\nBrand links to incorporate where relevant: ${links.join(', ')}` : '',
221
225
  '',
222
226
  'You will be given a set of "source articles" — these are background research, NOT publications you are writing for or about.',
223
227
  'Treat them as raw information. Synthesize the IDEAS into original content written as if you are the original author.',
@@ -246,7 +250,7 @@ function buildClassicSystemPrompt(brand, config) {
246
250
  '- 3-5 sections — each is ONE topic, rewritten in your voice as original content',
247
251
  '- Each section: title (compelling, scannable), body (80-150 words, markdown OK)',
248
252
  '- Each section: image_prompt — one-sentence visual description for an illustrator. Be specific about subject/style.',
249
- '- Do NOT include CTAs, "read more" links, or any URLs in section bodies. The newsletter is a self-contained readnever invent links or send readers off-property.',
253
+ '- Do NOT invent URLs or "read more" links. The newsletter is a self-contained read. The ONLY URLs allowed in section bodies are the brand links listed above (if any) weave them in naturally where relevant, do not force them.',
250
254
  `- Signoff: a SHORT human sign-off, formatted as two lines with \\n between them. First line is a closing phrase like "Best,", "Cheers,", "Until next week,", or "Stay sharp,". Second line is the team name like "The ${brand?.name || 'Team'} Team". Example: "Best,\\nThe ${brand?.name || 'Team'} Team". Do NOT write a summary, tagline, motto, or thematic conclusion sentence — this is the literal way you sign off the email, like the end of a letter.`,
251
255
  '- citations: array of { note, source } for any hard data referenced. Empty array if none.',
252
256
  '',
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Pipeline:
9
9
  * 1. Read newsletter categories from Manager.config.marketing.newsletter.content.categories
10
- * 2. Fetch ready sources from parent server (atomic claim via claimFor=brandId)
10
+ * 2. Fetch ready sources from parent server (no claiming child tracks locally)
11
11
  * 3. structure.js → AI authors subject, preheader, intro, sections, signoff
12
12
  * 4. image-illustrator.js → AI generates one flat-vector PNG per section via
13
13
  * gpt-image-2 (default). Set content.method.image = 'svg' to use the legacy
@@ -41,6 +41,7 @@ const { renderMarkdown } = require('./lib/markdown-renderer.js');
41
41
  const { uploadAssets, RAW_BASE } = require('./lib/image-host.js');
42
42
  const { buildPublicConfig } = require('../../../routes/brand/get.js');
43
43
  const { writeArticle, publishArticle } = require('../../../libraries/content/ghostii.js');
44
+ const { trackContentSource, contentSourceHash } = require('../../../events/cron/daily/blog-auto-publisher.js');
44
45
 
45
46
  /**
46
47
  * Generate newsletter content from parent server sources.
@@ -69,38 +70,52 @@ const { writeArticle, publishArticle } = require('../../../libraries/content/gho
69
70
  */
70
71
  async function generate(Manager, assistant, settings, opts = {}) {
71
72
  const newsletterRoleConfig = Manager.config?.marketing?.newsletter;
72
- const config = newsletterRoleConfig?.content;
73
+ const rawContent = newsletterRoleConfig?.content;
73
74
 
74
75
  if (!newsletterRoleConfig?.enabled) {
75
76
  assistant.log('Newsletter generator: newsletter disabled in config');
76
77
  return null;
77
78
  }
78
79
 
79
- if (!config) {
80
+ if (!rawContent) {
80
81
  assistant.log('Newsletter generator: no marketing.newsletter.content config block');
81
82
  return null;
82
83
  }
83
84
 
84
- // Either use pre-fetched sources (iteration test) or fetch from parent
85
+ // Content can be an array or single object (matches blog config shape)
86
+ const contentArray = powertools.arrayify(rawContent);
87
+ const config = contentArray[0];
88
+
89
+ if (!config) {
90
+ assistant.log('Newsletter generator: empty content array');
91
+ return null;
92
+ }
93
+
94
+ // Default sources to ['$parent'] if not specified
95
+ const contentSources = config.sources || ['$parent'];
96
+
97
+ // Either use pre-fetched sources (iteration test) or resolve from config
85
98
  let sources = opts.sources;
86
99
 
87
100
  if (!sources) {
88
- const categories = config.categories || [];
101
+ // For now, newsletter resolves $parent sources via the parent server
102
+ if (contentSources.includes('$parent')) {
103
+ const categories = config.categories || [];
89
104
 
90
- if (!categories.length) {
91
- assistant.log('Newsletter generator: no categories configured');
92
- return null;
93
- }
105
+ if (!categories.length) {
106
+ assistant.log('Newsletter generator: no categories configured');
107
+ return null;
108
+ }
94
109
 
95
- const parentUrl = Manager.getParentApiUrl();
110
+ const parentUrl = Manager.getParentApiUrl();
96
111
 
97
- if (!parentUrl) {
98
- assistant.log('Newsletter generator: no parent URL configured');
99
- return null;
100
- }
112
+ if (!parentUrl) {
113
+ assistant.log('Newsletter generator: no parent URL configured');
114
+ return null;
115
+ }
101
116
 
102
- const brandId = Manager.config?.brand?.id;
103
- sources = await fetchSources(parentUrl, categories, brandId, assistant);
117
+ sources = await fetchSources(parentUrl, categories, assistant);
118
+ }
104
119
  }
105
120
 
106
121
  if (!sources?.length) {
@@ -382,12 +397,20 @@ async function generate(Manager, assistant, settings, opts = {}) {
382
397
  });
383
398
  }
384
399
 
385
- // 4. Mark sources as used on parent server (unless caller opted out)
386
- if (!opts.skipClaim) {
387
- const parentUrl = Manager.getParentApiUrl();
388
-
389
- if (parentUrl) {
390
- await claimSources(parentUrl, sources, brand?.id, assistant);
400
+ // 4. Track all sources in the unified content-sources collection
401
+ const admin = Manager.libraries?.admin;
402
+ if (admin) {
403
+ for (const source of sources) {
404
+ await trackContentSource(admin, {
405
+ url: source.url || source.id,
406
+ origin: '$parent',
407
+ itemId: source.id,
408
+ itemTitle: source.title || '',
409
+ usedBy: 'newsletter',
410
+ brandId: brand?.id || '',
411
+ }).catch((e) => {
412
+ assistant.error(`Newsletter generator: Error tracking content source (non-fatal): ${e.message}`);
413
+ });
391
414
  }
392
415
  }
393
416
 
@@ -675,7 +698,7 @@ function aggregateTotals(filterMeta, structureMeta, images) {
675
698
  /**
676
699
  * Fetch ready newsletter sources from the parent server.
677
700
  */
678
- async function fetchSources(parentUrl, categories, brandId, assistant) {
701
+ async function fetchSources(parentUrl, categories, assistant) {
679
702
  const allSources = [];
680
703
 
681
704
  for (const category of categories) {
@@ -687,7 +710,6 @@ async function fetchSources(parentUrl, categories, brandId, assistant) {
687
710
  query: {
688
711
  category,
689
712
  limit: 3,
690
- claimFor: brandId,
691
713
  backendManagerKey: process.env.BACKEND_MANAGER_KEY,
692
714
  },
693
715
  });
@@ -703,27 +725,6 @@ async function fetchSources(parentUrl, categories, brandId, assistant) {
703
725
  return allSources;
704
726
  }
705
727
 
706
- /**
707
- * Mark sources as used on the parent server.
708
- */
709
- async function claimSources(parentUrl, sources, brandId, assistant) {
710
- for (const source of sources) {
711
- try {
712
- await fetch(`${parentUrl}/newsletter-sources`, {
713
- method: 'put',
714
- response: 'json',
715
- timeout: 60000,
716
- body: {
717
- id: source.id,
718
- usedBy: brandId || 'unknown',
719
- backendManagerKey: process.env.BACKEND_MANAGER_KEY,
720
- },
721
- });
722
- } catch (e) {
723
- assistant.error(`Newsletter generator: Failed to claim source ${source.id}: ${e.message}`);
724
- }
725
- }
726
- }
727
728
 
728
729
  /**
729
730
  * Generate a 20-character Firebase push ID (RTDB-style).
@@ -776,4 +777,4 @@ function generatePushId() {
776
777
  return id;
777
778
  }
778
779
 
779
- module.exports = { generate, fetchSources, claimSources, generatePushId };
780
+ module.exports = { generate, fetchSources, generatePushId };
@@ -10,7 +10,7 @@ module.exports = async ({ assistant, Manager }) => {
10
10
 
11
11
  /**
12
12
  * Build a public-safe config object from Manager.config
13
- * Excludes sensitive fields: sentry, analytics, ghostii, etc.
13
+ * Excludes sensitive fields: sentry, analytics, blog, etc.
14
14
  */
15
15
  function buildPublicConfig(config) {
16
16
  return {
@@ -146,41 +146,29 @@
146
146
  enabled: false,
147
147
  platform: 'beehiiv',
148
148
  publicationId: '', // Beehiiv publication ID (e.g., 'pub_xxxxx'). Populated by OMEGA's beehiiv/ensure/publication.js. Required for marketing sync to Beehiiv.
149
- content: {
150
- categories: [], // e.g., ['social-media', 'marketing'] — content categories to pull from parent server
151
- // AI-customization fields below are all optional with sensible defaults
152
- instructions: '', // free-form text passed to the AI ("focus on X", "avoid Y", brand voice notes)
153
- tone: 'professional', // 'professional', 'casual', 'actionable', 'witty', etc. passed to AI prompt
154
- template: 'clean', // 'clean' | 'editorial' | 'field-report' — layout template (each owns its own content shape and aesthetic)
155
- article: {
156
- enabled: false, // when true, the newsletter's lead section is expanded into a full blog post via Ghostii, published to the website repo, and linked from the newsletter with a "Read the full article" CTA
157
- author: null, // author slug for the linked article (Ghostii/admin-post picks a default if unset)
158
- },
159
- theme: {
160
- primaryColor: '#5B5BFF', // accent color: buttons, links, brand text
161
- secondaryColor: '#1E1E2A', // body text color
162
- accentColor: '#F6F7FB', // page background outside the 600px body
163
- font: 'Inter, system-ui, sans-serif',
164
- },
165
- // AI provider defaults are hard-coded in the library (openai for structure,
166
- // anthropic for SVG — each chosen for what each model does best). Override
167
- // per-run only via env: NEWSLETTER_PROVIDER_STRUCTURE / NEWSLETTER_PROVIDER_SVG.
168
- // Brand-owned sponsorship/promo slots — rendered as "sponsored" blocks inline
169
- // with the newsletter. Use for promoting your own products, affiliate offers,
170
- // or paid placements. Each entry can be positioned at 'top', 'middle', or 'end'.
171
- // Per-campaign overrides via settings.sponsorships on a marketing-campaigns doc.
172
- sponsorships: [
173
- // {
174
- // label: 'Sponsored', // optional eyebrow label (defaults to "Sponsored")
175
- // headline: 'Grow your audience faster', // bold headline
176
- // body: 'Short pitch text…', // 1-2 sentence body
177
- // url: 'https://somiibo.com/promo', // click destination
178
- // image: 'https://...png', // optional image URL
179
- // ctaLabel: 'Try Somiibo', // button text (default: "Learn more")
180
- // position: 'middle', // 'top' | 'middle' | 'end' (default: 'middle')
181
- // },
182
- ],
183
- },
149
+ // Content: single object or array (matches blog config shape). Generator uses the first entry.
150
+ content: [
151
+ {
152
+ sources: ['$parent'], // '$parent' | '$feed:https://...' | '$brand'
153
+ categories: [], // e.g., ['social-media', 'marketing'] topic constraints + AI categorization
154
+ links: [], // links to inject into generated content
155
+ instructions: '', // free-form text passed to the AI
156
+ tone: 'professional', // 'professional' | 'casual' | 'actionable' | 'witty' | etc.
157
+ keywords: [], // SEO keywords to weave in
158
+ template: 'clean', // 'clean' | 'editorial' | 'field-report' — layout template
159
+ article: {
160
+ enabled: false, // expand lead section into a full blog post via Ghostii
161
+ author: null,
162
+ },
163
+ theme: {
164
+ primaryColor: '#5B5BFF',
165
+ secondaryColor: '#1E1E2A',
166
+ accentColor: '#F6F7FB',
167
+ font: 'Inter, system-ui, sans-serif',
168
+ },
169
+ sponsorships: [],
170
+ },
171
+ ],
184
172
  },
185
173
  prune: {
186
174
  enabled: true,
@@ -195,48 +183,56 @@
195
183
  appId: '1:123:web:456',
196
184
  measurementId: 'G-0123456789',
197
185
  },
198
- // Standalone Ghostii article publisher (daily cron). OPT-IN: disabled by
199
- // default (articles: 0). Set articles >= 1 to auto-publish independent blog
200
- // posts. For newsletter-linked articles, use marketing.newsletter.content.article.enabled instead.
186
+ // Standalone blog auto-publisher (daily cron). OPT-IN: disabled by default.
187
+ // Set enabled: true and quantity >= 1 to auto-publish independent blog posts.
188
+ // For newsletter-linked articles, use marketing.newsletter.content.article.enabled instead.
189
+ //
190
+ // `platform` selects the article-generation provider (only 'ghostii' for now).
201
191
  //
202
192
  // Source types:
203
- // '$app' — generic brand-topic generation
193
+ // '$brand' — generic brand-topic generation
204
194
  // '$feed:https://example.com/feed' — RSS/Atom/JSON feed: pick one unprocessed article per run
195
+ // '$parent' — fetch sources from parent server's source pool (without claiming)
205
196
  // 'https://example.com/page' — fetch URL content as prompt seed
206
197
  // 'Write about topic X' — text: use directly as prompt seed
207
198
  //
208
- // Feed items are tracked in Firestore (ghostii-feed-items) so the same article
209
- // is never reprocessed. When a feed is unreachable or exhausted, falls back to $app.
210
- ghostii: [
211
- {
212
- articles: 0,
213
- sources: [
214
- '$app',
215
- // '$feed:https://techcrunch.com/feed/',
216
- // '$feed:https://feeds.arstechnica.com/arstechnica/technology-lab',
217
- ],
218
- links: [
219
- // ...
220
- ],
221
- prompt: '',
222
- chance: 1.0,
223
- author: null,
224
- postPath: 'ghostii',
225
- // Per-entry Ghostii API overrides (all optional, fall back to framework defaults)
226
- overrides: {
227
- // keywords: ['AI', 'technology'],
228
- // length: 'long', // short | medium | long | comprehensive
229
- // research: true, // web search for real external links
230
- // insertImages: true, // section images from Unsplash
231
- // headerImageUrl: 'unsplash', // disabled | unsplash | generate
232
- // maxLinks: 6,
233
- // sectionQuantity: 5,
234
- // feedUrl: '', // brand blog feed for title dedup
199
+ // All feed/parent sources are tracked in Firestore (`content-sources`) so the same
200
+ // article is never reprocessed. When a feed is unreachable or exhausted, falls back to $brand.
201
+ blog: {
202
+ enabled: false,
203
+ platform: 'ghostii',
204
+ content: [
205
+ {
206
+ sources: [ // '$brand' | '$feed:https://...' | '$parent'
207
+ '$brand',
208
+ // '$feed:https://techcrunch.com/feed/',
209
+ // '$parent',
210
+ ],
211
+ categories: [], // topic constraints + output tags
212
+ links: [], // links to inject into generated content
213
+ instructions: '', // free-form text passed to the AI
214
+ tone: 'professional', // 'professional' | 'casual' | 'actionable' | 'witty' | etc.
215
+ keywords: [], // SEO keywords to weave in
216
+ quantity: 0, // number of articles to generate per run
217
+ chance: 1.0,
218
+ author: null,
219
+ postPath: 'ghostii', // defaults to platform name; sub-folder under _posts/{year}/
220
+ // Per-entry Ghostii API overrides (all optional, fall back to framework defaults)
221
+ overrides: {
222
+ // keywords: ['AI', 'technology'],
223
+ // length: 'long', // short | medium | long | comprehensive
224
+ // research: true, // web search for real external links
225
+ // insertImages: true, // section images from Unsplash
226
+ // headerImageUrl: 'unsplash', // disabled | unsplash | generate
227
+ // maxLinks: 6,
228
+ // sectionQuantity: 5,
229
+ // feedUrl: '', // brand blog feed for title dedup
230
+ },
231
+ // brand: 'other-app-id', // Optional: target a different brand
232
+ // brandUrl: 'https://api.otherapp.com', // Required if brand is set
235
233
  },
236
- // brand: 'other-app-id', // Optional: target a different brand
237
- // brandUrl: 'https://api.otherapp.com', // Required if brand is set
238
- }
239
- ],
234
+ ],
235
+ },
240
236
  reviews: {
241
237
  enabled: true,
242
238
  sites: [