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,744 +0,0 @@
1
- #!/usr/bin/env tsx
2
-
3
- /**
4
- * Engagement Email Functions for AI Agents
5
- *
6
- * Helper functions for AI agents to use in customer engagement workflows.
7
- * Provides deterministic functions for issue retrieval, database lookup, and email sending.
8
- */
9
-
10
- import { execSync } from 'child_process';
11
- import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
12
- import { MongoClient } from 'mongodb';
13
- import { join } from 'path';
14
- import { determineDatabaseName, determineSchema, getCurrentGitBranch } from '../../src/utils/git-utils.js';
15
- import { fraimConfig, formatExecutiveDisplayName, formatPersonaSignature } from './fraim-config';
16
-
17
- // Get template path (relative to script location)
18
- function getTemplatePath(): string {
19
- // Script is at <this-path>
20
- // Template is at templates/customer-development/thank-you-email-template.html (Retrieve via get_fraim_file)
21
- // Or generic path? Let's keep existing path logic but relative to process.cwd() as before
22
- return join(process.cwd(), 'registry', 'templates', 'customer-development', 'thank-you-email-template.html');
23
- }
24
-
25
- interface ResolvedIssue {
26
- number: number;
27
- title: string;
28
- body: string;
29
- closed_at: string;
30
- user: {
31
- login: string;
32
- };
33
- labels: Array<{
34
- name: string;
35
- }>;
36
- }
37
-
38
- interface Executive {
39
- id?: string; // Executive ID (used in the identity collection)
40
- _id?: string | any; // MongoDB ObjectId (may be present but we use 'id')
41
- email: string;
42
- name: string;
43
- personaAccessToken?: string;
44
- personaRefreshToken?: string;
45
- }
46
-
47
- /**
48
- * Get the latest date from existing thank-you candidates files
49
- * Returns the most recent date when thank-you emails were sent, or null if no files exist
50
- */
51
- export function getLatestThankYouDate(): string | null {
52
- const notesDir = 'docs/customer-development/thank-you-notes';
53
- if (!existsSync(notesDir)) {
54
- return null;
55
- }
56
-
57
- const files = readdirSync(notesDir).filter(f => f.startsWith('thank-you-candidates-') && f.endsWith('.json'));
58
- if (files.length === 0) {
59
- return null;
60
- }
61
-
62
- // Extract dates from filenames (e.g., thank-you-candidates-2025-10-29.json)
63
- const dates: string[] = [];
64
- for (const file of files) {
65
- const dateMatch = file.match(/thank-you-candidates-(\d{4}-\d{2}-\d{2})\.json/);
66
- if (dateMatch) {
67
- const filePath = join(notesDir, file);
68
- try {
69
- const content = readFileSync(filePath, 'utf-8');
70
- const json = JSON.parse(content);
71
-
72
- // Check if any candidates have status "sent"
73
- const hasSentEmails = json.candidates?.some((c: any) => c.status === 'sent');
74
- if (hasSentEmails) {
75
- dates.push(dateMatch[1]);
76
- }
77
- } catch (error) {
78
- // Skip invalid JSON files
79
- console.warn(`⚠️ Could not parse ${file}:`, error);
80
- }
81
- }
82
- }
83
-
84
- if (dates.length === 0) {
85
- return null;
86
- }
87
-
88
- // Return the most recent date
89
- dates.sort();
90
- const latestDate = dates[dates.length - 1];
91
- console.log(`📅 Latest thank-you date found: ${latestDate}`);
92
- return latestDate;
93
- }
94
-
95
- /**
96
- * Get resolved issues since a specified date (or latest thank-you date if not provided)
97
- * AI agents call this to retrieve issues that were resolved
98
- * @param date Optional date string (YYYY-MM-DD). If not provided, uses latest thank-you date. If no thank-you files exist, uses last 14 days.
99
- */
100
- export async function getResolvedIssues(date?: string): Promise<ResolvedIssue[]> {
101
- if (!date) {
102
- // Try to get the latest thank-you date
103
- const latestThankYouDate = getLatestThankYouDate();
104
- if (latestThankYouDate) {
105
- date = latestThankYouDate;
106
- console.log(`📋 Using latest thank-you date as starting point: ${date}`);
107
- } else {
108
- // Fallback to last 14 days if no thank-you files exist
109
- const fallbackDate = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
110
- date = fallbackDate;
111
- console.log(`📋 No previous thank-you files found, using last 14 days: ${date}`);
112
- }
113
- }
114
-
115
- // Use ">YYYY-MM-DD" format to get issues updated AFTER the date (not on the date)
116
- // GitHub CLI requires quotes around the comparison operator
117
- const command = `gh search issues --repo=${fraimConfig.repoOwner}/${fraimConfig.repoName} --state=closed --updated=">${date}" --label=user-reported --json number,title,body,labels,closedAt,author`;
118
- const output = execSync(command, { encoding: 'utf-8' });
119
- return JSON.parse(output);
120
- }
121
-
122
- function getProductionDatabase() {
123
- const mongoUrl = process.env.PROD_MONGO_DATABASE_URL || process.env.MONGO_DATABASE_URL;
124
- if (!mongoUrl) {
125
- throw new Error('PROD_MONGO_DATABASE_URL or MONGO_DATABASE_URL environment variable is required');
126
- }
127
- return new MongoClient(mongoUrl);
128
- }
129
-
130
- /**
131
- * Get database name, defaulting to production
132
- */
133
- function getDatabaseName(): string {
134
- const env = process.env.NODE_ENV || process.env.ENVIRONMENT;
135
- // If explicitly set to ppe/staging, use that; otherwise default to prod
136
- if (env === 'ppe' || env === 'staging') {
137
- return determineDatabaseName();
138
- }
139
- // Default to production
140
- return process.env.MONGO_DB_NAME || 'fraim_prod';
141
- }
142
-
143
- /**
144
- * Get collection name with schema prefix (defaults to prod schema)
145
- */
146
- function getCollectionName(baseName: string): string {
147
- const env = process.env.NODE_ENV || process.env.ENVIRONMENT;
148
- // If explicitly set to ppe/staging, use that schema; otherwise default to prod
149
- if (env === 'ppe' || env === 'staging') {
150
- const schema = determineSchema(getCurrentGitBranch());
151
- return `${schema}_${baseName}`;
152
- }
153
- // Default to prod schema
154
- return `prod_${baseName}`;
155
- }
156
-
157
- /**
158
- * Find executive by email in database (defaults to production)
159
- * AI agents call this to get executive info for database lookups
160
- */
161
- export async function findExecutiveByEmail(email: string): Promise<Executive | null> {
162
- const client = getProductionDatabase();
163
- try {
164
- await client.connect();
165
- const dbName = getDatabaseName();
166
- const db = client.db(dbName);
167
- const executive = await db.collection(getCollectionName('Executive')).findOne({ email: email.toLowerCase() });
168
- return executive as unknown as Executive;
169
- } finally {
170
- await client.close();
171
- }
172
- }
173
-
174
- /**
175
- * Get the persona email for an executive from the identity collection
176
- * AI agents call this to get tokens or contact info for the executive's assistant
177
- */
178
- export async function getPersonaEmailForExecutive(executiveId: string): Promise<string> {
179
- const { MongoClient } = await import('mongodb');
180
- const mongoUrl = process.env.PROD_MONGO_DATABASE_URL || process.env.MONGO_DATABASE_URL;
181
- if (!mongoUrl) {
182
- console.warn(`⚠️ PROD_MONGO_DATABASE_URL not set, using default ${fraimConfig.personaName} email`);
183
- return fraimConfig.defaultEmail;
184
- }
185
-
186
- const client = new MongoClient(mongoUrl);
187
- try {
188
- await client.connect();
189
- const dbName = getDatabaseName();
190
- const db = client.db(dbName);
191
- const collectionName = getCollectionName(fraimConfig.identityCollection);
192
-
193
- // Query the identity collection (defaults to prod)
194
- let identity = await db.collection(collectionName).findOne({
195
- executive_id: executiveId,
196
- status: 'active'
197
- });
198
-
199
- if (!identity) {
200
- identity = await db.collection(collectionName).findOne({
201
- executive_id: executiveId
202
- });
203
- }
204
-
205
- if (identity && identity.email) {
206
- return identity.email;
207
- }
208
-
209
- return fraimConfig.defaultEmail;
210
- } catch (error) {
211
- console.warn(`⚠️ Could not get ${fraimConfig.personaName} email for executive ${executiveId}:`, error);
212
- return fraimConfig.defaultEmail;
213
- } finally {
214
- await client.close();
215
- }
216
- }
217
-
218
- interface Improvement {
219
- title: string;
220
- description: string;
221
- verification?: string;
222
- }
223
-
224
- interface Candidate {
225
- customer: { name: string; email: string };
226
- executive: { name: string; id?: string };
227
- status: 'pending' | 'sent' | 'failed';
228
- from: { displayName: string; email: string };
229
- to: string;
230
- subject: string;
231
- // Structured format (preferred)
232
- greeting?: string;
233
- opening?: string;
234
- improvements?: Improvement[];
235
- closing?: string;
236
- // Legacy format (fallback for backward compatibility)
237
- body?: string;
238
- }
239
-
240
- interface CandidatesFile {
241
- metadata: {
242
- generatedOn: string;
243
- issuesResolvedSince: string;
244
- totalCustomers: number;
245
- totalIssues: number;
246
- };
247
- candidates: Candidate[];
248
- }
249
-
250
- /**
251
- * Send emails from candidates file (JSON format)
252
- * AI agents call this after user review to send all emails
253
- * @param candidatesFilePath Path to the JSON candidates file
254
- * @param executiveId Optional executive ID to filter emails to specific executive only
255
- */
256
- export async function sendCustomerMail(candidatesFilePath: string, executiveId?: string): Promise<void> {
257
- if (!existsSync(candidatesFilePath)) {
258
- throw new Error(`Candidates file not found: ${candidatesFilePath}`);
259
- }
260
-
261
- console.log(`📧 Sending emails from candidates file: ${candidatesFilePath}`);
262
- if (executiveId) {
263
- console.log(`🎯 Filtering to executive ID: ${executiveId}`);
264
- }
265
-
266
- const content = readFileSync(candidatesFilePath, 'utf-8');
267
- const candidatesFile: CandidatesFile = JSON.parse(content);
268
-
269
- console.log(`📊 Found ${candidatesFile.candidates.length} candidates (${candidatesFile.metadata.totalCustomers} customers)`);
270
-
271
- // Filter candidates if executiveId is provided
272
- let candidatesToSend = candidatesFile.candidates;
273
- if (executiveId) {
274
- candidatesToSend = candidatesFile.candidates.filter(candidate =>
275
- candidate.executive.id === executiveId
276
- );
277
- console.log(`🔍 Filtered to ${candidatesToSend.length} candidate(s) for executive ${executiveId}`);
278
-
279
- if (candidatesToSend.length === 0) {
280
- console.log(`⚠️ No candidates found for executive ID: ${executiveId}`);
281
- return;
282
- }
283
- }
284
-
285
- // Update file with results as we send
286
- const updatedCandidates = [...candidatesFile.candidates];
287
-
288
- for (let i = 0; i < updatedCandidates.length; i++) {
289
- const candidate = updatedCandidates[i];
290
-
291
- // Skip if filtering by executive ID and this candidate doesn't match
292
- if (executiveId && candidate.executive.id !== executiveId) {
293
- continue;
294
- }
295
-
296
- if (candidate.status !== 'pending') {
297
- console.log(`⏭️ Skipping ${candidate.customer.email} (status: ${candidate.status})`);
298
- continue;
299
- }
300
-
301
- try {
302
- console.log(`📧 Sending email to ${candidate.customer.email}...`);
303
-
304
- // Get executive from database if we have their email
305
- const executive = await findExecutiveByEmail(candidate.customer.email);
306
-
307
- // Get persona tokens from identity collection if the executive is known
308
- let personaTokens: { access_token?: string; refresh_token?: string } | null = null;
309
- if (candidate.executive.id) {
310
- try {
311
- const client = getProductionDatabase();
312
- try {
313
- await client.connect();
314
- const dbName = getDatabaseName();
315
- const db = client.db(dbName);
316
- const collectionName = getCollectionName(fraimConfig.identityCollection);
317
-
318
- const identity = await db.collection(collectionName).findOne({
319
- executive_id: candidate.executive.id,
320
- status: 'active'
321
- }) || await db.collection(collectionName).findOne({
322
- executive_id: candidate.executive.id
323
- });
324
-
325
- if (identity && identity.access_token && identity.refresh_token) {
326
- personaTokens = {
327
- access_token: identity.access_token,
328
- refresh_token: identity.refresh_token
329
- };
330
- console.log(`Found ${fraimConfig.personaName} tokens in ${dbName} database for ${candidate.from.email}`);
331
- } else {
332
- console.warn(`No ${fraimConfig.personaName} tokens found in ${dbName} database for executive ${candidate.executive.id}`);
333
- }
334
- } finally {
335
- await client.close();
336
- }
337
- } catch (error) {
338
- console.warn(`Could not get ${fraimConfig.personaName} tokens from database for executive ${candidate.executive.id}:`, error);
339
- }
340
- }
341
- // Generate plain text body for fallback (from structured format if available)
342
- const plainTextBody = generatePlainTextBody(candidate);
343
-
344
- await sendSingleEmail({
345
- to: candidate.to,
346
- subject: candidate.subject,
347
- body: plainTextBody,
348
- fromEmail: candidate.from.email,
349
- fromDisplayName: candidate.from.displayName,
350
- candidate // Pass full candidate for HTML generation
351
- }, executive, personaTokens);
352
-
353
- console.log(`✅ Email sent to ${candidate.customer.email}`);
354
- updatedCandidates[i].status = 'sent';
355
-
356
- // Update file after each successful send
357
- const updatedFile: CandidatesFile = {
358
- ...candidatesFile,
359
- candidates: updatedCandidates
360
- };
361
- writeFileSync(candidatesFilePath, JSON.stringify(updatedFile, null, 2));
362
-
363
- } catch (error) {
364
- console.error(`❌ Failed to send email to ${candidate.customer.email}:`, error);
365
- updatedCandidates[i].status = 'failed';
366
-
367
- // Update file with failure status
368
- const updatedFile: CandidatesFile = {
369
- ...candidatesFile,
370
- candidates: updatedCandidates
371
- };
372
- writeFileSync(candidatesFilePath, JSON.stringify(updatedFile, null, 2));
373
- }
374
- }
375
-
376
- console.log(`\n✅ Email sending complete. Status updated in ${candidatesFilePath}`);
377
-
378
- // Exit cleanly to prevent hanging
379
- process.exit(0);
380
- }
381
-
382
- interface SendEmailParams {
383
- to: string;
384
- subject: string;
385
- body: string;
386
- fromEmail: string;
387
- fromDisplayName: string;
388
- candidate?: Candidate; // Optional: for structured HTML generation
389
- }
390
-
391
- async function sendSingleEmail(
392
- params: SendEmailParams,
393
- executive?: Executive | null,
394
- personaTokens?: { access_token?: string; refresh_token?: string } | null
395
- ): Promise<void> {
396
- const { to, subject, body, fromEmail, fromDisplayName } = params;
397
-
398
- // Get Persona's tokens - prioritize identity tokens, then executive tokens, then default
399
- let accessToken: string;
400
- let refreshToken: string;
401
-
402
- if (personaTokens?.access_token && personaTokens?.refresh_token) {
403
- // Use persona identity tokens (preferred - ensures correct From address)
404
- console.log(`🔑 Using ${fraimConfig.personaName} identity tokens for ${fromEmail}`);
405
- accessToken = personaTokens.access_token;
406
- refreshToken = personaTokens.refresh_token;
407
- } else if (executive?.personaAccessToken && executive?.personaRefreshToken) {
408
- // Fallback to executive-specific tokens
409
- console.log(`🔑 Using executive persona tokens as fallback`);
410
- accessToken = executive.personaAccessToken;
411
- refreshToken = executive.personaRefreshToken;
412
- } else {
413
- // Use default persona tokens as last resort
414
- console.log(`🔑 Using default ${fraimConfig.personaName} tokens as fallback`);
415
- accessToken = fraimConfig.defaultAccessToken;
416
- refreshToken = fraimConfig.defaultRefreshToken;
417
-
418
- if (!accessToken || !refreshToken) {
419
- throw new Error(`${fraimConfig.personaName} Gmail tokens not found. Need either identity tokens, executive tokens, or default tokens in environment variables.`);
420
- }
421
- }
422
-
423
- // Verify the From email matches the authenticated account (Gmail requirement)
424
- // For plus addressing, the base email should match
425
- const fromBaseEmail = fromEmail.includes('+')
426
- ? fromEmail.split('+')[0] + '@' + fromEmail.split('@')[1]
427
- : fromEmail;
428
-
429
- console.log(`📧 Sending from: "${fromDisplayName}" <${fromEmail}>`);
430
-
431
- // Generate HTML email from template (use structured format if available)
432
- const greeting = params.candidate?.greeting || 'Hi there,';
433
- const htmlBody = generateHtmlEmail({
434
- displayName: extractFirstName(greeting) || extractFirstName(body) || extractFirstName(fromDisplayName) || 'there',
435
- greeting: greeting,
436
- opening: params.candidate?.opening
437
- ? convertTextToHtml(params.candidate.opening)
438
- : convertBodyToHtml(body), // Fallback to legacy body
439
- improvements: params.candidate?.improvements || [],
440
- closing: params.candidate?.closing || '',
441
- executiveName: extractExecutiveName(fromDisplayName),
442
- fromEmail
443
- });
444
-
445
- // Create multipart email message (plain text + HTML)
446
- const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
447
- const emailMessage = [
448
- `To: ${to}`,
449
- `From: ${fromDisplayName} <${fromEmail}>`,
450
- `Subject: ${subject}`,
451
- `Content-Type: multipart/alternative; boundary="${boundary}"`,
452
- `MIME-Version: 1.0`,
453
- ``,
454
- `--${boundary}`,
455
- `Content-Type: text/plain; charset=utf-8`,
456
- `Content-Transfer-Encoding: 7bit`,
457
- ``,
458
- body,
459
- ``,
460
- `--${boundary}`,
461
- `Content-Type: text/html; charset=utf-8`,
462
- `Content-Transfer-Encoding: 7bit`,
463
- ``,
464
- htmlBody,
465
- ``,
466
- `--${boundary}--`
467
- ].join('\r\n');
468
-
469
- // Send email using Gmail API
470
- const url = 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send';
471
- const requestBody = {
472
- raw: Buffer.from(emailMessage).toString('base64')
473
- .replace(/\+/g, '-')
474
- .replace(/\//g, '_')
475
- .replace(/=+$/, '')
476
- };
477
-
478
- const response = await fetch(url, {
479
- method: 'POST',
480
- headers: {
481
- 'Authorization': `Bearer ${accessToken}`,
482
- 'Content-Type': 'application/json',
483
- },
484
- body: JSON.stringify(requestBody)
485
- });
486
-
487
- if (!response.ok) {
488
- if (response.status === 401) {
489
- console.log('🔄 Access token expired, refreshing...');
490
-
491
- // Refresh the access token
492
- const clientId = fraimConfig.defaultOAuthClientId || process.env.FRAIM_DEFAULT_OAUTH_CLIENT_ID || process.env.PERSONA_DEFAULT_OAUTH_CLIENT_ID;
493
- const clientSecret = fraimConfig.defaultOAuthClientSecret || process.env.FRAIM_DEFAULT_OAUTH_CLIENT_SECRET || process.env.PERSONA_DEFAULT_OAUTH_CLIENT_SECRET;
494
-
495
- if (!clientId || !clientSecret) {
496
- throw new Error('FRAIM_DEFAULT_OAUTH_CLIENT_ID and FRAIM_DEFAULT_OAUTH_CLIENT_SECRET environment variables are required for token refresh');
497
- }
498
-
499
- console.log('🔄 Refreshing token...');
500
-
501
- const refreshResponse = await fetch('https://oauth2.googleapis.com/token', {
502
- method: 'POST',
503
- headers: {
504
- 'Content-Type': 'application/x-www-form-urlencoded',
505
- },
506
- body: new URLSearchParams({
507
- client_id: clientId,
508
- client_secret: clientSecret,
509
- refresh_token: refreshToken,
510
- grant_type: 'refresh_token'
511
- })
512
- });
513
-
514
- if (!refreshResponse.ok) {
515
- const errorText = await refreshResponse.text();
516
- console.error('❌ Token refresh failed:', refreshResponse.status, errorText);
517
- throw new Error(`Failed to refresh access token: ${refreshResponse.status} - ${errorText}`);
518
- }
519
-
520
- const tokenData = await refreshResponse.json() as any;
521
- const newAccessToken = tokenData.access_token;
522
-
523
- console.log('✅ Token refreshed, retrying email send...');
524
-
525
- // Retry with new token
526
- const retryResponse = await fetch(url, {
527
- method: 'POST',
528
- headers: {
529
- 'Authorization': `Bearer ${newAccessToken}`,
530
- 'Content-Type': 'application/json',
531
- },
532
- body: JSON.stringify(requestBody)
533
- });
534
-
535
- if (!retryResponse.ok) {
536
- const errorText = await retryResponse.text();
537
- throw new Error(`Gmail API error after refresh: ${retryResponse.status} - ${errorText}`);
538
- }
539
-
540
- const result = await retryResponse.json() as any;
541
- console.log(`✅ Email sent successfully to ${to}`);
542
- console.log(`📧 Email ID: ${result.id}`);
543
- return;
544
- }
545
- const errorText = await response.text();
546
- throw new Error(`Gmail API error: ${response.status} - ${errorText}`);
547
- }
548
-
549
- const result = await response.json() as any;
550
- console.log(`✅ Email sent successfully to ${to}`);
551
- console.log(`📧 Email ID: ${result.id}`);
552
- }
553
-
554
- /**
555
- * Generate HTML email from template
556
- */
557
- function generateHtmlEmail(params: {
558
- displayName: string;
559
- greeting: string;
560
- opening: string;
561
- improvements: Improvement[];
562
- closing: string;
563
- executiveName: string;
564
- fromEmail: string;
565
- }): string {
566
- const templatePath = getTemplatePath();
567
- let template = readFileSync(templatePath, 'utf-8');
568
-
569
- // Replace simple template variables
570
- template = template.replace(/\{\{displayName\}\}/g, escapeHtml(params.displayName));
571
- template = template.replace(/\{\{executiveName\}\}/g, escapeHtml(params.executiveName));
572
- template = template.replace(/\{\{personaName\}\}/g, escapeHtml(fraimConfig.personaName));
573
- template = template.replace(/\{\{chatUrl\}\}/g, escapeHtml(fraimConfig.chatUrl || '#'));
574
- template = template.replace(/\{\{webAppUrl\}\}/g, escapeHtml(fraimConfig.webAppUrl || '#'));
575
- template = template.replace(/\{\{fromEmail\}\}/g, escapeHtml(params.fromEmail));
576
- template = template.replace(/\{\{greeting\}\}/g, escapeHtml(params.greeting));
577
- template = template.replace(/\{\{opening\}\}/g, params.opening);
578
-
579
- // Generate improvements list HTML
580
- const hasImprovements = params.improvements && params.improvements.length > 0;
581
- if (hasImprovements) {
582
- const improvementsHtml = params.improvements.map((improvement, index) => {
583
- let html = `<div style="margin-bottom: ${index < params.improvements.length - 1 ? '20px' : '0'}; padding-bottom: ${index < params.improvements.length - 1 ? '20px' : '0'}; border-bottom: ${index < params.improvements.length - 1 ? '1px solid #e9ecef' : 'none'};">`;
584
- html += `<div style="font-size: 16px; font-weight: 600; color: #333333; margin-bottom: 8px;">${escapeHtml(improvement.title)}</div>`;
585
- html += `<div style="font-size: 15px; color: #555555; line-height: 1.7; margin-bottom: ${improvement.verification ? '10px' : '0'};">${convertTextToHtml(improvement.description)}</div>`;
586
- if (improvement.verification) {
587
- html += `<div style="font-size: 14px; color: #667eea; font-style: italic; padding-top: 8px; border-top: 1px solid #e9ecef; margin-top: 8px;">💡 <strong>How to verify:</strong> ${convertTextToHtml(improvement.verification)}</div>`;
588
- }
589
- html += `</div>`;
590
- return html;
591
- }).join('');
592
-
593
- const improvementsSection = `
594
- <tr>
595
- <td style="padding: 0 30px 20px 30px;">
596
- <div style="background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 6px; padding: 20px; margin: 20px 0;">
597
- <div style="font-size: 18px; font-weight: 600; color: #333333; margin-bottom: 16px;">
598
- ✨ What's Fixed
599
- </div>
600
- ${improvementsHtml}
601
- </div>
602
- </td>
603
- </tr>`;
604
- template = template.replace(/\{\{#if hasImprovements\}\}[\s\S]*?\{\{\/if\}\}/, improvementsSection);
605
- } else {
606
- // Remove conditional block if no improvements
607
- template = template.replace(/\{\{#if hasImprovements\}\}[\s\S]*?\{\{\/if\}\}/, '');
608
- }
609
-
610
- // Replace closing paragraph
611
- if (params.closing) {
612
- template = template.replace(/\{\{#if closing\}\}[\s\S]*?\{\{\/if\}\}/, `
613
- <tr>
614
- <td style="padding: 0 30px 20px 30px;">
615
- <div style="font-size: 16px; color: #555555; line-height: 1.8;">
616
- ${convertTextToHtml(params.closing)}
617
- </div>
618
- </td>
619
- </tr>`);
620
- } else {
621
- template = template.replace(/\{\{#if closing\}\}[\s\S]*?\{\{\/if\}\}/, '');
622
- }
623
-
624
- return template;
625
- }
626
-
627
- /**
628
- * Generate plain text body from candidate (supports both structured and legacy formats)
629
- */
630
- function generatePlainTextBody(candidate: Candidate): string {
631
- // If structured format exists, use it
632
- if (candidate.greeting && candidate.opening) {
633
- let body = `${candidate.greeting}\n\n${candidate.opening}\n\n`;
634
-
635
- if (candidate.improvements && candidate.improvements.length > 0) {
636
- body += `**What was improved:**\n\n`;
637
- candidate.improvements.forEach((improvement, index) => {
638
- body += `${index + 1}. ${improvement.title}: ${improvement.description}`;
639
- if (improvement.verification) {
640
- body += `\n **How to verify:** ${improvement.verification}`;
641
- }
642
- body += `\n\n`;
643
- });
644
- }
645
-
646
- if (candidate.closing) {
647
- body += `${candidate.closing}\n\n`;
648
- }
649
-
650
- body += formatPersonaSignature();
651
-
652
- return body;
653
- }
654
-
655
- // Fallback to legacy body format
656
- return candidate.body || '';
657
- }
658
-
659
- /**
660
- * Extract first name from text
661
- */
662
- function extractFirstName(text: string): string | null {
663
- // Try to find "Hi [Name]" pattern
664
- const hiMatch = text.match(/\b[Hh]i\s+([A-Z][a-z]+)/);
665
- if (hiMatch) {
666
- return hiMatch[1];
667
- }
668
-
669
- // Try to find comma after greeting
670
- const commaMatch = text.match(/^[Hh]i\s+([^,]+),/);
671
- if (commaMatch) {
672
- return commaMatch[1].trim().split(' ')[0];
673
- }
674
-
675
- return null;
676
- }
677
-
678
- /**
679
- * Extract executive name from display name
680
- */
681
- function extractExecutiveName(displayName: string): string {
682
- // "{Persona} - [Name]'s AI Executive Assistant"
683
- // We need to match based on the configured pattern
684
- const pattern = fraimConfig.personaDisplayNamePattern.replace('{executiveName}', '(.+?)');
685
-
686
- try {
687
- // If pattern contains regex characters, they need expanding or escaping?
688
- // This is simple pattern matching.
689
- // Example: "Persona - {executiveName}'s AI Executive Assistant"
690
- // Regex: /Persona - (.+?)'s AI Executive Assistant/
691
- // We escape special chars except the group we added
692
- const escapedPattern = pattern
693
- .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape regex chars
694
- .replace('\\(\\.+\\?\\)', '(.+?)'); // Restore capture group
695
-
696
- const match = displayName.match(new RegExp(escapedPattern));
697
- return match ? match[1] : 'Your';
698
- } catch (e) {
699
- return 'Your';
700
- }
701
- }
702
-
703
- /**
704
- * Convert plain text to HTML (for legacy body format)
705
- */
706
- function convertBodyToHtml(body: string): string {
707
- return convertTextToHtml(body);
708
- }
709
-
710
- /**
711
- * Convert text to HTML (preserves markdown-style formatting)
712
- */
713
- function convertTextToHtml(text: string): string {
714
- if (!text) return '';
715
-
716
- let html = text;
717
-
718
- // Convert markdown-style bold **text** to <strong>text</strong>
719
- html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
720
-
721
- // Convert line breaks to paragraphs
722
- html = html.replace(/\n\n+/g, '</p><p style="margin: 12px 0;">');
723
- html = html.replace(/\n/g, '<br>');
724
-
725
- // Wrap in paragraph tags
726
- html = `<p style="margin: 0 0 12px 0;">${html}</p>`;
727
-
728
- // Clean up empty paragraphs
729
- html = html.replace(/<p[^>]*><\/p>/g, '');
730
-
731
- return html;
732
- }
733
-
734
- /**
735
- * Escape HTML special characters
736
- */
737
- function escapeHtml(text: string): string {
738
- return text
739
- .replace(/&/g, '&amp;')
740
- .replace(/</g, '&lt;')
741
- .replace(/>/g, '&gt;')
742
- .replace(/"/g, '&quot;')
743
- .replace(/'/g, '&#039;');
744
- }