From 717ec85607af5945724213c33cfe2292376d5fbc Mon Sep 17 00:00:00 2001 From: modimihir07 Date: Fri, 5 Jun 2026 15:22:40 +0530 Subject: [PATCH] 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 --- brain/active-projects.md | 2 + brain/journal/2026-06-05.md | 2 + dashboard/api.js | 42 ++ dashboard/index.html | 9 + dashboard/pages/agent-health.js | 115 +++++ dashboard/pages/goals.js | 176 ++++++++ dashboard/pages/journal.js | 165 ++++++++ dashboard/pages/kanban.js | 290 +++++++++++++ dashboard/pages/learning-analytics.js | 103 +++++ dashboard/pages/session-replay.js | 87 ++++ dashboard/pages/smart-router.js | 118 ++++++ dashboard/styles.css | 543 ++++++++++++++++++++++++ dashboard/utils.js | 7 + data/goals.json | 13 + data/kanban/0f822987.json | 12 + data/kanban/8893ad14.json | 15 + server.py | 579 ++++++++++++++++++++++++++ 17 files changed, 2278 insertions(+) create mode 100644 brain/journal/2026-06-05.md create mode 100644 dashboard/pages/agent-health.js create mode 100644 dashboard/pages/goals.js create mode 100644 dashboard/pages/journal.js create mode 100644 dashboard/pages/kanban.js create mode 100644 dashboard/pages/learning-analytics.js create mode 100644 dashboard/pages/session-replay.js create mode 100644 dashboard/pages/smart-router.js create mode 100644 data/goals.json create mode 100644 data/kanban/0f822987.json create mode 100644 data/kanban/8893ad14.json diff --git a/brain/active-projects.md b/brain/active-projects.md index acc328c..528f951 100644 --- a/brain/active-projects.md +++ b/brain/active-projects.md @@ -20,3 +20,5 @@ ## AgriAssist AI - Status: On hold - Hackathon project (slides, MVP, pitch strategy) + +- [Test goal](goal:c1c992ef) โ€” diff --git a/brain/journal/2026-06-05.md b/brain/journal/2026-06-05.md new file mode 100644 index 0000000..181cb85 --- /dev/null +++ b/brain/journal/2026-06-05.md @@ -0,0 +1,2 @@ +# Test Journal Entry +This is a test entry. \ No newline at end of file diff --git a/dashboard/api.js b/dashboard/api.js index a92c99c..bdb2284 100644 --- a/dashboard/api.js +++ b/dashboard/api.js @@ -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`), }; diff --git a/dashboard/index.html b/dashboard/index.html index 59c1e48..4f7899e 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -39,6 +39,15 @@ ๐Ÿง Memory โฑScheduler ๐Ÿ“‹Audit + + ๐Ÿ“ŒKanban Board + ๐ŸŽฏGoals + ๐Ÿ““Journal + + ๐ŸฅAgent Health0 + ๐ŸงญSmart Router + ๐Ÿ“ŠLearning Analytics + ๐Ÿ”„Session Replay ๐Ÿ’ฐCost Analytics ๐Ÿ”ŒPlugins diff --git a/dashboard/pages/agent-health.js b/dashboard/pages/agent-health.js new file mode 100644 index 0000000..8cd65ed --- /dev/null +++ b/dashboard/pages/agent-health.js @@ -0,0 +1,115 @@ +let agentHealthInterval = null; + +async function renderAgentHealth() { + const content = document.getElementById('pageContent'); + content.innerHTML = ` + +
+
+
+
+
+
Health Overview
+
+
Loading health data...
+
+ `; + 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 => ` +
+
+ ${agentIcons[a.name] || '๐Ÿค–'} +
+
+
${a.name}
+
+ + ${a.status} +
+
+
+
${a.status === 'online' ? '100' : '0'}%
+
Uptime
+
+
+
${a.status === 'online' ? 'โœ“' : 'โœ—'}
+
Reachable
+
+
+
${new Date(data.updated).toLocaleTimeString()}
+
Last Check
+
+
+
+
+ `).join(''); + const overview = document.getElementById('healthOverviewCard'); + if (overview) { + const online = agents.filter(a => a.status === 'online').length; + const total = agents.length; + overview.innerHTML = ` +
+
+
System Status
+
+ ${online}/${total} agents online ยท Last updated: ${new Date(data.updated).toLocaleTimeString()} +
+
+
+ + ${online === total ? 'All Online' : online > 0 ? 'Partial' : 'Offline'} +
+
+ `; + } + } catch (err) { + const cards = document.getElementById('agentHealthCards'); + if (cards) cards.innerHTML = `
โš 
Failed to load health data
${escapeHtml(err.message)}
`; + } +} diff --git a/dashboard/pages/goals.js b/dashboard/pages/goals.js new file mode 100644 index 0000000..30a7ea6 --- /dev/null +++ b/dashboard/pages/goals.js @@ -0,0 +1,176 @@ +async function renderGoals() { + const content = document.getElementById('pageContent'); + content.innerHTML = ` + +
+
+
0
+
Total Goals
+
+
+
0
+
Active
+
+
+
0
+
Completed
+
+
+
0%
+
Avg Progress
+
+
+
+ `; + 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 = `
๐ŸŽฏ
No goals yet
Create your first goal to start tracking progress
`; + 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 => ` +
+
+ ${g.status} + ${g.category} + ${g.target_date ? `๐ŸŽฏ ${g.target_date}` : ''} +
+
${escapeHtml(g.title)}
+ ${g.description ? `
${escapeHtml(g.description)}
` : ''} +
+
+
${g.progress || 0}%
+
+
+ + ${g.status !== 'completed' ? `` : ''} + +
+
+ `).join(''); + } catch (err) { + showToast('Failed to load goals: ' + err.message, 'error'); + } +} + +function showCreateGoalModal() { + const modal = document.getElementById('modalContainer'); + modal.innerHTML = ` + + `; +} + +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'); + } +} diff --git a/dashboard/pages/journal.js b/dashboard/pages/journal.js new file mode 100644 index 0000000..1f464da --- /dev/null +++ b/dashboard/pages/journal.js @@ -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 = ` + +
+
+
0
+
Total Entries
+
+
+
0
+
Words Today
+
+
+
0
+
Day Streak
+
+
+
+
+ + +
+
+
+
+
${formatDateDisplay(today)}
+
+ Auto-save on +
+
+ +
+
+ `; + 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 = `
๐Ÿ““
No journal entries yet
Write your first entry above
`; + return; + } + list.innerHTML = ` +
Recent Entries
+
+ ${entries.slice(0, 30).map(e => ` + + `).join('')} +
+ `; + } 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 = `
๐Ÿ”
No results for "${escapeHtml(q)}"
`; + return; + } + list.innerHTML = ` +
Results for "${escapeHtml(q)}"
+ ${results.map(r => ` +
+
${r.date}
+
${escapeHtml(r.preview)}
+
+ `).join('')} + `; + } catch (err) { + showToast('Search failed: ' + err.message, 'error'); + } +} diff --git a/dashboard/pages/kanban.js b/dashboard/pages/kanban.js new file mode 100644 index 0000000..fee2bb3 --- /dev/null +++ b/dashboard/pages/kanban.js @@ -0,0 +1,290 @@ +let kanbanData = null; + +async function renderKanban() { + const content = document.getElementById('pageContent'); + content.innerHTML = ` + +
+ + + +
+
+
+
+ `; + 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 = `
๐Ÿ“‹
Failed to load kanban
${escapeHtml(err.message)}
`; + } +} + +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 ` +
+
+
+ ${columnIcons[col] || '๐Ÿ“Œ'} + ${columnLabels[col] || col} + ${colTasks.length} +
+
+
+ ${colTasks.length === 0 ? `
No tasks
` : + colTasks.map(t => ` +
+
+ ${t.priority || 'medium'} +
+
${escapeHtml(t.title)}
+ ${t.body ? `
${escapeHtml(t.body.substring(0, 80))}${t.body.length > 80 ? '...' : ''}
` : ''} +
+ ${t.assignee ? `๐Ÿ‘ค ${escapeHtml(t.assignee)}` : ''} +
+ ${t.status === 'blocked' ? `
๐Ÿšซ Blocked
` : ''} +
+ `).join('')} +
+
+ `; + }).join(''); +} + +function showAddKanbanTask() { + const modal = document.getElementById('modalContainer'); + modal.innerHTML = ` + + `; +} + +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 = ` + + `; +} + +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(); +} diff --git a/dashboard/pages/learning-analytics.js b/dashboard/pages/learning-analytics.js new file mode 100644 index 0000000..88191d8 --- /dev/null +++ b/dashboard/pages/learning-analytics.js @@ -0,0 +1,103 @@ +async function renderLearningAnalytics() { + const content = document.getElementById('pageContent'); + content.innerHTML = ` + +
Skill Scores
+
+
+
+
+
+
Score Trends
+
+
Skill Details
+
+ `; + 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 = `
๐Ÿ“Š
No skill data yet
Skill evaluations will appear here as they accumulate
`; + } 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 ` +
+
${escapeHtml(s.name)}
+
${s.evals || 0} evaluations
+
+
+
+
+ ${pct}% +
+
+ `; + }).join(''); + } + } + const trendSection = document.getElementById('trendCharts'); + if (trendSection) { + const trendEntries = Object.entries(trends).slice(0, 4); + if (trendEntries.length === 0) { + trendSection.innerHTML = `
๐Ÿ“ˆ
No trend data
Trends will appear after multiple evaluations
`; + } else { + trendSection.innerHTML = trendEntries.map(([name, history]) => { + const vals = (history || []).slice(-10); + const maxVal = Math.max(...vals, 0.01); + const chartHeight = 120; + return ` +
+
${escapeHtml(name)}
+
+ ${vals.map((v, i) => { + const h = (v / maxVal) * (chartHeight - 25); + return `
`; + }).join('')} +
+
+ ${vals.length} data points + Avg: ${(vals.reduce((a, b) => a + b, 0) / vals.length * 100).toFixed(0)}% +
+
+ `; + }).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 ` +
+
+
+
${escapeHtml(s.name)}
+
${s.evals || 0} evaluations ยท Best: ${((s.best || 0) * 100).toFixed(0)}%
+
+ ${pct}% +
+
+ `; + }).join(''); + } + } + } catch (err) { + showToast('Failed to load analytics: ' + err.message, 'error'); + } +} diff --git a/dashboard/pages/session-replay.js b/dashboard/pages/session-replay.js new file mode 100644 index 0000000..ee90e89 --- /dev/null +++ b/dashboard/pages/session-replay.js @@ -0,0 +1,87 @@ +async function renderSessionReplay() { + const content = document.getElementById('pageContent'); + content.innerHTML = ` + +
+
Loading sessions...
+
+
+ `; + try { + const data = await api.listSessions(); + const sessions = data.sessions || []; + const list = document.getElementById('sessionList'); + if (sessions.length === 0) { + list.innerHTML = `
๐ŸŽฌ
No sessions found
Sessions from opencode will appear here
`; + return; + } + list.innerHTML = ` +
+ + + ${sessions.slice(0, 50).map(s => ` + + + + + + + `).join('')} +
Session IDDateSize
${escapeHtml(s.id)}${new Date(s.date).toLocaleString()}${formatSize(s.size || 0)}
+ ${sessions.length > 50 ? `
Showing 50 of ${sessions.length} sessions
` : ''} +
+ `; + } catch (err) { + document.getElementById('sessionList').innerHTML = `
โš 
Failed to load sessions
${escapeHtml(err.message)}
`; + } +} + +async function replaySession(id) { + const detail = document.getElementById('sessionDetail'); + detail.innerHTML = `
Loading session ${escapeHtml(id)}...
`; + try { + const data = await api.getSessionReplay(id); + const messages = data.messages || []; + const session = data.session || {}; + detail.innerHTML = ` +
+
+
+
Session: ${escapeHtml(id)}
+
${messages.length} messages ยท ${new Date(session.created_at || Date.now()).toLocaleString()}
+
+ +
+
+
+ ${messages.map(m => { + const isUser = m.role === 'user' || m.role === 'human'; + return ` +
+
${isUser ? '๐Ÿ‘ค You' : '๐Ÿค– Assistant'}
+
${escapeHtml((m.content || '').substring(0, 500))}${(m.content || '').length > 500 ? '...' : ''}
+
${new Date(m.timestamp || Date.now()).toLocaleTimeString()}
+
+ `; + }).join('')} + ${messages.length === 0 ? '
Empty session
' : ''} +
+ `; + } catch (err) { + detail.innerHTML = `
โš  Failed to replay session: ${escapeHtml(err.message)}
`; + } +} + +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'; +} diff --git a/dashboard/pages/smart-router.js b/dashboard/pages/smart-router.js new file mode 100644 index 0000000..ba8e319 --- /dev/null +++ b/dashboard/pages/smart-router.js @@ -0,0 +1,118 @@ +async function renderSmartRouter() { + const content = document.getElementById('pageContent'); + content.innerHTML = ` + +
+
+
Route a Task
+
+
+ + +
+
+
+ + +
+ + +
+
+
+
Routing Rules
+
+ + + + + +
AgentBest ForKeywords
๐Ÿ”ง opencodeCode, DevOps, infra, git, file operationscode, deploy, git, terraform, docker, test, build, script
โšก HermesMemory, scheduling, messaging, skillsmemory, schedule, cron, reminder, brain, plugin, backup
๐Ÿง  Gemini CLIResearch, analysis, study, document, reviewresearch, analyze, search, explain, study, learn, report
+
+ `; +} + +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 = ` +
+
+
+
+ ${agentIcons[data.suggested_agent] || '๐Ÿค–'} ${data.suggested_agent} +
+
+ Confidence: ${data.confidence} + ${data.confidence === 'high' ? 'โœ…' : data.confidence === 'medium' ? 'โš ๏ธ' : 'โ“'} +
+
+
+ Best Match +
+
+
+ ${Object.entries(data.scores || {}).map(([agent, score]) => ` + + ${agentIcons[agent] || '๐Ÿค–'} ${agent}: ${score} + + `).join('')} +
+
+ `; + 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 += ` +
+
+ โœ… +
+
Task Routed
+
${data.message}
+
+
+
+ `; + } catch (err) { + showToast('Routing failed: ' + err.message, 'error'); + } finally { + if (btn) { btn.disabled = false; btn.textContent = '๐Ÿš€ Route Task'; } + } +} diff --git a/dashboard/styles.css b/dashboard/styles.css index fdb9cfe..54c27f9 100644 --- a/dashboard/styles.css +++ b/dashboard/styles.css @@ -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; } +} diff --git a/dashboard/utils.js b/dashboard/utils.js index ba83422..393f11e 100644 --- a/dashboard/utils.js +++ b/dashboard/utils.js @@ -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' }, }; diff --git a/data/goals.json b/data/goals.json new file mode 100644 index 0000000..3f7702f --- /dev/null +++ b/data/goals.json @@ -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" + } +] \ No newline at end of file diff --git a/data/kanban/0f822987.json b/data/kanban/0f822987.json new file mode 100644 index 0000000..611d8f1 --- /dev/null +++ b/data/kanban/0f822987.json @@ -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" +} \ No newline at end of file diff --git a/data/kanban/8893ad14.json b/data/kanban/8893ad14.json new file mode 100644 index 0000000..18adcf7 --- /dev/null +++ b/data/kanban/8893ad14.json @@ -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" +} \ No newline at end of file diff --git a/server.py b/server.py index fbfc6f4..a3da146 100644 --- a/server.py +++ b/server.py @@ -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"