refactor: consolidate nvim crates, extract server library, and update workspace dependencies
This commit is contained in:
1 parent
a083719cf1
commit
533adfd41b
53 files changed
+5967
-1230
No files matched your search
@@ -119,8 +119,9 @@ impl McpTool for CreateEntitiesHandler {
|
||||
let req: CreateEntitiesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let mut inserted = Vec::new();
|
||||
state.modify_graph(|g| {
|
||||
for entity in req.entities {
|
||||
for mut entity in req.entities {
|
||||
if !entity.name.is_empty() {
|
||||
entity.entity_type = crate::models::normalize_entity_type(&entity.entity_type);
|
||||
inserted.push(entity.clone());
|
||||
g.entities.insert(entity.name.clone(), entity);
|
||||
}
|
||||
@@ -165,8 +166,9 @@ impl McpTool for CreateRelationsHandler {
|
||||
};
|
||||
let mut missing_nodes = std::collections::HashSet::new();
|
||||
state.modify_graph(|g| {
|
||||
for relation in req.relations {
|
||||
for mut relation in req.relations {
|
||||
if !relation.from.is_empty() && !relation.to.is_empty() {
|
||||
relation.relation_type = crate::models::normalize_relation_type(&relation.relation_type);
|
||||
let from_exists = g.entities.contains_key(&relation.from);
|
||||
let to_exists = g.entities.contains_key(&relation.to);
|
||||
if from_exists && to_exists {
|
||||
@@ -353,8 +355,9 @@ impl McpTool for ReadGraphHandler {
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: ReadGraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let max_tokens = req.max_tokens;
|
||||
let data = state.read_graph(|full| -> crate::error::Result<String> {
|
||||
if let Some(ns) = req.namespace {
|
||||
let mut result_json = if let Some(ns) = req.namespace {
|
||||
let mut filtered = BorrowedGraph::default();
|
||||
for (k, v) in &full.entities {
|
||||
if v.namespace == ns {
|
||||
@@ -366,10 +369,19 @@ impl McpTool for ReadGraphHandler {
|
||||
filtered.relations.push(r);
|
||||
}
|
||||
}
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?)
|
||||
serde_json::to_string(&filtered)?
|
||||
} else {
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string(full)?)
|
||||
serde_json::to_string(full)?
|
||||
};
|
||||
|
||||
if let Some(max_tok) = max_tokens {
|
||||
let max_chars = max_tok * 4;
|
||||
if result_json.len() > max_chars {
|
||||
result_json.truncate(max_chars);
|
||||
result_json.push_str("... [TRUNCATED_TO_MAX_TOKENS]");
|
||||
}
|
||||
}
|
||||
Ok(result_json)
|
||||
})?;
|
||||
Ok(data)
|
||||
}
|
||||
@@ -553,6 +565,54 @@ impl McpTool for CondenseEntityHandler {
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: CondenseEntityTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let mut missing = false;
|
||||
|
||||
if req.summarized_observations.is_empty() {
|
||||
let mut obs_to_condense = Vec::new();
|
||||
state.read_graph(|g| {
|
||||
if let Some(e) = g.entities.get(&req.entity_name) {
|
||||
obs_to_condense = e.observations.clone();
|
||||
} else {
|
||||
missing = true;
|
||||
}
|
||||
});
|
||||
if missing {
|
||||
return Err(crate::error::AppError::Internal(format!(
|
||||
"Error: Entity '{}' not found. Please verify the exact entity name using search_nodes.",
|
||||
req.entity_name
|
||||
)));
|
||||
}
|
||||
let mut unique_obs: Vec<String> = Vec::new();
|
||||
for obs in obs_to_condense {
|
||||
let clean = obs.trim().to_string();
|
||||
if !clean.is_empty() && !unique_obs.contains(&clean) {
|
||||
unique_obs.push(clean);
|
||||
}
|
||||
}
|
||||
if unique_obs.len() > 3 && state.ollama.is_available().await {
|
||||
let prompt = format!(
|
||||
"Condense the following observations for entity '{}' into 2-3 concise architectural facts:\n- {}",
|
||||
req.entity_name,
|
||||
unique_obs.join("\n- ")
|
||||
);
|
||||
if let Ok(summary) = state.ollama.generate(&prompt, None, None).await {
|
||||
let lines: Vec<String> = summary
|
||||
.lines()
|
||||
.map(|l| l.trim().trim_start_matches('-').trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect();
|
||||
if !lines.is_empty() {
|
||||
unique_obs = lines;
|
||||
}
|
||||
}
|
||||
}
|
||||
state.modify_graph(|master| {
|
||||
if let Some(e) = master.entities.get_mut(&req.entity_name) {
|
||||
e.observations = unique_obs;
|
||||
}
|
||||
});
|
||||
return Ok(format!("Entity '{}' observations condensed with deduplication/AI summary.", req.entity_name));
|
||||
}
|
||||
|
||||
state.modify_graph(|master| {
|
||||
if let Some(e) = master.entities.get_mut(&req.entity_name) {
|
||||
e.observations = req.summarized_observations;
|
||||
@@ -655,14 +715,316 @@ impl McpTool for FindOrphansHandler {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GetSubgraphHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for GetSubgraphHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"get_subgraph"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<GetSubgraphTool>("get_subgraph", "Execute get_subgraph")
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: GetSubgraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let root = req
|
||||
.root_entity
|
||||
.or(req.root_node)
|
||||
.ok_or_else(|| crate::error::AppError::Internal("root_entity or root_node is required".to_string()))?;
|
||||
let depth = req.depth.unwrap_or(2);
|
||||
let format = req.format.unwrap_or(SubgraphFormat::Json);
|
||||
|
||||
if format == SubgraphFormat::MarkdownTree {
|
||||
let (sub_entities, sub_relations) = state.read_graph(|g| {
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
let mut queue = std::collections::VecDeque::new();
|
||||
queue.push_back((root.clone(), 0));
|
||||
visited.insert(root.clone());
|
||||
|
||||
while let Some((curr, d)) = queue.pop_front() {
|
||||
if d >= depth {
|
||||
continue;
|
||||
}
|
||||
for r in &g.relations {
|
||||
if r.from == curr && !visited.contains(&r.to) {
|
||||
visited.insert(r.to.clone());
|
||||
queue.push_back((r.to.clone(), d + 1));
|
||||
} else if r.to == curr && !visited.contains(&r.from) {
|
||||
visited.insert(r.from.clone());
|
||||
queue.push_back((r.from.clone(), d + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ent: Vec<_> = g.entities.values().filter(|e| visited.contains(&e.name)).cloned().collect();
|
||||
let rel: Vec<_> = g.relations.iter().filter(|r| visited.contains(&r.from) && visited.contains(&r.to)).cloned().collect();
|
||||
(ent, rel)
|
||||
});
|
||||
|
||||
let mut markdown = format!("# Subgraph Topology for `{}`\n\n", root);
|
||||
markdown.push_str("## Entities\n");
|
||||
for e in &sub_entities {
|
||||
markdown.push_str(&format!("- **{}** (`{}`)\n", e.name, e.entity_type));
|
||||
for obs in &e.observations {
|
||||
markdown.push_str(&format!(" - {}\n", obs));
|
||||
}
|
||||
}
|
||||
|
||||
markdown.push_str("\n## Relations\n");
|
||||
for r in &sub_relations {
|
||||
markdown.push_str(&format!("- `{}` --[{}]--> `{}`\n", r.from, r.relation_type, r.to));
|
||||
}
|
||||
return Ok(markdown);
|
||||
}
|
||||
|
||||
let data = state.read_graph(|full| {
|
||||
let mut visited_nodes: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut matched_entities = std::collections::HashMap::new();
|
||||
let mut matched_relations = Vec::new();
|
||||
|
||||
let mut queue = std::collections::VecDeque::new();
|
||||
queue.push_back((root.clone(), 0));
|
||||
visited_nodes.insert(root.clone());
|
||||
|
||||
while let Some((curr, current_depth)) = queue.pop_front() {
|
||||
if let Some(entity) = full.entities.get(&curr) {
|
||||
matched_entities.insert(curr.clone(), entity.clone());
|
||||
}
|
||||
|
||||
if current_depth < depth {
|
||||
for rel in &full.relations {
|
||||
if rel.from == curr {
|
||||
matched_relations.push(rel.clone());
|
||||
if !visited_nodes.contains(&rel.to) {
|
||||
visited_nodes.insert(rel.to.clone());
|
||||
queue.push_back((rel.to.clone(), current_depth + 1));
|
||||
}
|
||||
} else if rel.to == curr {
|
||||
matched_relations.push(rel.clone());
|
||||
if !visited_nodes.contains(&rel.from) {
|
||||
visited_nodes.insert(rel.from.clone());
|
||||
queue.push_back((rel.from.clone(), current_depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = serde_json::json!({
|
||||
"root_node": root,
|
||||
"depth": depth,
|
||||
"entities": matched_entities,
|
||||
"relations": matched_relations,
|
||||
});
|
||||
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&result)?)
|
||||
})?;
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
}
|
||||
|
||||
use crate::handlers::utils::*;
|
||||
|
||||
pub struct SweepGraphHealthHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for SweepGraphHealthHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"sweep_graph_health"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<SweepGraphHealthTool>("sweep_graph_health", "Audit and sweep graph health")
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: SweepGraphHealthTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let auto_prune = req.auto_prune_orphans.unwrap_or(false);
|
||||
|
||||
let mut orphans = Vec::new();
|
||||
let mut duplicates = Vec::new();
|
||||
|
||||
state.modify_graph(|g| {
|
||||
// 1. Identify Orphans
|
||||
let mut connected = std::collections::HashSet::new();
|
||||
for r in &g.relations {
|
||||
connected.insert(r.from.clone());
|
||||
connected.insert(r.to.clone());
|
||||
}
|
||||
|
||||
for name in g.entities.keys() {
|
||||
if !connected.contains(name) {
|
||||
orphans.push(name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if auto_prune {
|
||||
for orphan in &orphans {
|
||||
g.entities.remove(orphan);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Compute similarity pairs for duplicate detection
|
||||
let names: Vec<_> = g.entities.keys().cloned().collect();
|
||||
for i in 0..names.len() {
|
||||
for j in (i + 1)..names.len() {
|
||||
let n1 = &names[i];
|
||||
let n2 = &names[j];
|
||||
|
||||
let l1 = n1.to_lowercase();
|
||||
let l2 = n2.to_lowercase();
|
||||
if l1 == l2 || ((l1.contains(&l2) || l2.contains(&l1)) && l1.len().min(l2.len()) > 3) {
|
||||
duplicates.push(serde_json::json!({
|
||||
"entity_a": n1,
|
||||
"entity_b": n2,
|
||||
"suggested_action": format!("merge_entities(source: '{}', target: '{}')", n1, n2)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let report = serde_json::json!({
|
||||
"orphaned_entities": orphans,
|
||||
"orphans_pruned": auto_prune,
|
||||
"potential_duplicates": duplicates,
|
||||
"health_score": if orphans.is_empty() && duplicates.is_empty() { "100%" } else { "Needs Maintenance" }
|
||||
});
|
||||
|
||||
Ok(serde_json::to_string_pretty(&report)?)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ResolveStaleSymbolsHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for ResolveStaleSymbolsHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"resolve_stale_symbols"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<ResolveStaleSymbolsTool>("resolve_stale_symbols", "Inspect Knowledge Graph entities and tech debt symbol/line references against files on disk, healing stale pointers.")
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: ResolveStaleSymbolsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let auto_heal = req.auto_heal.unwrap_or(true);
|
||||
|
||||
let mut checked = 0;
|
||||
let mut stale_entities = Vec::new();
|
||||
|
||||
state.modify_graph(|g| {
|
||||
for (name, _entity) in g.entities.iter_mut() {
|
||||
if let Some(target) = &req.target {
|
||||
if name != target && !name.contains(target) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
checked += 1;
|
||||
let path = std::path::Path::new(name);
|
||||
if (name.contains('/') || name.contains('\\') || name.contains('.')) && !path.exists() {
|
||||
stale_entities.push(name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if auto_heal {
|
||||
for stale in &stale_entities {
|
||||
g.entities.remove(stale);
|
||||
g.relations.retain(|r| &r.from != stale && &r.to != stale);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let report = serde_json::json!({
|
||||
"checked_count": checked,
|
||||
"stale_entities_found": stale_entities,
|
||||
"auto_healed": auto_heal,
|
||||
"status": "success"
|
||||
});
|
||||
|
||||
Ok(serde_json::to_string_pretty(&report)?)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SummarizeSubgraphHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for SummarizeSubgraphHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"summarize_subgraph"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<SummarizeSubgraphTool>("summarize_subgraph", "Generate compact, LLM-optimized Markdown topology of a graph component capped within a token budget.")
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: SummarizeSubgraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let depth = req.depth.unwrap_or(2);
|
||||
|
||||
let (sub_entities, sub_relations) = state.read_graph(|g| {
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
let mut queue = std::collections::VecDeque::new();
|
||||
queue.push_back((req.root_entity.clone(), 0));
|
||||
visited.insert(req.root_entity.clone());
|
||||
|
||||
while let Some((curr, d)) = queue.pop_front() {
|
||||
if d >= depth {
|
||||
continue;
|
||||
}
|
||||
for r in &g.relations {
|
||||
if r.from == curr && !visited.contains(&r.to) {
|
||||
visited.insert(r.to.clone());
|
||||
queue.push_back((r.to.clone(), d + 1));
|
||||
} else if r.to == curr && !visited.contains(&r.from) {
|
||||
visited.insert(r.from.clone());
|
||||
queue.push_back((r.from.clone(), d + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ent: Vec<_> = g.entities.values().filter(|e| visited.contains(&e.name)).cloned().collect();
|
||||
let rel: Vec<_> = g.relations.iter().filter(|r| visited.contains(&r.from) && visited.contains(&r.to)).cloned().collect();
|
||||
(ent, rel)
|
||||
});
|
||||
|
||||
let mut markdown = format!("# Subgraph Topology for `{}`\n\n", req.root_entity);
|
||||
markdown.push_str("## Entities\n");
|
||||
for e in &sub_entities {
|
||||
markdown.push_str(&format!("- **{}** (`{}`)\n", e.name, e.entity_type));
|
||||
for obs in &e.observations {
|
||||
markdown.push_str(&format!(" - {}\n", obs));
|
||||
}
|
||||
}
|
||||
|
||||
markdown.push_str("\n## Relations\n");
|
||||
for r in &sub_relations {
|
||||
markdown.push_str(&format!("- `{}` --[{}]--> `{}`\n", r.from, r.relation_type, r.to));
|
||||
}
|
||||
|
||||
let max_tokens = req.max_tokens.unwrap_or(1000);
|
||||
let max_chars = max_tokens * 4;
|
||||
if markdown.len() > max_chars {
|
||||
markdown.truncate(max_chars);
|
||||
markdown.push_str("\n... [Truncated to fit token budget]");
|
||||
}
|
||||
|
||||
Ok(markdown)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::handlers::meta::{BroadcastAgentSignalHandler, QueryAgentSignalsHandler};
|
||||
use serde_json::json;
|
||||
use tempfile::tempdir;
|
||||
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_and_read_entities() {
|
||||
let dir = tempdir().unwrap();
|
||||
@@ -888,4 +1250,71 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(!res_orphans.contains("Y"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_more_graph_handlers() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||
|
||||
let create_handler = CreateEntitiesHandler;
|
||||
let args_ent = json!({
|
||||
"entities": [
|
||||
{"name": "Alpha", "entity_type": "Node", "observations": ["Obs1", "Obs2"], "namespace": "global"},
|
||||
{"name": "Beta", "entity_type": "Node", "observations": ["Obs3"], "namespace": "global"}
|
||||
]
|
||||
});
|
||||
create_handler.execute(args_ent, state.clone()).await.unwrap();
|
||||
|
||||
let rel_handler = CreateRelationsHandler;
|
||||
let args_rel = json!({
|
||||
"relations": [
|
||||
{"from": "Alpha", "to": "Beta", "relation_type": "links_to", "namespace": "global"}
|
||||
]
|
||||
});
|
||||
rel_handler.execute(args_rel, state.clone()).await.unwrap();
|
||||
|
||||
let path_handler = QueryGraphPathHandler;
|
||||
let path_res = path_handler.execute(json!({"start_node": "Alpha", "end_node": "Beta"}), state.clone()).await.unwrap();
|
||||
assert!(path_res.contains("Alpha"));
|
||||
|
||||
let sub_handler = GetSubgraphHandler;
|
||||
let sub_res = sub_handler.execute(json!({"root_entity": "Alpha"}), state.clone()).await.unwrap();
|
||||
assert!(sub_res.contains("Alpha"));
|
||||
|
||||
let sum_handler = SummarizeSubgraphHandler;
|
||||
let sum_res = sum_handler.execute(json!({"root_entity": "Alpha"}), state.clone()).await.unwrap();
|
||||
assert!(!sum_res.is_empty());
|
||||
|
||||
|
||||
let sweep_handler = SweepGraphHealthHandler;
|
||||
let sweep_res = sweep_handler.execute(json!({}), state.clone()).await.unwrap();
|
||||
assert!(!sweep_res.is_empty());
|
||||
|
||||
let del_obs_handler = DeleteObservationsHandler;
|
||||
let del_obs_res = del_obs_handler.execute(json!({
|
||||
"deletions": [{"entity_name": "Alpha", "observations": ["Obs1"]}]
|
||||
}), state.clone()).await.unwrap();
|
||||
assert_eq!(del_obs_res, "Observations deleted");
|
||||
|
||||
let del_rel_handler = DeleteRelationsHandler;
|
||||
let del_rel_res = del_rel_handler.execute(json!({
|
||||
"relations": [{"from": "Alpha", "to": "Beta", "relation_type": "links_to"}]
|
||||
}), state.clone()).await.unwrap();
|
||||
assert_eq!(del_rel_res, "Relations deleted");
|
||||
|
||||
let bcast_handler = BroadcastAgentSignalHandler;
|
||||
let bcast_res = bcast_handler.execute(json!({
|
||||
"sender": "agent1",
|
||||
"signal_type": "task_completed",
|
||||
"payload": "fix_bug"
|
||||
}), state.clone()).await.unwrap();
|
||||
assert!(bcast_res.contains("Broadcasted signal"));
|
||||
|
||||
|
||||
let qsignal_handler = QueryAgentSignalsHandler;
|
||||
let qsignal_res = qsignal_handler.execute(json!({"sender": "agent1"}), state.clone()).await.unwrap();
|
||||
assert!(qsignal_res.contains("task_completed"));
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user