fraim-framework 2.0.30 → 2.0.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/src/cli/commands/init.js +29 -2
  2. package/dist/src/cli/commands/sync.js +18 -1
  3. package/dist/src/utils/script-sync-utils.js +218 -0
  4. package/dist/tests/debug-tools.js +6 -5
  5. package/dist/tests/test-chalk-regression.js +58 -8
  6. package/dist/tests/test-cli.js +70 -5
  7. package/dist/tests/test-end-to-end-hybrid-validation.js +349 -0
  8. package/dist/tests/test-first-run-journey.js +43 -3
  9. package/dist/tests/test-hybrid-script-execution.js +369 -0
  10. package/dist/tests/test-mcp-connection.js +2 -2
  11. package/dist/tests/test-mcp-issue-integration.js +12 -4
  12. package/dist/tests/test-mcp-lifecycle-methods.js +4 -4
  13. package/dist/tests/test-node-compatibility.js +24 -2
  14. package/dist/tests/test-prep-issue.js +4 -1
  15. package/dist/tests/test-script-location-independence.js +173 -0
  16. package/dist/tests/test-script-sync.js +557 -0
  17. package/dist/tests/test-session-rehydration.js +2 -2
  18. package/dist/tests/test-standalone.js +3 -3
  19. package/dist/tests/test-sync-version-update.js +1 -1
  20. package/dist/tests/test-telemetry.js +2 -2
  21. package/dist/tests/test-user-journey.js +8 -4
  22. package/dist/tests/test-utils.js +13 -0
  23. package/dist/tests/test-wizard.js +2 -2
  24. package/package.json +3 -3
  25. package/registry/rules/agent-testing-guidelines.md +502 -502
  26. package/registry/rules/ephemeral-execution.md +37 -27
  27. package/registry/rules/local-development.md +253 -251
  28. package/registry/rules/successful-debugging-patterns.md +491 -482
  29. package/registry/scripts/prep-issue.sh +468 -468
  30. package/registry/workflows/bootstrap/evaluate-code-quality.md +8 -2
  31. package/registry/workflows/bootstrap/verify-test-coverage.md +8 -2
  32. package/registry/workflows/customer-development/thank-customers.md +203 -193
  33. package/registry/workflows/customer-development/weekly-newsletter.md +366 -362
  34. package/registry/workflows/performance/analyze-performance.md +65 -63
  35. package/registry/workflows/product-building/implement.md +6 -2
  36. package/registry/workflows/product-building/prep-issue.md +11 -24
  37. package/registry/workflows/product-building/resolve.md +5 -1
  38. package/registry/workflows/replicate/replicate-discovery.md +336 -0
  39. package/registry/workflows/replicate/replicate-to-issues.md +319 -0
  40. package/registry/workflows/reviewer/review-implementation-vs-design-spec.md +632 -632
  41. package/.windsurf/rules/windsurf-rules.md +0 -7
  42. package/.windsurf/workflows/resolve-issue.md +0 -6
  43. package/.windsurf/workflows/retrospect.md +0 -6
  44. package/.windsurf/workflows/start-design.md +0 -6
  45. package/.windsurf/workflows/start-impl.md +0 -6
  46. package/.windsurf/workflows/start-spec.md +0 -6
  47. package/.windsurf/workflows/start-tests.md +0 -6
  48. package/bin/fraim.js +0 -23
  49. package/registry/scripts/build-scripts-generator.ts +0 -216
  50. package/registry/scripts/cleanup-branch.ts +0 -303
  51. package/registry/scripts/fraim-config.ts +0 -63
  52. package/registry/scripts/generate-engagement-emails.ts +0 -744
  53. package/registry/scripts/generic-issues-api.ts +0 -110
  54. package/registry/scripts/newsletter-helpers.ts +0 -874
  55. package/registry/scripts/openapi-generator.ts +0 -695
  56. package/registry/scripts/performance/profile-server.ts +0 -370
  57. package/registry/scripts/run-thank-you-workflow.ts +0 -122
  58. package/registry/scripts/send-newsletter-simple.ts +0 -104
  59. package/registry/scripts/send-thank-you-emails.ts +0 -57
  60. package/registry/workflows/replicate/re-implementation-strategy.md +0 -226
  61. package/registry/workflows/replicate/use-case-extraction.md +0 -135
  62. package/registry/workflows/replicate/visual-analysis.md +0 -154
  63. package/registry/workflows/replicate/website-discovery-analysis.md +0 -231
  64. package/sample_package.json +0 -18
  65. /package/registry/scripts/{replicate/comprehensive-explorer.py → comprehensive-explorer.py} +0 -0
  66. /package/registry/scripts/{replicate/interactive-explorer.py → interactive-explorer.py} +0 -0
  67. /package/registry/scripts/{replicate/scrape-site.py → scrape-site.py} +0 -0
@@ -1,874 +0,0 @@
1
- #!/usr/bin/env tsx
2
-
3
- /**
4
- * Newsletter Helper Functions
5
- *
6
- * DETERMINISTIC functions for AI agents to use when creating newsletters.
7
- * AI agents do the CREATIVE work (categorization, content writing).
8
- * These functions provide TOOLS (data fetching, email sending).
9
- */
10
-
11
- import { execSync } from 'child_process';
12
- import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'fs';
13
- import { MongoClient } from 'mongodb';
14
- import { join } from 'path';
15
- import { determineDatabaseName, determineSchema, getCurrentGitBranch } from '../../src/utils/git-utils';
16
- import { fraimConfig } from './fraim-config';
17
-
18
- // Reuse existing email infrastructure
19
- import { findExecutiveByEmail, getPersonaEmailForExecutive } from './generate-engagement-emails';
20
-
21
- interface ResolvedIssue {
22
- number: number;
23
- title: string;
24
- body: string;
25
- closedAt: string;
26
- author: {
27
- login: string;
28
- };
29
- labels: Array<{
30
- name: string;
31
- }>;
32
- }
33
-
34
- interface Executive {
35
- id?: string;
36
- email: string;
37
- name: string;
38
- }
39
-
40
- interface PotentialCustomer {
41
- email: string;
42
- name: string;
43
- addedDate?: string;
44
- source?: string; // e.g., "linkedin", "referral", "website", etc.
45
- }
46
-
47
- interface Newsletter {
48
- metadata: {
49
- generatedOn: string;
50
- weekStart: string;
51
- weekEnd: string;
52
- totalIssues: number;
53
- [key: string]: any;
54
- };
55
- content: {
56
- weekTitle: string;
57
- [key: string]: any;
58
- };
59
- }
60
-
61
- /**
62
- * Get the latest date from existing newsletter files
63
- * Returns the most recent date when newsletter was sent, or null if no files exist
64
- */
65
- export function getLatestNewsletterDate(): string | null {
66
- const newslettersDir = 'docs/customer-development/newsletters';
67
- if (!existsSync(newslettersDir)) {
68
- return null;
69
- }
70
-
71
- const files = readdirSync(newslettersDir).filter(f => f.startsWith('newsletter-') && f.endsWith('.json'));
72
- if (files.length === 0) {
73
- return null;
74
- }
75
-
76
- // Extract dates from filenames (e.g., newsletter-2025-11-01.json)
77
- const dates: string[] = [];
78
- for (const file of files) {
79
- const dateMatch = file.match(/newsletter-(\d{4}-\d{2}-\d{2})\.json/);
80
- if (dateMatch) {
81
- const filePath = join(newslettersDir, file);
82
- // Verify content is valid if needed, but for date extraction filename is enough
83
- dates.push(dateMatch[1]);
84
- }
85
- }
86
-
87
- if (dates.length === 0) {
88
- return null;
89
- }
90
-
91
- // Return the most recent date
92
- dates.sort();
93
- const latestDate = dates[dates.length - 1];
94
- console.log(`📅 Latest newsletter date found: ${latestDate}`);
95
- return latestDate;
96
- }
97
-
98
- /**
99
- * Get resolved issues for newsletter
100
- * DETERMINISTIC: Just fetches issues, doesn't categorize or filter
101
- * AI agent decides what to include and how to present it
102
- *
103
- * @param date Optional date string (YYYY-MM-DD). If not provided, uses latest newsletter date or last 7 days
104
- */
105
- export async function getResolvedIssuesForNewsletter(date?: string): Promise<ResolvedIssue[]> {
106
- if (!date) {
107
- // Try to get the latest newsletter date
108
- const latestNewsletterDate = getLatestNewsletterDate();
109
- if (latestNewsletterDate) {
110
- date = latestNewsletterDate;
111
- console.log(`📋 Using latest newsletter date as starting point: ${date}`);
112
- } else {
113
- // Fallback to last 7 days if no previous newsletters exist
114
- const fallbackDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
115
- date = fallbackDate;
116
- console.log(`📋 No previous newsletters found, using last 7 days: ${date}`);
117
- }
118
- }
119
-
120
- console.log(`📋 Fetching issues resolved since ${date}...`);
121
-
122
- // Get all closed issues from the specified date (not just user-reported)
123
- const command = `gh search issues --repo=${fraimConfig.repoOwner}/${fraimConfig.repoName} --state=closed --closed=">${date}" --json number,title,body,labels,closedAt,author --limit 100`;
124
- const output = execSync(command, { encoding: 'utf-8' });
125
- const issues: ResolvedIssue[] = JSON.parse(output);
126
-
127
- console.log(`✅ Found ${issues.length} resolved issues`);
128
-
129
- return issues;
130
- }
131
-
132
- /**
133
- * Get database name, defaulting to production
134
- */
135
- function getDatabaseName(): string {
136
- const env = process.env.NODE_ENV || process.env.ENVIRONMENT;
137
- // If explicitly set to ppe/staging, use that; otherwise default to prod
138
- if (env === 'ppe' || env === 'staging') {
139
- return determineDatabaseName();
140
- }
141
- // Default to production
142
- return process.env.MONGO_DB_NAME || 'fraim_prod';
143
- }
144
-
145
- /**
146
- * Get collection name with schema prefix (defaults to prod schema)
147
- */
148
- function getCollectionName(baseName: string): string {
149
- const env = process.env.NODE_ENV || process.env.ENVIRONMENT;
150
- // If explicitly set to ppe/staging, use that schema; otherwise default to prod
151
- if (env === 'ppe' || env === 'staging') {
152
- const schema = determineSchema(getCurrentGitBranch());
153
- return `${schema}_${baseName}`;
154
- }
155
- // Default to prod schema
156
- return `prod_${baseName}`;
157
- }
158
-
159
- /**
160
- * Get all active executives (defaults to production database)
161
- * DETERMINISTIC: Just fetches executive list
162
- */
163
- export async function getAllActiveExecutives(): Promise<Executive[]> {
164
- const mongoUrl = process.env.PROD_MONGO_DATABASE_URL || process.env.MONGO_DATABASE_URL;
165
- if (!mongoUrl) {
166
- throw new Error('PROD_MONGO_DATABASE_URL or MONGO_DATABASE_URL environment variable is required');
167
- }
168
-
169
- const client = new MongoClient(mongoUrl);
170
- try {
171
- await client.connect();
172
- const dbName = getDatabaseName();
173
- const collectionName = getCollectionName('Executive');
174
-
175
- console.log(`[getAllActiveExecutives] Querying database: ${dbName}, collection: ${collectionName}`);
176
-
177
- const db = client.db(dbName);
178
- const executives = await db.collection(collectionName)
179
- .find({})
180
- .toArray();
181
-
182
- console.log(`[getAllActiveExecutives] Found ${executives.length} executives in ${dbName}.${collectionName}`);
183
-
184
- // If no executives found in prod, check PPE as fallback
185
- if (executives.length === 0 && dbName === 'fraim_prod') {
186
- console.log(`[getAllActiveExecutives] No executives in prod, checking PPE as fallback...`);
187
- const ppeDb = client.db('fraim_ppe');
188
- const ppeExecutives = await ppeDb.collection('ppe_Executive')
189
- .find({})
190
- .toArray();
191
- console.log(`[getAllActiveExecutives] Found ${ppeExecutives.length} executives in fraim_ppe.ppe_Executive`);
192
- return ppeExecutives as unknown as Executive[];
193
- }
194
-
195
- return executives as unknown as Executive[];
196
- } finally {
197
- await client.close();
198
- }
199
- }
200
-
201
- /**
202
- * Get all potential customers (leads who have expressed interest)
203
- * DETERMINISTIC: Just fetches potential customer list from JSON file
204
- */
205
- export function getPotentialCustomers(): PotentialCustomer[] {
206
- const potentialCustomersPath = join(process.cwd(), 'docs', 'customer-development', 'potential-customers.json');
207
-
208
- if (!existsSync(potentialCustomersPath)) {
209
- return [];
210
- }
211
-
212
- try {
213
- const content = readFileSync(potentialCustomersPath, 'utf-8');
214
- return JSON.parse(content) as PotentialCustomer[];
215
- } catch (error) {
216
- console.error('❌ Error reading potential customers file:', error);
217
- return [];
218
- }
219
- }
220
-
221
- /**
222
- * Get default Persona email from database (defaults to production)
223
- * Extracts the base email (part before the +) from existing executive Persona emails
224
- * DETERMINISTIC: Reads from database, extracts pattern
225
- */
226
- async function getDefaultPersonaEmailFromDatabase(): Promise<string | null> {
227
- const mongoUrl = process.env.PROD_MONGO_DATABASE_URL || process.env.MONGO_DATABASE_URL;
228
- if (!mongoUrl) {
229
- return null;
230
- }
231
-
232
- const client = new MongoClient(mongoUrl);
233
- try {
234
- await client.connect();
235
- const dbName = getDatabaseName();
236
- const db = client.db(dbName);
237
- const collectionName = getCollectionName(fraimConfig.identityCollection);
238
-
239
- // Get any active Persona identity from the database (defaults to prod)
240
- let identity = await db.collection(collectionName)
241
- .findOne({ status: 'active' }) as { email?: string } | null;
242
-
243
- // Try any identity if no active one found or no email
244
- if (!identity || !identity.email) {
245
- identity = await db.collection(collectionName)
246
- .findOne({ email: { $exists: true } }) as { email?: string } | null;
247
- }
248
-
249
- if (!identity || !identity.email) {
250
- return null;
251
- }
252
-
253
- const email = identity.email;
254
-
255
- // Extract base email: if email is "persona+username@domain.com", extract "persona@domain.com"
256
- if (email.includes('+')) {
257
- const [localPart, rest] = email.split('+');
258
- const domain = rest.split('@')[1];
259
- return `${localPart}@${domain}`;
260
- }
261
-
262
- // If no plus sign, return as-is (it's already the base email)
263
- return email;
264
- } catch (error) {
265
- console.warn(`⚠️ Could not get default ${fraimConfig.personaName} email from database:`, error);
266
- return null;
267
- } finally {
268
- await client.close();
269
- }
270
- }
271
-
272
- /**
273
- * Generate HTML from newsletter JSON
274
- * DETERMINISTIC: Just applies template, no creative decisions
275
- */
276
- export function generateNewsletterHTML(newsletter: Newsletter): string {
277
- const templatePath = join(process.cwd(), 'registry', 'templates', 'customer-development', 'weekly-newsletter-template.html');
278
- let html = readFileSync(templatePath, 'utf-8');
279
-
280
- const { content } = newsletter;
281
-
282
- // Replace simple variables
283
- html = html.replace(/\{\{weekTitle\}\}/g, escapeHtml(content.weekTitle));
284
- html = html.replace(/\{\{weekSubtitle\}\}/g, escapeHtml(content.weekSubtitle));
285
- html = html.replace(/\{\{weekDate\}\}/g, escapeHtml(content.weekDate));
286
- html = html.replace(/\{\{personaName\}\}/g, escapeHtml(fraimConfig.personaName));
287
- html = html.replace(/\{\{chatUrl\}\}/g, escapeHtml(fraimConfig.chatUrl || '#'));
288
- html = html.replace(/\{\{webAppUrl\}\}/g, escapeHtml(fraimConfig.webAppUrl || '#'));
289
- html = html.replace(/\{\{openingMessage\}\}/g, escapeHtml(content.openingMessage));
290
- html = html.replace(/\{\{ctaLink\}\}/g, content.ctaLink || (fraimConfig.webAppUrl ? `${fraimConfig.webAppUrl}/wellness/${fraimConfig.personaName.toLowerCase()}` : '#'));
291
- html = html.replace(/\{\{ctaText\}\}/g, escapeHtml(content.ctaText || `Get ${fraimConfig.personaName} Now`));
292
- html = html.replace(/\{\{unsubscribeLink\}\}/g, '#');
293
-
294
- // Custom header text - check if this is the first newsletter
295
- if (content.weekDate && (content.weekDate.toLowerCase().includes('month of november') || content.weekDate.toLowerCase().includes('month of october'))) {
296
- html = html.replace(/🚀 Weekly Update/g, `🎉 ${fraimConfig.personaName}'s First Update`);
297
- html = html.replace(/📅 Week of/g, "📅");
298
- html = html.replace(/Coming Next Week/g, "Coming Next Month");
299
- }
300
-
301
- // Stats
302
- html = html.replace(/\{\{statsFeatures\}\}/g, (newsletter.metadata.featuresCount || 0).toString());
303
- html = html.replace(/\{\{statsImprovements\}\}/g, (newsletter.metadata.improvementsCount || 0).toString());
304
- html = html.replace(/\{\{statsBugFixes\}\}/g, (newsletter.metadata.bugFixesCount || 0).toString());
305
-
306
- // Hero feature(s) - support both single heroFeature and array of heroFeatures
307
- const heroFeatures = content.heroFeatures || (content.heroFeature ? [content.heroFeature] : []);
308
- if (heroFeatures.length > 0) {
309
- // Generate HTML for all hero features, wrapped in proper table structure
310
- const heroFeaturesHTML = heroFeatures.map((hero: any, index: number) => {
311
- const badge = heroFeatures.length > 1 ? `HERO FEATURE ${index + 1}` : 'HERO FEATURE';
312
- const marginBottom = index < heroFeatures.length - 1 ? '40px' : '0';
313
- return `
314
- <tr>
315
- <td style="padding: 0 40px ${marginBottom} 40px;">
316
- <div style="background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%); border: 2px solid #667eea; border-radius: 12px; padding: 35px; position: relative; overflow: hidden;">
317
- <div style="position: absolute; top: -10px; right: -10px; background: #ff6b6b; color: white; padding: 8px 20px; border-radius: 20px; font-size: 12px; font-weight: 700; letter-spacing: 1px; transform: rotate(12deg); box-shadow: 0 4px 12px rgba(255, 107, 107, 0.4);">
318
- ⭐ ${badge}
319
- </div>
320
- <div style="font-size: 14px; color: #667eea; font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 12px;">🎯 ${heroFeatures.length > 1 ? `Feature ${index + 1} of the Week` : 'Feature of the Week'}</div>
321
- <div style="font-size: 28px; font-weight: 800; color: #212529; margin-bottom: 16px; line-height: 1.3;">${escapeHtml(hero.title)}</div>
322
- <div style="font-size: 16px; color: #495057; line-height: 1.8; margin-bottom: ${hero.impact ? '20px' : '0'};">${escapeHtml(hero.description)}</div>
323
- ${hero.impact ? `
324
- <div style="background: white; border-left: 4px solid #28a745; padding: 16px 20px; border-radius: 8px; margin-top: 20px;">
325
- <div style="font-size: 14px; font-weight: 700; color: #28a745; margin-bottom: 8px;">💚 Real Impact:</div>
326
- <div style="font-size: 15px; color: #495057; line-height: 1.7;">${escapeHtml(hero.impact)}</div>
327
- </div>
328
- ` : ''}
329
- </div>
330
- </td>
331
- </tr>
332
- `;
333
- }).join('');
334
-
335
- html = html.replace(/\{\{heroFeatureTitle\}\}/g, escapeHtml(heroFeatures[0].title));
336
- html = html.replace(/\{\{heroFeatureDescription\}\}/g, escapeHtml(heroFeatures[0].description));
337
- html = html.replace(/\{\{heroFeatureImpact\}\}/g, escapeHtml(heroFeatures[0].impact || ''));
338
- html = html.replace(/\{\{#if hasHeroFeature\}\}([\s\S]*?)\{\{\/if\}\}/, heroFeaturesHTML);
339
- html = html.replace(/\{\{#if heroFeatureImpact\}\}([\s\S]*?)\{\{\/if\}\}/, heroFeatures[0].impact ? '$1' : '');
340
- } else {
341
- html = html.replace(/\{\{#if hasHeroFeature\}\}[\s\S]*?\{\{\/if\}\}/, '');
342
- }
343
-
344
- // New features list
345
- if (content.newFeatures && content.newFeatures.length > 0) {
346
- const featuresHTML = content.newFeatures.map((feature: any, index: number) => `
347
- <div style="margin-bottom: ${index < content.newFeatures.length - 1 ? '24px' : '0'}; padding: 24px; background: white; border-radius: 10px; border: 2px solid #e9ecef; box-shadow: 0 2px 8px rgba(0,0,0,0.04);">
348
- <div style="font-size: 19px; font-weight: 700; color: #212529; margin-bottom: 10px;">✨ ${escapeHtml(feature.title)}</div>
349
- <div style="font-size: 15px; color: #495057; line-height: 1.7;">${escapeHtml(feature.description)}</div>
350
- ${feature.impact ? `<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #e9ecef; font-size: 14px; color: #667eea; font-weight: 600;">💡 ${escapeHtml(feature.impact)}</div>` : ''}
351
- </div>
352
- `).join('');
353
- html = html.replace(/\{\{newFeaturesList\}\}/g, featuresHTML);
354
- html = html.replace(/\{\{#if hasNewFeatures\}\}([\s\S]*?)\{\{\/if\}\}/, '$1');
355
- } else {
356
- html = html.replace(/\{\{#if hasNewFeatures\}\}[\s\S]*?\{\{\/if\}\}/, '');
357
- }
358
-
359
- // Improvements list
360
- if (content.improvements && content.improvements.length > 0) {
361
- const improvementsHTML = content.improvements.map((improvement: any, index: number) => `
362
- <div style="margin-bottom: ${index < content.improvements.length - 1 ? '20px' : '0'}; padding: 20px; background: #f8f9fa; border-radius: 8px; border-left: 4px solid #28a745;">
363
- <div style="font-size: 17px; font-weight: 700; color: #212529; margin-bottom: 8px;">🚀 ${escapeHtml(improvement.title)}</div>
364
- <div style="font-size: 15px; color: #495057; line-height: 1.7;">${escapeHtml(improvement.description)}</div>
365
- </div>
366
- `).join('');
367
- html = html.replace(/\{\{improvementsList\}\}/g, improvementsHTML);
368
- html = html.replace(/\{\{#if hasImprovements\}\}([\s\S]*?)\{\{\/if\}\}/, '$1');
369
- } else {
370
- html = html.replace(/\{\{#if hasImprovements\}\}[\s\S]*?\{\{\/if\}\}/, '');
371
- }
372
-
373
- // Bug fixes list
374
- if (content.bugFixes && content.bugFixes.length > 0) {
375
- const bugFixesHTML = content.bugFixes.map((fix: any, index: number) => `
376
- <div style="margin-bottom: ${index < content.bugFixes.length - 1 ? '16px' : '0'}; padding: 16px; background: #fff8f0; border-radius: 8px; border-left: 4px solid #fd7e14;">
377
- <div style="font-size: 16px; font-weight: 600; color: #212529; margin-bottom: 6px;">🔧 ${escapeHtml(fix.title)}</div>
378
- <div style="font-size: 14px; color: #495057; line-height: 1.6;">${escapeHtml(fix.description)}</div>
379
- </div>
380
- `).join('');
381
- html = html.replace(/\{\{bugFixesList\}\}/g, bugFixesHTML);
382
- html = html.replace(/\{\{#if hasBugFixes\}\}([\s\S]*?)\{\{\/if\}\}/, '$1');
383
- } else {
384
- html = html.replace(/\{\{#if hasBugFixes\}\}[\s\S]*?\{\{\/if\}\}/, '');
385
- }
386
-
387
- // Testimonial
388
- if (content.testimonial) {
389
- html = html.replace(/\{\{testimonialText\}\}/g, escapeHtml(content.testimonial.text));
390
- html = html.replace(/\{\{testimonialAuthor\}\}/g, escapeHtml(content.testimonial.author));
391
- html = html.replace(/\{\{testimonialRole\}\}/g, escapeHtml(content.testimonial.role));
392
- html = html.replace(/\{\{#if hasTestimonial\}\}([\s\S]*?)\{\{\/if\}\}/, '$1');
393
- } else {
394
- html = html.replace(/\{\{#if hasTestimonial\}\}[\s\S]*?\{\{\/if\}\}/, '');
395
- }
396
-
397
- // Coming next
398
- if (content.comingNext) {
399
- html = html.replace(/\{\{comingNextText\}\}/g, escapeHtml(content.comingNext));
400
- html = html.replace(/\{\{#if hasComingNext\}\}([\s\S]*?)\{\{\/if\}\}/, '$1');
401
- } else {
402
- html = html.replace(/\{\{#if hasComingNext\}\}[\s\S]*?\{\{\/if\}\}/, '');
403
- }
404
-
405
- // Conditional sections
406
- html = html.replace(/\{\{#if showStats\}\}([\s\S]*?)\{\{\/if\}\}/, '$1');
407
- html = html.replace(/\{\{#if showFOMO\}\}([\s\S]*?)\{\{\/if\}\}/, '$1');
408
-
409
- return html;
410
- }
411
-
412
- /**
413
- * Escape HTML special characters
414
- */
415
- function escapeHtml(text: string): string {
416
- if (!text) return '';
417
- return text
418
- .replace(/&/g, '&amp;')
419
- .replace(/</g, '&lt;')
420
- .replace(/>/g, '&gt;')
421
- .replace(/"/g, '&quot;')
422
- .replace(/'/g, '&#039;');
423
- }
424
-
425
- /**
426
- * Save newsletter JSON and HTML
427
- * DETERMINISTIC: Just writes files
428
- */
429
- export function saveNewsletter(newsletter: Newsletter, outputDir?: string): string {
430
- const dir = outputDir || join(process.cwd(), 'docs', 'customer-development', 'newsletters');
431
- if (!existsSync(dir)) {
432
- mkdirSync(dir, { recursive: true });
433
- }
434
-
435
- const dateStr = newsletter.metadata.weekEnd;
436
- const jsonPath = join(dir, `newsletter-${dateStr}.json`);
437
- const htmlPath = join(dir, `newsletter-${dateStr}.html`);
438
-
439
- // Generate HTML
440
- const html = generateNewsletterHTML(newsletter);
441
-
442
- // Save JSON
443
- writeFileSync(jsonPath, JSON.stringify(newsletter, null, 2));
444
- console.log(`✅ Saved newsletter JSON to: ${jsonPath}`);
445
-
446
- // Save HTML
447
- writeFileSync(htmlPath, html);
448
- console.log(`✅ Saved newsletter HTML to: ${htmlPath}`);
449
-
450
- return jsonPath;
451
- }
452
-
453
- /**
454
- * Send newsletter to all executives and potential customers
455
- * DETERMINISTIC: Just sends emails, no decisions
456
- *
457
- * AI agents call this AFTER user approves the newsletter JSON
458
- * @param newsletterPath Path to newsletter JSON file
459
- * @param filterEmails Optional array of email addresses to send to (for testing)
460
- * @param filterExecIds Optional array of executive IDs to send to (for testing)
461
- * @param includePotentialCustomers Whether to include potential customers in the send (default: true)
462
- * @param showOnly If true, only show recipient list without sending
463
- */
464
- export async function sendNewsletterToExecutives(
465
- newsletterPath: string,
466
- filterEmails?: string[],
467
- filterExecIds?: string[],
468
- includePotentialCustomers: boolean = true,
469
- showOnly: boolean = false,
470
- includeExecutives: boolean = true
471
- ): Promise<void> {
472
- console.log(`📧 Sending newsletter from: ${newsletterPath}`);
473
-
474
- if (!existsSync(newsletterPath)) {
475
- throw new Error(`Newsletter file not found: ${newsletterPath}`);
476
- }
477
-
478
- const newsletter: Newsletter = JSON.parse(readFileSync(newsletterPath, 'utf-8'));
479
- const htmlPath = newsletterPath.replace('.json', '.html');
480
-
481
- if (!existsSync(htmlPath)) {
482
- throw new Error(`Newsletter HTML not found: ${htmlPath}. Generate it first.`);
483
- }
484
-
485
- const html = readFileSync(htmlPath, 'utf-8');
486
- let executives: any[] = [];
487
-
488
- // Get executives only if includeExecutives is true
489
- if (includeExecutives) {
490
- executives = await getAllActiveExecutives();
491
-
492
- // Filter executives if filters are provided
493
- if (filterEmails && filterEmails.length > 0) {
494
- console.log(`🔍 Filtering to ${filterEmails.length} email(s): ${filterEmails.join(', ')}`);
495
- executives = executives.filter(exec => filterEmails.includes(exec.email));
496
- }
497
-
498
- if (filterExecIds && filterExecIds.length > 0) {
499
- console.log(`🔍 Filtering to ${filterExecIds.length} executive ID(s): ${filterExecIds.join(', ')}`);
500
- executives = executives.filter(exec => exec.id && filterExecIds.includes(exec.id));
501
- }
502
- }
503
-
504
- // Get potential customers if requested
505
- let potentialCustomers: PotentialCustomer[] = [];
506
- if (includePotentialCustomers) {
507
- potentialCustomers = getPotentialCustomers();
508
-
509
- // Filter potential customers if filterEmails is provided
510
- if (filterEmails && filterEmails.length > 0) {
511
- potentialCustomers = potentialCustomers.filter(customer => filterEmails.includes(customer.email));
512
- }
513
- }
514
-
515
- if (includeExecutives) {
516
- console.log(`📊 Found ${executives.length} executive(s) to send to`);
517
- }
518
- if (includePotentialCustomers) {
519
- console.log(`📊 Found ${potentialCustomers.length} potential customer(s) to send to`);
520
- }
521
-
522
- if (showOnly) {
523
- console.log(`\n📋 Recipient List (--showonly mode - no emails will be sent):\n`);
524
-
525
- if (includeExecutives && executives.length > 0) {
526
- console.log(`\n👥 ACTIVE EXECUTIVES (${executives.length}):\n`);
527
- for (const exec of executives) {
528
- const fromEmail = exec.id
529
- ? await getPersonaEmailForExecutive(exec.id)
530
- : fraimConfig.defaultEmail;
531
- const fromDisplayName = `${fraimConfig.personaName} - ${exec.name}'s AI Executive Assistant`;
532
-
533
- console.log(` ${exec.name || 'Unknown Name'}`);
534
- console.log(` To: ${exec.email || 'No email'}`);
535
- console.log(` From Email: ${fromEmail}`);
536
- console.log(` From Display Name: ${fromDisplayName}`);
537
- console.log(` Executive ID: ${exec.id || 'No ID'}`);
538
- console.log('');
539
- }
540
- }
541
-
542
- if (potentialCustomers.length > 0) {
543
- const defaultEmail = fraimConfig.prodDefaultEmail || 'agent@example.com';
544
- const defaultDisplayName = `${fraimConfig.personaName} - Your AI Executive Assistant`;
545
-
546
- console.log(`\n🎯 POTENTIAL CUSTOMERS (${potentialCustomers.length}):\n`);
547
- potentialCustomers.forEach((customer, index) => {
548
- console.log(` ${customer.name || 'Unknown Name'}`);
549
- console.log(` To: ${customer.email || 'No email'}`);
550
- console.log(` From Email: ${defaultEmail}`);
551
- console.log(` From Display Name: ${defaultDisplayName}`);
552
- console.log(` Source: ${customer.source || 'Unknown'}`);
553
- console.log('');
554
- });
555
- }
556
-
557
- const totalRecipients = (includeExecutives ? executives.length : 0) + (includePotentialCustomers ? potentialCustomers.length : 0);
558
- console.log(`✅ Total: ${totalRecipients} recipient(s) would receive the newsletter`);
559
- return;
560
- }
561
-
562
- console.log(`\n📧 Sending newsletter...\n`);
563
-
564
- // Send to active executives (from their personal Persona email) - only if includeExecutives is true
565
- if (includeExecutives) {
566
- for (const executive of executives) {
567
- try {
568
- console.log(`📧 Sending to executive ${executive.name} (${executive.email})...`);
569
-
570
- const fromEmail = executive.id
571
- ? await getPersonaEmailForExecutive(executive.id)
572
- : fraimConfig.defaultEmail;
573
-
574
- const fromDisplayName = `${fraimConfig.personaName} - ${executive.name}'s AI Executive Assistant`;
575
- // Remove emojis from subject line for ASCII compatibility
576
- const subject = newsletter.content.weekTitle.replace(/[^\x00-\x7F]/g, '').trim();
577
- const plainText = generatePlainText(newsletter);
578
-
579
- // Get tokens and send (reuses existing infrastructure)
580
- const personaTokens = await getPersonaTokens(executive.id);
581
- const executiveData = await findExecutiveByEmail(executive.email);
582
-
583
- await sendEmailViaGmail({
584
- to: executive.email,
585
- subject,
586
- plainTextBody: plainText,
587
- htmlBody: html,
588
- fromEmail,
589
- fromDisplayName
590
- }, executiveData, personaTokens);
591
-
592
- console.log(`✅ Sent to ${executive.email}`);
593
- } catch (error) {
594
- console.error(`❌ Failed to send to ${executive.email}:`, error);
595
- }
596
- }
597
- }
598
-
599
- // Send to potential customers (from PROD_FRAIM_DEFAULT_EMAIL)
600
- const defaultEmail = fraimConfig.prodDefaultEmail || 'agent@example.com';
601
- const defaultDisplayName = `${fraimConfig.personaName} - Your AI Executive Assistant`;
602
- const defaultAccessToken = fraimConfig.prodAccessToken || '';
603
- const defaultRefreshToken = fraimConfig.prodRefreshToken || '';
604
-
605
- if (!defaultAccessToken || !defaultRefreshToken) {
606
- console.warn('⚠️ PROD_FRAIM_DEFAULT_ACCESS_TOKEN or PROD_FRAIM_DEFAULT_REFRESH_TOKEN not set. Skipping potential customers.');
607
- } else {
608
- for (const customer of potentialCustomers) {
609
- try {
610
- console.log(`📧 Sending to potential customer ${customer.name} (${customer.email})...`);
611
-
612
- // Remove emojis from subject line for ASCII compatibility
613
- const subject = newsletter.content.weekTitle.replace(/[^\x00-\x7F]/g, '').trim();
614
- const plainText = generatePlainText(newsletter);
615
-
616
- // Send from default Persona email with default tokens
617
- await sendEmailViaGmail({
618
- to: customer.email,
619
- subject,
620
- plainTextBody: plainText,
621
- htmlBody: html,
622
- fromEmail: defaultEmail,
623
- fromDisplayName: defaultDisplayName
624
- }, undefined, { access_token: defaultAccessToken, refresh_token: defaultRefreshToken });
625
-
626
- console.log(`✅ Sent to ${customer.email}`);
627
- } catch (error) {
628
- console.error(`❌ Failed to send to ${customer.email}:`, error);
629
- }
630
- }
631
- }
632
-
633
- console.log(`\n✅ Newsletter sending complete!`);
634
- if (includeExecutives) {
635
- console.log(` Sent to ${executives.length} executive(s)`);
636
- }
637
- if (includePotentialCustomers) {
638
- console.log(` Sent to ${potentialCustomers.length} potential customer(s)`);
639
- }
640
- process.exit(0);
641
- }
642
-
643
- /**
644
- * Get Persona tokens for executive
645
- */
646
- async function getPersonaTokens(executiveId?: string): Promise<{ access_token?: string; refresh_token?: string } | null> {
647
- if (!executiveId) return null;
648
-
649
- const mongoUrl = process.env.PROD_MONGO_DATABASE_URL || process.env.MONGO_DATABASE_URL;
650
- if (!mongoUrl) return null;
651
-
652
- const client = new MongoClient(mongoUrl);
653
- try {
654
- await client.connect();
655
- const dbName = getDatabaseName();
656
- const db = client.db(dbName);
657
- const collectionName = getCollectionName(fraimConfig.identityCollection);
658
-
659
- // Check for identity by executive ID
660
- const identity = await db.collection(collectionName).findOne({
661
- executive_id: executiveId,
662
- status: 'active'
663
- }) || await db.collection(collectionName).findOne({
664
- executive_id: executiveId
665
- });
666
-
667
- if (identity && identity.access_token && identity.refresh_token) {
668
- return {
669
- access_token: identity.access_token,
670
- refresh_token: identity.refresh_token
671
- };
672
- }
673
-
674
- return null;
675
- } finally {
676
- await client.close();
677
- }
678
- }
679
-
680
- /**
681
- * Generate plain text version
682
- */
683
- function generatePlainText(newsletter: Newsletter): string {
684
- const { content } = newsletter;
685
- let text = `${content.weekTitle}\n`;
686
- text += `${content.weekSubtitle}\n`;
687
- text += `Week of ${content.weekDate}\n\n`;
688
- text += `${content.openingMessage}\n\n`;
689
-
690
- // Hero feature(s) - support both single heroFeature and array of heroFeatures
691
- const heroFeatures = content.heroFeatures || (content.heroFeature ? [content.heroFeature] : []);
692
- if (heroFeatures.length > 0) {
693
- const heroLabel = heroFeatures.length > 1 ? '✨ HERO FEATURES OF THE WEEK' : '✨ FEATURE OF THE WEEK';
694
- text += `${heroLabel}\n\n`;
695
- heroFeatures.forEach((hero: any, index: number) => {
696
- if (heroFeatures.length > 1) {
697
- text += `HERO FEATURE ${index + 1}:\n`;
698
- }
699
- text += `${hero.title}\n`;
700
- text += `${hero.description}\n`;
701
- if (hero.impact) {
702
- text += `Impact: ${hero.impact}\n`;
703
- }
704
- text += `\n`;
705
- });
706
- }
707
-
708
- if (content.newFeatures && content.newFeatures.length > 0) {
709
- text += `NEW FEATURES:\n`;
710
- content.newFeatures.forEach((f: any) => {
711
- text += `• ${f.title}: ${f.description}\n`;
712
- });
713
- text += `\n`;
714
- }
715
-
716
- if (content.improvements && content.improvements.length > 0) {
717
- text += `IMPROVEMENTS:\n`;
718
- content.improvements.forEach((i: any) => {
719
- text += `• ${i.title}: ${i.description}\n`;
720
- });
721
- text += `\n`;
722
- }
723
-
724
- if (content.bugFixes && content.bugFixes.length > 0) {
725
- text += `BUG FIXES:\n`;
726
- content.bugFixes.forEach((b: any) => {
727
- text += `• ${b.title}: ${b.description}\n`;
728
- });
729
- text += `\n`;
730
- }
731
-
732
- if (content.testimonial) {
733
- text += `\n"${content.testimonial.text}"\n`;
734
- text += `— ${content.testimonial.author}, ${content.testimonial.role}\n\n`;
735
- }
736
-
737
- if (content.comingNext) {
738
- text += `COMING NEXT:\n${content.comingNext}\n\n`;
739
- }
740
-
741
- text += `\nWith gratitude,\n${fraimConfig.personaName}\nYour AI Executive Assistant\n\n`;
742
- text += `Visit: ${fraimConfig.webAppUrl ? `${fraimConfig.webAppUrl}/wellness/${fraimConfig.personaName.toLowerCase()}` : '#'}\n`;
743
-
744
- return text;
745
- }
746
-
747
- /**
748
- * Send email via Gmail API (reuses existing pattern)
749
- */
750
- async function sendEmailViaGmail(
751
- params: {
752
- to: string;
753
- subject: string;
754
- plainTextBody: string;
755
- htmlBody: string;
756
- fromEmail: string;
757
- fromDisplayName: string;
758
- },
759
- executive?: any,
760
- personaTokens?: { access_token?: string; refresh_token?: string } | null
761
- ): Promise<void> {
762
- const { to, subject, plainTextBody, htmlBody, fromEmail, fromDisplayName } = params;
763
-
764
- let accessToken: string;
765
- let refreshToken: string;
766
- let isProdToken = false; // Track if we're using PROD tokens
767
-
768
- if (personaTokens?.access_token && personaTokens?.refresh_token) {
769
- accessToken = personaTokens.access_token;
770
- refreshToken = personaTokens.refresh_token;
771
- // Check if these are PROD tokens by comparing with PROD_FRAIM_DEFAULT_REFRESH_TOKEN
772
- const prodRefreshToken = fraimConfig.prodRefreshToken || '';
773
- if (prodRefreshToken && refreshToken === prodRefreshToken) {
774
- isProdToken = true;
775
- }
776
- } else if (executive?.personaAccessToken && executive?.personaRefreshToken) {
777
- accessToken = executive.personaAccessToken;
778
- refreshToken = executive.personaRefreshToken;
779
- } else {
780
- accessToken = fraimConfig.defaultAccessToken || '';
781
- refreshToken = fraimConfig.defaultRefreshToken || '';
782
-
783
- if (!accessToken || !refreshToken) {
784
- throw new Error(`${fraimConfig.personaName} Gmail tokens not found`);
785
- }
786
- }
787
-
788
- const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
789
- const emailMessage = [
790
- `To: ${to}`,
791
- `From: ${fromDisplayName} <${fromEmail}>`,
792
- `Subject: ${subject}`,
793
- `Content-Type: multipart/alternative; boundary="${boundary}"`,
794
- `MIME-Version: 1.0`,
795
- ``,
796
- `--${boundary}`,
797
- `Content-Type: text/plain; charset=utf-8`,
798
- ``,
799
- `--${boundary}`,
800
- `Content-Type: text/html; charset=utf-8`,
801
- ``,
802
- htmlBody,
803
- ``,
804
- `--${boundary}--`
805
- ].join('\r\n');
806
-
807
- // Note: base64 encoding without newlines is important
808
- const url = 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send';
809
- const requestBody = {
810
- raw: Buffer.from(emailMessage).toString('base64')
811
- .replace(/\+/g, '-')
812
- .replace(/\//g, '_')
813
- .replace(/=+$/, '')
814
- };
815
-
816
- const response = await fetch(url, {
817
- method: 'POST',
818
- headers: {
819
- 'Authorization': `Bearer ${accessToken}`,
820
- 'Content-Type': 'application/json',
821
- },
822
- body: JSON.stringify(requestBody)
823
- });
824
-
825
- if (!response.ok) {
826
- if (response.status === 401) {
827
- // Token refresh logic - use PROD OAuth credentials if using PROD tokens
828
- const clientId = isProdToken
829
- ? fraimConfig.prodOAuthClientId
830
- : fraimConfig.defaultOAuthClientId;
831
- const clientSecret = isProdToken
832
- ? fraimConfig.prodOAuthClientSecret
833
- : fraimConfig.defaultOAuthClientSecret;
834
-
835
- if (!clientId || !clientSecret) {
836
- throw new Error(`OAuth credentials not found for token refresh${isProdToken ? ' (PROD)' : ''}`);
837
- }
838
-
839
- const refreshResponse = await fetch('https://oauth2.googleapis.com/token', {
840
- method: 'POST',
841
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
842
- body: new URLSearchParams({
843
- client_id: clientId,
844
- client_secret: clientSecret,
845
- refresh_token: refreshToken,
846
- grant_type: 'refresh_token'
847
- })
848
- });
849
-
850
- if (!refreshResponse.ok) {
851
- throw new Error('Failed to refresh access token');
852
- }
853
-
854
- const tokenData = await refreshResponse.json() as any;
855
-
856
- // Retry with new token
857
- const retryResponse = await fetch(url, {
858
- method: 'POST',
859
- headers: {
860
- 'Authorization': `Bearer ${tokenData.access_token}`,
861
- 'Content-Type': 'application/json',
862
- },
863
- body: JSON.stringify(requestBody)
864
- });
865
-
866
- if (!retryResponse.ok) {
867
- throw new Error(`Gmail API error after refresh: ${retryResponse.status}`);
868
- }
869
-
870
- return;
871
- }
872
- throw new Error(`Gmail API error: ${response.status}`);
873
- }
874
- }