feat: Add Brain Monitor dashboard and live API stats

This commit is contained in:
Riz Ashraf committed 2026-09-08 15:09:44 +01:00
1 parent 3239e32866
commit 18fd05bb11
10 files changed
+672 -80

No files matched your search

+2 -1
View File
@@ -15,8 +15,9 @@ rust-mcp-axum = "2.0.0"
rust-mcp-sdk = "2.0.0"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
strsim = "0.11.1"
tantivy = "0.26.1"
tokio = { version = "1.53.1", features = ["full"] }
tokio-util = { version = "0.7.19", features = ["io"] }
tracing = "0.1.44"
tracing-subscriber = "0.3.23"
uuid = { version = "1.26.0", features = ["v4"] }
+73
View File
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html>
<head>
<title>Brain Monitor</title>
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 40px; background-color: #f0f2f5; color: #333; }
h1 { color: #2c3e50; text-align: center; margin-bottom: 30px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; max-width: 800px; margin: 0 auto; }
.stat-card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); text-align: center; transition: transform 0.2s ease; }
.stat-card:hover { transform: translateY(-5px); }
.stat-value { font-size: 2.5em; font-weight: bold; color: #3498db; margin: 10px 0; }
.stat-label { font-size: 1.1em; color: #7f8c8d; text-transform: uppercase; letter-spacing: 1px; }
.status-dot { display: inline-block; width: 10px; height: 10px; background-color: #2ecc71; border-radius: 50%; margin-right: 8px; box-shadow: 0 0 5px #2ecc71; }
.header-bar { max-width: 800px; margin: 0 auto 20px; display: flex; justify-content: space-between; align-items: center; }
</style>
</head>
<body>
<div class="header-bar">
<h1>🧠 Antigravity Brain Monitor</h1>
<div><span class="status-dot"></span>System Online</div>
</div>
<div class="grid">
<div class="stat-card">
<div class="stat-label">Entities</div>
<div class="stat-value" id="val-entities">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Relations</div>
<div class="stat-value" id="val-relations">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Active Tasks</div>
<div class="stat-value" id="val-tasks">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Snippets</div>
<div class="stat-value" id="val-snippets">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Tech Debt</div>
<div class="stat-value" id="val-tech_debts">-</div>
</div>
<div class="stat-card">
<div class="stat-label">ADRs</div>
<div class="stat-value" id="val-adrs">-</div>
</div>
</div>
<script>
async function fetchStats() {
try {
const response = await fetch('/api/stats');
const data = await response.json();
document.getElementById('val-entities').innerText = data.entities;
document.getElementById('val-relations').innerText = data.relations;
document.getElementById('val-tasks').innerText = data.tasks;
document.getElementById('val-snippets').innerText = data.snippets;
document.getElementById('val-tech_debts').innerText = data.tech_debts;
document.getElementById('val-adrs').innerText = data.adrs;
} catch (err) {
console.error("Failed to fetch stats:", err);
}
}
// Initial fetch
fetchStats();
// Poll every 2 seconds
setInterval(fetchStats, 2000);
</script>
</body>
</html>
+63 -71
View File
@@ -120,6 +120,9 @@ impl ServerHandler for MemoryHandler {
} {
let entity: Entity = e;
if !entity.name.is_empty() {
if let Ok(idx) = self.state.search_index.read() {
let _ = idx.index_entity(&entity);
}
g.entities.insert(entity.name.clone(), entity);
}
}
@@ -267,29 +270,19 @@ impl ServerHandler for MemoryHandler {
}
"search_nodes" => {
let req: SearchNodesTool = parse_args(args)?;
let query = req.query.to_lowercase();
let tokens: Vec<&str> = query.split_whitespace().collect();
let matches = if let Ok(idx) = self.state.search_index.read() {
idx.search(&req.query, req.namespace.as_deref()).unwrap_or_default()
} else {
vec![]
};
let mut result = KnowledgeGraph::default();
for (name, e) in self.state.get_full_graph().entities {
if let Some(ns) = &req.namespace
&& e.namespace != *ns
{
continue;
}
let name_low = name.to_lowercase();
let type_low = e.entity_type.to_lowercase();
let full_txt = format!(
"{} {} {}",
name_low,
type_low,
e.observations.join(" ").to_lowercase()
);
let matched = full_txt.contains(&query)
|| (!tokens.is_empty() && tokens.iter().all(|&t| full_txt.contains(t)))
|| strsim::jaro_winkler(&name_low, &query) > 0.85
|| strsim::jaro_winkler(&type_low, &query) > 0.85;
if matched {
result.entities.insert(name, e);
let full = self.state.get_full_graph();
for (id, doc_type) in matches {
if doc_type == "entity" {
if let Some(e) = full.entities.get(&id) {
result.entities.insert(id, e.clone());
}
}
}
let data = serde_json::to_string(&result).unwrap_or_default();
@@ -456,16 +449,20 @@ impl ServerHandler for MemoryHandler {
.unwrap()
.as_secs();
let id = uuid::Uuid::new_v4().to_string();
let task = Task {
id: id.clone(),
title: req.title,
status: "pending".to_string(),
description: req.description,
created_at: now,
updated_at: now,
git_branch: req.git_branch,
};
if let Ok(idx) = self.state.search_index.read() {
let _ = idx.index_task(&task);
}
self.state.tasks.modify(|tasks| {
tasks.push(Task {
id: id.clone(),
title: req.title,
status: "pending".to_string(),
description: req.description,
created_at: now,
updated_at: now,
git_branch: req.git_branch,
});
tasks.push(task);
});
Ok(ServerResult::from(CallToolResult::text_content(vec![
format!("Task added with ID: {}", id).into(),
@@ -1098,47 +1095,42 @@ impl ServerHandler for MemoryHandler {
}
"omni_search" => {
let req: OmniSearchTool = parse_args(args)?;
let matches = if let Ok(idx) = self.state.search_index.read() {
idx.search(&req.query, req.namespace.as_deref()).unwrap_or_default()
} else {
vec![]
};
let mut kg = KnowledgeGraph::default();
let mut tasks = Vec::new();
let mut snippets = Vec::new();
let mut adrs = Vec::new();
let full = self.state.get_full_graph();
for (id, doc_type) in &matches {
if doc_type == "entity" {
if let Some(e) = full.entities.get(id) {
kg.entities.insert(id.clone(), e.clone());
}
}
}
for t in self.state.tasks.read() {
if matches.iter().any(|(id, typ)| id == &t.id && typ == "task") {
tasks.push(t);
}
}
for s in self.state.snippets.read() {
if matches.iter().any(|(id, typ)| id == &s.name && typ == "snippet") {
snippets.push(s);
}
}
for a in self.state.adrs.read() {
if matches.iter().any(|(id, typ)| id == &a.id && typ == "adr") {
adrs.push(a);
}
}
let q = req.query.to_lowercase();
let mut kg = self.state.get_full_graph();
kg.entities.retain(|name, e| {
(req.namespace.is_none() || e.namespace == *req.namespace.as_ref().unwrap())
&& (name.to_lowercase().contains(&q)
|| e.entity_type.to_lowercase().contains(&q)
|| e.observations.join(" ").to_lowercase().contains(&q))
});
let tasks: Vec<_> = self
.state
.tasks
.read()
.into_iter()
.filter(|t| {
t.title.to_lowercase().contains(&q)
|| t.description.to_lowercase().contains(&q)
})
.collect();
let snippets: Vec<_> = self
.state
.snippets
.read()
.into_iter()
.filter(|s| {
s.name.to_lowercase().contains(&q)
|| s.description.to_lowercase().contains(&q)
})
.collect();
let adrs: Vec<_> = self
.state
.adrs
.read()
.into_iter()
.filter(|a| {
a.title.to_lowercase().contains(&q)
|| a.context.to_lowercase().contains(&q)
|| a.decision.to_lowercase().contains(&q)
})
.collect();
let tech_debts: Vec<_> = self
.state
.tech_debts
+35 -1
View File
@@ -3,6 +3,7 @@ mod models;
mod state;
mod store;
mod tools;
mod search;
use crate::handlers::MemoryHandler;
use crate::models::*;
@@ -84,6 +85,7 @@ async fn reconcile_worker(state: Arc<MemoryState>) {
let has_files = glob::glob(&pattern).map(|p| p.count() > 0).unwrap_or(false);
if has_local || has_files {
state.apply_sync_write(|_master| {});
state.rebuild_index();
}
let now = SystemTime::now()
@@ -152,6 +154,35 @@ fn run_server(state: Arc<MemoryState>) -> SdkResult<()> {
axum::routing::get(|| async move {
axum::http::StatusCode::OK
}),
).with_route(
"/dashboard",
axum::routing::get(|| async move {
axum::response::Html(include_str!("dashboard.html"))
}),
).with_route(
"/api/stats",
axum::routing::get({
let state_clone = state.clone();
move || async move {
let (entities, relations) = {
let graph = state_clone.get_full_graph();
(graph.entities.len(), graph.relations.len())
};
let tasks = state_clone.tasks.read().len();
let snippets = state_clone.snippets.read().len();
let tech_debts = state_clone.tech_debts.read().len();
let adrs = state_clone.adrs.read().len();
axum::Json(serde_json::json!({
"entities": entities,
"relations": relations,
"tasks": tasks,
"snippets": snippets,
"tech_debts": tech_debts,
"adrs": adrs
}))
}
}),
);
println!("MCP Memory Server running on http://0.0.0.0:3000/sse");
@@ -190,7 +221,7 @@ fn main() -> SdkResult<()> {
{
// This shouldn't be executed on linux natively anymore due to workspace split,
// but keeping it as a fallback.
let target_url = cli.target.as_deref().unwrap_or("http://host.docker.internal:3000");
let target_url = cli.target.as_deref().unwrap_or("http://127.0.0.1:3000");
let _ = proxy::run_proxy(target_url);
return Ok(());
}
@@ -211,6 +242,7 @@ fn main() -> SdkResult<()> {
session_graph: RwLock::new(KnowledgeGraph::default()),
base_dir: base.clone(),
master_cache: RwLock::new((KnowledgeGraph::default(), SystemTime::UNIX_EPOCH)),
search_index: RwLock::new(crate::search::MemoryIndex::new().unwrap()),
ledger: Store::new(base.join("audit_ledger.json")),
sticky: Store::new(base.join("sticky_notes.json")),
tasks: Store::new(base.join("tasks.json")),
@@ -231,6 +263,8 @@ fn main() -> SdkResult<()> {
context_workspaces: Store::new(base.join("context_workspaces.json")),
});
state.rebuild_index();
if let Some(command) = cli.command {
match command {
Commands::Gate { subcmd } => {
+36 -6
View File
@@ -1,7 +1,7 @@
use tantivy::schema::*;
use tantivy::{doc, Index, IndexWriter, IndexReader, ReloadPolicy};
use std::sync::Mutex;
use crate::models::{Entity, Task, StickyNote, Adr, Snippet};
use crate::models::{Entity, Task, Adr, Snippet};
pub struct MemoryIndex {
index: Index,
@@ -71,21 +71,51 @@ impl MemoryIndex {
Ok(())
}
pub fn search(&self, query: &str, namespace: Option<&str>) -> tantivy::Result<Vec<String>> {
pub fn search(&self, query: &str, namespace: Option<&str>) -> tantivy::Result<Vec<(String, String)>> {
let searcher = self.reader.searcher();
let query_parser = tantivy::query::QueryParser::for_index(&self.index, vec![self.title_field, self.body_field]);
let q = query_parser.parse_query(query)?;
let top_docs = searcher.search(&q, &tantivy::collector::TopDocs::with_limit(50))?;
let top_docs = searcher.search(&q, &tantivy::collector::TopDocs::with_limit(50).order_by_score())?;
let mut results = Vec::new();
for (_score, doc_address) in top_docs {
let retrieved_doc = searcher.doc::<tantivy::TantivyDocument>(doc_address)?;
if let Some(val) = retrieved_doc.get_first(self.title_field) {
if let Some(t) = val.as_str() {
results.push(t.to_string());
let id = retrieved_doc.get_first(self.id_field).and_then(|v| v.as_str()).unwrap_or("").to_string();
let doc_type = retrieved_doc.get_first(self.type_field).and_then(|v| v.as_str()).unwrap_or("").to_string();
let doc_ns = retrieved_doc.get_first(self.namespace_field).and_then(|v| v.as_str()).unwrap_or("");
if let Some(ns) = namespace {
if doc_ns != ns && doc_ns != "global" {
continue;
}
}
results.push((id, doc_type));
}
Ok(results)
}
pub fn index_snippet(&self, s: &Snippet) -> tantivy::Result<()> {
let mut writer = self.writer.lock().unwrap();
writer.add_document(doc!(
self.id_field => s.name.clone(),
self.title_field => s.name.clone(),
self.body_field => format!("{} {}", s.language, s.description),
self.type_field => "snippet",
self.namespace_field => "global"
))?;
writer.commit()?;
Ok(())
}
pub fn index_adr(&self, a: &Adr) -> tantivy::Result<()> {
let mut writer = self.writer.lock().unwrap();
writer.add_document(doc!(
self.id_field => a.id.clone(),
self.title_field => a.title.clone(),
self.body_field => format!("{} {} {}", a.context, a.decision, a.consequence),
self.type_field => "adr",
self.namespace_field => "global"
))?;
writer.commit()?;
Ok(())
}
}
+24
View File
@@ -1,5 +1,6 @@
use crate::models::*;
use crate::store::{Store, read_json_file, write_json_atomic};
use crate::search::MemoryIndex;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::PathBuf;
@@ -11,6 +12,7 @@ pub struct MemoryState {
pub master_path: PathBuf,
pub session_graph: RwLock<KnowledgeGraph>,
pub master_cache: RwLock<(KnowledgeGraph, SystemTime)>,
pub search_index: RwLock<MemoryIndex>,
pub ledger: Store<Vec<CodeChange>>,
pub sticky: Store<Vec<StickyNote>>,
pub tasks: Store<Vec<Task>>,
@@ -136,5 +138,27 @@ impl MemoryState {
let _ = fs::remove_file(&lock_path);
}
pub fn rebuild_index(&self) {
if let Ok(new_idx) = MemoryIndex::new() {
let full = self.get_full_graph();
for (_, e) in &full.entities {
let _ = new_idx.index_entity(e);
}
for t in self.tasks.read() {
let _ = new_idx.index_task(&t);
}
for s in self.snippets.read() {
let _ = new_idx.index_snippet(&s);
}
for a in self.adrs.read() {
let _ = new_idx.index_adr(&a);
}
if let Ok(mut w) = self.search_index.write() {
*w = new_idx;
}
}
}
}