diff --git a/routes/personaforge.ts b/routes/personaforge.ts new file mode 100644 index 0000000..77bef83 --- /dev/null +++ b/routes/personaforge.ts @@ -0,0 +1,1189 @@ +/** + * PersonaForge — Agentify.help Admin Corpus Pipeline + * + * Admin tool for building WellAgent corpora from any expert's published work. + * Paul Graham (essay corpus) is the first built-in subject. + * + * Routes (agentify.help host only, admin key protected): + * GET /personaforge — SSR admin dashboard + * GET /api/personaforge/subjects — list all subjects + * POST /api/personaforge/subjects — add subject + * POST /api/personaforge/subjects/:slug/fetch — fetch artifact list from source + * POST /api/personaforge/subjects/:slug/build — build + upload corpus (async) + * GET /api/personaforge/subjects/:slug/logs — SSE build log stream + * POST /api/personaforge/subjects/:slug/overture — generate Overture letter via Claude + * POST /api/personaforge/subjects/:slug/submit — submit to WellSpr.ing staging import + * POST /api/personaforge/subjects/:slug/notify — send Overture email via Resend + */ + +import type { Express, Request, Response } from "express"; +import { pool } from "../db"; +import crypto from "crypto"; +import Anthropic from "@anthropic-ai/sdk"; +import { Resend } from "resend"; +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; +import { Upload } from "@aws-sdk/lib-storage"; + +const ADMIN_KEY = process.env.ADMIN_KEY || "b0db7a87384fc814b0f46ea7bdc6ab6a81152be5b098718b"; +const claude = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); +const resend = new Resend(process.env.RESEND_API_KEY); + +const S3_HOSTNAME = process.env.VULTR_S3_HOSTNAME || "ewr1.vultrobjects.com"; +const S3_BUCKET = process.env.CORPUS_S3_BUCKET || "agentify-corpus"; +const S3_ENDPOINT = `https://${S3_HOSTNAME}`; + +function getS3(): S3Client { + return new S3Client({ + endpoint: S3_ENDPOINT, + region: "ewr1", + forcePathStyle: true, + credentials: { + accessKeyId: process.env.VULTR_S3_ACCESS_KEY || "", + secretAccessKey: process.env.VULTR_S3_SECRET_KEY || "", + }, + }); +} + +// ── In-memory SSE broadcaster per slug ───────────────────────────────────── +const liveStreams = new Map>(); +function broadcast(slug: string, event: string, data: string) { + const clients = liveStreams.get(slug); + if (!clients) return; + const line = `event: ${event}\ndata: ${data}\n\n`; + clients.forEach(res => { try { res.write(line); } catch {} }); +} +function broadcastLog(slug: string, msg: string) { + console.log(`[PersonaForge/${slug}] ${msg}`); + broadcast(slug, "log", JSON.stringify({ ts: new Date().toISOString(), msg })); + pool.query( + `UPDATE personaforge_subjects SET build_log = COALESCE(build_log,'') || $2, updated_at=NOW() WHERE slug=$1`, + [slug, `[${new Date().toISOString().slice(11,19)}] ${msg}\n`] + ).catch(() => {}); +} + +// ── DB init ───────────────────────────────────────────────────────────────── +export async function initPersonaForge() { + await pool.query(` + CREATE TABLE IF NOT EXISTS personaforge_subjects ( + id SERIAL PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + subject_name TEXT NOT NULL, + domain TEXT, + source_type TEXT NOT NULL DEFAULT 'essays', + source_url TEXT, + essay_count INT DEFAULT 0, + chunk_count INT DEFAULT 0, + build_status TEXT NOT NULL DEFAULT 'pending', + build_log TEXT DEFAULT '', + corpus_version TEXT DEFAULT '1.0.0', + bundle_id TEXT, + overture_text TEXT, + overture_sent_at TIMESTAMPTZ, + overture_email TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS personaforge_artifacts ( + id SERIAL PRIMARY KEY, + subject_slug TEXT NOT NULL, + artifact_id TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + source_url TEXT, + pub_date TEXT, + artifact_type TEXT DEFAULT 'essay', + word_count INT, + chunk_count INT DEFAULT 0, + s3_key TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + // Seed Paul Graham as first subject + await pool.query(` + INSERT INTO personaforge_subjects + (slug, subject_name, domain, source_type, source_url, corpus_version) + VALUES + ('paul-graham', 'Paul Graham', 'Essays / Startup / Technology / Philosophy', + 'essays', 'https://paulgraham.com/articles.html', '1.0.0') + ON CONFLICT (slug) DO NOTHING + `); + console.log("[PersonaForge] Tables ready — Paul Graham seeded as subject #1"); +} + +// ── Host guard helper ──────────────────────────────────────────────────────── +function isAgentifyAdmin(req: Request): boolean { + const host = [ + req.headers["x-forwarded-host"], + req.headers["x-geo-node-host"], + req.hostname, + req.headers["host"], + ].flat().filter(Boolean).map((h: any) => h.toString().toLowerCase().split(",")[0].trim())[0] || ""; + return host.includes("agentify") && !host.startsWith("skills."); +} + +function requireAdminKey(req: Request, res: Response): boolean { + const key = (req.headers["x-admin-key"] as string) || req.query.admin_key as string; + if (key !== ADMIN_KEY) { + res.status(403).json({ error: "Admin key required" }); + return false; + } + return true; +} + +// ── Essay scraping (paulgraham.com-style HTML) ─────────────────────────────── +async function fetchEssayList(sourceUrl: string): Promise<{ slug: string; title: string; url: string }[]> { + const html = await fetch(sourceUrl, { headers: { "User-Agent": "WellSpr.ing PersonaForge/1.0" } }) + .then(r => r.text()); + const base = new URL(sourceUrl); + const results: { slug: string; title: string; url: string }[] = []; + // Match links to .html pages + const re = /]*>([^<]+)<\/a>/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(html)) !== null) { + const href = m[1].trim(); + const title = m[2].trim(); + if (!title || title.length < 3) continue; + try { + const full = new URL(href, base).href; + const slug = href.replace(/.*\//, "").replace(".html", "").toLowerCase().replace(/[^a-z0-9]+/g, "-"); + if (!results.find(r => r.slug === slug)) results.push({ slug, title, url: full }); + } catch {} + } + return results; +} + +async function fetchEssayText(url: string): Promise { + const html = await fetch(url, { headers: { "User-Agent": "WellSpr.ing PersonaForge/1.0" }, signal: AbortSignal.timeout(15000) }) + .then(r => r.text()).catch(() => ""); + // Strip tags, decode entities, collapse whitespace + return html + .replace(/]*>[\s\S]*?<\/script>/gi, " ") + .replace(/]*>[\s\S]*?<\/style>/gi, " ") + .replace(/<[^>]+>/g, " ") + .replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/ /g, " ").replace(/&#[0-9]+;/g, " ") + .replace(/\s+/g, " ").trim(); +} + +function chunkText(text: string, maxWords = 400): string[] { + const sentences = text.split(/(?<=[.!?])\s+/); + const chunks: string[] = []; + let current = ""; + for (const s of sentences) { + const proposed = current ? current + " " + s : s; + if (proposed.split(/\s+/).length > maxWords && current) { + chunks.push(current.trim()); + current = s; + } else { + current = proposed; + } + } + if (current.trim()) chunks.push(current.trim()); + return chunks.filter(c => c.split(/\s+/).length > 20); +} + +// ── Corpus build (runs async, streams logs) ────────────────────────────────── +const activeBuilds = new Set(); + +async function runCorpusBuild(slug: string) { + if (activeBuilds.has(slug)) { broadcastLog(slug, "Build already in progress"); return; } + activeBuilds.add(slug); + try { + await pool.query(`UPDATE personaforge_subjects SET build_status='building', build_log='', chunk_count=0, updated_at=NOW() WHERE slug=$1`, [slug]); + broadcastLog(slug, `Starting corpus build for ${slug}`); + + const subjectRow = (await pool.query(`SELECT * FROM personaforge_subjects WHERE slug=$1`, [slug])).rows[0]; + if (!subjectRow) { broadcastLog(slug, "Subject not found"); return; } + + // Fetch artifact list + const artifacts = (await pool.query(`SELECT * FROM personaforge_artifacts WHERE subject_slug=$1 ORDER BY id`, [slug])).rows; + if (artifacts.length === 0) { broadcastLog(slug, "No artifacts found — run Fetch Essays first"); return; } + + broadcastLog(slug, `Found ${artifacts.length} artifacts to process`); + + const allChunks: any[] = []; + let essaysProcessed = 0; + + for (const art of artifacts) { + try { + broadcastLog(slug, `[${essaysProcessed + 1}/${artifacts.length}] Fetching: ${art.title}`); + const text = await fetchEssayText(art.source_url); + const wordCount = text.split(/\s+/).length; + if (wordCount < 50) { broadcastLog(slug, ` → Skipped (too short: ${wordCount}w)`); continue; } + + const textChunks = chunkText(text, 400); + textChunks.forEach((content, i) => { + allChunks.push({ + chunk_id: `${art.artifact_id}-p${String(i + 1).padStart(3, "0")}`, + artifact_id: art.artifact_id, + title: art.title, + source_url: art.source_url, + pub_date: art.pub_date || null, + content, + word_count: content.split(/\s+/).length, + }); + }); + + await pool.query( + `UPDATE personaforge_artifacts SET word_count=$2, chunk_count=$3 WHERE artifact_id=$4`, + [wordCount, textChunks.length, art.artifact_id] + ); + broadcastLog(slug, ` → ${wordCount}w → ${textChunks.length} chunks`); + essaysProcessed++; + // Polite rate limit + await new Promise(r => setTimeout(r, 300)); + } catch (err: any) { + broadcastLog(slug, ` → Error: ${err.message}`); + } + } + + broadcastLog(slug, `\nTotal: ${essaysProcessed} essays, ${allChunks.length} chunks`); + if (allChunks.length === 0) { broadcastLog(slug, "No chunks produced — aborting"); return; } + + // Upload to Vultr S3 + const bundleId = crypto.randomUUID(); + const corpVer = subjectRow.corpus_version || "1.0.0"; + const prefix = `staging/${slug}/v${corpVer}/${bundleId}`; + const chunksKey = `${prefix}/chunks/index.ndjson`; + const manifestKey = `${prefix}/manifest.json`; + + broadcastLog(slug, `\nUploading ${allChunks.length} chunks to Vultr S3…`); + const s3 = getS3(); + const ndjson = allChunks.map(c => JSON.stringify(c)).join("\n"); + await new Upload({ client: s3, params: { Bucket: S3_BUCKET, Key: chunksKey, Body: Buffer.from(ndjson, "utf-8"), ContentType: "application/x-ndjson" } }).done(); + broadcastLog(slug, `✓ Chunks uploaded — ${chunksKey}`); + + const manifest = { + schema_version: "1.0", + bundle_id: bundleId, + expert_slug: slug, + attestation_uri: `https://wellspr.ing/vault/agents/${slug}/attestation-v1.0.json`, + attestation_version: "v1.0", + corpus_version: corpVer, + assembled_by: "personaforge", + assembled_at: new Date().toISOString(), + chunk_strategy: "paragraph-400w", + chunk_count: allChunks.length, + artifact_count: essaysProcessed, + chunks_index_s3_key: chunksKey, + manifest_sha256: "", + }; + const manifestBuf = Buffer.from(JSON.stringify(manifest, null, 2), "utf-8"); + manifest.manifest_sha256 = crypto.createHash("sha256").update(manifestBuf).digest("hex"); + const signedBuf = Buffer.from(JSON.stringify(manifest, null, 2), "utf-8"); + await new Upload({ client: s3, params: { Bucket: S3_BUCKET, Key: manifestKey, Body: signedBuf, ContentType: "application/json" } }).done(); + broadcastLog(slug, `✓ Manifest uploaded — ${manifestKey}`); + + // Update DB + await pool.query( + `UPDATE personaforge_subjects SET build_status='built', bundle_id=$2, chunk_count=$3, essay_count=$4, updated_at=NOW() WHERE slug=$1`, + [slug, bundleId, allChunks.length, essaysProcessed] + ); + broadcastLog(slug, `\n✓ Corpus build complete — bundle ${bundleId}`); + broadcast(slug, "done", JSON.stringify({ bundle_id: bundleId, chunk_count: allChunks.length })); + } catch (err: any) { + broadcastLog(slug, `\nFATAL: ${err.message}`); + await pool.query(`UPDATE personaforge_subjects SET build_status='error', updated_at=NOW() WHERE slug=$1`, [slug]); + broadcast(slug, "error", JSON.stringify({ error: err.message })); + } finally { + activeBuilds.delete(slug); + } +} + +// ── SSR page ───────────────────────────────────────────────────────────────── +function renderPersonaForgePage(subjects: any[]): string { + const sidebar = subjects.map(s => { + const statusColor = { pending: "#64748b", fetching: "#d97706", building: "#3b82f6", built: "#10b981", submitted: "#8b5cf6", error: "#ef4444" }[s.build_status as string] || "#64748b"; + const statusLabel = { pending: "Pending", fetching: "Fetching", building: "Building", built: "Built", submitted: "Submitted", overture_sent: "Overture Sent", error: "Error" }[s.build_status as string] || s.build_status; + return ` +
+
${s.subject_name}
+
${s.domain || ""}
+
+ ${statusLabel} + ${s.essay_count ? `${s.essay_count} essays` : ""} + ${s.chunk_count ? `${s.chunk_count} chunks` : ""} +
+
`; + }).join(""); + + return ` + + + + +PersonaForge — Agentify.help Admin + + + + +
+ + PersonaForge + Agentify.help · Corpus Pipeline Admin +
+ ${subjects.length} subjects + ${subjects.filter(s => ["built","submitted"].includes(s.build_status)).length} built +
+
+ +
+ + + + + +
+
+ +

Select a subject

+

Choose a subject from the sidebar or add a new one.

+
+ + +
+ +
+ + + + + + +`; +} + +// ── Route Registration ──────────────────────────────────────────────────────── +export async function registerPersonaForgeRoutes(app: Express) { + await initPersonaForge(); + + // ── GET /personaforge ── SSR admin page + app.get("/personaforge", async (req: Request, res: Response, next: any) => { + const host = [req.headers["x-forwarded-host"], req.hostname, req.headers["host"]] + .flat().filter(Boolean).map((h: any) => h.toString().toLowerCase())[0] || ""; + if (!host.includes("agentify") || host.startsWith("skills.")) return next(); + const key = req.query.admin_key as string || req.headers["x-admin-key"] as string; + if (key !== ADMIN_KEY) { + return res.status(401).send(` +
+ `); + } + try { + const subjects = (await pool.query(`SELECT * FROM personaforge_subjects ORDER BY created_at`)).rows; + res.type("text/html").send(renderPersonaForgePage(subjects)); + } catch (err: any) { + res.status(500).send("Error loading PersonaForge: " + err.message); + } + }); + + // ── GET /api/personaforge/subjects ────────────────────────────────────── + app.get("/api/personaforge/subjects", async (req: Request, res: Response) => { + if (!requireAdminKey(req, res)) return; + const rows = (await pool.query(`SELECT * FROM personaforge_subjects ORDER BY created_at`)).rows; + res.json({ subjects: rows }); + }); + + // ── GET /api/personaforge/subjects/:slug ───────────────────────────────── + app.get("/api/personaforge/subjects/:slug", async (req: Request, res: Response) => { + if (!requireAdminKey(req, res)) return; + const row = (await pool.query(`SELECT * FROM personaforge_subjects WHERE slug=$1`, [req.params.slug])).rows[0]; + if (!row) return res.status(404).json({ error: "not_found" }); + res.json(row); + }); + + // ── POST /api/personaforge/subjects ── Add subject + app.post("/api/personaforge/subjects", async (req: Request, res: Response) => { + if (!requireAdminKey(req, res)) return; + const { subject_name, domain, source_type = "essays", source_url } = req.body || {}; + if (!subject_name) return res.status(400).json({ error: "subject_name required" }); + const slug = subject_name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + try { + await pool.query( + `INSERT INTO personaforge_subjects (slug, subject_name, domain, source_type, source_url) + VALUES ($1,$2,$3,$4,$5) ON CONFLICT (slug) DO NOTHING`, + [slug, subject_name, domain || null, source_type, source_url || null] + ); + res.json({ ok: true, slug }); + } catch (err: any) { res.status(500).json({ error: err.message }); } + }); + + // ── POST /api/personaforge/subjects/:slug/fetch ── Fetch artifact list + app.post("/api/personaforge/subjects/:slug/fetch", async (req: Request, res: Response) => { + if (!requireAdminKey(req, res)) return; + const { slug } = req.params; + const subject = (await pool.query(`SELECT * FROM personaforge_subjects WHERE slug=$1`, [slug])).rows[0]; + if (!subject) return res.status(404).json({ error: "not_found" }); + if (!subject.source_url) return res.status(400).json({ error: "No source_url set for this subject" }); + + try { + await pool.query(`UPDATE personaforge_subjects SET build_status='fetching', updated_at=NOW() WHERE slug=$1`, [slug]); + const essays = await fetchEssayList(subject.source_url); + let inserted = 0; + for (const e of essays) { + const artifactId = `${slug}-${e.slug}`; + await pool.query( + `INSERT INTO personaforge_artifacts (subject_slug, artifact_id, title, source_url, artifact_type) + VALUES ($1,$2,$3,$4,'essay') ON CONFLICT (artifact_id) DO NOTHING`, + [slug, artifactId, e.title, e.url] + ); + inserted++; + } + await pool.query( + `UPDATE personaforge_subjects SET essay_count=$2, build_status='pending', updated_at=NOW() WHERE slug=$1`, + [slug, inserted] + ); + res.json({ ok: true, count: inserted, slug }); + } catch (err: any) { + await pool.query(`UPDATE personaforge_subjects SET build_status='error', updated_at=NOW() WHERE slug=$1`, [slug]); + res.status(500).json({ error: err.message }); + } + }); + + // ── GET /api/personaforge/subjects/:slug/artifacts ──────────────────────── + app.get("/api/personaforge/subjects/:slug/artifacts", async (req: Request, res: Response) => { + if (!requireAdminKey(req, res)) return; + const artifacts = (await pool.query( + `SELECT * FROM personaforge_artifacts WHERE subject_slug=$1 ORDER BY id`, [req.params.slug] + )).rows; + res.json({ artifacts }); + }); + + // ── POST /api/personaforge/subjects/:slug/build ── Start corpus build (async) + app.post("/api/personaforge/subjects/:slug/build", async (req: Request, res: Response) => { + if (!requireAdminKey(req, res)) return; + const { slug } = req.params; + res.json({ ok: true, message: "Build started — connect to /logs for progress" }); + // Run async (don't await) + runCorpusBuild(slug).catch(err => console.error("[PersonaForge] Build error:", err)); + }); + + // ── GET /api/personaforge/subjects/:slug/logs ── SSE build log stream + app.get("/api/personaforge/subjects/:slug/logs", (req: Request, res: Response) => { + const key = (req.headers["x-admin-key"] as string) || req.query.admin_key as string; + if (key !== ADMIN_KEY) return res.status(403).end(); + const { slug } = req.params; + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.flushHeaders(); + res.write("event: connected\ndata: {}\n\n"); + + if (!liveStreams.has(slug)) liveStreams.set(slug, new Set()); + liveStreams.get(slug)!.add(res); + + req.on("close", () => { + const set = liveStreams.get(slug); + if (set) { set.delete(res); if (!set.size) liveStreams.delete(slug); } + }); + }); + + // ── POST /api/personaforge/subjects/:slug/submit ── Submit to WellSpr.ing + app.post("/api/personaforge/subjects/:slug/submit", async (req: Request, res: Response) => { + if (!requireAdminKey(req, res)) return; + const { slug } = req.params; + const subject = (await pool.query(`SELECT * FROM personaforge_subjects WHERE slug=$1`, [slug])).rows[0]; + if (!subject) return res.status(404).json({ error: "not_found" }); + if (!subject.bundle_id) return res.status(400).json({ error: "No bundle built yet — run Build Corpus first" }); + + try { + // Call the staging import endpoint internally + const corpVer = subject.corpus_version || "1.0.0"; + const importRes = await fetch(`http://localhost:${process.env.PORT || 5000}/api/agentify/corpus/staging/import/${slug}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-admin-key": ADMIN_KEY, + }, + body: JSON.stringify({ + manifest_s3_key: `staging/${slug}/v${corpVer}/${subject.bundle_id}/manifest.json`, + }), + }); + const result = await importRes.json(); + if (importRes.ok || result.bundle_id) { + await pool.query(`UPDATE personaforge_subjects SET build_status='submitted', updated_at=NOW() WHERE slug=$1`, [slug]); + res.json({ ok: true, bundle_id: subject.bundle_id, staging_result: result }); + } else { + res.status(500).json({ error: result.error || "Import failed", detail: result }); + } + } catch (err: any) { res.status(500).json({ error: err.message }); } + }); + + // ── POST /api/personaforge/subjects/:slug/overture ── Generate Overture via Claude + app.post("/api/personaforge/subjects/:slug/overture", async (req: Request, res: Response) => { + if (!requireAdminKey(req, res)) return; + const { slug } = req.params; + const subject = (await pool.query(`SELECT * FROM personaforge_subjects WHERE slug=$1`, [slug])).rows[0]; + if (!subject) return res.status(404).json({ error: "not_found" }); + + const artifacts = (await pool.query( + `SELECT title, source_url, pub_date FROM personaforge_artifacts WHERE subject_slug=$1 ORDER BY id LIMIT 10`, [slug] + )).rows; + const corpusSample = artifacts.map(a => ` - "${a.title}" (${a.source_url})`).join("\n"); + + const prompt = `You are composing a formal Overture letter on behalf of WellSpr.ing / Agentify.Help to ${subject.subject_name}. + +Agentify.Help is a one-per-person registry for AI expert personas grounded in real published work. It enforces a covenant standard: corpus-grounded, steward-named, with a 30-day notification window and a permanent opt-out mechanism. + +The situation: we have compiled a corpus of ${subject.subject_name}'s published work (${subject.essay_count || "many"} essays/artifacts) and built an AI persona that reasons within their published framework. We are notifying them per the covenant protocol. + +Sample corpus artifacts: +${corpusSample || " (essays from their published body of work)"} + +The Overture must: +1. Acknowledge that multiple inferior AI wrappers of this person already exist +2. Explain what distinguishes Agentify.Help (covenant standard, corpus-grounded, steward-named, chain of title) +3. Offer three paths: Claim (endorse and gain editorial control), Correct (send amendments), Decline (permanent registry entry honoring their refusal) +4. Make clear that the Decline option is first-class and durable — not a failure condition +5. Explain the 30-day notification window +6. Use a tone appropriate to someone who values clear thinking over pitch language — no sales tactics, no manufactured urgency +7. Be approximately 400-600 words +8. Sign from "The WellSpr.ing / Agentify.Help Team" with contact ody@wellspr.ing + +Write the complete letter now:`; + + try { + const resp = await claude.messages.create({ + model: "claude-opus-4-5", + max_tokens: 1200, + messages: [{ role: "user", content: prompt }], + }); + const overture = (resp.content[0] as any)?.text || ""; + await pool.query(`UPDATE personaforge_subjects SET overture_text=$2, updated_at=NOW() WHERE slug=$1`, [slug, overture]); + res.json({ ok: true, overture }); + } catch (err: any) { res.status(500).json({ error: err.message }); } + }); + + // ── POST /api/personaforge/subjects/:slug/notify ── Send Overture email + app.post("/api/personaforge/subjects/:slug/notify", async (req: Request, res: Response) => { + if (!requireAdminKey(req, res)) return; + const { slug } = req.params; + const { email } = req.body || {}; + if (!email) return res.status(400).json({ error: "email required" }); + + const subject = (await pool.query(`SELECT * FROM personaforge_subjects WHERE slug=$1`, [slug])).rows[0]; + if (!subject) return res.status(404).json({ error: "not_found" }); + if (!subject.overture_text) return res.status(400).json({ error: "Generate Overture first" }); + + try { + await resend.emails.send({ + from: "Agentify.Help ", + to: [email], + subject: `Agentify.Help — WellAgent Notification for ${subject.subject_name}`, + text: subject.overture_text, + html: `
+
Agentify.Help · WellAgent Registry
+ ${subject.overture_text.split("\n\n").map((p: string) => `

${p.replace(/\n/g, "
")}

`).join("")} +
+
+ agentify.help · + Covenant · + Registry entry: agentify.help/${slug} +
+
`, + }); + await pool.query( + `UPDATE personaforge_subjects SET overture_sent_at=NOW(), overture_email=$2, build_status='overture_sent', updated_at=NOW() WHERE slug=$1`, + [slug, email] + ); + res.json({ ok: true, to: email }); + } catch (err: any) { res.status(500).json({ error: err.message }); } + }); + + // ── POST /api/personaforge/subjects/:slug/registry-push ── Push to agentify_subject_registry + app.post("/api/personaforge/subjects/:slug/registry-push", async (req: Request, res: Response) => { + if (!requireAdminKey(req, res)) return; + const { slug } = req.params; + const s = (await pool.query(`SELECT * FROM personaforge_subjects WHERE slug=$1`, [slug])).rows[0]; + if (!s) return res.status(404).json({ error: "not_found" }); + try { + await pool.query(` + INSERT INTO agentify_subject_registry + (subject_slug, subject_name, subject_domain, steward_name, steward_email, corpus_version, status, registered_via) + VALUES ($1,$2,$3,'WellSpr.ing PersonaForge','ody@wellspr.ing',$4,'active','personaforge') + ON CONFLICT (subject_slug) DO UPDATE SET + subject_name=EXCLUDED.subject_name, subject_domain=EXCLUDED.subject_domain, + corpus_version=EXCLUDED.corpus_version, status='active', updated_at=NOW() + `, [slug, s.subject_name, s.domain || null, s.corpus_version || "1.0.0"]); + res.json({ ok: true, slug }); + } catch (err: any) { res.status(500).json({ error: err.message }); } + }); + + console.log("[PersonaForge] Routes registered — agentify.help/personaforge"); +}