Founder Desk (L8): weekly campaign pack + Suite API

Completes the ladder — no tier is a placeholder now.

Every tier below builds one thing at a time, which is right for someone
still finding their words. At the top the constraint is different: these are
people running organisations who don't have an afternoon to spend clicking.
So one pass produces five social posts across five different angles, three
outreach messages (first approach, follow-up, pyramid-scheme answer), an
email, and three text ads — all carrying their link, all in the member's own
voice profile if they have one.

Partial failures are reported rather than hidden: if the engine can't finish
a section the pack says which, instead of quietly handing over a short week.

API access is real, not a label: a personal key over
GET /me, GET /team, POST /generate. Keys are stored hashed and shown exactly
once, compared in constant time, and every call goes through the same meters
and compliance rules as the web tools.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-28 11:09:48 -05:00
parent 85261f2719
commit f05d5a2746
7 changed files with 514 additions and 4 deletions
+167
View File
@@ -0,0 +1,167 @@
// Circle Suite — Founder Desk (level 8, Corona).
//
// Every tier below this produces ONE thing at a time: a post, a page, a
// sequence, an ad. That is right for someone still learning what to say. At the
// top of the ladder the constraint is different — these are people running
// organisations who do not have an afternoon to spend clicking one tool at a
// time. So the Founder Desk does the whole week in a single pass, across every
// channel, and hands it over as one pack.
//
// Second half of the tool is API access: a personal key so a founder can pull
// their own position, their organisation triage and generated copy into
// whatever they already run — a spreadsheet, a bot, their own site.
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const suiteAi = require('./suite-ai');
const suiteEmail = require('./suite-email');
const suiteTextAds = require('./suite-textads');
const MIN_LEVEL = 8;
let DATA_DIR = null;
function init(opts) {
DATA_DIR = opts.dataDir;
try { fs.mkdirSync(packDir(), { recursive: true }); } catch (e) {}
}
function packDir() { return path.join(DATA_DIR, 'founder'); }
function packFile(id) { return path.join(packDir(), String(Number(id)) + '.json'); }
function keysFile() { return path.join(DATA_DIR, 'suite-api-keys.json'); }
// ── API keys ────────────────────────────────────────────────────────────────
// Stored hashed. We show the key exactly once, at creation, the way every other
// API does it — if it leaks later we cannot un-leak it, but at least the file
// on disk is not a list of live credentials.
function readKeys() { try { return JSON.parse(fs.readFileSync(keysFile(), 'utf8')); } catch (e) { return {}; } }
function writeKeys(v) { try { fs.writeFileSync(keysFile(), JSON.stringify(v), { mode: 0o600 }); } catch (e) {} }
function hashKey(k) { return crypto.createHash('sha256').update(String(k)).digest('hex'); }
function keyInfo(memberId) {
const all = readKeys();
const rec = all[String(memberId)];
if (!rec) return { exists: false };
return { exists: true, createdAt: rec.createdAt, lastUsedAt: rec.lastUsedAt || null, hint: rec.hint };
}
function issueKey(memberId) {
const raw = 'rmc_' + crypto.randomBytes(24).toString('hex');
const all = readKeys();
all[String(memberId)] = {
hash: hashKey(raw), createdAt: new Date().toISOString(),
hint: raw.slice(0, 8) + '…' + raw.slice(-4)
};
writeKeys(all);
return raw; // the only time this is ever returned
}
function revokeKey(memberId) {
const all = readKeys();
delete all[String(memberId)];
writeKeys(all);
return true;
}
// Constant-time compare against every stored hash. The key set is small (only
// level-8 positions), so a linear scan is fine and keeps the key itself
// unguessable from the file.
function memberForKey(raw) {
if (!raw || typeof raw !== 'string' || raw.length < 16) return null;
const h = Buffer.from(hashKey(raw), 'hex');
const all = readKeys();
let found = null;
Object.keys(all).forEach(function (id) {
const stored = Buffer.from(String(all[id].hash || ''), 'hex');
if (stored.length === h.length && crypto.timingSafeEqual(stored, h)) found = Number(id);
});
if (found != null) {
all[String(found)].lastUsedAt = new Date().toISOString();
writeKeys(all);
}
return found;
}
// ── The weekly campaign pack ────────────────────────────────────────────────
function loadPack(id) { try { return JSON.parse(fs.readFileSync(packFile(id), 'utf8')); } catch (e) { return null; } }
function savePack(id, pack) { try { fs.writeFileSync(packFile(id), JSON.stringify(pack)); } catch (e) {} }
// Angles rotate through the hooks the rest of the site already uses, so the
// week has variety built in rather than seven versions of the same post.
const WEEK_ANGLES = [
{ key: 'two', brief: 'the whole job is two people and helping them get their two' },
{ key: 'graveyard', brief: 'why this one does not disappear like every other program — public contract, holds nobody\'s money' },
{ key: 'pocket', brief: 'the entry is small and one-time, not a subscription that bleeds you every month' },
{ key: 'verify', brief: 'do not trust anyone including me — read the contract yourself before you spend a cent' },
{ key: 'phone', brief: 'it runs entirely from a phone; no laptop, no stock, no shipping' }
];
function splitNumbered(text, want) {
// Tolerant split on "1." / "POST 1" / "---" style separators.
const parts = String(text || '')
.split(/(?:^|\n)\s*(?:[-*#]{3,}|(?:POST|ITEM)?\s*\d+[.)]?\s*)(?=\n|\s)/i)
.map(function (x) { return x.replace(/^[\s*#>-]+/, '').trim(); })
.filter(function (x) { return x.length > 25; });
return parts.slice(0, want);
}
async function buildPack(opts) {
const id = Number(opts.id);
const link = 'https://rmcircle.team/join/' + id;
const member = { link: link, id: id, voice: opts.voice || '' };
const pack = { id: id, at: new Date().toISOString(), posts: [], messages: [], emails: [], textAds: [] };
const problems = [];
// 1) A week of social posts, one per angle.
try {
const postInstruction =
'Write 5 separate social media posts for a member of the RM Circle team build, one for each of these angles:\n' +
WEEK_ANGLES.map(function (a, i) { return (i + 1) + '. ' + a.brief; }).join('\n') + '\n\n' +
'Each post: 40-90 words, punchy opening line, no hashtags, at most one emoji, ending with a soft invitation to look rather than a hard sell, then this exact link on its own final line: ' + link + '\n\n' +
'ABSOLUTE RULES: never promise, guarantee, project or imply income, earnings or returns. Never quote dollar values — POL quantities only. Never mention the Standard tier. Be honest that this is real cryptocurrency with real risk. Do not invent numbers.\n\n' +
(opts.voice ? opts.voice + '\n\n' : '') +
'Separate each post with a line containing only ---. Output nothing else: no headings, no commentary, no numbering.';
const raw = await suiteAi.generateRaw(postInstruction);
pack.posts = splitNumbered(raw, 5).map(function (t, i) {
return { angle: WEEK_ANGLES[i] ? WEEK_ANGLES[i].key : 'general', text: t };
});
if (!pack.posts.length) problems.push('posts');
} catch (e) { problems.push('posts'); }
// 2) Outreach messages — a first approach, a follow-up, an objection reply.
const msgKinds = [
{ kind: 'dm', label: 'First message', brief: 'reaching out to someone you know but have not spoken to in a while' },
{ kind: 'followup', label: 'Follow-up', brief: 'they looked at your link a few days ago and went quiet' },
{ kind: 'objection', label: 'Objection reply', brief: 'they said this sounds like a pyramid scheme' }
];
for (const mk of msgKinds) {
try {
const t = await suiteAi.generate(mk.kind, mk.brief, member);
pack.messages.push({ label: mk.label, kind: mk.kind, text: t });
} catch (e) { problems.push(mk.kind); }
}
// 3) An email the founder can drop into their autoresponder.
try {
pack.emails = await suiteEmail.generate('broadcast', 'a weekly update to your list about the team build', member);
} catch (e) { problems.push('email'); }
// 4) Text ads ready for the Traffic Desk.
try {
const t = await suiteTextAds.generate({ angle: 'general', count: 3 });
pack.textAds = t.variants;
} catch (e) { problems.push('text ads'); }
pack.problems = problems;
const produced = pack.posts.length + pack.messages.length + pack.emails.length + pack.textAds.length;
if (!produced) throw new Error('The engine did not come back with anything usable — try again in a moment.');
savePack(id, pack);
return pack;
}
module.exports = {
init, MIN_LEVEL, buildPack, loadPack,
keyInfo, issueKey, revokeKey, memberForKey, WEEK_ANGLES
};