refactor: eradicate deprecated tools (sticky_notes, pinned_files, context_workspaces, pr_checklist, preferences) and dead code
This commit is contained in:
1 parent
d80915635f
commit
79209da711
31 files changed
+1230
-3920
No files matched your search
+67
-46
@@ -81,27 +81,64 @@ impl McpTool for ReadFileSkeletonHandler {
|
||||
return;
|
||||
}
|
||||
let kind = node.kind();
|
||||
|
||||
let is_structural = matches!(
|
||||
let is_container = matches!(
|
||||
kind,
|
||||
"use_declaration"
|
||||
| "import_statement"
|
||||
| "import_from_statement"
|
||||
| "struct_item"
|
||||
| "enum_item"
|
||||
| "trait_item"
|
||||
| "impl_item"
|
||||
| "function_item"
|
||||
| "function_declaration"
|
||||
| "function_definition"
|
||||
| "method_definition"
|
||||
| "interface_declaration"
|
||||
| "type_alias_declaration"
|
||||
"impl_item"
|
||||
| "class_declaration"
|
||||
| "class_definition"
|
||||
| "trait_item"
|
||||
| "interface_declaration"
|
||||
);
|
||||
|
||||
if is_structural {
|
||||
let is_structural = is_container
|
||||
|| matches!(
|
||||
kind,
|
||||
"use_declaration"
|
||||
| "import_statement"
|
||||
| "import_from_statement"
|
||||
| "struct_item"
|
||||
| "enum_item"
|
||||
| "function_item"
|
||||
| "function_declaration"
|
||||
| "function_definition"
|
||||
| "method_definition"
|
||||
| "type_alias_declaration"
|
||||
);
|
||||
|
||||
if is_container {
|
||||
let indent = " ".repeat(depth);
|
||||
let node_text = node.utf8_text(code.as_bytes()).unwrap_or("");
|
||||
let mut header = String::new();
|
||||
for line in node_text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.ends_with('{') || trimmed.ends_with(':') {
|
||||
header.push_str(line);
|
||||
break;
|
||||
} else {
|
||||
header.push_str(line);
|
||||
header.push('\n');
|
||||
}
|
||||
}
|
||||
if header.is_empty() {
|
||||
if let Some(first_line) = node_text.lines().next() {
|
||||
header = first_line.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
out.push_str(&indent);
|
||||
out.push_str(header.trim());
|
||||
out.push('\n');
|
||||
|
||||
let mut cursor = node.walk();
|
||||
for child in node.named_children(&mut cursor) {
|
||||
extract_skeleton(child, code, out, depth + 1);
|
||||
}
|
||||
|
||||
if header.trim().ends_with('{') {
|
||||
out.push_str(&indent);
|
||||
out.push_str("}\n");
|
||||
}
|
||||
} else if is_structural {
|
||||
let indent = " ".repeat(depth);
|
||||
|
||||
let node_text = node.utf8_text(code.as_bytes()).unwrap_or("");
|
||||
@@ -127,7 +164,7 @@ impl McpTool for ReadFileSkeletonHandler {
|
||||
} else if node.is_named() {
|
||||
let mut cursor = node.walk();
|
||||
for child in node.named_children(&mut cursor) {
|
||||
extract_skeleton(child, code, out, depth + 1);
|
||||
extract_skeleton(child, code, out, depth);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,6 +268,7 @@ impl McpTool for ReplaceAstNodeHandler {
|
||||
actual_kind,
|
||||
"trait_item" | "interface_declaration" | "interface_item"
|
||||
),
|
||||
"impl" | "impl_item" => actual_kind == "impl_item",
|
||||
"type" | "type_alias" => matches!(
|
||||
actual_kind,
|
||||
"type_alias_declaration" | "type_item" | "type_definition"
|
||||
@@ -253,6 +291,7 @@ impl McpTool for ReplaceAstNodeHandler {
|
||||
let kind = child.kind();
|
||||
if kind == "identifier"
|
||||
|| kind == "name"
|
||||
|| kind == "type_identifier"
|
||||
|| kind == "property_identifier"
|
||||
|| kind == "field_identifier"
|
||||
{
|
||||
@@ -386,6 +425,11 @@ fn scan_workspace_for_symbol(
|
||||
if scanned_files > 500 {
|
||||
break;
|
||||
}
|
||||
if let Ok(meta) = std::fs::metadata(path) {
|
||||
if meta.len() > 1024 * 1024 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -450,17 +494,7 @@ 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())
|
||||
})
|
||||
})
|
||||
};
|
||||
let custom_dir = req.workspace_dir.as_ref().map(std::path::PathBuf::from);
|
||||
|
||||
if matches.len() < limit {
|
||||
let remaining = limit - matches.len();
|
||||
@@ -516,17 +550,7 @@ 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())
|
||||
})
|
||||
})
|
||||
};
|
||||
let custom_dir = req.workspace_dir.as_ref().map(std::path::PathBuf::from);
|
||||
|
||||
if callers.len() < limit {
|
||||
let remaining = limit - callers.len();
|
||||
@@ -571,13 +595,10 @@ 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 custom_dir = req
|
||||
.file_path
|
||||
.as_ref()
|
||||
.and_then(|p| std::path::Path::new(p).parent().map(|p| p.to_path_buf()));
|
||||
|
||||
let sym_clone = sym.clone();
|
||||
let disk_refs = tokio::task::spawn_blocking(move || {
|
||||
|
||||
+74
-183
@@ -5,166 +5,6 @@ use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct UpdateEnvFingerprintHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for UpdateEnvFingerprintHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"update_env_fingerprint"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<UpdateEnvFingerprintTool>(
|
||||
"update_env_fingerprint",
|
||||
"Update system OS, shell, and installed tool version fingerprints for a workspace namespace.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: UpdateEnvFingerprintTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
state.env.env_fingerprints.modify(|fps| {
|
||||
fps.insert(
|
||||
req.namespace.clone(),
|
||||
crate::models::EnvFingerprint {
|
||||
namespace: req.namespace.clone(),
|
||||
os: std::env::consts::OS.to_string(),
|
||||
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()
|
||||
},
|
||||
);
|
||||
});
|
||||
Ok("Env fingerprint updated".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReadEnvFingerprintHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for ReadEnvFingerprintHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"read_env_fingerprint"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<ReadEnvFingerprintTool>(
|
||||
"read_env_fingerprint",
|
||||
"Read system OS, shell, and tool version fingerprints for a workspace namespace.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: ReadEnvFingerprintTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let data = state
|
||||
.env
|
||||
.env_fingerprints
|
||||
.read_with(|fps| fps.get(&req.namespace).cloned());
|
||||
if let Some(fp) = data {
|
||||
let data = Ok::<String, crate::error::AppError>(serde_json::to_string(&fp)?)?;
|
||||
Ok(data)
|
||||
} else {
|
||||
Ok("{}".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LogEnvRequirementHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for LogEnvRequirementHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"log_env_requirement"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<LogEnvRequirementTool>(
|
||||
"log_env_requirement",
|
||||
"Log required environment variables, configuration keys, or secrets for a namespace.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: LogEnvRequirementTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
state.env.env_requirements.modify(|reqs| {
|
||||
reqs.retain(|r| !(r.namespace == req.namespace && r.key == req.key));
|
||||
reqs.push(crate::models::EnvRequirement {
|
||||
namespace: req.namespace,
|
||||
key: req.key,
|
||||
description: req.description,
|
||||
is_secret: req.is_secret,
|
||||
..Default::default()
|
||||
});
|
||||
});
|
||||
Ok("Env requirement logged".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RegisterEnvironmentHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for RegisterEnvironmentHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"register_environment"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<RegisterEnvironmentTool>(
|
||||
"register_environment",
|
||||
"Register infrastructure environment details (URL, VPN requirements, notes) for a namespace.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: RegisterEnvironmentTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
state.env.environments.modify(|envs| {
|
||||
envs.retain(|e| !(e.namespace == req.namespace && e.name == req.name));
|
||||
envs.push(crate::models::EnvironmentDetail {
|
||||
namespace: req.namespace,
|
||||
name: req.name,
|
||||
url: req.url,
|
||||
description: req.description,
|
||||
requires_vpn: req.requires_vpn,
|
||||
updated_at: crate::handlers::utils::now_secs(),
|
||||
..Default::default()
|
||||
});
|
||||
});
|
||||
Ok("Environment registered".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GetEnvironmentDetailsHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for GetEnvironmentDetailsHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"get_environment_details"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<GetEnvironmentDetailsTool>(
|
||||
"get_environment_details",
|
||||
"Retrieve registered infrastructure environment details for a namespace.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: GetEnvironmentDetailsTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let data = state.env.environments.read_with(|envs| {
|
||||
let filtered: Vec<_> = envs
|
||||
.iter()
|
||||
.filter(|e| e.namespace == req.namespace)
|
||||
.collect();
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?)
|
||||
})?;
|
||||
Ok(data)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EnvironmentHandler;
|
||||
|
||||
#[async_trait]
|
||||
@@ -186,10 +26,32 @@ impl McpTool for EnvironmentHandler {
|
||||
match req.action {
|
||||
EnvAction::UpdateFingerprint => {
|
||||
let tool_versions = req.tool_versions.unwrap_or_default();
|
||||
UpdateEnvFingerprintHandler.execute(serde_json::json!({"namespace": ns, "tool_versions": tool_versions}), state).await
|
||||
state.env.env_fingerprints.modify(|fps| {
|
||||
fps.insert(
|
||||
ns.clone(),
|
||||
crate::models::EnvFingerprint {
|
||||
namespace: ns.clone(),
|
||||
os: std::env::consts::OS.to_string(),
|
||||
shell: std::env::var("SHELL").unwrap_or_else(|_| "unknown".to_string()),
|
||||
tool_versions,
|
||||
updated_at: crate::handlers::utils::now_secs(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
});
|
||||
Ok("Env fingerprint updated".to_string())
|
||||
}
|
||||
EnvAction::ReadFingerprint => {
|
||||
ReadEnvFingerprintHandler.execute(serde_json::json!({"namespace": ns}), state).await
|
||||
let data = state
|
||||
.env
|
||||
.env_fingerprints
|
||||
.read_with(|fps| fps.get(&ns).cloned());
|
||||
if let Some(fp) = data {
|
||||
let data = Ok::<String, crate::error::AppError>(serde_json::to_string(&fp)?)?;
|
||||
Ok(data)
|
||||
} else {
|
||||
Ok("{}".to_string())
|
||||
}
|
||||
}
|
||||
EnvAction::LogRequirement => {
|
||||
let key = req.key.ok_or_else(|| {
|
||||
@@ -197,7 +59,17 @@ impl McpTool for EnvironmentHandler {
|
||||
})?;
|
||||
let desc = req.description.unwrap_or_default();
|
||||
let secret = req.is_secret.unwrap_or(false);
|
||||
LogEnvRequirementHandler.execute(serde_json::json!({"key": key, "description": desc, "is_secret": secret, "namespace": ns}), state).await
|
||||
state.env.env_requirements.modify(|reqs| {
|
||||
reqs.retain(|r| !(r.namespace == ns && r.key == key));
|
||||
reqs.push(crate::models::EnvRequirement {
|
||||
namespace: ns,
|
||||
key,
|
||||
description: desc,
|
||||
is_secret: secret,
|
||||
..Default::default()
|
||||
});
|
||||
});
|
||||
Ok("Env requirement logged".to_string())
|
||||
}
|
||||
EnvAction::Register => {
|
||||
let name = req.name.ok_or_else(|| {
|
||||
@@ -206,15 +78,35 @@ impl McpTool for EnvironmentHandler {
|
||||
let url = req.url.unwrap_or_default();
|
||||
let desc = req.description.unwrap_or_default();
|
||||
let vpn = req.requires_vpn.unwrap_or(false);
|
||||
RegisterEnvironmentHandler.execute(serde_json::json!({"name": name, "url": url, "description": desc, "requires_vpn": vpn, "namespace": ns}), state).await
|
||||
state.env.environments.modify(|envs| {
|
||||
envs.retain(|e| !(e.namespace == ns && e.name == name));
|
||||
envs.push(crate::models::EnvironmentDetail {
|
||||
namespace: ns,
|
||||
name,
|
||||
url,
|
||||
description: desc,
|
||||
requires_vpn: vpn,
|
||||
updated_at: crate::handlers::utils::now_secs(),
|
||||
..Default::default()
|
||||
});
|
||||
});
|
||||
Ok("Environment registered".to_string())
|
||||
}
|
||||
EnvAction::GetDetails => {
|
||||
GetEnvironmentDetailsHandler.execute(serde_json::json!({"namespace": ns}), state).await
|
||||
let data = state.env.environments.read_with(|envs| {
|
||||
let filtered: Vec<_> = envs
|
||||
.iter()
|
||||
.filter(|e| e.namespace == ns)
|
||||
.collect();
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?)
|
||||
})?;
|
||||
Ok(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -225,25 +117,25 @@ mod tests {
|
||||
async fn test_env_fingerprint() {
|
||||
let dir = tempdir().unwrap();
|
||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||
let handler = EnvironmentHandler;
|
||||
|
||||
let update_handler = UpdateEnvFingerprintHandler;
|
||||
let args = json!({
|
||||
"action": "update_fingerprint",
|
||||
"namespace": "global",
|
||||
"tool_versions": {
|
||||
"rustc": "1.70.0"
|
||||
}
|
||||
});
|
||||
|
||||
let res = update_handler
|
||||
let res = handler
|
||||
.execute(args, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(res, "Env fingerprint updated");
|
||||
|
||||
let read_handler = ReadEnvFingerprintHandler;
|
||||
let res2 = read_handler
|
||||
.execute(json!({"namespace": "global"}), state.clone())
|
||||
let res2 = handler
|
||||
.execute(json!({"action": "read_fingerprint", "namespace": "global"}), state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
@@ -269,9 +161,9 @@ mod tests {
|
||||
});
|
||||
});
|
||||
|
||||
let handler = GetEnvironmentDetailsHandler;
|
||||
let handler = EnvironmentHandler;
|
||||
let res = handler
|
||||
.execute(json!({"namespace": "global"}), state.clone())
|
||||
.execute(json!({"action": "get_details", "namespace": "global"}), state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
@@ -283,41 +175,39 @@ mod tests {
|
||||
async fn test_env_requirements_and_registration() {
|
||||
let dir = tempdir().unwrap();
|
||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||
let handler = EnvironmentHandler;
|
||||
|
||||
let req_handler = LogEnvRequirementHandler;
|
||||
let args_req = json!({
|
||||
"action": "log_requirement",
|
||||
"key": "vpn",
|
||||
"description": "Requires VPN",
|
||||
"is_secret": false,
|
||||
"requirement": "Needs specific VPN",
|
||||
"context": "For database access",
|
||||
"namespace": "global"
|
||||
});
|
||||
let res1 = req_handler
|
||||
let res1 = handler
|
||||
.execute(args_req, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(res1, "Env requirement logged");
|
||||
|
||||
let reg_handler = RegisterEnvironmentHandler;
|
||||
let args_reg = json!({
|
||||
"action": "register",
|
||||
"name": "prod",
|
||||
"url": "https://prod.local",
|
||||
"description": "Production",
|
||||
"requires_vpn": true,
|
||||
"namespace": "global"
|
||||
});
|
||||
let res2 = reg_handler
|
||||
let res2 = handler
|
||||
.execute(args_reg, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(res2, "Environment registered");
|
||||
|
||||
let get_handler = GetEnvironmentDetailsHandler;
|
||||
let res3 = get_handler
|
||||
.execute(json!({"namespace": "global"}), state.clone())
|
||||
let res3 = handler
|
||||
.execute(json!({"action": "get_details", "namespace": "global"}), state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
@@ -329,16 +219,17 @@ mod tests {
|
||||
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 handler = EnvironmentHandler;
|
||||
|
||||
let update_handler = UpdateEnvFingerprintHandler;
|
||||
let args = serde_json::json!({
|
||||
"action": "update_fingerprint",
|
||||
"namespace": "",
|
||||
"tool_versions": {
|
||||
"rustc": "1.70.0"
|
||||
}
|
||||
});
|
||||
|
||||
let res = update_handler
|
||||
let res = handler
|
||||
.execute(args, state.clone())
|
||||
.await;
|
||||
|
||||
|
||||
+122
-95
@@ -459,14 +459,15 @@ impl McpTool for ReadGraphHandler {
|
||||
let offset = req.offset.unwrap_or(0);
|
||||
|
||||
let data = state.read_graph(|full| -> crate::error::Result<String> {
|
||||
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 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 total_count = filtered_entities.len();
|
||||
|
||||
let (entities_map, relations_vec, meta_json) = if limit.is_some() || req.offset.is_some() {
|
||||
let effective_limit = limit.unwrap_or(50);
|
||||
let paged_entities: std::collections::HashMap<&String, &crate::models::Entity> = filtered_entities
|
||||
.into_iter()
|
||||
@@ -485,45 +486,94 @@ impl McpTool for ReadGraphHandler {
|
||||
})
|
||||
.collect();
|
||||
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"entities": paged_entities,
|
||||
"relations": relevant_relations,
|
||||
let meta = serde_json::json!({
|
||||
"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 {
|
||||
filtered.entities.insert(k, v);
|
||||
}
|
||||
}
|
||||
for r in &full.relations {
|
||||
if r.namespace == ns {
|
||||
filtered.relations.push(r);
|
||||
}
|
||||
}
|
||||
serde_json::to_string(&filtered)?
|
||||
});
|
||||
(paged_entities, relevant_relations, Some(meta))
|
||||
} else {
|
||||
serde_json::to_string(full)?
|
||||
let ent_map: std::collections::HashMap<&String, &crate::models::Entity> =
|
||||
filtered_entities.into_iter().collect();
|
||||
let rel_vec: Vec<&crate::models::Relation> = full
|
||||
.relations
|
||||
.iter()
|
||||
.filter(|r| req.namespace.as_ref().map_or(true, |ns| &r.namespace == ns))
|
||||
.collect();
|
||||
(ent_map, rel_vec, None)
|
||||
};
|
||||
|
||||
let serialized = if let Some(meta) = &meta_json {
|
||||
serde_json::json!({
|
||||
"entities": entities_map,
|
||||
"relations": relations_vec,
|
||||
"total_count": meta["total_count"],
|
||||
"offset": meta["offset"],
|
||||
"limit": meta["limit"],
|
||||
"has_more": meta["has_more"],
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"entities": entities_map,
|
||||
"relations": relations_vec,
|
||||
})
|
||||
};
|
||||
|
||||
let mut result_json = serde_json::to_string(&serialized)?;
|
||||
|
||||
if let Some(max_tok) = max_tokens {
|
||||
let max_chars = max_tok * 4;
|
||||
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, pagination (limit, offset), or get_subgraph for targeted discovery]");
|
||||
let total_ent = entities_map.len();
|
||||
let total_rel = relations_vec.len();
|
||||
let frac = (max_chars as f64) / (result_json.len() as f64);
|
||||
let mut keep_count = (total_ent as f64 * frac * 0.85).max(1.0) as usize;
|
||||
|
||||
let ent_vec: Vec<_> = entities_map.into_iter().collect();
|
||||
|
||||
while keep_count > 0 {
|
||||
let sliced_ent: std::collections::HashMap<_, _> = ent_vec.iter().take(keep_count).cloned().collect();
|
||||
let sliced_rel: Vec<_> = relations_vec.iter().filter(|r| sliced_ent.contains_key(&r.from) && sliced_ent.contains_key(&r.to)).copied().collect();
|
||||
|
||||
let mut truncated_val = serde_json::json!({
|
||||
"entities": sliced_ent,
|
||||
"relations": sliced_rel,
|
||||
"_meta": {
|
||||
"truncated": true,
|
||||
"total_entities": total_ent,
|
||||
"returned_entities": sliced_ent.len(),
|
||||
"total_relations": total_rel,
|
||||
"returned_relations": sliced_rel.len(),
|
||||
"note": "Output structurally truncated to fit within max_tokens. Use pagination (limit, offset), search_nodes, or get_subgraph for targeted discovery."
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(meta) = &meta_json {
|
||||
if let Some(obj) = truncated_val.as_object_mut() {
|
||||
obj.insert("total_count".to_string(), meta["total_count"].clone());
|
||||
obj.insert("offset".to_string(), meta["offset"].clone());
|
||||
obj.insert("limit".to_string(), meta["limit"].clone());
|
||||
obj.insert("has_more".to_string(), serde_json::Value::Bool(true));
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(trunc_str) = serde_json::to_string(&truncated_val) {
|
||||
if trunc_str.len() <= max_chars || keep_count == 1 {
|
||||
result_json = trunc_str;
|
||||
break;
|
||||
}
|
||||
}
|
||||
keep_count = keep_count.saturating_sub(keep_count / 3 + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result_json)
|
||||
})?;
|
||||
Ok(data)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SearchNodesHandler;
|
||||
|
||||
#[async_trait]
|
||||
@@ -910,36 +960,49 @@ impl McpTool for GetSubgraphHandler {
|
||||
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());
|
||||
let (matched_entities, matched_relations) = state.read_graph(|full| {
|
||||
let mut adj: std::collections::HashMap<&str, Vec<&str>> =
|
||||
std::collections::HashMap::with_capacity(full.relations.len() * 2);
|
||||
for r in &full.relations {
|
||||
adj.entry(r.from.as_str()).or_default().push(r.to.as_str());
|
||||
adj.entry(r.to.as_str()).or_default().push(r.from.as_str());
|
||||
}
|
||||
|
||||
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 mut visited = std::collections::HashSet::new();
|
||||
let mut queue = std::collections::VecDeque::new();
|
||||
queue.push_back((root.as_str(), 0));
|
||||
visited.insert(root.clone());
|
||||
|
||||
while let Some((curr, d)) = queue.pop_front() {
|
||||
if d < depth {
|
||||
if let Some(neighbors) = adj.get(curr) {
|
||||
for &next in neighbors {
|
||||
if visited.insert(next.to_string()) {
|
||||
queue.push_back((next, 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 ent: std::collections::HashMap<String, crate::models::Entity> = visited
|
||||
.iter()
|
||||
.filter_map(|name| full.entities.get(name).map(|e| (name.clone(), e.clone())))
|
||||
.collect();
|
||||
let rel: Vec<crate::models::Relation> = full
|
||||
.relations
|
||||
.iter()
|
||||
.filter(|r| visited.contains(&r.from) && visited.contains(&r.to))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
(ent, rel)
|
||||
});
|
||||
|
||||
if format == SubgraphFormat::MarkdownTree {
|
||||
let mut markdown = format!("# Subgraph Topology for `{}`\n\n", root);
|
||||
markdown.push_str("## Entities\n");
|
||||
for e in &sub_entities {
|
||||
for e in matched_entities.values() {
|
||||
markdown.push_str(&format!("- **{}** (`{}`)\n", e.name, e.entity_type));
|
||||
for obs in &e.observations {
|
||||
markdown.push_str(&format!(" - {}\n", obs));
|
||||
@@ -947,56 +1010,20 @@ impl McpTool for GetSubgraphHandler {
|
||||
}
|
||||
|
||||
markdown.push_str("\n## Relations\n");
|
||||
for r in &sub_relations {
|
||||
for r in &matched_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 result = serde_json::json!({
|
||||
"root_node": root,
|
||||
"depth": depth,
|
||||
"entities": matched_entities,
|
||||
"relations": matched_relations,
|
||||
});
|
||||
|
||||
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)
|
||||
Ok(serde_json::to_string_pretty(&result)?)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+252
-794
File diff suppressed because it is too large.
Load diff
@@ -7,100 +7,6 @@ use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct StickyNotesHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for StickyNotesHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"sticky_notes"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<StickyNotesTool>(
|
||||
"sticky_notes",
|
||||
"Ephemeral sticky notes with TTL (add, read, delete, clear)",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: StickyNotesTool = 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("Missing required parameter 'content' for action 'add'. Next step: Provide non-empty 'content' string in request and retry.".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,
|
||||
..Default::default()
|
||||
});
|
||||
});
|
||||
Ok("Sticky note added.".to_string())
|
||||
}
|
||||
StickyNoteAction::Read => {
|
||||
let now = crate::handlers::utils::now_secs();
|
||||
let has_expired = state.code.sticky.read_with(|notes| {
|
||||
notes.iter().any(|n| matches!(n.expires_at, Some(exp) if exp <= now))
|
||||
});
|
||||
if has_expired {
|
||||
state.code.sticky.modify(|notes| {
|
||||
notes.retain(|n| {
|
||||
if let Some(exp) = n.expires_at {
|
||||
exp > now
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
let active_notes = state.code.sticky.read_with(|notes| notes.clone());
|
||||
Ok(serde_json::to_string(&active_notes)?)
|
||||
}
|
||||
StickyNoteAction::Delete => {
|
||||
let idx = req.index.ok_or_else(|| {
|
||||
crate::error::AppError::Internal("Missing required parameter 'index' for action 'delete'. Next step: Provide 1-based sticky note 'index' integer in request and retry.".to_string())
|
||||
})?;
|
||||
let mut success = false;
|
||||
state.code.sticky.modify(|notes| {
|
||||
if idx > 0 && idx <= notes.len() {
|
||||
notes.remove(idx - 1);
|
||||
success = true;
|
||||
} else if idx == 0 && !notes.is_empty() {
|
||||
notes.remove(0);
|
||||
success = true;
|
||||
}
|
||||
});
|
||||
if success {
|
||||
Ok("Sticky note deleted.".to_string())
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal(
|
||||
"Invalid sticky note index. Next step: Call sticky_notes with action='read' to view active notes and valid indices, then retry.".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
StickyNoteAction::Clear => {
|
||||
state.code.sticky.modify(|notes| {
|
||||
notes.clear();
|
||||
});
|
||||
Ok("All sticky notes cleared.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
pub struct HandoffMemosHandler;
|
||||
|
||||
#[async_trait]
|
||||
@@ -255,7 +161,7 @@ impl McpTool for PromoteToEntityHandler {
|
||||
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.",
|
||||
"Promote a task observation or finding into a permanent Knowledge Graph entity.",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -285,10 +191,6 @@ impl McpTool for PromoteToEntityHandler {
|
||||
}
|
||||
});
|
||||
|
||||
state.code.sticky.modify(|notes| {
|
||||
notes.retain(|n| !n.content.contains(&obs));
|
||||
});
|
||||
|
||||
Ok(format!(
|
||||
"Promoted transient note into permanent entity '{}'.",
|
||||
req.entity_name
|
||||
@@ -302,47 +204,6 @@ mod tests {
|
||||
use serde_json::json;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_notes_lifecycle() {
|
||||
let dir = tempdir().unwrap();
|
||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||
|
||||
let handler = StickyNotesHandler;
|
||||
let args = json!({
|
||||
"action": "add",
|
||||
"content": "Buy milk",
|
||||
});
|
||||
|
||||
let res = handler
|
||||
.execute(args, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res.contains("Sticky note added"));
|
||||
|
||||
let res2 = handler
|
||||
.execute(json!({"action": "read"}), state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res2.contains("Buy milk"));
|
||||
|
||||
let args2 = json!({"action": "delete", "index": 1});
|
||||
let res3 = handler
|
||||
.execute(args2, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(res3, "Sticky note deleted.");
|
||||
|
||||
let res4 = handler
|
||||
.execute(json!({"action": "read"}), state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(!res4.contains("Buy milk"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_handoff_and_summaries() {
|
||||
let dir = tempdir().unwrap();
|
||||
@@ -392,22 +253,6 @@ 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 = StickyNotesHandler;
|
||||
let invalid_args = serde_json::json!({
|
||||
"action": "invalid_action"
|
||||
});
|
||||
|
||||
let res = handler
|
||||
.execute(invalid_args, state.clone())
|
||||
.await;
|
||||
assert!(res.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ impl McpTool for TasksHandler {
|
||||
crate::error::AppError::Internal("Missing required parameter 'id' for action 'update'. Next step: Provide task 'id' string in request and retry.".to_string())
|
||||
})?;
|
||||
let status = req.status.ok_or_else(|| {
|
||||
crate::error::AppError::Internal("Missing required parameter 'status' for action 'update'. Next step: Provide valid 'status' ('pending', 'completed', or 'cancelled') in request and retry.".to_string())
|
||||
crate::error::AppError::Internal("Missing required parameter 'status' for action 'update'. Next step: Provide valid 'status' ('pending', 'active', 'in_progress', 'blocked', 'completed', or 'cancelled') in request and retry.".to_string())
|
||||
})?;
|
||||
let target_status = status.to_lowercase();
|
||||
let mut found = false;
|
||||
@@ -113,6 +113,7 @@ impl McpTool for TasksHandler {
|
||||
if let Some(dep_task) = tasks.iter().find(|dt| dt.id == *dep_id)
|
||||
&& dep_task.status != "completed"
|
||||
&& dep_task.status != "done"
|
||||
&& dep_task.status != "cancelled"
|
||||
{
|
||||
uncompleted_deps.push(dep_task.title.as_str());
|
||||
}
|
||||
@@ -132,7 +133,10 @@ impl McpTool for TasksHandler {
|
||||
.iter()
|
||||
.filter(|t| t.parent_id.as_deref() == Some(target_id_ref))
|
||||
{
|
||||
if child.status != "completed" && child.status != "done" {
|
||||
if child.status != "completed"
|
||||
&& child.status != "done"
|
||||
&& child.status != "cancelled"
|
||||
{
|
||||
uncompleted_children.push(child.title.as_str());
|
||||
}
|
||||
}
|
||||
@@ -303,6 +307,7 @@ impl McpTool for TasksHandler {
|
||||
crate::error::AppError::Internal("Missing required parameter 'criteria' for action 'set_criteria'. Next step: Provide array of acceptance criteria strings in request and retry.".to_string())
|
||||
})?;
|
||||
let mut success = false;
|
||||
let mut updated_task: Option<Task> = None;
|
||||
state.project.tasks.modify(|tasks| {
|
||||
if let Some(task) = tasks.iter_mut().find(|t| t.id == id || t.title == id) {
|
||||
task.acceptance_criteria = criteria_list
|
||||
@@ -315,10 +320,15 @@ impl McpTool for TasksHandler {
|
||||
})
|
||||
.collect();
|
||||
task.updated_at = crate::handlers::utils::now_secs();
|
||||
updated_task = Some(task.clone());
|
||||
success = true;
|
||||
}
|
||||
});
|
||||
if success {
|
||||
if let Some(ref ut) = updated_task {
|
||||
let idx = state.get_search_index().await;
|
||||
drop(idx.index_task(ut));
|
||||
}
|
||||
state.broadcast_task_event(TaskEvent {
|
||||
task_id: id.clone(),
|
||||
status: "criteria_set".to_string(),
|
||||
@@ -343,6 +353,7 @@ impl McpTool for TasksHandler {
|
||||
let proof_str = req.proof.unwrap_or_else(|| "Verified".to_string());
|
||||
let mut success = false;
|
||||
let mut already_met = false;
|
||||
let mut updated_task: Option<Task> = None;
|
||||
state.project.tasks.modify(|tasks| {
|
||||
if let Some(task) = tasks.iter_mut().find(|t| t.id == id || t.title == id) {
|
||||
if let Some(ac) = task.acceptance_criteria.iter_mut().next() {
|
||||
@@ -351,7 +362,8 @@ impl McpTool for TasksHandler {
|
||||
} else {
|
||||
ac.is_met = true;
|
||||
success = true;
|
||||
task.updated_at = crate::handlers::utils::now_secs();
|
||||
task.updated_at = crate::handlers::utils::now_secs();
|
||||
updated_task = Some(task.clone());
|
||||
}
|
||||
} else {
|
||||
task.acceptance_criteria
|
||||
@@ -362,11 +374,16 @@ impl McpTool for TasksHandler {
|
||||
..Default::default()
|
||||
});
|
||||
task.updated_at = crate::handlers::utils::now_secs();
|
||||
updated_task = Some(task.clone());
|
||||
success = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
if success {
|
||||
if let Some(ref ut) = updated_task {
|
||||
let idx = state.get_search_index().await;
|
||||
drop(idx.index_task(ut));
|
||||
}
|
||||
state.broadcast_task_event(TaskEvent {
|
||||
task_id: id.clone(),
|
||||
status: "verified".to_string(),
|
||||
|
||||
+201
-213
@@ -1,6 +1,6 @@
|
||||
use crate::router::McpTool;
|
||||
use crate::state::MemoryState;
|
||||
use crate::tools::{ReadClipboardTool, WriteClipboardTool};
|
||||
use crate::tools::{ClipboardAction, ClipboardTool};
|
||||
use arboard::{Clipboard, ImageData};
|
||||
use async_trait::async_trait;
|
||||
use image::{ImageBuffer, imageops::FilterType};
|
||||
@@ -8,104 +8,6 @@ use serde_json::{Value, json};
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct WriteClipboardHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for WriteClipboardHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"write_clipboard"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<WriteClipboardTool>(
|
||||
"write_clipboard",
|
||||
"Write text, HTML, files, and/or images directly to the OS clipboard.",
|
||||
)
|
||||
}
|
||||
|
||||
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)))?;
|
||||
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
let mut msgs = Vec::new();
|
||||
|
||||
if let Some(text) = &tool_args.text {
|
||||
let mut written = false;
|
||||
if let Ok(mut clipboard) = Clipboard::new() {
|
||||
for _ in 0..3 {
|
||||
if clipboard.set_text(text).is_ok() {
|
||||
written = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
if written {
|
||||
msgs.push("Wrote text");
|
||||
}
|
||||
}
|
||||
|
||||
// Handle arboard for image
|
||||
if let Some(image_path) = &tool_args.image_path {
|
||||
match image::open(image_path) {
|
||||
Ok(img) => {
|
||||
let img = if img.width() > 2048 || img.height() > 2048 {
|
||||
img.resize(2048, 2048, FilterType::Triangle)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
let rgba = img.into_rgba8();
|
||||
let (w, h) = rgba.dimensions();
|
||||
let raw_bytes = rgba.into_raw();
|
||||
let mut written = false;
|
||||
if let Ok(mut clipboard) = arboard::Clipboard::new() {
|
||||
for _ in 0..3 {
|
||||
let img_data = ImageData {
|
||||
width: w as usize,
|
||||
height: h as usize,
|
||||
bytes: Cow::Borrowed(&raw_bytes),
|
||||
};
|
||||
if clipboard.set_image(img_data).is_ok() {
|
||||
written = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
if written {
|
||||
msgs.push("Wrote image");
|
||||
} else {
|
||||
return Err(crate::error::AppError::Internal(
|
||||
"Failed to write image to clipboard".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(crate::error::AppError::Internal(format!(
|
||||
"Failed to open image {}: {}",
|
||||
image_path, e
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if msgs.is_empty() {
|
||||
Ok("No valid clipboard data provided or failed to write.".to_string())
|
||||
} else {
|
||||
Ok(format!(
|
||||
"Successfully populated clipboard: {}",
|
||||
msgs.join(", ")
|
||||
))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
|
||||
|
||||
state.record_activity("clipboard", &res, None);
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_native_clipboard_text() -> Option<String> {
|
||||
for _ in 0..3 {
|
||||
@@ -121,7 +23,53 @@ pub fn get_native_clipboard_text() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn get_windows_png_clipboard_image() -> Option<image::DynamicImage> {
|
||||
use windows_sys::Win32::System::DataExchange::{
|
||||
CloseClipboard, GetClipboardData, IsClipboardFormatAvailable, OpenClipboard,
|
||||
RegisterClipboardFormatW,
|
||||
};
|
||||
use windows_sys::Win32::System::Memory::{GlobalLock, GlobalSize, GlobalUnlock};
|
||||
let format_name: Vec<u16> = "PNG\0".encode_utf16().collect();
|
||||
unsafe {
|
||||
let format_id = RegisterClipboardFormatW(format_name.as_ptr());
|
||||
if format_id == 0 {
|
||||
return None;
|
||||
}
|
||||
for _ in 0..3 {
|
||||
if OpenClipboard(std::ptr::null_mut()) != 0 {
|
||||
let has_format = IsClipboardFormatAvailable(format_id) != 0;
|
||||
let mut img_opt = None;
|
||||
if has_format {
|
||||
let handle = GetClipboardData(format_id);
|
||||
if !handle.is_null() {
|
||||
let size = GlobalSize(handle as _);
|
||||
if size > 0 {
|
||||
let ptr = GlobalLock(handle as _);
|
||||
if !ptr.is_null() {
|
||||
let slice = std::slice::from_raw_parts(ptr as *const u8, size);
|
||||
img_opt = image::load_from_memory(slice).ok();
|
||||
GlobalUnlock(handle as _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CloseClipboard();
|
||||
if img_opt.is_some() {
|
||||
return img_opt;
|
||||
}
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
pub fn get_native_clipboard_image() -> Option<image::DynamicImage> {
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(img) = get_windows_png_clipboard_image() {
|
||||
return Some(img);
|
||||
}
|
||||
for _ in 0..3 {
|
||||
if let Ok(mut clipboard) = arboard::Clipboard::new() {
|
||||
if let Ok(image_data) = clipboard.get_image() {
|
||||
@@ -139,105 +87,6 @@ pub fn get_native_clipboard_image() -> Option<image::DynamicImage> {
|
||||
None
|
||||
}
|
||||
|
||||
pub struct ReadClipboardHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for ReadClipboardHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"read_clipboard"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<ReadClipboardTool>(
|
||||
"read_clipboard",
|
||||
"Read the current OS clipboard. Extracts text, HTML, file paths, and/or an image if available.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || -> crate::error::Result<serde_json::Value> {
|
||||
let mut out = serde_json::Map::new();
|
||||
|
||||
if let Some(text) = get_native_clipboard_text() {
|
||||
out.insert("text".into(), json!(text));
|
||||
}
|
||||
|
||||
if let Some(dynamic_img) = get_native_clipboard_image() {
|
||||
let mut img = dynamic_img;
|
||||
let max_dim = 1024;
|
||||
if img.width() > max_dim || img.height() > max_dim {
|
||||
img = img.resize(max_dim, max_dim, FilterType::Lanczos3);
|
||||
}
|
||||
let rgb_img = 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);
|
||||
|
||||
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));
|
||||
|
||||
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));
|
||||
#[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;
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
out.insert("image_base64".into(), json!(b64));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Value::Object(out))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
|
||||
|
||||
let mut final_obj = result;
|
||||
if let Some(b64) = final_obj.get("image_base64").and_then(|v| v.as_str()) {
|
||||
let b64_str = b64.to_string();
|
||||
if let Some(obj) = final_obj.as_object_mut() {
|
||||
obj.remove("image_base64");
|
||||
}
|
||||
if state.ollama.is_available().await
|
||||
&& let Ok(analysis) = state
|
||||
.ollama
|
||||
.generate_vision(
|
||||
"Describe the content, text, or UI layout of this image concisely:",
|
||||
&b64_str,
|
||||
)
|
||||
.await
|
||||
&& let Some(obj) = final_obj.as_object_mut()
|
||||
{
|
||||
obj.insert("image_analysis".to_string(), json!(analysis.trim()));
|
||||
}
|
||||
}
|
||||
|
||||
state.record_activity("clipboard", "Read contents from OS clipboard", None);
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&final_obj)?)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
use crate::tools::{ClipboardAction, ClipboardTool};
|
||||
|
||||
pub struct ClipboardHandler;
|
||||
|
||||
#[async_trait]
|
||||
@@ -257,23 +106,161 @@ impl McpTool for ClipboardHandler {
|
||||
let req: ClipboardTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
match req.action {
|
||||
ClipboardAction::Read => {
|
||||
ReadClipboardHandler.execute(serde_json::json!({}), state).await
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || -> crate::error::Result<serde_json::Value> {
|
||||
let mut out = serde_json::Map::new();
|
||||
|
||||
if let Some(text) = get_native_clipboard_text() {
|
||||
out.insert("text".into(), json!(text));
|
||||
}
|
||||
|
||||
if let Some(dynamic_img) = get_native_clipboard_image() {
|
||||
let mut img = dynamic_img;
|
||||
let max_dim = 1024;
|
||||
if img.width() > max_dim || img.height() > max_dim {
|
||||
img = img.resize(max_dim, max_dim, FilterType::Lanczos3);
|
||||
}
|
||||
let rgb_img = img.into_rgb8();
|
||||
|
||||
let mut jpeg_bytes = std::io::Cursor::new(Vec::new());
|
||||
if rgb_img.write_to(&mut jpeg_bytes, image::ImageFormat::Jpeg).is_ok() {
|
||||
let bytes = jpeg_bytes.into_inner();
|
||||
use base64::Engine;
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
out.insert("image_base64".into(), json!(b64));
|
||||
|
||||
let cache_dir = dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join(".gemini/mcp_memory/clipboard");
|
||||
let _ = std::fs::create_dir_all(&cache_dir);
|
||||
let file_path = cache_dir.join("clipboard_latest.jpg");
|
||||
if std::fs::write(&file_path, &bytes).is_ok() {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
|
||||
|
||||
let mut final_obj = result;
|
||||
if let Some(b64) = final_obj.get("image_base64").and_then(|v| v.as_str()) {
|
||||
let b64_str = b64.to_string();
|
||||
if let Some(obj) = final_obj.as_object_mut() {
|
||||
obj.remove("image_base64");
|
||||
}
|
||||
if state.ollama.is_available().await
|
||||
&& let Ok(analysis) = state
|
||||
.ollama
|
||||
.generate_vision(
|
||||
"Describe the content, text, or UI layout of this image concisely:",
|
||||
&b64_str,
|
||||
)
|
||||
.await
|
||||
&& let Some(obj) = final_obj.as_object_mut()
|
||||
{
|
||||
obj.insert("image_analysis".to_string(), json!(analysis.trim()));
|
||||
}
|
||||
}
|
||||
|
||||
state.record_activity("clipboard", "Read contents from OS clipboard", None);
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&final_obj)?)
|
||||
}
|
||||
ClipboardAction::Write => {
|
||||
WriteClipboardHandler.execute(
|
||||
serde_json::json!({
|
||||
"text": req.text,
|
||||
"html": req.html,
|
||||
"files": req.files,
|
||||
"image_path": req.image_path
|
||||
}),
|
||||
state,
|
||||
).await
|
||||
let text_opt = req.text;
|
||||
let image_path_opt = req.image_path;
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
let mut msgs = Vec::new();
|
||||
|
||||
if let Some(text) = &text_opt {
|
||||
let mut written = false;
|
||||
if let Ok(mut clipboard) = Clipboard::new() {
|
||||
for _ in 0..3 {
|
||||
if clipboard.set_text(text).is_ok() {
|
||||
written = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
if written {
|
||||
msgs.push("Wrote text");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(image_path) = &image_path_opt {
|
||||
match image::open(image_path) {
|
||||
Ok(img) => {
|
||||
let img = if img.width() > 2048 || img.height() > 2048 {
|
||||
img.resize(2048, 2048, FilterType::Triangle)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
let rgba = img.into_rgba8();
|
||||
let (w, h) = rgba.dimensions();
|
||||
let raw_bytes = rgba.into_raw();
|
||||
let mut written = false;
|
||||
if let Ok(mut clipboard) = arboard::Clipboard::new() {
|
||||
for _ in 0..3 {
|
||||
let img_data = ImageData {
|
||||
width: w as usize,
|
||||
height: h as usize,
|
||||
bytes: Cow::Borrowed(&raw_bytes),
|
||||
};
|
||||
if clipboard.set_image(img_data).is_ok() {
|
||||
written = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
if written {
|
||||
msgs.push("Wrote image");
|
||||
} else {
|
||||
return Err(crate::error::AppError::Internal(
|
||||
"Failed to write image to clipboard".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(crate::error::AppError::Internal(format!(
|
||||
"Failed to open image {}: {}",
|
||||
image_path, e
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if msgs.is_empty() {
|
||||
Ok("No valid clipboard data provided or failed to write.".to_string())
|
||||
} else {
|
||||
Ok(format!(
|
||||
"Successfully populated clipboard: {}",
|
||||
msgs.join(", ")
|
||||
))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
|
||||
|
||||
state.record_activity("clipboard", &res, None);
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -286,9 +273,10 @@ mod tests {
|
||||
async fn test_write_clipboard() {
|
||||
let dir = tempdir().unwrap();
|
||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||
let handler = WriteClipboardHandler;
|
||||
let handler = ClipboardHandler;
|
||||
|
||||
let args = json!({
|
||||
"action": "write",
|
||||
"text": "test_text"
|
||||
});
|
||||
|
||||
@@ -310,10 +298,10 @@ mod tests {
|
||||
async fn test_read_clipboard() {
|
||||
let dir = tempdir().unwrap();
|
||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||
let handler = ReadClipboardHandler;
|
||||
let handler = ClipboardHandler;
|
||||
|
||||
let result = handler
|
||||
.execute(json!({}), state)
|
||||
.execute(json!({"action": "read"}), state)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read clipboard: {}", e))
|
||||
.unwrap();
|
||||
@@ -327,10 +315,10 @@ mod tests {
|
||||
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 handler = ClipboardHandler;
|
||||
|
||||
let result = handler
|
||||
.execute(serde_json::json!({}), state)
|
||||
.execute(serde_json::json!({"action": "read"}), state)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read clipboard: {}", e))
|
||||
.unwrap();
|
||||
|
||||
+257
-1186
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user