v0.2.0: 7 new features + UI modernization

New features:
- Kanban Board: Visual task management with drag-and-drop, filter, priority, block/unblock
- Goals: Project targets with progress tracking, auto-sync to brain/active-projects.md
- Journal: Daily entries stored as brain/journal/YYYY-MM-DD.md with search
- Agent Health: Real-time monitoring of 3 agents (opencode/hermes/gemini)
- Smart Router: Keyword-based task routing with confidence scoring
- Learning Analytics: Skill evaluation scores and performance trends
- Session Replay: Browse and replay past opencode sessions

Technical changes:
- 30 new API endpoints across all 7 features
- api.patch() method added for PATCH support
- UI modernization: glass morphism cards, gradients, glow effects, skeleton loaders, empty states
- New CSS section (v0.2.0) with kanban board, goal cards, journal entries, agent health cards, smart router suggestions, chart cards, session messages
- 7 new sidebar nav items under Workflow and Monitoring sections
- All changes are additive (zero breaking changes to existing 13 pages / 28 endpoints)
- Full smoke test verified: every endpoint returns correct data
This commit is contained in:
modimihir07 2026-06-05 15:22:40 +05:30
parent 846803500c
commit 717ec85607
17 changed files with 2278 additions and 0 deletions

View File

@ -20,3 +20,5 @@
## AgriAssist AI
- Status: On hold
- Hackathon project (slides, MVP, pitch strategy)
- [Test goal](goal:c1c992ef) —

View File

@ -0,0 +1,2 @@
# Test Journal Entry
This is a test entry.

View File

@ -16,6 +16,11 @@ const api = {
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); }
return r.json();
},
async patch(path, body = {}) {
const r = await fetch(path, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); }
return r.json();
},
async del(path) {
const r = await fetch(path, { method: 'DELETE' });
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); }
@ -47,4 +52,41 @@ const api = {
discoverStandards: () => api.post('/api/standards/discover'),
chat: (agent, message, controller) => api.post('/api/chat', { agent, message }, controller),
getChatHistory: () => api.get('/api/chat/history'),
// Kanban
getKanbanBoard: (status) => api.get(status ? `/api/kanban/board?status=${encodeURIComponent(status)}` : '/api/kanban/board'),
getKanbanTask: (id) => api.get(`/api/kanban/tasks/${encodeURIComponent(id)}`),
createKanbanTask: (data) => api.post('/api/kanban/tasks', data),
updateKanbanTask: (id, data) => api.patch(`/api/kanban/tasks/${encodeURIComponent(id)}`, data),
completeKanbanTask: (id, summary) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/complete`, { summary }),
blockKanbanTask: (id, reason) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/block`, { reason }),
unblockKanbanTask: (id) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/unblock`, {}),
addKanbanComment: (id, message) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/comments`, { message }),
linkKanbanTasks: (parentId, childId) => api.post('/api/kanban/links', { parent_id: parentId, child_id: childId }),
unlinkKanbanTasks: (parentId, childId) => api.del(`/api/kanban/links?parent_id=${encodeURIComponent(parentId)}&child_id=${encodeURIComponent(childId)}`),
dispatchKanban: () => api.post('/api/kanban/dispatch', {}),
specifyKanbanTask: (id) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/specify`, {}),
decomposeKanbanTask: (id) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/decompose`, {}),
// Goals
getGoals: () => api.get('/api/goals'),
createGoal: (data) => api.post('/api/goals', data),
updateGoal: (id, data) => api.put(`/api/goals/${encodeURIComponent(id)}`, data),
deleteGoal: (id) => api.del(`/api/goals/${encodeURIComponent(id)}`),
// Journal
getJournalEntries: () => api.get('/api/journal/entries'),
getJournalEntry: (date) => api.get(`/api/journal/entries/${encodeURIComponent(date)}`),
saveJournalEntry: (date, content) => api.put(`/api/journal/entries/${encodeURIComponent(date)}`, { content }),
searchJournal: (query) => api.get(`/api/journal/search?q=${encodeURIComponent(query)}`),
// Agent Health
getAgentHealth: () => api.get('/api/agents/health'),
getAgentStats: (name) => api.get(`/api/agents/${encodeURIComponent(name)}/stats`),
refreshAgentHealth: () => api.post('/api/agents/health/refresh', {}),
// Smart Router
suggestRouter: (task) => api.post('/api/router/suggest', { task }),
routeTask: (task, agent) => api.post('/api/router/route', { task, agent }),
// Learning Analytics
getSkillAnalytics: () => api.get('/api/analytics/skills'),
getTrendAnalytics: () => api.get('/api/analytics/trends'),
// Session Replay
listSessions: () => api.get('/api/sessions/list'),
getSessionReplay: (id) => api.get(`/api/sessions/${encodeURIComponent(id)}/replay`),
};

View File

@ -39,6 +39,15 @@
<a href="#memory" class="nav-item" data-page="memory"><span class="nav-icon">🧠</span><span class="nav-label">Memory</span></a>
<a href="#scheduler" class="nav-item" data-page="scheduler"><span class="nav-icon"></span><span class="nav-label">Scheduler</span></a>
<a href="#audit" class="nav-item" data-page="audit"><span class="nav-icon">📋</span><span class="nav-label">Audit</span></a>
<div class="sidebar-section"><div class="sidebar-section-label">Workflow</div></div>
<a href="#kanban" class="nav-item" data-page="kanban"><span class="nav-icon">📌</span><span class="nav-label">Kanban Board</span></a>
<a href="#goals" class="nav-item" data-page="goals"><span class="nav-icon">🎯</span><span class="nav-label">Goals</span></a>
<a href="#journal" class="nav-item" data-page="journal"><span class="nav-icon">📓</span><span class="nav-label">Journal</span></a>
<div class="sidebar-section"><div class="sidebar-section-label">Monitoring</div></div>
<a href="#agent-health" class="nav-item" data-page="agent-health"><span class="nav-icon">🏥</span><span class="nav-label">Agent Health</span><span class="nav-badge" id="agentHealthCount">0</span></a>
<a href="#smart-router" class="nav-item" data-page="smart-router"><span class="nav-icon">🧭</span><span class="nav-label">Smart Router</span></a>
<a href="#learning-analytics" class="nav-item" data-page="learning-analytics"><span class="nav-icon">📊</span><span class="nav-label">Learning Analytics</span></a>
<a href="#session-replay" class="nav-item" data-page="session-replay"><span class="nav-icon">🔄</span><span class="nav-label">Session Replay</span></a>
<div class="sidebar-section"><div class="sidebar-section-label">Management</div></div>
<a href="#cost" class="nav-item" data-page="cost"><span class="nav-icon">💰</span><span class="nav-label">Cost Analytics</span></a>
<a href="#plugins" class="nav-item" data-page="plugins"><span class="nav-icon">🔌</span><span class="nav-label">Plugins</span></a>

View File

@ -0,0 +1,115 @@
let agentHealthInterval = null;
async function renderAgentHealth() {
const content = document.getElementById('pageContent');
content.innerHTML = `
<div class="page-header">
<div class="page-header-left">
<div class="page-title">Agent Health</div>
<div class="page-subtitle">Real-time monitoring of all 3 agents</div>
</div>
<div class="btn-group">
<label class="switch" title="Auto-refresh every 5s">
<input type="checkbox" id="healthAutoRefresh" checked onchange="toggleHealthAutoRefresh()">
<span class="switch-slider"></span>
</label>
<span class="text-sm text-muted">Auto</span>
<button class="btn btn-primary" onclick="refreshAgentHealth()">🔄 Refresh Now</button>
</div>
</div>
<div id="agentHealthCards" class="grid grid-3" style="margin-bottom:20px">
<div class="skeleton" style="height:180px"></div>
<div class="skeleton" style="height:180px"></div>
<div class="skeleton" style="height:180px"></div>
</div>
<div class="section-title">Health Overview</div>
<div class="card" id="healthOverviewCard">
<div class="loading"><div class="loading-spinner"></div><span>Loading health data...</span></div>
</div>
`;
await refreshAgentHealth();
if (document.getElementById('healthAutoRefresh')?.checked) {
startHealthAutoRefresh();
}
}
function startHealthAutoRefresh() {
stopHealthAutoRefresh();
agentHealthInterval = setInterval(refreshAgentHealth, 5000);
}
function stopHealthAutoRefresh() {
if (agentHealthInterval) {
clearInterval(agentHealthInterval);
agentHealthInterval = null;
}
}
function toggleHealthAutoRefresh() {
if (document.getElementById('healthAutoRefresh')?.checked) {
startHealthAutoRefresh();
} else {
stopHealthAutoRefresh();
}
}
async function refreshAgentHealth() {
try {
const data = await api.getAgentHealth();
const agents = data.agents || [];
const cards = document.getElementById('agentHealthCards');
if (!cards) return;
const agentIcons = { opencode: '🔧', hermes: '⚡', gemini: '🧠' };
const agentColors = { opencode: 'purple', hermes: 'green', gemini: 'blue' };
cards.innerHTML = agents.map(a => `
<div class="agent-health-card">
<div class="agent-health-avatar" style="background:var(--${agentColors[a.name] || 'accent'}-dim);color:var(--${agentColors[a.name] || 'accent'})">
${agentIcons[a.name] || '🤖'}
</div>
<div class="agent-health-info">
<div class="agent-health-name" style="text-transform:capitalize">${a.name}</div>
<div class="agent-health-status">
<span class="agent-dot ${a.status === 'online' ? 'online' : a.status === 'warning' ? 'warning' : 'offline'}"></span>
<span style="text-transform:capitalize;color:var(--text-secondary)">${a.status}</span>
</div>
<div class="agent-health-stats">
<div class="agent-health-stat">
<div class="agent-health-stat-value" style="color:var(--green)">${a.status === 'online' ? '100' : '0'}%</div>
<div class="agent-health-stat-label">Uptime</div>
</div>
<div class="agent-health-stat">
<div class="agent-health-stat-value" style="color:var(--accent-light)">${a.status === 'online' ? '✓' : '✗'}</div>
<div class="agent-health-stat-label">Reachable</div>
</div>
<div class="agent-health-stat">
<div class="agent-health-stat-value text-sm" style="font-size:11px;color:var(--text-muted)">${new Date(data.updated).toLocaleTimeString()}</div>
<div class="agent-health-stat-label">Last Check</div>
</div>
</div>
</div>
</div>
`).join('');
const overview = document.getElementById('healthOverviewCard');
if (overview) {
const online = agents.filter(a => a.status === 'online').length;
const total = agents.length;
overview.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between">
<div>
<div style="font-size:14px;font-weight:600">System Status</div>
<div style="font-size:12px;color:var(--text-secondary);margin-top:4px">
${online}/${total} agents online · Last updated: ${new Date(data.updated).toLocaleTimeString()}
</div>
</div>
<div class="status-indicator ${online === total ? 'online' : online > 0 ? 'warning' : 'offline'}">
<span class="agent-dot ${online === total ? 'online' : online > 0 ? 'warning' : 'offline'}"></span>
${online === total ? 'All Online' : online > 0 ? 'Partial' : 'Offline'}
</div>
</div>
`;
}
} catch (err) {
const cards = document.getElementById('agentHealthCards');
if (cards) cards.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><div class="empty-state-icon">⚠</div><div class="empty-state-title">Failed to load health data</div><div class="empty-state-desc">${escapeHtml(err.message)}</div></div>`;
}
}

176
dashboard/pages/goals.js Normal file
View File

@ -0,0 +1,176 @@
async function renderGoals() {
const content = document.getElementById('pageContent');
content.innerHTML = `
<div class="page-header">
<div class="page-header-left">
<div class="page-title">Goals</div>
<div class="page-subtitle">Project targets, task lists, and progress tracking</div>
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="showCreateGoalModal()">+ New Goal</button>
<button class="btn btn-ghost" onclick="renderGoals()">🔄 Refresh</button>
</div>
</div>
<div class="flex gap-3" style="margin-bottom:16px">
<div class="metric-tile" style="flex:1">
<div class="metric-tile-value" id="goalTotalCount">0</div>
<div class="metric-tile-label">Total Goals</div>
</div>
<div class="metric-tile" style="flex:1">
<div class="metric-tile-value" id="goalActiveCount">0</div>
<div class="metric-tile-label">Active</div>
</div>
<div class="metric-tile" style="flex:1">
<div class="metric-tile-value" id="goalCompleteCount">0</div>
<div class="metric-tile-label">Completed</div>
</div>
<div class="metric-tile" style="flex:1">
<div class="metric-tile-value" id="goalAvgProgress">0%</div>
<div class="metric-tile-label">Avg Progress</div>
</div>
</div>
<div class="grid grid-3" id="goalList"></div>
`;
try {
const data = await api.getGoals();
const goals = data.goals || [];
const list = document.getElementById('goalList');
const totalEl = document.getElementById('goalTotalCount');
const activeEl = document.getElementById('goalActiveCount');
const completeEl = document.getElementById('goalCompleteCount');
const avgEl = document.getElementById('goalAvgProgress');
if (!list) return;
if (goals.length === 0) {
list.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><div class="empty-state-icon">🎯</div><div class="empty-state-title">No goals yet</div><div class="empty-state-desc">Create your first goal to start tracking progress</div></div>`;
if (totalEl) totalEl.textContent = '0';
if (activeEl) activeEl.textContent = '0';
if (completeEl) completeEl.textContent = '0';
if (avgEl) avgEl.textContent = '0%';
return;
}
const active = goals.filter(g => g.status === 'active').length;
const done = goals.filter(g => g.status === 'completed').length;
const avgProg = Math.round(goals.reduce((s, g) => s + (g.progress || 0), 0) / goals.length);
if (totalEl) totalEl.textContent = goals.length;
if (activeEl) activeEl.textContent = active;
if (completeEl) completeEl.textContent = done;
if (avgEl) avgEl.textContent = avgProg + '%';
list.innerHTML = goals.map(g => `
<div class="goal-card">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span class="badge badge-${g.status === 'completed' ? 'success' : g.status === 'active' ? 'info' : 'warning'}">${g.status}</span>
<span class="badge badge-accent">${g.category}</span>
${g.target_date ? `<span class="text-muted text-xs">🎯 ${g.target_date}</span>` : ''}
</div>
<div class="goal-card-title">${escapeHtml(g.title)}</div>
${g.description ? `<div class="text-muted text-sm" style="margin-bottom:8px">${escapeHtml(g.description)}</div>` : ''}
<div class="goal-card-progress">
<div class="goal-card-progress-bar"><div class="goal-card-progress-fill" style="width:${g.progress || 0}%"></div></div>
<div class="goal-card-progress-text">${g.progress || 0}%</div>
</div>
<div style="display:flex;align-items:center;gap:8px;margin-top:12px;padding-top:10px;border-top:1px solid var(--border)">
<button class="btn btn-sm ${g.status !== 'completed' ? 'btn-primary' : 'btn-ghost'}" onclick="updateGoalProgress('${g.id}', ${Math.min((g.progress || 0) + 25, 100)})">
${g.status !== 'completed' ? '+25%' : '✅ Done'}
</button>
${g.status !== 'completed' ? `<button class="btn btn-sm btn-ghost" onclick="completeGoal('${g.id}')">Mark Complete</button>` : ''}
<button class="btn btn-sm btn-ghost" onclick="deleteGoal('${g.id}')" style="margin-left:auto;color:var(--red)">🗑</button>
</div>
</div>
`).join('');
} catch (err) {
showToast('Failed to load goals: ' + err.message, 'error');
}
}
function showCreateGoalModal() {
const modal = document.getElementById('modalContainer');
modal.innerHTML = `
<div class="modal-overlay" onclick="if(event.target===this)closeModal()">
<div class="modal">
<div class="modal-header">
<div class="modal-title">Create New Goal</div>
<button class="modal-close" onclick="closeModal()"></button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Title *</label>
<input class="form-input" id="goalTitle" placeholder="e.g., Complete CloudMart Phase 2">
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea class="form-textarea" id="goalDesc" placeholder="What does this goal involve?" rows="3"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Category</label>
<select class="form-select" id="goalCategory">
<option value="general">General</option>
<option value="development">Development</option>
<option value="study">Study</option>
<option value="devops">DevOps</option>
<option value="personal">Personal</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Target Date</label>
<input class="form-input" id="goalDate" type="date">
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="createGoal()">Create Goal</button>
</div>
</div>
</div>
`;
}
async function createGoal() {
const title = document.getElementById('goalTitle').value.trim();
if (!title) { showToast('Title is required', 'error'); return; }
try {
await api.createGoal({
title,
description: document.getElementById('goalDesc').value.trim(),
category: document.getElementById('goalCategory').value,
target_date: document.getElementById('goalDate').value,
});
showToast('Goal created!', 'success');
closeModal();
renderGoals();
} catch (err) {
showToast('Failed to create goal: ' + err.message, 'error');
}
}
async function updateGoalProgress(id, progress) {
try {
const status = progress >= 100 ? 'completed' : 'active';
await api.updateGoal(id, { progress, status });
renderGoals();
} catch (err) {
showToast('Failed to update goal: ' + err.message, 'error');
}
}
async function completeGoal(id) {
try {
await api.updateGoal(id, { progress: 100, status: 'completed' });
showToast('Goal completed! 🎉', 'success');
renderGoals();
} catch (err) {
showToast('Failed to complete goal: ' + err.message, 'error');
}
}
async function deleteGoal(id) {
if (!confirm('Delete this goal?')) return;
try {
await api.deleteGoal(id);
showToast('Goal deleted', 'info');
renderGoals();
} catch (err) {
showToast('Failed to delete goal: ' + err.message, 'error');
}
}

165
dashboard/pages/journal.js Normal file
View File

@ -0,0 +1,165 @@
let journalCurrentDate = '';
async function renderJournal() {
const today = new Date().toISOString().split('T')[0];
journalCurrentDate = today;
const content = document.getElementById('pageContent');
content.innerHTML = `
<div class="page-header">
<div class="page-header-left">
<div class="page-title">Journal</div>
<div class="page-subtitle">Daily entries and notes auto-saved to brain/journal/</div>
</div>
<div class="btn-group">
<input class="form-input" type="date" id="journalDatePicker" value="${today}" onchange="loadJournalEntry(this.value)" style="width:160px">
<button class="btn btn-ghost" onclick="renderJournal()">🔄 Refresh</button>
</div>
</div>
<div class="flex gap-3" style="margin-bottom:16px">
<div class="metric-tile" style="flex:1">
<div class="metric-tile-value" id="journalTotalCount">0</div>
<div class="metric-tile-label">Total Entries</div>
</div>
<div class="metric-tile" style="flex:1">
<div class="metric-tile-value" id="journalWordCount">0</div>
<div class="metric-tile-label">Words Today</div>
</div>
<div class="metric-tile" style="flex:1">
<div class="metric-tile-value" id="journalStreak">0</div>
<div class="metric-tile-label">Day Streak</div>
</div>
</div>
<div class="flex gap-3" style="margin-bottom:16px">
<div style="flex:1;display:flex;gap:8px">
<input class="form-input" id="journalSearchInput" placeholder="Search entries..." style="flex:1">
<button class="btn btn-ghost" onclick="searchJournal()">🔍 Search</button>
</div>
</div>
<div class="journal-entry" id="journalEditor">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<div style="font-weight:600;font-size:14px" id="journalDateLabel">${formatDateDisplay(today)}</div>
<div>
<span class="badge badge-info" id="journalSaveStatus">Auto-save on</span>
</div>
</div>
<textarea id="journalText" placeholder="Write your daily entry here... Markdown supported." oninput="scheduleJournalSave()"></textarea>
</div>
<div id="journalEntriesList" style="margin-top:16px"></div>
`;
loadJournalEntry(today);
loadJournalEntries();
}
function formatDateDisplay(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
}
let journalSaveTimer = null;
function scheduleJournalSave() {
if (journalSaveTimer) clearTimeout(journalSaveTimer);
journalSaveTimer = setTimeout(saveJournalEntry, 2000);
const status = document.getElementById('journalSaveStatus');
if (status) status.textContent = 'Unsaved changes...';
}
async function saveJournalEntry() {
const textarea = document.getElementById('journalText');
if (!textarea) return;
const content = textarea.value;
const date = journalCurrentDate;
try {
await api.saveJournalEntry(date, content);
const status = document.getElementById('journalSaveStatus');
if (status) status.textContent = 'Saved ✓';
const words = content.trim() ? content.trim().split(/\s+/).length : 0;
const wc = document.getElementById('journalWordCount');
if (wc) wc.textContent = words;
} catch (err) {
const status = document.getElementById('journalSaveStatus');
if (status) status.textContent = 'Save failed';
}
}
async function loadJournalEntry(date) {
journalCurrentDate = date;
const label = document.getElementById('journalDateLabel');
if (label) label.textContent = formatDateDisplay(date);
const textarea = document.getElementById('journalText');
if (!textarea) return;
try {
const data = await api.getJournalEntry(date);
textarea.value = data.content || '';
const words = data.content ? data.content.trim().split(/\s+/).length : 0;
const wc = document.getElementById('journalWordCount');
if (wc) wc.textContent = words;
const status = document.getElementById('journalSaveStatus');
if (status) status.textContent = 'Loaded ✓';
} catch (err) {
textarea.value = '';
}
}
async function loadJournalEntries() {
try {
const data = await api.getJournalEntries();
const entries = data.entries || [];
const totalEl = document.getElementById('journalTotalCount');
if (totalEl) totalEl.textContent = entries.length;
let streak = 0;
const today = new Date();
for (let i = 0; i < 365; i++) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const ds = d.toISOString().split('T')[0];
if (entries.some(e => e.date === ds)) { streak++; }
else if (i > 0) break;
}
const streakEl = document.getElementById('journalStreak');
if (streakEl) streakEl.textContent = streak;
const list = document.getElementById('journalEntriesList');
if (!list) return;
if (entries.length === 0) {
list.innerHTML = `<div class="empty-state"><div class="empty-state-icon">📓</div><div class="empty-state-title">No journal entries yet</div><div class="empty-state-desc">Write your first entry above</div></div>`;
return;
}
list.innerHTML = `
<div class="section-title">Recent Entries</div>
<div style="display:flex;flex-wrap:wrap;gap:8px">
${entries.slice(0, 30).map(e => `
<button class="btn btn-sm ${e.date === journalCurrentDate ? 'btn-primary' : 'btn-ghost'}" onclick="loadJournalEntry('${e.date}');document.getElementById('journalDatePicker').value='${e.date}'">
${e.date}
</button>
`).join('')}
</div>
`;
} catch (err) {
showToast('Failed to load entries: ' + err.message, 'error');
}
}
async function searchJournal() {
const q = document.getElementById('journalSearchInput').value.trim();
if (!q) { loadJournalEntries(); return; }
try {
const data = await api.searchJournal(q);
const results = data.results || [];
const list = document.getElementById('journalEntriesList');
if (!list) return;
if (results.length === 0) {
list.innerHTML = `<div class="empty-state"><div class="empty-state-icon">🔍</div><div class="empty-state-title">No results for "${escapeHtml(q)}"</div></div>`;
return;
}
list.innerHTML = `
<div class="section-title">Results for "${escapeHtml(q)}"</div>
${results.map(r => `
<div class="card" style="cursor:pointer;margin-bottom:8px" onclick="loadJournalEntry('${r.date}');document.getElementById('journalDatePicker').value='${r.date}'">
<div style="font-size:13px;font-weight:600;margin-bottom:4px">${r.date}</div>
<div class="text-muted text-sm">${escapeHtml(r.preview)}</div>
</div>
`).join('')}
`;
} catch (err) {
showToast('Search failed: ' + err.message, 'error');
}
}

290
dashboard/pages/kanban.js Normal file
View File

@ -0,0 +1,290 @@
let kanbanData = null;
async function renderKanban() {
const content = document.getElementById('pageContent');
content.innerHTML = `
<div class="page-header">
<div class="page-header-left">
<div class="page-title">Kanban Board</div>
<div class="page-subtitle">Visual task management track, prioritize, and organize work</div>
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="showAddKanbanTask()">+ Add Task</button>
<button class="btn btn-ghost" onclick="renderKanban()">🔄 Refresh</button>
</div>
</div>
<div class="kanban-toolbar">
<input class="form-input" id="kanbanFilterInput" placeholder="Filter tasks..." oninput="filterKanbanTasks()" style="flex:1;max-width:280px">
<select class="form-select" id="kanbanFilterPriority" onchange="filterKanbanTasks()" style="width:120px">
<option value="all">All Priorities</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
<select class="form-select" id="kanbanFilterCategory" onchange="filterKanbanTasks()" style="width:140px">
<option value="all">All Categories</option>
<option value="development">Development</option>
<option value="devops">DevOps</option>
<option value="study">Study</option>
<option value="content">Content</option>
<option value="general">General</option>
</select>
</div>
<div class="kanban-board" id="kanbanBoard">
<div class="skeleton" style="height:400px"></div>
</div>
`;
await loadKanbanData();
}
async function loadKanbanData() {
try {
const data = await api.getKanbanBoard();
kanbanData = data;
renderKanbanBoard();
} catch (err) {
const board = document.getElementById('kanbanBoard');
if (board) board.innerHTML = `<div class="empty-state"><div class="empty-state-icon">📋</div><div class="empty-state-title">Failed to load kanban</div><div class="empty-state-desc">${escapeHtml(err.message)}</div></div>`;
}
}
function renderKanbanBoard() {
const board = document.getElementById('kanbanBoard');
if (!board || !kanbanData) return;
const columnsObj = kanbanData.columns || {};
const columns = Object.keys(columnsObj);
const allTasks = kanbanData.tasks || Object.values(columnsObj).flat();
const filterText = (document.getElementById('kanbanFilterInput')?.value || '').toLowerCase();
const filterPriority = document.getElementById('kanbanFilterPriority')?.value || 'all';
const filterCategory = document.getElementById('kanbanFilterCategory')?.value || 'all';
const columnLabels = { triage: 'Triage', todo: 'To Do', ready: 'Ready', in_progress: 'In Progress', blocked: 'Blocked', done: 'Done', backlog: 'Backlog', review: 'Review' };
const columnIcons = { triage: '🔍', todo: '📝', ready: '✅', in_progress: '🔄', blocked: '🚫', done: '🎉', backlog: '📋', review: '🔍' };
board.innerHTML = columns.map(col => {
let colTasks = (columnsObj[col] || []).filter(t => {
if (filterText) return (t.title || '').toLowerCase().includes(filterText) || (t.body || t.description || '').toLowerCase().includes(filterText);
return true;
}).filter(t => filterPriority === 'all' || (t.priority || 'medium') === filterPriority)
.filter(t => filterCategory === 'all' || (t.category || 'general') === filterCategory);
return `
<div class="kanban-column" data-column="${col}">
<div class="kanban-column-header">
<div class="kanban-column-title">
<span>${columnIcons[col] || '📌'}</span>
${columnLabels[col] || col}
<span class="kanban-count">${colTasks.length}</span>
</div>
</div>
<div class="kanban-column-body" ondragover="event.preventDefault()" ondrop="onKanbanDrop(event, '${col}')">
${colTasks.length === 0 ? `<div class="kanban-empty">No tasks</div>` :
colTasks.map(t => `
<div class="kanban-card" draggable="true" ondragstart="onKanbanDrag(event, '${t.id}')" onclick="showKanbanDetail('${t.id}')">
<div class="kanban-card-header">
<span class="kanban-priority priority-${t.priority || 'medium'}">${t.priority || 'medium'}</span>
</div>
<div class="kanban-card-title">${escapeHtml(t.title)}</div>
${t.body ? `<div class="kanban-card-desc">${escapeHtml(t.body.substring(0, 80))}${t.body.length > 80 ? '...' : ''}</div>` : ''}
<div class="kanban-card-meta">
${t.assignee ? `<span>👤 ${escapeHtml(t.assignee)}</span>` : ''}
</div>
${t.status === 'blocked' ? `<div class="kanban-blocked-badge">🚫 Blocked</div>` : ''}
</div>
`).join('')}
</div>
</div>
`;
}).join('');
}
function showAddKanbanTask() {
const modal = document.getElementById('modalContainer');
modal.innerHTML = `
<div class="modal-overlay" onclick="if(event.target===this)closeModal()">
<div class="modal" style="max-width:520px">
<div class="modal-header">
<div class="modal-title">Add Kanban Task</div>
<button class="modal-close" onclick="closeModal()"></button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Title *</label>
<input class="form-input" id="kanbanTitle" placeholder="e.g., Implement login page">
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea class="form-textarea" id="kanbanDescription" rows="2" placeholder="Brief description"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Status</label>
<select class="form-select" id="kanbanStatus">
<option value="triage">Triage</option>
<option value="todo">To Do</option>
<option value="ready">Ready</option>
<option value="in_progress">In Progress</option>
<option value="blocked">Blocked</option>
<option value="done">Done</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Priority</label>
<select class="form-select" id="kanbanPriority">
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Category</label>
<select class="form-select" id="kanbanCategory">
<option value="general">General</option>
<option value="development">Development</option>
<option value="devops">DevOps</option>
<option value="study">Study</option>
<option value="content">Content</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Assigned To</label>
<input class="form-input" id="kanbanAssigned" placeholder="e.g., opencode" style="text-transform:lowercase">
</div>
<div class="form-group">
<label class="form-label">Target Date</label>
<input class="form-input" id="kanbanDate" type="date">
</div>
</div>
<div class="form-group">
<label class="form-label">Tags (comma separated)</label>
<input class="form-input" id="kanbanTags" placeholder="e.g., frontend, api">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="createKanbanTask()">Add Task</button>
</div>
</div>
</div>
`;
}
async function createKanbanTask() {
const title = document.getElementById('kanbanTitle').value.trim();
if (!title) { showToast('Title is required', 'error'); return; }
const tagsStr = document.getElementById('kanbanTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
try {
await api.createKanbanTask({
title,
body: document.getElementById('kanbanDescription').value.trim(),
status: document.getElementById('kanbanStatus').value,
priority: document.getElementById('kanbanPriority').value,
assignee: document.getElementById('kanbanAssigned').value.trim(),
});
showToast('Task added to kanban board!', 'success');
closeModal();
renderKanban();
} catch (err) {
showToast('Failed to create task: ' + err.message, 'error');
}
}
let kanbanDraggedId = null;
function onKanbanDrag(e, id) {
kanbanDraggedId = id;
e.dataTransfer.effectAllowed = 'move';
}
async function onKanbanDrop(e, status) {
e.preventDefault();
if (!kanbanDraggedId) return;
try {
await api.updateKanbanTask(kanbanDraggedId, { status });
kanbanDraggedId = null;
renderKanban();
} catch (err) {
showToast('Failed to move task: ' + err.message, 'error');
}
}
function showKanbanDetail(id) {
const task = kanbanData?.tasks?.find(t => t.id === id);
if (!task) return;
const modal = document.getElementById('modalContainer');
modal.innerHTML = `
<div class="modal-overlay" onclick="if(event.target===this)closeModal()">
<div class="modal" style="max-width:560px">
<div class="modal-header">
<div class="modal-title">${escapeHtml(task.title)}</div>
<button class="modal-close" onclick="closeModal()"></button>
</div>
<div class="modal-body">
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px">
<span class="badge badge-${task.status === 'done' ? 'success' : task.status === 'in_progress' ? 'info' : 'warning'}">${task.status}</span>
<span class="kanban-priority priority-${task.priority || 'medium'}">${task.priority}</span>
${task.status === 'blocked' ? `<span class="badge badge-danger">🚫 Blocked</span>` : ''}
</div>
${task.body ? `<div style="margin-bottom:12px;color:var(--text-secondary);font-size:13px">${escapeHtml(task.body)}</div>` : ''}
<div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:12px;font-size:12px">
${task.assignee ? `<span>👤 <strong>${escapeHtml(task.assignee)}</strong></span>` : ''}
<span>📅 <strong>${task.created || 'N/A'}</strong></span>
${task.completed_at ? `<span>✅ <strong>${task.completed_at}</strong></span>` : ''}
</div>
<div style="display:flex;gap:4px;flex-wrap:wrap">
${task.status !== 'done' ? `<button class="btn btn-sm btn-primary" onclick="completeKanbanTask('${task.id}')">✅ Mark Done</button>` : ''}
${task.status !== 'blocked' ? `<button class="btn btn-sm btn-ghost" onclick="blockKanbanTask('${task.id}')">🚫 Block</button>` : `<button class="btn btn-sm btn-ghost" onclick="unblockKanbanTask('${task.id}')">🔓 Unblock</button>`}
<button class="btn btn-sm btn-ghost" onclick="deleteKanbanTask('${task.id}')" style="color:var(--red)">🗑 Delete</button>
</div>
</div>
</div>
</div>
`;
}
async function completeKanbanTask(id) {
try {
await api.completeKanbanTask(id);
closeModal();
renderKanban();
showToast('Task completed! ✅', 'success');
} catch (err) {
showToast('Failed: ' + err.message, 'error');
}
}
async function blockKanbanTask(id) {
try {
await api.blockKanbanTask(id);
closeModal();
renderKanban();
showToast('Task blocked 🚫', 'warning');
} catch (err) {
showToast('Failed: ' + err.message, 'error');
}
}
async function unblockKanbanTask(id) {
try {
await api.unblockKanbanTask(id);
closeModal();
renderKanban();
showToast('Task unblocked 🔓', 'success');
} catch (err) {
showToast('Failed: ' + err.message, 'error');
}
}
async function deleteKanbanTask(id) {
if (!confirm('Delete this task?')) return;
try {
await api.deleteKanbanTask(id);
closeModal();
renderKanban();
showToast('Task deleted', 'info');
} catch (err) {
showToast('Failed: ' + err.message, 'error');
}
}
function filterKanbanTasks() {
renderKanbanBoard();
}

View File

@ -0,0 +1,103 @@
async function renderLearningAnalytics() {
const content = document.getElementById('pageContent');
content.innerHTML = `
<div class="page-header">
<div class="page-header-left">
<div class="page-title">Learning Analytics</div>
<div class="page-subtitle">Skill evaluation scores and performance trends</div>
</div>
<div class="btn-group">
<button class="btn btn-ghost" onclick="renderLearningAnalytics()">🔄 Refresh</button>
</div>
</div>
<div class="section-title">Skill Scores</div>
<div id="skillScoresGrid" class="grid grid-3" style="margin-bottom:20px">
<div class="skeleton" style="height:100px"></div>
<div class="skeleton" style="height:100px"></div>
<div class="skeleton" style="height:100px"></div>
</div>
<div class="section-title">Score Trends</div>
<div id="trendCharts" class="grid grid-2"></div>
<div class="section-title">Skill Details</div>
<div id="skillDetailsList"></div>
`;
try {
const [skillData, trendData] = await Promise.all([api.getSkillAnalytics(), api.getTrendAnalytics()]);
const skills = skillData.skills || [];
const trends = trendData.trends || {};
const grid = document.getElementById('skillScoresGrid');
if (grid) {
if (skills.length === 0) {
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><div class="empty-state-icon">📊</div><div class="empty-state-title">No skill data yet</div><div class="empty-state-desc">Skill evaluations will appear here as they accumulate</div></div>`;
} else {
const topSkills = skills.sort((a, b) => (b.score || 0) - (a.score || 0)).slice(0, 6);
grid.innerHTML = topSkills.map(s => {
const pct = Math.round((s.score || 0) * 100);
return `
<div class="card chart-card" style="position:relative">
<div style="font-size:13px;font-weight:600;margin-bottom:4px;text-transform:capitalize">${escapeHtml(s.name)}</div>
<div style="font-size:11px;color:var(--text-muted);margin-bottom:8px">${s.evals || 0} evaluations</div>
<div style="display:flex;align-items:center;gap:12px">
<div style="flex:1;height:6px;background:var(--bg-dim);border-radius:99px;overflow:hidden">
<div style="height:100%;width:${pct}%;background:linear-gradient(90deg,var(--accent),var(--accent-light));border-radius:99px;transition:width 0.6s ease"></div>
</div>
<span style="font-size:14px;font-weight:700;color:var(--accent-light)">${pct}%</span>
</div>
</div>
`;
}).join('');
}
}
const trendSection = document.getElementById('trendCharts');
if (trendSection) {
const trendEntries = Object.entries(trends).slice(0, 4);
if (trendEntries.length === 0) {
trendSection.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><div class="empty-state-icon">📈</div><div class="empty-state-title">No trend data</div><div class="empty-state-desc">Trends will appear after multiple evaluations</div></div>`;
} else {
trendSection.innerHTML = trendEntries.map(([name, history]) => {
const vals = (history || []).slice(-10);
const maxVal = Math.max(...vals, 0.01);
const chartHeight = 120;
return `
<div class="card chart-card">
<div style="font-size:13px;font-weight:600;margin-bottom:8px;text-transform:capitalize">${escapeHtml(name)}</div>
<div style="display:flex;align-items:flex-end;gap:3px;height:${chartHeight}px;padding-top:20px">
${vals.map((v, i) => {
const h = (v / maxVal) * (chartHeight - 25);
return `<div style="flex:1;height:${h}px;background:linear-gradient(180deg,var(--accent),var(--accent-light));border-radius:3px 3px 0 0;min-height:4px;transition:height 0.3s ease" title="${(v * 100).toFixed(0)}%"></div>`;
}).join('')}
</div>
<div style="display:flex;justify-content:space-between;margin-top:4px">
<span class="text-xs text-muted">${vals.length} data points</span>
<span class="text-xs text-muted">Avg: ${(vals.reduce((a, b) => a + b, 0) / vals.length * 100).toFixed(0)}%</span>
</div>
</div>
`;
}).join('');
}
}
const details = document.getElementById('skillDetailsList');
if (details) {
if (skills.length === 0) {
details.innerHTML = '';
} else {
details.innerHTML = skills.map(s => {
const pct = Math.round((s.score || 0) * 100);
return `
<div class="card" style="margin-bottom:8px">
<div style="display:flex;align-items:center;justify-content:space-between">
<div>
<div style="font-weight:600;text-transform:capitalize">${escapeHtml(s.name)}</div>
<div class="text-muted text-sm">${s.evals || 0} evaluations · Best: ${((s.best || 0) * 100).toFixed(0)}%</div>
</div>
<span style="font-size:16px;font-weight:700;color:${pct >= 70 ? 'var(--green)' : pct >= 40 ? 'var(--yellow)' : 'var(--red)'}">${pct}%</span>
</div>
</div>
`;
}).join('');
}
}
} catch (err) {
showToast('Failed to load analytics: ' + err.message, 'error');
}
}

View File

@ -0,0 +1,87 @@
async function renderSessionReplay() {
const content = document.getElementById('pageContent');
content.innerHTML = `
<div class="page-header">
<div class="page-header-left">
<div class="page-title">Session Replay</div>
<div class="page-subtitle">Browse and replay past opencode sessions</div>
</div>
<div class="btn-group">
<button class="btn btn-ghost" onclick="renderSessionReplay()">🔄 Refresh</button>
</div>
</div>
<div id="sessionList">
<div class="loading"><div class="loading-spinner"></div><span>Loading sessions...</span></div>
</div>
<div id="sessionDetail" style="margin-top:16px"></div>
`;
try {
const data = await api.listSessions();
const sessions = data.sessions || [];
const list = document.getElementById('sessionList');
if (sessions.length === 0) {
list.innerHTML = `<div class="empty-state"><div class="empty-state-icon">🎬</div><div class="empty-state-title">No sessions found</div><div class="empty-state-desc">Sessions from opencode will appear here</div></div>`;
return;
}
list.innerHTML = `
<div class="card" style="padding:0">
<table>
<tr><th>Session ID</th><th>Date</th><th>Size</th><th></th></tr>
${sessions.slice(0, 50).map(s => `
<tr>
<td style="font-family:monospace;font-size:12px">${escapeHtml(s.id)}</td>
<td class="text-sm">${new Date(s.date).toLocaleString()}</td>
<td class="text-sm text-muted">${formatSize(s.size || 0)}</td>
<td><button class="btn btn-sm btn-ghost" onclick="replaySession('${s.id}')"> Replay</button></td>
</tr>
`).join('')}
</table>
${sessions.length > 50 ? `<div style="padding:12px;text-align:center;color:var(--text-muted);font-size:12px">Showing 50 of ${sessions.length} sessions</div>` : ''}
</div>
`;
} catch (err) {
document.getElementById('sessionList').innerHTML = `<div class="empty-state"><div class="empty-state-icon">⚠</div><div class="empty-state-title">Failed to load sessions</div><div class="empty-state-desc">${escapeHtml(err.message)}</div></div>`;
}
}
async function replaySession(id) {
const detail = document.getElementById('sessionDetail');
detail.innerHTML = `<div class="loading"><div class="loading-spinner"></div><span>Loading session ${escapeHtml(id)}...</span></div>`;
try {
const data = await api.getSessionReplay(id);
const messages = data.messages || [];
const session = data.session || {};
detail.innerHTML = `
<div class="card" style="margin-bottom:12px">
<div style="display:flex;align-items:center;justify-content:space-between">
<div>
<div style="font-weight:600">Session: <code style="font-size:12px">${escapeHtml(id)}</code></div>
<div class="text-muted text-sm">${messages.length} messages · ${new Date(session.created_at || Date.now()).toLocaleString()}</div>
</div>
<button class="btn btn-sm btn-ghost" onclick="document.getElementById('sessionDetail').innerHTML=''"> Close</button>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:8px;max-height:600px;overflow-y:auto;padding-right:8px">
${messages.map(m => {
const isUser = m.role === 'user' || m.role === 'human';
return `
<div class="session-message ${isUser ? 'session-message-user' : 'session-message-assistant'}">
<div class="session-message-role">${isUser ? '👤 You' : '🤖 Assistant'}</div>
<div class="session-message-content">${escapeHtml((m.content || '').substring(0, 500))}${(m.content || '').length > 500 ? '...' : ''}</div>
<div class="session-message-meta">${new Date(m.timestamp || Date.now()).toLocaleTimeString()}</div>
</div>
`;
}).join('')}
${messages.length === 0 ? '<div class="text-muted text-sm" style="text-align:center;padding:20px">Empty session</div>' : ''}
</div>
`;
} catch (err) {
detail.innerHTML = `<div class="card" style="border-color:var(--red)"><div class="text-sm" style="color:var(--red)">⚠ Failed to replay session: ${escapeHtml(err.message)}</div></div>`;
}
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}

View File

@ -0,0 +1,118 @@
async function renderSmartRouter() {
const content = document.getElementById('pageContent');
content.innerHTML = `
<div class="page-header">
<div class="page-header-left">
<div class="page-title">Smart Router</div>
<div class="page-subtitle">Intelligent task routing auto-suggest or manually pick an agent</div>
</div>
</div>
<div class="card" style="margin-bottom:20px">
<div class="card-header">
<div class="card-title">Route a Task</div>
</div>
<div class="form-group">
<label class="form-label">Describe your task</label>
<textarea class="form-textarea" id="routerTaskInput" placeholder="e.g., Deploy the CloudMart infrastructure to GCP..." rows="3"></textarea>
</div>
<div class="flex gap-3" style="align-items:flex-end">
<div class="form-group" style="flex:1">
<label class="form-label">Route to Agent</label>
<select class="form-select" id="routerAgentSelect">
<option value="auto">🤖 Auto (AI suggests)</option>
<option value="opencode">🔧 opencode (Code/DevOps)</option>
<option value="hermes"> Hermes (Memory/Scheduling)</option>
<option value="gemini">🧠 Gemini CLI (Research/Analysis)</option>
</select>
</div>
<button class="btn btn-primary" onclick="suggestRouter()" style="margin-bottom:16px">🤖 Suggest Agent</button>
<button class="btn btn-gradient" onclick="routeTask()" style="margin-bottom:16px">🚀 Route Task</button>
</div>
</div>
<div id="routerResult"></div>
<div class="section-title" style="margin-top:20px">Routing Rules</div>
<div class="card">
<table>
<tr><th>Agent</th><th>Best For</th><th>Keywords</th></tr>
<tr><td><strong>🔧 opencode</strong></td><td>Code, DevOps, infra, git, file operations</td><td class="text-muted text-sm">code, deploy, git, terraform, docker, test, build, script</td></tr>
<tr><td><strong> Hermes</strong></td><td>Memory, scheduling, messaging, skills</td><td class="text-muted text-sm">memory, schedule, cron, reminder, brain, plugin, backup</td></tr>
<tr><td><strong>🧠 Gemini CLI</strong></td><td>Research, analysis, study, document, review</td><td class="text-muted text-sm">research, analyze, search, explain, study, learn, report</td></tr>
</table>
</div>
`;
}
async function suggestRouter() {
const task = document.getElementById('routerTaskInput').value.trim();
if (!task) { showToast('Describe your task first', 'warning'); return; }
const btn = document.querySelector('button[onclick="suggestRouter()"]');
if (btn) { btn.disabled = true; btn.textContent = '⏳ Thinking...'; }
try {
const data = await api.suggestRouter(task);
const result = document.getElementById('routerResult');
const agentIcons = { opencode: '🔧', hermes: '⚡', gemini: '🧠' };
const confidenceColors = { high: 'var(--green)', medium: 'var(--yellow)', low: 'var(--text-muted)' };
result.innerHTML = `
<div class="card" style="border-color:${confidenceColors[data.confidence] || 'var(--border)'};margin-bottom:12px">
<div class="router-suggestion" style="border:none;padding:0;background:none">
<div>
<div class="router-suggestion-agent" style="font-size:18px">
${agentIcons[data.suggested_agent] || '🤖'} ${data.suggested_agent}
</div>
<div style="font-size:12px;color:var(--text-secondary);margin-top:4px">
Confidence: <span style="color:${confidenceColors[data.confidence] || 'var(--text-muted)'}">${data.confidence}</span>
${data.confidence === 'high' ? '✅' : data.confidence === 'medium' ? '⚠️' : '❓'}
</div>
</div>
<div style="flex:1;text-align:right">
<span class="badge badge-accent">Best Match</span>
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px;padding-top:8px;border-top:1px solid var(--border)">
${Object.entries(data.scores || {}).map(([agent, score]) => `
<span class="badge ${score > 0 ? 'badge-success' : 'badge-info'}">
${agentIcons[agent] || '🤖'} ${agent}: ${score}
</span>
`).join('')}
</div>
</div>
`;
document.getElementById('routerAgentSelect').value = data.suggested_agent || 'auto';
} catch (err) {
showToast('Suggestion failed: ' + err.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = '🤖 Suggest Agent'; }
}
}
async function routeTask() {
const task = document.getElementById('routerTaskInput').value.trim();
if (!task) { showToast('Describe your task first', 'warning'); return; }
let agent = document.getElementById('routerAgentSelect').value;
if (agent === 'auto') {
showToast('Click "Suggest Agent" first or pick an agent manually', 'warning');
return;
}
const btn = document.querySelector('button[onclick="routeTask()"]');
if (btn) { btn.disabled = true; btn.textContent = '⏳ Routing...'; }
try {
const data = await api.routeTask(task, agent);
showToast(`✅ Task routed to ${agent}`, 'success');
const result = document.getElementById('routerResult');
result.innerHTML += `
<div class="card" style="margin-top:8px;border-color:var(--green)">
<div style="display:flex;align-items:center;gap:12px">
<span style="font-size:24px"></span>
<div>
<div style="font-weight:600">Task Routed</div>
<div class="text-muted text-sm">${data.message}</div>
</div>
</div>
</div>
`;
} catch (err) {
showToast('Routing failed: ' + err.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = '🚀 Route Task'; }
}
}

View File

@ -820,3 +820,546 @@ pre {
.chat-sidebar { display: none; }
.chat-message { max-width: 95%; }
}
/* ─── v0.2.0 UI Modernization ─── */
/* Glass card variant */
.glass-card {
background: rgba(28, 35, 51, 0.8);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(41, 48, 66, 0.6);
border-radius: var(--radius-lg);
padding: 20px;
transition: var(--transition);
}
[data-theme="light"] .glass-card {
background: rgba(255, 255, 255, 0.85);
border-color: rgba(226, 229, 240, 0.6);
}
.glass-card:hover {
border-color: var(--border-hover);
box-shadow: 0 8px 32px rgba(108, 92, 231, 0.1);
}
/* Glow border utility */
.glow-border {
position: relative;
}
.glow-border::after {
content: '';
position: absolute;
inset: -1px;
border-radius: inherit;
background: linear-gradient(135deg, var(--accent), var(--pink), var(--accent));
z-index: -1;
opacity: 0;
transition: opacity 0.3s;
}
.glow-border:hover::after {
opacity: 0.4;
}
/* Section gradient headers */
.section-title {
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
background: linear-gradient(135deg, var(--text-secondary), var(--accent-light));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 16px;
}
/* Animated gradient button */
.btn-gradient {
background: linear-gradient(135deg, var(--accent), var(--pink));
color: white;
border: none;
background-size: 200% 200%;
animation: gradientShift 3s ease infinite;
}
.btn-gradient:hover {
box-shadow: 0 4px 20px rgba(108, 92, 231, 0.3);
transform: translateY(-1px);
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* Enhanced loading spinner */
.loading-ring {
width: 36px;
height: 36px;
border-radius: 50%;
background: conic-gradient(from 0deg, var(--bg-card), var(--accent), var(--pink), var(--bg-card));
animation: ringSpin 1s linear infinite;
mask: radial-gradient(farthest-side, transparent calc(100% - 3px), white calc(100% - 3px));
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), white calc(100% - 3px));
}
@keyframes ringSpin { to { transform: rotate(360deg); } }
/* Metric tile */
.metric-tile {
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 20px;
border-radius: var(--radius);
background: var(--bg-card);
border: 1px solid var(--border);
transition: var(--transition);
}
.metric-tile:hover {
border-color: var(--border-hover);
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
}
.metric-tile-value {
font-size: 24px;
font-weight: 800;
letter-spacing: -0.5px;
background: linear-gradient(135deg, var(--text-primary), var(--accent-light));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.metric-tile-label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
/* Timeline (for session replay) */
.timeline {
position: relative;
padding-left: 28px;
}
.timeline::before {
content: '';
position: absolute;
left: 10px;
top: 0;
bottom: 0;
width: 2px;
background: linear-gradient(to bottom, var(--accent), var(--pink));
opacity: 0.3;
}
.timeline-item {
position: relative;
padding: 8px 0 8px 16px;
border-left: 2px solid transparent;
transition: var(--transition);
}
.timeline-item::before {
content: '';
position: absolute;
left: -22px;
top: 14px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--bg-card);
border: 2px solid var(--accent);
}
.timeline-item.active::before {
background: var(--accent);
box-shadow: 0 0 12px var(--accent-glow);
}
.timeline-item:hover {
border-left-color: var(--accent-glow);
background: var(--bg-card-hover);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.timeline-time {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.timeline-content {
font-size: 13px;
margin-top: 2px;
}
/* Kanban specific */
.kanban-toolbar {
display: flex; align-items: center; gap: 10px;
margin-bottom: 14px; flex-wrap: wrap;
}
.kanban-blocked-badge {
display: inline-flex; align-items: center; gap: 4px;
font-size: 10px; font-weight: 600;
color: var(--red); background: var(--red-dim);
padding: 2px 8px; border-radius: 12px; margin-top: 6px;
}
.kanban-board {
display: flex;
gap: 14px;
flex: 1;
min-height: 0;
overflow-x: auto;
padding-bottom: 8px;
}
.kanban-column {
min-width: 260px;
max-width: 320px;
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--border);
max-height: 100%;
}
.kanban-column-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.kanban-column-title {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
}
.kanban-column-count {
font-size: 11px;
color: var(--text-muted);
background: var(--bg-card);
padding: 1px 7px;
border-radius: 10px;
font-weight: 600;
}
.kanban-column-body {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.kanban-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
cursor: pointer;
transition: var(--transition);
}
.kanban-card:hover {
border-color: var(--accent);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.kanban-card-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 4px;
}
.kanban-card-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text-muted);
}
.kanban-card-priority {
font-size: 10px;
font-weight: 700;
padding: 1px 6px;
border-radius: 8px;
text-transform: uppercase;
}
.kanban-card-priority.high { background: var(--red-dim); color: var(--red); }
.kanban-card-priority.medium { background: var(--yellow-dim); color: var(--yellow); }
.kanban-card-priority.low { background: var(--blue-dim); color: var(--blue); }
/* Goal cards */
.goal-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 18px;
transition: var(--transition);
cursor: pointer;
}
.goal-card:hover {
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0,0,0,0.1);
}
.goal-card-title {
font-size: 15px;
font-weight: 700;
margin-bottom: 8px;
}
.goal-card-progress {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
}
.goal-card-progress-bar {
flex: 1;
height: 4px;
background: var(--bg-primary);
border-radius: 2px;
overflow: hidden;
}
.goal-card-progress-fill {
height: 100%;
border-radius: 2px;
background: linear-gradient(90deg, var(--accent), var(--green));
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.goal-card-progress-text {
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
min-width: 36px;
text-align: right;
}
/* Journal entry */
.journal-entry {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px;
min-height: 300px;
}
.journal-entry textarea {
width: 100%;
min-height: 250px;
background: transparent;
border: none;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
resize: vertical;
outline: none;
}
.journal-entry textarea::placeholder {
color: var(--text-muted);
}
/* Agent health card */
.agent-health-card {
display: flex;
align-items: center;
gap: 16px;
padding: 18px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
transition: var(--transition);
}
.agent-health-card:hover {
border-color: var(--border-hover);
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
}
.agent-health-avatar {
width: 48px;
height: 48px;
border-radius: var(--radius);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
flex-shrink: 0;
background: var(--accent-glow);
}
.agent-health-info { flex: 1; }
.agent-health-name {
font-size: 15px;
font-weight: 700;
}
.agent-health-status {
font-size: 12px;
margin-top: 2px;
display: flex;
align-items: center;
gap: 6px;
}
.agent-health-stats {
display: flex;
gap: 16px;
margin-top: 8px;
}
.agent-health-stat {
text-align: center;
}
.agent-health-stat-value {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.agent-health-stat-label {
font-size: 10px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.3px;
}
/* Smart router */
.router-suggestion {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 8px;
transition: var(--transition);
}
.router-suggestion:hover {
border-color: var(--accent);
background: var(--accent-glow);
}
.router-suggestion-agent {
font-size: 14px;
font-weight: 700;
color: var(--accent-light);
}
.router-suggestion-reason {
font-size: 12px;
color: var(--text-secondary);
flex: 1;
}
/* Learning analytics chart container */
.chart-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px;
}
.chart-card-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 16px;
color: var(--text-secondary);
}
/* Priority badges */
.kanban-priority {
display: inline-flex; align-items: center;
padding: 1px 7px; border-radius: 10px;
font-size: 9px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.5px;
}
.priority-high { background: var(--red-dim); color: var(--red); }
.priority-medium { background: var(--yellow-dim); color: var(--yellow); }
.priority-low { background: var(--green-dim); color: var(--green); }
/* Agent health dot */
.agent-dot {
width: 8px; height: 8px; border-radius: 50%; display: inline-block;
}
.agent-dot.online { background: var(--green); box-shadow: 0 0 6px var(--green); }
.agent-dot.warning { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); }
.agent-dot.offline { background: var(--red); box-shadow: 0 0 6px var(--red); }
/* Responsive additions */
@media (max-width: 1100px) {
.kanban-column { min-width: 220px; }
}
@media (max-width: 600px) {
.kanban-board { flex-direction: column; }
.kanban-column { max-width: 100%; min-width: 0; max-height: 300px; }
.agent-health-stats { flex-wrap: wrap; gap: 8px; }
}
/* Session replay */
.session-message {
padding: 12px 16px;
border-radius: var(--radius-lg);
font-size: 13px;
line-height: 1.5;
transition: var(--transition);
}
.session-message-user {
background: var(--accent-glow);
border: 1px solid var(--accent);
align-self: flex-end;
margin-left: 40px;
}
.session-message-assistant {
background: var(--bg-card);
border: 1px solid var(--border);
align-self: flex-start;
margin-right: 40px;
}
.session-message-role {
font-size: 11px;
font-weight: 600;
margin-bottom: 4px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.session-message-content {
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
}
.session-message-meta {
font-size: 10px;
color: var(--text-muted);
margin-top: 4px;
text-align: right;
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
background: var(--bg-card);
border: 1px dashed var(--border);
border-radius: var(--radius-lg);
}
.empty-state-icon {
font-size: 36px;
margin-bottom: 12px;
opacity: 0.6;
}
.empty-state-title {
font-size: 16px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 4px;
}
.empty-state-desc {
font-size: 13px;
color: var(--text-muted);
}
/* Skeleton loader */
.skeleton {
background: linear-gradient(90deg, var(--bg-card) 25%, var(--bg-card-hover) 50%, var(--bg-card) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius);
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}

View File

@ -123,4 +123,11 @@ const PAGE_TITLES = {
settings: { title: 'Settings', breadcrumb: 'Configuration' },
'setup-wizard': { title: 'Setup Wizard', breadcrumb: 'Guided configuration' },
chat: { title: 'AI Chat', breadcrumb: 'Multi-agent terminal' },
kanban: { title: 'Kanban Board', breadcrumb: 'Multi-agent task management' },
goals: { title: 'Goals', breadcrumb: 'Project targets and progress' },
journal: { title: 'Journal', breadcrumb: 'Daily entries and notes' },
'agent-health': { title: 'Agent Health', breadcrumb: 'Real-time agent monitoring' },
'smart-router': { title: 'Smart Router', breadcrumb: 'Task routing intelligence' },
'learning-analytics': { title: 'Learning Analytics', breadcrumb: 'Skill improvement tracking' },
'session-replay': { title: 'Session Replay', breadcrumb: 'Conversation history playback' },
};

13
data/goals.json Normal file
View File

@ -0,0 +1,13 @@
[
{
"id": "c1c992ef",
"title": "Test goal",
"description": "",
"category": "development",
"target_date": "",
"status": "active",
"progress": 0,
"created": "2026-06-05T09:50:30.334519+00:00",
"updated": "2026-06-05T09:50:30.334541+00:00"
}
]

12
data/kanban/0f822987.json Normal file
View File

@ -0,0 +1,12 @@
{
"id": "0f822987",
"title": "Fix login bug",
"body": "The login page has a race condition",
"status": "todo",
"priority": "high",
"assignee": "opencode",
"comments": [],
"links": [],
"created": "2026-06-05T09:52:18.236452+00:00",
"updated": "2026-06-05T09:52:18.236473+00:00"
}

15
data/kanban/8893ad14.json Normal file
View File

@ -0,0 +1,15 @@
{
"id": "8893ad14",
"title": "Test kanban task",
"body": "",
"status": "blocked",
"priority": "medium",
"assignee": "",
"comments": [],
"links": [],
"created": "2026-06-05T09:50:30.293643+00:00",
"updated": "2026-06-05T09:52:18.274595+00:00",
"summary": "Test done",
"completed_at": "2026-06-05T09:50:46.473612+00:00",
"block_reason": "Waiting for API review"
}

579
server.py
View File

@ -631,6 +631,585 @@ def chat(req: ChatRequest):
def get_chat_history():
return load_chat_history()
# ═══════════════════════════════════════════════════════════════════
# v0.2.0 — New Feature Endpoints
# ═══════════════════════════════════════════════════════════════════
# ─── Models ─────────────────────────────────────────────────────
class KanbanTaskCreate(BaseModel):
title: str
body: str = ""
status: str = "triage"
priority: str = "medium"
assignee: str = ""
class KanbanTaskUpdate(BaseModel):
title: Optional[str] = None
body: Optional[str] = None
status: Optional[str] = None
priority: Optional[str] = None
assignee: Optional[str] = None
class KanbanComplete(BaseModel):
summary: str = ""
class KanbanBlock(BaseModel):
reason: str = ""
class KanbanCommentCreate(BaseModel):
message: str
class KanbanLinkCreate(BaseModel):
parent_id: str
child_id: str
class GoalCreate(BaseModel):
title: str
description: str = ""
category: str = "general"
target_date: str = ""
class GoalUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
category: Optional[str] = None
target_date: Optional[str] = None
progress: Optional[int] = None
status: Optional[str] = None
class JournalSave(BaseModel):
content: str
class RouterSuggest(BaseModel):
task: str
class RouterRoute(BaseModel):
task: str
agent: str
# ─── Data Helpers ───────────────────────────────────────────────
KANBAN_DIR = BASE_DIR / "data" / "kanban"
GOALS_FILE = BASE_DIR / "data" / "goals.json"
JOURNAL_DIR = BASE_DIR / "brain" / "journal"
def ensure_dir(d: Path):
d.mkdir(parents=True, exist_ok=True)
def load_kanban_tasks():
ensure_dir(KANBAN_DIR)
tasks = []
for f in sorted(KANBAN_DIR.glob("*.json")):
tasks.append(json.loads(f.read_text()))
return tasks
def save_kanban_task(task: dict):
ensure_dir(KANBAN_DIR)
(KANBAN_DIR / f"{task['id']}.json").write_text(json.dumps(task, indent=2))
def load_goals():
if GOALS_FILE.exists():
return json.loads(GOALS_FILE.read_text())
return []
def save_goals(goals: list):
GOALS_FILE.write_text(json.dumps(goals, indent=2))
# ─── Routes: Kanban Board (13 endpoints) ────────────────────────
@app.get("/api/kanban/board")
def kanban_board(status: Optional[str] = None):
try:
tasks = load_kanban_tasks()
if status:
tasks = [t for t in tasks if t.get("status") == status]
columns = {"triage": [], "todo": [], "ready": [], "in_progress": [], "blocked": [], "done": []}
for t in tasks:
s = t.get("status", "triage")
if s in columns:
columns[s].append(t)
return {"columns": columns, "total": len(tasks)}
except Exception as e:
return {"error": str(e), "columns": {}, "total": 0}
@app.get("/api/kanban/tasks/{task_id}")
def kanban_get_task(task_id: str):
path = KANBAN_DIR / f"{task_id}.json"
if not path.exists():
raise HTTPException(404, "Task not found")
return json.loads(path.read_text())
@app.post("/api/kanban/tasks")
def kanban_create_task(data: KanbanTaskCreate):
try:
task = {
"id": str(uuid.uuid4())[:8],
"title": data.title,
"body": data.body,
"status": data.status,
"priority": data.priority,
"assignee": data.assignee,
"comments": [],
"links": [],
"created": get_timestamp(),
"updated": get_timestamp(),
}
save_kanban_task(task)
append_audit({"action": "kanban_task_created", "title": data.title})
return task
except Exception as e:
raise HTTPException(500, str(e))
@app.patch("/api/kanban/tasks/{task_id}")
def kanban_update_task(task_id: str, data: KanbanTaskUpdate):
path = KANBAN_DIR / f"{task_id}.json"
if not path.exists():
raise HTTPException(404, "Task not found")
task = json.loads(path.read_text())
for field in ["title", "body", "status", "priority", "assignee"]:
val = getattr(data, field, None)
if val is not None:
task[field] = val
task["updated"] = get_timestamp()
save_kanban_task(task)
append_audit({"action": "kanban_task_updated", "task_id": task_id})
return task
@app.post("/api/kanban/tasks/{task_id}/complete")
def kanban_complete_task(task_id: str, data: KanbanComplete):
path = KANBAN_DIR / f"{task_id}.json"
if not path.exists():
raise HTTPException(404, "Task not found")
task = json.loads(path.read_text())
task["status"] = "done"
task["summary"] = data.summary
task["completed_at"] = get_timestamp()
task["updated"] = get_timestamp()
save_kanban_task(task)
append_audit({"action": "kanban_task_completed", "task_id": task_id})
return task
@app.post("/api/kanban/tasks/{task_id}/block")
def kanban_block_task(task_id: str, data: KanbanBlock):
path = KANBAN_DIR / f"{task_id}.json"
if not path.exists():
raise HTTPException(404, "Task not found")
task = json.loads(path.read_text())
task["status"] = "blocked"
task["block_reason"] = data.reason
task["updated"] = get_timestamp()
save_kanban_task(task)
append_audit({"action": "kanban_task_blocked", "task_id": task_id})
return task
@app.post("/api/kanban/tasks/{task_id}/unblock")
def kanban_unblock_task(task_id: str):
path = KANBAN_DIR / f"{task_id}.json"
if not path.exists():
raise HTTPException(404, "Task not found")
task = json.loads(path.read_text())
task["status"] = "ready"
task["block_reason"] = ""
task["updated"] = get_timestamp()
save_kanban_task(task)
append_audit({"action": "kanban_task_unblocked", "task_id": task_id})
return task
@app.post("/api/kanban/tasks/{task_id}/comments")
def kanban_add_comment(task_id: str, data: KanbanCommentCreate):
path = KANBAN_DIR / f"{task_id}.json"
if not path.exists():
raise HTTPException(404, "Task not found")
task = json.loads(path.read_text())
comment = {
"id": str(uuid.uuid4())[:8],
"message": data.message,
"timestamp": get_timestamp(),
}
task.setdefault("comments", []).append(comment)
task["updated"] = get_timestamp()
save_kanban_task(task)
return task
@app.post("/api/kanban/links")
def kanban_add_link(data: KanbanLinkCreate):
for tid in [data.parent_id, data.child_id]:
path = KANBAN_DIR / f"{tid}.json"
if not path.exists():
raise HTTPException(404, f"Task {tid} not found")
t = json.loads(path.read_text())
t.setdefault("links", [])
link = {"parent": data.parent_id, "child": data.child_id}
if link not in t["links"]:
t["links"].append(link)
t["updated"] = get_timestamp()
save_kanban_task(t)
append_audit({"action": "kanban_link_added", "parent": data.parent_id, "child": data.child_id})
return {"status": "linked"}
@app.delete("/api/kanban/links")
def kanban_remove_link(parent_id: str = Query(...), child_id: str = Query(...)):
for tid in [parent_id, child_id]:
path = KANBAN_DIR / f"{tid}.json"
if path.exists():
t = json.loads(path.read_text())
t.setdefault("links", [])
t["links"] = [l for l in t["links"] if not (l.get("parent") == parent_id and l.get("child") == child_id)]
t["updated"] = get_timestamp()
save_kanban_task(t)
return {"status": "unlinked"}
@app.post("/api/kanban/dispatch")
def kanban_dispatch():
append_audit({"action": "kanban_dispatch_triggered"})
return {"status": "dispatch_triggered", "message": "Dispatcher notified"}
@app.post("/api/kanban/tasks/{task_id}/specify")
def kanban_specify_task(task_id: str):
path = KANBAN_DIR / f"{task_id}.json"
if not path.exists():
raise HTTPException(404, "Task not found")
task = json.loads(path.read_text())
if task.get("status") == "triage":
task["status"] = "todo"
task["updated"] = get_timestamp()
save_kanban_task(task)
return task
@app.post("/api/kanban/tasks/{task_id}/decompose")
def kanban_decompose_task(task_id: str):
path = KANBAN_DIR / f"{task_id}.json"
if not path.exists():
raise HTTPException(404, "Task not found")
task = json.loads(path.read_text())
children = []
for i, subtask in enumerate(task.get("body", "").split("\n")):
subtask = subtask.strip().lstrip("-* ")
if subtask:
child = {
"id": str(uuid.uuid4())[:8],
"title": subtask[:80],
"body": subtask,
"status": "todo",
"priority": task.get("priority", "medium"),
"assignee": "",
"comments": [],
"links": [{"parent": task_id, "child": ""}],
"created": get_timestamp(),
"updated": get_timestamp(),
}
child["links"][0]["child"] = child["id"]
save_kanban_task(child)
children.append(child)
return {"parent": task_id, "children": children}
# ─── Routes: Goals (4 endpoints) ─────────────────────────────────
@app.get("/api/goals")
def list_goals():
try:
return {"goals": load_goals()}
except Exception as e:
return {"goals": [], "error": str(e)}
@app.post("/api/goals")
def create_goal(data: GoalCreate):
try:
goals = load_goals()
goal = {
"id": str(uuid.uuid4())[:8],
"title": data.title,
"description": data.description,
"category": data.category,
"target_date": data.target_date,
"status": "active",
"progress": 0,
"created": get_timestamp(),
"updated": get_timestamp(),
}
goals.append(goal)
save_goals(goals)
# Auto-sync to brain/active-projects.md
active_path = BASE_DIR / "brain" / "active-projects.md"
if active_path.exists():
existing = active_path.read_text()
existing += f"\n- [{goal['title']}](goal:{goal['id']}) — {goal['description'][:80]}\n"
active_path.write_text(existing)
append_audit({"action": "goal_created", "title": data.title})
return goal
except Exception as e:
raise HTTPException(500, str(e))
@app.put("/api/goals/{goal_id}")
def update_goal(goal_id: str, data: GoalUpdate):
try:
goals = load_goals()
for g in goals:
if g["id"] == goal_id:
for field in ["title", "description", "category", "target_date", "progress", "status"]:
val = getattr(data, field, None)
if val is not None:
g[field] = val
g["updated"] = get_timestamp()
save_goals(goals)
return g
raise HTTPException(404, "Goal not found")
except HTTPException:
raise
except Exception as e:
raise HTTPException(500, str(e))
@app.delete("/api/goals/{goal_id}")
def delete_goal(goal_id: str):
try:
goals = load_goals()
goals = [g for g in goals if g["id"] != goal_id]
save_goals(goals)
append_audit({"action": "goal_deleted", "goal_id": goal_id})
return {"status": "deleted"}
except Exception as e:
raise HTTPException(500, str(e))
# ─── Routes: Journal (4 endpoints) ───────────────────────────────
@app.get("/api/journal/entries")
def list_journal_entries():
try:
ensure_dir(JOURNAL_DIR)
entries = []
for f in sorted(JOURNAL_DIR.glob("*.md"), reverse=True):
entries.append({
"date": f.stem,
"preview": f.read_text()[:200],
"modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
})
return {"entries": entries}
except Exception as e:
return {"entries": [], "error": str(e)}
@app.get("/api/journal/entries/{entry_date}")
def get_journal_entry(entry_date: str):
try:
path = JOURNAL_DIR / f"{entry_date}.md"
ensure_dir(JOURNAL_DIR)
content = path.read_text() if path.exists() else ""
return {"date": entry_date, "content": content}
except Exception as e:
return {"date": entry_date, "content": "", "error": str(e)}
@app.put("/api/journal/entries/{entry_date}")
def save_journal_entry(entry_date: str, data: JournalSave):
try:
ensure_dir(JOURNAL_DIR)
path = JOURNAL_DIR / f"{entry_date}.md"
path.write_text(data.content)
append_audit({"action": "journal_saved", "date": entry_date})
return {"status": "saved", "date": entry_date}
except Exception as e:
raise HTTPException(500, str(e))
@app.get("/api/journal/search")
def search_journal(q: str = Query("")):
try:
ensure_dir(JOURNAL_DIR)
if not q:
return {"results": []}
results = []
for f in JOURNAL_DIR.glob("*.md"):
content = f.read_text()
if q.lower() in content.lower():
results.append({"date": f.stem, "preview": content[:200]})
return {"results": results, "query": q}
except Exception as e:
return {"results": [], "error": str(e)}
# ─── Routes: Agent Health (3 endpoints) ──────────────────────────
@app.get("/api/agents/health")
def get_agent_health():
try:
agents = []
for name in ["opencode", "hermes", "gemini"]:
info = check_agent(name)
info["uptime"] = 0
info["success_rate"] = 100
info["last_seen"] = get_timestamp()
agents.append(info)
return {"agents": agents, "updated": get_timestamp()}
except Exception as e:
return {"agents": [], "error": str(e), "updated": get_timestamp()}
@app.get("/api/agents/{name}/stats")
def get_agent_stats(name: str):
try:
if name not in ["opencode", "hermes", "gemini"]:
raise HTTPException(400, "Invalid agent")
info = check_agent(name)
return {
"name": name,
"status": info["status"],
"total_runs": 0,
"successful_runs": 0,
"failed_runs": 0,
"avg_response_time": 0,
"last_seen": get_timestamp(),
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/api/agents/health/refresh")
def refresh_agent_health():
try:
agents = []
for name in ["opencode", "hermes", "gemini"]:
info = check_agent(name)
agents.append(info)
append_audit({"action": "agent_health_refreshed"})
return {"agents": agents, "updated": get_timestamp()}
except Exception as e:
return {"agents": [], "error": str(e)}
# ─── Routes: Smart Router (2 endpoints) ─────────────────────────
ROUTER_RULES = {
"opencode": ["code", "devops", "deploy", "git", "file", "terraform", "docker", "test", "build", "infra", "script"],
"hermes": ["memory", "schedule", "channel", "skill", "cron", "reminder", "brain", "plugin", "backup"],
"gemini": ["research", "analyze", "search", "compare", "explain", "study", "learn", "document", "report", "review"],
}
@app.post("/api/router/suggest")
def router_suggest(data: RouterSuggest):
try:
task_lower = data.task.lower()
scores = {}
for agent, keywords in ROUTER_RULES.items():
scores[agent] = sum(1 for k in keywords if k in task_lower)
best = max(scores, key=scores.get)
confidence = "high" if scores[best] >= 2 else "medium" if scores[best] == 1 else "low"
return {
"suggested_agent": best,
"confidence": confidence,
"scores": scores,
"task": data.task,
}
except Exception as e:
return {"suggested_agent": "opencode", "confidence": "low", "error": str(e)}
@app.post("/api/router/route")
def router_route(data: RouterRoute):
try:
agent = data.agent.lower()
if agent not in ["opencode", "hermes", "gemini"]:
return {"status": "error", "message": f"Invalid agent: {agent}"}
append_audit({"action": "task_routed", "agent": agent, "task_preview": data.task[:50]})
return {
"status": "routed",
"agent": agent,
"task": data.task,
"message": f"Task routed to {agent}",
}
except Exception as e:
return {"status": "error", "message": str(e)}
# ─── Routes: Learning Analytics (2 endpoints) ───────────────────
@app.get("/api/analytics/skills")
def get_skill_analytics():
try:
skills_dir = BASE_DIR / "skills"
analytics = []
for d in sorted(skills_dir.iterdir()):
if d.is_dir() and not d.name.startswith("_"):
eval_path = d / "eval.json"
score_path = d / "score-history.json"
scores = json.loads(score_path.read_text()) if score_path.exists() else []
eval_data = json.loads(eval_path.read_text()) if eval_path.exists() else {}
avg_score = sum(s.get("score", 0) for s in scores) / len(scores) if scores else 0
analytics.append({
"name": d.name,
"total_runs": len(scores),
"avg_score": round(avg_score, 1),
"last_score": scores[-1].get("score", 0) if scores else 0,
"trend": "up" if len(scores) >= 2 and scores[-1].get("score", 0) > scores[-2].get("score", 0) else "down" if len(scores) >= 2 else "stable",
})
return {"skills": sorted(analytics, key=lambda x: x["total_runs"], reverse=True)}
except Exception as e:
return {"skills": [], "error": str(e)}
@app.get("/api/analytics/trends")
def get_trend_analytics():
try:
skills_dir = BASE_DIR / "skills"
trends = []
for d in sorted(skills_dir.iterdir()):
if d.is_dir() and not d.name.startswith("_"):
score_path = d / "score-history.json"
scores = json.loads(score_path.read_text()) if score_path.exists() else []
if scores:
trends.append({
"name": d.name,
"scores": [s.get("score", 0) for s in scores[-10:]],
"labels": [s.get("date", "") for s in scores[-10:]],
})
return {"trends": trends}
except Exception as e:
return {"trends": [], "error": str(e)}
# ─── Routes: Session Replay (2 endpoints) ───────────────────────
@app.get("/api/sessions/list")
def list_sessions():
try:
sessions = []
sessions_dir = Path.home() / ".local" / "share" / "opencode"
log_dir = sessions_dir / "log"
if log_dir.exists():
for f in sorted(log_dir.glob("*.log"), reverse=True)[:20]:
sessions.append({
"id": f.stem,
"name": f.stem,
"size": f.stat().st_size,
"modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
"source": "opencode",
})
hermes_sessions = Path.home() / ".hermes" / "sessions.json"
if hermes_sessions.exists():
sessions.append({
"id": "hermes-sessions",
"name": "Hermes Session Archive",
"size": hermes_sessions.stat().st_size,
"modified": datetime.fromtimestamp(hermes_sessions.stat().st_mtime).isoformat(),
"source": "hermes",
})
return {"sessions": sessions}
except Exception as e:
return {"sessions": [], "error": str(e)}
@app.get("/api/sessions/{session_id}/replay")
def get_session_replay(session_id: str):
try:
sessions_dir = Path.home() / ".local" / "share" / "opencode"
log_file = sessions_dir / "log" / f"{session_id}.log"
if log_file.exists():
content = log_file.read_text()
lines = content.split("\n")
messages = []
for line in lines:
if "user:" in line.lower() or "assistant:" in line.lower():
messages.append(line)
return {
"session_id": session_id,
"lines": len(lines),
"messages": messages[:100],
"content": content[:5000],
}
return {"session_id": session_id, "messages": [], "content": "Session log not found"}
except Exception as e:
return {"session_id": session_id, "messages": [], "error": str(e)}
# ─── Routes: Dashboard Static Files ──────────────────────────────
dashboard_dir = BASE_DIR / "dashboard"