feat(embedding,vision): candle embeddings with offline fallback, on-demand clipboard vision capture, and concurrency audit

This commit is contained in:
Riz Ashraf committed 2026-10-07 06:36:09 +01:00
1 parent 5bd8b1587a
commit e4a0fe72df
47 files changed
+6292 -3503

No files matched your search

+135 -24
View File
@@ -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();
+4
View File
@@ -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()
});
});
+4
View File
@@ -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
+89 -27
View File
@@ -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);
}
}
+4 -2
View File
@@ -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
View File
@@ -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()
});
});
+9 -3
View File
@@ -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
View File
@@ -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();
+13
View File
@@ -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::*;
+9 -58
View File
@@ -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() {
+279 -84
View File
@@ -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)?)
}
}