agentify-help/routes/steward.ts

1935 lines
96 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* PersonaForge Stewardship Dashboard
*
* The steward-facing workspace for vetting corpus artifacts before a build.
* Not the admin panel — this is the person responsible for the corpus doing
* their actual review work, with Ody as a research assistant.
*
* Routes (agentify.help host, dashboard-token protected):
* GET /steward/:slug — SSR dashboard
* GET /api/steward/:slug/info — subject + stats
* GET /api/steward/:slug/artifacts — list artifacts + curation
* PATCH /api/steward/:slug/artifacts/:artifact_id — update curation status
* POST /api/steward/:slug/artifacts/:artifact_id/ody-ask — Ody analysis
* POST /api/steward/:slug/corpus/fetch — trigger essay fetch
* POST /api/steward/:slug/corpus/build — build approved-only corpus
* GET /api/steward/:slug/dashboard-token — admin: get/generate token
*/
import type { Express, Request, Response } from "express";
import { pool } from "../db";
import crypto from "crypto";
import Anthropic from "@anthropic-ai/sdk";
const ADMIN_KEY = process.env.ADMIN_KEY || "b0db7a87384fc814b0f46ea7bdc6ab6a81152be5b098718b";
const claude = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
// ── Schema migrations ─────────────────────────────────────────────────────────
export async function initStewardDash() {
// Curation columns on artifacts
await pool.query(`ALTER TABLE personaforge_artifacts ADD COLUMN IF NOT EXISTS curation_status TEXT NOT NULL DEFAULT 'pending'`);
await pool.query(`ALTER TABLE personaforge_artifacts ADD COLUMN IF NOT EXISTS curation_note TEXT`);
await pool.query(`ALTER TABLE personaforge_artifacts ADD COLUMN IF NOT EXISTS ody_verdict TEXT`);
// Manual artifact content (bypasses URL fetch — used for injected bio facts, Wikipedia excerpts, etc.)
await pool.query(`ALTER TABLE personaforge_artifacts ADD COLUMN IF NOT EXISTS content_text TEXT`);
// Dashboard token stored on personaforge_subjects (guaranteed to exist for all subjects)
await pool.query(`ALTER TABLE personaforge_subjects ADD COLUMN IF NOT EXISTS dashboard_token TEXT`);
// Biographical baseline facts (structured markdown, highest-priority corpus chunk, not curatable)
await pool.query(`ALTER TABLE personaforge_subjects ADD COLUMN IF NOT EXISTS bio_facts_md TEXT`);
// PTP attestation metadata for bio_facts (who wrote it and under what token/scope)
await pool.query(`ALTER TABLE personaforge_subjects ADD COLUMN IF NOT EXISTS bio_facts_ptp_token_id TEXT`);
await pool.query(`ALTER TABLE personaforge_subjects ADD COLUMN IF NOT EXISTS bio_facts_attested_at TIMESTAMPTZ`);
await pool.query(`ALTER TABLE personaforge_subjects ADD COLUMN IF NOT EXISTS bio_facts_vcap_uri TEXT`);
// Wikipedia/secondary source summary (skepticism-weighted; separate from primary corpus)
await pool.query(`ALTER TABLE personaforge_subjects ADD COLUMN IF NOT EXISTS wiki_summary_md TEXT`);
// PTP attestation metadata for wiki summary
await pool.query(`ALTER TABLE personaforge_subjects ADD COLUMN IF NOT EXISTS wiki_ptp_token_id TEXT`);
await pool.query(`ALTER TABLE personaforge_subjects ADD COLUMN IF NOT EXISTS wiki_attested_at TIMESTAMPTZ`);
// Corpus consult log (PTP injection events, QA sessions)
await pool.query(`
CREATE TABLE IF NOT EXISTS corpus_consult_log (
id SERIAL PRIMARY KEY,
slug TEXT NOT NULL,
event_type TEXT NOT NULL,
token_id TEXT,
scope TEXT,
purpose TEXT,
attested_at TEXT,
session_notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
// Also add to registry for forward-compat (new stewards via web form)
await pool.query(`ALTER TABLE agentify_subject_registry ADD COLUMN IF NOT EXISTS dashboard_token TEXT`).catch(() => {});
// Co-steward collaboration: array of invited co-steward emails
await pool.query(`ALTER TABLE agentify_subject_registry ADD COLUMN IF NOT EXISTS co_steward_emails TEXT[] DEFAULT '{}'`).catch(() => {});
await pool.query(`ALTER TABLE personaforge_subjects ADD COLUMN IF NOT EXISTS co_steward_emails TEXT[] DEFAULT '{}'`).catch(() => {});
// Catalog source curation — park/restore without deleting
await pool.query(`ALTER TABLE agentify_source_catalog ADD COLUMN IF NOT EXISTS catalog_status TEXT NOT NULL DEFAULT 'active'`).catch(() => {});
// Steward action audit log
await pool.query(`
CREATE TABLE IF NOT EXISTS steward_audit_log (
id SERIAL PRIMARY KEY,
subject_slug TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT 'steward',
action TEXT NOT NULL,
target_url TEXT,
target_title TEXT,
detail TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`).catch(() => {});
console.log("[StewardDash] Schema columns ready");
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function isAgentify(req: Request): boolean {
const candidates = [
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());
return candidates.some(h => h.includes("agentify") && !h.startsWith("skills."));
}
async function validateStewardToken(slug: string, token: string | undefined): Promise<boolean> {
if (!token) return false;
// Primary source: personaforge_subjects (exists for all subjects including admin-seeded)
const { rows } = await pool.query(
`SELECT dashboard_token FROM personaforge_subjects WHERE slug = $1`,
[slug]
);
if (rows.length > 0 && rows[0].dashboard_token === token) return true;
// Fallback: agentify_subject_registry (for stewards registered via web form)
const { rows: reg } = await pool.query(
`SELECT dashboard_token FROM agentify_subject_registry WHERE subject_slug = $1`,
[slug]
).catch(() => ({ rows: [] }));
return reg.length > 0 && reg[0].dashboard_token === token;
}
async function getOrCreateDashboardToken(slug: string): Promise<string> {
// Primary: personaforge_subjects
const { rows } = await pool.query(
`SELECT dashboard_token FROM personaforge_subjects WHERE slug = $1`, [slug]
);
if (rows[0]?.dashboard_token) return rows[0].dashboard_token;
const token = crypto.randomBytes(24).toString("hex");
// Store in personaforge_subjects
const updated = await pool.query(
`UPDATE personaforge_subjects SET dashboard_token = $2, updated_at = NOW() WHERE slug = $1 RETURNING slug`,
[slug, token]
);
if ((updated.rowCount || 0) === 0) throw new Error(`Subject '${slug}' not found in personaforge_subjects`);
// Also sync to agentify_subject_registry if that row exists
await pool.query(
`UPDATE agentify_subject_registry SET dashboard_token = $2, updated_at = NOW() WHERE subject_slug = $1`,
[slug, token]
).catch(() => {});
return token;
}
async function fetchEssayText(url: string): Promise<string> {
try {
const html = await fetch(url, {
headers: { "User-Agent": "WellSpr.ing PersonaForge/1.0" },
signal: AbortSignal.timeout(15000),
}).then(r => r.text());
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(/\s+/g, " ").trim();
} catch {
return "";
}
}
// ── SSR Dashboard page ────────────────────────────────────────────────────────
function dashboardPage(slug: string, subjectName: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${subjectName} — Stewardship Dashboard | Agentify.Help</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{
--navy:#07102a;--navy2:#0d1b3e;--navy3:#132148;
--gold:#d4a730;--gold2:#e8c24a;
--green:#22c55e;--red:#ef4444;--amber:#f59e0b;--blue:#60a5fa;
--muted:#8899bb;--border:#1e3060;
--text:#dde6f4;--text2:#a8bcd8;
}
body{background:var(--navy);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;min-height:100vh}
/* ── header ── */
.header{background:var(--navy2);border-bottom:1px solid var(--border);padding:1rem 2rem;display:flex;align-items:center;gap:1.5rem;flex-wrap:wrap}
.header-left{display:flex;align-items:center;gap:1rem}
.logo-badge{background:var(--gold);color:#07102a;font-size:0.6rem;font-weight:900;letter-spacing:0.12em;padding:0.25rem 0.5rem;border-radius:3px}
.header-title{font-size:1.25rem;font-weight:700;color:#fff}
.header-sub{font-size:0.8rem;color:var(--muted);margin-top:0.1rem}
.header-actions{margin-left:auto;display:flex;gap:0.75rem;align-items:center}
.btn{display:inline-flex;align-items:center;gap:0.4rem;padding:0.5rem 1rem;border-radius:6px;border:none;cursor:pointer;font-size:0.85rem;font-weight:600;transition:opacity 0.15s}
.btn:disabled{opacity:0.4;cursor:not-allowed}
.btn-gold{background:var(--gold);color:#07102a}
.btn-gold:hover:not(:disabled){background:var(--gold2)}
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text2)}
.btn-outline:hover:not(:disabled){border-color:var(--gold);color:var(--gold)}
.btn-sm{padding:0.3rem 0.65rem;font-size:0.78rem}
/* ── stats bar ── */
.stats-bar{background:var(--navy3);border-bottom:1px solid var(--border);padding:0.85rem 2rem;display:flex;gap:2rem;align-items:center;flex-wrap:wrap}
.stat{display:flex;flex-direction:column;align-items:center;gap:0.15rem}
.stat-val{font-size:1.4rem;font-weight:800;color:#fff}
.stat-label{font-size:0.7rem;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted)}
.stat-val.green{color:var(--green)}
.stat-val.red{color:var(--red)}
.stat-val.amber{color:var(--amber)}
.progress-wrap{flex:1;min-width:200px}
.progress-label{font-size:0.75rem;color:var(--muted);margin-bottom:0.35rem}
.progress-bar{height:6px;background:var(--border);border-radius:3px;overflow:hidden}
.progress-fill{height:100%;background:var(--green);border-radius:3px;transition:width 0.4s}
/* ── filters ── */
.filters{padding:1rem 2rem;display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;border-bottom:1px solid var(--border)}
.filter-btn{padding:0.4rem 0.9rem;border-radius:20px;border:1px solid var(--border);background:transparent;color:var(--text2);font-size:0.82rem;cursor:pointer;transition:all 0.15s}
.filter-btn.active{background:var(--gold);color:#07102a;border-color:var(--gold);font-weight:700}
.filter-btn:hover:not(.active){border-color:var(--gold);color:var(--gold)}
.search-wrap{margin-left:auto}
.search-input{background:var(--navy3);border:1px solid var(--border);border-radius:6px;padding:0.4rem 0.75rem;color:var(--text);font-size:0.82rem;width:220px;outline:none}
.search-input:focus{border-color:var(--gold)}
/* ── artifact grid ── */
.artifacts{padding:1.5rem 2rem;display:flex;flex-direction:column;gap:0.85rem}
.artifact-card{background:var(--navy2);border:1px solid var(--border);border-radius:10px;border-left:4px solid var(--border);padding:1rem 1.25rem;transition:border-color 0.2s}
.artifact-card.pending{border-left-color:var(--border)}
.artifact-card.approved{border-left-color:var(--green)}
.artifact-card.rejected{border-left-color:var(--red)}
.artifact-card.flagged{border-left-color:var(--amber)}
.card-top{display:flex;align-items:flex-start;gap:1rem}
.card-meta{flex:1;min-width:0}
.card-title{font-size:0.97rem;font-weight:600;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card-details{display:flex;gap:0.75rem;margin-top:0.3rem;flex-wrap:wrap}
.detail-chip{font-size:0.72rem;color:var(--muted);background:var(--navy3);padding:0.18rem 0.5rem;border-radius:4px}
.status-badge{font-size:0.7rem;font-weight:700;padding:0.22rem 0.6rem;border-radius:4px;text-transform:uppercase;letter-spacing:0.06em;white-space:nowrap}
.status-badge.pending{background:rgba(136,153,187,0.15);color:var(--muted)}
.status-badge.approved{background:rgba(34,197,94,0.15);color:var(--green)}
.status-badge.rejected{background:rgba(239,68,68,0.15);color:var(--red)}
.status-badge.flagged{background:rgba(245,158,11,0.15);color:var(--amber)}
.card-actions{display:flex;gap:0.5rem;margin-top:0.85rem;flex-wrap:wrap;align-items:center}
.act-btn{padding:0.3rem 0.7rem;border-radius:5px;border:1px solid;font-size:0.78rem;font-weight:600;cursor:pointer;transition:all 0.15s}
.act-btn.approve{border-color:rgba(34,197,94,0.4);color:var(--green);background:rgba(34,197,94,0.08)}
.act-btn.approve:hover,.act-btn.approve.active{background:var(--green);color:#07102a;border-color:var(--green)}
.act-btn.reject{border-color:rgba(239,68,68,0.4);color:var(--red);background:rgba(239,68,68,0.08)}
.act-btn.reject:hover,.act-btn.reject.active{background:var(--red);color:#fff;border-color:var(--red)}
.act-btn.flag{border-color:rgba(245,158,11,0.4);color:var(--amber);background:rgba(245,158,11,0.08)}
.act-btn.flag:hover,.act-btn.flag.active{background:var(--amber);color:#07102a;border-color:var(--amber)}
.act-btn.ody{border-color:rgba(96,165,250,0.4);color:var(--blue);background:rgba(96,165,250,0.08)}
.act-btn.ody:hover{background:var(--blue);color:#07102a;border-color:var(--blue)}
.act-btn:disabled{opacity:0.4;cursor:not-allowed}
.source-link{font-size:0.72rem;color:var(--muted);text-decoration:none;margin-left:auto}
.source-link:hover{color:var(--gold)}
/* ── Ody verdict inline ── */
.ody-verdict{background:var(--navy3);border:1px solid rgba(96,165,250,0.25);border-radius:7px;padding:0.85rem 1rem;margin-top:0.85rem;position:relative}
.ody-verdict-label{font-size:0.68rem;font-weight:700;text-transform:uppercase;letter-spacing:0.1em;color:var(--blue);margin-bottom:0.4rem}
.ody-verdict-text{font-size:0.86rem;color:var(--text2);line-height:1.6}
.ody-verdict-tag{display:inline-block;font-size:0.7rem;font-weight:700;padding:0.15rem 0.5rem;border-radius:4px;margin-bottom:0.5rem}
.ody-verdict-tag.approve{background:rgba(34,197,94,0.15);color:var(--green)}
.ody-verdict-tag.flag{background:rgba(245,158,11,0.15);color:var(--amber)}
.ody-verdict-tag.reject{background:rgba(239,68,68,0.15);color:var(--red)}
.ody-close{position:absolute;top:0.5rem;right:0.75rem;background:none;border:none;color:var(--muted);cursor:pointer;font-size:1rem;padding:0.1rem}
.ody-close:hover{color:var(--text)}
.ody-spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(96,165,250,0.3);border-top-color:var(--blue);border-radius:50%;animation:spin 0.7s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
/* ── empty + loading states ── */
.empty-state{text-align:center;padding:4rem 2rem;color:var(--muted)}
.empty-state h2{font-size:1.1rem;color:var(--text2);margin-bottom:0.5rem}
.empty-state p{font-size:0.85rem;line-height:1.6}
.loading-state{text-align:center;padding:4rem 2rem;color:var(--muted);font-size:0.9rem}
/* ── fetch modal ── */
.modal-overlay{position:fixed;inset:0;background:rgba(7,16,42,0.85);display:flex;align-items:center;justify-content:center;z-index:100;backdrop-filter:blur(4px)}
.modal-overlay.hidden{display:none}
.modal{background:var(--navy2);border:1px solid var(--border);border-radius:12px;padding:2rem;max-width:480px;width:90%;max-height:80vh;overflow-y:auto}
.modal h2{font-size:1.1rem;font-weight:700;color:#fff;margin-bottom:0.5rem}
.modal p{font-size:0.85rem;color:var(--text2);line-height:1.6;margin-bottom:1.25rem}
.modal-log{background:var(--navy);border:1px solid var(--border);border-radius:6px;padding:0.75rem;font-size:0.75rem;font-family:monospace;color:var(--muted);height:150px;overflow-y:auto;margin-bottom:1rem}
.modal-actions{display:flex;gap:0.75rem;justify-content:flex-end}
/* ── build status panel ── */
.build-panel{background:var(--navy3);border-top:1px solid var(--border);padding:1rem 2rem;display:none;align-items:center;gap:1rem;flex-wrap:wrap;position:sticky;bottom:0}
.build-panel.visible{display:flex}
.build-log{background:var(--navy);border:1px solid var(--border);border-radius:6px;padding:0.5rem 0.75rem;font-size:0.72rem;font-family:monospace;color:var(--muted);flex:1;min-width:200px;max-height:80px;overflow-y:auto}
/* ── biographical baseline panel ── */
.bio-panel{border-left:3px solid var(--gold);background:rgba(212,175,55,0.06);margin:1.25rem 2rem 0;border-radius:6px;overflow:hidden}
.bio-panel-header{display:flex;align-items:center;gap:0.6rem;padding:0.7rem 1rem;cursor:pointer;user-select:none}
.bio-panel-header h3{font-size:0.85rem;font-weight:700;color:var(--gold);margin:0;letter-spacing:0.04em;text-transform:uppercase}
.bio-panel-header .bio-badge{font-size:0.68rem;background:rgba(212,175,55,0.18);color:var(--gold);padding:0.2rem 0.5rem;border-radius:10px;font-weight:600}
.bio-panel-header .bio-expand{margin-left:auto;font-size:0.7rem;color:var(--muted)}
.bio-panel-body{padding:0 1rem 1rem;display:none}
.bio-panel-body.open{display:block}
.bio-section{margin-bottom:1rem}
.bio-section h4{font-size:0.75rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;margin:0 0 0.4rem}
.bio-textarea{width:100%;background:var(--navy);border:1px solid var(--border);border-radius:5px;color:#e8e8e8;font-family:monospace;font-size:0.72rem;line-height:1.5;padding:0.6rem 0.75rem;resize:vertical;min-height:160px;box-sizing:border-box}
.bio-textarea:focus{outline:none;border-color:var(--gold)}
.bio-actions{display:flex;gap:0.6rem;margin-top:0.5rem;align-items:center}
.bio-status{font-size:0.72rem;color:var(--muted);margin-left:auto}
a{color:inherit;text-decoration:none}
/* ── tab nav ── */
.tab-nav{display:flex;align-items:center;gap:0.5rem;padding:0.6rem 2rem;border-bottom:1px solid var(--border);flex-wrap:wrap;background:var(--navy2)}
.tab-btn{padding:0.38rem 0.85rem;border-radius:20px;border:1px solid var(--border);background:transparent;color:var(--text2);font-size:0.82rem;cursor:pointer;transition:all 0.15s;display:inline-flex;align-items:center;gap:0.35rem}
.tab-btn.active{background:var(--gold);color:#07102a;border-color:var(--gold);font-weight:700}
.tab-btn:hover:not(.active){border-color:var(--gold);color:var(--gold)}
.tab-count{font-size:0.7rem;background:rgba(255,255,255,0.14);padding:0.05rem 0.45rem;border-radius:10px;min-width:1.2em;text-align:center}
.tab-btn.active .tab-count{background:rgba(0,0,0,0.18)}
/* ── corpus sources ── */
.corpus-section{padding:1.25rem 2rem;display:flex;flex-direction:column;gap:0.75rem}
.cs-toolbar{display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;margin-bottom:0.25rem}
#csStatus{font-size:0.8rem;color:var(--muted);flex:1}
.cs-list{display:flex;flex-direction:column;gap:0.65rem}
.cs-card{background:var(--navy2);border:1px solid var(--border);border-radius:10px;border-left:4px solid var(--border);padding:0.9rem 1.1rem;transition:border-color 0.2s;display:flex;align-items:flex-start;gap:1rem}
.cs-card.parked{border-left-color:var(--amber);opacity:0.6}
.cs-card.active{border-left-color:var(--green)}
.cs-main{flex:1;min-width:0}
.cs-title{font-size:0.88rem;font-weight:600;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:0.3rem}
.cs-meta{display:flex;gap:0.45rem;flex-wrap:wrap;margin-bottom:0.4rem}
.cs-badge{font-size:0.62rem;text-transform:uppercase;letter-spacing:0.05em;padding:0.15rem 0.45rem;border-radius:3px;font-weight:700;background:rgba(255,255,255,0.07);color:var(--muted)}
.cs-badge.podcast{background:rgba(96,165,250,0.14);color:var(--blue)}
.cs-badge.article{background:rgba(74,222,128,0.1);color:#4ade80}
.cs-badge.book{background:rgba(212,167,55,0.14);color:var(--gold)}
.cs-badge.interview{background:rgba(192,132,252,0.14);color:#c084fc}
.cs-badge.parked-badge{background:rgba(245,158,11,0.14);color:var(--amber)}
.cs-badge.active-badge{background:rgba(34,197,94,0.1);color:var(--green)}
.cs-actions{display:flex;gap:0.5rem;align-items:center;flex-shrink:0}
.cs-btn{padding:0.3rem 0.7rem;border-radius:5px;border:1px solid var(--border);background:transparent;color:var(--text2);font-size:0.75rem;cursor:pointer;transition:all 0.15s;font-family:inherit;white-space:nowrap}
.cs-btn:hover{border-color:var(--gold);color:var(--gold)}
.cs-btn.park{border-color:rgba(245,158,11,0.35);color:var(--amber)}
.cs-btn.park:hover{background:rgba(245,158,11,0.09)}
.cs-btn.restore{border-color:rgba(34,197,94,0.35);color:var(--green)}
.cs-btn.restore:hover{background:rgba(34,197,94,0.09)}
/* ── activity log ── */
.activity-section{padding:1.5rem 2rem;max-width:760px}
.act-header{font-size:0.78rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:0.09em;margin-bottom:1rem}
.act-item{display:flex;gap:0.85rem;padding:0.6rem 0;border-bottom:1px solid var(--border)}
.act-item:last-child{border-bottom:none}
.act-icon{width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.72rem;flex-shrink:0;background:rgba(255,255,255,0.06);margin-top:0.1rem}
.act-body{flex:1;min-width:0}
.act-what{font-size:0.82rem;color:var(--text);text-transform:capitalize}
.act-detail{font-size:0.73rem;color:var(--muted);margin-top:0.15rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.act-when{font-size:0.7rem;color:var(--muted);white-space:nowrap;flex-shrink:0;padding-top:0.15rem}
.act-empty{font-size:0.85rem;color:var(--muted);padding:2.5rem;text-align:center}
.discover-query-wrap{margin:0.75rem 0}
.discover-query-wrap .search-input{width:100%;font-size:0.85rem}
.discover-result{background:var(--navy);border:1px solid var(--border);border-radius:5px;padding:0.6rem 0.75rem;font-size:0.72rem;font-family:monospace;color:var(--text2);white-space:pre-wrap;max-height:200px;overflow-y:auto;line-height:1.5}
</style>
</head>
<body>
<!-- ── Header ── -->
<header class="header">
<div class="header-left">
<span class="logo-badge">AGENTIFY.HELP</span>
<div>
<div class="header-title" id="subjectName">${subjectName}</div>
<div class="header-sub">Corpus Stewardship Dashboard</div>
</div>
</div>
<div class="header-actions">
<button class="btn btn-outline btn-sm" id="btnFetch" onclick="openFetchModal()">Fetch source essays</button>
<button class="btn btn-gold" id="btnBuild" onclick="triggerBuild()" disabled>
Build corpus from approved
</button>
</div>
</header>
<!-- ── Stats bar ── -->
<div class="stats-bar" id="statsBar">
<div class="stat"><span class="stat-val" id="statTotal">—</span><span class="stat-label">Total</span></div>
<div class="stat"><span class="stat-val green" id="statApproved">—</span><span class="stat-label">Approved</span></div>
<div class="stat"><span class="stat-val red" id="statRejected">—</span><span class="stat-label">Rejected</span></div>
<div class="stat"><span class="stat-val amber" id="statFlagged">—</span><span class="stat-label">Flagged</span></div>
<div class="stat"><span class="stat-val" id="statPending">—</span><span class="stat-label">Pending</span></div>
<div class="progress-wrap">
<div class="progress-label" id="progressLabel">0 of 0 reviewed</div>
<div class="progress-bar"><div class="progress-fill" id="progressFill" style="width:0%"></div></div>
</div>
</div>
<!-- ── Biographical Baseline Panel ── -->
<div class="bio-panel" id="bioPanel">
<div class="bio-panel-header" onclick="toggleBioPanel()">
<h3>Biographical Baseline</h3>
<span class="bio-badge" id="bioBadge">Loading…</span>
<span class="bio-expand" id="bioExpandLabel">▼ show</span>
</div>
<div class="bio-panel-body" id="bioPanelBody">
<div class="bio-section">
<h4>Ground-Truth Facts — injected as first corpus chunk, highest priority, not curatable</h4>
<textarea class="bio-textarea" id="bioFactsMd" placeholder="Paste structured biographical facts here (markdown). These become the persona's factual anchor — name, birthdate, family, education, career timeline, known frameworks. Skepticism-weight each fact: [V]=verified, [P]=partial, [U]=unconfirmed."></textarea>
<div class="bio-actions">
<button class="btn btn-gold btn-sm" onclick="saveBioFacts()">Save facts</button>
<button class="btn btn-outline btn-sm" onclick="loadBioFacts()">Reload</button>
<span class="bio-status" id="bioFactsStatus"></span>
</div>
</div>
<div class="bio-section">
<h4>Wikipedia / Secondary Sources — skepticism-weighted; separate from primary corpus</h4>
<textarea class="bio-textarea" id="wikiSummaryMd" placeholder="Paste Wikipedia summary or article excerpts here. These are ingested with a skepticism-weighting note — the persona knows this is secondary source material and will not treat it as first-person memory."></textarea>
<div class="bio-actions">
<button class="btn btn-gold btn-sm" onclick="saveWikiSummary()">Save wiki/articles</button>
<button class="btn btn-outline btn-sm" onclick="loadBioFacts()">Reload</button>
<span class="bio-status" id="wikiStatus"></span>
</div>
</div>
</div>
</div>
<!-- ── Tab nav ── -->
<div class="tab-nav">
<button class="tab-btn active" onclick="switchTab('artifacts',this)">Artifacts <span class="tab-count" id="tabCountArtifacts">0</span></button>
<button class="tab-btn" onclick="switchTab('corpus',this)">Corpus Sources <span class="tab-count" id="tabCountCorpus">0</span></button>
<button class="tab-btn" onclick="switchTab('activity',this)">Activity <span class="tab-count" id="tabCountActivity"></span></button>
<button class="btn btn-outline btn-sm" style="margin-left:auto" onclick="openDiscoverModal()"> Discover content</button>
</div>
<!-- ── Filters ── -->
<div class="filters" id="artifactFilters">
<button class="filter-btn active" data-filter="all" onclick="setFilter('all',this)">All</button>
<button class="filter-btn" data-filter="pending" onclick="setFilter('pending',this)">Pending</button>
<button class="filter-btn" data-filter="approved" onclick="setFilter('approved',this)">Approved</button>
<button class="filter-btn" data-filter="flagged" onclick="setFilter('flagged',this)">Flagged</button>
<button class="filter-btn" data-filter="rejected" onclick="setFilter('rejected',this)">Rejected</button>
<div class="search-wrap">
<input class="search-input" type="text" id="searchInput" placeholder="Search essays..." oninput="renderArtifacts()">
</div>
</div>
<!-- ── Artifact list ── -->
<div class="artifacts" id="artifactsContainer">
<div class="loading-state" id="loadingState">Loading corpus artifacts…</div>
</div>
<!-- ── Corpus Sources section ── -->
<div class="corpus-section" id="corpusSection" style="display:none">
<div class="cs-toolbar">
<span id="csStatus">Loading…</span>
<button class="filter-btn active" data-csf="all" onclick="setCsFilter('all',this)">All</button>
<button class="filter-btn" data-csf="active" onclick="setCsFilter('active',this)">Active</button>
<button class="filter-btn" data-csf="parked" onclick="setCsFilter('parked',this)">Parked</button>
</div>
<div class="cs-list" id="csList"><div class="loading-state">Loading corpus sources…</div></div>
</div>
<!-- ── Activity section ── -->
<div class="activity-section" id="activitySection" style="display:none">
<div class="act-list" id="actList"><div class="act-empty">Loading activity…</div></div>
</div>
<!-- ── Discover modal ── -->
<div class="modal-overlay hidden" id="discoverModal">
<div class="modal">
<h2>Discover new content</h2>
<p>Let Ody search for books, articles, and other sources not yet in the corpus. Results appear here for review — nothing is added automatically.</p>
<div class="discover-query-wrap">
<input class="search-input" type="text" id="discoverQuery" placeholder='e.g. "books by Guy Kawasaki" or leave blank for all types'>
</div>
<div class="discover-result" id="discoverLog">Ready.</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeDiscoverModal()">Close</button>
<button class="btn btn-gold" id="btnDiscoverConfirm" onclick="doDiscover()">Find content</button>
</div>
</div>
</div>
<!-- ── Build panel ── -->
<div class="build-panel" id="buildPanel">
<span style="font-size:0.85rem;font-weight:600;color:var(--gold)">Building corpus…</span>
<div class="build-log" id="buildLog"></div>
<button class="btn btn-outline btn-sm" onclick="closeBuildPanel()">Dismiss</button>
</div>
<!-- ── Fetch modal ── -->
<div class="modal-overlay hidden" id="fetchModal">
<div class="modal">
<h2>Fetch source essays</h2>
<p>This will scrape the source URL and pull the full list of essays. Existing entries are preserved. No essay text is fetched yet — just titles and URLs.</p>
<div class="modal-log" id="fetchLog">Ready.</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeFetchModal()">Cancel</button>
<button class="btn btn-gold" id="btnFetchConfirm" onclick="doFetch()">Fetch essays</button>
</div>
</div>
</div>
<script>
const SLUG = ${JSON.stringify(slug)};
const TOKEN = new URLSearchParams(location.search).get('token') || sessionStorage.getItem('steward_token_' + SLUG) || '';
if (TOKEN) sessionStorage.setItem('steward_token_' + SLUG, TOKEN);
let allArtifacts = [];
let currentFilter = 'all';
// ── Bootstrap ──────────────────────────────────────────────────────────────
async function init() {
await Promise.all([loadArtifacts(), loadBioFacts(), loadCatalogSources(), loadActivity()]);
}
// ── Tab switching ──────────────────────────────────────────────────────────
function switchTab(tab, btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const filters = document.getElementById('artifactFilters');
const artifacts = document.getElementById('artifactsContainer');
const corpus = document.getElementById('corpusSection');
const activity = document.getElementById('activitySection');
if (tab === 'artifacts') {
filters.style.display = '';
artifacts.style.display = '';
corpus.style.display = 'none';
activity.style.display = 'none';
} else if (tab === 'corpus') {
filters.style.display = 'none';
artifacts.style.display = 'none';
corpus.style.display = '';
activity.style.display = 'none';
} else {
filters.style.display = 'none';
artifacts.style.display = 'none';
corpus.style.display = 'none';
activity.style.display = '';
}
}
// ── Catalog Sources ─────────────────────────────────────────────────────────
let allCatalogSources = [];
let csLoaded = false;
let csFilter = 'all';
let actLoaded = false;
function setCsFilter(f, el) {
csFilter = f;
document.querySelectorAll('[data-csf]').forEach(b => b.classList.remove('active'));
el.classList.add('active');
renderCatalogSources();
}
async function loadCatalogSources() {
const listEl = document.getElementById('csList');
const statusEl = document.getElementById('csStatus');
if (!listEl) return;
try {
const r = await fetch('/api/steward/' + SLUG + '/catalog-sources?token=' + TOKEN);
if (!r.ok) {
if (r.status === 403) { listEl.innerHTML = '<div class="empty-state"><p>Invalid token.</p></div>'; return; }
throw new Error(await r.text());
}
const data = await r.json();
allCatalogSources = data.sources || [];
csLoaded = true;
const countEl = document.getElementById('tabCountCorpus');
if (countEl) countEl.textContent = allCatalogSources.length;
if (statusEl) statusEl.textContent = allCatalogSources.length + ' sources in corpus';
renderCatalogSources();
} catch(e) {
listEl.innerHTML = '<div class="empty-state"><h2>Error</h2><p>' + esc(e.message) + '</p></div>';
if (statusEl) statusEl.textContent = 'Error loading';
}
}
function renderCatalogSources() {
const listEl = document.getElementById('csList');
if (!listEl) return;
let visible = allCatalogSources.filter(s => {
if (csFilter === 'active') return s.catalog_status !== 'parked';
if (csFilter === 'parked') return s.catalog_status === 'parked';
return true;
});
if (!visible.length) {
listEl.innerHTML = '<div class="empty-state"><h2>No sources</h2><p>Try a different filter or use Discover to find new content.</p></div>';
return;
}
listEl.innerHTML = visible.map(s => catalogCardHTML(s)).join('');
}
function catalogCardHTML(s) {
const parked = s.catalog_status === 'parked';
const type = (s.type || 'article').toLowerCase();
const chunks = s.chunk_count ? s.chunk_count + ' chunks' : '';
let domain = '';
try {
if (s.url && !s.url.startsWith('podcast://')) domain = new URL(s.url).hostname.replace('www.', '');
} catch {}
return \`<div class="cs-card \${parked ? 'parked' : 'active'}" id="cscard-\${s.id}">
<div class="cs-main">
<div class="cs-title" title="\${esc(s.title || s.url)}">\${esc(s.title || s.url)}</div>
<div class="cs-meta">
<span class="cs-badge \${type}">\${type}</span>
\${chunks ? \`<span class="cs-badge">\${esc(chunks)}</span>\` : ''}
\${domain ? \`<span class="cs-badge">\${esc(domain)}</span>\` : ''}
<span class="cs-badge \${parked ? 'parked-badge' : 'active-badge'}">\${parked ? 'Parked' : 'Active'}</span>
</div>
\${s.url && !s.url.startsWith('podcast://') ? \`<a href="\${esc(s.url)}" target="_blank" rel="noopener" style="font-size:0.72rem;color:var(--muted)">View source ↗</a>\` : ''}
</div>
<div class="cs-actions">
\${parked
? \`<button class="cs-btn restore" onclick="togglePark(\${s.id},false)">↩ Restore</button>\`
: \`<button class="cs-btn park" onclick="togglePark(\${s.id},true)">⊘ Park</button>\`
}
</div>
</div>\`;
}
async function togglePark(id, park) {
const card = document.getElementById('cscard-' + id);
const btn = card ? card.querySelector('.cs-btn') : null;
if (btn) { btn.disabled = true; btn.textContent = '…'; }
try {
const r = await fetch('/api/steward/' + SLUG + '/catalog-sources/' + id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: TOKEN, action: park ? 'park' : 'restore' }),
});
if (!r.ok) throw new Error(await r.text());
const s = allCatalogSources.find(x => x.id === id);
if (s) s.catalog_status = park ? 'parked' : 'active';
renderCatalogSources();
actLoaded = false;
loadActivity();
} catch(e) {
alert('Could not save: ' + e.message);
if (btn) { btn.disabled = false; btn.textContent = park ? '⊘ Park' : '↩ Restore'; }
}
}
// ── Activity log ──────────────────────────────────────────────────────────
async function loadActivity() {
const listEl = document.getElementById('actList');
if (!listEl) return;
try {
const r = await fetch('/api/steward/' + SLUG + '/activity?token=' + TOKEN);
if (!r.ok) throw new Error(await r.text());
const data = await r.json();
const items = data.items || [];
actLoaded = true;
const countEl = document.getElementById('tabCountActivity');
if (countEl) countEl.textContent = items.length > 0 ? String(items.length) : '';
if (!items.length) {
listEl.innerHTML = '<div class="act-empty">No activity recorded yet. Actions like parking sources or discovering content will appear here.</div>';
return;
}
const icons = {park:'⊘',restore:'↩',discover:'⌕',build:'⚙',fetch:'⬇',curation:'✓'};
listEl.innerHTML = '<div class="act-header">Recent activity</div>' + items.map(item => {
const icon = icons[item.action] || '·';
const when = item.created_at ? new Date(item.created_at).toLocaleString() : '';
return \`<div class="act-item">
<div class="act-icon">\${esc(icon)}</div>
<div class="act-body">
<div class="act-what">\${esc(item.action)}</div>
\${item.target_title ? \`<div class="act-detail">\${esc(item.target_title)}</div>\` : ''}
\${item.detail ? \`<div class="act-detail" style="color:var(--text2)">\${esc(item.detail)}</div>\` : ''}
</div>
<div class="act-when">\${esc(when)}</div>
</div>\`;
}).join('');
} catch(e) {
listEl.innerHTML = '<div class="act-empty">Error loading activity: ' + esc(e.message) + '</div>';
}
}
// ── Discover content ──────────────────────────────────────────────────────
function openDiscoverModal() {
document.getElementById('discoverLog').textContent = 'Ready to search for new sources.';
document.getElementById('discoverQuery').value = '';
document.getElementById('discoverModal').classList.remove('hidden');
document.getElementById('btnDiscoverConfirm').disabled = false;
document.getElementById('btnDiscoverConfirm').textContent = 'Find content';
}
function closeDiscoverModal() {
document.getElementById('discoverModal').classList.add('hidden');
}
async function doDiscover() {
const logEl = document.getElementById('discoverLog');
const btn = document.getElementById('btnDiscoverConfirm');
const query = document.getElementById('discoverQuery').value.trim();
btn.disabled = true;
btn.textContent = 'Searching…';
logEl.textContent = 'Asking Ody to find new content…';
try {
const r = await fetch('/api/steward/' + SLUG + '/discover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: TOKEN, query }),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Discover failed');
const found = data.suggestions || [];
if (!found.length) {
logEl.textContent = 'No new sources found. Try a more specific query.';
} else {
logEl.textContent = 'Found ' + found.length + ' potential source(s):\\n\\n' +
found.map((s, i) => (i+1) + '. ' + (s.title || 'Untitled') + '\\n ' + (s.url || '') + (s.note ? '\\n ' + s.note : '')).join('\\n\\n');
}
btn.textContent = 'Search again';
btn.disabled = false;
actLoaded = false;
loadActivity();
} catch(e) {
logEl.textContent = 'Error: ' + e.message;
btn.disabled = false;
btn.textContent = 'Try again';
}
}
// ── Biographical Baseline ──────────────────────────────────────────────────
let bioPanelOpen = false;
function toggleBioPanel() {
bioPanelOpen = !bioPanelOpen;
document.getElementById('bioPanelBody').classList.toggle('open', bioPanelOpen);
document.getElementById('bioExpandLabel').textContent = bioPanelOpen ? '▲ hide' : '▼ show';
}
async function loadBioFacts() {
try {
const r = await fetch('/api/steward/' + SLUG + '/bio-facts?token=' + TOKEN);
const data = await r.json();
document.getElementById('bioFactsMd').value = data.bio_facts_md || '';
document.getElementById('wikiSummaryMd').value = data.wiki_summary_md || '';
const hasB = !!(data.bio_facts_md && data.bio_facts_md.trim());
const hasW = !!(data.wiki_summary_md && data.wiki_summary_md.trim());
const badge = hasB ? (hasW ? 'Bio + Wiki loaded' : 'Bio loaded') : (hasW ? 'Wiki only' : 'Not set');
document.getElementById('bioBadge').textContent = badge;
document.getElementById('bioBadge').style.color = hasB ? 'var(--gold)' : 'var(--muted)';
if (hasB && !bioPanelOpen) toggleBioPanel();
} catch (e) {
document.getElementById('bioBadge').textContent = 'Error loading';
}
}
async function saveBioFacts() {
const md = document.getElementById('bioFactsMd').value;
const st = document.getElementById('bioFactsStatus');
st.textContent = 'Saving…';
try {
const r = await fetch('/api/steward/' + SLUG + '/bio-facts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: TOKEN, bio_facts_md: md }),
});
const data = await r.json();
st.textContent = data.ok ? 'Saved ✓' : ('Error: ' + (data.error || 'unknown'));
if (data.ok) await loadBioFacts();
} catch (e) {
st.textContent = 'Network error';
}
setTimeout(() => { st.textContent = ''; }, 3000);
}
async function saveWikiSummary() {
const md = document.getElementById('wikiSummaryMd').value;
const st = document.getElementById('wikiStatus');
st.textContent = 'Saving…';
try {
const r = await fetch('/api/steward/' + SLUG + '/bio-facts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: TOKEN, wiki_summary_md: md }),
});
const data = await r.json();
st.textContent = data.ok ? 'Saved ✓' : ('Error: ' + (data.error || 'unknown'));
if (data.ok) await loadBioFacts();
} catch (e) {
st.textContent = 'Network error';
}
setTimeout(() => { st.textContent = ''; }, 3000);
}
async function loadArtifacts() {
try {
const r = await fetch('/api/steward/' + SLUG + '/artifacts?token=' + TOKEN);
if (!r.ok) {
if (r.status === 403) return showError('Invalid or expired dashboard token. Check your link.');
return showError('Could not load artifacts.');
}
const data = await r.json();
allArtifacts = data.artifacts || [];
renderStats();
renderArtifacts();
updateBuildBtn();
} catch (e) {
showError('Network error loading artifacts.');
}
}
function showError(msg) {
document.getElementById('artifactsContainer').innerHTML =
'<div class="empty-state"><h2>Error</h2><p>' + msg + '</p></div>';
}
// ── Stats ──────────────────────────────────────────────────────────────────
function renderStats() {
const total = allArtifacts.length;
const approved = allArtifacts.filter(a => a.curation_status === 'approved').length;
const rejected = allArtifacts.filter(a => a.curation_status === 'rejected').length;
const flagged = allArtifacts.filter(a => a.curation_status === 'flagged').length;
const pending = allArtifacts.filter(a => a.curation_status === 'pending').length;
const reviewed = total - pending;
document.getElementById('statTotal').textContent = total;
document.getElementById('statApproved').textContent = approved;
document.getElementById('statRejected').textContent = rejected;
document.getElementById('statFlagged').textContent = flagged;
document.getElementById('statPending').textContent = pending;
document.getElementById('progressLabel').textContent = reviewed + ' of ' + total + ' reviewed';
document.getElementById('progressFill').style.width = total ? Math.round(reviewed/total*100)+'%' : '0%';
}
function updateBuildBtn() {
const approved = allArtifacts.filter(a => a.curation_status === 'approved').length;
const btn = document.getElementById('btnBuild');
btn.disabled = approved === 0;
btn.textContent = approved > 0
? 'Build corpus from ' + approved + ' approved'
: 'Build corpus from approved';
}
// ── Filter + search ────────────────────────────────────────────────────────
function setFilter(f, el) {
currentFilter = f;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
el.classList.add('active');
renderArtifacts();
}
function renderArtifacts() {
const query = (document.getElementById('searchInput').value || '').toLowerCase();
let visible = allArtifacts.filter(a => {
if (currentFilter !== 'all' && a.curation_status !== currentFilter) return false;
if (query && !a.title.toLowerCase().includes(query)) return false;
return true;
});
const container = document.getElementById('artifactsContainer');
if (allArtifacts.length === 0) {
container.innerHTML = '<div class="empty-state"><h2>No essays fetched yet</h2><p>Click "Fetch source essays" to pull the essay list from the source URL. No essay text is downloaded yet — just titles and links.</p></div>';
return;
}
if (visible.length === 0) {
container.innerHTML = '<div class="empty-state"><h2>No matches</h2><p>Try a different filter or search term.</p></div>';
return;
}
container.innerHTML = visible.map(a => cardHTML(a)).join('');
}
// ── Card HTML ──────────────────────────────────────────────────────────────
function cardHTML(a) {
const wc = a.word_count ? a.word_count.toLocaleString() + ' words' : 'word count unknown';
const date = a.pub_date || 'undated';
const statusClass = a.curation_status || 'pending';
const statusLabel = {pending:'Pending',approved:'Approved',rejected:'Rejected',flagged:'Flagged'}[statusClass] || 'Pending';
const verdictHtml = a.ody_verdict ? odtVerdictInlineHTML(a.ody_verdict, a.artifact_id) : '';
return \`<div class="artifact-card \${statusClass}" id="card-\${a.artifact_id}">
<div class="card-top">
<div class="card-meta">
<div class="card-title" title="\${esc(a.title)}">\${esc(a.title)}</div>
<div class="card-details">
<span class="detail-chip">\${esc(date)}</span>
<span class="detail-chip">\${esc(wc)}</span>
<span class="detail-chip">\${esc(a.artifact_type || 'essay')}</span>
</div>
</div>
<span class="status-badge \${statusClass}">\${statusLabel}</span>
</div>
<div class="card-actions">
<button class="act-btn approve \${statusClass==='approved'?'active':''}" onclick="setCuration('\${a.artifact_id}','approved',this)">✓ Approve</button>
<button class="act-btn reject \${statusClass==='rejected'?'active':''}" onclick="setCuration('\${a.artifact_id}','rejected',this)">✗ Reject</button>
<button class="act-btn flag \${statusClass==='flagged'?'active':''}" onclick="setCuration('\${a.artifact_id}','flagged',this)">⚑ Flag</button>
<button class="act-btn ody" id="ody-btn-\${a.artifact_id}" onclick="askOdy('\${a.artifact_id}',\${JSON.stringify(a.title)},\${JSON.stringify(a.source_url||'')})">Ask Ody</button>
\${a.source_url ? \`<a class="source-link" href="\${esc(a.source_url)}" target="_blank" rel="noopener">Read →</a>\` : ''}
</div>
\${verdictHtml}
</div>\`;
}
function odtVerdictInlineHTML(verdict, artifactId) {
const parsed = tryParseVerdict(verdict);
return \`<div class="ody-verdict" id="verdict-\${artifactId}">
<div class="ody-verdict-label">Ody's read</div>
\${parsed.tag ? \`<span class="ody-verdict-tag \${parsed.tag}">\${parsed.tagLabel}</span>\` : ''}
<div class="ody-verdict-text">\${esc(parsed.body)}</div>
<button class="ody-close" onclick="clearVerdict('\${artifactId}')" title="Dismiss">×</button>
</div>\`;
}
function tryParseVerdict(text) {
const lower = text.toLowerCase();
let tag = '', tagLabel = '';
if (lower.includes('verdict: approve') || lower.startsWith('approve')) { tag='approve'; tagLabel='Approve'; }
else if (lower.includes('verdict: flag') || lower.startsWith('flag')) { tag='flag'; tagLabel='Flag for review'; }
else if (lower.includes('verdict: reject') || lower.startsWith('reject')) { tag='reject'; tagLabel='Reject'; }
return { tag, tagLabel, body: text };
}
function clearVerdict(artifactId) {
const el = document.getElementById('verdict-' + artifactId);
if (el) el.remove();
const a = allArtifacts.find(x => x.artifact_id === artifactId);
if (a) a.ody_verdict = null;
}
function esc(str) {
return String(str||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Curation actions ───────────────────────────────────────────────────────
async function setCuration(artifactId, status, clickedBtn) {
const card = document.getElementById('card-' + artifactId);
const btns = card.querySelectorAll('.act-btn.approve,.act-btn.reject,.act-btn.flag');
btns.forEach(b => b.disabled = true);
try {
const r = await fetch('/api/steward/' + SLUG + '/artifacts/' + artifactId, {
method: 'PATCH',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ curation_status: status, token: TOKEN }),
});
if (!r.ok) throw new Error('Save failed');
const a = allArtifacts.find(x => x.artifact_id === artifactId);
if (a) a.curation_status = status;
// update card in place
card.className = 'artifact-card ' + status;
const badge = card.querySelector('.status-badge');
if (badge) {
badge.className = 'status-badge ' + status;
badge.textContent = {pending:'Pending',approved:'Approved',rejected:'Rejected',flagged:'Flagged'}[status];
}
btns.forEach(b => {
b.classList.remove('active');
b.disabled = false;
});
clickedBtn.classList.add('active');
renderStats();
updateBuildBtn();
} catch {
btns.forEach(b => b.disabled = false);
alert('Could not save. Try again.');
}
}
// ── Ask Ody ────────────────────────────────────────────────────────────────
async function askOdy(artifactId, title, sourceUrl) {
const btn = document.getElementById('ody-btn-' + artifactId);
if (!btn) return;
btn.disabled = true;
btn.innerHTML = '<span class="ody-spinner"></span> Asking…';
// Remove existing verdict if any
const existingVerdict = document.getElementById('verdict-' + artifactId);
if (existingVerdict) existingVerdict.remove();
// Show spinner in card
const card = document.getElementById('card-' + artifactId);
const spinEl = document.createElement('div');
spinEl.className = 'ody-verdict';
spinEl.id = 'verdict-' + artifactId;
spinEl.innerHTML = '<div class="ody-verdict-label">Ody is reading…</div><div style="display:flex;align-items:center;gap:0.5rem;margin-top:0.4rem"><span class="ody-spinner"></span><span style="font-size:0.82rem;color:var(--text2)">Fetching essay and analyzing…</span></div>';
card.appendChild(spinEl);
try {
const r = await fetch('/api/steward/' + SLUG + '/artifacts/' + artifactId + '/ody-ask', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ token: TOKEN }),
});
if (!r.ok) throw new Error(await r.text());
const data = await r.json();
const verdict = data.verdict || '';
const a = allArtifacts.find(x => x.artifact_id === artifactId);
if (a) a.ody_verdict = verdict;
// Replace spinner with verdict
const verdictEl = document.getElementById('verdict-' + artifactId);
if (verdictEl) {
const parsed = tryParseVerdict(verdict);
verdictEl.innerHTML = \`
<div class="ody-verdict-label">Ody's read</div>
\${parsed.tag ? \`<span class="ody-verdict-tag \${parsed.tag}">\${parsed.tagLabel}</span>\` : ''}
<div class="ody-verdict-text">\${esc(verdict)}</div>
<button class="ody-close" onclick="clearVerdict('\${artifactId}')" title="Dismiss">×</button>
\`;
}
} catch(e) {
const verdictEl = document.getElementById('verdict-' + artifactId);
if (verdictEl) {
verdictEl.innerHTML = '<div class="ody-verdict-label" style="color:var(--red)">Error</div><div class="ody-verdict-text">Could not get Ody\u2019s analysis. ' + esc(e.message) + '</div><button class="ody-close" onclick="clearVerdict(\\''+artifactId+'\\')">×</button>';
}
} finally {
btn.disabled = false;
btn.innerHTML = 'Ask Ody';
}
}
// ── Fetch modal ────────────────────────────────────────────────────────────
function openFetchModal() {
document.getElementById('fetchLog').textContent = 'Ready to fetch.';
document.getElementById('fetchModal').classList.remove('hidden');
document.getElementById('btnFetchConfirm').disabled = false;
document.getElementById('btnFetchConfirm').textContent = 'Fetch essays';
}
function closeFetchModal() {
document.getElementById('fetchModal').classList.add('hidden');
}
async function doFetch() {
const logEl = document.getElementById('fetchLog');
const btn = document.getElementById('btnFetchConfirm');
btn.disabled = true;
btn.textContent = 'Fetching…';
logEl.textContent = 'Sending fetch request…\\n';
try {
const r = await fetch('/api/steward/' + SLUG + '/corpus/fetch', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ token: TOKEN }),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Fetch failed');
logEl.textContent += 'Done. ' + (data.count || 0) + ' essays found.\\n';
btn.textContent = 'Close';
btn.disabled = false;
btn.onclick = closeFetchModal;
await loadArtifacts();
} catch(e) {
logEl.textContent += 'Error: ' + e.message;
btn.disabled = false;
btn.textContent = 'Retry';
btn.onclick = doFetch;
}
}
// ── Build ──────────────────────────────────────────────────────────────────
async function triggerBuild() {
const approved = allArtifacts.filter(a => a.curation_status === 'approved');
if (approved.length === 0) return;
const panel = document.getElementById('buildPanel');
const logEl = document.getElementById('buildLog');
panel.classList.add('visible');
logEl.textContent = 'Initiating corpus build for ' + approved.length + ' approved essays…\\n';
document.getElementById('btnBuild').disabled = true;
try {
const r = await fetch('/api/steward/' + SLUG + '/corpus/build', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ token: TOKEN }),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Build failed to start');
logEl.textContent += 'Build started. Bundle: ' + (data.bundle_id || '?') + '\\n';
logEl.textContent += 'Connecting to live log stream…\\n';
// SSE stream
const es = new EventSource('/api/personaforge/subjects/' + SLUG + '/logs');
es.addEventListener('log', e => {
try {
const { msg } = JSON.parse(e.data);
logEl.textContent += msg + '\\n';
logEl.scrollTop = logEl.scrollHeight;
} catch {}
});
es.addEventListener('done', () => {
logEl.textContent += '\\nBuild complete.\\n';
es.close();
document.getElementById('btnBuild').disabled = false;
});
es.onerror = () => {
logEl.textContent += '(log stream ended)\\n';
es.close();
document.getElementById('btnBuild').disabled = false;
};
} catch(e) {
logEl.textContent += 'Error: ' + e.message;
document.getElementById('btnBuild').disabled = false;
}
}
function closeBuildPanel() {
document.getElementById('buildPanel').classList.remove('visible');
}
// ── Run ────────────────────────────────────────────────────────────────────
init();
</script>
</body>
</html>`;
}
// ── Routes ────────────────────────────────────────────────────────────────────
// NOTE: This function is intentionally synchronous so that app.get("/steward/:slug")
// is registered before serveSpaFallback() in the production middleware stack.
// initStewardDash() runs DB schema migrations in the background — the route
// handlers themselves are safe to execute once the DB is available.
export function registerStewardRoutes(app: Express) {
// Run schema migrations in the background; do NOT await before registering routes
initStewardDash().catch((e: any) => console.error("[StewardDash] init error:", e.message));
// ── GET /steward/:slug — SSR dashboard ──────────────────────────────────
// Note: host guard removed — the dashboard token is the access control.
// Subjects may exist in either personaforge_subjects (admin-seeded) or
// agentify_subject_registry (registered via web form). We check both and
// auto-promote registry-only subjects into personaforge_subjects on first access.
app.get("/steward/:slug", async (req: Request, res: Response) => {
const { slug } = req.params;
const token = req.query.token as string;
const notFoundPage = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Not found</title>
<style>body{background:#07102a;color:#dde6f4;font-family:system-ui;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
.box{text-align:center}h1{color:#d4a730;margin-bottom:0.5rem}p{color:#8899bb;font-size:0.9rem}</style></head>
<body><div class="box"><h1>Not found</h1><p>No subject found for slug: ${slug}</p></div></body></html>`;
const accessDeniedPage = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Access denied</title>
<style>body{background:#07102a;color:#dde6f4;font-family:system-ui;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
.box{text-align:center;max-width:400px}h1{color:#d4a730;margin-bottom:0.5rem}p{color:#8899bb;font-size:0.9rem;line-height:1.6}</style></head>
<body><div class="box"><h1>Access denied</h1><p>This dashboard requires a valid stewardship token. Use the link from your confirmation email, or contact <a href="mailto:ody@wellspr.ing" style="color:#d4a730">ody@wellspr.ing</a>.</p></div></body></html>`;
try {
// 1. Look up in personaforge_subjects (primary/canonical source)
let { rows: pf } = await pool.query(
`SELECT subject_name, build_status, dashboard_token FROM personaforge_subjects WHERE slug = $1`, [slug]
);
// 2. If not found in personaforge_subjects, check agentify_subject_registry
// (stewards who registered via the web form end up here)
if (!pf[0]) {
const { rows: reg } = await pool.query(
`SELECT subject_name, subject_domain, steward_name, steward_email, dashboard_token, status
FROM agentify_subject_registry WHERE subject_slug = $1`, [slug]
).catch(() => ({ rows: [] as any[] }));
if (!reg[0]) {
return res.status(404).send(notFoundPage);
}
const r = reg[0];
// Auto-promote into personaforge_subjects so all dashboard features work
await pool.query(
`INSERT INTO personaforge_subjects (slug, subject_name, domain, build_status, dashboard_token)
VALUES ($1, $2, $3, 'active', $4)
ON CONFLICT (slug) DO UPDATE
SET subject_name = EXCLUDED.subject_name,
domain = EXCLUDED.domain,
dashboard_token = COALESCE(personaforge_subjects.dashboard_token, EXCLUDED.dashboard_token),
updated_at = NOW()`,
[slug, r.subject_name, r.subject_domain || "general", r.dashboard_token || null]
).catch((e: any) => console.warn("[StewardDash] auto-promote error:", e.message));
// Re-fetch so the rest of the handler sees the row
const refetch = await pool.query(
`SELECT subject_name, build_status, dashboard_token FROM personaforge_subjects WHERE slug = $1`, [slug]
);
pf = refetch.rows;
if (!pf[0]) {
// Fallback: use registry data directly
const validToken = r.dashboard_token;
if (!validToken || token !== validToken) return res.status(403).send(accessDeniedPage);
return res.send(dashboardPage(slug, r.subject_name));
}
}
const subjectName = pf[0].subject_name;
const validToken = pf[0].dashboard_token;
if (!validToken || token !== validToken) return res.status(403).send(accessDeniedPage);
return res.send(dashboardPage(slug, subjectName));
} catch (e: any) {
console.error("[StewardDash] /steward/:slug error:", e.message);
return res.status(500).send(`<!DOCTYPE html><html><body style="background:#07102a;color:#dde6f4;font-family:system-ui;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0"><div style="text-align:center"><h1 style="color:#d4a730">Error</h1><p style="color:#8899bb">Something went wrong loading this dashboard. Please try again.</p></div></body></html>`);
}
});
// ── GET /api/steward/:slug/info ──────────────────────────────────────────
app.get("/api/steward/:slug/info", async (req: Request, res: Response) => {
const { slug } = req.params;
const token = req.query.token as string || req.body?.token;
if (!await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token" });
const { rows: pf } = await pool.query(`SELECT * FROM personaforge_subjects WHERE slug = $1`, [slug]);
if (!pf[0]) return res.status(404).json({ error: "Subject not found" });
const { rows: counts } = await pool.query(
`SELECT curation_status, COUNT(*) as n FROM personaforge_artifacts WHERE subject_slug = $1 GROUP BY curation_status`, [slug]
);
const stats: Record<string, number> = { pending: 0, approved: 0, rejected: 0, flagged: 0 };
counts.forEach(r => { stats[r.curation_status] = parseInt(r.n); });
res.json({ subject: pf[0], stats });
});
// ── GET /api/steward/:slug/artifacts ────────────────────────────────────
app.get("/api/steward/:slug/artifacts", async (req: Request, res: Response) => {
const { slug } = req.params;
const token = req.query.token as string;
if (!await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token" });
const { rows } = await pool.query(
`SELECT artifact_id, title, source_url, pub_date, artifact_type, word_count, chunk_count, curation_status, curation_note, ody_verdict, created_at
FROM personaforge_artifacts WHERE subject_slug = $1 ORDER BY pub_date DESC NULLS LAST, title ASC`,
[slug]
);
res.json({ artifacts: rows });
});
// ── PATCH /api/steward/:slug/artifacts/:artifact_id ─────────────────────
app.patch("/api/steward/:slug/artifacts/:artifact_id", async (req: Request, res: Response) => {
const { slug, artifact_id } = req.params;
const { token, curation_status, curation_note } = req.body;
if (!await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token" });
const allowed = ["pending", "approved", "rejected", "flagged"];
if (!allowed.includes(curation_status)) return res.status(400).json({ error: "Invalid curation_status" });
await pool.query(
`UPDATE personaforge_artifacts SET curation_status = $1, curation_note = $2 WHERE artifact_id = $3 AND subject_slug = $4`,
[curation_status, curation_note || null, artifact_id, slug]
);
res.json({ ok: true });
});
// ── GET /api/steward/:slug/catalog-sources ──────────────────────────────
app.get("/api/steward/:slug/catalog-sources", async (req: Request, res: Response) => {
const { slug } = req.params;
const token = req.query.token as string;
if (!await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token" });
const { rows } = await pool.query(
`SELECT id, url, title, type, chunk_count, catalog_status, ingested_at
FROM agentify_source_catalog WHERE expert_slug = $1
ORDER BY type ASC, title ASC NULLS LAST`,
[slug]
);
res.json({ sources: rows });
});
// ── PATCH /api/steward/:slug/catalog-sources/:id ─────────────────────────
app.patch("/api/steward/:slug/catalog-sources/:id", async (req: Request, res: Response) => {
const { slug, id } = req.params;
const { token, action } = req.body;
if (!await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token" });
if (!["park", "restore"].includes(action)) return res.status(400).json({ error: "action must be 'park' or 'restore'" });
const newStatus = action === "park" ? "parked" : "active";
const { rows } = await pool.query(
`UPDATE agentify_source_catalog SET catalog_status = $1 WHERE id = $2 AND expert_slug = $3 RETURNING title, url`,
[newStatus, id, slug]
);
if (!rows[0]) return res.status(404).json({ error: "Source not found" });
await pool.query(
`INSERT INTO steward_audit_log (subject_slug, actor, action, target_url, target_title, detail)
VALUES ($1, 'steward', $2, $3, $4, $5)`,
[slug, action, rows[0].url, rows[0].title, `Status set to ${newStatus}`]
).catch(() => {});
res.json({ ok: true, status: newStatus });
});
// ── POST /api/steward/:slug/discover ─────────────────────────────────────
app.post("/api/steward/:slug/discover", async (req: Request, res: Response) => {
const { slug } = req.params;
const { token, query } = req.body;
if (!await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token" });
const { rows: pf } = await pool.query(`SELECT subject_name FROM personaforge_subjects WHERE slug = $1`, [slug]);
const subjectName = pf[0]?.subject_name || slug;
const { rows: existing } = await pool.query(
`SELECT url FROM agentify_source_catalog WHERE expert_slug = $1`, [slug]
);
const existingUrls = new Set(existing.map((r: any) => r.url));
const skipList = [...existingUrls].slice(0, 30).join("\n");
const systemPrompt = `You are a research librarian helping build an authoritative corpus for "${subjectName}".
Find content sources NOT already in the corpus. Focus on books (publisher page or Google Books URL), landmark articles, and notable interviews.
Return a JSON array no markdown fences of objects: { "title": string, "url": string, "type": "book"|"article"|"podcast"|"interview", "note": string }`;
const userMsg = query
? `Find new sources matching: ${query}\n\nSkip these already-indexed URLs:\n${skipList}`
: `Find books, long-form articles, and notable interviews by or about ${subjectName}.\n\nSkip these already-indexed URLs:\n${skipList}`;
try {
const Anthropic = (await import("@anthropic-ai/sdk")).default;
const ai = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const msg = await ai.messages.create({
model: "claude-opus-4-5",
max_tokens: 1024,
system: systemPrompt,
messages: [{ role: "user", content: userMsg }],
});
const text = ((msg.content[0] as any).text || "[]").trim();
let suggestions: any[] = [];
try { suggestions = JSON.parse(text); } catch { suggestions = []; }
suggestions = suggestions.filter((s: any) => s.url && !existingUrls.has(s.url));
await pool.query(
`INSERT INTO steward_audit_log (subject_slug, actor, action, detail)
VALUES ($1, 'steward', 'discover', $2)`,
[slug, `Found ${suggestions.length} suggestion(s)${query ? ` for: ${query}` : ""}`]
).catch(() => {});
res.json({ ok: true, suggestions });
} catch (e: any) {
res.status(500).json({ error: e.message });
}
});
// ── GET /api/steward/:slug/activity ──────────────────────────────────────
app.get("/api/steward/:slug/activity", async (req: Request, res: Response) => {
const { slug } = req.params;
const token = req.query.token as string;
if (!await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token" });
const { rows } = await pool.query(
`SELECT id, actor, action, target_title, detail, created_at
FROM steward_audit_log WHERE subject_slug = $1
ORDER BY created_at DESC LIMIT 100`,
[slug]
);
res.json({ items: rows });
});
// ── POST /api/steward/:slug/artifacts/:artifact_id/ody-ask ──────────────
app.post("/api/steward/:slug/artifacts/:artifact_id/ody-ask", async (req: Request, res: Response) => {
const { slug, artifact_id } = req.params;
const { token } = req.body;
if (!await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token" });
// Get artifact
const { rows } = await pool.query(
`SELECT title, source_url, word_count, artifact_type FROM personaforge_artifacts WHERE artifact_id = $1 AND subject_slug = $2`,
[artifact_id, slug]
);
if (!rows[0]) return res.status(404).json({ error: "Artifact not found" });
const art = rows[0];
// Get subject info
const { rows: pf } = await pool.query(`SELECT subject_name, domain FROM personaforge_subjects WHERE slug = $1`, [slug]);
const subject = pf[0] || { subject_name: slug, domain: "" };
// Fetch essay text (first ~3000 words worth)
let essayText = "";
if (art.source_url) {
const raw = await fetchEssayText(art.source_url);
essayText = raw.substring(0, 12000); // ~3000 words
}
if (!essayText) {
return res.json({ verdict: `Verdict: Flag\n\nCould not fetch the essay text from ${art.source_url || "the source URL"}. You may want to review this one manually before including it.` });
}
const prompt = `You are helping a steward vet corpus material for an AI persona based on ${subject.subject_name}.
The steward is reviewing each piece of source material to decide whether it should be included in the corpus that will train and ground the persona.
**Essay title:** "${art.title}"
**Type:** ${art.artifact_type || "essay"}
**Word count:** ${art.word_count || "unknown"}
**Essay text (excerpt):**
${essayText}
Please give a concise vetting assessment (35 sentences). Address:
1. Is this representative of ${subject.subject_name}'s actual thinking and voice?
2. Is the content high signal — substantive ideas, not just announcements or filler?
3. Any concerns about quality, accuracy, or relevance to the corpus?
Then give your verdict on a new line: "Verdict: Approve", "Verdict: Flag", or "Verdict: Reject".`;
try {
const msg = await claude.messages.create({
model: "claude-haiku-4-5",
max_tokens: 400,
messages: [{ role: "user", content: prompt }],
});
const verdict = (msg.content[0] as any).text || "";
// Store verdict
await pool.query(
`UPDATE personaforge_artifacts SET ody_verdict = $1 WHERE artifact_id = $2`,
[verdict, artifact_id]
);
res.json({ verdict });
} catch (e: any) {
console.error("[StewardDash] Ody ask error:", e.message);
res.status(500).json({ error: "Claude error: " + e.message });
}
});
// ── POST /api/steward/:slug/corpus/fetch ────────────────────────────────
// Delegates to PersonaForge fetch (admin-side), authorized via steward token
app.post("/api/steward/:slug/corpus/fetch", async (req: Request, res: Response) => {
const { slug } = req.params;
const { token } = req.body;
if (!await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token" });
// Call the PersonaForge fetch endpoint internally (same process)
try {
const { rows: pf } = await pool.query(
`SELECT source_url FROM personaforge_subjects WHERE slug = $1`, [slug]
);
if (!pf[0]?.source_url) return res.status(400).json({ error: "No source URL configured for this subject" });
const sourceUrl = pf[0].source_url;
const html = await fetch(sourceUrl, { headers: { "User-Agent": "WellSpr.ing PersonaForge/1.0" } }).then(r => r.text());
const base = new URL(sourceUrl);
const found: { slug: string; title: string; url: string }[] = [];
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 artSlug = href.replace(/.*\//, "").replace(".html", "").toLowerCase().replace(/[^a-z0-9]+/g, "-");
if (!found.find(r => r.slug === artSlug)) found.push({ slug: artSlug, title, url: full });
} catch {}
}
let inserted = 0;
for (const item of found) {
const artifactId = `${slug}::${item.slug}`;
const result = await pool.query(
`INSERT INTO personaforge_artifacts (subject_slug, artifact_id, title, source_url, artifact_type, curation_status)
VALUES ($1, $2, $3, $4, 'essay', 'pending')
ON CONFLICT (artifact_id) DO NOTHING`,
[slug, artifactId, item.title, item.url]
);
if (result.rowCount && result.rowCount > 0) inserted++;
}
await pool.query(`UPDATE personaforge_subjects SET essay_count = $2, updated_at = NOW() WHERE slug = $1`, [slug, found.length]);
res.json({ ok: true, count: found.length, inserted });
} catch (e: any) {
console.error("[StewardDash] Fetch error:", e.message);
res.status(500).json({ error: e.message });
}
});
// ── POST /api/steward/:slug/corpus/build ────────────────────────────────
// Triggers a build using only approved artifacts
app.post("/api/steward/:slug/corpus/build", async (req: Request, res: Response) => {
const { slug } = req.params;
const { token } = req.body;
if (!await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token" });
// Get approved artifact IDs
const { rows: approved } = await pool.query(
`SELECT artifact_id FROM personaforge_artifacts WHERE subject_slug = $1 AND curation_status = 'approved'`, [slug]
);
if (approved.length === 0) return res.status(400).json({ error: "No approved artifacts to build" });
// Delegate to personaforge build endpoint (internal HTTP call via admin key)
try {
const bundleId = crypto.randomUUID();
await pool.query(
`UPDATE personaforge_subjects SET build_status = 'building', bundle_id = $2, build_log = '', updated_at = NOW() WHERE slug = $1`,
[slug, bundleId]
);
res.json({ ok: true, bundle_id: bundleId, approved_count: approved.length, message: "Build started — connect to SSE log stream for progress" });
// Async build (fire and forget — steward watches via SSE)
setImmediate(() => runApprovedBuild(slug, bundleId, approved.map(r => r.artifact_id)));
} catch (e: any) {
res.status(500).json({ error: e.message });
}
});
// ── POST /api/steward/:slug/bio-facts/ptp ───────────────────────────────
// PTP-authenticated corpus injection. Any AI agent holding a valid WellSpr.ing
// PTP token with scope corpus:write:personaforge:{slug} (or :*) may inject
// verified biographical facts and have them stamped with their attestation.
//
// Intended use: a Claude session runs the QA skill against a persona, verifies
// facts against primary sources, then writes the verified facts back to the
// corpus with a PTP-attested audit trail. The token_id is stored with the facts
// so anyone can verify who/what wrote them and under what scope.
//
// Flow:
// 1. Agent requests PTP token: POST /api/v1/ptp/token
// scope: "corpus:write:personaforge:{slug}" (or "corpus:write:personaforge:*")
// purpose: "QA review and verified fact injection for [Subject Name]"
// 2. Agent presents token here with facts payload
// 3. Server verifies token validity + scope coverage
// 4. Facts stored with ptp_token_id, attested_at, vcap_attestation_uri
// 5. Returns verifiable attestation record
app.post("/api/steward/:slug/bio-facts/ptp", async (req: Request, res: Response) => {
const { slug } = req.params;
const { token_id, bio_facts_md, wiki_summary_md, session_notes } = req.body;
if (!token_id) return res.status(400).json({ error: "token_id required (PTP presence token)" });
if (bio_facts_md === undefined && wiki_summary_md === undefined) {
return res.status(400).json({ error: "At least one of bio_facts_md or wiki_summary_md is required" });
}
try {
// Verify the PTP token and check scope coverage for this subject
const requiredScope = `corpus:write:personaforge:${slug}`;
const ptpResult = await pool.query(`SELECT * FROM ptp_tokens WHERE id = $1`, [token_id]);
if (!ptpResult.rows.length) return res.status(403).json({ error: "ptp_token_not_found", token_id });
const tk = ptpResult.rows[0];
const now = new Date();
if (tk.revoked) return res.status(403).json({ error: "ptp_token_revoked", revoked_at: tk.revoked_at });
if (tk.expires_at && new Date(tk.expires_at) < now) {
return res.status(403).json({ error: "ptp_token_expired", expired_at: tk.expires_at });
}
// Scope check: token scope must equal or be a wildcard superset of corpus:write:personaforge:{slug}
const tokenParts = (tk.scope as string).split(":");
const requiredParts = requiredScope.split(":");
let scopeOk = true;
for (let i = 0; i < requiredParts.length; i++) {
if (tokenParts[i] !== "*" && tokenParts[i] !== requiredParts[i]) { scopeOk = false; break; }
}
if (!scopeOk) {
return res.status(403).json({
error: "scope_insufficient",
token_scope: tk.scope,
required_scope: requiredScope,
hint: `Request a token with scope "corpus:write:personaforge:${slug}" or "corpus:write:personaforge:*"`,
});
}
// Ensure subject row exists
await pool.query(
`INSERT INTO personaforge_subjects (slug, subject_name, build_status)
VALUES ($1, $1, 'registered') ON CONFLICT (slug) DO NOTHING`, [slug]
);
// Write facts + stamp with PTP attestation metadata
const attestedAt = new Date().toISOString();
if (bio_facts_md !== undefined) {
await pool.query(
`UPDATE personaforge_subjects
SET bio_facts_md = $2,
bio_facts_ptp_token_id = $3,
bio_facts_attested_at = $4,
bio_facts_vcap_uri = $5,
updated_at = NOW()
WHERE slug = $1`,
[slug, bio_facts_md, token_id, attestedAt, tk.vcap_attestation_uri]
);
}
if (wiki_summary_md !== undefined) {
await pool.query(
`UPDATE personaforge_subjects
SET wiki_summary_md = $2,
wiki_ptp_token_id = $3,
wiki_attested_at = $4,
updated_at = NOW()
WHERE slug = $1`,
[slug, wiki_summary_md, token_id, attestedAt]
);
}
// Log to corpus_consult_log if it exists (non-fatal)
await pool.query(
`INSERT INTO corpus_consult_log
(slug, event_type, token_id, scope, purpose, attested_at, session_notes, created_at)
VALUES ($1, 'ptp_bio_injection', $2, $3, $4, $5, $6, NOW())`,
[slug, token_id, tk.scope, tk.purpose_declaration, attestedAt, session_notes || null]
).catch(() => {}); // table may not exist in all environments
res.json({
ok: true,
slug,
attested_at: attestedAt,
token_id,
scope: tk.scope,
vcap_attestation_uri: tk.vcac_attestation_uri || tk.vcap_attestation_uri,
fields_written: [
...(bio_facts_md !== undefined ? ["bio_facts_md"] : []),
...(wiki_summary_md !== undefined ? ["wiki_summary_md"] : []),
],
verification: `GET https://wellspr.ing/api/v1/ptp/token/${token_id}`,
note: "Biographical facts are now stamped with this PTP token. The corpus build will inject them as priority-0 chunks.",
});
} catch (e: any) {
console.error("[StewardDash] PTP injection error:", e.message);
res.status(500).json({ error: e.message });
}
});
// ── GET /api/steward/:slug/bio-facts ────────────────────────────────────
app.get("/api/steward/:slug/bio-facts", async (req: Request, res: Response) => {
const { slug } = req.params;
const token = req.query.token as string;
if (!await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token" });
try {
const { rows } = await pool.query(
`SELECT bio_facts_md, wiki_summary_md FROM personaforge_subjects WHERE slug = $1`, [slug]
);
const row = rows[0] || {};
res.json({ bio_facts_md: row.bio_facts_md || null, wiki_summary_md: row.wiki_summary_md || null });
} catch (e: any) {
res.status(500).json({ error: e.message });
}
});
// ── POST /api/steward/:slug/bio-facts ────────────────────────────────────
// Requires steward token (or admin key). Updates bio_facts_md and/or wiki_summary_md.
app.post("/api/steward/:slug/bio-facts", async (req: Request, res: Response) => {
const { slug } = req.params;
const { token, bio_facts_md, wiki_summary_md } = req.body;
const adminKey = (req.headers["x-admin-key"] as string) || req.query.admin_key as string;
const isAdmin = adminKey === ADMIN_KEY;
if (!isAdmin && !await validateStewardToken(slug, token)) return res.status(403).json({ error: "Invalid token or admin key" });
if (bio_facts_md === undefined && wiki_summary_md === undefined) return res.status(400).json({ error: "Nothing to update" });
try {
// Ensure the subject row exists (auto-create if missing, e.g. dev DB or fresh environment)
await pool.query(
`INSERT INTO personaforge_subjects (slug, subject_name, build_status)
VALUES ($1, $1, 'registered')
ON CONFLICT (slug) DO NOTHING`,
[slug]
);
// Update whichever fields were provided
if (bio_facts_md !== undefined) {
await pool.query(
`UPDATE personaforge_subjects SET bio_facts_md = $2, updated_at = NOW() WHERE slug = $1`,
[slug, bio_facts_md]
);
}
if (wiki_summary_md !== undefined) {
await pool.query(
`UPDATE personaforge_subjects SET wiki_summary_md = $2, updated_at = NOW() WHERE slug = $1`,
[slug, wiki_summary_md]
);
}
res.json({ ok: true });
} catch (e: any) {
res.status(500).json({ error: e.message });
}
});
// ── POST /api/steward/:slug/artifacts/inject ─────────────────────────────
// Admin-only. Inject a manual artifact with full text content (no URL required).
// Used for Wikipedia excerpts, articles-about-the-person, curated secondary sources.
app.post("/api/steward/:slug/artifacts/inject", async (req: Request, res: Response) => {
const { slug } = req.params;
const adminKey = (req.headers["x-admin-key"] as string) || req.body.admin_key;
if (adminKey !== ADMIN_KEY) return res.status(403).json({ error: "Admin key required" });
const { title, content_text, artifact_type, source_url, pub_date } = req.body;
if (!title || !content_text) return res.status(400).json({ error: "title and content_text required" });
const artifactId = `${slug}::injected::${crypto.randomUUID().slice(0, 8)}`;
try {
await pool.query(
`INSERT INTO personaforge_artifacts
(subject_slug, artifact_id, title, source_url, artifact_type, content_text, curation_status, pub_date)
VALUES ($1, $2, $3, $4, $5, $6, 'approved', $7)
ON CONFLICT (artifact_id) DO UPDATE SET content_text = EXCLUDED.content_text, title = EXCLUDED.title`,
[slug, artifactId, title, source_url || null, artifact_type || 'injected', content_text, pub_date || null]
);
await pool.query(`UPDATE personaforge_subjects SET updated_at = NOW() WHERE slug = $1`, [slug]);
res.json({ ok: true, artifact_id: artifactId });
} catch (e: any) {
res.status(500).json({ error: e.message });
}
});
// ── GET /api/steward/:slug/dashboard-token (admin only) ─────────────────
app.get("/api/steward/:slug/dashboard-token", async (req: Request, res: Response) => {
const { slug } = req.params;
const key = (req.headers["x-admin-key"] as string) || req.query.admin_key as string;
if (key !== ADMIN_KEY) return res.status(403).json({ error: "Admin key required" });
const token = await getOrCreateDashboardToken(slug);
const dashUrl = `https://agentify.help/steward/${slug}?token=${token}`;
res.json({ slug, dashboard_token: token, dashboard_url: dashUrl });
});
// ── POST /api/steward/:slug/invite-co-steward ────────────────────────────
// Invite a collaborator email to co-steward this agent.
// They gain access via the /my-agents portfolio magic-link flow.
app.post("/api/steward/:slug/invite-co-steward", async (req: Request, res: Response) => {
if (!isAgentify(req)) return res.status(404).json({ error: "Not found" });
const { slug } = req.params;
const token = req.query.token as string | undefined;
const key = (req.headers["x-admin-key"] as string) || (req.query.admin_key as string);
if (key !== ADMIN_KEY && !(await validateStewardToken(slug, token))) {
return res.status(403).json({ error: "Steward token or admin key required" });
}
const { email } = req.body ?? {};
if (!email || !email.includes("@")) return res.status(400).json({ error: "Valid email required" });
const normalized = email.toLowerCase().trim();
await pool.query(
`UPDATE agentify_subject_registry
SET co_steward_emails = array_append(COALESCE(co_steward_emails, '{}'), $2)
WHERE subject_slug = $1
AND NOT ($2 = ANY(COALESCE(co_steward_emails, '{}')))`,
[slug, normalized]
).catch(() => {});
await pool.query(
`UPDATE personaforge_subjects
SET co_steward_emails = array_append(COALESCE(co_steward_emails, '{}'), $2)
WHERE slug = $1
AND NOT ($2 = ANY(COALESCE(co_steward_emails, '{}')))`,
[slug, normalized]
).catch(() => {});
res.json({ ok: true, slug, co_steward_email: normalized,
note: "Co-steward added. They can now use /my-agents with their email to receive a portal access link." });
});
// ── GET /api/steward/:slug/co-stewards ───────────────────────────────────
app.get("/api/steward/:slug/co-stewards", async (req: Request, res: Response) => {
if (!isAgentify(req)) return res.status(404).json({ error: "Not found" });
const { slug } = req.params;
const token = req.query.token as string | undefined;
const key = (req.headers["x-admin-key"] as string) || (req.query.admin_key as string);
if (key !== ADMIN_KEY && !(await validateStewardToken(slug, token))) {
return res.status(403).json({ error: "Steward token or admin key required" });
}
const { rows } = await pool.query(
`SELECT co_steward_emails FROM agentify_subject_registry WHERE subject_slug = $1`, [slug]
).catch(() => ({ rows: [] }));
res.json({ slug, co_steward_emails: rows[0]?.co_steward_emails || [] });
});
// ── DELETE /api/steward/:slug/co-stewards/:email ─────────────────────────
app.delete("/api/steward/:slug/co-stewards/:email", async (req: Request, res: Response) => {
if (!isAgentify(req)) return res.status(404).json({ error: "Not found" });
const { slug, email } = req.params;
const token = req.query.token as string | undefined;
const key = (req.headers["x-admin-key"] as string) || (req.query.admin_key as string);
if (key !== ADMIN_KEY && !(await validateStewardToken(slug, token))) {
return res.status(403).json({ error: "Steward token or admin key required" });
}
const normalized = decodeURIComponent(email).toLowerCase().trim();
await pool.query(
`UPDATE agentify_subject_registry
SET co_steward_emails = array_remove(COALESCE(co_steward_emails, '{}'), $2)
WHERE subject_slug = $1`,
[slug, normalized]
).catch(() => {});
await pool.query(
`UPDATE personaforge_subjects
SET co_steward_emails = array_remove(COALESCE(co_steward_emails, '{}'), $2)
WHERE slug = $1`,
[slug, normalized]
).catch(() => {});
res.json({ ok: true, slug, removed: normalized });
});
}
// ── Approved-only corpus build (async) ───────────────────────────────────────
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const S3_HOSTNAME = process.env.VULTR_S3_HOSTNAME || "ewr1.vultrobjects.com";
const S3_BUCKET = process.env.CORPUS_S3_BUCKET || "agentify-corpus";
function getS3(): S3Client {
return new S3Client({
endpoint: `https://${S3_HOSTNAME}`,
region: "ewr1",
forcePathStyle: true,
credentials: {
accessKeyId: process.env.VULTR_S3_ACCESS_KEY || "",
secretAccessKey: process.env.VULTR_S3_SECRET_KEY || "",
},
});
}
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 >= 30);
}
async function broadcastStewardLog(slug: string, msg: string) {
console.log(`[StewardBuild/${slug}] ${msg}`);
await 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(() => {});
}
async function runApprovedBuild(slug: string, bundleId: string, approvedIds: string[]) {
const s3 = getS3();
const allChunks: any[] = [];
let chunkSeq = 0;
let processed = 0;
let errors = 0;
const { rows: pf } = await pool.query(
`SELECT subject_name, domain, corpus_version, bio_facts_md, wiki_summary_md FROM personaforge_subjects WHERE slug = $1`, [slug]
);
const subject = pf[0] || { subject_name: slug, domain: "", corpus_version: "1.0.0" };
await broadcastStewardLog(slug, `Starting approved-only build: ${approvedIds.length} artifacts, bundle ${bundleId}`);
// ── Priority chunk 0: Biographical baseline facts ──────────────────────
if (subject.bio_facts_md && subject.bio_facts_md.trim()) {
const bioChunks = chunkText(subject.bio_facts_md);
for (const chunk of bioChunks) {
allChunks.push({
chunk_id: `${slug}-${bundleId}-bio-${String(chunkSeq++).padStart(5, "0")}`,
subject_slug: slug,
artifact_id: `${slug}::bio_facts_baseline`,
title: "Biographical Baseline — Ground-Truth Facts",
pub_date: null,
artifact_type: "bio_facts",
priority: "critical",
text: chunk,
});
}
await broadcastStewardLog(slug, `Injected biographical baseline (${bioChunks.length} chunks) as priority-0 corpus anchor.`);
}
// ── Priority chunk 1: Wikipedia / secondary sources ────────────────────
if (subject.wiki_summary_md && subject.wiki_summary_md.trim()) {
const wikiPreamble = `[SKEPTICISM NOTE: The following is from Wikipedia and secondary sources. Treat as background reference, not first-person memory. Verify contested facts against primary corpus.]\n\n`;
const wikiChunks = chunkText(wikiPreamble + subject.wiki_summary_md);
for (const chunk of wikiChunks) {
allChunks.push({
chunk_id: `${slug}-${bundleId}-wiki-${String(chunkSeq++).padStart(5, "0")}`,
subject_slug: slug,
artifact_id: `${slug}::wiki_secondary_sources`,
title: "Wikipedia & Secondary Sources — Skepticism-Weighted",
pub_date: null,
artifact_type: "wiki_secondary",
priority: "background",
text: chunk,
});
}
await broadcastStewardLog(slug, `Injected Wikipedia/secondary sources (${wikiChunks.length} chunks) with skepticism weighting.`);
}
for (const artifactId of approvedIds) {
const { rows: arts } = await pool.query(
`SELECT title, source_url, pub_date, artifact_type, content_text FROM personaforge_artifacts WHERE artifact_id = $1`, [artifactId]
);
if (!arts[0]) continue;
const art = arts[0];
try {
await broadcastStewardLog(slug, `Fetching: ${art.title}`);
// Use inline content_text if present (injected artifacts), otherwise fetch from URL
const text = art.content_text || (art.source_url ? await fetchEssayTextBuild(art.source_url) : "");
if (!text || text.split(/\s+/).length < 50) {
await broadcastStewardLog(slug, `Skipped (too short): ${art.title}`);
errors++;
continue;
}
const chunks = chunkText(text);
const wc = text.split(/\s+/).length;
for (const chunk of chunks) {
allChunks.push({
chunk_id: `${slug}-${bundleId}-${String(chunkSeq++).padStart(5, "0")}`,
subject_slug: slug,
artifact_id: artifactId,
title: art.title,
pub_date: art.pub_date || null,
artifact_type: art.artifact_type || "essay",
text: chunk,
});
}
await pool.query(
`UPDATE personaforge_artifacts SET chunk_count = $1, word_count = $2 WHERE artifact_id = $3`,
[chunks.length, wc, artifactId]
);
processed++;
} catch (e: any) {
await broadcastStewardLog(slug, `Error on ${art.title}: ${e.message}`);
errors++;
}
}
await broadcastStewardLog(slug, `All artifacts processed. ${allChunks.length} chunks total. Uploading to S3…`);
try {
const prefix = `staging/${slug}/v${subject.corpus_version}/${bundleId}`;
const ndjson = allChunks.map(c => JSON.stringify(c)).join("\n");
await s3.send(new PutObjectCommand({
Bucket: S3_BUCKET,
Key: `${prefix}/chunks/index.ndjson`,
Body: ndjson,
ContentType: "application/x-ndjson",
}));
const manifest = {
bundle_id: bundleId,
subject_slug: slug,
subject_name: subject.subject_name,
domain: subject.domain,
corpus_version: subject.corpus_version,
chunk_count: allChunks.length,
artifact_count: processed,
approved_only: true,
built_at: new Date().toISOString(),
};
await s3.send(new PutObjectCommand({
Bucket: S3_BUCKET,
Key: `${prefix}/manifest.json`,
Body: JSON.stringify(manifest, null, 2),
ContentType: "application/json",
}));
await pool.query(
`UPDATE personaforge_subjects SET build_status = 'built', chunk_count = $2, updated_at = NOW() WHERE slug = $1`,
[slug, allChunks.length]
);
await broadcastStewardLog(slug, `Build complete. ${allChunks.length} chunks uploaded. Bundle: ${bundleId}`);
if (errors > 0) await broadcastStewardLog(slug, `Note: ${errors} artifacts had errors and were skipped.`);
} catch (e: any) {
await broadcastStewardLog(slug, `S3 upload error: ${e.message}`);
await pool.query(`UPDATE personaforge_subjects SET build_status = 'error' WHERE slug = $1`, [slug]);
}
}
async function fetchEssayTextBuild(url: string): Promise<string> {
try {
const html = await fetch(url, {
headers: { "User-Agent": "WellSpr.ing PersonaForge/1.0" },
signal: AbortSignal.timeout(20000),
}).then(r => r.text());
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(/\s+/g, " ").trim();
} catch {
return "";
}
}