agentify-help/routes/personaforge.ts

1189 lines
56 KiB
TypeScript

/**
* 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<string, Set<Response>>();
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\s+href="([^"]+\.html)"[^>]*>([^<]+)<\/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<string> {
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(/<script[^>]*>[\s\S]*?<\/script>/gi, " ")
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&nbsp;/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<string>();
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 `
<div class="subject-row" data-slug="${s.slug}" onclick="selectSubject('${s.slug}')">
<div class="subject-name">${s.subject_name}</div>
<div class="subject-meta">${s.domain || ""}</div>
<div class="subject-badges">
<span class="badge-status" style="color:${statusColor}">${statusLabel}</span>
${s.essay_count ? `<span class="badge-count">${s.essay_count} essays</span>` : ""}
${s.chunk_count ? `<span class="badge-count">${s.chunk_count} chunks</span>` : ""}
</div>
</div>`;
}).join("");
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PersonaForge — Agentify.help Admin</title>
<style>
:root {
--bg: #0f172a;
--bg2: #1e293b;
--bg3: #334155;
--surface: #1e293b;
--border: rgba(255,255,255,0.08);
--accent: #d97706;
--accent2: #f59e0b;
--green: #10b981;
--blue: #3b82f6;
--red: #ef4444;
--text: #f1f5f9;
--text2: #94a3b8;
--text3: #64748b;
--font: 'Inter', system-ui, sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font); background: var(--bg); color: var(--text); height: 100vh; overflow: hidden; display: flex; flex-direction: column; }
/* ── Top bar ── */
.topbar {
display: flex; align-items: center; gap: 0.75rem; padding: 0 1.5rem; height: 52px;
background: var(--bg2); border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.topbar-brand { font-size: 0.95rem; font-weight: 800; letter-spacing: -0.02em; color: var(--accent2); }
.topbar-sub { font-size: 0.75rem; color: var(--text3); margin-left: 0.5rem; }
.topbar-right { margin-left: auto; display: flex; gap: 0.5rem; align-items: center; }
.topbar-stat { font-size: 0.72rem; color: var(--text3); padding: 0.25rem 0.6rem; background: var(--bg3); border-radius: 4px; }
/* ── Layout ── */
.layout { display: flex; flex: 1; overflow: hidden; }
/* ── Left sidebar ── */
.sidebar {
width: 260px; flex-shrink: 0; background: var(--bg2); border-right: 1px solid var(--border);
display: flex; flex-direction: column; overflow: hidden;
}
.sidebar-header {
padding: 0.9rem 1rem 0.6rem; border-bottom: 1px solid var(--border);
font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text3);
display: flex; align-items: center; justify-content: space-between;
}
.btn-add-subject {
background: var(--accent); color: #000; border: none; padding: 0.2rem 0.55rem;
border-radius: 4px; font-size: 0.7rem; font-weight: 700; cursor: pointer; letter-spacing: 0.04em;
}
.subject-list { flex: 1; overflow-y: auto; }
.subject-row {
padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); cursor: pointer;
transition: background 0.1s;
}
.subject-row:hover, .subject-row.active { background: var(--bg3); }
.subject-row.active { border-left: 3px solid var(--accent); }
.subject-name { font-size: 0.85rem; font-weight: 600; color: var(--text); }
.subject-meta { font-size: 0.72rem; color: var(--text3); margin-top: 0.15rem; }
.subject-badges { display: flex; gap: 0.35rem; margin-top: 0.35rem; flex-wrap: wrap; }
.badge-status { font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; }
.badge-count { font-size: 0.65rem; color: var(--text3); background: var(--bg); padding: 0.1rem 0.35rem; border-radius: 3px; }
/* ── Main panel ── */
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.main-header {
padding: 1rem 1.5rem 0.8rem; background: var(--bg2); border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.main-title { font-size: 1.1rem; font-weight: 800; color: var(--text); }
.main-domain { font-size: 0.78rem; color: var(--text3); margin-top: 0.2rem; }
.main-actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; flex-wrap: wrap; }
.btn { padding: 0.45rem 0.85rem; border-radius: 6px; border: none; font-size: 0.78rem; font-weight: 600; cursor: pointer; transition: opacity 0.15s; display: inline-flex; align-items: center; gap: 0.35rem; }
.btn:hover { opacity: 0.85; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary { background: var(--accent); color: #000; }
.btn-build { background: var(--blue); color: #fff; }
.btn-green { background: var(--green); color: #fff; }
.btn-outline { background: transparent; color: var(--text2); border: 1px solid var(--border); }
.btn-danger { background: var(--red); color: #fff; }
.badge { font-size: 0.65rem; padding: 0.15rem 0.45rem; border-radius: 3px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
.badge-built { background: rgba(16,185,129,0.15); color: #10b981; }
.badge-building { background: rgba(59,130,246,0.15); color: #60a5fa; }
.badge-pending { background: rgba(100,116,139,0.12); color: #94a3b8; }
.badge-error { background: rgba(239,68,68,0.15); color: #f87171; }
/* ── Tabs ── */
.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); background: var(--bg); padding: 0 1.5rem; flex-shrink: 0; }
.tab { padding: 0.7rem 1rem; font-size: 0.78rem; font-weight: 600; color: var(--text3); cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.15s; position: relative; top: 1px; white-space: nowrap; }
.tab:hover { color: var(--text); }
.tab.active { color: var(--accent2); border-bottom-color: var(--accent2); }
.tab-body { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
.tab-pane { display: none; flex-direction: column; height: 100%; }
.tab-pane.active { display: flex; }
.scroll-area { flex: 1; overflow-y: auto; padding: 1.25rem 1.5rem; }
/* ── Pipeline view ── */
.pipeline-grid { display: flex; flex-direction: column; gap: 0.75rem; }
.pipeline-card {
background: var(--bg2); border: 1px solid var(--border); border-radius: 8px;
padding: 0.9rem 1.1rem; display: flex; align-items: flex-start; gap: 1rem;
}
.pipeline-info { flex: 1; }
.pipeline-name { font-size: 0.9rem; font-weight: 700; }
.pipeline-sub { font-size: 0.75rem; color: var(--text3); margin-top: 0.15rem; }
.pipeline-stats { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
.pipeline-stat { font-size: 0.7rem; color: var(--text3); background: var(--bg3); padding: 0.15rem 0.45rem; border-radius: 3px; }
.pipeline-actions { display: flex; flex-direction: column; gap: 0.4rem; min-width: 160px; }
/* ── Artifact list ── */
.artifact-row {
display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0;
border-bottom: 1px solid var(--border); font-size: 0.78rem;
}
.artifact-title { flex: 1; color: var(--text); }
.artifact-meta { color: var(--text3); font-size: 0.7rem; white-space: nowrap; }
.artifact-link { color: var(--accent2); text-decoration: none; font-size: 0.7rem; }
.artifact-link:hover { text-decoration: underline; }
.section-head {
font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--text3); margin-bottom: 0.75rem; padding-bottom: 0.35rem; border-bottom: 1px solid var(--border);
}
/* ── Terminal ── */
.terminal-wrap { height: 260px; flex-shrink: 0; border-top: 1px solid var(--border); background: #020617; display: flex; flex-direction: column; }
.terminal-header {
display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 1rem;
border-bottom: 1px solid rgba(255,255,255,0.04); flex-shrink: 0;
}
.terminal-title { font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em; color: #4ade80; }
.terminal-dot { width: 7px; height: 7px; border-radius: 50%; }
.terminal-body { flex: 1; overflow-y: auto; padding: 0.6rem 1rem; font-family: monospace; font-size: 0.72rem; line-height: 1.6; }
.log-line { color: #a7f3d0; }
.log-ok { color: #4ade80; }
.log-err { color: #f87171; }
.log-dim { color: #4a7c5e; }
#term-cursor { display: inline-block; width: 7px; height: 12px; background: #4ade80; vertical-align: middle; animation: blink 1s step-end infinite; }
@keyframes blink { 50% { opacity: 0; } }
/* ── Overture ── */
.overture-box {
background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem;
font-size: 0.82rem; line-height: 1.7; color: var(--text2); white-space: pre-wrap; font-family: Georgia, serif;
max-height: 420px; overflow-y: auto;
}
.form-group { margin-bottom: 1rem; }
.form-label { font-size: 0.72rem; font-weight: 600; color: var(--text3); text-transform: uppercase; letter-spacing: 0.06em; display: block; margin-bottom: 0.4rem; }
.form-input {
width: 100%; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px;
padding: 0.55rem 0.75rem; color: var(--text); font-size: 0.82rem; font-family: inherit;
}
.form-input:focus { outline: none; border-color: var(--accent); }
textarea.form-input { resize: vertical; min-height: 80px; }
/* ── Modal ── */
.modal-overlay {
display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: 1000;
align-items: center; justify-content: center;
}
.modal-overlay.open { display: flex; }
.modal {
background: var(--bg2); border: 1px solid var(--border); border-radius: 10px;
width: 420px; max-width: 95vw; padding: 1.5rem;
}
.modal-title { font-size: 0.9rem; font-weight: 700; margin-bottom: 1.25rem; }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1.25rem; }
/* ── Add subject form ── */
#add-modal .form-group { margin-bottom: 0.85rem; }
/* ── Empty state ── */
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--text3); }
.empty-state h3 { color: var(--text2); margin-bottom: 0.5rem; }
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-thumb { background: var(--bg3); border-radius: 3px; }
</style>
</head>
<body>
<div class="topbar">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#d97706" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
<span class="topbar-brand">PersonaForge</span>
<span class="topbar-sub">Agentify.help · Corpus Pipeline Admin</span>
<div class="topbar-right">
<span class="topbar-stat" id="stat-subjects">${subjects.length} subjects</span>
<span class="topbar-stat" id="stat-built">${subjects.filter(s => ["built","submitted"].includes(s.build_status)).length} built</span>
</div>
</div>
<div class="layout">
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-header">
Subjects
<button class="btn-add-subject" onclick="openAddModal()">+ Add</button>
</div>
<div class="subject-list" id="subject-list">
${sidebar}
</div>
</div>
<!-- Main -->
<div class="main" id="main-panel">
<div id="no-selection" class="empty-state" style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#334155" stroke-width="1.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
<h3 style="margin-top:1rem">Select a subject</h3>
<p style="font-size:0.8rem;margin-top:0.4rem">Choose a subject from the sidebar or add a new one.</p>
</div>
<div id="subject-panel" style="display:none;flex:1;display:none;flex-direction:column;overflow:hidden;">
<div class="main-header">
<div style="display:flex;align-items:center;gap:0.75rem;">
<div>
<div class="main-title" id="panel-name">—</div>
<div class="main-domain" id="panel-domain">—</div>
</div>
<span class="badge badge-pending" id="panel-status-badge">Pending</span>
</div>
<div class="main-actions">
<button class="btn btn-primary" id="btn-fetch" onclick="fetchArtifacts()">⬇ Fetch Essays</button>
<button class="btn btn-build" id="btn-build" onclick="startBuild()">⚙ Build Corpus</button>
<button class="btn btn-green" id="btn-submit" onclick="submitCorpus()">↑ Submit to WellSpr.ing</button>
<button class="btn btn-outline" id="btn-overture" onclick="generateOverture()">✉ Compose Overture</button>
<button class="btn btn-outline" onclick="refreshSubject()">↺ Refresh</button>
</div>
</div>
<div class="tabs">
<div class="tab active" onclick="showTab('pipeline')">Pipeline</div>
<div class="tab" onclick="showTab('corpus')">Corpus</div>
<div class="tab" onclick="showTab('overture')">Overture</div>
<div class="tab" onclick="showTab('registry')">Registry</div>
</div>
<div class="tab-body">
<!-- Pipeline tab -->
<div class="tab-pane active" id="tab-pipeline">
<div class="scroll-area">
<div class="section-head">Build Pipeline</div>
<div id="pipeline-steps"></div>
<div style="margin-top:1.5rem" class="section-head">All Subjects</div>
<div class="pipeline-grid" id="all-pipeline">
${subjects.map(s => {
const sc = { pending:"#64748b",building:"#3b82f6",built:"#10b981",submitted:"#8b5cf6",error:"#ef4444" }[s.build_status as string]||"#64748b";
return `<div class="pipeline-card">
<div class="pipeline-info">
<div class="pipeline-name">${s.subject_name}</div>
<div class="pipeline-sub">${s.domain||"—"}</div>
<div class="pipeline-stats">
<span class="pipeline-stat">${s.essay_count||0} essays</span>
<span class="pipeline-stat">${s.chunk_count||0} chunks</span>
<span class="pipeline-stat" style="color:${sc}">${s.build_status}</span>
</div>
</div>
<div class="pipeline-actions">
<button class="btn btn-primary btn" style="font-size:0.72rem;" onclick="selectSubject('${s.slug}')">Open </button>
</div>
</div>`;
}).join("")}
</div>
</div>
</div>
<!-- Corpus tab -->
<div class="tab-pane" id="tab-corpus">
<div class="scroll-area">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
<div class="section-head" style="margin:0">Artifacts (<span id="artifact-count">0</span>)</div>
<span id="corpus-source" style="font-size:0.72rem;color:#64748b"></span>
</div>
<div id="artifact-list"></div>
</div>
</div>
<!-- Overture tab -->
<div class="tab-pane" id="tab-overture">
<div class="scroll-area">
<div class="section-head">Overture Letter</div>
<div class="form-group">
<label class="form-label">Expert Email</label>
<input type="email" class="form-input" id="overture-email" placeholder="expert@example.com" style="max-width:360px">
</div>
<div style="display:flex;gap:0.5rem;margin-bottom:1rem;">
<button class="btn btn-primary" onclick="generateOverture()">⚡ Generate via Claude</button>
<button class="btn btn-green" onclick="sendOverture()">✉ Send Overture</button>
</div>
<div id="overture-content">
<div class="overture-box" id="overture-text" style="color:#4a5568;font-style:italic">No Overture composed yet. Click Generate to create one.</div>
</div>
</div>
</div>
<!-- Registry tab -->
<div class="tab-pane" id="tab-registry">
<div class="scroll-area">
<div class="section-head">Registry Details</div>
<div id="registry-details"></div>
<div style="margin-top:1.25rem">
<button class="btn btn-green" onclick="pushToRegistry()">↑ Push to Agentify Registry</button>
</div>
</div>
</div>
</div>
<!-- Terminal -->
<div class="terminal-wrap">
<div class="terminal-header">
<div class="terminal-dot" id="term-dot" style="background:#10b981"></div>
<span class="terminal-title">Build Log</span>
<span id="term-status" style="font-size:0.65rem;color:#4a7c5e;margin-left:0.5rem">idle</span>
<button onclick="clearLog()" style="margin-left:auto;background:none;border:1px solid rgba(255,255,255,0.08);color:#4a7c5e;padding:0.1rem 0.45rem;border-radius:3px;font-size:0.65rem;cursor:pointer;">Clear</button>
</div>
<div class="terminal-body" id="term-body">
<div class="log-dim">PersonaForge ready.</div>
<span id="term-cursor"></span>
</div>
</div>
</div>
</div>
</div>
<!-- Add Subject Modal -->
<div class="modal-overlay" id="add-modal">
<div class="modal">
<div class="modal-title">Add Subject</div>
<div id="add-modal-form">
<div class="form-group">
<label class="form-label">Full Name</label>
<input type="text" class="form-input" id="add-name" placeholder="Paul Graham">
</div>
<div class="form-group">
<label class="form-label">Domain / Expertise</label>
<input type="text" class="form-input" id="add-domain" placeholder="Essays / Startups / Philosophy">
</div>
<div class="form-group">
<label class="form-label">Source Type</label>
<select class="form-input" id="add-source-type">
<option value="essays">Essays (Web)</option>
<option value="books">Books / Documents</option>
<option value="pubmed">PubMed (Medical)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Source URL</label>
<input type="url" class="form-input" id="add-source-url" placeholder="https://paulgraham.com/articles.html">
</div>
</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeAddModal()">Cancel</button>
<button class="btn btn-primary" onclick="submitAddSubject()">Add Subject</button>
</div>
</div>
</div>
<script>
const ADMIN_KEY = new URLSearchParams(location.search).get('admin_key') || '';
let currentSlug = null;
let currentSubject = null;
let sseSource = null;
let buildPolling = null;
// ── Init ──
(function() {
// Auto-select Paul Graham
const rows = document.querySelectorAll('.subject-row');
if (rows.length > 0) selectSubject(rows[0].dataset.slug);
})();
// ── Subject selection ──
function selectSubject(slug) {
currentSlug = slug;
document.querySelectorAll('.subject-row').forEach(r => r.classList.toggle('active', r.dataset.slug === slug));
document.getElementById('no-selection').style.display = 'none';
document.getElementById('subject-panel').style.display = 'flex';
loadSubject(slug);
}
async function loadSubject(slug) {
const data = await apiFetch('/api/personaforge/subjects/' + slug);
if (!data) return;
currentSubject = data;
document.getElementById('panel-name').textContent = data.subject_name;
document.getElementById('panel-domain').textContent = data.domain || '';
updateStatusBadge(data.build_status);
document.getElementById('overture-email').value = data.overture_email || '';
if (data.overture_text) {
document.getElementById('overture-text').textContent = data.overture_text;
document.getElementById('overture-text').style.fontStyle = 'normal';
document.getElementById('overture-text').style.color = '';
}
loadArtifacts(slug);
loadRegistryDetails(data);
// Restore build log
if (data.build_log) renderBuildLog(data.build_log);
}
function updateStatusBadge(status) {
const badge = document.getElementById('panel-status-badge');
const labels = { pending:'Pending', fetching:'Fetching', building:'Building…', built:'Built', submitted:'Submitted', overture_sent:'Overture Sent', error:'Error' };
const classes = { pending:'badge-pending', building:'badge-building', built:'badge-built', submitted:'badge-built', error:'badge-error' };
badge.textContent = labels[status] || status;
badge.className = 'badge ' + (classes[status] || 'badge-pending');
}
async function refreshSubject() { if (currentSlug) await loadSubject(currentSlug); }
// ── Tabs ──
function showTab(name) {
document.querySelectorAll('.tab').forEach((t,i) => t.classList.toggle('active', ['pipeline','corpus','overture','registry'][i] === name));
document.querySelectorAll('.tab-pane').forEach(p => p.classList.toggle('active', p.id === 'tab-' + name));
}
// ── Fetch artifacts ──
async function fetchArtifacts() {
if (!currentSlug) return;
setStatus('fetching');
appendLog('Fetching essay list from source…');
const r = await apiFetch('/api/personaforge/subjects/' + currentSlug + '/fetch', { method: 'POST' });
if (r?.ok) {
appendLog('✓ Found ' + r.count + ' essays');
await loadArtifacts(currentSlug);
setStatus('pending');
} else {
appendLog('Error: ' + (r?.error || 'unknown'));
setStatus('error');
}
}
async function loadArtifacts(slug) {
const data = await apiFetch('/api/personaforge/subjects/' + slug + '/artifacts');
if (!data) return;
document.getElementById('artifact-count').textContent = data.artifacts?.length || 0;
if (currentSubject?.source_url) document.getElementById('corpus-source').textContent = currentSubject.source_url;
const list = document.getElementById('artifact-list');
if (!data.artifacts?.length) { list.innerHTML = '<div class="log-dim" style="padding:1rem 0">No artifacts yet — click Fetch Essays.</div>'; return; }
list.innerHTML = data.artifacts.map(a => \`
<div class="artifact-row">
<div class="artifact-title"><a href="\${a.source_url || '#'}" class="artifact-link" target="_blank">\${a.title}</a></div>
\${a.word_count ? \`<div class="artifact-meta">\${a.word_count.toLocaleString()}w · \${a.chunk_count||0}ch</div>\` : ''}
\${a.pub_date ? \`<div class="artifact-meta">\${a.pub_date}</div>\` : ''}
</div>
\`).join('');
}
function loadRegistryDetails(s) {
document.getElementById('registry-details').innerHTML = \`
<table style="width:100%;border-collapse:collapse;font-size:0.78rem;">
\${[
['Slug', s.slug],
['Name', s.subject_name],
['Domain', s.domain||'—'],
['Source', s.source_type],
['Source URL', \`<a href="\${s.source_url||'#'}" class="artifact-link" target="_blank">\${s.source_url||'—'}</a>\`],
['Corpus Version', s.corpus_version||'—'],
['Bundle ID', s.bundle_id||'—'],
['Essays', s.essay_count||0],
['Chunks', s.chunk_count||0],
['Status', s.build_status],
].map(([k,v]) => \`<tr><td style="padding:0.4rem 0;color:#64748b;width:140px">\${k}</td><td style="padding:0.4rem 0;color:#f1f5f9">\${v}</td></tr>\`).join('')}
</table>
\`;
}
// ── Build ──
async function startBuild() {
if (!currentSlug) return;
if (!confirm('Start full corpus build? This fetches all essays and uploads to Vultr S3.')) return;
setStatus('building');
clearLog();
connectSSE(currentSlug);
await apiFetch('/api/personaforge/subjects/' + currentSlug + '/build', { method: 'POST' });
}
function connectSSE(slug) {
if (sseSource) { sseSource.close(); sseSource = null; }
const url = '/api/personaforge/subjects/' + slug + '/logs?admin_key=' + encodeURIComponent(ADMIN_KEY);
sseSource = new EventSource(url);
document.getElementById('term-dot').style.background = '#3b82f6';
document.getElementById('term-status').textContent = 'building';
sseSource.addEventListener('log', e => {
try { const d = JSON.parse(e.data); appendLog(d.msg); } catch {}
});
sseSource.addEventListener('done', e => {
try { const d = JSON.parse(e.data); appendLog('✓ Done — bundle ' + d.bundle_id); } catch {}
document.getElementById('term-dot').style.background = '#10b981';
document.getElementById('term-status').textContent = 'complete';
if (sseSource) sseSource.close(); sseSource = null;
setTimeout(() => loadSubject(currentSlug), 500);
});
sseSource.addEventListener('error', e => {
appendLog('Build error');
document.getElementById('term-dot').style.background = '#ef4444';
document.getElementById('term-status').textContent = 'error';
if (sseSource) sseSource.close(); sseSource = null;
});
}
// ── Corpus submission ──
async function submitCorpus() {
if (!currentSlug) return;
if (!confirm('Submit corpus to WellSpr.ing staging import?')) return;
appendLog('Submitting to WellSpr.ing…');
const r = await apiFetch('/api/personaforge/subjects/' + currentSlug + '/submit', { method: 'POST' });
if (r?.ok) {
appendLog('✓ Submitted — bundle_id: ' + r.bundle_id);
setStatus('submitted');
} else {
appendLog('Error: ' + (r?.error || 'unknown'));
}
}
// ── Overture ──
async function generateOverture() {
if (!currentSlug) return;
appendLog('Generating Overture via Claude…');
const el = document.getElementById('overture-text');
el.textContent = 'Generating…';
el.style.fontStyle = 'italic';
const r = await apiFetch('/api/personaforge/subjects/' + currentSlug + '/overture', { method: 'POST' });
if (r?.overture) {
el.textContent = r.overture;
el.style.fontStyle = 'normal';
el.style.color = '';
appendLog('✓ Overture composed (' + r.overture.length + ' chars)');
} else {
el.textContent = r?.error || 'Generation failed';
appendLog('Error: ' + (r?.error || 'unknown'));
}
}
async function sendOverture() {
if (!currentSlug) return;
const email = document.getElementById('overture-email').value.trim();
if (!email) return alert('Enter the expert email first.');
if (!confirm('Send Overture to ' + email + '?')) return;
appendLog('Sending Overture to ' + email + '…');
const r = await apiFetch('/api/personaforge/subjects/' + currentSlug + '/notify', {
method: 'POST',
body: JSON.stringify({ email }),
});
if (r?.ok) { appendLog('✓ Overture sent to ' + email); }
else { appendLog('Error: ' + (r?.error || 'unknown')); }
}
// ── Registry push ──
async function pushToRegistry() {
if (!currentSlug || !currentSubject) return;
const r = await apiFetch('/api/personaforge/subjects/' + currentSlug + '/registry-push', { method: 'POST' });
if (r?.ok) { appendLog('✓ Pushed to Agentify subject registry'); }
else { appendLog('Error: ' + (r?.error || 'Push failed')); }
}
// ── Add subject modal ──
function openAddModal() { document.getElementById('add-modal').classList.add('open'); }
function closeAddModal() { document.getElementById('add-modal').classList.remove('open'); }
async function submitAddSubject() {
const name = document.getElementById('add-name').value.trim();
const domain = document.getElementById('add-domain').value.trim();
const source_type = document.getElementById('add-source-type').value;
const source_url = document.getElementById('add-source-url').value.trim();
if (!name) return alert('Name required');
const r = await apiFetch('/api/personaforge/subjects', {
method: 'POST',
body: JSON.stringify({ subject_name: name, domain, source_type, source_url }),
});
if (r?.ok) { closeAddModal(); location.reload(); }
else { alert(r?.error || 'Failed to add subject'); }
}
// ── Log helpers ──
function appendLog(msg) {
const term = document.getElementById('term-body');
const cursor = document.getElementById('term-cursor');
const cls = msg.startsWith('✓') ? 'log-ok' : msg.startsWith('FATAL') || msg.startsWith('Error') ? 'log-err' : 'log-line';
const div = document.createElement('div');
div.className = cls;
div.textContent = msg;
term.insertBefore(div, cursor);
term.scrollTop = term.scrollHeight;
}
function renderBuildLog(log) {
log.split('\\n').filter(Boolean).forEach(l => {
const ts = l.match(/^\[[\d:]+\] (.+)$/);
appendLog(ts ? ts[1] : l);
});
}
function clearLog() {
const term = document.getElementById('term-body');
const cursor = document.getElementById('term-cursor');
while (term.firstChild && term.firstChild !== cursor) term.removeChild(term.firstChild);
}
function setStatus(status) {
updateStatusBadge(status);
const row = document.querySelector('.subject-row.active');
if (row) { const b = row.querySelector('.badge-status'); if (b) b.textContent = status; }
}
// ── API helpers ──
async function apiFetch(url, opts = {}) {
try {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json', 'x-admin-key': ADMIN_KEY, ...(opts.headers||{}) },
...opts,
});
return await res.json();
} catch (e) {
console.error('apiFetch error', url, e);
return null;
}
}
</script>
</body>
</html>`;
}
// ── 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(`<!DOCTYPE html><html><body style="font-family:monospace;background:#0f172a;color:#f1f5f9;display:flex;align-items:center;justify-content:center;height:100vh;margin:0">
<form method="get"><label>Admin Key: <input name="admin_key" type="password" style="background:#1e293b;color:#f1f5f9;border:1px solid #334155;padding:0.4rem;border-radius:4px;margin-right:0.5rem">
<button type="submit" style="background:#d97706;color:#000;border:none;padding:0.4rem 0.8rem;border-radius:4px;font-weight:700;cursor:pointer">Enter PersonaForge</button></label></form>
</body></html>`);
}
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 <ody@wellspr.ing>",
to: [email],
subject: `Agentify.Help — WellAgent Notification for ${subject.subject_name}`,
text: subject.overture_text,
html: `<div style="font-family:Georgia,serif;max-width:640px;margin:0 auto;padding:2rem;color:#1a1a1a;line-height:1.75">
<div style="font-size:0.75rem;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:#1a3a2a;margin-bottom:1.5rem">Agentify.Help · WellAgent Registry</div>
${subject.overture_text.split("\n\n").map((p: string) => `<p style="margin-bottom:1.2rem">${p.replace(/\n/g, "<br>")}</p>`).join("")}
<hr style="border:none;border-top:1px solid #e5e7eb;margin:2rem 0">
<div style="font-size:0.8rem;color:#6b7280">
<a href="https://agentify.help" style="color:#1a3a2a">agentify.help</a> ·
<a href="https://wellspr.ing/constitution" style="color:#1a3a2a">Covenant</a> ·
Registry entry: <a href="https://agentify.help/${slug}" style="color:#1a3a2a">agentify.help/${slug}</a>
</div>
</div>`,
});
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");
}