Building corpus…
@@ -378,7 +482,215 @@ let currentFilter = 'all';
// ── Bootstrap ──────────────────────────────────────────────────────────────
async function init() {
- await Promise.all([loadArtifacts(), loadBioFacts()]);
+ 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 = '
'; 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 = '
Error
' + esc(e.message) + '
';
+ 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 = '
No sources
Try a different filter or use Discover to find new content.
';
+ 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 \`
+
+
\${esc(s.title || s.url)}
+
+ \${type}
+ \${chunks ? \`\${esc(chunks)}\` : ''}
+ \${domain ? \`\${esc(domain)}\` : ''}
+ \${parked ? 'Parked' : 'Active'}
+
+ \${s.url && !s.url.startsWith('podcast://') ? \`
View source ↗\` : ''}
+
+
+ \${parked
+ ? \`\`
+ : \`\`
+ }
+
+
\`;
+}
+
+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 = '
No activity recorded yet. Actions like parking sources or discovering content will appear here.
';
+ return;
+ }
+ const icons = {park:'⊘',restore:'↩',discover:'⌕',build:'⚙',fetch:'⬇',curation:'✓'};
+ listEl.innerHTML = '' + items.map(item => {
+ const icon = icons[item.action] || '·';
+ const when = item.created_at ? new Date(item.created_at).toLocaleString() : '';
+ return \`
+
\${esc(icon)}
+
+
\${esc(item.action)}
+ \${item.target_title ? \`
\${esc(item.target_title)}
\` : ''}
+ \${item.detail ? \`
\${esc(item.detail)}
\` : ''}
+
+
\${esc(when)}
+
\`;
+ }).join('');
+ } catch(e) {
+ listEl.innerHTML = '
Error loading activity: ' + esc(e.message) + '
';
+ }
+}
+
+// ── 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 ──────────────────────────────────────────────────
@@ -899,6 +1211,108 @@ export function registerStewardRoutes(app: Express) {
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", express.json({ limit: "1mb" }), 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", express.json({ limit: "1mb" }), 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;