From 2a8b311a83bad9cad6262edd636554e8b1d98fb4 Mon Sep 17 00:00:00 2001 From: martbost Date: Fri, 28 Aug 2026 06:51:16 -0500 Subject: [PATCH] Copy Engine ships: Suite AI layer (own engine, RM facts + compliance in our system prompt, deterministic link injection), metered generate/meters APIs, /suite/copy tool page; L2 tile now live Co-Authored-By: Claude Fable 5 --- public/suite-copy.html | 62 ++++++++++++++++++++ public/suite-copy.js | 126 +++++++++++++++++++++++++++++++++++++++++ public/suite.js | 2 +- server.js | 59 ++++++++++++++++++- suite-ai.js | 117 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 public/suite-copy.html create mode 100644 public/suite-copy.js create mode 100644 suite-ai.js diff --git a/public/suite-copy.html b/public/suite-copy.html new file mode 100644 index 0000000..bf3c88e --- /dev/null +++ b/public/suite-copy.html @@ -0,0 +1,62 @@ +Copy Engine | The Circle Suite + + + +
+
CIRCLE SUITE · LEVEL 2 · ASCENSUS
+

Copy Engine.

+

Tell it what you need and who it's for. It writes in the team's voice, follows our honesty rules, and drops your invite link at the end. Read it before you send it — you're the publisher.

+ +
+ +
+ +
+ + +
The more specific you are — the person, the angle, the situation — the better it writes. Mention any detail you want included.
+
+ + + +
+
+
+
+
+
+
+ +

Teach it forward: when this saves you fifteen minutes, show your two how to use it — that's how the whole team gets faster.
Everything the engine writes follows the team's rules: no income promises, no invented numbers, honest about risk. It's still your name on the post, so read before you send. Independent team resource · No income is guaranteed · Cryptocurrency involves risk.

+
+ + + diff --git a/public/suite-copy.js b/public/suite-copy.js new file mode 100644 index 0000000..19a698b --- /dev/null +++ b/public/suite-copy.js @@ -0,0 +1,126 @@ +// Copy Engine client. Entitlement + meter come from the server; the page just +// collects a brief, streams nothing (single response), and shows the result. +(function () { + 'use strict'; + var KINDS = [ + { k: 'post', label: 'Social post' }, + { k: 'dm', label: 'Direct message' }, + { k: 'followup', label: 'Follow-up' }, + { k: 'objection', label: 'Objection reply' }, + { k: 'email', label: 'Email' }, + { k: 'story', label: 'Personal story post' } + ]; + var PLACEHOLDERS = { + post: "e.g. A post for people who've been burned by side hustles before — honest, no hype, mention that everything is verifiable on-chain", + dm: "e.g. A message to an old coworker who's always complaining about money but is skeptical of crypto", + followup: "e.g. Following up with someone who watched the video last week and said 'let me think about it'", + objection: "e.g. They said: isn't this just a pyramid scheme?", + email: "e.g. Email to my list introducing the team build — they know me from my marketing content, not crypto", + story: "e.g. Why I stopped chasing complicated funnels and went back to something simple I could teach" + }; + var $ = function (id) { return document.getElementById(id); }; + var kind = 'post', busy = false, lastBrief = ''; + + function renderKinds() { + var host = $('cpKinds'); + host.innerHTML = ''; + KINDS.forEach(function (x) { + var b = document.createElement('button'); + b.type = 'button'; + b.className = 'cp-kind' + (x.k === kind ? ' on' : ''); + b.textContent = x.label; + b.addEventListener('click', function () { + kind = x.k; + $('cpBrief').placeholder = PLACEHOLDERS[kind] || ''; + renderKinds(); + }); + host.appendChild(b); + }); + } + + function showMeter(m) { + if (!m || !m.limit) { $('cpMeter').textContent = ''; return; } + $('cpMeter').innerHTML = '' + m.remaining + ' of ' + m.limit + ' generations left this month'; + } + + function gate(msg, gold) { + var g = $('cpGate'); + g.style.display = 'block'; + g.innerHTML = msg; + if (gold === false) g.style.borderColor = 'var(--teal)'; + $('cpCard').style.opacity = '.55'; + $('cpGo').disabled = true; + $('cpBrief').disabled = true; + } + + async function boot() { + renderKinds(); + try { + var r = await fetch('/api/public/suite-meters'); + if (r.status === 401) { + gate('You’re not signed in yet. Open the Suite and connect the wallet that holds your position — one free signature — then come back.'); + return; + } + if (r.status === 403) { + gate('The Copy Engine isn’t open for this position yet. See your Suite.'); + return; + } + if (!r.ok) return; + var d = await r.json(); + var m = d.meters && d.meters.copy; + if (m && m.reason === 'locked') { + gate('The Copy Engine unlocks at Ascensus (level 2). You’re at level ' + d.level + ' — your next upgrade opens it, along with the Page Builder. See what an upgrade costs →'); + return; + } + showMeter(m); + } catch (e) {} + } + + async function run() { + if (busy) return; + var brief = $('cpBrief').value.trim(); + if (brief.length < 3) { $('cpBrief').focus(); return; } + lastBrief = brief; + busy = true; + $('cpErr').style.display = 'none'; + $('cpGo').disabled = true; + $('cpGo').innerHTML = 'Writing… (up to a minute)'; + try { + var r = await fetch('/api/public/suite-generate', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kind: kind, brief: brief }) + }); + var d = await r.json(); + if (!r.ok) { + $('cpErr').textContent = d.error || 'That didn’t go through — try again.'; + $('cpErr').style.display = 'block'; + if (d.meter) showMeter(d.meter); + } else { + $('cpText').textContent = d.text; + $('cpOut').style.display = 'block'; + $('cpAgain').style.display = ''; + showMeter(d.meter); + $('cpOut').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + } catch (e) { + $('cpErr').textContent = 'Connection hiccup — try again.'; + $('cpErr').style.display = 'block'; + } + busy = false; + $('cpGo').disabled = false; + $('cpGo').textContent = '✍️ Write it'; + } + + document.addEventListener('DOMContentLoaded', function () { + boot(); + $('cpGo').addEventListener('click', run); + $('cpAgain').addEventListener('click', run); + $('cpCopy').addEventListener('click', function () { + var t = $('cpText').textContent; + navigator.clipboard.writeText(t).then(function () { + $('cpCopied').style.display = ''; + setTimeout(function () { $('cpCopied').style.display = 'none'; }, 2500); + }); + }); + }); +})(); diff --git a/public/suite.js b/public/suite.js index bdec777..07a0c19 100644 --- a/public/suite.js +++ b/public/suite.js @@ -13,7 +13,7 @@ { lv: 1, ico: '🎓', name: 'The Circle Method', desc: 'The 10-lesson recruiting and coaching course. Lessons unlock with this same wallet.', href: '/training', live: true }, { lv: 1, ico: '📈', name: 'Live Dashboard', desc: 'Your pipeline, organization bar, coach panel and wallet-verified team messages.', href: '/my', live: true }, { lv: 1, ico: '🤖', name: 'AI Coach', desc: '24/7 answers about the system, in 21+ languages — the chat bubble on every page of this site.', href: '#', live: true, coach: true }, - { lv: 2, ico: '✍️', name: 'Copy Engine', desc: 'An AI copywriter tuned to this business: posts, DMs, follow-ups and objection replies with your link filled in.', live: false }, + { lv: 2, ico: '✍️', name: 'Copy Engine', desc: 'An AI copywriter tuned to this business: posts, DMs, follow-ups and objection replies with your link filled in.', href: '/suite/copy', live: true }, { lv: 2, ico: '🧱', name: 'Page Builder', desc: 'Answer five questions, get your own hosted bridge page — your story, your angle video, your QR.', live: false }, { lv: 3, ico: '📧', name: 'Email Engine', desc: 'Follow-up sequences and broadcasts in the team voice, exportable to any autoresponder.', live: false }, { lv: 3, ico: '🎥', name: 'Video Maker', desc: 'The team’s master promo videos rendered with your personal end-card — your name, your QR, your link.', live: false }, diff --git a/server.js b/server.js index 64373ea..2fabf80 100644 --- a/server.js +++ b/server.js @@ -17,6 +17,8 @@ const CONFIG_FILE = path.join(DATA_DIR, 'config.json'); const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'changeme'; const IS_PROD = process.env.NODE_ENV === 'production'; messages.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD }); +const suiteMeter = require('./suite-meter'); suiteMeter.init({ dataDir: DATA_DIR }); +const suiteAI = require('./suite-ai'); suiteAI.init({ dataDir: DATA_DIR }); const tgbot = require('./tgbot'); tgbot.init({ dataDir: DATA_DIR, chain, getConfig, messages, baseUrl: 'https://rmcircle.team' }); const SESSION_TTL = 8 * 60 * 60 * 1000; @@ -634,6 +636,61 @@ async function handleApi(req,res,pathname){ if(r.error)return json(res,401,{error:r.error}); return json(res,200,{ok:true,id:r.id},{'Set-Cookie':messages.sessionCookie(r.token)}); } + // ── Circle Suite entitlement helper (shared by suite-me and the tools) ──── + async function suiteEntitlement(req){ + const s=messages.authFromCookie(req); + if(!s)return {error:'Not signed in.',code:401}; + const cached=memberCache.get(s.id); + let d; + if(cached&&Date.now()-cached.ts<120000)d=cached.data; + else{ + d=await Promise.race([chain.memberPublic(s.id),new Promise((_,rej)=>setTimeout(()=>rej(new Error('timeout')),20000))]); + memberCache.set(s.id,{ts:Date.now(),data:d}); + } + if(!d||!d.registered)return {error:'Position not found.',code:404}; + const cfg=getConfig(); + const roots=String(cfg.teamRootId||cfg.orgRootId||'21').split(',').map(x=>Number(x.trim())).filter(Boolean); + const chainIds=Array.isArray(d.uplineChain)?d.uplineChain.map(Number):[]; + const inOrg=roots.some(r=>Number(d.id)===r||chainIds.includes(r)); + const allow=String(cfg.suiteAllowlist||'').split(',').map(x=>Number(x.trim())).filter(Boolean); + const beta=allow.length>0; + const allowed=!beta||allow.includes(Number(d.id)); + return {d,inOrg,beta,allowed}; + } + + if(req.method==='GET'&&pathname==='/api/public/suite-meters'){ + const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'Not available for this position yet.'}); + return json(res,200,{level:e.d.level,meters:suiteMeter.meters(e.d.id,e.d.level)}); + } + + if(req.method==='POST'&&pathname==='/api/public/suite-generate'){ + const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); + const b=await bodyJson(req)||{}; + const kind=String(b.kind||'post'); + const brief=String(b.brief||'').trim().slice(0,1200); + if(!suiteAI.KINDS[kind])return json(res,400,{error:'Unknown copy type.'}); + if(brief.length<3)return json(res,400,{error:'Tell the engine what the piece is about.'}); + if(!suiteAI.configured())return json(res,503,{error:'The Copy Engine is warming up — try again shortly.'}); + const gate=suiteMeter.check(e.d.id,e.d.level,'copy'); + if(!gate.allowed){ + return json(res,429,{error:gate.reason==='locked' + ? 'The Copy Engine unlocks at Ascensus (level 2). Your next upgrade opens it.' + : 'You have used all '+gate.limit+' Copy Engine generations for this month. It resets on the 1st — or a level upgrade raises your allowance.',meter:gate}); + } + const link='https://rmcircle.team/join/'+e.d.id; + try{ + const text=await suiteAI.generate(kind,brief,{link:link,id:e.d.id}); + suiteMeter.record(e.d.id,'copy',1); + return json(res,200,{text:text,meter:suiteMeter.check(e.d.id,e.d.level,'copy')}); + }catch(err){ + return json(res,502,{error:String(err.message||err)}); + } + } + if(req.method==='GET'&&pathname==='/api/public/suite-me'){ // The Circle Suite entitlement: signed-in wallet -> live level + org check. const s=messages.authFromCookie(req); @@ -932,7 +989,7 @@ const server=http.createServer(async(req,res)=>{ if((mj=pathname.match(/^\/join\/(\d{1,15})$/)))return serveMemberPage(req,res,path.join(PUBLIC_DIR,'join.html'),'join',mj[1]); } let file; - if(pathname==='/')file=path.join(PUBLIC_DIR,'index.html');else if(pathname==='/app'||pathname==='/app/')file=path.join(PUBLIC_DIR,'app.html');else if(pathname==='/start'||pathname==='/start/')file=path.join(PUBLIC_DIR,'start.html');else if(pathname==='/training'||pathname==='/training/')file=path.join(PUBLIC_DIR,'training.html');else if(pathname==='/admin'||pathname==='/admin/')file=path.join(PUBLIC_DIR,'admin.html');else if(pathname==='/my'||pathname==='/my/'||/^\/my\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'my.html');else if(pathname==='/contract'||pathname==='/contract/')file=path.join(PUBLIC_DIR,'contract.html');else if(pathname==='/disclaimer'||pathname==='/disclaimer/')file=path.join(PUBLIC_DIR,'disclaimer.html');else if(pathname==='/how-pay-works'||pathname==='/how-pay-works/')file=path.join(PUBLIC_DIR,'how-pay-works.html');else if(pathname==='/tools'||pathname==='/tools/')file=path.join(PUBLIC_DIR,'tools.html');else if(pathname==='/fast-start'||pathname==='/fast-start/')file=path.join(PUBLIC_DIR,'fast-start.html');else if(pathname==='/flyers'||pathname==='/flyers/')file=path.join(PUBLIC_DIR,'flyers.html');else if(pathname==='/weekly-rhythm'||pathname==='/weekly-rhythm/')file=path.join(PUBLIC_DIR,'weekly-rhythm.html');else if(pathname==='/generation-pay'||pathname==='/generation-pay/')file=path.join(PUBLIC_DIR,'generation-pay.html');else if(pathname==='/suite'||pathname==='/suite/')file=path.join(PUBLIC_DIR,'suite.html');else if(pathname==='/direct-join'||pathname==='/direct-join/'){if(!getSession(req)){res.writeHead(302,{Location:'/admin'});return res.end();}file=path.join(ROOT,'private','direct-join.html');}else if(pathname==='/join-now'||pathname==='/join-now/'){if(!getConfig().dappFallbackPublic){res.writeHead(302,{Location:'/start'});return res.end();}file=path.join(ROOT,'private','join-now.html');}else if(/^\/join\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'join.html');else if(pathname==='/join'||pathname==='/join/'){res.writeHead(302,{Location:'/join-now'});return res.end();}else{ + if(pathname==='/')file=path.join(PUBLIC_DIR,'index.html');else if(pathname==='/app'||pathname==='/app/')file=path.join(PUBLIC_DIR,'app.html');else if(pathname==='/start'||pathname==='/start/')file=path.join(PUBLIC_DIR,'start.html');else if(pathname==='/training'||pathname==='/training/')file=path.join(PUBLIC_DIR,'training.html');else if(pathname==='/admin'||pathname==='/admin/')file=path.join(PUBLIC_DIR,'admin.html');else if(pathname==='/my'||pathname==='/my/'||/^\/my\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'my.html');else if(pathname==='/contract'||pathname==='/contract/')file=path.join(PUBLIC_DIR,'contract.html');else if(pathname==='/disclaimer'||pathname==='/disclaimer/')file=path.join(PUBLIC_DIR,'disclaimer.html');else if(pathname==='/how-pay-works'||pathname==='/how-pay-works/')file=path.join(PUBLIC_DIR,'how-pay-works.html');else if(pathname==='/tools'||pathname==='/tools/')file=path.join(PUBLIC_DIR,'tools.html');else if(pathname==='/fast-start'||pathname==='/fast-start/')file=path.join(PUBLIC_DIR,'fast-start.html');else if(pathname==='/flyers'||pathname==='/flyers/')file=path.join(PUBLIC_DIR,'flyers.html');else if(pathname==='/weekly-rhythm'||pathname==='/weekly-rhythm/')file=path.join(PUBLIC_DIR,'weekly-rhythm.html');else if(pathname==='/generation-pay'||pathname==='/generation-pay/')file=path.join(PUBLIC_DIR,'generation-pay.html');else if(pathname==='/suite'||pathname==='/suite/')file=path.join(PUBLIC_DIR,'suite.html');else if(pathname==='/suite/copy'||pathname==='/suite/copy/')file=path.join(PUBLIC_DIR,'suite-copy.html');else if(pathname==='/direct-join'||pathname==='/direct-join/'){if(!getSession(req)){res.writeHead(302,{Location:'/admin'});return res.end();}file=path.join(ROOT,'private','direct-join.html');}else if(pathname==='/join-now'||pathname==='/join-now/'){if(!getConfig().dappFallbackPublic){res.writeHead(302,{Location:'/start'});return res.end();}file=path.join(ROOT,'private','join-now.html');}else if(/^\/join\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'join.html');else if(pathname==='/join'||pathname==='/join/'){res.writeHead(302,{Location:'/join-now'});return res.end();}else{ const safe=path.normalize(pathname).replace(/^([.][.][/\\])+/, '').replace(/^[/\\]+/,'');file=path.join(PUBLIC_DIR,safe);if(!file.startsWith(PUBLIC_DIR))file=''; } if(file&&staticFile(req,res,file))return;return staticFile(req,res,path.join(PUBLIC_DIR,'404.html'),404); diff --git a/suite-ai.js b/suite-ai.js new file mode 100644 index 0000000..fbe7d1d --- /dev/null +++ b/suite-ai.js @@ -0,0 +1,117 @@ +// Circle Suite AI layer — talks to the Suite's own Hermes engine (provisioned +// like a BV tenant, own $10-capped OpenRouter key). Creds live in the data +// volume as suite-engine.json, never in env or the repo. +// +// Design rule: the RM knowledge and the compliance rules are OUR system prompt, +// sent with every request. They live in version control, not in the engine's +// knowledge base, so a rule change is a deploy — not a retraining. +'use strict'; +const fs = require('fs'); +const path = require('path'); + +let DATA_DIR = null; +function init(opts) { DATA_DIR = opts.dataDir; } +function creds() { + try { return JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'suite-engine.json'), 'utf8')); } + catch (e) { return null; } +} +function configured() { const c = creds(); return !!(c && c.url && c.key); } + +// ── The non-negotiables. Every generation carries these. ──────────────────── +const COMPLIANCE = [ + 'NEVER promise, guarantee, project or imply income, earnings, returns or profit.', + 'NEVER use hype like "guaranteed", "risk-free", "passive income", "get rich", "financial freedom is certain".', + 'Do NOT invent numbers. The ONLY money figures you may state: Premium entry is 362 POL; the first upgrade (Ascensus) is 621.40 POL; upgrade prices double at each level. If you are unsure of a figure, leave it out.', + 'NEVER quote dollar values — POL\'s price moves. Speak in POL quantities or plain language only.', + 'We promote the PREMIUM tier only. Never mention the Standard tier or its prices.', + 'Be honest that this involves real cryptocurrency and real risk, and that results depend on effort — especially in anything longer than a couple of sentences.', + 'Never claim the team or the contract does something it does not. No fake urgency, no fake scarcity, no invented testimonials or member results.' +].join(' '); + +const FACTS = [ + 'The RM Circle is a crypto team build running on a public, verified smart contract on Polygon (0x33BdAEEfd6d17D80aE53816c916dFb26c4fB2DAF). The code cannot be changed and holds no member funds — every payment is person-to-person in the same transaction, and anyone can verify it on-chain.', + 'The whole job: get 2 personal referrals to qualify, help those 2 get their 2, and teach them to do the same. Depth over width.', + 'Two income streams: your directs\' entry payments come to you, and upgrade payments travel up the matrix to the first qualified upline whose level is at or above the level the buyer is leaving ("your level is your reach").', + 'Upgrades carry no fees — 100% goes to a member. The 5% admin charge exists on entries only.', + 'Levels: Scintilla, Ascensus, Fabrica, Culmen, Apex, Fastigium, Vertex, Corona.', + 'Every paid position also gets a license to the Circle Suite — the team\'s marketing toolkit (promo center, printable handouts with the member\'s QR, training course, live dashboard, AI coach, and more as it ships).' +].join(' '); + +const VOICE = 'Write like a straight-talking internet marketer who has been around long enough to hate hype: plain words, short sentences, honest about risk, warm and confident, never salesy or breathless. Contractions are good. No corporate filler. No emoji unless the format asks for it.'; + +// kind -> {label, shape} +const KINDS = { + post: { label: 'Social post', shape: 'Write ONE social media post of 40-90 words. Punchy opening line. No hashtags. At most one emoji. End with a soft invitation to look, not a hard sell.' }, + dm: { label: 'Direct message', shape: 'Write ONE short direct message to send a friend or contact, 30-60 words. Conversational, personal, zero pressure. It should sound like a text from a real person, not a pitch. Ask a question or invite them to take a look.' }, + followup: { label: 'Follow-up message', shape: 'Write ONE short follow-up message (30-70 words) to someone who looked but has not decided. No guilt, no pressure, no fake urgency. Be useful and easy to say no to.' }, + objection: { label: 'Objection reply', shape: 'Write ONE reply (60-120 words) that answers the objection honestly. Concede whatever is true in it first, then give the real answer, then invite them to verify for themselves. Never argue.' }, + email: { label: 'Email', shape: 'Write ONE short email: a subject line on the first line prefixed "Subject: ", then a blank line, then a 90-160 word body and a sign-off line. Plain text, no HTML.' }, + story: { label: 'Short story/testimonial-style post', shape: 'Write ONE first-person post of 80-140 words telling a small, believable, PERSONAL story about the member\'s own experience or thinking. Do NOT invent earnings, results, or events — keep it about motivation, doubts, or what they are learning.' } +}; + +function buildMessages(kind, brief, member) { + const k = KINDS[kind] || KINDS.post; + const link = member && member.link ? member.link : ''; + const sys = + 'You write promotional copy for a member of the RM Circle team.\n\n' + + 'WHAT IT IS: ' + FACTS + '\n\n' + + 'VOICE: ' + VOICE + '\n\n' + + 'COMPLIANCE (absolute, overrides everything else): ' + COMPLIANCE + '\n\n' + + 'FORMAT: ' + k.shape + '\n\n' + + (link + ? 'CALL TO ACTION: end with this exact link on its own final line, nothing after it: ' + link + '\n\n' + : 'Do not invent a link.\n\n') + + 'Output ONLY the finished copy. No preamble, no explanation, no options, no quotation marks around it, no markdown headers.'; + const user = 'Write ' + k.label.toLowerCase() + '.\n\nWhat this piece is about / who it is for:\n' + brief; + return [{ role: 'system', content: sys }, { role: 'user', content: user }]; +} + +// Engine call. Long timeout: the agent narrates internally before answering. +function generate(kind, brief, member) { + return new Promise(function (resolve, reject) { + const c = creds(); + if (!c) return reject(new Error('The Copy Engine is not configured yet.')); + const body = JSON.stringify({ model: c.model || 'hermes-agent', messages: buildMessages(kind, brief, member) }); + const u = new URL(c.url.replace(/\/$/, '') + '/chat/completions'); + const lib = u.protocol === 'https:' ? require('https') : require('http'); + const req = lib.request({ + hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), 'Authorization': 'Bearer ' + c.key } + }, function (res) { + let data = ''; + res.on('data', function (d) { data += d; }); + res.on('end', function () { + if (res.statusCode !== 200) return reject(new Error('Engine returned ' + res.statusCode)); + try { + const j = JSON.parse(data); + let text = (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content || '').trim(); + text = cleanup(text, member); + if (!text) return reject(new Error('The engine came back empty — try again.')); + resolve(text); + } catch (e) { reject(new Error('Could not read the engine response.')); } + }); + }); + req.on('error', function (e) { reject(new Error('Could not reach the Copy Engine: ' + e.message)); }); + req.setTimeout(240000, function () { req.destroy(new Error('The engine took too long — try again.')); }); + req.write(body); req.end(); + }); +} + +// Deterministic post-processing: strip scaffolding the model sometimes adds, +// and make sure the member's real link is the one that ships. +function cleanup(text, member) { + let t = String(text || ''); + t = t.replace(/^```[a-z]*\s*/i, '').replace(/```\s*$/i, ''); + t = t.replace(/^(here('|’)s|here is)[^\n:]{0,60}:\s*/i, ''); + t = t.replace(/^["“](.+)["”]$/s, '$1'); + const link = member && member.link; + if (link) { + // any rmcircle link the model produced becomes the member's own + t = t.replace(/https?:\/\/(?:www\.)?rmcircle\.team\/\S*/gi, link); + t = t.replace(/\[LINK\]|\{link\}/gi, link); + if (t.indexOf(link) === -1) t = t.replace(/\s*$/, '\n\n' + link); + } + return t.trim(); +} + +module.exports = { init, configured, generate, KINDS };