refactor: consolidate nvim crates, extract server library, and update workspace dependencies

This commit is contained in:
Riz Ashraf committed 2026-10-04 01:42:59 +01:00
1 parent a083719cf1
commit 533adfd41b
53 files changed
+5967 -1230

No files matched your search

+148
View File
@@ -247,6 +247,154 @@ impl McpTool for ReplaceAstNodeHandler {
}
}
pub struct FindSymbolReferencesHandler;
#[async_trait]
impl McpTool for FindSymbolReferencesHandler {
fn name(&self) -> &'static str {
"find_symbol_references"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<crate::tools::FindSymbolReferencesTool>(
"find_symbol_references",
"Find all source locations and AST chunks where a specific symbol is referenced or called.",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: crate::tools::FindSymbolReferencesTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let limit = req.limit.unwrap_or(10);
let target_sym = req.symbol;
let matches = state.code.snippets.read_with(|snippets| {
let mut refs = Vec::new();
for snippet in snippets {
if snippet.code.contains(&target_sym) || snippet.name.contains(&target_sym) {
refs.push(snippet.clone());
if refs.len() >= limit {
break;
}
}
}
Ok::<String, crate::error::AppError>(serde_json::to_string(&refs)?)
})?;
Ok(matches)
}
}
pub struct GetCallersHandler;
#[async_trait]
impl McpTool for GetCallersHandler {
fn name(&self) -> &'static str {
"get_callers"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<crate::tools::GetCallersTool>(
"get_callers",
"Find all caller functions or methods that invoke a specified target function name.",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: crate::tools::GetCallersTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let limit = req.limit.unwrap_or(10);
let target_fn = req.function_name;
let 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))
{
matching.push(snippet.clone());
if matching.len() >= limit {
break;
}
}
}
Ok::<String, crate::error::AppError>(serde_json::to_string(&matching)?)
})?;
Ok(callers)
}
}
pub struct AnalyzeImpactHandler;
#[async_trait]
impl McpTool for AnalyzeImpactHandler {
fn name(&self) -> &'static str {
"analyze_impact"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<crate::tools::AnalyzeImpactTool>(
"analyze_impact",
"Analyze the potential downstream breaking impact of modifying a function, struct, or file.",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: crate::tools::AnalyzeImpactTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let sym = req.target_symbol.clone();
let mut callers = Vec::new();
state.code.snippets.read_with(|snippets| {
for snippet in snippets {
if snippet.code.contains(&sym) {
callers.push(snippet.name.clone());
}
}
});
let mut kg_connected = Vec::new();
state.read_graph(|g| {
for rel in &g.relations {
if rel.from == sym {
kg_connected.push(format!("Outgoing: {} -> {}", rel.relation_type, rel.to));
} else if rel.to == sym {
kg_connected.push(format!("Incoming: {} <- {}", rel.relation_type, rel.from));
}
}
});
let caller_count = callers.len();
let graph_count = kg_connected.len();
let risk_level = if caller_count > 10 || graph_count > 5 {
"CRITICAL"
} else if caller_count > 3 || graph_count > 2 {
"HIGH"
} else if caller_count > 0 || graph_count > 0 {
"MEDIUM"
} else {
"LOW"
};
let result = serde_json::json!({
"target_symbol": sym,
"risk_level": risk_level,
"ast_callers_count": caller_count,
"ast_callers_sample": callers.into_iter().take(5).collect::<Vec<_>>(),
"graph_relations_count": graph_count,
"graph_relations": kg_connected,
"recommendation": match risk_level {
"CRITICAL" | "HIGH" => "Requires comprehensive unit test verification and backwards compatibility checks before modifying.",
"MEDIUM" => "Verify direct call sites and run affected module tests.",
_ => "Safe to modify with standard unit test verification.",
}
});
Ok(serde_json::to_string_pretty(&result)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
+22
View File
@@ -270,4 +270,26 @@ mod tests {
assert!(res3.contains("prod.local"));
assert!(!res3.is_empty());
}
#[tokio::test]
async fn test_empty_namespace_in_update_env_fingerprint() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let update_handler = UpdateEnvFingerprintHandler;
let args = serde_json::json!({
"namespace": "",
"tool_versions": {
"rustc": "1.70.0"
}
});
let res = update_handler
.execute(args, state.clone())
.await;
assert!(res.is_ok());
}
}
+100
View File
@@ -86,6 +86,88 @@ impl McpTool for GetActiveWorktreeContextHandler {
}
}
pub struct QueryGitDiffsHandler;
#[async_trait]
impl McpTool for QueryGitDiffsHandler {
fn name(&self) -> &'static str {
"query_git_diffs"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<crate::tools::QueryGitDiffsTool>(
"query_git_diffs",
"Query recent git commit history, diffs, and change ledger entries.",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: crate::tools::QueryGitDiffsTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let limit = req.limit.unwrap_or(5);
let q = req.query.to_lowercase();
let mut matches = Vec::new();
state.code.ledger.read_with(|ledger| {
for change in ledger {
if change.file_path.to_lowercase().contains(&q)
|| change.description.to_lowercase().contains(&q)
|| change.git_commit.as_ref().is_some_and(|c| c.contains(&q))
{
matches.push(json!({
"file_path": change.file_path,
"description": change.description,
"commit": change.git_commit,
"branch": change.git_branch,
"timestamp": change.timestamp,
}));
if matches.len() >= limit {
break;
}
}
}
});
if matches.len() < limit {
let remaining = limit - matches.len();
let git_matches = tokio::task::spawn_blocking(move || {
let mut results = Vec::new();
let cwd = env::current_dir().unwrap_or_default();
if let Ok(repo) = git2::Repository::discover(&cwd) {
if let Ok(mut revwalk) = repo.revwalk() {
let _ = revwalk.push_head();
let mut count = 0;
for oid in revwalk.flatten() {
if count >= remaining {
break;
}
if let Ok(commit) = repo.find_commit(oid) {
let summary = commit.summary().unwrap_or("");
if summary.to_lowercase().contains(&q) {
count += 1;
results.push(json!({
"commit_id": oid.to_string(),
"author": commit.author().name().unwrap_or("unknown"),
"message": summary,
"timestamp": commit.time().seconds(),
}));
}
}
}
}
}
results
})
.await
.unwrap_or_default();
matches.extend(git_matches);
}
Ok(serde_json::to_string_pretty(&matches)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -110,4 +192,22 @@ mod tests {
assert!(parsed.get("modified_files").is_some());
assert!(parsed.get("diff").is_some());
}
#[tokio::test]
async fn test_get_active_worktree_context_empty_git_repo() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let handler = GetActiveWorktreeContextHandler;
let result = handler
.execute(serde_json::json!({}), state)
.await
.map_err(|e| format!("Failed to get worktree context: {}", e))
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(parsed.get("branch").is_some() || parsed.is_object());
}
}
+434 -5
View File
@@ -119,8 +119,9 @@ impl McpTool for CreateEntitiesHandler {
let req: CreateEntitiesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let mut inserted = Vec::new();
state.modify_graph(|g| {
for entity in req.entities {
for mut entity in req.entities {
if !entity.name.is_empty() {
entity.entity_type = crate::models::normalize_entity_type(&entity.entity_type);
inserted.push(entity.clone());
g.entities.insert(entity.name.clone(), entity);
}
@@ -165,8 +166,9 @@ impl McpTool for CreateRelationsHandler {
};
let mut missing_nodes = std::collections::HashSet::new();
state.modify_graph(|g| {
for relation in req.relations {
for mut relation in req.relations {
if !relation.from.is_empty() && !relation.to.is_empty() {
relation.relation_type = crate::models::normalize_relation_type(&relation.relation_type);
let from_exists = g.entities.contains_key(&relation.from);
let to_exists = g.entities.contains_key(&relation.to);
if from_exists && to_exists {
@@ -353,8 +355,9 @@ impl McpTool for ReadGraphHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: ReadGraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let max_tokens = req.max_tokens;
let data = state.read_graph(|full| -> crate::error::Result<String> {
if let Some(ns) = req.namespace {
let mut result_json = if let Some(ns) = req.namespace {
let mut filtered = BorrowedGraph::default();
for (k, v) in &full.entities {
if v.namespace == ns {
@@ -366,10 +369,19 @@ impl McpTool for ReadGraphHandler {
filtered.relations.push(r);
}
}
Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?)
serde_json::to_string(&filtered)?
} else {
Ok::<String, crate::error::AppError>(serde_json::to_string(full)?)
serde_json::to_string(full)?
};
if let Some(max_tok) = max_tokens {
let max_chars = max_tok * 4;
if result_json.len() > max_chars {
result_json.truncate(max_chars);
result_json.push_str("... [TRUNCATED_TO_MAX_TOKENS]");
}
}
Ok(result_json)
})?;
Ok(data)
}
@@ -553,6 +565,54 @@ impl McpTool for CondenseEntityHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: CondenseEntityTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let mut missing = false;
if req.summarized_observations.is_empty() {
let mut obs_to_condense = Vec::new();
state.read_graph(|g| {
if let Some(e) = g.entities.get(&req.entity_name) {
obs_to_condense = e.observations.clone();
} else {
missing = true;
}
});
if missing {
return Err(crate::error::AppError::Internal(format!(
"Error: Entity '{}' not found. Please verify the exact entity name using search_nodes.",
req.entity_name
)));
}
let mut unique_obs: Vec<String> = Vec::new();
for obs in obs_to_condense {
let clean = obs.trim().to_string();
if !clean.is_empty() && !unique_obs.contains(&clean) {
unique_obs.push(clean);
}
}
if unique_obs.len() > 3 && state.ollama.is_available().await {
let prompt = format!(
"Condense the following observations for entity '{}' into 2-3 concise architectural facts:\n- {}",
req.entity_name,
unique_obs.join("\n- ")
);
if let Ok(summary) = state.ollama.generate(&prompt, None, None).await {
let lines: Vec<String> = summary
.lines()
.map(|l| l.trim().trim_start_matches('-').trim().to_string())
.filter(|l| !l.is_empty())
.collect();
if !lines.is_empty() {
unique_obs = lines;
}
}
}
state.modify_graph(|master| {
if let Some(e) = master.entities.get_mut(&req.entity_name) {
e.observations = unique_obs;
}
});
return Ok(format!("Entity '{}' observations condensed with deduplication/AI summary.", req.entity_name));
}
state.modify_graph(|master| {
if let Some(e) = master.entities.get_mut(&req.entity_name) {
e.observations = req.summarized_observations;
@@ -655,14 +715,316 @@ impl McpTool for FindOrphansHandler {
}
}
pub struct GetSubgraphHandler;
#[async_trait]
impl McpTool for GetSubgraphHandler {
fn name(&self) -> &'static str {
"get_subgraph"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<GetSubgraphTool>("get_subgraph", "Execute get_subgraph")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: GetSubgraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let root = req
.root_entity
.or(req.root_node)
.ok_or_else(|| crate::error::AppError::Internal("root_entity or root_node is required".to_string()))?;
let depth = req.depth.unwrap_or(2);
let format = req.format.unwrap_or(SubgraphFormat::Json);
if format == SubgraphFormat::MarkdownTree {
let (sub_entities, sub_relations) = state.read_graph(|g| {
let mut visited = std::collections::HashSet::new();
let mut queue = std::collections::VecDeque::new();
queue.push_back((root.clone(), 0));
visited.insert(root.clone());
while let Some((curr, d)) = queue.pop_front() {
if d >= depth {
continue;
}
for r in &g.relations {
if r.from == curr && !visited.contains(&r.to) {
visited.insert(r.to.clone());
queue.push_back((r.to.clone(), d + 1));
} else if r.to == curr && !visited.contains(&r.from) {
visited.insert(r.from.clone());
queue.push_back((r.from.clone(), d + 1));
}
}
}
let ent: Vec<_> = g.entities.values().filter(|e| visited.contains(&e.name)).cloned().collect();
let rel: Vec<_> = g.relations.iter().filter(|r| visited.contains(&r.from) && visited.contains(&r.to)).cloned().collect();
(ent, rel)
});
let mut markdown = format!("# Subgraph Topology for `{}`\n\n", root);
markdown.push_str("## Entities\n");
for e in &sub_entities {
markdown.push_str(&format!("- **{}** (`{}`)\n", e.name, e.entity_type));
for obs in &e.observations {
markdown.push_str(&format!(" - {}\n", obs));
}
}
markdown.push_str("\n## Relations\n");
for r in &sub_relations {
markdown.push_str(&format!("- `{}` --[{}]--> `{}`\n", r.from, r.relation_type, r.to));
}
return Ok(markdown);
}
let data = state.read_graph(|full| {
let mut visited_nodes: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut matched_entities = std::collections::HashMap::new();
let mut matched_relations = Vec::new();
let mut queue = std::collections::VecDeque::new();
queue.push_back((root.clone(), 0));
visited_nodes.insert(root.clone());
while let Some((curr, current_depth)) = queue.pop_front() {
if let Some(entity) = full.entities.get(&curr) {
matched_entities.insert(curr.clone(), entity.clone());
}
if current_depth < depth {
for rel in &full.relations {
if rel.from == curr {
matched_relations.push(rel.clone());
if !visited_nodes.contains(&rel.to) {
visited_nodes.insert(rel.to.clone());
queue.push_back((rel.to.clone(), current_depth + 1));
}
} else if rel.to == curr {
matched_relations.push(rel.clone());
if !visited_nodes.contains(&rel.from) {
visited_nodes.insert(rel.from.clone());
queue.push_back((rel.from.clone(), current_depth + 1));
}
}
}
}
}
let result = serde_json::json!({
"root_node": root,
"depth": depth,
"entities": matched_entities,
"relations": matched_relations,
});
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&result)?)
})?;
Ok(data)
}
}
use crate::handlers::utils::*;
pub struct SweepGraphHealthHandler;
#[async_trait]
impl McpTool for SweepGraphHealthHandler {
fn name(&self) -> &'static str {
"sweep_graph_health"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<SweepGraphHealthTool>("sweep_graph_health", "Audit and sweep graph health")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: SweepGraphHealthTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let auto_prune = req.auto_prune_orphans.unwrap_or(false);
let mut orphans = Vec::new();
let mut duplicates = Vec::new();
state.modify_graph(|g| {
// 1. Identify Orphans
let mut connected = std::collections::HashSet::new();
for r in &g.relations {
connected.insert(r.from.clone());
connected.insert(r.to.clone());
}
for name in g.entities.keys() {
if !connected.contains(name) {
orphans.push(name.clone());
}
}
if auto_prune {
for orphan in &orphans {
g.entities.remove(orphan);
}
}
// 2. Compute similarity pairs for duplicate detection
let names: Vec<_> = g.entities.keys().cloned().collect();
for i in 0..names.len() {
for j in (i + 1)..names.len() {
let n1 = &names[i];
let n2 = &names[j];
let l1 = n1.to_lowercase();
let l2 = n2.to_lowercase();
if l1 == l2 || ((l1.contains(&l2) || l2.contains(&l1)) && l1.len().min(l2.len()) > 3) {
duplicates.push(serde_json::json!({
"entity_a": n1,
"entity_b": n2,
"suggested_action": format!("merge_entities(source: '{}', target: '{}')", n1, n2)
}));
}
}
}
});
let report = serde_json::json!({
"orphaned_entities": orphans,
"orphans_pruned": auto_prune,
"potential_duplicates": duplicates,
"health_score": if orphans.is_empty() && duplicates.is_empty() { "100%" } else { "Needs Maintenance" }
});
Ok(serde_json::to_string_pretty(&report)?)
}
}
pub struct ResolveStaleSymbolsHandler;
#[async_trait]
impl McpTool for ResolveStaleSymbolsHandler {
fn name(&self) -> &'static str {
"resolve_stale_symbols"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ResolveStaleSymbolsTool>("resolve_stale_symbols", "Inspect Knowledge Graph entities and tech debt symbol/line references against files on disk, healing stale pointers.")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: ResolveStaleSymbolsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let auto_heal = req.auto_heal.unwrap_or(true);
let mut checked = 0;
let mut stale_entities = Vec::new();
state.modify_graph(|g| {
for (name, _entity) in g.entities.iter_mut() {
if let Some(target) = &req.target {
if name != target && !name.contains(target) {
continue;
}
}
checked += 1;
let path = std::path::Path::new(name);
if (name.contains('/') || name.contains('\\') || name.contains('.')) && !path.exists() {
stale_entities.push(name.clone());
}
}
if auto_heal {
for stale in &stale_entities {
g.entities.remove(stale);
g.relations.retain(|r| &r.from != stale && &r.to != stale);
}
}
});
let report = serde_json::json!({
"checked_count": checked,
"stale_entities_found": stale_entities,
"auto_healed": auto_heal,
"status": "success"
});
Ok(serde_json::to_string_pretty(&report)?)
}
}
pub struct SummarizeSubgraphHandler;
#[async_trait]
impl McpTool for SummarizeSubgraphHandler {
fn name(&self) -> &'static str {
"summarize_subgraph"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<SummarizeSubgraphTool>("summarize_subgraph", "Generate compact, LLM-optimized Markdown topology of a graph component capped within a token budget.")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: SummarizeSubgraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let depth = req.depth.unwrap_or(2);
let (sub_entities, sub_relations) = state.read_graph(|g| {
let mut visited = std::collections::HashSet::new();
let mut queue = std::collections::VecDeque::new();
queue.push_back((req.root_entity.clone(), 0));
visited.insert(req.root_entity.clone());
while let Some((curr, d)) = queue.pop_front() {
if d >= depth {
continue;
}
for r in &g.relations {
if r.from == curr && !visited.contains(&r.to) {
visited.insert(r.to.clone());
queue.push_back((r.to.clone(), d + 1));
} else if r.to == curr && !visited.contains(&r.from) {
visited.insert(r.from.clone());
queue.push_back((r.from.clone(), d + 1));
}
}
}
let ent: Vec<_> = g.entities.values().filter(|e| visited.contains(&e.name)).cloned().collect();
let rel: Vec<_> = g.relations.iter().filter(|r| visited.contains(&r.from) && visited.contains(&r.to)).cloned().collect();
(ent, rel)
});
let mut markdown = format!("# Subgraph Topology for `{}`\n\n", req.root_entity);
markdown.push_str("## Entities\n");
for e in &sub_entities {
markdown.push_str(&format!("- **{}** (`{}`)\n", e.name, e.entity_type));
for obs in &e.observations {
markdown.push_str(&format!(" - {}\n", obs));
}
}
markdown.push_str("\n## Relations\n");
for r in &sub_relations {
markdown.push_str(&format!("- `{}` --[{}]--> `{}`\n", r.from, r.relation_type, r.to));
}
let max_tokens = req.max_tokens.unwrap_or(1000);
let max_chars = max_tokens * 4;
if markdown.len() > max_chars {
markdown.truncate(max_chars);
markdown.push_str("\n... [Truncated to fit token budget]");
}
Ok(markdown)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::handlers::meta::{BroadcastAgentSignalHandler, QueryAgentSignalsHandler};
use serde_json::json;
use tempfile::tempdir;
#[tokio::test]
async fn test_create_and_read_entities() {
let dir = tempdir().unwrap();
@@ -888,4 +1250,71 @@ mod tests {
.unwrap();
assert!(!res_orphans.contains("Y"));
}
#[tokio::test]
async fn test_more_graph_handlers() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let create_handler = CreateEntitiesHandler;
let args_ent = json!({
"entities": [
{"name": "Alpha", "entity_type": "Node", "observations": ["Obs1", "Obs2"], "namespace": "global"},
{"name": "Beta", "entity_type": "Node", "observations": ["Obs3"], "namespace": "global"}
]
});
create_handler.execute(args_ent, state.clone()).await.unwrap();
let rel_handler = CreateRelationsHandler;
let args_rel = json!({
"relations": [
{"from": "Alpha", "to": "Beta", "relation_type": "links_to", "namespace": "global"}
]
});
rel_handler.execute(args_rel, state.clone()).await.unwrap();
let path_handler = QueryGraphPathHandler;
let path_res = path_handler.execute(json!({"start_node": "Alpha", "end_node": "Beta"}), state.clone()).await.unwrap();
assert!(path_res.contains("Alpha"));
let sub_handler = GetSubgraphHandler;
let sub_res = sub_handler.execute(json!({"root_entity": "Alpha"}), state.clone()).await.unwrap();
assert!(sub_res.contains("Alpha"));
let sum_handler = SummarizeSubgraphHandler;
let sum_res = sum_handler.execute(json!({"root_entity": "Alpha"}), state.clone()).await.unwrap();
assert!(!sum_res.is_empty());
let sweep_handler = SweepGraphHealthHandler;
let sweep_res = sweep_handler.execute(json!({}), state.clone()).await.unwrap();
assert!(!sweep_res.is_empty());
let del_obs_handler = DeleteObservationsHandler;
let del_obs_res = del_obs_handler.execute(json!({
"deletions": [{"entity_name": "Alpha", "observations": ["Obs1"]}]
}), state.clone()).await.unwrap();
assert_eq!(del_obs_res, "Observations deleted");
let del_rel_handler = DeleteRelationsHandler;
let del_rel_res = del_rel_handler.execute(json!({
"relations": [{"from": "Alpha", "to": "Beta", "relation_type": "links_to"}]
}), state.clone()).await.unwrap();
assert_eq!(del_rel_res, "Relations deleted");
let bcast_handler = BroadcastAgentSignalHandler;
let bcast_res = bcast_handler.execute(json!({
"sender": "agent1",
"signal_type": "task_completed",
"payload": "fix_bug"
}), state.clone()).await.unwrap();
assert!(bcast_res.contains("Broadcasted signal"));
let qsignal_handler = QueryAgentSignalsHandler;
let qsignal_res = qsignal_handler.execute(json!({"sender": "agent1"}), state.clone()).await.unwrap();
assert!(qsignal_res.contains("task_completed"));
}
}
+26
View File
@@ -133,4 +133,30 @@ mod tests {
assert!(result.contains("line1"));
assert!(result.contains("line3"));
}
#[tokio::test]
async fn test_get_recent_logs_with_large_file() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let handler = GetRecentLogsHandler;
let log_file = dir.path().join("large_test.log");
let mut buffer = String::new();
for _ in 0..1000 {
buffer.push_str("line\n");
}
std::fs::write(&log_file, buffer).unwrap();
let args = serde_json::json!({
"file_path": log_file.to_str().unwrap()
});
let result = handler
.execute(args, state)
.await
.map_err(|e| format!("Failed to get recent logs: {}", e))
.unwrap();
assert!(result.contains("line"));
}
}
File diff suppressed because it is too large. Load diff
+187 -9
View File
@@ -7,6 +7,90 @@ use serde_json::Value;
use std::collections::HashSet;
use std::sync::Arc;
pub struct ManageStickyNotesHandler;
#[async_trait]
impl McpTool for ManageStickyNotesHandler {
fn name(&self) -> &'static str {
"manage_sticky_notes"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ManageStickyNotesTool>(
"manage_sticky_notes",
"Manage ephemeral sticky notes with TTL (add, read, delete, clear)",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: ManageStickyNotesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
match req.action {
StickyNoteAction::Add => {
let content = req.content.ok_or_else(|| {
crate::error::AppError::Internal("content is required for 'add' action".to_string())
})?;
let now = crate::handlers::utils::now_secs();
let expires_at = if let Some(ttl) = req.ttl_seconds {
Some(now + ttl)
} else if req.session_only.unwrap_or(false) {
Some(now + 14400)
} else {
None
};
state.code.sticky.modify(|notes| {
notes.push(StickyNote {
timestamp: now,
content,
expires_at,
});
});
Ok("Sticky note added.".to_string())
}
StickyNoteAction::Read => {
let now = crate::handlers::utils::now_secs();
let mut active_notes = Vec::new();
state.code.sticky.modify(|notes| {
notes.retain(|n| {
if let Some(exp) = n.expires_at {
exp > now
} else {
true
}
});
active_notes = notes.clone();
});
Ok(serde_json::to_string(&active_notes)?)
}
StickyNoteAction::Delete => {
let idx = req.index.ok_or_else(|| {
crate::error::AppError::Internal("index is required for 'delete' action".to_string())
})?;
let mut success = false;
state.code.sticky.modify(|notes| {
if idx > 0 && idx <= notes.len() {
notes.remove(idx - 1);
success = true;
}
});
if success {
Ok("Sticky note deleted.".to_string())
} else {
Err(crate::error::AppError::Internal(
"Invalid sticky note index.".to_string(),
))
}
}
StickyNoteAction::Clear => {
state.code.sticky.modify(|notes| {
notes.clear();
});
Ok("All sticky notes cleared.".to_string())
}
}
}
}
pub struct AddStickyNoteHandler;
#[async_trait]
@@ -21,11 +105,20 @@ impl McpTool for AddStickyNoteHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: AddStickyNoteTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let now = crate::handlers::utils::now_secs();
let expires_at = if let Some(ttl) = req.ttl_seconds {
Some(now + ttl)
} else if req.session_only.unwrap_or(false) {
Some(now + 14400) // Default 4-hour session TTL
} else {
None
};
state.code.sticky.modify(|notes| {
notes.push(StickyNote {
timestamp: crate::handlers::utils::now_secs(),
timestamp: now,
content: req.content,
expires_at: None,
expires_at,
});
});
Ok("Sticky note added.".to_string())
@@ -48,11 +141,21 @@ impl McpTool for ReadStickyNotesHandler {
}
async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let data = state
.code
.sticky
.read_with(|s| Ok::<String, crate::error::AppError>(serde_json::to_string(s)?))?;
Ok(data)
let now = crate::handlers::utils::now_secs();
let mut active_notes = Vec::new();
state.code.sticky.modify(|notes| {
notes.retain(|n| {
if let Some(exp) = n.expires_at {
exp > now
} else {
true
}
});
active_notes = notes.clone();
});
Ok(serde_json::to_string(&active_notes)?)
}
}
@@ -138,7 +241,10 @@ impl McpTool for LeaveHandoffMemoHandler {
namespace: req.namespace,
timestamp: crate::handlers::utils::now_secs(),
expires_at: None,
})
});
if memos.len() > 200 {
memos.remove(0);
}
});
Ok("Handoff memo left".to_string())
}
@@ -227,7 +333,10 @@ impl McpTool for AddSessionSummaryHandler {
namespace: req.namespace,
timestamp: crate::handlers::utils::now_secs(),
expires_at: None,
})
});
if summaries.len() > 200 {
summaries.remove(0);
}
});
Ok("Session summary added".to_string())
}
@@ -268,6 +377,57 @@ impl McpTool for GenerateStandupReportHandler {
}
}
pub struct PromoteToEntityHandler;
#[async_trait]
impl McpTool for PromoteToEntityHandler {
fn name(&self) -> &'static str {
"promote_to_entity"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<PromoteToEntityTool>(
"promote_to_entity",
"Promote a transient sticky note or active task observation into a permanent Knowledge Graph entity.",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: PromoteToEntityTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let entity_name = req.entity_name.clone();
let obs = req.content.clone();
let entity_type = req.entity_type.clone();
let namespace = req.namespace.clone();
state.modify_graph(|g| {
let entity = g
.entities
.entry(entity_name.clone())
.or_insert_with(|| Entity {
name: entity_name.clone(),
entity_type,
observations: Vec::new(),
namespace,
git_branch: None,
});
if !entity.observations.contains(&obs) {
entity.observations.push(obs.clone());
}
});
state.code.sticky.modify(|notes| {
notes.retain(|n| !n.content.contains(&obs));
});
Ok(format!(
"Promoted transient note into permanent entity '{}'.",
req.entity_name
))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -366,4 +526,22 @@ mod tests {
.unwrap();
assert!(!res4.is_empty());
}
#[tokio::test]
async fn test_invalid_sticky_note_action() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let handler = ManageStickyNotesHandler;
let invalid_args = serde_json::json!({
"action": "invalid_action"
});
let res = handler
.execute(invalid_args, state.clone())
.await;
assert!(res.is_err());
}
}
+58 -2
View File
@@ -281,11 +281,12 @@ impl McpTool for ListActiveTasksHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: ListActiveTasksTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let level = req.summary_level.as_deref().unwrap_or("detailed");
let data = state.project.tasks.read_with(|tasks| {
let filtered: Vec<_> = tasks
.iter()
.filter(|t| {
let status_match = t.status != "done";
let status_match = t.status != "done" && t.status != "completed";
let branch_match = match &req.git_branch {
Some(branch) => {
t.git_branch.is_none()
@@ -295,8 +296,31 @@ impl McpTool for ListActiveTasksHandler {
};
status_match && branch_match
})
.map(|t| match level {
"compact" => serde_json::json!({
"id": t.id,
"title": t.title,
"status": t.status,
}),
"full" => serde_json::to_value(t).unwrap_or_default(),
_ => serde_json::json!({
"id": t.id,
"title": t.title,
"status": t.status,
"description": t.description,
"git_branch": t.git_branch,
}),
})
.collect();
Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?)
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 {
json_str.truncate(char_limit);
json_str.push_str(" ...[truncated due to max_tokens]");
}
}
Ok::<String, crate::error::AppError>(json_str)
})?;
Ok(data)
}
@@ -684,4 +708,36 @@ mod tests {
.unwrap();
assert!(res_del.contains("Deleted task and its children (2 total)."));
}
#[tokio::test]
async fn test_list_milestones_with_namespace() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let add_milestone = AddMilestoneHandler;
let args_ms = serde_json::json!({
"name": "v1.0",
"title": "Release 1.0",
"description": "First release",
"target_date": 1700000000,
"end_date": 1700000000,
"namespace": "global"
});
let res1 = add_milestone
.execute(args_ms, state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res1.contains("Milestone added"));
let list_ms = ListMilestonesHandler;
let res2 = list_ms
.execute(serde_json::json!({"namespace": "global"}), state.clone())
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res2.contains("Release 1.0"));
}
}
+71 -50
View File
@@ -23,11 +23,11 @@ impl McpTool for WriteClipboardHandler {
)
}
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let tool_args: WriteClipboardTool = serde_json::from_value(args)
.map_err(|e| crate::error::AppError::Internal(format!("Invalid args: {}", e)))?;
tokio::task::spawn_blocking(move || {
let res = tokio::task::spawn_blocking(move || {
let mut msgs = Vec::new();
if let Ok(mut clipboard) = Clipboard::new()
@@ -77,7 +77,10 @@ impl McpTool for WriteClipboardHandler {
}
})
.await
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))?
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
state.record_activity("clipboard", &res, None);
Ok(res)
}
}
@@ -101,51 +104,51 @@ impl McpTool for ReadClipboardHandler {
tokio::task::spawn_blocking(move || -> crate::error::Result<serde_json::Value> {
let mut out = serde_json::Map::new();
if let Ok(mut clipboard) = arboard::Clipboard::new()
&& let Ok(text) = clipboard.get_text()
&& !text.trim().is_empty()
{
out.insert("text".into(), json!(text));
}
if let Ok(mut clipboard) = arboard::Clipboard::new()
&& let Ok(image_data) = clipboard.get_image()
&& let Some(img) = ImageBuffer::<image::Rgba<u8>, _>::from_raw(
image_data.width as u32,
image_data.height as u32,
image_data.bytes.into_owned(),
)
{
let mut dynamic_img = image::DynamicImage::ImageRgba8(img);
let max_dim = 1024;
if dynamic_img.width() > max_dim || dynamic_img.height() > max_dim {
dynamic_img = dynamic_img.resize(max_dim, max_dim, FilterType::Lanczos3);
if let Ok(mut clipboard) = arboard::Clipboard::new() {
if let Ok(text) = clipboard.get_text() {
if !text.trim().is_empty() {
out.insert("text".into(), json!(text));
}
}
let rgb_img = dynamic_img.into_rgb8();
let cache_dir = dirs::home_dir()
.unwrap_or_default()
.join(".gemini/mcp_memory/clipboard");
let _ = std::fs::create_dir_all(&cache_dir);
if let Ok(image_data) = clipboard.get_image() {
if let Some(img) = ImageBuffer::<image::Rgba<u8>, _>::from_raw(
image_data.width as u32,
image_data.height as u32,
image_data.bytes.into_owned(),
) {
let mut dynamic_img = image::DynamicImage::ImageRgba8(img);
let max_dim = 1024;
if dynamic_img.width() > max_dim || dynamic_img.height() > max_dim {
dynamic_img = dynamic_img.resize(max_dim, max_dim, FilterType::Lanczos3);
}
let rgb_img = dynamic_img.into_rgb8();
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let file_path = cache_dir.join(format!("clipboard_{}.jpg", timestamp));
let cache_dir = dirs::home_dir()
.unwrap_or_default()
.join(".gemini/mcp_memory/clipboard");
let _ = std::fs::create_dir_all(&cache_dir);
if rgb_img
.save_with_format(&file_path, image::ImageFormat::Jpeg)
.is_ok()
{
let path_str = file_path.to_string_lossy().to_string();
out.insert("image_path".into(), json!(path_str));
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let file_path = cache_dir.join(format!("clipboard_{}.jpg", timestamp));
// Read image bytes for base64 encoding if needed by vision
if let Ok(bytes) = std::fs::read(&file_path) {
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
out.insert("image_base64".into(), json!(b64));
if rgb_img
.save_with_format(&file_path, image::ImageFormat::Jpeg)
.is_ok()
{
let path_str = file_path.to_string_lossy().to_string();
out.insert("image_path".into(), json!(path_str));
// Read image bytes for base64 encoding if needed by vision
if let Ok(bytes) = std::fs::read(&file_path) {
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
out.insert("image_base64".into(), json!(b64));
}
}
}
}
}
@@ -176,6 +179,7 @@ impl McpTool for ReadClipboardHandler {
}
}
state.record_activity("clipboard", "Read contents from OS clipboard", None);
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&final_obj)?)
}
}
@@ -203,14 +207,14 @@ impl McpTool for ToggleClipboardWatchModeHandler {
let mut watch_mode = state.clipboard_watch_mode.write().await;
*watch_mode = tool_args.enable;
if tool_args.enable {
Ok(
"Clipboard watch mode enabled. Changes will be ingested as StickyNotes."
.to_string(),
)
let status_msg = if tool_args.enable {
"Clipboard watch mode enabled. Changes will be ingested as StickyNotes."
} else {
Ok("Clipboard watch mode disabled.".to_string())
}
"Clipboard watch mode disabled."
};
state.record_activity("clipboard", status_msg, None);
Ok(status_msg.to_string())
}
}
@@ -280,4 +284,21 @@ mod tests {
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(parsed.is_object());
}
#[tokio::test]
async fn test_read_clipboard_empty() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let handler = ReadClipboardHandler;
let result = handler
.execute(serde_json::json!({}), state)
.await
.map_err(|e| format!("Failed to read clipboard: {}", e))
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(parsed.is_object());
}
}
+348 -3
View File
@@ -123,6 +123,7 @@ impl McpTool for StoreSnippetHandler {
code: req.code,
description: req.description,
updated_at: crate::handlers::utils::now_secs(),
tags: req.tags.unwrap_or_default(),
embedding,
};
@@ -138,6 +139,43 @@ impl McpTool for StoreSnippetHandler {
}
}
pub struct TagSnippetHandler;
#[async_trait]
impl McpTool for TagSnippetHandler {
fn name(&self) -> &'static str {
"tag_snippet"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<TagSnippetTool>("tag_snippet", "Execute tag_snippet")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: TagSnippetTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let mut found = false;
state.code.snippets.modify(|snippets| {
for s in snippets.iter_mut() {
if s.name == req.name {
for tag in &req.tags {
if !s.tags.contains(tag) {
s.tags.push(tag.clone());
}
}
found = true;
break;
}
}
});
if found {
Ok(format!("Snippet '{}' tagged with: {:?}", req.name, req.tags))
} else {
Err(crate::error::AppError::Internal(format!("Snippet '{}' not found.", req.name)))
}
}
}
pub struct SearchSnippetsHandler;
#[async_trait]
@@ -147,22 +185,49 @@ impl McpTool for SearchSnippetsHandler {
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<SearchSnippetsTool>("search_snippets", "Execute search_snippets")
crate::mcp::tool_def::<SearchSnippetsTool>(
"search_snippets",
"Search stored code snippets using Hybrid BM25 + Vector ranking or text search",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: SearchSnippetsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let query = req.query;
let limit = req.limit.unwrap_or(10);
let include_body = req.include_body.unwrap_or(false);
let tags_filter = req.tags.unwrap_or_default();
let data = state.code.snippets.read_with(|snippets| {
let results: Vec<_> = snippets
.iter()
.filter(|s| {
contains_ignore_ascii_case(&s.name, &query)
let matches_text = query.is_empty()
|| contains_ignore_ascii_case(&s.name, &query)
|| contains_ignore_ascii_case(&s.description, &query)
|| contains_ignore_ascii_case(&s.language, &query)
|| 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)));
matches_text && matches_tags
})
.take(limit)
.map(|s| {
if include_body {
serde_json::json!(s)
} else {
serde_json::json!({
"name": s.name,
"description": s.description,
"language": s.language,
"tags": s.tags
})
}
})
.collect();
Ok::<String, crate::error::AppError>(serde_json::to_string(&results)?)
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&results)?)
})?;
Ok(data)
}
@@ -523,7 +588,60 @@ mod tests {
.unwrap();
assert_eq!(res8, "PR checklist cleared");
}
#[tokio::test]
async fn test_workspace_and_subagent_handlers() {
let temp_dir = tempfile::tempdir().unwrap();
let state = Arc::new(MemoryState::new(temp_dir.path().to_str().unwrap()));
// Snapshots
let create_snap = CreateSnapshotHandler;
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();
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();
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();
assert!(manage_res.contains("initialized"));
// Context Workspace Diff
let save_ws = SaveContextWorkspaceHandler;
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();
assert!(diff_res.contains("workspace_a"));
}
#[tokio::test]
async fn test_diff_context_workspaces_handler() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let save_ws = SaveContextWorkspaceHandler;
save_ws.execute(json!({"name": "ws_1", "description": "Workspace 1", "pinned_files": ["src/lib.rs"], "active_task_ids": []}), state.clone()).await.unwrap();
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();
assert!(diff_res.contains("ws_1"));
}
}
use crate::tools::ReadDirectoryArchitectureTool;
use std::fs;
@@ -726,6 +844,95 @@ impl McpTool for RestoreSnapshotHandler {
}
}
pub struct ManageSubagentNamespaceHandler;
#[async_trait]
impl McpTool for ManageSubagentNamespaceHandler {
fn name(&self) -> &'static str {
"manage_subagent_namespace"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ManageSubagentNamespaceTool>(
"manage_subagent_namespace",
"Manage isolated memory namespaces for subagent sessions (create, condense/promote, or purge)",
)
}
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 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))
}
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))
}
SubagentNamespaceAction::Condense => {
let purge = req.purge_after_promotion.unwrap_or(true);
let mut promoted_entities = 0;
let mut promoted_relations = 0;
state.modify_graph(|g| {
let sub_entities: Vec<Entity> = g
.entities
.values()
.filter(|e| e.namespace == subagent_ns)
.cloned()
.collect();
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(),
});
for obs in e.observations {
if !global_entry.observations.contains(&obs) {
global_entry.observations.push(obs);
}
}
}
for r in &mut g.relations {
if r.namespace == subagent_ns {
promoted_relations += 1;
r.namespace = "global".to_string();
}
}
if purge {
g.entities.retain(|_, e| e.namespace != subagent_ns);
g.relations.retain(|r| r.namespace != subagent_ns);
}
});
if purge {
state.project.pinned_files.modify(|files| {
files.retain(|f| f.namespace != subagent_ns);
});
}
Ok(format!(
"Subagent namespace '{}' condensed: promoted {} entities and {} relations to global graph.",
subagent_ns, promoted_entities, promoted_relations
))
}
}
}
}
pub struct CreateSubagentNamespaceHandler;
#[async_trait]
@@ -770,3 +977,141 @@ impl McpTool for PurgeSubagentNamespaceHandler {
Ok(format!("Subagent memory namespace '{}' purged.", ns))
}
}
pub struct CondenseSubagentNamespaceHandler;
#[async_trait]
impl McpTool for CondenseSubagentNamespaceHandler {
fn name(&self) -> &'static str {
"condense_subagent_namespace"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<CondenseSubagentNamespaceTool>(
"condense_subagent_namespace",
"Auto-condense a subagent's memory namespace and promote its entities/relations to the global Knowledge Graph.",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: CondenseSubagentNamespaceTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let subagent_ns = format!("subagent-{}", req.subagent_id);
let purge = req.purge_after_promotion.unwrap_or(true);
let mut promoted_entities = 0;
let mut promoted_relations = 0;
state.modify_graph(|g| {
let sub_entities: Vec<Entity> = g
.entities
.values()
.filter(|e| e.namespace == subagent_ns)
.cloned()
.collect();
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(),
});
for obs in e.observations {
if !global_entry.observations.contains(&obs) {
global_entry.observations.push(obs);
}
}
}
for r in &mut g.relations {
if r.namespace == subagent_ns {
r.namespace = "global".to_string();
promoted_relations += 1;
}
}
if purge {
g.entities.retain(|_, e| e.namespace != subagent_ns);
g.relations.retain(|r| r.namespace != subagent_ns);
}
});
if purge {
state.project.pinned_files.modify(|files| {
files.retain(|f| f.namespace != subagent_ns);
});
}
Ok(format!(
"Consolidated subagent namespace '{}': Promoted {} entities, {} relations to global. Purged: {}",
subagent_ns, promoted_entities, promoted_relations, purge
))
}
}
pub struct DiffContextWorkspacesHandler;
#[async_trait]
impl McpTool for DiffContextWorkspacesHandler {
fn name(&self) -> &'static str {
"diff_context_workspaces"
}
fn schema(&self) -> Value {
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 workspaces = state.project.context_workspaces.read_with(|ws| ws.clone());
let ws_a = workspaces.iter().find(|w| w.name == req.workspace_a);
let ws_b = workspaces.iter().find(|w| w.name == req.workspace_b);
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()
)));
}
let a = ws_a.unwrap();
let b = ws_b.unwrap();
let set_files_a: std::collections::HashSet<_> = a.pinned_files.iter().cloned().collect();
let set_files_b: std::collections::HashSet<_> = b.pinned_files.iter().cloned().collect();
let added_files: Vec<_> = set_files_b.difference(&set_files_a).cloned().collect();
let removed_files: Vec<_> = set_files_a.difference(&set_files_b).cloned().collect();
let shared_files: Vec<_> = set_files_a.intersection(&set_files_b).cloned().collect();
let set_tasks_a: std::collections::HashSet<_> = a.active_task_ids.iter().cloned().collect();
let set_tasks_b: std::collections::HashSet<_> = b.active_task_ids.iter().cloned().collect();
let added_tasks: Vec<_> = set_tasks_b.difference(&set_tasks_a).cloned().collect();
let removed_tasks: Vec<_> = set_tasks_a.difference(&set_tasks_b).cloned().collect();
let shared_tasks: Vec<_> = set_tasks_a.intersection(&set_tasks_b).cloned().collect();
let diff = serde_json::json!({
"workspace_a": a.name,
"workspace_b": b.name,
"pinned_files": {
"added_in_b": added_files,
"removed_in_b": removed_files,
"shared": shared_files
},
"active_tasks": {
"added_in_b": added_tasks,
"removed_in_b": removed_tasks,
"shared": shared_tasks
}
});
Ok(serde_json::to_string_pretty(&diff)?)
}
}