// Circle Suite — Text Ad generator for the Traffic Desk. // // Text placements out-perform banners on this network (357 text ads have taken // ~171 clicks each against ~150 for banners, on a fraction of the inventory), // so members get a generator for them too. // // The format is not a guess. It was measured across 60 live text ads on the // network: Subject is capped at 20 characters, Body is ALWAYS exactly three // lines joined by
, each capped at 24 characters. Every ad in the sample // obeyed that, without exception. // // Two deliberate design decisions: // // 1. The engine writes PLAIN text inside a deliberately tight budget, and we // apply the emoji ourselves afterward. Language models cannot count // characters reliably — especially with emoji, where one glyph can be // several code points — so asking for "<=24 chars with emoji" produces // overflow every time. Writing to 20 and adding a known pair of 1-code-point // emoji is arithmetic we control, and it lets us rotate the emoji // independently of the copy so two ads never look alike. // // 2. The palette carries NO money emoji. The network is full of piles-of-cash // ads; ours deliberately do not imply earnings, because the compliance rule // against implying income outranks matching the neighbours. 'use strict'; const suiteAi = require('./suite-ai'); const MAX_SUBJECT = 20; // measured hard cap on live inventory const MAX_LINE = 24; // measured hard cap per body line const BODY_LINES = 3; // every single live ad uses exactly three // Budget the engine writes to, leaving room for the emoji we add. const SUBJECT_BUDGET = MAX_SUBJECT - 2; const LINE_BUDGET = MAX_LINE - 2; // Single-code-point emoji only — no variation selectors, no ZWJ sequences, so // the character arithmetic above stays exact. const PALETTES = [ ['⚡', '⚡'], // high voltage ['🔥', '🔥'], // fire ['🚀', '🚀'], // rocket ['🔗', '🔗'], // link ['👥', '👥'], // busts in silhouette ['🤝', '🤝'], // handshake ['🎯', '🎯'], // direct hit ['⭐', '⭐'], // star ['🧩', '🧩'], // puzzle piece ['🔓', '🔓'], // unlocked ['🟣', '🟣'], // purple circle (Polygon) ['✅', '✅'] // check mark ]; // Angles mirror the ones the landing pages already support, so a click on the // ad continues the same hook it started with. const ANGLES = { general: { label: 'The straight pitch', brief: 'the plain honest pitch: a crypto team build on a public smart contract, you need two people, payments are person to person' }, two: { label: 'You only need two', brief: 'the whole job is getting two people and helping them get their two — depth, not width, and nothing else to memorise' }, pocket: { label: 'Pocket change', brief: 'the entry is small and one-time, this is not a monthly subscription that bleeds you' }, phone: { label: 'Phone only', brief: 'the entire thing runs from a phone — no laptop, no office, no stock, no shipping' }, graveyard: { label: 'Side hustle graveyard', brief: 'this one does not vanish like every other program, because the contract is public, unchangeable and holds nobody\'s money' }, verify: { label: 'Verify it yourself', brief: 'do not trust anyone, read the contract on-chain yourself before you put in a cent' } }; function angles() { return Object.keys(ANGLES).map(function (k) { return { key: k, label: ANGLES[k].label }; }); } function cp(s) { return Array.from(String(s || '')); } function cpLen(s) { return cp(s).length; } // Trim to a code-point budget on a word boundary where possible, so an ad never // ends mid-word. Falls back to a hard cut for a single long token. function fit(s, budget) { s = String(s || '').replace(/\s+/g, ' ').trim(); s = s.replace(/[<>]/g, ''); if (cpLen(s) <= budget) return s; const words = s.split(' '); let out = ''; for (let i = 0; i < words.length; i++) { const next = out ? out + ' ' + words[i] : words[i]; if (cpLen(next) > budget) break; out = next; } if (!out) out = cp(s).slice(0, budget).join(''); return out.replace(/[,;:\-–—]+$/, '').trim(); } // Strip the decoration models add on their own so our budget maths holds. function strip(s) { return String(s || '') .replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}\u{2B00}-\u{2BFF}]/gu, '') .replace(/^["'“”\s*#>\-]+|["'“”\s*]+$/g, '') .replace(/\s+/g, ' ') .trim(); } function parse(text) { const out = []; // Split on an "AD n" marker, tolerating markdown and missing numbers. const blocks = String(text || '').split(/(?:^|\n)[ \t]*[*#>\s]*AD\s*\d*[ \t]*[*#:]*[ \t]*(?=\n)/i); blocks.forEach(function (b) { const grab = function (label) { const m = b.match(new RegExp('(?:^|\\n)[ \\t]*[*#>\\s]*' + label + '[ \\t]*[*#]*[ \\t]*:?[ \\t]*[*#]*[ \\t]*(.+)', 'i')); return m ? strip(m[1]) : ''; }; const subject = grab('SUBJECT'); const l1 = grab('L1'), l2 = grab('L2'), l3 = grab('L3'); if (!subject || !l1) return; out.push({ subject: subject, lines: [l1, l2, l3] }); }); return out; } // Apply emoji and enforce the measured limits. Line 3 is left bare on most ads // because the live inventory does that too — a plain uppercase closing line // reads as a call to action rather than more decoration. function decorate(v, index) { const pal = PALETTES[index % PALETTES.length]; const alt = PALETTES[(index + 5) % PALETTES.length]; const subject = fit(strip(v.subject), SUBJECT_BUDGET); const lines = []; const raw = v.lines.map(function (l) { return strip(l); }); lines.push(pal[0] + fit(raw[0], LINE_BUDGET) + pal[1]); if (raw[1]) lines.push(alt[0] + fit(raw[1], LINE_BUDGET) + alt[1]); else lines.push(''); // Third line: bare, and upper-cased when it is short enough to read as a CTA. if (raw[2]) { const third = fit(raw[2], MAX_LINE); lines.push(cpLen(third) <= 18 ? third.toUpperCase() : third); } else lines.push(''); return { subject: pal[0] + subject + pal[1], lines: lines, preview: lines.filter(Boolean).join('\n') }; } function valid(v) { if (cpLen(v.subject) > MAX_SUBJECT) return false; if (v.lines.length !== BODY_LINES) return false; if (v.lines.some(function (l) { return cpLen(l) > MAX_LINE; })) return false; if (!v.lines[0]) return false; return true; } // Generate `want` distinct ads. We ask for extras because some come back // unusable, and dedupe on the subject so a member never sees two of the same. async function generate(opts) { const want = Math.max(1, Math.min(8, Number(opts.count) || 5)); const angle = ANGLES[opts.angle] ? opts.angle : 'general'; const ask = want + 3; const instruction = 'You are writing tiny text ads for a rotating advertising network, promoting a crypto team build called The RM Circle.\n\n' + 'WHAT IT IS: The RM Circle is a team build on a public, verified smart contract on Polygon. The contract cannot be changed and holds no member funds — every payment goes person to person in the same transaction. The whole job is getting two personal referrals and helping them get their two.\n\n' + 'ANGLE FOR THESE ADS: ' + ANGLES[angle].brief + '\n\n' + 'ABSOLUTE RULES:\n' + '- NEVER promise, guarantee, project or imply income, earnings, returns or profit. No "$", no numbers of money, no "earn", no "profit", no "income", no "passive".\n' + '- No hype words: guaranteed, risk-free, get rich, financial freedom.\n' + '- Do not invent figures or results.\n' + '- Write PLAIN TEXT ONLY. Do NOT use emoji, asterisks, quotes or HTML — decoration is added later by the system.\n\n' + 'HARD LENGTH LIMITS (count every character, spaces included — going over makes the ad unusable):\n' + '- SUBJECT: at most ' + SUBJECT_BUDGET + ' characters.\n' + '- L1, L2, L3: at most ' + LINE_BUDGET + ' characters EACH.\n' + 'These are extremely short. Think billboard, not sentence. Fragments are good. L3 should be a short call to action.\n\n' + 'Write ' + ask + ' different ads. Vary the wording and the hook across them. Output EXACTLY this shape and nothing else:\n\n' + 'AD 1\nSUBJECT: \nL1: \nL2: \nL3: \n\nAD 2\nSUBJECT: \nL1: \nL2: \nL3: \n\n(and so on)'; const text = await suiteAi.generateRaw(instruction); const parsed = parse(text); const seen = {}; const out = []; parsed.forEach(function (v, i) { const d = decorate(v, i); if (!valid(d)) return; const key = d.subject.toLowerCase(); if (seen[key]) return; seen[key] = 1; out.push(d); }); if (!out.length) throw new Error('The engine did not come back with usable ads — try again.'); return { angle: angle, variants: out.slice(0, want) }; } module.exports = { generate, angles, decorate, fit, parse, valid, ANGLES, PALETTES, MAX_SUBJECT, MAX_LINE, BODY_LINES };