Email Engine ships (L3): sequence generation (welcome/follow-up/re-engagement/broadcast) on the Suite engine with tolerant EMAIL/SUBJECT/BODY parsing, per-email copy buttons, metered; tile live
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
// Circle Suite — Email Engine (L3). Same engine, same compliance layer as the
|
||||
// Copy Engine; the difference is shape: it writes SEQUENCES, not single pieces.
|
||||
//
|
||||
// Output contract is the same trick that works elsewhere: labelled plain text
|
||||
// parsed deterministically, with a forgiving parser (models drop colons and
|
||||
// add markdown emphasis).
|
||||
'use strict';
|
||||
const suiteAI = require('./suite-ai');
|
||||
|
||||
const KINDS = {
|
||||
welcome: {
|
||||
label: 'Welcome sequence',
|
||||
count: 4,
|
||||
brief: 'a 4-email welcome sequence for someone who just joined the team under this member. Email 1 welcomes them and points at the first 48 hours. Email 2 covers getting their first two. Email 3 handles the doubt that shows up in week one. Email 4 is about teaching their two to do the same.'
|
||||
},
|
||||
followup: {
|
||||
label: 'Follow-up sequence',
|
||||
count: 4,
|
||||
brief: 'a 4-email follow-up sequence for someone who looked at the opportunity but has not joined. Space the emails a few days apart in tone. No pressure, no fake deadlines, no guilt. Each email should be useful on its own and easy to ignore.'
|
||||
},
|
||||
reengage: {
|
||||
label: 'Re-engagement sequence',
|
||||
count: 3,
|
||||
brief: 'a 3-email re-engagement sequence for a member who joined but went quiet. Warm, no shaming, assume life got busy. Make the next step small and specific.'
|
||||
},
|
||||
broadcast: {
|
||||
label: 'Single broadcast',
|
||||
count: 1,
|
||||
brief: 'ONE standalone broadcast email to a list about the team build.'
|
||||
}
|
||||
};
|
||||
|
||||
function buildPrompt(kind, brief, member) {
|
||||
const k = KINDS[kind] || KINDS.broadcast;
|
||||
const link = (member && member.link) || '';
|
||||
return (
|
||||
'Write ' + k.brief + '\n\n' +
|
||||
'CONTEXT FROM THE MEMBER (who the list is, what to emphasise):\n' +
|
||||
(brief || '(nothing specific — write something honest and general)') + '\n\n' +
|
||||
'Return EXACTLY this shape, repeated ' + k.count + ' time(s), and nothing else:\n' +
|
||||
'EMAIL 1\n' +
|
||||
'SUBJECT: one line, under 60 characters, no clickbait, no ALL CAPS\n' +
|
||||
'BODY: 90-170 words, plain text, short paragraphs separated by blank lines, ending with a sign-off line\n' +
|
||||
(k.count > 1 ? 'EMAIL 2\nSUBJECT: ...\nBODY: ...\n(and so on)\n' : '') + '\n' +
|
||||
(link ? 'Where a link belongs, use exactly: ' + link + '\n' : 'Do not invent a link.\n') +
|
||||
'No HTML, no markdown headers, no emoji in subject lines, no merge tags.'
|
||||
);
|
||||
}
|
||||
|
||||
// Tolerant parse: EMAIL n / SUBJECT / BODY, colon and emphasis optional.
|
||||
function parseEmails(raw) {
|
||||
const text = String(raw || '').replace(/\r/g, '');
|
||||
const marks = [];
|
||||
const re = /(?:^|\n)[ \t]*[*#>\s]*EMAIL[ \t]*#?\s*(\d+)[ \t]*[*#]*[ \t]*:?[ \t]*/gi;
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) marks.push({ n: Number(m[1]), at: m.index, from: m.index + m[0].length });
|
||||
const chunks = [];
|
||||
if (!marks.length) {
|
||||
chunks.push(text);
|
||||
} else {
|
||||
marks.forEach(function (mk, i) {
|
||||
chunks.push(text.slice(mk.from, i + 1 < marks.length ? marks[i + 1].at : text.length));
|
||||
});
|
||||
}
|
||||
const out = [];
|
||||
chunks.forEach(function (c) {
|
||||
const sm = c.match(/(?:^|\n)[ \t]*[*#>\s]*SUBJECT[ \t]*[*#]*[ \t]*:?[ \t]*[*#]*[ \t]*(.*)/i);
|
||||
const bm = c.match(/(?:^|\n)[ \t]*[*#>\s]*BODY[ \t]*[*#]*[ \t]*:?[ \t]*[*#]*[ \t]*([\s\S]*)/i);
|
||||
const subject = sm ? sm[1].replace(/[*#]+/g, '').trim().slice(0, 120) : '';
|
||||
let body = bm ? bm[1] : (sm ? c.slice(c.indexOf(sm[0]) + sm[0].length) : c);
|
||||
body = String(body).replace(/^[*#\s]+/, '').trim();
|
||||
if (subject || body) out.push({ subject: subject || '(no subject)', body: body });
|
||||
});
|
||||
return out.filter(function (e) { return e.body && e.body.length > 20; });
|
||||
}
|
||||
|
||||
async function generate(kind, brief, member) {
|
||||
const raw = await suiteAI.generateRaw(buildPrompt(kind, brief, member));
|
||||
let emails = parseEmails(raw);
|
||||
if (!emails.length) throw new Error('The writer came back incomplete — try again.');
|
||||
const link = member && member.link;
|
||||
if (link) {
|
||||
emails = emails.map(function (e) {
|
||||
let b = e.body.replace(/https?:\/\/(?:www\.)?rmcircle\.team\/\S*/gi, link);
|
||||
b = b.replace(/\[LINK\]|\{link\}/gi, link);
|
||||
return { subject: e.subject, body: b };
|
||||
});
|
||||
}
|
||||
return emails;
|
||||
}
|
||||
|
||||
module.exports = { KINDS, generate, parseEmails };
|
||||
Reference in New Issue
Block a user