1189 lines
56 KiB
TypeScript
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(/&/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<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");
|
|
}
|