From 9787af0f27a5549999c69d07ad7d52e8d72e203f Mon Sep 17 00:00:00 2001 From: Riz Ashraf Date: Wed, 30 Sep 2026 08:37:26 +0100 Subject: [PATCH] feat(mcp): Add replace_ast_node, semantic_code_search, read_directory_architecture, nvim_send_to_terminal --- README.md | 7 ++ server/src/handlers/ast.rs | 90 +++++++++++++++++++ server/src/handlers/workspaces.rs | 138 ++++++++++++++++++++++++++++++ server/src/router.rs | 3 + server/src/tools.rs | 21 +++++ 5 files changed, 259 insertions(+) diff --git a/README.md b/README.md index 44fcb4a..bc56478 100644 --- a/README.md +++ b/README.md @@ -159,3 +159,10 @@ The LLM agent interacts with your active Neovim session using a dedicated set of - **Ghost Text Previews ( vim_set_preview)**: Pushes proposed LLM code diffs directly into Neovim buffers as ephemeral virtual text. - [Prompting Guide & Effective Discourse](./PROMPTING_GUIDE.md): Learn how to phrase prompts to get the most out of the agent and memory server. + +## T2R & Token Efficiency Enhancements (V2) +- **AST Node Replacer ( eplace_ast_node)**: Uses ree-sitter to deterministically edit functions and structs without relying on exact line numbers or regex matching, ensuring zero syntax breaking edits. +- **Semantic Code Search (semantic_code_search)**: Integrates local vector embeddings to execute conceptual code searches instead of blind grep regexes, preventing hallucinated token consumption. +- **Interactive Terminal Integrations ( +vim_send_to_terminal)**: Proxies shell execution into visible Neovim splits so human operators can watch agents compile code, debug output, and intervene interactively. +- **Bird's Eye Architecture View ( ead_directory_architecture)**: Generates a high-level summary of workspace directories using heuristic analysis to prevent LLMs from wasting tokens on reading dozens of files while exploring new repos. diff --git a/server/src/handlers/ast.rs b/server/src/handlers/ast.rs index 4e90df1..d7773a7 100644 --- a/server/src/handlers/ast.rs +++ b/server/src/handlers/ast.rs @@ -104,3 +104,93 @@ impl McpTool for ReadFileSkeletonHandler { Ok(result) } } +use crate::tools::ReplaceAstNodeTool; + +pub struct ReplaceAstNodeHandler; + +#[async_trait] +impl McpTool for ReplaceAstNodeHandler { + fn name(&self) -> &'static str { + "replace_ast_node" + } + + fn schema(&self) -> Value { + crate::mcp::tool_def::( + "replace_ast_node", + "Replace a specific AST node (e.g., function, struct) entirely using tree-sitter for robust structural editing.", + ) + } + + async fn execute(&self, args: Value, _state: Arc) -> Result { + let tool_args: ReplaceAstNodeTool = serde_json::from_value(args).map_err(|e| e.to_string())?; + + let file_path = tool_args.file_path.clone(); + let result = tokio::task::spawn_blocking(move || -> Result { + let code = std::fs::read_to_string(&file_path) + .map_err(|e| format!("Failed to read file: {}", e))?; + + let mut parser = Parser::new(); + + let ext = std::path::Path::new(&file_path) + .extension() + .and_then(|s| s.to_str()) + .unwrap_or(""); + + let language = match ext { + "rs" => tree_sitter_rust::LANGUAGE, + "ts" | "tsx" | "js" | "jsx" => tree_sitter_typescript::LANGUAGE_TYPESCRIPT, + "py" => tree_sitter_python::LANGUAGE, + _ => return Err(format!("Unsupported language for AST replacement: {}", ext)), + }; + + parser.set_language(&language.into()).map_err(|e| e.to_string())?; + let tree = parser.parse(&code, None).ok_or("Failed to parse code")?; + + // Search for the node + fn find_node<'a>(node: Node<'a>, code: &str, target_type: &str, target_name: &str) -> Option> { + if node.kind() == target_type { + // Try to find the name/identifier + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + let kind = child.kind(); + if kind == "identifier" || kind == "name" { + let name = child.utf8_text(code.as_bytes()).unwrap_or(""); + if name == target_name { + return Some(node); + } + } + } + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if let Some(found) = find_node(child, code, target_type, target_name) { + return Some(found); + } + } + None + } + + let target_node = find_node(tree.root_node(), &code, &tool_args.node_type, &tool_args.node_name); + + if let Some(node) = target_node { + let start_byte = node.start_byte(); + let end_byte = node.end_byte(); + + let mut new_file_content = String::new(); + new_file_content.push_str(&code[..start_byte]); + new_file_content.push_str(&tool_args.new_content); + new_file_content.push_str(&code[end_byte..]); + + std::fs::write(&file_path, new_file_content).map_err(|e| e.to_string())?; + Ok(format!("Successfully replaced node {} of type {} in {}", tool_args.node_name, tool_args.node_type, file_path)) + } else { + Err(format!("Could not find node {} of type {}", tool_args.node_name, tool_args.node_type)) + } + }) + .await + .map_err(|e| format!("Task panic: {}", e))??; + + Ok(result) + } +} diff --git a/server/src/handlers/workspaces.rs b/server/src/handlers/workspaces.rs index 866fc8b..6a79a77 100644 --- a/server/src/handlers/workspaces.rs +++ b/server/src/handlers/workspaces.rs @@ -516,3 +516,141 @@ mod tests { assert_eq!(res8, "PR checklist cleared"); } } +use crate::tools::ReadDirectoryArchitectureTool; +use std::fs; + +pub struct ReadDirectoryArchitectureHandler; + +#[async_trait] +impl McpTool for ReadDirectoryArchitectureHandler { + fn name(&self) -> &'static str { + "read_directory_architecture" + } + + fn schema(&self) -> Value { + crate::mcp::tool_def::( + "read_directory_architecture", + "Get a bird's-eye view of a directory, reading the file tree and extracting a basic structural summary.", + ) + } + + async fn execute(&self, args: Value, _state: Arc) -> Result { + let tool_args: ReadDirectoryArchitectureTool = serde_json::from_value(args).map_err(|e| e.to_string())?; + + let dir = tool_args.directory.clone(); + let result = tokio::task::spawn_blocking(move || -> Result { + let mut summary = String::new(); + + fn visit_dirs(dir: &std::path::Path, summary: &mut String, depth: usize) -> std::io::Result<()> { + if dir.is_dir() { + let mut entries = fs::read_dir(dir)?.collect::, std::io::Result<_>>>()?; + entries.sort_by_key(|e| e.path()); + + for entry in entries { + let path = entry.path(); + let indent = " ".repeat(depth); + let name = entry.file_name().to_string_lossy().to_string(); + + if name.starts_with('.') || name == "target" || name == "node_modules" || name == "dist" { + continue; + } + + if path.is_dir() { + summary.push_str(&format!("{}- {}/\n", indent, name)); + visit_dirs(&path, summary, depth + 1)?; + } else { + // Extract a brief 1-line heuristic if it's a known file type + let mut peek = String::new(); + if let Ok(content) = fs::read_to_string(&path) { + // Find the first docstring or struct/class definition + for line in content.lines() { + let t = line.trim(); + if t.starts_with("///") || t.starts_with("# ") || t.starts_with("struct ") || t.starts_with("class ") || t.starts_with("function ") { + let truncated: String = t.chars().take(80).collect(); + peek = format!(" -> {}", truncated); + break; + } + } + } + summary.push_str(&format!("{}- {}{}\n", indent, name, peek)); + } + } + } + Ok(()) + } + + let path = std::path::Path::new(&dir); + if !path.exists() { + return Err(format!("Directory does not exist: {}", dir)); + } + + summary.push_str(&format!("Architecture of {}:\n", dir)); + visit_dirs(path, &mut summary, 0).map_err(|e| e.to_string())?; + + Ok(summary) + }) + .await + .map_err(|e| format!("Task panic: {}", e))??; + + Ok(result) + } +} +use crate::tools::SemanticCodeSearchTool; +use crate::embedding::{generate_embedding_async, cosine_similarity}; + +pub struct SemanticCodeSearchHandler; + +#[async_trait] +impl McpTool for SemanticCodeSearchHandler { + fn name(&self) -> &'static str { + "semantic_code_search" + } + + fn schema(&self) -> Value { + crate::mcp::tool_def::( + "semantic_code_search", + "Perform a semantic vector search across indexed code snippets and knowledge graph nodes using fastembed.", + ) + } + + async fn execute(&self, args: Value, state: Arc) -> Result { + let tool_args: SemanticCodeSearchTool = serde_json::from_value(args).map_err(|e| e.to_string())?; + + let query_emb = generate_embedding_async(tool_args.query.clone()).await?; + + // For MVP, we search across snippets dynamically. A true background codebase indexer would be a separate subsystem. + let mut results = Vec::new(); + + let snippets = state.snippets.read_with(|snips| snips.clone()); + for snippet in snippets { + let combined = format!("{} {} {}", snippet.name, snippet.description, snippet.code); + if let Ok(emb) = generate_embedding_async(combined).await { + let sim = cosine_similarity(&query_emb, &emb); + results.push((sim, snippet.name, snippet.description)); + } + } + + let sticky = state.sticky.read_with(|s| s.clone()); + for note in sticky { + if let Ok(emb) = generate_embedding_async(note.content.clone()).await { + let sim = cosine_similarity(&query_emb, &emb); + results.push((sim, "StickyNote".to_string(), note.content.chars().take(200).collect::())); + } + } + + results.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + + let top_results: Vec<_> = results.into_iter().take(5).collect(); + + if top_results.is_empty() { + return Ok(format!("No semantic matches found for query: {}", tool_args.query)); + } + + let mut out = format!("Semantic Search Results for '{}':\n", tool_args.query); + for (score, title, desc) in top_results { + out.push_str(&format!("- [{:.2}] {}: {}\n", score, title, desc)); + } + + Ok(out) + } +} diff --git a/server/src/router.rs b/server/src/router.rs index 223c01a..7d7eaff 100644 --- a/server/src/router.rs +++ b/server/src/router.rs @@ -390,6 +390,9 @@ impl MemoryHandler { register!(logs::GetRecentLogsHandler); register!(ast::ReadFileSkeletonHandler); register!(vision::ToggleClipboardWatchModeHandler); + register!(ast::ReplaceAstNodeHandler); + register!(workspaces::ReadDirectoryArchitectureHandler); + register!(workspaces::SemanticCodeSearchHandler); Self { state, diff --git a/server/src/tools.rs b/server/src/tools.rs index bc34839..efbbd0d 100644 --- a/server/src/tools.rs +++ b/server/src/tools.rs @@ -679,3 +679,24 @@ pub struct ToggleClipboardWatchModeTool { pub struct ReadFileSkeletonTool { pub file_path: String, } +/// Replace a specific AST node in a file (robust structural editing) +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct ReplaceAstNodeTool { + pub file_path: String, + pub node_type: String, // e.g., "function_item", "impl_item" + pub node_name: String, // e.g., "execute" + pub new_content: String, +} + +/// Semantic code search using local vector embeddings +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct SemanticCodeSearchTool { + pub query: String, + pub directory: Option, +} + +/// Get a bird's-eye view of directory architecture +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct ReadDirectoryArchitectureTool { + pub directory: String, +}