Phase 4-6: Message Bus, Memory Search (ChromaDB), Token Tracking, Topology Graph

Phase 4: Message Bus Viewer
- Backend: get_message_bus_status(), send_bus_message() in agents.py
- Route: GET /api/agents/message-bus, POST /api/agents/{id}/bus-message
- Frontend: Message Bus tab in agent detail overlay

Phase 5: Memory Search (ChromaDB)
- Backend: _search_agent_memory(), _search_all_agents_memory() via ChromaDB rose_memory collection
- Route: GET /api/agents/memory/search, GET /api/agents/{id}/memory/search
- Frontend: Search bar added to Memory tab, renders confidence scores + topics

Phase 6: Token Tracking + Topology Graph
- Backend: get_agent_usage() reads ~/.hermes/agents/{id}/usage.json
- Route: GET /api/agents/{id}/usage
- Frontend: Usage tab with today/week/month token counts and cost
- Frontend: Topology tab with SVG radial graph of agent network
This commit is contained in:
Rose
2026-04-20 14:45:11 +02:00
parent 8b8a507ace
commit 00045314f8
5 changed files with 812 additions and 18 deletions

View File

@@ -1919,6 +1919,9 @@ async function openAgentDetail(agentId) {
<button class="agent-tab${_agentTab==='errors'?' active':''}" onclick="switchAgentTab('errors')">Errors</button>
<button class="agent-tab${_agentTab==='chat'?' active':''}" onclick="switchAgentTab('chat')">Chat History</button>
<button class="agent-tab${_agentTab==='tasks'?' active':''}" onclick="switchAgentTab('tasks')">Tasks</button>
<button class="agent-tab${_agentTab==='bus'?' active':''}" onclick="switchAgentTab('bus')">Message Bus</button>
<button class="agent-tab${_agentTab==='usage'?' active':''}" onclick="switchAgentTab('usage')">Usage</button>
<button class="agent-tab${_agentTab==='topology'?' active':''}" onclick="switchAgentTab('topology')">Topology</button>
</div>
<div id="agentTabContent" class="agent-tab-content">
@@ -1938,7 +1941,7 @@ async function switchAgentTab(tab) {
// Update tab buttons
document.querySelectorAll('.agent-tab').forEach((el, i) => {
const tabs = ['overview', 'soul', 'memory', 'inbox', 'activity', 'errors', 'chat', 'tasks'];
const tabs = ['overview', 'soul', 'memory', 'inbox', 'activity', 'errors', 'chat', 'tasks', 'bus', 'usage', 'topology'];
el.classList.toggle('active', tabs[i] === tab);
});
@@ -1969,6 +1972,15 @@ async function switchAgentTab(tab) {
case 'tasks':
await loadAgentTasks(agentId, content);
break;
case 'bus':
await loadAgentBus(agentId, content);
break;
case 'usage':
await loadAgentUsage(agentId, content);
break;
case 'topology':
await loadAgentTopology(agentId, content);
break;
}
}
@@ -2085,20 +2097,21 @@ async function loadAgentSoul(agentId, content) {
async function loadAgentMemory(agentId, content) {
const canEdit = agentId !== 'rose';
// Fetch memory.md + render search bar
try {
const agent = await api(`/api/agents/${agentId}`);
const memory = agent.memory || '';
if (!memory) {
content.innerHTML = `<div style="padding:16px;text-align:center;color:var(--muted);font-size:12px">
<div style="font-size:24px;margin-bottom:8px">🧠</div>
<div>No memory.md found</div>
</div>`;
return;
}
content.innerHTML = `
<div style="padding:0 0 8px;border-bottom:1px solid var(--border);margin-bottom:8px">
<div style="display:flex;gap:6px;align-items:center">
<input id="memSearchInput" placeholder="Search memory... (ChromaDB)" style="flex:1;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 8px;font-size:11px;outline:none" onkeydown="if(event.key==='Enter')searchAgentMemory('${agentId}')">
<button class="cron-btn run" style="flex-shrink:0;padding:5px 10px" onclick="searchAgentMemory('${agentId}')">Search</button>
</div>
<div id="memSearchResults" style="display:none;margin-top:8px"></div>
</div>
<div id="memoryView">
${canEdit ? `<button class="agent-edit-btn" onclick="editAgentMemory('${agentId}')">✏️ Edit</button>` : ''}
<div class="agent-md-content">${renderMarkdown(memory)}</div>
<div class="agent-md-content">${memory ? renderMarkdown(memory) : '<div style="color:var(--muted);font-size:12px;text-align:center;padding:16px">No memory.md found</div>'}</div>
</div>
<div id="memoryEdit" style="display:none">
<textarea id="memoryEditArea" rows="18" style="width:100%;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:8px;padding:10px;font-family:monospace;font-size:11px;line-height:1.6;resize:vertical;outline:none;box-sizing:border-box">${esc(memory)}</textarea>
@@ -2114,6 +2127,40 @@ async function loadAgentMemory(agentId, content) {
}
}
async function searchAgentMemory(agentId) {
const q = document.getElementById('memSearchInput').value.trim();
const resultsBox = document.getElementById('memSearchResults');
if (!q) return;
resultsBox.style.display = '';
resultsBox.innerHTML = `<div style="font-size:10px;color:var(--muted);padding:4px 0">Searching...</div>`;
try {
const endpoint = agentId === 'rose'
? `/api/agents/memory/search?q=${encodeURIComponent(q)}`
: `/api/agents/${agentId}/memory/search?q=${encodeURIComponent(q)}`;
const data = await api(endpoint);
const results = data.results || [];
if (!results.length) {
resultsBox.innerHTML = `<div style="font-size:11px;color:var(--muted);padding:4px 0">No results for "${esc(q)}"</div>`;
return;
}
resultsBox.innerHTML = `
<div style="font-size:10px;color:var(--muted);margin-bottom:6px;text-transform:uppercase;font-weight:600">${results.length} result${results.length!==1?'s':''}</div>
${results.map(r => `
<div style="border:1px solid var(--border);border-radius:8px;padding:8px;margin-bottom:6px;background:rgba(255,255,255,.02)">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:4px">
<span style="font-size:11px;font-weight:600;color:var(--blue)">${esc(r.topic)}</span>
<span style="font-size:9px;color:var(--muted);opacity:.7">${(r.confidence*100).toFixed(0)}%</span>
</div>
<div style="font-size:10px;color:var(--muted);margin-bottom:4px">${r.agent ? 'Agent: '+esc(r.agent)+' · ' : ''}Topic: ${esc(r.topic)}</div>
<div style="font-size:10px;color:var(--text);line-height:1.4;max-height:60px;overflow:hidden">${esc((r.content||'').slice(0,200))}</div>
</div>
`).join('')}
`;
} catch(e) {
resultsBox.innerHTML = `<div style="font-size:11px;color:var(--accent)">Error: ${esc(e.message)}</div>`;
}
}
async function loadAgentInboxTab(agentId, content) {
try {
const data = await api(`/api/agents/${agentId}/inbox`);
@@ -2377,6 +2424,223 @@ async function loadAgentTasks(agentId, content) {
}
}
async function loadAgentBus(agentId, content) {
try {
const data = await api(`/api/agents/message-bus`);
const bus = data.bus || {};
// Collect all messages across agents, filter to those involving agentId
const allMsgs = [];
for (const [aId, aData] of Object.entries(bus)) {
const msgs = aData.messages || [];
for (const m of msgs) {
if (m.from === agentId || m.to === agentId) {
allMsgs.push({ ...m, _agent: aId });
}
}
}
// Sort newest first
allMsgs.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || ''));
if (allMsgs.length === 0) {
content.innerHTML = `
<div style="padding:24px;text-align:center;color:var(--muted);font-size:12px">
<div style="font-size:28px;margin-bottom:8px">🚌</div>
<div>No messages in the bus</div>
<div style="font-size:10px;margin-top:4px;opacity:0.6">Messages between agents appear here</div>
</div>
<div style="padding:12px;border-top:1px solid var(--border);margin-top:8px">
<div style="font-size:10px;color:var(--muted);margin-bottom:6px;text-transform:uppercase;font-weight:600">Send Message via Bus</div>
<input id="busSubject" placeholder="Subject" style="width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 8px;font-size:11px;margin-bottom:4px;outline:none;box-sizing:border-box">
<textarea id="busContent" rows="3" placeholder="Message content..." style="width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 8px;font-size:11px;margin-bottom:4px;resize:none;outline:none;font-family:inherit;box-sizing:border-box"></textarea>
<button class="cron-btn run" style="width:100%" onclick="sendBusMessage('${agentId}')">Send via Bus</button>
</div>`;
return;
}
const rows = allMsgs.map(m => {
const ts = m.timestamp ? new Date(m.timestamp).toLocaleString() : 'N/A';
const rel = m.timestamp ? _relTime(m.timestamp) : '';
const isOutgoing = m.from === agentId;
const dirIcon = isOutgoing ? '📤' : '📥';
const dirLabel = isOutgoing ? `${m.to}` : `${m.from}`;
const typeColor = m.type === 'request' ? '#ff9800' : '#4caf50';
return `
<div class="bus-msg${isOutgoing ? ' outgoing' : ''}">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
<span style="font-size:12px">${dirIcon}</span>
<span style="font-size:9px;font-weight:700;color:${typeColor}">${esc(m.type || '').toUpperCase()}</span>
<span style="font-size:9px;color:var(--muted)">${dirLabel}</span>
<span style="font-size:9px;color:var(--muted);margin-left:auto">${esc(ts)} · ${rel}</span>
</div>
<div style="font-size:11px;font-weight:500;margin-bottom:2px">${esc(m.subject || '(no subject)')}</div>
<div style="font-size:10px;color:var(--muted)">${esc(String(m.content || '').slice(0, 120))}</div>
</div>`;
}).join('');
content.innerHTML = `
<div class="bus-list">${rows}</div>
<div style="padding:12px;border-top:1px solid var(--border);margin-top:8px">
<div style="font-size:10px;color:var(--muted);margin-bottom:6px;text-transform:uppercase;font-weight:600">Send Message via Bus</div>
<input id="busSubject" placeholder="Subject" style="width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 8px;font-size:11px;margin-bottom:4px;outline:none;box-sizing:border-box">
<textarea id="busContent" rows="3" placeholder="Message content..." style="width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 8px;font-size:11px;margin-bottom:4px;resize:none;outline:none;font-family:inherit;box-sizing:border-box"></textarea>
<button class="cron-btn run" style="width:100%" onclick="sendBusMessage('${agentId}')">Send via Bus</button>
</div>`;
} catch(e) {
content.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`;
}
}
async function sendBusMessage(toAgent) {
const subject = document.getElementById('busSubject').value.trim();
const content = document.getElementById('busContent').value.trim();
if (!subject && !content) {
showToast('Please enter a subject or message');
return;
}
try {
const r = await api(`/api/agents/${toAgent}/bus-message`, {
method: 'POST',
body: JSON.stringify({ from_agent: 'rose', subject, content }),
});
if (!r.ok) throw new Error(r.error || 'Send failed');
showToast('Message sent via bus');
document.getElementById('busSubject').value = '';
document.getElementById('busContent').value = '';
// Refresh
await switchAgentTab('bus');
} catch(e) {
showToast('Error: ' + e.message);
}
}
async function loadAgentTopology(agentId, content) {
const agents = [
{ id: 'rose', name: 'rose', emoji: '🌹', tier: 'orchestrator', x: 0, y: 0 },
{ id: 'lotus', name: 'lotus', emoji: '🪷', tier: 'tier2', x: 0, y: -1 },
{ id: 'forget-me-not', name: 'forget-me-not', emoji: '🌼', tier: 'tier2', x: 0.7, y: -0.7 },
{ id: 'sunflower', name: 'sunflower', emoji: '🌻', tier: 'tier2', x: -1, y: 0 },
{ id: 'iris', name: 'iris', emoji: '⚜️', tier: 'tier2', x: 0, y: 1 },
{ id: 'ivy', name: 'ivy', emoji: '🌿', tier: 'tier2', x: -0.7, y: 0.7 },
{ id: 'dandelion', name: 'dandelion', emoji: '🛡️', tier: 'tier2', x: 0.7, y: 0.7 },
{ id: 'root', name: 'root', emoji: '🌳', tier: 'tier2', x: 0, y: 0.7 }
];
const connections = [
{ from: 'rose', to: 'lotus' },
{ from: 'rose', to: 'forget-me-not' },
{ from: 'rose', to: 'sunflower' },
{ from: 'rose', to: 'iris' },
{ from: 'rose', to: 'ivy' },
{ from: 'rose', to: 'dandelion' },
{ from: 'rose', to: 'root' },
{ from: 'lotus', to: 'forget-me-not' },
{ from: 'sunflower', to: 'lotus' },
{ from: 'iris', to: 'rose' },
{ from: 'ivy', to: 'rose' },
{ from: 'dandelion', to: 'rose' },
{ from: 'root', to: 'rose' }
];
const scale = 80;
let svg = '<svg viewBox="-200 -160 400 320" style="width:100%;max-width:500px;display:block;margin:0 auto;background:transparent">';
svg += '<defs><filter id="glow"><feGaussianBlur stdDeviation="3" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>';
connections.forEach(conn => {
const from = agents.find(a => a.id === conn.from);
const to = agents.find(a => a.id === conn.to);
if (from && to) {
svg += '<line x1="' + (from.x * scale) + '" y1="' + (from.y * scale) + '" x2="' + (to.x * scale) + '" y2="' + (to.y * scale) + '" stroke="rgba(255,255,255,0.15)" stroke-width="1.5"/>';
}
});
agents.forEach(agent => {
const px = agent.x * scale;
const py = agent.y * scale;
const isRose = agent.tier === 'orchestrator';
const color = isRose ? '#F5C542' : '#5B8FA8';
const r = 28;
const isActive = agentId === agent.id;
if (isActive) {
svg += '<circle cx="' + px + '" cy="' + py + '" r="' + (r + 8) + '" fill="' + color + '" opacity="0.3"><animate attributeName="r" values="' + (r+8) + ';' + (r+14) + ';' + (r+8) + '" dur="2s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.3;0.15;0.3" dur="2s" repeatCount="indefinite"/></circle>';
}
svg += '<circle cx="' + px + '" cy="' + py + '" r="' + r + '" fill="' + color + '" stroke="' + (isActive ? '#fff' : 'rgba(255,255,255,0.3)') + '" stroke-width="' + (isActive ? 2 : 1) + '" filter="' + (isActive ? 'url(#glow)' : '') + '"/>';
svg += '<text x="' + px + '" y="' + (py + 5) + '" text-anchor="middle" font-size="16">' + agent.emoji + '</text>';
svg += '<text x="' + px + '" y="' + (py + r + 14) + '" text-anchor="middle" font-size="9" fill="rgba(255,255,255,0.7)" font-family="system-ui,sans-serif">' + agent.name + '</text>';
});
svg += '</svg>';
content.innerHTML = '<div class="topology-view">' + svg + '</div>';
}
async function loadAgentUsage(agentId, content) {
try {
const data = await api(`/api/agents/${agentId}/usage`);
if (data.error) {
content.innerHTML = `<div style="padding:24px;text-align:center;color:var(--muted);font-size:12px">${esc(data.error)}</div>`;
return;
}
const fmt = (n) => n.toLocaleString();
const fmtCost = (c) => '$' + c.toFixed(4);
const historyRows = (data.history || []).slice(0, 14).map(h => `
<tr>
<td style="font-size:10px;padding:4px 8px">${esc(h.date)}</td>
<td style="font-size:10px;padding:4px 8px;text-align:right">${fmt(h.total_tokens || 0)}</td>
<td style="font-size:10px;padding:4px 8px;text-align:right">${fmt(h.prompt_tokens || 0)}</td>
<td style="font-size:10px;padding:4px 8px;text-align:right">${fmt(h.completion_tokens || 0)}</td>
<td style="font-size:10px;padding:4px 8px;text-align:right;color:#4caf50">${fmtCost(h.cost_usd || 0)}</td>
</tr>`).join('');
content.innerHTML = `
<div style="padding:16px">
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px">
<div style="background:var(--card-bg);border:1px solid var(--border);border-radius:10px;padding:14px;text-align:center">
<div style="font-size:9px;text-transform:uppercase;color:var(--muted);margin-bottom:6px;font-weight:600">Today</div>
<div style="font-size:20px;font-weight:700;color:var(--text)">${fmt(data.today.tokens)}</div>
<div style="font-size:11px;color:#4caf50;margin-top:2px">${fmtCost(data.today.cost)}</div>
</div>
<div style="background:var(--card-bg);border:1px solid var(--border);border-radius:10px;padding:14px;text-align:center">
<div style="font-size:9px;text-transform:uppercase;color:var(--muted);margin-bottom:6px;font-weight:600">This Week</div>
<div style="font-size:20px;font-weight:700;color:var(--text)">${fmt(data.week.tokens)}</div>
<div style="font-size:11px;color:#4caf50;margin-top:2px">${fmtCost(data.week.cost)}</div>
</div>
<div style="background:var(--card-bg);border:1px solid var(--border);border-radius:10px;padding:14px;text-align:center">
<div style="font-size:9px;text-transform:uppercase;color:var(--muted);margin-bottom:6px;font-weight:600">This Month</div>
<div style="font-size:20px;font-weight:700;color:var(--text)">${fmt(data.month.tokens)}</div>
<div style="font-size:11px;color:#4caf50;margin-top:2px">${fmtCost(data.month.cost)}</div>
</div>
</div>
${historyRows ? `
<div style="font-size:10px;font-weight:600;color:var(--muted);text-transform:uppercase;margin-bottom:8px">Recent History</div>
<table style="width:100%;border-collapse:collapse">
<thead>
<tr style="border-bottom:1px solid var(--border)">
<th style="text-align:left;font-size:9px;padding:4px 8px;color:var(--muted)">Date</th>
<th style="text-align:right;font-size:9px;padding:4px 8px;color:var(--muted)">Total</th>
<th style="text-align:right;font-size:9px;padding:4px 8px;color:var(--muted)">Prompt</th>
<th style="text-align:right;font-size:9px;padding:4px 8px;color:var(--muted)">Completion</th>
<th style="text-align:right;font-size:9px;padding:4px 8px;color:var(--muted)">Cost</th>
</tr>
</thead>
<tbody>${historyRows}</tbody>
</table>` : `
<div style="text-align:center;padding:24px;color:var(--muted);font-size:12px">
<div style="font-size:28px;margin-bottom:8px">📊</div>
<div>No usage data recorded yet</div>
<div style="font-size:10px;margin-top:4px;opacity:0.6">Token usage will appear here once recorded</div>
</div>`}
</div>`;
} catch(e) {
content.innerHTML = `<div style="padding:24px;text-align:center;color:#e94560;font-size:12px">Failed to load usage: ${esc(e.message)}</div>`;
}
}
// Edit handlers
function editAgentSoul(agentId) {
document.getElementById('soulView').style.display = 'none';
@@ -2575,21 +2839,53 @@ function _applyLogFilter() {
}
// Filter by search
if (_currentLogSearch) {
const q = _currentLogSearch.toLowerCase();
lines = lines.filter(line => line.toLowerCase().includes(q));
const searchLower = _currentLogSearch ? _currentLogSearch.toLowerCase() : '';
if (searchLower) {
lines = lines.filter(line => line.toLowerCase().includes(searchLower));
}
// Render
const html = esc(lines.join('\n')) || '<span style="color:var(--muted)">(no matches)</span>';
$('logsContent').innerHTML = html;
// Match count
// Render with highlighting
const total = _currentLogContent.split('\n').length;
const shown = lines.length;
$('logsMatchCount').textContent = _currentLogSearch || _currentLogLevel !== 'all'
$('logsMatchCount').textContent = searchLower || _currentLogLevel !== 'all'
? `${shown} of ${total} lines shown`
: `${total} lines`;
if (lines.length === 0) {
$('logsContent').innerHTML = '<span style="color:var(--muted)">(no matches)</span>';
return;
}
// Escape first, then highlight search terms
const highlighted = lines.map((line, i) => {
const escaped = esc(line) || '&nbsp;';
if (searchLower) {
// Highlight all occurrences of search term (case-insensitive, preserves case in display)
const regex = new RegExp(escRegex(_currentLogSearch), 'gi');
return `<span class="log-line" data-idx="${i}">${escaped.replace(regex, m => `<mark class="log-highlight">${m}</mark>`)}</span>`;
}
return `<span class="log-line" data-idx="${i}">${escaped}</span>`;
}).join('\n');
$('logsContent').innerHTML = highlighted;
// Auto-scroll to first match when searching
if (searchLower) {
const pre = $('logsContent');
const first = pre.querySelector('.log-highlight');
if (first) {
first.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
} else {
// Normal: scroll to bottom (newest)
const pre = $('logsContent');
pre.scrollTop = pre.scrollHeight;
}
}
// Escape special regex characters
function escRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function filterLogContent() {