tidaldb/applications/forage/server/static/index.html
jordan f4cfd6c81f feat: complete M8 replication primitives + forage enhancements + docs
Milestone 8 (phases 1-4):
- Shard-aware WAL segment naming, BatchHeader v2, ShardRouter
- Transport trait, InProcessTransport, WalShipper, FollowerDb
- HLC, PNCounter, LWWRegister, CrdtSignalState, ReconciliationEngine
- Session replication bridge with SeqNo/HWM, idempotency store

Forage application:
- Multi-source discovery engine with MAB exploration
- Embedding-based label system, server handlers, UI refresh

Other:
- QUICKSTART.md, README.md, milestone-8 planning docs
- Hard negative union semantics, RLHF export enhancements
- Recovery benchmark and visibility test expansions
- Split 8 oversized source files per CODING_GUIDELINES §9

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:17:19 -07:00

634 lines
24 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Forage</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0f0f0f; color: #e0e0e0; min-height: 100vh; }
header { display: flex; align-items: center; gap: 16px; padding: 16px 24px; border-bottom: 1px solid #222; flex-wrap: wrap; }
header h1 { font-size: 1.4rem; font-weight: 700; color: #fff; }
header select { background: #1a1a1a; color: #e0e0e0; border: 1px solid #333; border-radius: 6px; padding: 6px 10px; font-size: 0.9rem; cursor: pointer; }
#interests { font-size: 0.8rem; color: #666; margin-left: 4px; transition: color 0.4s; }
#interests.active { color: #4ade80; }
#feed { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; padding: 24px; }
.card { background: #1a1a1a; border: 1px solid #2a2a2a; border-radius: 10px; padding: 18px; display: flex; flex-direction: column; gap: 10px; transition: border-color 0.2s; position: relative; overflow: hidden; }
.card:hover { border-color: #444; }
.card.removing { opacity: 0; transform: scale(0.95); transition: all 0.2s; }
.dwell-bar { position: absolute; bottom: 0; left: 0; height: 2px; width: 0%; background: #4ade80; transition: none; opacity: 0; }
.card.dwelling .dwell-bar { opacity: 1; }
.card.dwell-done .dwell-bar { width: 100% !important; background: #4ade80; opacity: 0; transition: opacity 0.6s 0.2s; }
.card-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.chip { font-size: 0.72rem; font-weight: 600; padding: 3px 8px; border-radius: 999px; text-transform: uppercase; letter-spacing: 0.04em; }
.chip-category { background: #222; color: #aaa; }
.chip-match { background: #0d2e1a; color: #4ade80; }
.chip-exploring { background: #1e0d2e; color: #c084fc; }
.chip-trending { background: #2e1a0d; color: #fb923c; }
.chip-resurfaced { background: #0d1e2e; color: #60a5fa; }
.chip-discovered { background: #1e1a0d; color: #fbbf24; }
.chip-bridge { background: #0d2e2e; color: #2dd4bf; }
.reading-time { font-size: 0.75rem; color: #666; margin-left: auto; }
.card-title { font-size: 1rem; font-weight: 600; color: #f0f0f0; line-height: 1.4; cursor: pointer; }
.card-title:hover { color: #fff; text-decoration: underline; }
.card-source { font-size: 0.78rem; color: #666; }
.card-desc { font-size: 0.85rem; color: #aaa; line-height: 1.5; }
.card-actions { display: flex; gap: 8px; margin-top: 4px; }
.btn { font-size: 0.8rem; padding: 6px 14px; border-radius: 6px; border: 1px solid #333; background: #222; color: #ccc; cursor: pointer; transition: background 0.15s; }
.btn:hover { background: #2a2a2a; color: #fff; }
.btn-save.saved { background: #1a2e1a; border-color: #4ade80; color: #4ade80; }
.score { position: absolute; top: 12px; right: 14px; font-size: 0.68rem; color: #444; }
#status { padding: 8px 24px; font-size: 0.8rem; color: #555; }
.loading { text-align: center; padding: 60px; color: #555; }
#toast { position: fixed; bottom: 24px; right: 24px; background: #1a2e1a; border: 1px solid #4ade80; color: #4ade80; padding: 10px 18px; border-radius: 8px; font-size: 0.82rem; opacity: 0; transform: translateY(8px); transition: opacity 0.25s, transform 0.25s; pointer-events: none; z-index: 100; }
#toast.show { opacity: 1; transform: translateY(0); }
/* ── Auth modal ── */
#auth-modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.85); z-index: 200; align-items: center; justify-content: center; }
#auth-modal.show { display: flex; }
#auth-box { background: #1a1a1a; border: 1px solid #333; border-radius: 12px; padding: 28px; width: 320px; display: flex; flex-direction: column; gap: 14px; }
#auth-box h2 { font-size: 1rem; color: #fff; }
#auth-box p { font-size: 0.82rem; color: #888; }
#auth-token-input { background: #111; border: 1px solid #333; color: #e0e0e0; border-radius: 6px; padding: 8px 10px; font-size: 0.9rem; }
#auth-token-input:focus { outline: none; border-color: #4ade80; }
#auth-submit { background: #1a2e1a; border: 1px solid #4ade80; color: #4ade80; padding: 8px; border-radius: 6px; font-size: 0.85rem; cursor: pointer; }
#auth-submit:hover { background: #223e22; }
#auth-error { font-size: 0.78rem; color: #f87171; min-height: 18px; }
/* ── Discovery status bar ── */
#discovery-status {
display: flex; align-items: center; gap: 8px;
padding: 6px 24px; font-size: 0.78rem; color: #555;
border-bottom: 1px solid #1a1a1a;
}
#discovery-status .dot {
width: 7px; height: 7px; border-radius: 50%; background: #333; flex-shrink: 0;
}
#discovery-status .dot.active { background: #4ade80; }
#discovery-status .dot.discovering {
background: #fb923c;
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
/* ── Tag chips ── */
.card-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.chip-tag {
font-size: 0.68rem; padding: 2px 7px; border-radius: 999px;
border: 1px solid #333; color: #888; background: transparent;
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s;
}
.chip-tag:hover { border-color: #555; color: #bbb; }
.chip-tag-selected {
border-color: #4ade80; background: #1a3a22; color: #4ade80;
}
.chip-tag-selected:hover { border-color: #6aee9a; background: #22472a; }
/* ── Tag preferences bar ── */
#tag-prefs-bar {
padding: 4px 24px 6px; font-size: 0.76rem; color: #555;
border-bottom: 1px solid #1a1a1a; min-height: 24px;
}
#tag-prefs-bar .tag-prefs-clear {
margin-left: 8px; font-size: 0.72rem; color: #4ade80;
cursor: pointer; text-decoration: underline; background: none;
border: none; padding: 0; font-family: inherit;
}
#tag-prefs-bar .tag-prefs-clear:hover { color: #6aee9a; }
/* ── Content type badge ── */
.chip-content-type {
font-size: 0.68rem; font-weight: 600; padding: 2px 7px; border-radius: 999px;
text-transform: uppercase; letter-spacing: 0.04em;
}
.chip-ct-analysis { background: #0d1e3a; color: #60a5fa; }
.chip-ct-tutorial { background: #0d2e1a; color: #4ade80; }
.chip-ct-news { background: #222; color: #888; }
.chip-ct-opinion { background: #2e1e0a; color: #fb923c; }
.chip-ct-review { background: #1e0d2e; color: #c084fc; }
.chip-ct-interview { background: #0d2e2a; color: #2dd4bf; }
.chip-ct-research { background: #1a2e0d; color: #a3e635; }
/* ── SSE new-capture flash ── */
@keyframes newCapture {
0% { border-color: #4ade80; box-shadow: 0 0 14px rgba(74,222,128,0.35); }
100% { border-color: #2a2a2a; box-shadow: none; }
}
.card.new-capture { animation: newCapture 2s ease-out forwards; }
</style>
</head>
<body>
<header>
<h1>Forage</h1>
<label style="font-size:0.85rem;color:#888;">User:</label>
<select id="user-select">
<option value="1">User 1 — Cold Start</option>
<option value="2">User 2 — Explorer</option>
<option value="3">User 3 — Converged</option>
</select>
<button class="btn" onclick="fetchFeed()">Refresh</button>
<span id="interests"></span>
<span id="status"></span>
</header>
<div id="discovery-status">
<span class="dot" id="agent-dot"></span>
<span id="agent-label">Checking agent status…</span>
</div>
<div id="tag-prefs-bar"></div>
<div id="feed"><div class="loading">Loading feed…</div></div>
<div id="toast"></div>
<!-- Auth modal: shown when a request returns 401 -->
<div id="auth-modal">
<div id="auth-box">
<h2>Authentication required</h2>
<p>This Forage server requires a token. Enter it below to continue.</p>
<input type="password" id="auth-token-input" placeholder="Bearer token">
<div id="auth-error"></div>
<button id="auth-submit">Connect</button>
</div>
</div>
<script>
let currentUser = 1;
let itemMeta = {};
const BASE = '';
// Auto-refresh pauses while any card is being read.
let dwellActive = false;
let refreshTimer = null;
// ── Auth helpers ────────────────────────────────────────────────────────────
function getToken() {
return localStorage.getItem('forage_token') || '';
}
function setToken(t) {
localStorage.setItem('forage_token', t);
}
function authHeaders() {
const h = { 'Content-Type': 'application/json' };
const t = getToken();
if (t) h['Authorization'] = `Bearer ${t}`;
return h;
}
async function apiFetch(path, options = {}) {
const headers = { ...authHeaders(), ...(options.headers || {}) };
const res = await fetch(`${BASE}${path}`, { ...options, headers });
if (res.status === 401) {
showAuthModal();
return null;
}
return res;
}
// ── Auth modal ──────────────────────────────────────────────────────────────
function showAuthModal() {
document.getElementById('auth-modal').classList.add('show');
document.getElementById('auth-token-input').focus();
}
function hideAuthModal() {
document.getElementById('auth-modal').classList.remove('show');
document.getElementById('auth-error').textContent = '';
}
document.getElementById('auth-submit').addEventListener('click', async () => {
const token = document.getElementById('auth-token-input').value.trim();
if (!token) return;
setToken(token);
// Verify the token works
const res = await fetch(`${BASE}/prefs?user=${currentUser}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
hideAuthModal();
loadItemMeta().then(() => { fetchFeed(); fetchPrefs(); });
} else {
document.getElementById('auth-error').textContent = 'Invalid token — try again';
}
});
document.getElementById('auth-token-input').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('auth-submit').click();
});
// ── Tag preferences ─────────────────────────────────────────────────────────
function getTagPrefs() {
return localStorage.getItem('forage_tag_prefs')?.split(',').filter(Boolean) ?? [];
}
function setTagPrefs(tags) {
localStorage.setItem('forage_tag_prefs', tags.join(','));
}
function updateTagPrefsBar() {
const bar = document.getElementById('tag-prefs-bar');
if (!bar) return;
bar.innerHTML = '';
const prefs = getTagPrefs();
if (prefs.length === 0) return;
const text = document.createTextNode(`\uD83D\uDCCC Preferred: ${prefs.join(', ')} `);
bar.appendChild(text);
const clearBtn = document.createElement('button');
clearBtn.className = 'tag-prefs-clear';
clearBtn.textContent = 'Clear';
clearBtn.addEventListener('click', () => {
setTagPrefs([]);
updateTagPrefsBar();
// Deselect all visible tag chips
document.querySelectorAll('.chip-tag-selected').forEach(el => {
el.classList.remove('chip-tag-selected');
});
});
bar.appendChild(clearBtn);
}
// ── Utilities ───────────────────────────────────────────────────────────────
function scheduleRefresh() {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => {
if (!dwellActive) fetchFeed();
scheduleRefresh();
}, 8000);
}
function showToast(msg) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 2800);
}
function labelKey(label) {
if (typeof label === 'string') return label;
if (label && typeof label === 'object' && 'bridge' in label) return 'bridge';
return 'match';
}
function labelClass(label) {
const map = { match: 'chip-match', exploring: 'chip-exploring', trending: 'chip-trending', resurfaced: 'chip-resurfaced', bridge: 'chip-bridge', captured: 'chip-exploring' };
return map[labelKey(label)] || 'chip-match';
}
function labelText(label) {
if (labelKey(label) === 'bridge') {
const { cat_a, cat_b } = label.bridge;
return `bridge: ${cat_a} \u00d7 ${cat_b}`;
}
const map = { match: 'Match', exploring: 'Exploring', trending: 'Trending', resurfaced: 'Resurfaced', captured: 'Captured' };
return map[label] || label;
}
function categoryClass(category) {
return category === 'discovered' ? 'chip-discovered' : 'chip-category';
}
// ── Signal posting ──────────────────────────────────────────────────────────
async function postSignal(userId, itemId, signalType, durationMs) {
const body = { user_id: userId, item_id: itemId, signal_type: signalType };
if (durationMs !== undefined) body.duration_ms = durationMs;
await apiFetch('/signal', {
method: 'POST',
body: JSON.stringify(body),
}).catch(() => {});
}
// ── Discovery status ────────────────────────────────────────────────────────
async function pollDiscoveryStatus() {
try {
const res = await apiFetch('/discovery/status');
if (!res) return;
const data = await res.json();
const dot = document.getElementById('agent-dot');
const label = document.getElementById('agent-label');
if (data.agent_connected) {
dot.className = 'dot active';
const mins = data.last_run_seconds_ago != null
? Math.round(data.last_run_seconds_ago / 60)
: null;
label.textContent = mins != null
? `Active — last run ${mins} min ago · ${data.items_found_last_run} items`
: 'Active — no runs yet';
} else {
dot.className = 'dot';
label.textContent = 'Agent not connected — run ./forage-discover.sh to start discovery';
}
} catch {
// server unreachable or auth failed — leave as is
}
setTimeout(pollDiscoveryStatus, 30_000);
}
// ── Feed ────────────────────────────────────────────────────────────────────
async function fetchPrefs() {
try {
const res = await apiFetch(`/prefs?user=${currentUser}`);
if (!res) return;
const data = await res.json();
const el = document.getElementById('interests');
if (data.has_preferences && data.categories.length > 0) {
el.textContent = `Interests: ${data.categories.join(', ')}`;
el.classList.add('active');
setTimeout(() => el.classList.remove('active'), 1500);
} else {
el.textContent = 'No preferences yet — browse some pages!';
el.classList.remove('active');
}
} catch (e) {
// non-fatal
}
}
function makeDwellBar() {
const bar = document.createElement('div');
bar.className = 'dwell-bar';
return bar;
}
function makeCard(item) {
const card = document.createElement('div');
card.className = 'card';
card.dataset.id = item.id;
const meta = itemMeta[item.id] || {};
const url = item.url || meta.url || '#';
// Score badge
const scoreSpan = document.createElement('span');
scoreSpan.className = 'score';
scoreSpan.textContent = `score: ${(item.score || 0).toFixed(3)}`;
card.appendChild(scoreSpan);
// Meta row: category chip, label chip, content type badge, reading time
const metaDiv = document.createElement('div');
metaDiv.className = 'card-meta';
const catChip = document.createElement('span');
catChip.className = `chip ${categoryClass(item.category)}`;
catChip.textContent = item.category;
metaDiv.appendChild(catChip);
const lblChip = document.createElement('span');
lblChip.className = `chip ${labelClass(item.label)}`;
lblChip.textContent = labelText(item.label);
metaDiv.appendChild(lblChip);
if (item.content_type) {
const ctChip = document.createElement('span');
ctChip.className = `chip chip-content-type chip-ct-${item.content_type}`;
ctChip.textContent = item.content_type;
metaDiv.appendChild(ctChip);
}
const readTime = document.createElement('span');
readTime.className = 'reading-time';
readTime.textContent = `${item.reading_time_min || meta.reading_time_min || '?'} min`;
metaDiv.appendChild(readTime);
card.appendChild(metaDiv);
// Title
const titleDiv = document.createElement('div');
titleDiv.className = 'card-title';
titleDiv.textContent = item.title;
card.appendChild(titleDiv);
// Source
const sourceDiv = document.createElement('div');
sourceDiv.className = 'card-source';
sourceDiv.textContent = item.source;
card.appendChild(sourceDiv);
// Description (prefer summary when available)
const desc = document.createElement('div');
desc.className = 'card-desc';
desc.textContent = (item.summary && item.summary.length > 0) ? item.summary : (item.description || meta.description || '');
card.appendChild(desc);
// Tag chips (up to 3)
if (item.tags && item.tags.length > 0) {
const tagsDiv = document.createElement('div');
tagsDiv.className = 'card-tags';
item.tags.slice(0, 3).forEach(tag => {
const chip = document.createElement('span');
chip.className = 'chip-tag';
if (getTagPrefs().includes(tag)) chip.classList.add('chip-tag-selected');
chip.textContent = tag;
chip.addEventListener('click', () => {
const prefs = getTagPrefs();
const idx = prefs.indexOf(tag);
if (idx === -1) {
prefs.push(tag);
} else {
prefs.splice(idx, 1);
}
setTagPrefs(prefs);
chip.classList.toggle('chip-tag-selected', prefs.includes(tag));
updateTagPrefsBar();
});
tagsDiv.appendChild(chip);
});
card.appendChild(tagsDiv);
}
// Actions
const actionsDiv = document.createElement('div');
actionsDiv.className = 'card-actions';
actionsDiv.innerHTML = `
<button class="btn btn-skip">Skip</button>
<button class="btn btn-save">Save</button>
<button class="btn btn-share">Share</button>
`;
card.appendChild(actionsDiv);
const bar = makeDwellBar();
card.appendChild(bar);
card.querySelector('.card-title').addEventListener('click', () => {
postSignal(currentUser, item.id, 'view');
window.open(url, '_blank', 'noopener');
});
const DWELL_MS = 3000;
const COMPLETION_MS = 15000;
let dwellStart = null;
let dwellRaf = null;
function animateBar(elapsed) {
const pct = Math.min(elapsed / COMPLETION_MS * 100, 100);
bar.style.width = pct + '%';
if (elapsed < COMPLETION_MS && dwellStart !== null) {
dwellRaf = requestAnimationFrame(() => animateBar(Date.now() - dwellStart));
}
}
card.addEventListener('mouseenter', () => {
dwellStart = Date.now();
dwellActive = true;
card.classList.add('dwelling');
dwellRaf = requestAnimationFrame(() => animateBar(0));
});
card.addEventListener('mouseleave', () => {
if (dwellStart) {
const dur = Date.now() - dwellStart;
cancelAnimationFrame(dwellRaf);
dwellStart = null;
card.classList.remove('dwelling');
if (dur >= DWELL_MS) {
postSignal(currentUser, item.id, 'dwell', dur).then(() => {
if (dur >= COMPLETION_MS) {
card.classList.add('dwell-done');
setTimeout(() => card.classList.remove('dwell-done'), 1000);
showToast(`Reading noted ✓ Updating interests…`);
fetchPrefs();
}
});
}
bar.style.width = '0%';
}
setTimeout(() => {
const anyHovered = document.querySelector('.card.dwelling');
if (!anyHovered) dwellActive = false;
}, 50);
});
card.querySelector('.btn-skip').addEventListener('click', () => {
postSignal(currentUser, item.id, 'skip');
card.classList.add('removing');
setTimeout(() => { card.remove(); }, 200);
});
const saveBtn = card.querySelector('.btn-save');
saveBtn.addEventListener('click', () => {
postSignal(currentUser, item.id, 'save');
saveBtn.classList.toggle('saved');
saveBtn.textContent = saveBtn.classList.contains('saved') ? 'Saved ✓' : 'Save';
});
card.querySelector('.btn-share').addEventListener('click', () => {
postSignal(currentUser, item.id, 'share').then(() => {
showToast(`Shared ✓ Updating interests…`);
fetchPrefs();
});
});
return card;
}
// ── SSE live capture ────────────────────────────────────────────────────────
// Insert a newly captured item at the top of the feed without re-rendering
// existing cards (preserves dwell state, scroll position, everything).
function prependCard(event) {
const item = {
id: event.item_id,
title: event.title,
url: event.url,
source: event.source,
reading_time_min: event.reading_time_min,
description: event.description,
category: event.category || 'discovered',
label: 'captured',
score: 0,
tags: event.tags || [],
entities: event.entities || [],
content_type: event.content_type || '',
summary: event.summary || '',
};
// Keep itemMeta current so card fallback lookups work.
itemMeta[item.id] = item;
const feed = document.getElementById('feed');
// Remove the empty-state placeholder if present.
const placeholder = feed.querySelector('.loading');
if (placeholder) placeholder.remove();
const card = makeCard(item);
card.classList.add('new-capture');
feed.insertBefore(card, feed.firstChild);
// Trim to a reasonable max so the page doesn't grow forever.
const cards = feed.querySelectorAll('.card');
if (cards.length > 12) cards[cards.length - 1].remove();
}
function connectSSE() {
const token = getToken();
const url = token ? `/events?token=${encodeURIComponent(token)}` : '/events';
const es = new EventSource(url);
es.onmessage = (e) => {
try { prependCard(JSON.parse(e.data)); } catch {}
};
// EventSource reconnects automatically on error; nothing else to do.
return es;
}
async function fetchFeed() {
const start = Date.now();
document.getElementById('status').textContent = 'Loading…';
try {
const res = await apiFetch(`/feed?user=${currentUser}&limit=7`);
if (!res) return;
const data = await res.json();
const items = data.items || [];
const feed = document.getElementById('feed');
feed.innerHTML = '';
if (items.length === 0) {
feed.innerHTML = '<div class="loading">No items yet — try reading some articles!</div>';
} else {
items.forEach(item => feed.appendChild(makeCard(item)));
}
const ms = Date.now() - start;
document.getElementById('status').textContent = `${items.length} items in ${ms}ms`;
} catch (e) {
document.getElementById('status').textContent = 'Error: ' + e.message;
}
}
async function loadItemMeta() {
try {
const res = await apiFetch('/items');
if (!res) return;
const items = await res.json();
items.forEach(item => { itemMeta[item.id] = item; });
} catch (e) {
console.warn('Failed to load item metadata', e);
}
}
document.getElementById('user-select').addEventListener('change', e => {
currentUser = parseInt(e.target.value, 10);
dwellActive = false;
fetchFeed();
fetchPrefs();
});
// Initial load
updateTagPrefsBar();
loadItemMeta().then(() => {
fetchFeed();
fetchPrefs();
});
scheduleRefresh();
connectSSE();
pollDiscoveryStatus();
</script>
</body>
</html>