diff --git a/dashboard/api.js b/dashboard/api.js
index 599ac22..092a5a7 100644
--- a/dashboard/api.js
+++ b/dashboard/api.js
@@ -91,6 +91,8 @@ const api = {
// Agent Time Monitor
getAgentTime: (recompute = false) => api.get(`/api/agent-time${recompute ? '?recompute=true' : ''}`),
recomputeAgentTime: () => api.post('/api/agent-time/recompute', {}),
+ // Agent Insights (AI usage analytics)
+ getAgentInsights: (recompute = false) => api.get(`/api/agent-insights${recompute ? '?recompute=true' : ''}`),
// Agent Registry
getAgents: () => api.get('/api/agents'),
registerAgent: (data) => api.post('/api/agents/register', data),
diff --git a/dashboard/index.html b/dashboard/index.html
index 2985fd0..da7abd9 100644
--- a/dashboard/index.html
+++ b/dashboard/index.html
@@ -49,6 +49,7 @@
๐ฅAgent Health0
๐งญSmart Router
๐Learning Analytics
+ ๐ง Agent Insights
๐Session Replay
โฑAgent Time
diff --git a/dashboard/pages/agent-insights.js b/dashboard/pages/agent-insights.js
new file mode 100644
index 0000000..a3d5199
--- /dev/null
+++ b/dashboard/pages/agent-insights.js
@@ -0,0 +1,193 @@
+// Agent Insights โ live AI-agent usage analytics.
+// Answers: who I chat with most, who spends the most time, who's best
+// suited for what, and which models I use per agent. All derived from
+// real data via /api/agent-insights.
+
+function fmtDuration(sec) {
+ sec = Math.max(0, Math.round(sec || 0));
+ const h = Math.floor(sec / 3600);
+ const m = Math.floor((sec % 3600) / 60);
+ const s = sec % 60;
+ if (h > 0) return `${h}h ${m}m`;
+ if (m > 0) return `${m}m ${s}s`;
+ return `${s}s`;
+}
+
+function agentColor(agent) {
+ const palette = {
+ opencode: '#6c5ce7', hermes: '#00b894', gemini: '#0984e3',
+ jarvis: '#fd79a8', kilocode: '#e17055', codex: '#fdcb6e',
+ system: '#636e72', test: '#b2bec3',
+ };
+ return palette[agent] || '#74b9ff';
+}
+
+let _charts = {};
+function destroyCharts() {
+ Object.values(_charts).forEach(c => { try { c.destroy(); } catch (e) {} });
+ _charts = {};
+}
+
+async function renderAgentInsights() {
+ const content = document.getElementById('pageContent');
+ content.innerHTML = `
+
+
+ `;
+
+ let data;
+ try {
+ data = await api.getAgentInsights();
+ } catch (err) {
+ document.getElementById('insightsBody').innerHTML = `
+ โ
+
Could not load insights
+
${escapeHtml(err.message)}
`;
+ return;
+ }
+
+ const t = data.totals || {};
+ const pa = data.per_agent || {};
+ const body = document.getElementById('insightsBody');
+
+ body.innerHTML = `
+
+
๐ฌ
${(t.total_user_turns||0).toLocaleString()}
Your chat turns
+
โฑ
${fmtDuration(t.total_time_seconds)}
Total agent time
+
๐ค
${t.agents_observed||0}
Agents observed
+
๐ฐ
$${(t.total_cost||0).toFixed(4)}
Tracked cost
+
+
+
+
+
+
+
+
+
+
+ Generated ${formatDate(data.generated_at)} ยท data: chat-history, cost-history, agent-time, agent-registry, router-keywords
+ `;
+
+ // Charts
+ const chats = (data.most_chatted || []).slice(0, 8);
+ const times = (data.most_time || []).slice(0, 8);
+
+ _charts.chats = new Chart(document.getElementById('chatsChart'), {
+ type: 'bar',
+ data: {
+ labels: chats.map(c => c.display_name || c.agent),
+ datasets: [{
+ label: 'Your chat turns',
+ data: chats.map(c => c.user_turns),
+ backgroundColor: chats.map(c => agentColor(c.agent)),
+ borderRadius: 6,
+ }],
+ },
+ options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
+ });
+
+ _charts.time = new Chart(document.getElementById('timeChart'), {
+ type: 'bar',
+ data: {
+ labels: times.map(c => c.display_name || c.agent),
+ datasets: [{
+ label: 'Time spent',
+ data: times.map(c => Math.round(c.time_seconds / 60)),
+ backgroundColor: times.map(c => agentColor(c.agent)),
+ borderRadius: 6,
+ }],
+ },
+ options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: (ctx) => `${ctx.parsed.y} min` } } }, scales: { y: { beginAtZero: true, title: { display: true, text: 'minutes' } } } },
+ });
+
+ // Suite cards
+ const suiteGrid = document.getElementById('suiteGrid');
+ const suiteAgents = Object.values(pa)
+ .filter(a => (a.suite && (a.suite.description || a.suite.keywords?.length)))
+ .sort((a, b) => (b.user_turns + b.time_seconds / 60) - (a.user_turns + a.time_seconds / 60));
+ if (suiteAgents.length === 0) {
+ suiteGrid.innerHTML = 'No role data available yet.
';
+ } else {
+ suiteGrid.innerHTML = suiteAgents.map(a => {
+ const s = a.suite || {};
+ const kws = (s.keywords || []).slice(0, 8).map(k => `${escapeHtml(k)}`).join('');
+ return `
+
+
+ ${escapeHtml(a.display_name || a.agent)}
+ ${a.user_turns} chats ยท ${fmtDuration(a.time_seconds)}
+
+
${escapeHtml(s.description || s.role_hint || 'โ')}
+
${kws || 'no keywords'}
+
`;
+ }).join('');
+ }
+
+ // Models per agent
+ const modelsRows = Object.values(pa)
+ .filter(a => a.top_models && a.top_models.length)
+ .sort((a, b) => b.tokens - a.tokens);
+ document.getElementById('modelsTable').innerHTML = modelsRows.length
+ ? `
+ | Agent | Top models (by uses) | Tokens | Cost |
+ ${modelsRows.map(a => `
+
+ | ${escapeHtml(a.display_name || a.agent)} |
+ ${a.top_models.slice(0, 4).map(([m, c]) => `${escapeHtml(m)} ร${c}`).join(' ')} |
+ ${(a.tokens || 0).toLocaleString()} |
+ $${(a.cost || 0).toFixed(4)} |
+
`).join('')}
+
`
+ : 'No cost/model data recorded yet.
';
+
+ // Full breakdown
+ const rows = Object.values(pa).sort((a, b) => b.user_turns - a.user_turns);
+ document.getElementById('breakdownTable').innerHTML = `
+
+ | Agent | Chat turns | Msgs | Time | Sessions | Top model | First seen | Last seen |
+ ${rows.map(a => `
+
+ | ${escapeHtml(a.display_name || a.agent)} |
+ ${a.user_turns} |
+ ${a.chat_messages} |
+ ${fmtDuration(a.time_seconds)} |
+ ${a.sessions} |
+ ${a.top_models && a.top_models.length ? escapeHtml(a.top_models[0][0]) : 'โ'} |
+ ${a.first_seen ? formatDate(a.first_seen) : 'โ'} |
+ ${a.last_seen ? formatDate(a.last_seen) : 'โ'} |
+
`).join('')}
+
`;
+}
+
+async function recomputeAndRenderInsights() {
+ try { await api.recomputeAgentTime(); } catch (e) {}
+ await renderAgentInsights();
+}
diff --git a/dashboard/utils.js b/dashboard/utils.js
index b3afcc5..da28eec 100644
--- a/dashboard/utils.js
+++ b/dashboard/utils.js
@@ -117,6 +117,7 @@ const PAGE_TITLES = {
scheduler: { title: 'Scheduler', breadcrumb: 'Automated workflows' },
audit: { title: 'Audit Log', breadcrumb: 'System activity trail' },
cost: { title: 'Cost Analytics', breadcrumb: 'Usage & spending' },
+ 'agent-insights': { title: 'Agent Insights', breadcrumb: 'AI usage analytics' },
plugins: { title: 'Plugin Registry', breadcrumb: 'Manage plugins' },
backups: { title: 'Backups', breadcrumb: 'Disaster recovery' },
prompts: { title: 'Prompt Library', breadcrumb: 'Reusable templates' },
diff --git a/server.py b/server.py
index 494085d..ea84839 100644
--- a/server.py
+++ b/server.py
@@ -2341,6 +2341,174 @@ def agent_time_skin():
body = f"{report.get('total_human', '0h 0m 0s')}\n{total} {per}"
return Response(content=body, media_type="text/plain; charset=utf-8")
+
+# โโโ Routes: Agent Insights (AI usage analytics) โโโโโโโโโโโโโโโโโโ
+# Aggregates real usage data across chat history, cost history, derived
+# agent-time, the agent registry, and router keywords to answer:
+# - which agent do I chat with most
+# - which agent spends the most time completing tasks
+# - which agent seems best suited for what
+# - which models I use most with each agent
+# All source data is read-only; no writes happen on this endpoint.
+
+ROUTER_KEYWORDS_FILE = BASE_DIR / "data" / "router-keywords.json"
+
+# Human-readable role hints keyed by agent name (falls back to registry
+# description / router keywords when unknown).
+AGENT_ROLE_HINTS = {
+ "opencode": "Code generation, file ops, DevOps/infra, git, software engineering",
+ "hermes": "Persistent memory, cron scheduling, messaging channels, skill hub, coordination",
+ "gemini": "Web research, multi-modal/image/PDF analysis, reasoning, data analysis",
+ "jarvis": "Local-first personal AI: deep research, knowledge, memory, general reasoning",
+ "kilocode": "AI coding assistant: implement, refactor, programming tasks",
+ "codex": "OpenAI Codex: coding, debugging, tests, builds",
+}
+
+
+@app.get("/api/agent-insights")
+def get_agent_insights(recompute: bool = False):
+ # Pull the four required datasets from disk.
+ chat = load_chat_history().get("messages", [])
+ cost = read_json(
+ BASE_DIR / "data" / "cost-history.json",
+ {"entries": []},
+ ).get("entries", [])
+ registry = load_agent_registry()
+ router_kw = read_json(ROUTER_KEYWORDS_FILE, {})
+
+ if recompute or not AGENT_TIME_REPORT.exists():
+ time_report = _recompute_agent_time()
+ else:
+ try:
+ time_report = json.loads(AGENT_TIME_REPORT.read_text(encoding="utf-8"))
+ except Exception:
+ time_report = _recompute_agent_time()
+ time_agents = time_report.get("agents", {})
+
+ # 1) Chat volume per agent (count user+assistant messages; pair count
+ # = number of user turns, which is the most meaningful "chat" metric).
+ chat_counts = {}
+ user_turns = {}
+ first_chat = {}
+ last_chat = {}
+ for m in chat:
+ a = m.get("agent") or "unknown"
+ chat_counts[a] = chat_counts.get(a, 0) + 1
+ if m.get("role") == "user":
+ user_turns[a] = user_turns.get(a, 0) + 1
+ ts = m.get("timestamp")
+ if ts:
+ if a not in first_chat or ts < first_chat[a]:
+ first_chat[a] = ts
+ if a not in last_chat or ts > last_chat[a]:
+ last_chat[a] = ts
+
+ # 2) Time per agent (from derived agent-time report).
+ time_per_agent = {
+ a: {
+ "total_seconds": d.get("total_seconds", 0),
+ "sessions": d.get("sessions", 0),
+ "touches": d.get("touches", 0),
+ }
+ for a, d in time_agents.items()
+ }
+
+ # 3) Models used per agent (from cost history).
+ models_per_agent = {}
+ tokens_per_agent = {}
+ cost_per_agent = {}
+ for e in cost:
+ a = e.get("agent") or "unknown"
+ model = e.get("model") or "unknown"
+ models_per_agent.setdefault(a, {})
+ models_per_agent[a][model] = models_per_agent[a].get(model, 0) + 1
+ tokens_per_agent[a] = tokens_per_agent.get(a, 0) + (e.get("tokens", 0) or 0)
+ cost_per_agent[a] = cost_per_agent.get(a, 0) + (e.get("cost", 0) or 0)
+
+ # 4) Best-suited-for: combine registry description, router keywords,
+ # and AGENTS.md role hints into a per-agent "suite" summary.
+ suite = {}
+ for name in set(list(registry.keys()) + list(router_kw.keys()) +
+ list(chat_counts.keys()) + list(time_per_agent.keys())):
+ desc = (registry.get(name, {}) or {}).get("description", "")
+ role = AGENT_ROLE_HINTS.get(name, "")
+ kws = router_kw.get(name, [])
+ suite[name] = {
+ "description": desc or role,
+ "role_hint": role,
+ "keywords": kws,
+ }
+
+ # Build a per-agent consolidated view.
+ all_agents = set(
+ list(chat_counts.keys()) + list(time_per_agent.keys())
+ + list(models_per_agent.keys()) + list(suite.keys())
+ )
+ per_agent = {}
+ for a in all_agents:
+ per_agent[a] = {
+ "agent": a,
+ "display_name": (registry.get(a, {}) or {}).get("display_name", a),
+ "chat_messages": chat_counts.get(a, 0),
+ "user_turns": user_turns.get(a, 0),
+ "time_seconds": time_per_agent.get(a, {}).get("total_seconds", 0),
+ "sessions": time_per_agent.get(a, {}).get("sessions", 0),
+ "touches": time_per_agent.get(a, {}).get("touches", 0),
+ "tokens": tokens_per_agent.get(a, 0),
+ "cost": round(cost_per_agent.get(a, 0), 6),
+ "top_models": sorted(
+ models_per_agent.get(a, {}).items(),
+ key=lambda kv: kv[1], reverse=True,
+ ),
+ "suite": suite.get(a, {}),
+ "first_seen": first_chat.get(a),
+ "last_seen": last_chat.get(a),
+ }
+
+ # Rankings
+ by_chats = sorted(
+ per_agent.values(), key=lambda x: x["user_turns"], reverse=True
+ )
+ by_time = sorted(
+ per_agent.values(), key=lambda x: x["time_seconds"], reverse=True
+ )
+
+ # Most-used model overall per agent already in top_models; also a global
+ # model popularity map.
+ global_models = {}
+ for a, models in models_per_agent.items():
+ for m, c in models.items():
+ global_models[m] = global_models.get(m, 0) + c
+
+ return {
+ "generated_at": get_timestamp(),
+ "totals": {
+ "total_chat_messages": sum(chat_counts.values()),
+ "total_user_turns": sum(user_turns.values()),
+ "total_time_seconds": time_report.get("total_seconds", 0),
+ "total_time_human": time_report.get("total_human", "0h 0m 0s"),
+ "total_tokens": sum(tokens_per_agent.values()),
+ "total_cost": round(sum(cost_per_agent.values()), 6),
+ "agents_observed": len(per_agent),
+ },
+ "most_chatted": [
+ {"agent": x["agent"], "display_name": x["display_name"],
+ "user_turns": x["user_turns"], "chat_messages": x["chat_messages"]}
+ for x in by_chats if x["user_turns"] > 0
+ ],
+ "most_time": [
+ {"agent": x["agent"], "display_name": x["display_name"],
+ "time_seconds": x["time_seconds"], "sessions": x["sessions"],
+ "touches": x["touches"]}
+ for x in by_time if x["time_seconds"] > 0
+ ],
+ "global_models": sorted(
+ global_models.items(), key=lambda kv: kv[1], reverse=True
+ ),
+ "per_agent": per_agent,
+ }
+
+
# โโโ Favicon โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
FAVICON_SVG = ''