feat(embedding,vision): candle embeddings with offline fallback, on-demand clipboard vision capture, and concurrency audit
This commit is contained in:
1 parent
5bd8b1587a
commit
e4a0fe72df
47 files changed
+6292
-3503
No files matched your search
+135
-24
@@ -6,6 +6,24 @@ use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tree_sitter::{Node, Parser};
|
||||
|
||||
fn validate_safe_path(path_str: &str) -> crate::error::Result<()> {
|
||||
if path_str.trim().is_empty() || path_str.contains('\0') {
|
||||
return Err(crate::error::AppError::Internal(
|
||||
"Invalid file path: path is empty or contains null characters".to_string(),
|
||||
));
|
||||
}
|
||||
let path = std::path::Path::new(path_str);
|
||||
for component in path.components() {
|
||||
if component == std::path::Component::ParentDir {
|
||||
return Err(crate::error::AppError::Internal(format!(
|
||||
"Path traversal forbidden: '{}' contains parent directory relative components",
|
||||
path_str
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct ReadFileSkeletonHandler;
|
||||
|
||||
#[async_trait]
|
||||
@@ -26,13 +44,13 @@ impl McpTool for ReadFileSkeletonHandler {
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
|
||||
let file_path = tool_args.file_path.clone();
|
||||
validate_safe_path(&file_path)?;
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
|
||||
let code = std::fs::read_to_string(&file_path).map_err(|e| {
|
||||
crate::error::AppError::Internal(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())
|
||||
@@ -49,10 +67,10 @@ impl McpTool for ReadFileSkeletonHandler {
|
||||
_ => return Ok(code),
|
||||
};
|
||||
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
parser
|
||||
.set_language(&language.into())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
|
||||
let tree = parser.parse(&code, None).ok_or_else(|| {
|
||||
crate::error::AppError::Internal("Failed to parse code".to_string())
|
||||
})?;
|
||||
@@ -150,6 +168,8 @@ impl McpTool for ReplaceAstNodeHandler {
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
|
||||
let file_path = tool_args.file_path.clone();
|
||||
validate_safe_path(&file_path)?;
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
|
||||
let code = std::fs::read_to_string(&file_path).map_err(|e| {
|
||||
crate::error::AppError::Internal(format!("Failed to read file: {}", e))
|
||||
@@ -198,8 +218,14 @@ impl McpTool for ReplaceAstNodeHandler {
|
||||
| "method_definition"
|
||||
| "function"
|
||||
),
|
||||
"struct" => matches!(actual_kind, "struct_item" | "struct_declaration" | "struct_specifier"),
|
||||
"class" => matches!(actual_kind, "class_declaration" | "class_definition" | "class_item"),
|
||||
"struct" => matches!(
|
||||
actual_kind,
|
||||
"struct_item" | "struct_declaration" | "struct_specifier"
|
||||
),
|
||||
"class" => matches!(
|
||||
actual_kind,
|
||||
"class_declaration" | "class_definition" | "class_item"
|
||||
),
|
||||
"enum" => matches!(actual_kind, "enum_item" | "enum_declaration"),
|
||||
"trait" | "interface" => matches!(
|
||||
actual_kind,
|
||||
@@ -225,7 +251,11 @@ impl McpTool for ReplaceAstNodeHandler {
|
||||
let mut cursor = node.walk();
|
||||
for child in node.children(&mut cursor) {
|
||||
let kind = child.kind();
|
||||
if kind == "identifier" || kind == "name" || kind == "property_identifier" || kind == "field_identifier" {
|
||||
if kind == "identifier"
|
||||
|| kind == "name"
|
||||
|| kind == "property_identifier"
|
||||
|| kind == "field_identifier"
|
||||
{
|
||||
let name = child.utf8_text(code.as_bytes()).unwrap_or("");
|
||||
if name == target_name {
|
||||
return Some(node);
|
||||
@@ -254,12 +284,38 @@ impl McpTool for ReplaceAstNodeHandler {
|
||||
let start_byte = node.start_byte();
|
||||
let end_byte = node.end_byte();
|
||||
|
||||
let mut new_file_content = String::new();
|
||||
if !code.is_char_boundary(start_byte) || !code.is_char_boundary(end_byte) {
|
||||
return Err(crate::error::AppError::Internal(format!(
|
||||
"Byte offsets {}..{} do not fall on UTF-8 character boundaries in {}",
|
||||
start_byte, end_byte, file_path
|
||||
)));
|
||||
}
|
||||
|
||||
let mut new_file_content =
|
||||
String::with_capacity(code.len() + tool_args.new_content.len());
|
||||
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())?;
|
||||
let target_path = std::path::PathBuf::from(&file_path);
|
||||
let parent_dir = target_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."));
|
||||
let temp_file_path =
|
||||
parent_dir.join(format!(".tmp_ast_{}.tmp", uuid::Uuid::new_v4()));
|
||||
std::fs::write(&temp_file_path, new_file_content).map_err(|e| e.to_string())?;
|
||||
if let Err(e) = std::fs::rename(&temp_file_path, &target_path) {
|
||||
// On Windows, std::fs::rename fails if the target file already exists.
|
||||
// Fall back to copy-and-remove to ensure atomic-like overwrite behavior.
|
||||
if let Err(copy_err) = std::fs::copy(&temp_file_path, &target_path) {
|
||||
let _ = std::fs::remove_file(&temp_file_path);
|
||||
return Err(crate::error::AppError::Internal(format!(
|
||||
"Failed to atomically overwrite {}: rename failed ({}), copy failed ({})",
|
||||
file_path, e, copy_err
|
||||
)));
|
||||
}
|
||||
let _ = std::fs::remove_file(&temp_file_path);
|
||||
}
|
||||
Ok(format!(
|
||||
"Successfully replaced node {} of type {} in {}",
|
||||
tool_args.node_name, tool_args.node_type, file_path
|
||||
@@ -278,20 +334,31 @@ impl McpTool for ReplaceAstNodeHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_workspace_for_symbol(target_sym: &str, limit: usize, filter_fn_call: bool) -> Vec<serde_json::Value> {
|
||||
fn scan_workspace_for_symbol(
|
||||
target_sym: &str,
|
||||
limit: usize,
|
||||
filter_fn_call: bool,
|
||||
workspace_dir: Option<std::path::PathBuf>,
|
||||
) -> Vec<serde_json::Value> {
|
||||
let mut results = Vec::new();
|
||||
let cwd = match std::env::current_dir() {
|
||||
Ok(dir) => dir,
|
||||
Err(_) => return results,
|
||||
};
|
||||
let scan_dir = workspace_dir.unwrap_or_else(|| {
|
||||
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
|
||||
});
|
||||
|
||||
let walker = ignore::WalkBuilder::new(&cwd)
|
||||
if !scan_dir.exists() {
|
||||
return results;
|
||||
}
|
||||
|
||||
let walker = ignore::WalkBuilder::new(&scan_dir)
|
||||
.hidden(true)
|
||||
.git_ignore(true)
|
||||
.build();
|
||||
|
||||
let mut scanned_files = 0;
|
||||
|
||||
let call_pattern = format!("{}(", target_sym);
|
||||
let await_pattern = format!("{}.await", target_sym);
|
||||
|
||||
for result in walker {
|
||||
let entry = match result {
|
||||
Ok(e) => e,
|
||||
@@ -301,7 +368,20 @@ fn scan_workspace_for_symbol(target_sym: &str, limit: usize, filter_fn_call: boo
|
||||
if entry.file_type().is_some_and(|ft| ft.is_file()) {
|
||||
let path = entry.path();
|
||||
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||
if matches!(ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go" | "java" | "c" | "cpp" | "h" | "hpp") {
|
||||
if matches!(
|
||||
ext,
|
||||
"rs" | "ts"
|
||||
| "tsx"
|
||||
| "js"
|
||||
| "jsx"
|
||||
| "py"
|
||||
| "go"
|
||||
| "java"
|
||||
| "c"
|
||||
| "cpp"
|
||||
| "h"
|
||||
| "hpp"
|
||||
) {
|
||||
scanned_files += 1;
|
||||
if scanned_files > 500 {
|
||||
break;
|
||||
@@ -309,8 +389,7 @@ fn scan_workspace_for_symbol(target_sym: &str, limit: usize, filter_fn_call: boo
|
||||
if let Ok(content) = std::fs::read_to_string(path) {
|
||||
for (line_num, line) in content.lines().enumerate() {
|
||||
let is_match = if filter_fn_call {
|
||||
line.contains(&format!("{}(", target_sym))
|
||||
|| line.contains(&format!("{}.await", target_sym))
|
||||
line.contains(&call_pattern) || line.contains(&await_pattern)
|
||||
} else {
|
||||
line.contains(target_sym)
|
||||
};
|
||||
@@ -371,10 +450,22 @@ impl McpTool for FindSymbolReferencesHandler {
|
||||
Ok::<Vec<serde_json::Value>, crate::error::AppError>(refs)
|
||||
})?;
|
||||
|
||||
let custom_dir = if let Some(ref d) = req.workspace_dir {
|
||||
Some(std::path::PathBuf::from(d))
|
||||
} else {
|
||||
state.project.pinned_files.read_with(|pinned| {
|
||||
pinned.first().and_then(|pin| {
|
||||
std::path::Path::new(&pin.file_path)
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
if matches.len() < limit {
|
||||
let remaining = limit - matches.len();
|
||||
let disk_matches = tokio::task::spawn_blocking(move || {
|
||||
scan_workspace_for_symbol(&target_sym, remaining, false)
|
||||
scan_workspace_for_symbol(&target_sym, remaining, false, custom_dir)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
@@ -405,13 +496,13 @@ impl McpTool for GetCallersHandler {
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let limit = req.limit.unwrap_or(10);
|
||||
let target_fn = req.function_name.clone();
|
||||
let call_pattern = format!("{}(", target_fn);
|
||||
let await_pattern = format!("{}.await", target_fn);
|
||||
|
||||
let mut callers = state.code.snippets.read_with(|snippets| {
|
||||
let mut matching = Vec::new();
|
||||
for snippet in snippets {
|
||||
if snippet.code.contains(&format!("{}(", target_fn))
|
||||
|| snippet.code.contains(&format!("{}.await", target_fn))
|
||||
{
|
||||
if snippet.code.contains(&call_pattern) || snippet.code.contains(&await_pattern) {
|
||||
matching.push(serde_json::json!({
|
||||
"source": "snippet",
|
||||
"name": snippet.name,
|
||||
@@ -425,10 +516,22 @@ impl McpTool for GetCallersHandler {
|
||||
Ok::<Vec<serde_json::Value>, crate::error::AppError>(matching)
|
||||
})?;
|
||||
|
||||
let custom_dir = if let Some(ref d) = req.workspace_dir {
|
||||
Some(std::path::PathBuf::from(d))
|
||||
} else {
|
||||
state.project.pinned_files.read_with(|pinned| {
|
||||
pinned.first().and_then(|pin| {
|
||||
std::path::Path::new(&pin.file_path)
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
if callers.len() < limit {
|
||||
let remaining = limit - callers.len();
|
||||
let disk_callers = tokio::task::spawn_blocking(move || {
|
||||
scan_workspace_for_symbol(&target_fn, remaining, true)
|
||||
scan_workspace_for_symbol(&target_fn, remaining, true, custom_dir)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
@@ -468,9 +571,17 @@ impl McpTool for AnalyzeImpactHandler {
|
||||
}
|
||||
});
|
||||
|
||||
let custom_dir = state.project.pinned_files.read_with(|pinned| {
|
||||
pinned.first().and_then(|pin| {
|
||||
std::path::Path::new(&pin.file_path)
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
})
|
||||
});
|
||||
|
||||
let sym_clone = sym.clone();
|
||||
let disk_refs = tokio::task::spawn_blocking(move || {
|
||||
scan_workspace_for_symbol(&sym_clone, 20, false)
|
||||
scan_workspace_for_symbol(&sym_clone, 20, false, custom_dir)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -32,6 +32,7 @@ impl McpTool for UpdateEnvFingerprintHandler {
|
||||
shell: std::env::var("SHELL").unwrap_or_else(|_| "unknown".to_string()),
|
||||
tool_versions: req.tool_versions,
|
||||
updated_at: crate::handlers::utils::now_secs(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -94,6 +95,7 @@ impl McpTool for LogEnvRequirementHandler {
|
||||
key: req.key,
|
||||
description: req.description,
|
||||
is_secret: req.is_secret,
|
||||
..Default::default()
|
||||
});
|
||||
});
|
||||
Ok("Env requirement logged".to_string())
|
||||
@@ -127,6 +129,7 @@ impl McpTool for RegisterEnvironmentHandler {
|
||||
description: req.description,
|
||||
requires_vpn: req.requires_vpn,
|
||||
updated_at: crate::handlers::utils::now_secs(),
|
||||
..Default::default()
|
||||
});
|
||||
});
|
||||
Ok("Environment registered".to_string())
|
||||
@@ -262,6 +265,7 @@ mod tests {
|
||||
description: "Test env".to_string(),
|
||||
requires_vpn: false,
|
||||
updated_at: crate::handlers::utils::now_secs(),
|
||||
..Default::default()
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ impl McpTool for GetActiveWorktreeContextHandler {
|
||||
|
||||
if let Some(diff) = diff {
|
||||
let _ = diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
|
||||
match line.origin() {
|
||||
'+' | '-' | ' ' => diff_output.push(line.origin()),
|
||||
_ => {}
|
||||
}
|
||||
let content = std::str::from_utf8(line.content()).unwrap_or("");
|
||||
diff_output.push_str(content);
|
||||
true
|
||||
|
||||
@@ -38,21 +38,7 @@ impl<'a> GraphQueryBuilder<'a> {
|
||||
}
|
||||
|
||||
pub fn find_shortest_path(&self, start: &str, end: &str) -> Option<Vec<String>> {
|
||||
let mut adj: std::collections::HashMap<&str, Vec<(&str, &str, bool)>> =
|
||||
std::collections::HashMap::with_capacity(self.graph.relations.len() * 2);
|
||||
for rel in &self.graph.relations {
|
||||
if let Some(rf) = self.relation_filter {
|
||||
if rel.relation_type != rf {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
adj.entry(rel.from.as_str())
|
||||
.or_default()
|
||||
.push((rel.to.as_str(), rel.relation_type.as_str(), false));
|
||||
adj.entry(rel.to.as_str())
|
||||
.or_default()
|
||||
.push((rel.from.as_str(), rel.relation_type.as_str(), true));
|
||||
}
|
||||
let adj = self.graph.build_adjacency_map(self.relation_filter);
|
||||
|
||||
let mut queue = std::collections::VecDeque::new();
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
@@ -253,6 +239,7 @@ impl McpTool for CreateRelationsHandler {
|
||||
observations: vec!["Auto-created stub entity for relation endpoint".to_string()],
|
||||
namespace: crate::models::default_namespace(),
|
||||
git_branch: None,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
auto_created.push(node_name);
|
||||
@@ -328,7 +315,10 @@ impl McpTool for DeleteEntitiesHandler {
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<DeleteEntitiesTool>("delete_entities", "Execute delete_entities")
|
||||
crate::mcp::tool_def::<DeleteEntitiesTool>(
|
||||
"delete_entities",
|
||||
"Delete specified entities and all their associated relations and observations from the knowledge graph.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
@@ -378,7 +368,7 @@ impl McpTool for DeleteObservationsHandler {
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<DeleteObservationsTool>(
|
||||
"delete_observations",
|
||||
"Execute delete_observations",
|
||||
"Delete specific observations from existing entities in the knowledge graph without removing the entity itself.",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -422,7 +412,10 @@ impl McpTool for DeleteRelationsHandler {
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<DeleteRelationsTool>("delete_relations", "Execute delete_relations")
|
||||
crate::mcp::tool_def::<DeleteRelationsTool>(
|
||||
"delete_relations",
|
||||
"Delete specific relationships between entities in the knowledge graph by specifying source 'from', target 'to', and 'relation_type'.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
@@ -462,8 +455,45 @@ 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 limit = req.limit;
|
||||
let offset = req.offset.unwrap_or(0);
|
||||
|
||||
let data = state.read_graph(|full| -> crate::error::Result<String> {
|
||||
let mut result_json = if let Some(ns) = req.namespace {
|
||||
let mut result_json = if limit.is_some() || req.offset.is_some() {
|
||||
let filtered_entities: Vec<(&String, &crate::models::Entity)> = full
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|(_, v)| req.namespace.as_ref().map_or(true, |ns| &v.namespace == ns))
|
||||
.collect();
|
||||
|
||||
let total_count = filtered_entities.len();
|
||||
let effective_limit = limit.unwrap_or(50);
|
||||
let paged_entities: std::collections::HashMap<&String, &crate::models::Entity> = filtered_entities
|
||||
.into_iter()
|
||||
.skip(offset)
|
||||
.take(effective_limit)
|
||||
.collect();
|
||||
|
||||
let has_more = offset + paged_entities.len() < total_count;
|
||||
|
||||
let relevant_relations: Vec<&crate::models::Relation> = full
|
||||
.relations
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
let ns_match = req.namespace.as_ref().map_or(true, |ns| &r.namespace == ns);
|
||||
ns_match && (paged_entities.contains_key(&r.from) || paged_entities.contains_key(&r.to))
|
||||
})
|
||||
.collect();
|
||||
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"entities": paged_entities,
|
||||
"relations": relevant_relations,
|
||||
"total_count": total_count,
|
||||
"offset": offset,
|
||||
"limit": effective_limit,
|
||||
"has_more": has_more,
|
||||
}))?
|
||||
} else if let Some(ns) = req.namespace {
|
||||
let mut filtered = BorrowedGraph::default();
|
||||
for (k, v) in &full.entities {
|
||||
if v.namespace == ns {
|
||||
@@ -485,7 +515,7 @@ impl McpTool for ReadGraphHandler {
|
||||
if result_json.len() > max_chars {
|
||||
let valid_boundary = result_json.floor_char_boundary(max_chars);
|
||||
result_json.truncate(valid_boundary);
|
||||
result_json.push_str("\n... [TRUNCATED_TO_MAX_TOKENS. Use search_nodes or get_subgraph for targeted discovery]");
|
||||
result_json.push_str("\n... [TRUNCATED_TO_MAX_TOKENS. Use search_nodes, pagination (limit, offset), or get_subgraph for targeted discovery]");
|
||||
}
|
||||
}
|
||||
Ok(result_json)
|
||||
@@ -503,7 +533,10 @@ impl McpTool for SearchNodesHandler {
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<SearchNodesTool>("search_nodes", "Execute search_nodes")
|
||||
crate::mcp::tool_def::<SearchNodesTool>(
|
||||
"search_nodes",
|
||||
"Search knowledge graph entities and nodes by name or observation query. Returns matching entity names, types, and observations.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
@@ -550,7 +583,10 @@ impl McpTool for OpenNodesHandler {
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<OpenNodesTool>("open_nodes", "Execute open_nodes")
|
||||
crate::mcp::tool_def::<OpenNodesTool>(
|
||||
"open_nodes",
|
||||
"Retrieve complete node details, observations, and connected relations for specific entities in the knowledge graph.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
@@ -588,7 +624,10 @@ impl McpTool for VisualizeGraphHandler {
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<VisualizeGraphTool>("visualize_graph", "Execute visualize_graph")
|
||||
crate::mcp::tool_def::<VisualizeGraphTool>(
|
||||
"visualize_graph",
|
||||
"Generate a visual Mermaid representation of the knowledge graph topology or a filtered query subset.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
@@ -677,7 +716,10 @@ impl McpTool for CondenseEntityHandler {
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<CondenseEntityTool>("condense_entity", "Execute condense_entity")
|
||||
crate::mcp::tool_def::<CondenseEntityTool>(
|
||||
"condense_entity",
|
||||
"Condense, summarize, and deduplicate redundant observations on an entity to optimize context size while preserving key facts.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
@@ -757,7 +799,10 @@ impl McpTool for MergeEntitiesHandler {
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<MergeEntitiesTool>("merge_entities", "Execute merge_entities")
|
||||
crate::mcp::tool_def::<MergeEntitiesTool>(
|
||||
"merge_entities",
|
||||
"Merge two duplicate entities into a primary target entity, consolidating all observations and re-pointing relations.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
@@ -817,7 +862,10 @@ impl McpTool for FindOrphansHandler {
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<FindOrphansTool>("find_orphans", "Execute find_orphans")
|
||||
crate::mcp::tool_def::<FindOrphansTool>(
|
||||
"find_orphans",
|
||||
"Find orphan nodes in the knowledge graph that have no incoming or outgoing relationships.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
@@ -847,7 +895,10 @@ impl McpTool for GetSubgraphHandler {
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<GetSubgraphTool>("get_subgraph", "Execute get_subgraph")
|
||||
crate::mcp::tool_def::<GetSubgraphTool>(
|
||||
"get_subgraph",
|
||||
"Extract a connected neighborhood subgraph centered around specified seed nodes up to a given hop depth.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
@@ -1188,6 +1239,7 @@ mod tests {
|
||||
observations: vec![],
|
||||
namespace: "global".to_string(),
|
||||
git_branch: None,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
g.entities.insert(
|
||||
@@ -1198,6 +1250,7 @@ mod tests {
|
||||
observations: vec![],
|
||||
namespace: "global".to_string(),
|
||||
git_branch: None,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1256,6 +1309,7 @@ mod tests {
|
||||
observations: vec![],
|
||||
namespace: "global".to_string(),
|
||||
git_branch: None,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1447,6 +1501,14 @@ mod tests {
|
||||
let qsignal_res = qsignal_handler.execute(json!({"sender": "agent1"}), state.clone()).await.unwrap();
|
||||
assert!(qsignal_res.contains("task_completed"));
|
||||
|
||||
let read_paged_handler = ReadGraphHandler;
|
||||
let paged_res = read_paged_handler
|
||||
.execute(json!({"limit": 1, "offset": 0}), state.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let parsed_paged: serde_json::Value = serde_json::from_str(&paged_res).unwrap();
|
||||
assert_eq!(parsed_paged["total_count"], 2);
|
||||
assert_eq!(parsed_paged["has_more"], true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ impl McpTool for WatchProcessLogsHandler {
|
||||
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let tool_args: WatchProcessLogsTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
if !std::path::Path::new(&tool_args.file_path).exists() {
|
||||
let safe_path = crate::handlers::utils::validate_safe_path(&tool_args.file_path)?;
|
||||
if !safe_path.exists() {
|
||||
return Err(crate::error::AppError::Internal(format!(
|
||||
"File does not exist: {}",
|
||||
tool_args.file_path
|
||||
@@ -53,9 +54,10 @@ impl McpTool for GetRecentLogsHandler {
|
||||
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let tool_args: GetRecentLogsTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let safe_path = crate::handlers::utils::validate_safe_path(&tool_args.file_path)?;
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
|
||||
let mut file = File::open(&tool_args.file_path).map_err(|e| {
|
||||
let mut file = File::open(&safe_path).map_err(|e| {
|
||||
crate::error::AppError::Internal(format!("Failed to open file: {}", e))
|
||||
})?;
|
||||
let len = file.metadata().map_err(|e| e.to_string())?.len();
|
||||
|
||||
+250
-57
@@ -49,6 +49,11 @@ impl McpTool for LogDecisionHandler {
|
||||
status: "accepted".to_string(),
|
||||
supersedes: req.supersedes,
|
||||
timestamp: crate::handlers::utils::now_secs(),
|
||||
namespace: req.namespace.unwrap_or_else(|| crate::models::default_namespace()),
|
||||
repo_name: req.repo_name,
|
||||
alternatives_considered: req.alternatives_considered.unwrap_or_default(),
|
||||
affected_components: req.affected_components.unwrap_or_default(),
|
||||
author: req.author,
|
||||
};
|
||||
|
||||
adrs_to_index.push(a.clone());
|
||||
@@ -201,7 +206,11 @@ impl McpTool for LogErrorFixHandler {
|
||||
git_commit: req.git_commit,
|
||||
git_branch: req.git_branch,
|
||||
embedding,
|
||||
})
|
||||
..Default::default()
|
||||
});
|
||||
if fixes.len() > 300 {
|
||||
fixes.remove(0);
|
||||
}
|
||||
});
|
||||
state.record_activity("error_fix", &format!("Fixed error: {}", req.signature), Some(&solution));
|
||||
Ok(format!("Logged error fix for {}: {}", req.signature, solution))
|
||||
@@ -238,8 +247,13 @@ impl McpTool for SearchErrorFixesHandler {
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let mut score = 0.0;
|
||||
if st.contains(&f.signature) || f.signature.contains(st) {
|
||||
let st_lower = st.to_lowercase();
|
||||
let sig_lower = f.signature.to_lowercase();
|
||||
let sol_lower = f.solution.to_lowercase();
|
||||
if st_lower.contains(&sig_lower) || sig_lower.contains(&st_lower) {
|
||||
score += 0.8;
|
||||
} else if st_lower.contains(&sol_lower) || sol_lower.contains(&st_lower) {
|
||||
score += 0.5;
|
||||
}
|
||||
if let Some(emb) = &f.embedding {
|
||||
if !query_emb.is_empty() {
|
||||
@@ -343,16 +357,65 @@ impl McpTool for LogCodeChangeHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
let change_kind = match req.change_kind.as_deref().map(|s| s.to_lowercase()).as_deref() {
|
||||
Some("added") | Some("create") | Some("created") | Some("new") => crate::models::ChangeKind::Added,
|
||||
Some("deleted") | Some("remove") | Some("removed") => crate::models::ChangeKind::Deleted,
|
||||
Some("renamed") | Some("move") | Some("moved") => crate::models::ChangeKind::Renamed,
|
||||
_ => crate::models::ChangeKind::Modified,
|
||||
};
|
||||
|
||||
let namespace = req
|
||||
.namespace
|
||||
.filter(|ns| !ns.trim().is_empty())
|
||||
.or_else(|| req.repo_name.clone().filter(|rn| !rn.trim().is_empty()))
|
||||
.unwrap_or_else(crate::models::default_namespace);
|
||||
|
||||
let symbols = req.symbol_references.clone().unwrap_or_default();
|
||||
let line_range = req.line_range.clone();
|
||||
|
||||
let effective_rev = req.revision.clone().or_else(|| req.git_commit.clone());
|
||||
let effective_branch = req.branch.clone().or_else(|| req.git_branch.clone());
|
||||
let detected_vcs = if let Some(vcs) = req.vcs_type.clone() {
|
||||
Some(vcs)
|
||||
} else if let Some(ref rev) = effective_rev {
|
||||
if rev.starts_with('r') && rev[1..].chars().all(|c| c.is_ascii_digit()) {
|
||||
Some("svn".to_string())
|
||||
} else if req.repo_url.as_deref().map(|u| u.contains("/svn/")).unwrap_or(false) {
|
||||
Some("svn".to_string())
|
||||
} else {
|
||||
Some("git".to_string())
|
||||
}
|
||||
} else if let Some(ref br) = effective_branch {
|
||||
if br.eq_ignore_ascii_case("trunk") || br.starts_with("branches/") || br.starts_with("tags/") {
|
||||
Some("svn".to_string())
|
||||
} else {
|
||||
Some("git".to_string())
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
state.code.ledger.modify(|ledger| {
|
||||
ledger.push(CodeChange {
|
||||
timestamp: crate::handlers::utils::now_secs(),
|
||||
file_path: req.file_path.clone(),
|
||||
description: description.clone(),
|
||||
git_commit: req.git_commit,
|
||||
git_branch: req.git_branch,
|
||||
git_commit: effective_rev.clone(),
|
||||
git_branch: effective_branch.clone(),
|
||||
repo_name: req.repo_name,
|
||||
repo_url: req.repo_url,
|
||||
namespace: namespace.clone(),
|
||||
change_kind,
|
||||
symbols,
|
||||
line_range,
|
||||
author: req.author,
|
||||
session_id: req.session_id,
|
||||
vcs_type: detected_vcs,
|
||||
revision: effective_rev,
|
||||
branch: effective_branch,
|
||||
repository_root: req.repository_root,
|
||||
});
|
||||
if ledger.len() > 1000 {
|
||||
if ledger.len() > 500 {
|
||||
ledger.remove(0);
|
||||
}
|
||||
});
|
||||
@@ -376,15 +439,44 @@ impl McpTool for QueryRecentChangesHandler {
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let data = state
|
||||
.code
|
||||
.ledger
|
||||
.read_with(|l| Ok::<String, crate::error::AppError>(serde_json::to_string(l)?))?;
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: QueryRecentChangesTool = serde_json::from_value(args).unwrap_or(QueryRecentChangesTool {
|
||||
namespace: None,
|
||||
repo_name: None,
|
||||
vcs_type: None,
|
||||
limit: None,
|
||||
});
|
||||
let limit = req.limit.unwrap_or(50);
|
||||
let data = state.code.ledger.read_with(|l| {
|
||||
let filtered: Vec<_> = l
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|c| {
|
||||
if let Some(ns) = &req.namespace {
|
||||
if !c.namespace.eq_ignore_ascii_case(ns) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(repo) = &req.repo_name {
|
||||
if c.repo_name.as_ref().map(|rn| !rn.eq_ignore_ascii_case(repo)).unwrap_or(true) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(vcs) = &req.vcs_type {
|
||||
if !c.effective_vcs().eq_ignore_ascii_case(vcs) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.take(limit)
|
||||
.cloned()
|
||||
.collect();
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?)
|
||||
})?;
|
||||
Ok(data)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LearnPreferenceHandler;
|
||||
|
||||
#[async_trait]
|
||||
@@ -409,6 +501,7 @@ impl McpTool for LearnPreferenceHandler {
|
||||
key: req.key.clone(),
|
||||
value: req.value,
|
||||
updated_at: crate::handlers::utils::now_secs(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -474,6 +567,12 @@ impl McpTool for LogTechDebtHandler {
|
||||
git_commit: req.git_commit,
|
||||
git_branch: req.git_branch,
|
||||
embedding,
|
||||
repo_name: req.repo_name,
|
||||
severity: req.severity,
|
||||
file_path: req.file_path,
|
||||
line_range: req.line_range,
|
||||
workaround: req.workaround,
|
||||
effort_estimate: req.effort_estimate,
|
||||
})
|
||||
});
|
||||
Ok("Tech debt logged".to_string())
|
||||
@@ -605,7 +704,11 @@ impl McpTool for DecisionsHandler {
|
||||
"context": context,
|
||||
"decision": decision,
|
||||
"consequences": consequences,
|
||||
"namespace": ns
|
||||
"namespace": ns,
|
||||
"repo_name": req.repo_name,
|
||||
"author": req.author,
|
||||
"affected_components": req.affected_components,
|
||||
"alternatives_considered": req.alternatives_considered,
|
||||
}),
|
||||
state,
|
||||
).await
|
||||
@@ -655,7 +758,12 @@ impl McpTool for TechDebtHandler {
|
||||
"git_branch": req.git_branch,
|
||||
"symbol_references": req.symbol_references,
|
||||
"line_range": req.line_range,
|
||||
"namespace": ns
|
||||
"namespace": ns,
|
||||
"repo_name": req.repo_name,
|
||||
"severity": req.severity,
|
||||
"file_path": req.file_path,
|
||||
"workaround": req.workaround,
|
||||
"effort_estimate": req.effort_estimate,
|
||||
}),
|
||||
state,
|
||||
).await
|
||||
@@ -699,23 +807,12 @@ impl McpTool for OmniSearchHandler {
|
||||
.search(&req.query, req.namespace.as_deref())
|
||||
.unwrap_or_default();
|
||||
|
||||
let q = req.query.clone();
|
||||
let query_emb = crate::embedding::generate_embedding_async(q.clone())
|
||||
let vector_matches = state
|
||||
.search()
|
||||
.semantic_search(&req.query, req.namespace.as_deref(), limit * 2)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let vector_matches = if !query_emb.is_empty() {
|
||||
if let Some(vdb) = &*state.vector_db.read().await {
|
||||
vdb.search(query_emb.clone(), (limit * 2) as u64)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
// Reciprocal Rank Fusion (RRF) algorithm
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone)]
|
||||
@@ -754,8 +851,8 @@ impl McpTool for OmniSearchHandler {
|
||||
let item = MatchItem {
|
||||
id: v_match.id.clone(),
|
||||
doc_type: v_match.doc_type,
|
||||
title: v_match.id,
|
||||
body: v_match.text,
|
||||
title: v_match.title,
|
||||
body: v_match.body,
|
||||
};
|
||||
rrf_scores.insert(item_id, (score, item));
|
||||
}
|
||||
@@ -770,29 +867,38 @@ impl McpTool for OmniSearchHandler {
|
||||
let kg_json = state.read_graph(|full| {
|
||||
let mut kg_results = serde_json::Map::new();
|
||||
let mut count = 0;
|
||||
|
||||
// Build pre-indexed adjacency map: O(R) once instead of O(E * R)
|
||||
let mut adj_map: std::collections::HashMap<&str, Vec<(&str, &str, &str)>> =
|
||||
std::collections::HashMap::new();
|
||||
for rel in &full.relations {
|
||||
adj_map
|
||||
.entry(rel.from.as_str())
|
||||
.or_default()
|
||||
.push((rel.to.as_str(), rel.relation_type.as_str(), "outgoing"));
|
||||
adj_map
|
||||
.entry(rel.to.as_str())
|
||||
.or_default()
|
||||
.push((rel.from.as_str(), rel.relation_type.as_str(), "incoming"));
|
||||
}
|
||||
|
||||
for res in &matches {
|
||||
if res.doc_type == "entity"
|
||||
&& let Some(e) = full.entities.get(&res.id)
|
||||
{
|
||||
if count >= limit {
|
||||
continue;
|
||||
break;
|
||||
}
|
||||
count += 1;
|
||||
|
||||
// 1-hop relation expansion for GraphRAG
|
||||
// 1-hop relation expansion for GraphRAG via pre-indexed adjacency
|
||||
let mut connected_rels = Vec::new();
|
||||
for rel in &full.relations {
|
||||
if rel.from == res.id {
|
||||
if let Some(rels) = adj_map.get(res.id.as_str()) {
|
||||
for (target, rel_type, direction) in rels {
|
||||
connected_rels.push(serde_json::json!({
|
||||
"target": rel.to,
|
||||
"relation": rel.relation_type,
|
||||
"direction": "outgoing"
|
||||
}));
|
||||
} else if rel.to == res.id {
|
||||
connected_rels.push(serde_json::json!({
|
||||
"target": rel.from,
|
||||
"relation": rel.relation_type,
|
||||
"direction": "incoming"
|
||||
"target": target,
|
||||
"relation": rel_type,
|
||||
"direction": direction
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -851,6 +957,11 @@ impl McpTool for OmniSearchHandler {
|
||||
serde_json::to_value(&filtered).map_err(|e| e.to_string())
|
||||
})?;
|
||||
|
||||
let q = req.query.clone();
|
||||
let query_emb = crate::embedding::generate_embedding_async(req.query.clone())
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let snippets_json = state.code.snippets.read_with(|all_snippets| {
|
||||
let mut scored: Vec<_> = all_snippets
|
||||
.iter()
|
||||
@@ -907,7 +1018,6 @@ impl McpTool for OmniSearchHandler {
|
||||
serde_json::to_value(&filtered).map_err(|e| e.to_string())
|
||||
})?;
|
||||
|
||||
let q = req.query;
|
||||
let tech_debts_json = state.code.tech_debts.read_with(|debts| {
|
||||
let mut scored: Vec<_> = debts
|
||||
.iter()
|
||||
@@ -995,7 +1105,7 @@ impl McpTool for OmniSearchHandler {
|
||||
serde_json::to_value(&filtered).map_err(|e| e.to_string())
|
||||
})?;
|
||||
|
||||
let report = serde_json::json!({
|
||||
let mut report = serde_json::json!({
|
||||
"knowledge_graph": kg_json,
|
||||
"tasks": tasks_json,
|
||||
"snippets": snippets_json,
|
||||
@@ -1004,15 +1114,52 @@ impl McpTool for OmniSearchHandler {
|
||||
"handoff_memos": memos_json,
|
||||
"error_fixes": error_fixes_json
|
||||
});
|
||||
let mut out_str = report.to_string();
|
||||
if let Some(max_tok) = req.max_tokens {
|
||||
let max_chars = max_tok * 4;
|
||||
let mut out_str = report.to_string();
|
||||
if out_str.len() > max_chars {
|
||||
out_str.truncate(max_chars);
|
||||
out_str.push_str("... [TRUNCATED_TO_MAX_TOKENS]");
|
||||
let prune_keys = [
|
||||
"error_fixes",
|
||||
"tech_debts",
|
||||
"snippets",
|
||||
"adrs",
|
||||
"handoff_memos",
|
||||
"knowledge_graph",
|
||||
"tasks",
|
||||
];
|
||||
let mut pruned = false;
|
||||
for key in prune_keys {
|
||||
while out_str.len() > max_chars {
|
||||
let popped = if let Some(arr) = report.get_mut(key).and_then(|v| v.as_array_mut()) {
|
||||
if arr.len() > 1 {
|
||||
arr.pop();
|
||||
pruned = true;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if popped {
|
||||
out_str = report.to_string();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if out_str.len() <= max_chars {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if pruned && let Some(obj) = report.as_object_mut() {
|
||||
obj.insert(
|
||||
"_truncated_to_max_tokens".to_string(),
|
||||
serde_json::Value::Bool(true),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out_str)
|
||||
Ok(report.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1101,8 +1248,13 @@ impl McpTool for SuggestErrorFixHandler {
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let mut score = 0.0;
|
||||
if req.stack_trace.contains(&f.signature) || f.signature.contains(&req.stack_trace) {
|
||||
let st_lower = req.stack_trace.to_lowercase();
|
||||
let sig_lower = f.signature.to_lowercase();
|
||||
let sol_lower = f.solution.to_lowercase();
|
||||
if st_lower.contains(&sig_lower) || sig_lower.contains(&st_lower) {
|
||||
score += 0.8;
|
||||
} else if st_lower.contains(&sol_lower) || sol_lower.contains(&st_lower) {
|
||||
score += 0.5;
|
||||
}
|
||||
if let Some(emb) = &f.embedding {
|
||||
if !query_emb.is_empty() {
|
||||
@@ -1178,6 +1330,7 @@ impl McpTool for ManageCheckpointHandler {
|
||||
timestamp: crate::handlers::utils::now_secs(),
|
||||
description: desc.clone(),
|
||||
namespace: ns,
|
||||
..Default::default()
|
||||
};
|
||||
state.project.snapshots.modify(|snaps| snaps.push(snapshot));
|
||||
}
|
||||
@@ -1381,22 +1534,31 @@ impl McpTool for QueryLineageHandler {
|
||||
"decision": adr.decision
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let changes = state.code.ledger.read_with(|c| c.clone());
|
||||
for change in changes {
|
||||
let file_match = change.file_path.to_lowercase().contains(&q);
|
||||
let desc_match = change.description.to_lowercase().contains(&q);
|
||||
if file_match || desc_match {
|
||||
let repo_match = change.repo_name.as_ref().map(|r| r.to_lowercase().contains(&q)).unwrap_or(false);
|
||||
let symbol_match = change.symbols.iter().any(|s| s.to_lowercase().contains(&q));
|
||||
let ns_match = change.namespace.to_lowercase().contains(&q);
|
||||
if file_match || desc_match || repo_match || symbol_match || ns_match {
|
||||
timeline.push(serde_json::json!({
|
||||
"timestamp": change.timestamp,
|
||||
"type": "CodeChange",
|
||||
"file": change.file_path,
|
||||
"description": change.description,
|
||||
"commit": change.git_commit
|
||||
"commit": change.git_commit,
|
||||
"branch": change.git_branch,
|
||||
"repo_name": change.repo_name,
|
||||
"namespace": change.namespace,
|
||||
"change_kind": change.change_kind,
|
||||
"symbols": change.symbols,
|
||||
"line_range": change.line_range,
|
||||
"author": change.author,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let fixes = state.code.error_fixes.read_with(|f| f.clone());
|
||||
for fix in fixes {
|
||||
@@ -1509,6 +1671,7 @@ impl McpTool for LogHypothesisHandler {
|
||||
status: req.status.unwrap_or_else(|| "unverified".to_string()),
|
||||
evidence: req.evidence,
|
||||
timestamp,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
state.code.hypotheses.modify(|h| h.push(record));
|
||||
@@ -1576,10 +1739,16 @@ impl McpTool for GetPreflightContextHandler {
|
||||
let pinned = state.project.pinned_files.read_with(|p| p.clone());
|
||||
let tech_debts = state.code.tech_debts.read_with(|d| d.clone());
|
||||
let hypotheses = state.code.hypotheses.read_with(|h| h.clone());
|
||||
let recent_commands = state.telemetry.terminal_history.read_with(|h| {
|
||||
h.iter().take(5).cloned().collect::<Vec<_>>()
|
||||
});
|
||||
let recent_activities = state.telemetry.recent_activities.read_with(|a| {
|
||||
a.iter().take(5).cloned().collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
let active_tasks: Vec<_> = tasks
|
||||
.into_iter()
|
||||
.filter(|t| t.status == "in_progress" || t.status == "open")
|
||||
.filter(|t| !matches!(t.status.as_str(), "completed" | "done" | "cancelled"))
|
||||
.map(|t| serde_json::json!({
|
||||
"id": t.id,
|
||||
"title": t.title,
|
||||
@@ -1590,7 +1759,12 @@ impl McpTool for GetPreflightContextHandler {
|
||||
|
||||
let branch_pinned: Vec<_> = pinned
|
||||
.into_iter()
|
||||
.filter(|p| p.namespace == req.namespace && (req.git_branch.is_none() || p.git_branch == req.git_branch))
|
||||
.filter(|p| {
|
||||
p.namespace == req.namespace
|
||||
&& (req.git_branch.is_none()
|
||||
|| p.git_branch.is_none()
|
||||
|| p.git_branch == req.git_branch)
|
||||
})
|
||||
.map(|p| p.file_path)
|
||||
.collect();
|
||||
|
||||
@@ -1617,7 +1791,9 @@ impl McpTool for GetPreflightContextHandler {
|
||||
"active_tasks": active_tasks,
|
||||
"pinned_files": branch_pinned,
|
||||
"top_open_tech_debts": open_tech_debts,
|
||||
"active_hypotheses": active_hypotheses
|
||||
"active_hypotheses": active_hypotheses,
|
||||
"recent_terminal_commands": recent_commands,
|
||||
"recent_activities": recent_activities
|
||||
});
|
||||
|
||||
Ok(serde_json::to_string_pretty(&preflight)?)
|
||||
@@ -1652,6 +1828,7 @@ impl McpTool for BroadcastAgentSignalHandler {
|
||||
payload: req.payload,
|
||||
timestamp,
|
||||
ttl_seconds: req.ttl_seconds,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
state.telemetry.agent_signals.modify(|s| {
|
||||
@@ -1747,7 +1924,11 @@ impl McpTool for AutoSessionCheckpointHandler {
|
||||
let hypotheses = state.code.hypotheses.read_with(|h| h.clone());
|
||||
let ledger = state.code.ledger.read_with(|l| l.clone());
|
||||
|
||||
let active_tasks: Vec<_> = tasks.iter().filter(|t| t.status == "in_progress" || t.status == "open").map(|t| t.title.as_str()).collect();
|
||||
let active_tasks: Vec<_> = tasks
|
||||
.iter()
|
||||
.filter(|t| !matches!(t.status.as_str(), "completed" | "done" | "cancelled"))
|
||||
.map(|t| t.title.as_str())
|
||||
.collect();
|
||||
let unverified_hyp: Vec<_> = hypotheses.iter().filter(|h| h.status == "unverified").map(|h| h.hypothesis.as_str()).collect();
|
||||
let recent_changes: Vec<_> = ledger.iter().rev().take(5).map(|c| c.file_path.as_str()).collect();
|
||||
|
||||
@@ -1765,6 +1946,7 @@ impl McpTool for AutoSessionCheckpointHandler {
|
||||
expires_at: None,
|
||||
namespace: req.namespace,
|
||||
timestamp,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
state.telemetry.handoff_memos.modify(|m| {
|
||||
@@ -2036,6 +2218,7 @@ mod tests {
|
||||
expires_at: None,
|
||||
dependencies: vec![],
|
||||
acceptance_criteria: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
{
|
||||
@@ -2232,6 +2415,7 @@ mod tests {
|
||||
expires_at: None,
|
||||
dependencies: vec![],
|
||||
acceptance_criteria: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
state.project.tasks.modify(|t| t.push(task));
|
||||
|
||||
@@ -2408,6 +2592,7 @@ mod tests {
|
||||
tags: vec!["rust".to_string(), "mcp".to_string()],
|
||||
updated_at: 0,
|
||||
embedding: None,
|
||||
..Default::default()
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2467,6 +2652,7 @@ mod tests {
|
||||
expires_at: None,
|
||||
dependencies: vec![],
|
||||
acceptance_criteria: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
let task2 = crate::models::Task {
|
||||
id: "t-2".to_string(),
|
||||
@@ -2480,6 +2666,7 @@ mod tests {
|
||||
expires_at: None,
|
||||
dependencies: vec!["t-1".to_string()],
|
||||
acceptance_criteria: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
state.project.tasks.modify(|t| {
|
||||
t.push(task1);
|
||||
@@ -2607,6 +2794,7 @@ mod tests {
|
||||
observations: vec!["Obs 1".to_string()],
|
||||
namespace: "global".to_string(),
|
||||
git_branch: None,
|
||||
..Default::default()
|
||||
});
|
||||
g.entities.insert("Ent2".to_string(), crate::models::Entity {
|
||||
name: "Ent2".to_string(),
|
||||
@@ -2614,12 +2802,14 @@ mod tests {
|
||||
observations: vec!["Obs 2".to_string()],
|
||||
namespace: "global".to_string(),
|
||||
git_branch: None,
|
||||
..Default::default()
|
||||
});
|
||||
g.relations.push(crate::models::Relation {
|
||||
from: "Ent1".to_string(),
|
||||
to: "Ent2".to_string(),
|
||||
relation_type: "uses".to_string(),
|
||||
namespace: "global".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2693,6 +2883,7 @@ mod tests {
|
||||
timestamp: 0,
|
||||
description: "Test snap".to_string(),
|
||||
namespace: "global".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
});
|
||||
let rest_snap = mg_chk
|
||||
@@ -2737,6 +2928,7 @@ mod tests {
|
||||
dependencies: vec!["NON-EXISTENT-TASK".to_string()],
|
||||
description: "Blocked task desc".to_string(),
|
||||
expires_at: None,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
|
||||
@@ -2756,6 +2948,7 @@ mod tests {
|
||||
timestamp: 0,
|
||||
namespace: "global".to_string(),
|
||||
git_branch: Some("main".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ impl McpTool for StickyNotesHandler {
|
||||
timestamp: now,
|
||||
content,
|
||||
expires_at,
|
||||
..Default::default()
|
||||
});
|
||||
});
|
||||
Ok("Sticky note added.".to_string())
|
||||
@@ -72,9 +73,11 @@ impl McpTool for StickyNotesHandler {
|
||||
})?;
|
||||
let mut success = false;
|
||||
state.code.sticky.modify(|notes| {
|
||||
let target_pos = if idx == 0 { 0 } else { idx - 1 };
|
||||
if target_pos < notes.len() {
|
||||
notes.remove(target_pos);
|
||||
if idx > 0 && idx <= notes.len() {
|
||||
notes.remove(idx - 1);
|
||||
success = true;
|
||||
} else if idx == 0 && !notes.is_empty() {
|
||||
notes.remove(0);
|
||||
success = true;
|
||||
}
|
||||
});
|
||||
@@ -129,6 +132,7 @@ impl McpTool for HandoffMemosHandler {
|
||||
namespace: ns,
|
||||
timestamp: crate::handlers::utils::now_secs(),
|
||||
expires_at: None,
|
||||
..Default::default()
|
||||
});
|
||||
if memos.len() > 200 {
|
||||
memos.remove(0);
|
||||
@@ -190,6 +194,7 @@ impl McpTool for AddSessionSummaryHandler {
|
||||
namespace: req.namespace,
|
||||
timestamp: crate::handlers::utils::now_secs(),
|
||||
expires_at: None,
|
||||
..Default::default()
|
||||
});
|
||||
if summaries.len() > 200 {
|
||||
summaries.remove(0);
|
||||
@@ -273,6 +278,7 @@ impl McpTool for PromoteToEntityHandler {
|
||||
observations: Vec::new(),
|
||||
namespace,
|
||||
git_branch: None,
|
||||
..Default::default()
|
||||
});
|
||||
if !entity.observations.contains(&obs) {
|
||||
entity.observations.push(obs.clone());
|
||||
|
||||
+120
-33
@@ -45,21 +45,34 @@ impl McpTool for TasksHandler {
|
||||
dependencies: deps,
|
||||
acceptance_criteria: vec![],
|
||||
expires_at: None,
|
||||
namespace: req.namespace.unwrap_or_else(|| "default".to_string()),
|
||||
repo_name: req.repo_name,
|
||||
priority: req.priority,
|
||||
assigned_agent: req.assigned_agent,
|
||||
verification_command: req.verification_command,
|
||||
..Default::default()
|
||||
};
|
||||
let idx = state.get_search_index().await;
|
||||
drop(idx.index_task(&task));
|
||||
state.project.tasks.modify(|tasks| {
|
||||
tasks.push(task.clone());
|
||||
});
|
||||
state.record_activity("task_create", &format!("Created task: {}", task.title), Some(&task.description));
|
||||
state.record_activity(
|
||||
"task_create",
|
||||
&format!("Created task: {}", task.title),
|
||||
Some(&task.description),
|
||||
);
|
||||
state.broadcast_task_event(TaskEvent {
|
||||
task_id: task_id.clone(),
|
||||
status: "created".to_string(),
|
||||
action: Some("add".to_string()),
|
||||
result: Some(serde_json::json!({ "title": task.title, "git_branch": task.git_branch })),
|
||||
result: Some(
|
||||
serde_json::json!({ "title": task.title, "git_branch": task.git_branch }),
|
||||
),
|
||||
error: None,
|
||||
timestamp: now,
|
||||
session_id: None,
|
||||
..Default::default()
|
||||
});
|
||||
Ok(format!("Task added with ID: {}", task_id))
|
||||
}
|
||||
@@ -75,6 +88,8 @@ impl McpTool for TasksHandler {
|
||||
let mut blocked = false;
|
||||
let mut blocker_details = String::new();
|
||||
|
||||
let mut updated_task: Option<Task> = None;
|
||||
|
||||
state.project.tasks.modify(|tasks| {
|
||||
let target_idx = tasks.iter().position(|t| t.id == id || t.title == id);
|
||||
let target_idx = match target_idx {
|
||||
@@ -84,7 +99,11 @@ impl McpTool for TasksHandler {
|
||||
found = true;
|
||||
|
||||
if target_status == "done" || target_status == "completed" {
|
||||
if tasks[target_idx].acceptance_criteria.iter().any(|c| !c.is_met) {
|
||||
if tasks[target_idx]
|
||||
.acceptance_criteria
|
||||
.iter()
|
||||
.any(|c| !c.is_met)
|
||||
{
|
||||
blocked = true;
|
||||
blocker_details = "Unmet acceptance criteria exist.".to_string();
|
||||
}
|
||||
@@ -100,20 +119,29 @@ impl McpTool for TasksHandler {
|
||||
}
|
||||
if !uncompleted_deps.is_empty() {
|
||||
blocked = true;
|
||||
blocker_details = format!("Blocked by dependencies: {}", uncompleted_deps.join(", "));
|
||||
blocker_details = format!(
|
||||
"Blocked by dependencies: {}",
|
||||
uncompleted_deps.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
if !blocked {
|
||||
let target_id_ref = tasks[target_idx].id.as_str();
|
||||
let mut uncompleted_children = Vec::new();
|
||||
for child in tasks.iter().filter(|t| t.parent_id.as_deref() == Some(target_id_ref)) {
|
||||
for child in tasks
|
||||
.iter()
|
||||
.filter(|t| t.parent_id.as_deref() == Some(target_id_ref))
|
||||
{
|
||||
if child.status != "completed" && child.status != "done" {
|
||||
uncompleted_children.push(child.title.as_str());
|
||||
}
|
||||
}
|
||||
if !uncompleted_children.is_empty() {
|
||||
blocked = true;
|
||||
blocker_details = format!("Blocked by child tasks: {}", uncompleted_children.join(", "));
|
||||
blocker_details = format!(
|
||||
"Blocked by child tasks: {}",
|
||||
uncompleted_children.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,6 +149,7 @@ impl McpTool for TasksHandler {
|
||||
if !blocked {
|
||||
tasks[target_idx].status = target_status.clone();
|
||||
tasks[target_idx].updated_at = crate::handlers::utils::now_secs();
|
||||
updated_task = Some(tasks[target_idx].clone());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -133,10 +162,22 @@ impl McpTool for TasksHandler {
|
||||
error: Some(blocker_details.clone()),
|
||||
timestamp: crate::handlers::utils::now_secs(),
|
||||
session_id: None,
|
||||
..Default::default()
|
||||
});
|
||||
Err(crate::error::AppError::Internal(format!("Error: Cannot transition task. {}", blocker_details)))
|
||||
Err(crate::error::AppError::Internal(format!(
|
||||
"Error: Cannot transition task. {}",
|
||||
blocker_details
|
||||
)))
|
||||
} else if found {
|
||||
state.record_activity("task_update", &format!("Task {} status -> {}", id, status), None);
|
||||
if let Some(ref ut) = updated_task {
|
||||
let idx = state.get_search_index().await;
|
||||
drop(idx.index_task(ut));
|
||||
}
|
||||
state.record_activity(
|
||||
"task_update",
|
||||
&format!("Task {} status -> {}", id, status),
|
||||
None,
|
||||
);
|
||||
state.broadcast_task_event(TaskEvent {
|
||||
task_id: id.clone(),
|
||||
status: target_status.clone(),
|
||||
@@ -145,10 +186,13 @@ impl McpTool for TasksHandler {
|
||||
error: None,
|
||||
timestamp: crate::handlers::utils::now_secs(),
|
||||
session_id: None,
|
||||
..Default::default()
|
||||
});
|
||||
Ok("Task status updated.".to_string())
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal("Task not found. Please verify the task ID.".to_string()))
|
||||
Err(crate::error::AppError::Internal(
|
||||
"Task not found. Please verify the task ID.".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
TaskAction::Delete => {
|
||||
@@ -163,9 +207,12 @@ impl McpTool for TasksHandler {
|
||||
for (idx, t) in tasks.iter().enumerate() {
|
||||
id_to_index.insert(t.id.as_str(), idx);
|
||||
}
|
||||
let mut children_map: std::collections::HashMap<usize, Vec<usize>> = std::collections::HashMap::new();
|
||||
let mut children_map: std::collections::HashMap<usize, Vec<usize>> =
|
||||
std::collections::HashMap::new();
|
||||
for (idx, t) in tasks.iter().enumerate() {
|
||||
if let Some(pid) = &t.parent_id && let Some(&p_idx) = id_to_index.get(pid.as_str()) {
|
||||
if let Some(pid) = &t.parent_id
|
||||
&& let Some(&p_idx) = id_to_index.get(pid.as_str())
|
||||
{
|
||||
children_map.entry(p_idx).or_default().push(idx);
|
||||
}
|
||||
}
|
||||
@@ -174,7 +221,9 @@ impl McpTool for TasksHandler {
|
||||
let mut queue = std::collections::VecDeque::new();
|
||||
queue.push_back(start_idx);
|
||||
while let Some(curr) = queue.pop_front() {
|
||||
if to_delete_idx.insert(curr) && let Some(children) = children_map.get(&curr) {
|
||||
if to_delete_idx.insert(curr)
|
||||
&& let Some(children) = children_map.get(&curr)
|
||||
{
|
||||
queue.extend(children.iter().copied());
|
||||
}
|
||||
}
|
||||
@@ -199,19 +248,29 @@ impl McpTool for TasksHandler {
|
||||
error: None,
|
||||
timestamp: crate::handlers::utils::now_secs(),
|
||||
session_id: None,
|
||||
..Default::default()
|
||||
});
|
||||
Ok(format!("Deleted task and its children ({} total).", deleted_count))
|
||||
Ok(format!(
|
||||
"Deleted task and its children ({} total).",
|
||||
deleted_count
|
||||
))
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal("Task not found. Please verify the task ID.".to_string()))
|
||||
Err(crate::error::AppError::Internal(
|
||||
"Task not found. Please verify the task ID.".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
TaskAction::List => {
|
||||
let level = req.summary_level.as_deref().unwrap_or("detailed");
|
||||
let data = state.project.tasks.read_with(|tasks| {
|
||||
let filtered: Vec<_> = tasks
|
||||
let mut filtered: Vec<_> = tasks
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
let status_match = t.status != "done" && t.status != "completed";
|
||||
let status_match = match req.status.as_deref() {
|
||||
Some("all") => true,
|
||||
Some(s) => t.status.eq_ignore_ascii_case(s),
|
||||
None => t.status != "done" && t.status != "completed",
|
||||
};
|
||||
let branch_match = match &req.git_branch {
|
||||
Some(branch) => t.git_branch.is_none() || t.git_branch.as_deref() == Some(branch.as_str()),
|
||||
None => true,
|
||||
@@ -227,10 +286,9 @@ impl McpTool for TasksHandler {
|
||||
let mut json_str = serde_json::to_string(&filtered)?;
|
||||
if let Some(max_t) = req.max_tokens {
|
||||
let char_limit = max_t * 4;
|
||||
if json_str.len() > char_limit {
|
||||
let boundary = json_str.floor_char_boundary(char_limit);
|
||||
json_str.truncate(boundary);
|
||||
json_str.push_str(" ...[truncated due to max_tokens]");
|
||||
while json_str.len() > char_limit && !filtered.is_empty() {
|
||||
filtered.pop();
|
||||
json_str = serde_json::to_string(&filtered)?;
|
||||
}
|
||||
}
|
||||
Ok::<String, crate::error::AppError>(json_str)
|
||||
@@ -253,6 +311,7 @@ impl McpTool for TasksHandler {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
description: desc,
|
||||
is_met: false,
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
task.updated_at = crate::handlers::utils::now_secs();
|
||||
@@ -268,10 +327,13 @@ impl McpTool for TasksHandler {
|
||||
error: None,
|
||||
timestamp: crate::handlers::utils::now_secs(),
|
||||
session_id: None,
|
||||
..Default::default()
|
||||
});
|
||||
Ok("Acceptance criteria set successfully.".to_string())
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal("Task not found. Please verify the task ID.".to_string()))
|
||||
Err(crate::error::AppError::Internal(
|
||||
"Task not found. Please verify the task ID.".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
TaskAction::Verify => {
|
||||
@@ -292,11 +354,13 @@ impl McpTool for TasksHandler {
|
||||
task.updated_at = crate::handlers::utils::now_secs();
|
||||
}
|
||||
} else {
|
||||
task.acceptance_criteria.push(crate::models::AcceptanceCriteria {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
description: proof_str.clone(),
|
||||
is_met: true,
|
||||
});
|
||||
task.acceptance_criteria
|
||||
.push(crate::models::AcceptanceCriteria {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
description: proof_str.clone(),
|
||||
is_met: true,
|
||||
..Default::default()
|
||||
});
|
||||
task.updated_at = crate::handlers::utils::now_secs();
|
||||
success = true;
|
||||
}
|
||||
@@ -311,12 +375,18 @@ impl McpTool for TasksHandler {
|
||||
error: None,
|
||||
timestamp: crate::handlers::utils::now_secs(),
|
||||
session_id: None,
|
||||
..Default::default()
|
||||
});
|
||||
Ok(format!("Acceptance criteria verified with proof: {}", proof_str))
|
||||
Ok(format!(
|
||||
"Acceptance criteria verified with proof: {}",
|
||||
proof_str
|
||||
))
|
||||
} else if already_met {
|
||||
Ok("Acceptance criteria was already met.".to_string())
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal("Task not found. Please verify the task ID.".to_string()))
|
||||
Err(crate::error::AppError::Internal(
|
||||
"Task not found. Please verify the task ID.".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,14 +415,23 @@ impl McpTool for MilestonesHandler {
|
||||
let title = req.title.ok_or_else(|| {
|
||||
crate::error::AppError::Internal("Missing required parameter 'title' for action 'add'. Next step: Provide non-empty 'title' string in request and retry.".to_string())
|
||||
})?;
|
||||
let ns = req.namespace.unwrap_or_else(|| crate::models::default_namespace());
|
||||
let ns = req
|
||||
.namespace
|
||||
.unwrap_or_else(|| crate::models::default_namespace());
|
||||
state.project.milestones.modify(|ms| {
|
||||
ms.push(crate::models::Milestone {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
title,
|
||||
status: "pending".to_string(),
|
||||
namespace: ns,
|
||||
target_date: None,
|
||||
target_date: req
|
||||
.target_date
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse::<u64>().ok()),
|
||||
description: req.description,
|
||||
deliverables: req.deliverables.unwrap_or_default(),
|
||||
repo_name: req.repo_name,
|
||||
..Default::default()
|
||||
})
|
||||
});
|
||||
Ok("Milestone added".to_string())
|
||||
@@ -377,7 +456,9 @@ impl McpTool for MilestonesHandler {
|
||||
if found {
|
||||
Ok("Milestone updated".to_string())
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal("Milestone not found. Please verify the milestone ID.".to_string()))
|
||||
Err(crate::error::AppError::Internal(
|
||||
"Milestone not found. Please verify the milestone ID.".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
MilestoneAction::List => {
|
||||
@@ -504,7 +585,10 @@ mod tests {
|
||||
assert_eq!(res2, "Milestone updated");
|
||||
|
||||
let res3 = handler_ms
|
||||
.execute(json!({"action": "list", "namespace": "global"}), state.clone())
|
||||
.execute(
|
||||
json!({"action": "list", "namespace": "global"}),
|
||||
state.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
@@ -602,7 +686,10 @@ mod tests {
|
||||
assert!(res1.contains("Milestone added"));
|
||||
|
||||
let res2 = handler
|
||||
.execute(serde_json::json!({"action": "list", "namespace": "global"}), state.clone())
|
||||
.execute(
|
||||
serde_json::json!({"action": "list", "namespace": "global"}),
|
||||
state.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
|
||||
@@ -14,6 +14,19 @@ pub fn now_secs() -> u64 {
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
pub fn validate_safe_path(path_str: &str) -> crate::error::Result<std::path::PathBuf> {
|
||||
let path = std::path::Path::new(path_str);
|
||||
for component in path.components() {
|
||||
if component == std::path::Component::ParentDir {
|
||||
return Err(crate::error::AppError::BadRequest(format!(
|
||||
"Path traversal forbidden: '{}' contains relative parent directory components ('..')",
|
||||
path_str
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(path.to_path_buf())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -188,6 +188,14 @@ impl McpTool for ReadClipboardHandler {
|
||||
{
|
||||
let path_str = file_path.to_string_lossy().to_string();
|
||||
out.insert("image_path".into(), json!(path_str));
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(rest) = path_str.strip_prefix("C:\\") {
|
||||
let wsl_path = format!("/mnt/c/{}", rest.replace('\\', "/"));
|
||||
out.insert("image_path_wsl".into(), json!(wsl_path));
|
||||
} else if let Some(rest) = path_str.strip_prefix("c:\\") {
|
||||
let wsl_path = format!("/mnt/c/{}", rest.replace('\\', "/"));
|
||||
out.insert("image_path_wsl".into(), json!(wsl_path));
|
||||
}
|
||||
|
||||
if let Ok(bytes) = std::fs::read(&file_path) {
|
||||
use base64::Engine;
|
||||
@@ -226,42 +234,7 @@ impl McpTool for ReadClipboardHandler {
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&final_obj)?)
|
||||
}
|
||||
}
|
||||
use crate::tools::ToggleClipboardWatchModeTool;
|
||||
|
||||
pub struct ToggleClipboardWatchModeHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for ToggleClipboardWatchModeHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"toggle_clipboard_watch_mode"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<ToggleClipboardWatchModeTool>(
|
||||
"toggle_clipboard_watch_mode",
|
||||
"Enable or disable clipboard watch mode (auto-ingesting into StickyNotes).",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let tool_args: ToggleClipboardWatchModeTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut watch_mode = state.clipboard_watch_mode.write().await;
|
||||
*watch_mode = tool_args.enable;
|
||||
drop(watch_mode);
|
||||
state.clipboard_notify.notify_waiters();
|
||||
|
||||
let status_msg = if tool_args.enable {
|
||||
"Clipboard watch mode enabled. Changes will be ingested as StickyNotes."
|
||||
} else {
|
||||
"Clipboard watch mode disabled."
|
||||
};
|
||||
|
||||
state.record_activity("clipboard", status_msg, None);
|
||||
Ok(status_msg.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
use crate::tools::{ClipboardAction, ClipboardTool};
|
||||
|
||||
@@ -276,7 +249,7 @@ impl McpTool for ClipboardHandler {
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<ClipboardTool>(
|
||||
"clipboard",
|
||||
"Consolidated OS clipboard management (read, write, toggle_watch)",
|
||||
"Consolidated OS clipboard management (read, write)",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -297,10 +270,6 @@ impl McpTool for ClipboardHandler {
|
||||
state,
|
||||
).await
|
||||
}
|
||||
ClipboardAction::ToggleWatch => {
|
||||
let enable = req.enabled.unwrap_or(true);
|
||||
ToggleClipboardWatchModeHandler.execute(serde_json::json!({"enable": enable}), state).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -312,24 +281,6 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_toggle_clipboard_watch_mode() {
|
||||
let dir = tempdir().unwrap();
|
||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||
let handler = ToggleClipboardWatchModeHandler;
|
||||
|
||||
let args = json!({
|
||||
"enable": true
|
||||
});
|
||||
|
||||
let result = handler
|
||||
.execute(args, state.clone())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to toggle clipboard: {}", e))
|
||||
.unwrap();
|
||||
assert!(result.contains("enabled"));
|
||||
assert_eq!(*state.clipboard_watch_mode.read().await, true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_write_clipboard() {
|
||||
|
||||
@@ -15,7 +15,10 @@ impl McpTool for PinFileHandler {
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<PinFileTool>("pin_file", "Pin a file path in the workspace context to prioritize it across subagent turns.")
|
||||
crate::mcp::tool_def::<PinFileTool>(
|
||||
"pin_file",
|
||||
"Pin a file path in the workspace context to prioritize it across subagent turns.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
@@ -27,6 +30,9 @@ impl McpTool for PinFileHandler {
|
||||
file_path: req.file_path,
|
||||
timestamp: crate::handlers::utils::now_secs(),
|
||||
git_branch: req.git_branch,
|
||||
repo_name: req.repo_name,
|
||||
symbol_or_line: req.symbol_or_line,
|
||||
pin_reason: req.pin_reason,
|
||||
});
|
||||
});
|
||||
Ok("File pinned".to_string())
|
||||
@@ -42,7 +48,10 @@ impl McpTool for UnpinFileHandler {
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<UnpinFileTool>("unpin_file", "Unpin a file path from the workspace context.")
|
||||
crate::mcp::tool_def::<UnpinFileTool>(
|
||||
"unpin_file",
|
||||
"Unpin a file path from the workspace context.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
@@ -104,7 +113,10 @@ impl McpTool for StoreSnippetHandler {
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<StoreSnippetTool>("store_snippet", "Store or replace a reusable code snippet with vector embedding and search tags.")
|
||||
crate::mcp::tool_def::<StoreSnippetTool>(
|
||||
"store_snippet",
|
||||
"Store or replace a reusable code snippet with vector embedding and search tags.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
@@ -125,6 +137,12 @@ impl McpTool for StoreSnippetHandler {
|
||||
updated_at: crate::handlers::utils::now_secs(),
|
||||
tags: req.tags.unwrap_or_default(),
|
||||
embedding,
|
||||
namespace: req
|
||||
.namespace
|
||||
.unwrap_or_else(|| crate::models::default_namespace()),
|
||||
repo_name: req.repo_name,
|
||||
origin_file: req.origin_file,
|
||||
line_range: req.line_range,
|
||||
};
|
||||
|
||||
let idx = state.get_search_index().await;
|
||||
@@ -148,7 +166,10 @@ impl McpTool for TagSnippetHandler {
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<TagSnippetTool>("tag_snippet", "Add organizational tags to an existing stored code snippet.")
|
||||
crate::mcp::tool_def::<TagSnippetTool>(
|
||||
"tag_snippet",
|
||||
"Add organizational tags to an existing stored code snippet.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
@@ -169,9 +190,15 @@ impl McpTool for TagSnippetHandler {
|
||||
});
|
||||
|
||||
if found {
|
||||
Ok(format!("Snippet '{}' tagged with: {:?}", req.name, req.tags))
|
||||
Ok(format!(
|
||||
"Snippet '{}' tagged with: {:?}",
|
||||
req.name, req.tags
|
||||
))
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal(format!("Snippet '{}' not found.", req.name)))
|
||||
Err(crate::error::AppError::Internal(format!(
|
||||
"Snippet '{}' not found.",
|
||||
req.name
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,7 +236,9 @@ impl McpTool for SearchSnippetsHandler {
|
||||
|| s.tags.iter().any(|t| contains_ignore_ascii_case(t, &query));
|
||||
|
||||
let matches_tags = tags_filter.is_empty()
|
||||
|| tags_filter.iter().any(|tf| s.tags.iter().any(|st| contains_ignore_ascii_case(st, tf)));
|
||||
|| tags_filter
|
||||
.iter()
|
||||
.any(|tf| s.tags.iter().any(|st| contains_ignore_ascii_case(st, tf)));
|
||||
|
||||
matches_text && matches_tags
|
||||
})
|
||||
@@ -292,6 +321,10 @@ impl McpTool for SaveContextWorkspaceHandler {
|
||||
pinned_files: req.pinned_files,
|
||||
active_task_ids: req.active_task_ids,
|
||||
saved_at: crate::handlers::utils::now_secs(),
|
||||
description: req.description,
|
||||
repo_name: req.repo_name,
|
||||
git_branch: req.git_branch,
|
||||
vcs_revision: req.vcs_revision,
|
||||
});
|
||||
});
|
||||
Ok("Context workspace saved".to_string())
|
||||
@@ -419,6 +452,10 @@ impl McpTool for AddPrChecklistItemHandler {
|
||||
namespace: req.namespace,
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
description: req.description,
|
||||
is_checked: req.is_checked.unwrap_or(false),
|
||||
category: req.category,
|
||||
repo_name: req.repo_name,
|
||||
automated_check: req.automated_check,
|
||||
})
|
||||
});
|
||||
Ok("PR checklist item added".to_string())
|
||||
@@ -492,22 +529,43 @@ impl McpTool for PinnedFilesHandler {
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: PinnedFilesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let ns = req.namespace.unwrap_or_else(|| crate::models::default_namespace());
|
||||
let ns = req
|
||||
.namespace
|
||||
.unwrap_or_else(|| crate::models::default_namespace());
|
||||
match req.action {
|
||||
PinnedFileAction::Pin => {
|
||||
let path = req.path.ok_or_else(|| {
|
||||
crate::error::AppError::Internal("Missing required parameter 'path' for action 'pin'. Next step: Provide absolute or relative file 'path' in request and retry.".to_string())
|
||||
})?;
|
||||
PinFileHandler.execute(serde_json::json!({"file_path": path, "namespace": ns}), state).await
|
||||
PinFileHandler
|
||||
.execute(
|
||||
serde_json::json!({
|
||||
"file_path": path,
|
||||
"namespace": ns,
|
||||
"git_branch": req.git_branch,
|
||||
"pin_reason": req.pin_reason,
|
||||
"symbol_or_line": req.symbol_or_line,
|
||||
"repo_name": req.repo_name,
|
||||
}),
|
||||
state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
PinnedFileAction::Unpin => {
|
||||
let path = req.path.ok_or_else(|| {
|
||||
crate::error::AppError::Internal("Missing required parameter 'path' for action 'unpin'. Next step: Provide file 'path' in request and retry.".to_string())
|
||||
})?;
|
||||
UnpinFileHandler.execute(serde_json::json!({"file_path": path, "namespace": ns}), state).await
|
||||
UnpinFileHandler
|
||||
.execute(
|
||||
serde_json::json!({"file_path": path, "namespace": ns}),
|
||||
state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
PinnedFileAction::List => {
|
||||
ListPinnedFilesHandler.execute(serde_json::json!({"namespace": ns}), state).await
|
||||
ListPinnedFilesHandler
|
||||
.execute(serde_json::json!({"namespace": ns}), state)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -530,7 +588,9 @@ impl McpTool for ContextWorkspacesHandler {
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: ContextWorkspacesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let ns = req.namespace.unwrap_or_else(|| crate::models::default_namespace());
|
||||
let ns = req
|
||||
.namespace
|
||||
.unwrap_or_else(|| crate::models::default_namespace());
|
||||
match req.action {
|
||||
ContextWorkspaceAction::Save => {
|
||||
let name = req.name.ok_or_else(|| {
|
||||
@@ -538,30 +598,42 @@ impl McpTool for ContextWorkspacesHandler {
|
||||
})?;
|
||||
let pinned_files = req.pinned_files.unwrap_or_default();
|
||||
let active_task_ids = req.active_task_ids.unwrap_or_default();
|
||||
SaveContextWorkspaceHandler.execute(
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"namespace": ns,
|
||||
"pinned_files": pinned_files,
|
||||
"active_task_ids": active_task_ids
|
||||
}),
|
||||
state,
|
||||
).await
|
||||
SaveContextWorkspaceHandler
|
||||
.execute(
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"namespace": ns,
|
||||
"pinned_files": pinned_files,
|
||||
"active_task_ids": active_task_ids,
|
||||
"description": req.description,
|
||||
"repo_name": req.repo_name,
|
||||
"git_branch": req.git_branch,
|
||||
"vcs_revision": req.vcs_revision,
|
||||
}),
|
||||
state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ContextWorkspaceAction::Load => {
|
||||
let name = req.name.ok_or_else(|| {
|
||||
crate::error::AppError::Internal("Missing required parameter 'name' for action 'load'. Next step: Provide workspace 'name' string in request and retry.".to_string())
|
||||
})?;
|
||||
LoadContextWorkspaceHandler.execute(serde_json::json!({"name": name, "namespace": ns}), state).await
|
||||
LoadContextWorkspaceHandler
|
||||
.execute(serde_json::json!({"name": name, "namespace": ns}), state)
|
||||
.await
|
||||
}
|
||||
ContextWorkspaceAction::List => {
|
||||
ListContextWorkspacesHandler.execute(serde_json::json!({"namespace": ns}), state).await
|
||||
ListContextWorkspacesHandler
|
||||
.execute(serde_json::json!({"namespace": ns}), state)
|
||||
.await
|
||||
}
|
||||
ContextWorkspaceAction::Delete => {
|
||||
let name = req.name.ok_or_else(|| {
|
||||
crate::error::AppError::Internal("Missing required parameter 'name' for action 'delete'. Next step: Provide workspace 'name' string in request and retry.".to_string())
|
||||
})?;
|
||||
DeleteContextWorkspaceHandler.execute(serde_json::json!({"name": name, "namespace": ns}), state).await
|
||||
DeleteContextWorkspaceHandler
|
||||
.execute(serde_json::json!({"name": name, "namespace": ns}), state)
|
||||
.await
|
||||
}
|
||||
ContextWorkspaceAction::Diff => {
|
||||
let name = req.name.ok_or_else(|| {
|
||||
@@ -593,19 +665,37 @@ impl McpTool for PrChecklistHandler {
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: PrChecklistTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let ns = req.namespace.unwrap_or_else(|| crate::models::default_namespace());
|
||||
let ns = req
|
||||
.namespace
|
||||
.unwrap_or_else(|| crate::models::default_namespace());
|
||||
match req.action {
|
||||
PrChecklistAction::Add => {
|
||||
let desc = req.description.ok_or_else(|| {
|
||||
crate::error::AppError::Internal("Missing required parameter 'description' for action 'add'. Next step: Provide checklist item 'description' string in request and retry.".to_string())
|
||||
})?;
|
||||
AddPrChecklistItemHandler.execute(serde_json::json!({"description": desc, "namespace": ns}), state).await
|
||||
AddPrChecklistItemHandler
|
||||
.execute(
|
||||
serde_json::json!({
|
||||
"description": desc,
|
||||
"namespace": ns,
|
||||
"category": req.category,
|
||||
"automated_check": req.automated_check,
|
||||
"is_checked": req.is_checked,
|
||||
"repo_name": req.repo_name,
|
||||
}),
|
||||
state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
PrChecklistAction::Get => {
|
||||
GetPrChecklistHandler.execute(serde_json::json!({"namespace": ns}), state).await
|
||||
GetPrChecklistHandler
|
||||
.execute(serde_json::json!({"namespace": ns}), state)
|
||||
.await
|
||||
}
|
||||
PrChecklistAction::Clear => {
|
||||
ClearPrChecklistHandler.execute(serde_json::json!({"namespace": ns}), state).await
|
||||
ClearPrChecklistHandler
|
||||
.execute(serde_json::json!({"namespace": ns}), state)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -636,37 +726,51 @@ impl McpTool for SnippetsHandler {
|
||||
let lang = req.language.unwrap_or_else(|| "text".to_string());
|
||||
let code = req.code.unwrap_or_default();
|
||||
let desc = req.description.unwrap_or_default();
|
||||
StoreSnippetHandler.execute(
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"language": lang,
|
||||
"code": code,
|
||||
"description": desc,
|
||||
"tags": req.tags
|
||||
}),
|
||||
state,
|
||||
).await
|
||||
StoreSnippetHandler
|
||||
.execute(
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"language": lang,
|
||||
"code": code,
|
||||
"description": desc,
|
||||
"tags": req.tags,
|
||||
"namespace": req.namespace,
|
||||
"repo_name": req.repo_name,
|
||||
"origin_file": req.origin_file,
|
||||
"line_range": req.line_range,
|
||||
}),
|
||||
state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
SnippetAction::Search => {
|
||||
let q = req.query.or(req.name).unwrap_or_default();
|
||||
if req.hybrid.unwrap_or(false) {
|
||||
crate::handlers::meta::SearchSnippetsHybridHandler.execute(serde_json::json!({"query": q, "tags": req.tags}), state).await
|
||||
crate::handlers::meta::SearchSnippetsHybridHandler
|
||||
.execute(serde_json::json!({"query": q, "tags": req.tags}), state)
|
||||
.await
|
||||
} else {
|
||||
SearchSnippetsHandler.execute(serde_json::json!({"query": q, "tags": req.tags}), state).await
|
||||
SearchSnippetsHandler
|
||||
.execute(serde_json::json!({"query": q, "tags": req.tags}), state)
|
||||
.await
|
||||
}
|
||||
}
|
||||
SnippetAction::Delete => {
|
||||
let id = req.name.or(req.id).or(req.query).ok_or_else(|| {
|
||||
crate::error::AppError::Internal("Missing required parameter 'name', 'id', or 'query' for action 'delete'. Next step: Provide snippet ID/name in request and retry.".to_string())
|
||||
})?;
|
||||
DeleteSnippetHandler.execute(serde_json::json!({"name": id}), state).await
|
||||
DeleteSnippetHandler
|
||||
.execute(serde_json::json!({"name": id}), state)
|
||||
.await
|
||||
}
|
||||
SnippetAction::Tag => {
|
||||
let id = req.name.or(req.id).or(req.query).ok_or_else(|| {
|
||||
crate::error::AppError::Internal("Missing required parameter 'name', 'id', or 'query' for action 'tag'. Next step: Provide snippet ID/name and 'tags' array in request and retry.".to_string())
|
||||
})?;
|
||||
let tags = req.tags.unwrap_or_default();
|
||||
TagSnippetHandler.execute(serde_json::json!({"name": id, "tags": tags}), state).await
|
||||
TagSnippetHandler
|
||||
.execute(serde_json::json!({"name": id, "tags": tags}), state)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -793,24 +897,42 @@ mod tests {
|
||||
|
||||
// Snapshots
|
||||
let create_snap = CreateSnapshotHandler;
|
||||
let snap_res = create_snap.execute(json!({"description": "Test snap", "namespace": "global"}), state.clone()).await.unwrap();
|
||||
let snap_res = create_snap
|
||||
.execute(
|
||||
json!({"description": "Test snap", "namespace": "global"}),
|
||||
state.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(snap_res.contains("SNAP-"));
|
||||
let snap_id = snap_res.split_whitespace().nth(1).unwrap();
|
||||
|
||||
let restore_snap = RestoreSnapshotHandler;
|
||||
let restore_res = restore_snap.execute(json!({"snapshot_id": snap_id}), state.clone()).await.unwrap();
|
||||
let restore_res = restore_snap
|
||||
.execute(json!({"snapshot_id": snap_id}), state.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(restore_res.contains("Successfully restored"));
|
||||
|
||||
// Subagent Namespace
|
||||
let create_sub_ns = CreateSubagentNamespaceHandler;
|
||||
let sub_ns_res = create_sub_ns.execute(json!({"subagent_id": "test_agent"}), state.clone()).await.unwrap();
|
||||
let sub_ns_res = create_sub_ns
|
||||
.execute(json!({"subagent_id": "test_agent"}), state.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(sub_ns_res.contains("subagent-test_agent"));
|
||||
|
||||
let manage_sub_ns = ManageSubagentNamespaceHandler;
|
||||
let manage_res = manage_sub_ns.execute(json!({
|
||||
"subagent_id": "test_agent",
|
||||
"action": "create"
|
||||
}), state.clone()).await.unwrap();
|
||||
let manage_res = manage_sub_ns
|
||||
.execute(
|
||||
json!({
|
||||
"subagent_id": "test_agent",
|
||||
"action": "create"
|
||||
}),
|
||||
state.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(manage_res.contains("initialized"));
|
||||
|
||||
// Context Workspace Diff
|
||||
@@ -818,9 +940,14 @@ mod tests {
|
||||
save_ws.execute(json!({"name": "ws_a", "description": "Workspace A", "pinned_files": ["src/lib.rs"], "active_task_ids": []}), state.clone()).await.unwrap();
|
||||
save_ws.execute(json!({"name": "ws_b", "description": "Workspace B", "pinned_files": ["src/main.rs"], "active_task_ids": []}), state.clone()).await.unwrap();
|
||||
|
||||
|
||||
let diff_ws = DiffContextWorkspacesHandler;
|
||||
let diff_res = diff_ws.execute(json!({"workspace_a": "ws_a", "workspace_b": "ws_b"}), state.clone()).await.unwrap();
|
||||
let diff_res = diff_ws
|
||||
.execute(
|
||||
json!({"workspace_a": "ws_a", "workspace_b": "ws_b"}),
|
||||
state.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(diff_res.contains("workspace_a"));
|
||||
}
|
||||
|
||||
@@ -834,7 +961,13 @@ mod tests {
|
||||
save_ws.execute(json!({"name": "ws_2", "description": "Workspace 2", "pinned_files": ["src/main.rs"], "active_task_ids": []}), state.clone()).await.unwrap();
|
||||
|
||||
let diff_ws = DiffContextWorkspacesHandler;
|
||||
let diff_res = diff_ws.execute(json!({"workspace_a": "ws_1", "workspace_b": "ws_2"}), state.clone()).await.unwrap();
|
||||
let diff_res = diff_ws
|
||||
.execute(
|
||||
json!({"workspace_a": "ws_1", "workspace_b": "ws_2"}),
|
||||
state.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(diff_res.contains("ws_1"));
|
||||
}
|
||||
}
|
||||
@@ -998,7 +1131,10 @@ impl McpTool for CreateSnapshotHandler {
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: CreateSnapshotTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let snapshot_id = format!("SNAP-{}", uuid::Uuid::new_v4().to_string()[..8].to_uppercase());
|
||||
let snapshot_id = format!(
|
||||
"SNAP-{}",
|
||||
uuid::Uuid::new_v4().to_string()[..8].to_uppercase()
|
||||
);
|
||||
let ns = req.namespace.unwrap_or_else(|| "global".to_string());
|
||||
|
||||
let snapshot = crate::models::StateSnapshot {
|
||||
@@ -1006,14 +1142,22 @@ impl McpTool for CreateSnapshotHandler {
|
||||
timestamp: crate::handlers::utils::now_secs(),
|
||||
description: req.description.clone(),
|
||||
namespace: ns.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
state.project.snapshots.modify(|snaps| {
|
||||
snaps.push(snapshot);
|
||||
});
|
||||
|
||||
state.record_activity("snapshot", &format!("Created snapshot {}: {}", snapshot_id, req.description), None);
|
||||
Ok(format!("Snapshot {} created for namespace '{}'", snapshot_id, ns))
|
||||
state.record_activity(
|
||||
"snapshot",
|
||||
&format!("Created snapshot {}: {}", snapshot_id, req.description),
|
||||
None,
|
||||
);
|
||||
Ok(format!(
|
||||
"Snapshot {} created for namespace '{}'",
|
||||
snapshot_id, ns
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1031,15 +1175,26 @@ impl McpTool for RestoreSnapshotHandler {
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: RestoreSnapshotTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let found = state.project.snapshots.read_with(|snaps| {
|
||||
snaps.iter().any(|s| s.id == req.snapshot_id)
|
||||
});
|
||||
let found = state
|
||||
.project
|
||||
.snapshots
|
||||
.read_with(|snaps| snaps.iter().any(|s| s.id == req.snapshot_id));
|
||||
|
||||
if found {
|
||||
state.record_activity("snapshot", &format!("Restored snapshot {}", req.snapshot_id), None);
|
||||
Ok(format!("Successfully restored memory state from snapshot {}", req.snapshot_id))
|
||||
state.record_activity(
|
||||
"snapshot",
|
||||
&format!("Restored snapshot {}", req.snapshot_id),
|
||||
None,
|
||||
);
|
||||
Ok(format!(
|
||||
"Successfully restored memory state from snapshot {}",
|
||||
req.snapshot_id
|
||||
))
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal(format!("Snapshot {} not found", req.snapshot_id)))
|
||||
Err(crate::error::AppError::Internal(format!(
|
||||
"Snapshot {} not found",
|
||||
req.snapshot_id
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1060,20 +1215,35 @@ impl McpTool for ManageSubagentNamespaceHandler {
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: ManageSubagentNamespaceTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let req: ManageSubagentNamespaceTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let subagent_ns = format!("subagent-{}", req.subagent_id);
|
||||
|
||||
match req.action {
|
||||
SubagentNamespaceAction::Create => {
|
||||
state.record_activity("namespace", &format!("Created subagent namespace {}", subagent_ns), None);
|
||||
Ok(format!("Subagent memory namespace '{}' initialized.", subagent_ns))
|
||||
state.record_activity(
|
||||
"namespace",
|
||||
&format!("Created subagent namespace {}", subagent_ns),
|
||||
None,
|
||||
);
|
||||
Ok(format!(
|
||||
"Subagent memory namespace '{}' initialized.",
|
||||
subagent_ns
|
||||
))
|
||||
}
|
||||
SubagentNamespaceAction::Purge => {
|
||||
state.project.pinned_files.modify(|files| {
|
||||
files.retain(|f| f.namespace != subagent_ns);
|
||||
});
|
||||
state.record_activity("namespace", &format!("Purged subagent namespace {}", subagent_ns), None);
|
||||
Ok(format!("Subagent memory namespace '{}' purged.", subagent_ns))
|
||||
state.record_activity(
|
||||
"namespace",
|
||||
&format!("Purged subagent namespace {}", subagent_ns),
|
||||
None,
|
||||
);
|
||||
Ok(format!(
|
||||
"Subagent memory namespace '{}' purged.",
|
||||
subagent_ns
|
||||
))
|
||||
}
|
||||
SubagentNamespaceAction::Condense => {
|
||||
let purge = req.purge_after_promotion.unwrap_or(true);
|
||||
@@ -1091,13 +1261,15 @@ impl McpTool for ManageSubagentNamespaceHandler {
|
||||
for mut e in sub_entities {
|
||||
promoted_entities += 1;
|
||||
e.namespace = "global".to_string();
|
||||
let global_entry = g.entities.entry(e.name.clone()).or_insert_with(|| Entity {
|
||||
name: e.name.clone(),
|
||||
entity_type: e.entity_type.clone(),
|
||||
observations: Vec::new(),
|
||||
namespace: "global".to_string(),
|
||||
git_branch: e.git_branch.clone(),
|
||||
});
|
||||
let global_entry =
|
||||
g.entities.entry(e.name.clone()).or_insert_with(|| Entity {
|
||||
name: e.name.clone(),
|
||||
entity_type: e.entity_type.clone(),
|
||||
observations: Vec::new(),
|
||||
namespace: "global".to_string(),
|
||||
git_branch: e.git_branch.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
for obs in e.observations {
|
||||
if !global_entry.observations.contains(&obs) {
|
||||
global_entry.observations.push(obs);
|
||||
@@ -1142,13 +1314,21 @@ impl McpTool for CreateSubagentNamespaceHandler {
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<CreateSubagentNamespaceTool>("create_subagent_namespace", "Create subagent namespace")
|
||||
crate::mcp::tool_def::<CreateSubagentNamespaceTool>(
|
||||
"create_subagent_namespace",
|
||||
"Create subagent namespace",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: CreateSubagentNamespaceTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let req: CreateSubagentNamespaceTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let ns = format!("subagent-{}", req.subagent_id);
|
||||
state.record_activity("namespace", &format!("Created subagent namespace {}", ns), None);
|
||||
state.record_activity(
|
||||
"namespace",
|
||||
&format!("Created subagent namespace {}", ns),
|
||||
None,
|
||||
);
|
||||
Ok(format!("Subagent memory namespace '{}' initialized.", ns))
|
||||
}
|
||||
}
|
||||
@@ -1162,18 +1342,26 @@ impl McpTool for PurgeSubagentNamespaceHandler {
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<PurgeSubagentNamespaceTool>("purge_subagent_namespace", "Purge subagent namespace")
|
||||
crate::mcp::tool_def::<PurgeSubagentNamespaceTool>(
|
||||
"purge_subagent_namespace",
|
||||
"Purge subagent namespace",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: PurgeSubagentNamespaceTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let req: PurgeSubagentNamespaceTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let ns = format!("subagent-{}", req.subagent_id);
|
||||
|
||||
|
||||
state.project.pinned_files.modify(|files| {
|
||||
files.retain(|f| f.namespace != ns);
|
||||
});
|
||||
|
||||
state.record_activity("namespace", &format!("Purged subagent namespace {}", ns), None);
|
||||
state.record_activity(
|
||||
"namespace",
|
||||
&format!("Purged subagent namespace {}", ns),
|
||||
None,
|
||||
);
|
||||
Ok(format!("Subagent memory namespace '{}' purged.", ns))
|
||||
}
|
||||
}
|
||||
@@ -1219,6 +1407,7 @@ impl McpTool for CondenseSubagentNamespaceHandler {
|
||||
observations: Vec::new(),
|
||||
namespace: "global".to_string(),
|
||||
git_branch: e.git_branch.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
for obs in e.observations {
|
||||
if !global_entry.observations.contains(&obs) {
|
||||
@@ -1262,11 +1451,15 @@ impl McpTool for DiffContextWorkspacesHandler {
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<DiffContextWorkspacesTool>("diff_context_workspaces", "Execute diff_context_workspaces")
|
||||
crate::mcp::tool_def::<DiffContextWorkspacesTool>(
|
||||
"diff_context_workspaces",
|
||||
"Execute diff_context_workspaces",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: DiffContextWorkspacesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let req: DiffContextWorkspacesTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
|
||||
let workspaces = state.project.context_workspaces.read_with(|ws| ws.clone());
|
||||
let ws_a = workspaces.iter().find(|w| w.name == req.workspace_a);
|
||||
@@ -1275,7 +1468,10 @@ impl McpTool for DiffContextWorkspacesHandler {
|
||||
if ws_a.is_none() || ws_b.is_none() {
|
||||
return Err(crate::error::AppError::Internal(format!(
|
||||
"One or both context workspaces not found: '{}' found={}, '{}' found={}",
|
||||
req.workspace_a, ws_a.is_some(), req.workspace_b, ws_b.is_some()
|
||||
req.workspace_a,
|
||||
ws_a.is_some(),
|
||||
req.workspace_b,
|
||||
ws_b.is_some()
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -1314,4 +1510,3 @@ impl McpTool for DiffContextWorkspacesHandler {
|
||||
Ok(serde_json::to_string_pretty(&diff)?)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user