chore: fix formatting and clippy lints

This commit is contained in:
Riz Ashraf committed 2026-10-08 23:01:38 +01:00
1 parent 8952bd5399
commit 9d9e959744
34 files changed
+1081 -572

No files matched your search

+1 -1
View File
@@ -23,7 +23,7 @@ pub async fn read_mcp_message<R: tokio::io::AsyncRead + Unpin>(
break; break;
} }
if trimmed_line.as_bytes().len() >= 15 if trimmed_line.len() >= 15
&& trimmed_line.as_bytes()[..15].eq_ignore_ascii_case(b"content-length:") && trimmed_line.as_bytes()[..15].eq_ignore_ascii_case(b"content-length:")
{ {
length = trimmed_line[15..].trim().parse().unwrap_or(0); length = trimmed_line[15..].trim().parse().unwrap_or(0);
+39 -29
View File
@@ -214,7 +214,7 @@ pub struct NvimRequest {
pub reply: oneshot::Sender<Result<rmpv::Value, String>>, pub reply: oneshot::Sender<Result<rmpv::Value, String>>,
} }
use std::sync::atomic::{AtomicU32, AtomicU64, AtomicU8, Ordering}; use std::sync::atomic::{AtomicU8, AtomicU32, AtomicU64, Ordering};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CircuitState { pub enum CircuitState {
@@ -275,7 +275,9 @@ impl NvimCircuitBreaker {
pub fn record_success(&self) { pub fn record_success(&self) {
self.consecutive_failures.store(0, Ordering::SeqCst); self.consecutive_failures.store(0, Ordering::SeqCst);
let prev = self.state.swap(CircuitState::Closed as u8, Ordering::SeqCst); let prev = self
.state
.swap(CircuitState::Closed as u8, Ordering::SeqCst);
if prev != CircuitState::Closed as u8 { if prev != CircuitState::Closed as u8 {
tracing::info!("Neovim RPC circuit breaker transitioned to Closed (recovered)"); tracing::info!("Neovim RPC circuit breaker transitioned to Closed (recovered)");
} }
@@ -299,7 +301,8 @@ impl NvimCircuitBreaker {
pub fn reset(&self) { pub fn reset(&self) {
self.consecutive_failures.store(0, Ordering::SeqCst); self.consecutive_failures.store(0, Ordering::SeqCst);
self.state.store(CircuitState::Closed as u8, Ordering::SeqCst); self.state
.store(CircuitState::Closed as u8, Ordering::SeqCst);
} }
} }
@@ -374,18 +377,15 @@ async fn get_nvim_connection() -> Result<mpsc::Sender<NvimRequest>, String> {
// without spawning any external nvim.exe child process! // without spawning any external nvim.exe child process!
{ {
let conn_lock = NVIM_CONN.lock().await; let conn_lock = NVIM_CONN.lock().await;
if let Some(sender) = conn_lock.as_ref() { if let Some(sender) = conn_lock.as_ref()
if !sender.is_closed() { && !sender.is_closed() {
let active_lock = ACTIVE_SOCKET.lock().await; let active_lock = ACTIVE_SOCKET.lock().await;
if let Some(ref current_socket) = *active_lock { if let Some(ref current_socket) = *active_lock
if let Ok(target) = read_active_socket_file().await { && let Ok(target) = read_active_socket_file().await
if !target.is_empty() && &target == current_socket { && !target.is_empty() && &target == current_socket {
return Ok(sender.clone()); return Ok(sender.clone());
} }
}
}
} }
}
} }
let interactive_socket = get_socket_path().await.ok(); let interactive_socket = get_socket_path().await.ok();
@@ -643,7 +643,10 @@ async fn get_nvim_connection() -> Result<mpsc::Sender<NvimRequest>, String> {
async fn call_nvim(req: rmpv::Value) -> Result<rmpv::Value, String> { async fn call_nvim(req: rmpv::Value) -> Result<rmpv::Value, String> {
if !CIRCUIT_BREAKER.can_execute() { if !CIRCUIT_BREAKER.can_execute() {
return Err("Neovim RPC circuit breaker is OPEN (consecutive failures detected). Failing fast.".to_string()); return Err(
"Neovim RPC circuit breaker is OPEN (consecutive failures detected). Failing fast."
.to_string(),
);
} }
let msgid = if let rmpv::Value::Array(ref arr) = req { let msgid = if let rmpv::Value::Array(ref arr) = req {
@@ -1749,17 +1752,18 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
.to_string() .to_string()
}); });
let options_arr = match args.get("options").and_then(|v| v.as_array()) { let options_arr =
Some(arr) => { match args.get("options").and_then(|v| v.as_array()) {
let rmp_arr: Vec<rmpv::Value> = arr Some(arr) => {
.iter() let rmp_arr: Vec<rmpv::Value> = arr
.filter_map(|item| item.as_str()) .iter()
.map(|s| rmpv::Value::String(s.into())) .filter_map(|item| item.as_str())
.collect(); .map(|s| rmpv::Value::String(s.into()))
rmpv::Value::Array(rmp_arr) .collect();
} rmpv::Value::Array(rmp_arr)
None => rmpv::Value::Nil, }
}; None => rmpv::Value::Nil,
};
let lua_code = r#" let lua_code = r#"
local prompt_text, options, request_id = ... local prompt_text, options, request_id = ...
@@ -1782,7 +1786,8 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
) )
); );
} else { } else {
send_error(id, -32602, "Missing 'prompt' or 'prompt_text'").await; send_error(id, -32602, "Missing 'prompt' or 'prompt_text'")
.await;
} }
} }
"extmark" => { "extmark" => {
@@ -1812,11 +1817,10 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
} }
"highlight" | "clear_highlight" => { "highlight" | "clear_highlight" => {
let mut modified_args = args.clone(); let mut modified_args = args.clone();
if action == "clear_highlight" { if action == "clear_highlight"
if let Some(obj) = modified_args.as_object_mut() { && let Some(obj) = modified_args.as_object_mut() {
obj.insert("clear_only".to_string(), json!(true)); obj.insert("clear_only".to_string(), json!(true));
} }
}
let args_rmp = json_to_rmpv(&modified_args); let args_rmp = json_to_rmpv(&modified_args);
let code = " let code = "
local args = ... local args = ...
@@ -2216,13 +2220,19 @@ mod tests {
// 2nd failure - trips to Open // 2nd failure - trips to Open
cb.record_failure(); cb.record_failure();
assert_eq!(cb.current_state(), CircuitState::Open); assert_eq!(cb.current_state(), CircuitState::Open);
assert!(!cb.can_execute(), "Circuit breaker should fail fast when Open"); assert!(
!cb.can_execute(),
"Circuit breaker should fail fast when Open"
);
// Wait for cooldown // Wait for cooldown
std::thread::sleep(std::time::Duration::from_millis(60)); std::thread::sleep(std::time::Duration::from_millis(60));
// After cooldown, can_execute transitions to HalfOpen // After cooldown, can_execute transitions to HalfOpen
assert!(cb.can_execute(), "After cooldown, should allow HalfOpen probe"); assert!(
cb.can_execute(),
"After cooldown, should allow HalfOpen probe"
);
assert_eq!(cb.current_state(), CircuitState::HalfOpen); assert_eq!(cb.current_state(), CircuitState::HalfOpen);
// Success in HalfOpen recovers back to Closed // Success in HalfOpen recovers back to Closed
+4 -1
View File
@@ -41,7 +41,10 @@ pub async fn wait_for_event_handler(
} }
} }
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
tracing::warn!("Event bus receiver lagged by {} messages; continuing wait.", skipped); tracing::warn!(
"Event bus receiver lagged by {} messages; continuing wait.",
skipped
);
continue; continue;
} }
Err(tokio::sync::broadcast::error::RecvError::Closed) => { Err(tokio::sync::broadcast::error::RecvError::Closed) => {
+42 -24
View File
@@ -44,8 +44,7 @@ pub fn find_projected_knowledge(
|| normalized_file.ends_with(&format!("/{}", ndfp.trim_start_matches('/'))) || normalized_file.ends_with(&format!("/{}", ndfp.trim_start_matches('/')))
|| ndfp.ends_with(&format!("/{}", normalized_file.trim_start_matches('/'))) || ndfp.ends_with(&format!("/{}", normalized_file.trim_start_matches('/')))
|| (!file_name.is_empty() || (!file_name.is_empty()
&& (ndfp == file_name && (ndfp == file_name || ndfp.ends_with(&format!("/{}", file_name))))
|| ndfp.ends_with(&format!("/{}", file_name))))
{ {
return true; return true;
} }
@@ -73,8 +72,7 @@ pub fn find_projected_knowledge(
all_adrs all_adrs
.iter() .iter()
.filter(|a| { .filter(|a| {
a.status.eq_ignore_ascii_case("accepted") a.status.eq_ignore_ascii_case("accepted") || a.status.eq_ignore_ascii_case("active")
|| a.status.eq_ignore_ascii_case("active")
}) })
.filter(|a| { .filter(|a| {
for comp in &a.affected_components { for comp in &a.affected_components {
@@ -189,31 +187,47 @@ pub async fn nvim_telemetry_handler(
if payload.event.starts_with("agent_") || payload.event.starts_with("diff_") { if payload.event.starts_with("agent_") || payload.event.starts_with("diff_") {
let payload_val = serde_json::json!(&payload); let payload_val = serde_json::json!(&payload);
let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent { let _ = state
topic: format!("nvim:ui:{}", payload.event), .handler
session_id: Some(payload.session_id.clone()), .state
payload: payload_val.clone(), .event_bus_tx
}); .send(crate::state::GenericEvent {
topic: format!("nvim:ui:{}", payload.event),
if let Some(ref req_id) = payload.request_id {
let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
topic: format!("nvim:ui:{}:{}", payload.event, req_id),
session_id: Some(payload.session_id.clone()), session_id: Some(payload.session_id.clone()),
payload: payload_val.clone(), payload: payload_val.clone(),
}); });
if let Some(ref req_id) = payload.request_id {
let _ = state
.handler
.state
.event_bus_tx
.send(crate::state::GenericEvent {
topic: format!("nvim:ui:{}:{}", payload.event, req_id),
session_id: Some(payload.session_id.clone()),
payload: payload_val.clone(),
});
} }
if let Some(ref diff_id) = payload.diff_id { if let Some(ref diff_id) = payload.diff_id {
let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent { let _ = state
topic: format!("nvim:ui:{}:{}", payload.event, diff_id), .handler
session_id: Some(payload.session_id.clone()), .state
payload: payload_val.clone(), .event_bus_tx
}); .send(crate::state::GenericEvent {
let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent { topic: format!("nvim:ui:{}:{}", payload.event, diff_id),
topic: format!("nvim:ui:diff_decision:{}", diff_id), session_id: Some(payload.session_id.clone()),
session_id: Some(payload.session_id.clone()), payload: payload_val.clone(),
payload: payload_val.clone(), });
}); let _ = state
.handler
.state
.event_bus_tx
.send(crate::state::GenericEvent {
topic: format!("nvim:ui:diff_decision:{}", diff_id),
session_id: Some(payload.session_id.clone()),
payload: payload_val.clone(),
});
} }
} }
@@ -230,7 +244,11 @@ pub async fn nvim_telemetry_handler(
tasks tasks
.iter() .iter()
.find(|t| t.is_active()) .find(|t| t.is_active())
.or_else(|| tasks.iter().find(|t| t.status == "pending" && t.parent_id.is_none())) .or_else(|| {
tasks
.iter()
.find(|t| t.status == "pending" && t.parent_id.is_none())
})
.map(|t| t.title.clone()) .map(|t| t.title.clone())
}); });
+16 -8
View File
@@ -66,9 +66,11 @@ pub async fn handle_socket(socket: WebSocket, state: Arc<AppState>, _client_type
activities activities
.iter() .iter()
.filter_map(|act_val| { .filter_map(|act_val| {
if act_val["category"] == "TASK_EVENT" { if act_val["category"] == "TASK_EVENT"
if let Some(details_str) = act_val["details"].as_str() { && let Some(details_str) = act_val["details"].as_str()
if let Ok(event_val) = serde_json::from_str::<serde_json::Value>(details_str) { && let Ok(event_val) =
serde_json::from_str::<serde_json::Value>(details_str)
{
return Some( return Some(
serde_json::json!({ serde_json::json!({
"jsonrpc": "2.0", "jsonrpc": "2.0",
@@ -78,8 +80,6 @@ pub async fn handle_socket(socket: WebSocket, state: Arc<AppState>, _client_type
.to_string(), .to_string(),
); );
} }
}
}
None None
}) })
.take(5) .take(5)
@@ -262,7 +262,10 @@ mod tests {
mem_state.broadcast_task_event(task_event.clone()); mem_state.broadcast_task_event(task_event.clone());
// Verify recent activities recorded the event // Verify recent activities recorded the event
let recorded = mem_state.telemetry.recent_activities.read_with(|act| act.clone()); let recorded = mem_state
.telemetry
.recent_activities
.read_with(|act| act.clone());
assert!(!recorded.is_empty()); assert!(!recorded.is_empty());
assert_eq!(recorded[0]["category"], "TASK_EVENT"); assert_eq!(recorded[0]["category"], "TASK_EVENT");
@@ -293,7 +296,9 @@ mod tests {
.filter_map(|act_val| { .filter_map(|act_val| {
if act_val["category"] == "TASK_EVENT" { if act_val["category"] == "TASK_EVENT" {
if let Some(details_str) = act_val["details"].as_str() { if let Some(details_str) = act_val["details"].as_str() {
if let Ok(event_val) = serde_json::from_str::<serde_json::Value>(details_str) { if let Ok(event_val) =
serde_json::from_str::<serde_json::Value>(details_str)
{
return Some( return Some(
serde_json::json!({ serde_json::json!({
"jsonrpc": "2.0", "jsonrpc": "2.0",
@@ -315,7 +320,10 @@ mod tests {
let _ = tx.try_send(notif); let _ = tx.try_send(notif);
} }
let replayed_msg = rx.recv().await.expect("Expected replayed task event notification"); let replayed_msg = rx
.recv()
.await
.expect("Expected replayed task event notification");
let parsed: serde_json::Value = serde_json::from_str(&replayed_msg).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&replayed_msg).unwrap();
assert_eq!(parsed["method"], "notifications/task/completed"); assert_eq!(parsed["method"], "notifications/task/completed");
assert_eq!(parsed["params"]["task_id"], "task-999"); assert_eq!(parsed["params"]["task_id"], "task-999");
+15 -15
View File
@@ -1,8 +1,8 @@
use mcp_memory_server::router::MemoryHandler;
use mcp_memory_server::state::MemoryState;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use mcp_memory_server::router::MemoryHandler;
use mcp_memory_server::state::MemoryState;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -13,7 +13,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let handler = MemoryHandler::new(state); let handler = MemoryHandler::new(state);
let win_dir = PathBuf::from(r"C:\Users\reazul.ashraf\.gemini\antigravity-cli\mcp\mcp-memory"); let win_dir = PathBuf::from(r"C:\Users\reazul.ashraf\.gemini\antigravity-cli\mcp\mcp-memory");
let wsl_dir = PathBuf::from(r"\\wsl.localhost\Ubuntu\home\riz\.gemini\antigravity-cli\mcp\mcp-memory"); let wsl_dir =
PathBuf::from(r"\\wsl.localhost\Ubuntu\home\riz\.gemini\antigravity-cli\mcp\mcp-memory");
fs::create_dir_all(&win_dir)?; fs::create_dir_all(&win_dir)?;
if !wsl_dir.exists() { if !wsl_dir.exists() {
@@ -41,14 +42,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
if let Ok(entries) = fs::read_dir(&win_dir) { if let Ok(entries) = fs::read_dir(&win_dir) {
for entry in entries.flatten() { for entry in entries.flatten() {
let path = entry.path(); let path = entry.path();
if path.extension().map_or(false, |ext| ext == "json") { if path.extension().is_some_and(|ext| ext == "json")
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
if !active_tools.contains(stem) { && !active_tools.contains(stem) {
println!(" [STALE REMOVED Win] {}", path.display()); println!(" [STALE REMOVED Win] {}", path.display());
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
} }
}
}
} }
} }
@@ -56,23 +55,24 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
if let Ok(entries) = fs::read_dir(&wsl_dir) { if let Ok(entries) = fs::read_dir(&wsl_dir) {
for entry in entries.flatten() { for entry in entries.flatten() {
let path = entry.path(); let path = entry.path();
if path.extension().map_or(false, |ext| ext == "json") { if path.extension().is_some_and(|ext| ext == "json")
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
if !active_tools.contains(stem) { && !active_tools.contains(stem) {
println!(" [STALE REMOVED WSL] {}", path.display()); println!(" [STALE REMOVED WSL] {}", path.display());
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
} }
}
}
} }
} }
// Sync instructions.md // Sync instructions.md
let instructions_src = PathBuf::from(r"C:\Users\reazul.ashraf\workspace\rust\mcp-memory\instructions.md"); let instructions_src =
PathBuf::from(r"C:\Users\reazul.ashraf\workspace\rust\mcp-memory\instructions.md");
if instructions_src.exists() { if instructions_src.exists() {
let _ = fs::copy(&instructions_src, win_dir.join("instructions.md")); let _ = fs::copy(&instructions_src, win_dir.join("instructions.md"));
let _ = fs::copy(&instructions_src, wsl_dir.join("instructions.md")); let _ = fs::copy(&instructions_src, wsl_dir.join("instructions.md"));
let server_instructions = PathBuf::from(r"C:\Users\reazul.ashraf\workspace\rust\mcp-memory\server\src\instructions.md"); let server_instructions = PathBuf::from(
r"C:\Users\reazul.ashraf\workspace\rust\mcp-memory\server\src\instructions.md",
);
let _ = fs::copy(&instructions_src, server_instructions); let _ = fs::copy(&instructions_src, server_instructions);
println!(" [OK] Synchronized instructions.md to Win, WSL, and server/src/instructions.md"); println!(" [OK] Synchronized instructions.md to Win, WSL, and server/src/instructions.md");
} }
+5
View File
@@ -0,0 +1,5 @@
fn main() {
let mut img = image::DynamicImage::new_rgb8(10, 10);
img.invert();
let _img2 = img.adjust_contrast(20.0);
}
+42
View File
@@ -0,0 +1,42 @@
use serde_json::Value;
use std::env;
use std::fs;
use std::path::PathBuf;
use tracing::info;
pub fn load_mcp_config_env() {
let config_path = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gemini/config/mcp_config.json");
if !config_path.exists() {
return;
}
let config_str = match fs::read_to_string(&config_path) {
Ok(s) => s,
Err(_) => return,
};
let parsed: Value = match serde_json::from_str(&config_str) {
Ok(v) => v,
Err(_) => return,
};
if let Some(env_obj) = parsed
.get("mcpServers")
.and_then(|v| v.get("mcp-memory"))
.and_then(|v| v.get("env"))
.and_then(|v| v.as_object())
{
for (key, val) in env_obj {
if let Some(val_str) = val.as_str()
&& env::var(key).is_err() {
unsafe {
env::set_var(key, val_str);
}
info!("Loaded env var {} from mcp_config.json", key);
}
}
}
}
+4 -6
View File
@@ -167,23 +167,21 @@ pub fn init_redb(base: &Path) -> Arc<Database> {
); );
} else { } else {
let migrated_path = json_path.with_extension("json.migrated"); let migrated_path = json_path.with_extension("json.migrated");
if let Err(_) = std::fs::rename(&json_path, &migrated_path) { if std::fs::rename(&json_path, &migrated_path).is_err()
if migrated_path.exists() { && migrated_path.exists() {
let _ = std::fs::remove_file(&migrated_path); let _ = std::fs::remove_file(&migrated_path);
let _ = std::fs::rename(&json_path, &migrated_path); let _ = std::fs::rename(&json_path, &migrated_path);
} }
}
} }
} }
} }
} }
} }
} }
if opened_ok { if opened_ok
if let Err(e) = write_txn.commit() { && let Err(e) = write_txn.commit() {
tracing::error!("Failed to commit database migration transaction: {}", e); tracing::error!("Failed to commit database migration transaction: {}", e);
} }
}
} }
Err(e) => { Err(e) => {
tracing::error!( tracing::error!(
+13 -3
View File
@@ -17,7 +17,11 @@ pub enum AppError {
EntityNotFound { name: String, namespace: String }, EntityNotFound { name: String, namespace: String },
#[error("Invalid relation between '{from}' and '{to}': {reason}")] #[error("Invalid relation between '{from}' and '{to}': {reason}")]
InvalidRelation { from: String, to: String, reason: String }, InvalidRelation {
from: String,
to: String,
reason: String,
},
#[error("Database error for key '{key}': {message}")] #[error("Database error for key '{key}': {message}")]
DatabaseError { key: String, message: String }, DatabaseError { key: String, message: String },
@@ -50,11 +54,17 @@ impl IntoResponse for AppError {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
AppError::EntityNotFound { name, namespace } => ( AppError::EntityNotFound { name, namespace } => (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
format!("Entity '{}' was not found in namespace '{}'", name, namespace), format!(
"Entity '{}' was not found in namespace '{}'",
name, namespace
),
), ),
AppError::InvalidRelation { from, to, reason } => ( AppError::InvalidRelation { from, to, reason } => (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
format!("Invalid relation between '{}' and '{}': {}", from, to, reason), format!(
"Invalid relation between '{}' and '{}': {}",
from, to, reason
),
), ),
AppError::DatabaseError { key, message } => ( AppError::DatabaseError { key, message } => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
+4 -6
View File
@@ -119,11 +119,10 @@ impl McpTool for ReadFileSkeletonHandler {
header.push('\n'); header.push('\n');
} }
} }
if header.is_empty() { if header.is_empty()
if let Some(first_line) = node_text.lines().next() { && let Some(first_line) = node_text.lines().next() {
header = first_line.to_string(); header = first_line.to_string();
} }
}
out.push_str(&indent); out.push_str(&indent);
out.push_str(header.trim()); out.push_str(header.trim());
@@ -425,11 +424,10 @@ fn scan_workspace_for_symbol(
if scanned_files > 500 { if scanned_files > 500 {
break; break;
} }
if let Ok(meta) = std::fs::metadata(path) { if let Ok(meta) = std::fs::metadata(path)
if meta.len() > 1024 * 1024 { && meta.len() > 1024 * 1024 {
continue; continue;
} }
}
if let Ok(content) = std::fs::read_to_string(path) { if let Ok(content) = std::fs::read_to_string(path) {
for (line_num, line) in content.lines().enumerate() { for (line_num, line) in content.lines().enumerate() {
let is_match = if filter_fn_call { let is_match = if filter_fn_call {
+33 -17
View File
@@ -22,16 +22,31 @@ impl McpTool for EnvironmentHandler {
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 req: EnvironmentTool = serde_json::from_value(args).map_err(|e| e.to_string())?; let req: EnvironmentTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let ns = req.namespace.unwrap_or_else(|| crate::models::default_namespace()); let ns = req
.namespace
.unwrap_or_else(crate::models::default_namespace);
match req.action { match req.action {
EnvAction::UpdateFingerprint => { EnvAction::UpdateFingerprint => {
let tool_versions = req.tool_versions.unwrap_or_default(); let tool_versions = req.tool_versions.unwrap_or_default();
let detected_os = req.os.unwrap_or_else(|| std::env::consts::OS.to_string()); let detected_os = req.os.unwrap_or_else(|| std::env::consts::OS.to_string());
let detected_shell = req.shell let detected_shell = req
.shell
.or_else(|| std::env::var("SHELL").ok()) .or_else(|| std::env::var("SHELL").ok())
.or_else(|| std::env::var("STARSHIP_SHELL").ok()) .or_else(|| std::env::var("STARSHIP_SHELL").ok())
.or_else(|| if std::env::var("PSModulePath").is_ok() { Some("pwsh".to_string()) } else { None }) .or_else(|| {
.or_else(|| if std::env::var("COMSPEC").is_ok() { Some("cmd".to_string()) } else { None }) if std::env::var("PSModulePath").is_ok() {
Some("pwsh".to_string())
} else {
None
}
})
.or_else(|| {
if std::env::var("COMSPEC").is_ok() {
Some("cmd".to_string())
} else {
None
}
})
.unwrap_or_else(|| "unknown".to_string()); .unwrap_or_else(|| "unknown".to_string());
state.env.env_fingerprints.modify(|fps| { state.env.env_fingerprints.modify(|fps| {
@@ -102,10 +117,7 @@ impl McpTool for EnvironmentHandler {
} }
EnvAction::GetDetails => { EnvAction::GetDetails => {
let data = state.env.environments.read_with(|envs| { let data = state.env.environments.read_with(|envs| {
let filtered: Vec<_> = envs let filtered: Vec<_> = envs.iter().filter(|e| e.namespace == ns).collect();
.iter()
.filter(|e| e.namespace == ns)
.collect();
Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?) Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?)
})?; })?;
Ok(data) Ok(data)
@@ -114,7 +126,6 @@ impl McpTool for EnvironmentHandler {
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -143,7 +154,10 @@ mod tests {
assert_eq!(res, "Env fingerprint updated"); assert_eq!(res, "Env fingerprint updated");
let res2 = handler let res2 = handler
.execute(json!({"action": "read_fingerprint", "namespace": "global"}), state.clone()) .execute(
json!({"action": "read_fingerprint", "namespace": "global"}),
state.clone(),
)
.await .await
.map_err(|e| crate::error::AppError::Internal(e.to_string())) .map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap(); .unwrap();
@@ -171,7 +185,10 @@ mod tests {
let handler = EnvironmentHandler; let handler = EnvironmentHandler;
let res = handler let res = handler
.execute(json!({"action": "get_details", "namespace": "global"}), state.clone()) .execute(
json!({"action": "get_details", "namespace": "global"}),
state.clone(),
)
.await .await
.map_err(|e| crate::error::AppError::Internal(e.to_string())) .map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap(); .unwrap();
@@ -215,7 +232,10 @@ mod tests {
assert_eq!(res2, "Environment registered"); assert_eq!(res2, "Environment registered");
let res3 = handler let res3 = handler
.execute(json!({"action": "get_details", "namespace": "global"}), state.clone()) .execute(
json!({"action": "get_details", "namespace": "global"}),
state.clone(),
)
.await .await
.map_err(|e| crate::error::AppError::Internal(e.to_string())) .map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap(); .unwrap();
@@ -237,12 +257,8 @@ mod tests {
} }
}); });
let res = handler let res = handler.execute(args, state.clone()).await;
.execute(args, state.clone())
.await;
assert!(res.is_ok()); assert!(res.is_ok());
} }
} }
+2 -4
View File
@@ -139,8 +139,8 @@ impl McpTool for QueryGitDiffsHandler {
let git_matches = tokio::task::spawn_blocking(move || { let git_matches = tokio::task::spawn_blocking(move || {
let mut results = Vec::new(); let mut results = Vec::new();
let cwd = env::current_dir().unwrap_or_default(); let cwd = env::current_dir().unwrap_or_default();
if let Ok(repo) = git2::Repository::discover(&cwd) { if let Ok(repo) = git2::Repository::discover(&cwd)
if let Ok(mut revwalk) = repo.revwalk() { && let Ok(mut revwalk) = repo.revwalk() {
let _ = revwalk.push_head(); let _ = revwalk.push_head();
let mut count = 0; let mut count = 0;
for oid in revwalk.flatten() { for oid in revwalk.flatten() {
@@ -161,7 +161,6 @@ impl McpTool for QueryGitDiffsHandler {
} }
} }
} }
}
results results
}) })
.await .await
@@ -215,4 +214,3 @@ mod tests {
assert!(parsed.get("branch").is_some() || parsed.is_object()); assert!(parsed.get("branch").is_some() || parsed.is_object());
} }
} }
+153 -69
View File
@@ -62,8 +62,8 @@ impl<'a> GraphQueryBuilder<'a> {
break; break;
} }
nodes_at_current_depth -= 1; nodes_at_current_depth -= 1;
if current_depth < self.max_depth { if current_depth < self.max_depth
if let Some(neighbors) = adj.get(current) { && let Some(neighbors) = adj.get(current) {
for &(neighbor, rel_type, is_inverse) in neighbors { for &(neighbor, rel_type, is_inverse) in neighbors {
if !visited.contains(neighbor) { if !visited.contains(neighbor) {
visited.insert(neighbor); visited.insert(neighbor);
@@ -73,7 +73,6 @@ impl<'a> GraphQueryBuilder<'a> {
} }
} }
} }
}
if nodes_at_current_depth == 0 { if nodes_at_current_depth == 0 {
current_depth += 1; current_depth += 1;
nodes_at_current_depth = nodes_at_next_depth; nodes_at_current_depth = nodes_at_next_depth;
@@ -138,7 +137,9 @@ impl McpTool for QueryGraphPathHandler {
}) })
}) })
.await .await
.map_err(|e| crate::error::AppError::Internal(format!("Graph traversal task failed: {}", e)))? .map_err(|e| {
crate::error::AppError::Internal(format!("Graph traversal task failed: {}", e))
})?
} }
} }
@@ -169,12 +170,19 @@ impl McpTool for CreateEntitiesHandler {
} }
} }
}); });
let names: Vec<String> = inserted.iter().map(|e| format!("{} ({})", e.name, e.entity_type)).collect(); let names: Vec<String> = inserted
.iter()
.map(|e| format!("{} ({})", e.name, e.entity_type))
.collect();
if !inserted.is_empty() { if !inserted.is_empty() {
let idx = state.get_search_index().await; let idx = state.get_search_index().await;
let _ = idx.index_entities_batch(&inserted).await; let _ = idx.index_entities_batch(&inserted).await;
} }
Ok(format!("Successfully created {} entity/entities: {}", names.len(), names.join(", "))) Ok(format!(
"Successfully created {} entity/entities: {}",
names.len(),
names.join(", ")
))
} }
} }
@@ -236,7 +244,9 @@ impl McpTool for CreateRelationsHandler {
crate::models::Entity { crate::models::Entity {
name: node_name.clone(), name: node_name.clone(),
entity_type: "Entity".to_string(), entity_type: "Entity".to_string(),
observations: vec!["Auto-created stub entity for relation endpoint".to_string()], observations: vec![
"Auto-created stub entity for relation endpoint".to_string(),
],
namespace: crate::models::default_namespace(), namespace: crate::models::default_namespace(),
git_branch: None, git_branch: None,
..Default::default() ..Default::default()
@@ -247,16 +257,28 @@ impl McpTool for CreateRelationsHandler {
} }
for mut relation in req.relations { for mut relation in req.relations {
if !relation.from.is_empty() && !relation.to.is_empty() { if !relation.from.is_empty() && !relation.to.is_empty() {
relation.relation_type = crate::models::normalize_relation_type(&relation.relation_type); relation.relation_type =
added_relations.push(format!("{} -[{}]-> {}", relation.from, relation.relation_type, relation.to)); crate::models::normalize_relation_type(&relation.relation_type);
added_relations.push(format!(
"{} -[{}]-> {}",
relation.from, relation.relation_type, relation.to
));
g.relations.push(relation); g.relations.push(relation);
} }
} }
}); });
let mut msg = format!("Successfully created {} relation(s):\n{}", added_relations.len(), added_relations.join("\n")); let mut msg = format!(
"Successfully created {} relation(s):\n{}",
added_relations.len(),
added_relations.join("\n")
);
if !auto_created.is_empty() { if !auto_created.is_empty() {
msg.push_str(&format!("\nNote: Auto-created {} missing stub entity/entities: {}", auto_created.len(), auto_created.join(", "))); msg.push_str(&format!(
"\nNote: Auto-created {} missing stub entity/entities: {}",
auto_created.len(),
auto_created.join(", ")
));
} }
Ok(msg) Ok(msg)
} }
@@ -462,7 +484,7 @@ impl McpTool for ReadGraphHandler {
let filtered_entities: Vec<(&String, &crate::models::Entity)> = full let filtered_entities: Vec<(&String, &crate::models::Entity)> = full
.entities .entities
.iter() .iter()
.filter(|(_, v)| req.namespace.as_ref().map_or(true, |ns| &v.namespace == ns)) .filter(|(_, v)| req.namespace.as_ref().is_none_or(|ns| &v.namespace == ns))
.collect(); .collect();
let total_count = filtered_entities.len(); let total_count = filtered_entities.len();
@@ -481,7 +503,7 @@ impl McpTool for ReadGraphHandler {
.relations .relations
.iter() .iter()
.filter(|r| { .filter(|r| {
let ns_match = req.namespace.as_ref().map_or(true, |ns| &r.namespace == ns); let ns_match = req.namespace.as_ref().is_none_or(|ns| &r.namespace == ns);
ns_match && (paged_entities.contains_key(&r.from) || paged_entities.contains_key(&r.to)) ns_match && (paged_entities.contains_key(&r.from) || paged_entities.contains_key(&r.to))
}) })
.collect(); .collect();
@@ -499,7 +521,7 @@ impl McpTool for ReadGraphHandler {
let rel_vec: Vec<&crate::models::Relation> = full let rel_vec: Vec<&crate::models::Relation> = full
.relations .relations
.iter() .iter()
.filter(|r| req.namespace.as_ref().map_or(true, |ns| &r.namespace == ns)) .filter(|r| req.namespace.as_ref().is_none_or(|ns| &r.namespace == ns))
.collect(); .collect();
(ent_map, rel_vec, None) (ent_map, rel_vec, None)
}; };
@@ -549,21 +571,19 @@ impl McpTool for ReadGraphHandler {
} }
}); });
if let Some(meta) = &meta_json { if let Some(meta) = &meta_json
if let Some(obj) = truncated_val.as_object_mut() { && let Some(obj) = truncated_val.as_object_mut() {
obj.insert("total_count".to_string(), meta["total_count"].clone()); obj.insert("total_count".to_string(), meta["total_count"].clone());
obj.insert("offset".to_string(), meta["offset"].clone()); obj.insert("offset".to_string(), meta["offset"].clone());
obj.insert("limit".to_string(), meta["limit"].clone()); obj.insert("limit".to_string(), meta["limit"].clone());
obj.insert("has_more".to_string(), serde_json::Value::Bool(true)); obj.insert("has_more".to_string(), serde_json::Value::Bool(true));
} }
}
if let Ok(trunc_str) = serde_json::to_string(&truncated_val) { if let Ok(trunc_str) = serde_json::to_string(&truncated_val)
if trunc_str.len() <= max_chars || keep_count == 1 { && (trunc_str.len() <= max_chars || keep_count == 1) {
result_json = trunc_str; result_json = trunc_str;
break; break;
} }
}
keep_count = keep_count.saturating_sub(keep_count / 3 + 1); keep_count = keep_count.saturating_sub(keep_count / 3 + 1);
} }
} }
@@ -820,7 +840,10 @@ impl McpTool for CondenseEntityHandler {
e.observations = unique_obs; e.observations = unique_obs;
} }
}); });
return Ok(format!("Entity '{}' observations condensed with deduplication/AI summary.", req.entity_name)); return Ok(format!(
"Entity '{}' observations condensed with deduplication/AI summary.",
req.entity_name
));
} }
state.modify_graph(|master| { state.modify_graph(|master| {
@@ -953,10 +976,9 @@ impl McpTool for GetSubgraphHandler {
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 req: GetSubgraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?; let req: GetSubgraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let root = req let root = req.root_entity.or(req.root_node).ok_or_else(|| {
.root_entity crate::error::AppError::Internal("root_entity or root_node is required".to_string())
.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 depth = req.depth.unwrap_or(2);
let format = req.format.unwrap_or(SubgraphFormat::Json); let format = req.format.unwrap_or(SubgraphFormat::Json);
@@ -974,15 +996,14 @@ impl McpTool for GetSubgraphHandler {
visited.insert(root.clone()); visited.insert(root.clone());
while let Some((curr, d)) = queue.pop_front() { while let Some((curr, d)) = queue.pop_front() {
if d < depth { if d < depth
if let Some(neighbors) = adj.get(curr) { && let Some(neighbors) = adj.get(curr) {
for &next in neighbors { for &next in neighbors {
if visited.insert(next.to_string()) { if visited.insert(next.to_string()) {
queue.push_back((next, d + 1)); queue.push_back((next, d + 1));
} }
} }
} }
}
} }
let ent: std::collections::HashMap<String, crate::models::Entity> = visited let ent: std::collections::HashMap<String, crate::models::Entity> = visited
@@ -1011,7 +1032,10 @@ impl McpTool for GetSubgraphHandler {
markdown.push_str("\n## Relations\n"); markdown.push_str("\n## Relations\n");
for r in &matched_relations { for r in &matched_relations {
markdown.push_str(&format!("- `{}` --[{}]--> `{}`\n", r.from, r.relation_type, r.to)); markdown.push_str(&format!(
"- `{}` --[{}]--> `{}`\n",
r.from, r.relation_type, r.to
));
} }
return Ok(markdown); return Ok(markdown);
} }
@@ -1038,7 +1062,10 @@ impl McpTool for SweepGraphHealthHandler {
} }
fn schema(&self) -> Value { fn schema(&self) -> Value {
crate::mcp::tool_def::<SweepGraphHealthTool>("sweep_graph_health", "Audit and sweep graph health") 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> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
@@ -1132,26 +1159,31 @@ impl McpTool for ResolveStaleSymbolsHandler {
} }
fn schema(&self) -> Value { 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.") 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> { 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 req: ResolveStaleSymbolsTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let auto_heal = req.auto_heal.unwrap_or(true); let auto_heal = req.auto_heal.unwrap_or(true);
let mut checked = 0; let mut checked = 0;
let mut stale_entities = Vec::new(); let mut stale_entities = Vec::new();
state.modify_graph(|g| { state.modify_graph(|g| {
for (name, _entity) in g.entities.iter_mut() { for name in g.entities.keys() {
if let Some(target) = &req.target { if let Some(target) = &req.target
if name != target && !name.contains(target) { && name != target && !name.contains(target) {
continue; continue;
} }
}
checked += 1; checked += 1;
let path = std::path::Path::new(name); let path = std::path::Path::new(name);
if (name.contains('/') || name.contains('\\') || name.contains('.')) && !path.exists() { if (name.contains('/') || name.contains('\\') || name.contains('.'))
&& !path.exists()
{
stale_entities.push(name.clone()); stale_entities.push(name.clone());
} }
} }
@@ -1184,7 +1216,10 @@ impl McpTool for SummarizeSubgraphHandler {
} }
fn schema(&self) -> Value { 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.") 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> { async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
@@ -1212,8 +1247,18 @@ impl McpTool for SummarizeSubgraphHandler {
} }
} }
let ent: Vec<_> = g.entities.values().filter(|e| visited.contains(&e.name)).cloned().collect(); let ent: Vec<_> = g
let rel: Vec<_> = g.relations.iter().filter(|r| visited.contains(&r.from) && visited.contains(&r.to)).cloned().collect(); .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) (ent, rel)
}); });
@@ -1228,7 +1273,10 @@ impl McpTool for SummarizeSubgraphHandler {
markdown.push_str("\n## Relations\n"); markdown.push_str("\n## Relations\n");
for r in &sub_relations { for r in &sub_relations {
markdown.push_str(&format!("- `{}` --[{}]--> `{}`\n", r.from, r.relation_type, r.to)); markdown.push_str(&format!(
"- `{}` --[{}]--> `{}`\n",
r.from, r.relation_type, r.to
));
} }
let max_tokens = req.max_tokens.unwrap_or(1000); let max_tokens = req.max_tokens.unwrap_or(1000);
@@ -1249,7 +1297,6 @@ mod tests {
use crate::handlers::meta::AgentSignalsHandler; use crate::handlers::meta::AgentSignalsHandler;
use serde_json::json; use serde_json::json;
#[tokio::test] #[tokio::test]
async fn test_create_and_read_entities() { async fn test_create_and_read_entities() {
let state = Arc::new(MemoryState::new_in_memory()); let state = Arc::new(MemoryState::new_in_memory());
@@ -1324,10 +1371,7 @@ mod tests {
{"source": "A", "target": "B", "relationType": "knows"} {"source": "A", "target": "B", "relationType": "knows"}
] ]
}); });
let alias_res = handler let alias_res = handler.execute(alias_args, state.clone()).await.unwrap();
.execute(alias_args, state.clone())
.await
.unwrap();
assert!(alias_res.contains("Successfully created 1 relation(s)")); assert!(alias_res.contains("Successfully created 1 relation(s)"));
// Test semantic LLM schema feedback on missing fields // Test semantic LLM schema feedback on missing fields
@@ -1499,7 +1543,10 @@ mod tests {
{"name": "Beta", "entity_type": "Node", "observations": ["Obs3"], "namespace": "global"} {"name": "Beta", "entity_type": "Node", "observations": ["Obs3"], "namespace": "global"}
] ]
}); });
create_handler.execute(args_ent, state.clone()).await.unwrap(); create_handler
.execute(args_ent, state.clone())
.await
.unwrap();
let rel_handler = CreateRelationsHandler; let rel_handler = CreateRelationsHandler;
let args_rel = json!({ let args_rel = json!({
@@ -1510,48 +1557,86 @@ mod tests {
rel_handler.execute(args_rel, state.clone()).await.unwrap(); rel_handler.execute(args_rel, state.clone()).await.unwrap();
let path_handler = QueryGraphPathHandler; let path_handler = QueryGraphPathHandler;
let path_res = path_handler.execute(json!({"start_node": "Alpha", "end_node": "Beta"}), state.clone()).await.unwrap(); let path_res = path_handler
.execute(
json!({"start_node": "Alpha", "end_node": "Beta"}),
state.clone(),
)
.await
.unwrap();
assert!(path_res.contains("Alpha")); assert!(path_res.contains("Alpha"));
let sub_handler = GetSubgraphHandler; let sub_handler = GetSubgraphHandler;
let sub_res = sub_handler.execute(json!({"root_entity": "Alpha"}), state.clone()).await.unwrap(); let sub_res = sub_handler
.execute(json!({"root_entity": "Alpha"}), state.clone())
.await
.unwrap();
assert!(sub_res.contains("Alpha")); assert!(sub_res.contains("Alpha"));
let sum_handler = SummarizeSubgraphHandler; let sum_handler = SummarizeSubgraphHandler;
let sum_res = sum_handler.execute(json!({"root_entity": "Alpha"}), state.clone()).await.unwrap(); let sum_res = sum_handler
.execute(json!({"root_entity": "Alpha"}), state.clone())
.await
.unwrap();
assert!(!sum_res.is_empty()); assert!(!sum_res.is_empty());
let sweep_handler = SweepGraphHealthHandler; let sweep_handler = SweepGraphHealthHandler;
let sweep_res = sweep_handler.execute(json!({}), state.clone()).await.unwrap(); let sweep_res = sweep_handler
.execute(json!({}), state.clone())
.await
.unwrap();
assert!(!sweep_res.is_empty()); assert!(!sweep_res.is_empty());
let del_obs_handler = DeleteObservationsHandler; let del_obs_handler = DeleteObservationsHandler;
let del_obs_res = del_obs_handler.execute(json!({ let del_obs_res = del_obs_handler
"deletions": [{"entity_name": "Alpha", "observations": ["Obs1"]}] .execute(
}), state.clone()).await.unwrap(); json!({
"deletions": [{"entity_name": "Alpha", "observations": ["Obs1"]}]
}),
state.clone(),
)
.await
.unwrap();
assert_eq!(del_obs_res, "Observations deleted"); assert_eq!(del_obs_res, "Observations deleted");
let del_rel_handler = DeleteRelationsHandler; let del_rel_handler = DeleteRelationsHandler;
let del_rel_res = del_rel_handler.execute(json!({ let del_rel_res = del_rel_handler
"relations": [{"from": "Alpha", "to": "Beta", "relation_type": "links_to"}] .execute(
}), state.clone()).await.unwrap(); json!({
"relations": [{"from": "Alpha", "to": "Beta", "relation_type": "links_to"}]
}),
state.clone(),
)
.await
.unwrap();
assert_eq!(del_rel_res, "Relations deleted"); assert_eq!(del_rel_res, "Relations deleted");
let bcast_handler = AgentSignalsHandler; let bcast_handler = AgentSignalsHandler;
let bcast_res = bcast_handler.execute(json!({ let bcast_res = bcast_handler
"action": "broadcast", .execute(
"sender": "agent1", json!({
"signal_type": "task_completed", "action": "broadcast",
"payload": "fix_bug" "sender": "agent1",
}), state.clone()).await.unwrap(); "signal_type": "task_completed",
"payload": "fix_bug"
}),
state.clone(),
)
.await
.unwrap();
assert!(bcast_res.contains("Broadcasted signal")); assert!(bcast_res.contains("Broadcasted signal"));
let qsignal_handler = AgentSignalsHandler; let qsignal_handler = AgentSignalsHandler;
let qsignal_res = qsignal_handler.execute(json!({ let qsignal_res = qsignal_handler
"action": "query", .execute(
"sender": "agent1" json!({
}), state.clone()).await.unwrap(); "action": "query",
"sender": "agent1"
}),
state.clone(),
)
.await
.unwrap();
assert!(qsignal_res.contains("task_completed")); assert!(qsignal_res.contains("task_completed"));
let read_paged_handler = ReadGraphHandler; let read_paged_handler = ReadGraphHandler;
let paged_res = read_paged_handler let paged_res = read_paged_handler
@@ -1563,4 +1648,3 @@ mod tests {
assert_eq!(parsed_paged["has_more"], true); assert_eq!(parsed_paged["has_more"], true);
} }
} }
+24 -22
View File
@@ -38,32 +38,34 @@ impl McpTool for ProcessLogsHandler {
} }
ProcessLogAction::Get => { ProcessLogAction::Get => {
let max_lines = tool_args.max_lines.unwrap_or(100); let max_lines = tool_args.max_lines.unwrap_or(100);
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> { let result =
let mut file = File::open(&safe_path).map_err(|e| { tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
crate::error::AppError::Internal(format!("Failed to open file: {}", e)) let mut file = File::open(&safe_path).map_err(|e| {
})?; crate::error::AppError::Internal(format!("Failed to open file: {}", e))
let len = file.metadata().map_err(|e| e.to_string())?.len(); })?;
let len = file.metadata().map_err(|e| e.to_string())?.len();
let read_size = std::cmp::min(16 * 1024, len); let read_size = std::cmp::min(16 * 1024, len);
file.seek(SeekFrom::End(-(read_size as i64))) file.seek(SeekFrom::End(-(read_size as i64)))
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let mut vec_buf = Vec::new(); let mut vec_buf = Vec::new();
file.read_to_end(&mut vec_buf) file.read_to_end(&mut vec_buf).map_err(|e| e.to_string())?;
.map_err(|e| e.to_string())?;
let buffer = String::from_utf8_lossy(&vec_buf).to_string(); let buffer = String::from_utf8_lossy(&vec_buf).to_string();
let lines: Vec<&str> = buffer.lines().collect(); let lines: Vec<&str> = buffer.lines().collect();
let recent_lines = if lines.len() > max_lines { let recent_lines = if lines.len() > max_lines {
lines[lines.len() - max_lines..].join("\n") lines[lines.len() - max_lines..].join("\n")
} else { } else {
buffer buffer
}; };
Ok(recent_lines) Ok(recent_lines)
}) })
.await .await
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??; .map_err(|e| {
crate::error::AppError::Internal(format!("Task panic: {}", e))
})??;
Ok(result) Ok(result)
} }
+134 -103
View File
@@ -110,11 +110,10 @@ impl McpTool for SearchErrorFixesHandler {
} else if st_lower.contains(&sol_lower) || sol_lower.contains(&st_lower) { } else if st_lower.contains(&sol_lower) || sol_lower.contains(&st_lower) {
score += 0.5; score += 0.5;
} }
if let Some(emb) = &f.embedding { if let Some(emb) = &f.embedding
if !query_emb.is_empty() { && !query_emb.is_empty() {
score += crate::embedding::cosine_similarity(&query_emb, emb); score += crate::embedding::cosine_similarity(&query_emb, emb);
} }
}
(f, score) (f, score)
}) })
.filter(|(_, score)| *score > 0.1) .filter(|(_, score)| *score > 0.1)
@@ -198,11 +197,10 @@ impl McpTool for LogCodeChangeHandler {
if let Some(range) = &req.line_range { if let Some(range) = &req.line_range {
description = format!("{} [Line Range: {}]", description, range); description = format!("{} [Line Range: {}]", description, range);
} }
if let Some(symbols) = &req.symbol_references { if let Some(symbols) = &req.symbol_references
if !symbols.is_empty() { && !symbols.is_empty() {
description = format!("{} [Symbols: {}]", description, symbols.join(", ")); description = format!("{} [Symbols: {}]", description, symbols.join(", "));
} }
}
if state.ollama.is_available().await { if state.ollama.is_available().await {
let prompt = format!( let prompt = format!(
@@ -323,13 +321,14 @@ impl McpTool for QueryRecentChangesHandler {
} }
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 req: QueryRecentChangesTool = serde_json::from_value(args).unwrap_or(QueryRecentChangesTool { let req: QueryRecentChangesTool =
namespace: None, serde_json::from_value(args).unwrap_or(QueryRecentChangesTool {
repo_name: None, namespace: None,
vcs_type: None, repo_name: None,
limit: None, vcs_type: None,
offset: None, limit: None,
}); offset: None,
});
let limit = req.limit.unwrap_or(50); let limit = req.limit.unwrap_or(50);
let offset = req.offset.unwrap_or(0); let offset = req.offset.unwrap_or(0);
let data = state.code.ledger.read_with(|l| { let data = state.code.ledger.read_with(|l| {
@@ -337,25 +336,22 @@ impl McpTool for QueryRecentChangesHandler {
.iter() .iter()
.rev() .rev()
.filter(|c| { .filter(|c| {
if let Some(ns) = &req.namespace { if let Some(ns) = &req.namespace
if !c.namespace.eq_ignore_ascii_case(ns) { && !c.namespace.eq_ignore_ascii_case(ns) {
return false; return false;
} }
} if let Some(repo) = &req.repo_name
if let Some(repo) = &req.repo_name { && c.repo_name
if c.repo_name
.as_ref() .as_ref()
.map(|rn| !rn.eq_ignore_ascii_case(repo)) .map(|rn| !rn.eq_ignore_ascii_case(repo))
.unwrap_or(true) .unwrap_or(true)
{ {
return false; return false;
} }
} if let Some(vcs) = &req.vcs_type
if let Some(vcs) = &req.vcs_type { && !c.effective_vcs().eq_ignore_ascii_case(vcs) {
if !c.effective_vcs().eq_ignore_ascii_case(vcs) {
return false; return false;
} }
}
true true
}) })
.skip(offset) .skip(offset)
@@ -386,7 +382,7 @@ impl McpTool for DecisionsHandler {
let req: DecisionsTool = serde_json::from_value(args).map_err(|e| e.to_string())?; let req: DecisionsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let ns = req let ns = req
.namespace .namespace
.unwrap_or_else(|| crate::models::default_namespace()); .unwrap_or_else(crate::models::default_namespace);
match req.action { match req.action {
DecisionAction::Log => { DecisionAction::Log => {
let title = req.title.ok_or_else(|| { let title = req.title.ok_or_else(|| {
@@ -464,14 +460,14 @@ impl McpTool for DecisionsHandler {
state.code.adrs.modify(|adrs| { state.code.adrs.modify(|adrs| {
let target_pos = adrs.iter().position(|a| a.id.eq_ignore_ascii_case(&id)); let target_pos = adrs.iter().position(|a| a.id.eq_ignore_ascii_case(&id));
if let Some(pos) = target_pos { if let Some(pos) = target_pos {
if let Some(superseded_id) = &req.supersedes { if let Some(superseded_id) = &req.supersedes
if let Some(s_pos) = adrs.iter().position(|a| a.id.eq_ignore_ascii_case(superseded_id)) { && let Some(s_pos) = adrs
if s_pos != pos { .iter()
.position(|a| a.id.eq_ignore_ascii_case(superseded_id))
&& s_pos != pos {
adrs[s_pos].status = "superseded".to_string(); adrs[s_pos].status = "superseded".to_string();
adrs_to_index.push(adrs[s_pos].clone()); adrs_to_index.push(adrs[s_pos].clone());
} }
}
}
let a = &mut adrs[pos]; let a = &mut adrs[pos];
if let Some(t) = req.title { if let Some(t) = req.title {
@@ -488,7 +484,9 @@ impl McpTool for DecisionsHandler {
} }
if let Some(s) = req.status { if let Some(s) = req.status {
let s_lower = s.to_ascii_lowercase(); let s_lower = s.to_ascii_lowercase();
if (s_lower == "implemented" || s_lower == "resolved") && a.resolved_at.is_none() { if (s_lower == "implemented" || s_lower == "resolved")
&& a.resolved_at.is_none()
{
a.resolved_at = Some(crate::handlers::utils::now_secs()); a.resolved_at = Some(crate::handlers::utils::now_secs());
} else if s_lower != "implemented" && s_lower != "resolved" { } else if s_lower != "implemented" && s_lower != "resolved" {
a.resolved_at = None; a.resolved_at = None;
@@ -535,11 +533,15 @@ impl McpTool for DecisionsHandler {
&format!("Updated {}: {}", adr.id, adr.title), &format!("Updated {}: {}", adr.id, adr.title),
Some(&adr.status), Some(&adr.status),
); );
Ok(format!("Updated decision {}: {} (status: {})", adr.id, adr.title, adr.status)) Ok(format!(
} else { "Updated decision {}: {} (status: {})",
Err(crate::error::AppError::Internal( adr.id, adr.title, adr.status
format!("Decision with id '{}' not found", id),
)) ))
} else {
Err(crate::error::AppError::Internal(format!(
"Decision with id '{}' not found",
id
)))
} }
} }
DecisionAction::Query => { DecisionAction::Query => {
@@ -554,9 +556,17 @@ impl McpTool for DecisionsHandler {
} }
if let Some(q) = &req.query { if let Some(q) = &req.query {
crate::handlers::utils::contains_ignore_ascii_case(&a.title, q) crate::handlers::utils::contains_ignore_ascii_case(&a.title, q)
|| crate::handlers::utils::contains_ignore_ascii_case(&a.context, q) || crate::handlers::utils::contains_ignore_ascii_case(
|| crate::handlers::utils::contains_ignore_ascii_case(&a.decision, q) &a.context, q,
|| crate::handlers::utils::contains_ignore_ascii_case(&a.consequence, q) )
|| crate::handlers::utils::contains_ignore_ascii_case(
&a.decision,
q,
)
|| crate::handlers::utils::contains_ignore_ascii_case(
&a.consequence,
q,
)
} else { } else {
true true
} }
@@ -632,17 +642,14 @@ impl McpTool for TechDebtHandler {
let req: TechDebtTool = serde_json::from_value(args).map_err(|e| e.to_string())?; let req: TechDebtTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let ns = req let ns = req
.namespace .namespace
.unwrap_or_else(|| crate::models::default_namespace()); .unwrap_or_else(crate::models::default_namespace);
match req.action { match req.action {
TechDebtAction::Log => { TechDebtAction::Log => {
let desc = req.description.or(req.title).ok_or_else(|| { let desc = req.description.or(req.title).ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'description' for action 'log'. Next step: Provide tech debt 'description' in request and retry.".to_string()) crate::error::AppError::Internal("Missing required parameter 'description' for action 'log'. Next step: Provide tech debt 'description' in request and retry.".to_string())
})?; })?;
let ideal = req.ideal_solution.unwrap_or_default(); let ideal = req.ideal_solution.unwrap_or_default();
let text_to_embed = format!( let text_to_embed = format!("Description: {}\nIdeal Solution: {}", desc, ideal);
"Description: {}\nIdeal Solution: {}",
desc, ideal
);
let embedding = crate::embedding::generate_embedding_async(text_to_embed) let embedding = crate::embedding::generate_embedding_async(text_to_embed)
.await .await
.ok(); .ok();
@@ -665,18 +672,21 @@ impl McpTool for TechDebtHandler {
effort_estimate: req.effort_estimate, effort_estimate: req.effort_estimate,
}); });
if debts.len() > 300 { if debts.len() > 300 {
let severity_rank = |sev: Option<&str>| match sev.unwrap_or("").to_lowercase().as_str() { let severity_rank =
"critical" => 4, |sev: Option<&str>| match sev.unwrap_or("").to_lowercase().as_str() {
"high" => 3, "critical" => 4,
"medium" => 2, "high" => 3,
"low" => 1, "medium" => 2,
_ => 1, "low" => 1,
}; _ => 1,
if let Some((idx_to_remove, _)) = debts.iter().enumerate().min_by_key(|(_, d)| { };
let status_score = if d.is_resolved { 0 } else { 10 }; if let Some((idx_to_remove, _)) =
let sev_score = severity_rank(d.severity.as_deref()); debts.iter().enumerate().min_by_key(|(_, d)| {
(status_score + sev_score, d.created_at) let status_score = if d.is_resolved { 0 } else { 10 };
}) { let sev_score = severity_rank(d.severity.as_deref());
(status_score + sev_score, d.created_at)
})
{
debts.remove(idx_to_remove); debts.remove(idx_to_remove);
} }
} }
@@ -712,9 +722,7 @@ impl McpTool for TechDebtHandler {
let data = state.code.tech_debts.read_with(|debts| { let data = state.code.tech_debts.read_with(|debts| {
let filtered: Vec<_> = debts let filtered: Vec<_> = debts
.iter() .iter()
.filter(|d| { .filter(|d| d.namespace == ns && (inc || !d.is_resolved))
d.namespace == ns && (inc || !d.is_resolved)
})
.map(|d| match level { .map(|d| match level {
"compact" => serde_json::json!({ "compact" => serde_json::json!({
"id": d.id, "id": d.id,
@@ -811,7 +819,8 @@ impl McpTool for OmniSearchHandler {
let mut ranked_items: Vec<_> = rrf_scores.into_values().collect(); let mut ranked_items: Vec<_> = rrf_scores.into_values().collect();
ranked_items.sort_by(|a, b| b.0.total_cmp(&a.0)); ranked_items.sort_by(|a, b| b.0.total_cmp(&a.0));
let matches: Vec<crate::search::SearchResult> = ranked_items.into_iter().map(|(_, item)| item).collect(); let matches: Vec<crate::search::SearchResult> =
ranked_items.into_iter().map(|(_, item)| item).collect();
let kg_json = state.read_graph(|full| { let kg_json = state.read_graph(|full| {
let mut kg_results = serde_json::Map::new(); let mut kg_results = serde_json::Map::new();
@@ -1263,25 +1272,21 @@ impl McpTool for ManageCheckpointHandler {
))); )));
} }
if let Ok(graph_content) = std::fs::read_to_string(target_dir.join("graph.json")) { if let Ok(graph_content) = std::fs::read_to_string(target_dir.join("graph.json"))
if let Ok(graph) = serde_json::from_str(&graph_content) { && let Ok(graph) = serde_json::from_str(&graph_content) {
state.graph.modify(|g| *g = graph); state.graph.modify(|g| *g = graph);
} }
}
if let Ok(tasks_content) = std::fs::read_to_string(target_dir.join("tasks.json")) { if let Ok(tasks_content) = std::fs::read_to_string(target_dir.join("tasks.json"))
if let Ok(tasks) = serde_json::from_str(&tasks_content) { && let Ok(tasks) = serde_json::from_str(&tasks_content) {
state.project.tasks.modify(|t| *t = tasks); state.project.tasks.modify(|t| *t = tasks);
} }
}
if let Ok(debts_content) = if let Ok(debts_content) =
std::fs::read_to_string(target_dir.join("tech_debts.json")) std::fs::read_to_string(target_dir.join("tech_debts.json"))
{ && let Ok(debts) = serde_json::from_str(&debts_content) {
if let Ok(debts) = serde_json::from_str(&debts_content) {
state.code.tech_debts.modify(|d| *d = debts); state.code.tech_debts.modify(|d| *d = debts);
} }
}
Ok(format!("Checkpoint '{}' restored successfully.", name)) Ok(format!("Checkpoint '{}' restored successfully.", name))
} }
@@ -1290,11 +1295,10 @@ impl McpTool for ManageCheckpointHandler {
let checkpoints_dir = state.base_dir.join("checkpoints"); let checkpoints_dir = state.base_dir.join("checkpoints");
if let Ok(entries) = std::fs::read_dir(&checkpoints_dir) { if let Ok(entries) = std::fs::read_dir(&checkpoints_dir) {
for entry in entries.flatten() { for entry in entries.flatten() {
if entry.path().is_dir() { if entry.path().is_dir()
if let Some(n) = entry.file_name().to_str() { && let Some(n) = entry.file_name().to_str() {
list.push(serde_json::json!({"type": "checkpoint", "name": n})); list.push(serde_json::json!({"type": "checkpoint", "name": n}));
} }
}
} }
} }
let snaps = state.project.snapshots.read_with(|snaps| snaps.clone()); let snaps = state.project.snapshots.read_with(|snaps| snaps.clone());
@@ -1461,13 +1465,11 @@ impl McpTool for GetNextActionableTasksHandler {
continue; continue;
} }
if let Some(branch) = &req.git_branch { if let Some(branch) = &req.git_branch
if let Some(tb) = &task.git_branch { && let Some(tb) = &task.git_branch
if tb != branch { && tb != branch {
continue; continue;
} }
}
}
let unblocked = task.dependencies.is_empty() let unblocked = task.dependencies.is_empty()
|| task.dependencies.iter().all(|d| completed_ids.contains(d)); || task.dependencies.iter().all(|d| completed_ids.contains(d));
@@ -1509,7 +1511,9 @@ impl McpTool for HypothesesHandler {
match req.action { match req.action {
HypothesisAction::Log => { HypothesisAction::Log => {
let hyp_text = req.hypothesis.ok_or_else(|| { let hyp_text = req.hypothesis.ok_or_else(|| {
crate::error::AppError::Internal("Missing required 'hypothesis' for action 'log'".to_string()) crate::error::AppError::Internal(
"Missing required 'hypothesis' for action 'log'".to_string(),
)
})?; })?;
let hyp_id = format!( let hyp_id = format!(
"HYP-{}", "HYP-{}",
@@ -1536,17 +1540,16 @@ impl McpTool for HypothesesHandler {
let filtered: Vec<_> = hypotheses let filtered: Vec<_> = hypotheses
.into_iter() .into_iter()
.filter(|h| { .filter(|h| {
if let Some(tid) = &req.task_id { if let Some(tid) = &req.task_id
if h.task_id.as_ref() != Some(tid) { && h.task_id.as_ref() != Some(tid) {
return false; return false;
} }
}
if let Some(q) = &req.query { if let Some(q) = &req.query {
let lq = q.to_lowercase(); let lq = q.to_lowercase();
return h.hypothesis.to_lowercase().contains(&lq) return h.hypothesis.to_lowercase().contains(&lq)
|| h.evidence || h.evidence
.as_ref() .as_ref()
.map_or(false, |e| e.to_lowercase().contains(&lq)); .is_some_and(|e| e.to_lowercase().contains(&lq));
} }
true true
}) })
@@ -1656,13 +1659,19 @@ impl McpTool for AgentSignalsHandler {
match req.action { match req.action {
AgentSignalAction::Broadcast => { AgentSignalAction::Broadcast => {
let sender = req.sender.ok_or_else(|| { let sender = req.sender.ok_or_else(|| {
crate::error::AppError::Internal("Missing required 'sender' for action 'broadcast'".to_string()) crate::error::AppError::Internal(
"Missing required 'sender' for action 'broadcast'".to_string(),
)
})?; })?;
let signal_type = req.signal_type.ok_or_else(|| { let signal_type = req.signal_type.ok_or_else(|| {
crate::error::AppError::Internal("Missing required 'signal_type' for action 'broadcast'".to_string()) crate::error::AppError::Internal(
"Missing required 'signal_type' for action 'broadcast'".to_string(),
)
})?; })?;
let payload = req.payload.ok_or_else(|| { let payload = req.payload.ok_or_else(|| {
crate::error::AppError::Internal("Missing required 'payload' for action 'broadcast'".to_string()) crate::error::AppError::Internal(
"Missing required 'payload' for action 'broadcast'".to_string(),
)
})?; })?;
let timestamp = std::time::SystemTime::now() let timestamp = std::time::SystemTime::now()
@@ -1715,25 +1724,21 @@ impl McpTool for AgentSignalsHandler {
signals signals
.iter() .iter()
.filter(|s| { .filter(|s| {
if let Some(ttl) = s.ttl_seconds { if let Some(ttl) = s.ttl_seconds
if now > s.timestamp + ttl { && now > s.timestamp + ttl {
return false; return false;
} }
} if let Some(sender) = &req.sender
if let Some(sender) = &req.sender { && s.sender.to_lowercase() != sender.to_lowercase() {
if s.sender.to_lowercase() != sender.to_lowercase() {
return false; return false;
} }
} if let Some(st) = &req.signal_type
if let Some(st) = &req.signal_type { && s.signal_type.to_lowercase() != st.to_lowercase() {
if s.signal_type.to_lowercase() != st.to_lowercase() {
return false; return false;
} }
}
true true
}) })
.cloned() .take(req.limit.unwrap_or(20)).cloned()
.take(req.limit.unwrap_or(20))
.collect::<Vec<_>>() .collect::<Vec<_>>()
}); });
@@ -2110,7 +2115,10 @@ mod tests {
assert!(q_dec_res.contains("Fast async API routing")); assert!(q_dec_res.contains("Fast async API routing"));
let q_by_consequence = handler_dec let q_by_consequence = handler_dec
.execute(serde_json::json!({"action": "query", "query": "Fast async"}), state.clone()) .execute(
serde_json::json!({"action": "query", "query": "Fast async"}),
state.clone(),
)
.await .await
.unwrap(); .unwrap();
assert!(q_by_consequence.contains("Use Axum")); assert!(q_by_consequence.contains("Use Axum"));
@@ -2133,7 +2141,10 @@ mod tests {
assert!(update_res.contains("implemented")); assert!(update_res.contains("implemented"));
let q_after_update = handler_dec let q_after_update = handler_dec
.execute(serde_json::json!({"action": "query", "include_body": false}), state.clone()) .execute(
serde_json::json!({"action": "query", "include_body": false}),
state.clone(),
)
.await .await
.unwrap(); .unwrap();
assert!(q_after_update.contains("implemented")); assert!(q_after_update.contains("implemented"));
@@ -2143,7 +2154,10 @@ mod tests {
assert!(q_after_update.contains("resolved_at")); assert!(q_after_update.contains("resolved_at"));
let del_dec_res = handler_dec let del_dec_res = handler_dec
.execute(serde_json::json!({"action": "delete", "id": "ADR-0001"}), state.clone()) .execute(
serde_json::json!({"action": "delete", "id": "ADR-0001"}),
state.clone(),
)
.await; .await;
assert!(del_dec_res.is_ok()); assert!(del_dec_res.is_ok());
@@ -2163,14 +2177,20 @@ mod tests {
assert!(td_res.contains("Tech debt logged")); assert!(td_res.contains("Tech debt logged"));
let list_td_res = handler_td let list_td_res = handler_td
.execute(serde_json::json!({"action": "list", "include_resolved": true}), state.clone()) .execute(
serde_json::json!({"action": "list", "include_resolved": true}),
state.clone(),
)
.await .await
.unwrap(); .unwrap();
assert!(list_td_res.contains("Replace unwraps")); assert!(list_td_res.contains("Replace unwraps"));
let debt_id = state.code.tech_debts.read_with(|debts| debts[0].id.clone()); let debt_id = state.code.tech_debts.read_with(|debts| debts[0].id.clone());
let res_td_res = handler_td let res_td_res = handler_td
.execute(serde_json::json!({"action": "resolve", "id": debt_id}), state.clone()) .execute(
serde_json::json!({"action": "resolve", "id": debt_id}),
state.clone(),
)
.await; .await;
assert!(res_td_res.is_ok()); assert!(res_td_res.is_ok());
@@ -2394,7 +2414,10 @@ mod tests {
) )
.await .await
.unwrap(); .unwrap();
assert!(rest_state_res.contains("restored successfully") || rest_state_res.contains("restored memory state from snapshot")); assert!(
rest_state_res.contains("restored successfully")
|| rest_state_res.contains("restored memory state from snapshot")
);
let mg_list = mg_chk let mg_list = mg_chk
.execute(serde_json::json!({"action": "list"}), state.clone()) .execute(serde_json::json!({"action": "list"}), state.clone())
@@ -2607,7 +2630,10 @@ mod tests {
// DeleteDecision non-existent // DeleteDecision non-existent
let del_dec_err = handler_dec let del_dec_err = handler_dec
.execute(serde_json::json!({"action": "delete", "id": "ADR-9999"}), state.clone()) .execute(
serde_json::json!({"action": "delete", "id": "ADR-9999"}),
state.clone(),
)
.await; .await;
assert!(del_dec_err.is_err()); assert!(del_dec_err.is_err());
@@ -2882,9 +2908,14 @@ mod tests {
state.code.tech_debts.read_with(|debts| { state.code.tech_debts.read_with(|debts| {
assert_eq!(debts.len(), 300); assert_eq!(debts.len(), 300);
assert!(debts.iter().any(|d| d.id == "critical-debt"), "Critical unresolved debt must be retained"); assert!(
assert!(!debts.iter().any(|d| d.id == "resolved-debt"), "Resolved debt should have been evicted first"); debts.iter().any(|d| d.id == "critical-debt"),
"Critical unresolved debt must be retained"
);
assert!(
!debts.iter().any(|d| d.id == "resolved-debt"),
"Resolved debt should have been evicted first"
);
}); });
} }
} }
-1
View File
@@ -10,4 +10,3 @@ pub mod tasks;
pub mod utils; pub mod utils;
pub mod vision; pub mod vision;
pub mod workspaces; pub mod workspaces;
+30 -14
View File
@@ -29,7 +29,9 @@ impl McpTool for HandoffMemosHandler {
let content = req.content.ok_or_else(|| { let content = req.content.ok_or_else(|| {
crate::error::AppError::Internal("Missing required parameter 'content' for action 'leave'. Next step: Provide non-empty 'content' string in request and retry.".to_string()) crate::error::AppError::Internal("Missing required parameter 'content' for action 'leave'. Next step: Provide non-empty 'content' string in request and retry.".to_string())
})?; })?;
let ns = req.namespace.unwrap_or_else(|| crate::models::default_namespace()); let ns = req
.namespace
.unwrap_or_else(crate::models::default_namespace);
state.telemetry.handoff_memos.modify(|memos| { state.telemetry.handoff_memos.modify(|memos| {
memos.push(crate::models::HandoffMemo { memos.push(crate::models::HandoffMemo {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
@@ -109,11 +111,13 @@ impl McpTool for AddSessionSummaryHandler {
}); });
if summaries.len() > 200 { if summaries.len() > 200 {
let now = crate::handlers::utils::now_secs(); let now = crate::handlers::utils::now_secs();
if let Some((idx_to_remove, _)) = summaries.iter().enumerate().min_by_key(|(_, s)| { if let Some((idx_to_remove, _)) =
let is_expired = s.expires_at.map(|exp| exp < now).unwrap_or(false); summaries.iter().enumerate().min_by_key(|(_, s)| {
let status_score = if is_expired { 0 } else { 1 }; let is_expired = s.expires_at.map(|exp| exp < now).unwrap_or(false);
(status_score, s.timestamp) let status_score = if is_expired { 0 } else { 1 };
}) { (status_score, s.timestamp)
})
{
summaries.remove(idx_to_remove); summaries.remove(idx_to_remove);
} }
} }
@@ -143,13 +147,25 @@ impl McpTool for GenerateStandupReportHandler {
let cutoff = crate::handlers::utils::now_secs().saturating_sub(req.hours_lookback * 3600); let cutoff = crate::handlers::utils::now_secs().saturating_sub(req.hours_lookback * 3600);
let filtered_tasks = state.project.tasks.read_with(|items| { let filtered_tasks = state.project.tasks.read_with(|items| {
items.iter().filter(|t| t.updated_at >= cutoff).cloned().collect::<Vec<_>>() items
.iter()
.filter(|t| t.updated_at >= cutoff)
.cloned()
.collect::<Vec<_>>()
}); });
let filtered_changes = state.code.ledger.read_with(|changes| { let filtered_changes = state.code.ledger.read_with(|changes| {
changes.iter().filter(|c| c.timestamp >= cutoff).cloned().collect::<Vec<_>>() changes
.iter()
.filter(|c| c.timestamp >= cutoff)
.cloned()
.collect::<Vec<_>>()
}); });
let filtered_summaries = state.telemetry.session_summaries.read_with(|summaries| { let filtered_summaries = state.telemetry.session_summaries.read_with(|summaries| {
summaries.iter().filter(|s| s.namespace == req.namespace && s.timestamp >= cutoff).cloned().collect::<Vec<_>>() summaries
.iter()
.filter(|s| s.namespace == req.namespace && s.timestamp >= cutoff)
.cloned()
.collect::<Vec<_>>()
}); });
let report = serde_json::json!({ let report = serde_json::json!({
@@ -178,8 +194,7 @@ impl McpTool for PromoteToEntityHandler {
} }
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 req: PromoteToEntityTool = let req: PromoteToEntityTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
serde_json::from_value(args).map_err(|e| e.to_string())?;
let entity_name = req.entity_name.clone(); let entity_name = req.entity_name.clone();
let obs = req.content.clone(); let obs = req.content.clone();
@@ -236,7 +251,10 @@ mod tests {
assert_eq!(res, "Handoff memo left."); assert_eq!(res, "Handoff memo left.");
let res2 = handoff_handler let res2 = handoff_handler
.execute(json!({"action": "read", "namespace": "global"}), state.clone()) .execute(
json!({"action": "read", "namespace": "global"}),
state.clone(),
)
.await .await
.map_err(|e| crate::error::AppError::Internal(e.to_string())) .map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap(); .unwrap();
@@ -266,5 +284,3 @@ mod tests {
assert!(!res4.is_empty()); assert!(!res4.is_empty());
} }
} }
+28 -16
View File
@@ -39,7 +39,9 @@ pub fn extract_adrs(text: &str) -> Vec<String> {
if i + 3 <= len && lower_chars[i..i + 3] == ['a', 'd', 'r'] { if i + 3 <= len && lower_chars[i..i + 3] == ['a', 'd', 'r'] {
let mut cursor = i + 3; let mut cursor = i + 3;
// Skip optional '-', ':', or whitespace // Skip optional '-', ':', or whitespace
while cursor < len && (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ') { while cursor < len
&& (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ')
{
cursor += 1; cursor += 1;
} }
// Collect consecutive digits // Collect consecutive digits
@@ -80,11 +82,17 @@ pub fn extract_tech_debts(text: &str) -> Vec<String> {
while i < len { while i < len {
if i + 2 <= len && lower_chars[i..i + 2] == ['t', 'd'] { if i + 2 <= len && lower_chars[i..i + 2] == ['t', 'd'] {
let mut cursor = i + 2; let mut cursor = i + 2;
while cursor < len && (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ') { while cursor < len
&& (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ')
{
cursor += 1; cursor += 1;
} }
let start = cursor; let start = cursor;
while cursor < len && (chars[cursor].is_ascii_alphanumeric() || chars[cursor] == '_' || chars[cursor] == '-') { while cursor < len
&& (chars[cursor].is_ascii_alphanumeric()
|| chars[cursor] == '_'
|| chars[cursor] == '-')
{
cursor += 1; cursor += 1;
} }
if cursor > start { if cursor > start {
@@ -117,11 +125,17 @@ pub fn extract_tasks(text: &str) -> Vec<String> {
while i < len { while i < len {
if i + 4 <= len && lower_chars[i..i + 4] == ['t', 'a', 's', 'k'] { if i + 4 <= len && lower_chars[i..i + 4] == ['t', 'a', 's', 'k'] {
let mut cursor = i + 4; let mut cursor = i + 4;
while cursor < len && (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ') { while cursor < len
&& (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ')
{
cursor += 1; cursor += 1;
} }
let start = cursor; let start = cursor;
while cursor < len && (chars[cursor].is_ascii_alphanumeric() || chars[cursor] == '_' || chars[cursor] == '-') { while cursor < len
&& (chars[cursor].is_ascii_alphanumeric()
|| chars[cursor] == '_'
|| chars[cursor] == '-')
{
cursor += 1; cursor += 1;
} }
if cursor > start { if cursor > start {
@@ -162,8 +176,7 @@ pub fn cascade_unblock_tasks(state: &MemoryState) -> Vec<String> {
} else { } else {
let all_deps_completed = t.dependencies.iter().all(|dep_id| { let all_deps_completed = t.dependencies.iter().all(|dep_id| {
tasks_snapshot.iter().any(|other| { tasks_snapshot.iter().any(|other| {
(other.id == *dep_id || other.title == *dep_id) (other.id == *dep_id || other.title == *dep_id) && !other.is_active()
&& !other.is_active()
}) })
}); });
if all_deps_completed { if all_deps_completed {
@@ -226,19 +239,17 @@ pub fn recalculate_milestones(state: &MemoryState) -> Vec<String> {
} }
updated.push(m.id.clone()); updated.push(m.id.clone());
} }
} else if completed > 0 } else if (completed > 0
|| tasks_snapshot.iter().any(|t| { || tasks_snapshot.iter().any(|t| {
m.task_ids.iter().any(|tid| tid == &t.id || tid == &t.title) m.task_ids.iter().any(|tid| tid == &t.id || tid == &t.title)
&& (t.status == "in_progress" || t.status == "active") && (t.status == "in_progress" || t.status == "active")
}) }))
{ && !m.status.eq_ignore_ascii_case("in_progress")
if !m.status.eq_ignore_ascii_case("in_progress")
&& !m.status.eq_ignore_ascii_case("completed") && !m.status.eq_ignore_ascii_case("completed")
{ {
m.status = "in_progress".to_string(); m.status = "in_progress".to_string();
updated.push(m.id.clone()); updated.push(m.id.clone());
} }
}
} }
}); });
@@ -275,11 +286,10 @@ pub async fn reconcile_commit_or_code_change(
if adr.id.eq_ignore_ascii_case(cand) { if adr.id.eq_ignore_ascii_case(cand) {
return true; return true;
} }
if let (Some(n1), Some(n2)) = (adr_numeric_id(&adr.id), adr_numeric_id(cand)) { if let (Some(n1), Some(n2)) = (adr_numeric_id(&adr.id), adr_numeric_id(cand))
if n1 == n2 { && n1 == n2 {
return true; return true;
} }
}
false false
}); });
@@ -324,7 +334,9 @@ pub async fn reconcile_commit_or_code_change(
if d.is_resolved { if d.is_resolved {
continue; continue;
} }
let matches_id = td_candidates.iter().any(|cand| d.id.eq_ignore_ascii_case(cand)); let matches_id = td_candidates
.iter()
.any(|cand| d.id.eq_ignore_ascii_case(cand));
let matches_file = file_path let matches_file = file_path
.map(|fp| d.file_path.as_deref() == Some(fp)) .map(|fp| d.file_path.as_deref() == Some(fp))
.unwrap_or(false) .unwrap_or(false)
+10 -10
View File
@@ -366,14 +366,14 @@ impl McpTool for TasksHandler {
let mut updated_task: Option<Task> = None; let mut updated_task: Option<Task> = None;
state.project.tasks.modify(|tasks| { state.project.tasks.modify(|tasks| {
if let Some(task) = tasks.iter_mut().find(|t| t.id == id || t.title == id) { 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() { if let Some(ac) = task.acceptance_criteria.first_mut() {
if ac.is_met { if ac.is_met {
already_met = true; already_met = true;
} else { } else {
ac.is_met = true; ac.is_met = true;
success = true; success = true;
task.updated_at = crate::handlers::utils::now_secs(); task.updated_at = crate::handlers::utils::now_secs();
updated_task = Some(task.clone()); updated_task = Some(task.clone());
} }
} else { } else {
task.acceptance_criteria task.acceptance_criteria
@@ -444,7 +444,7 @@ impl McpTool for MilestonesHandler {
})?; })?;
let ns = req let ns = req
.namespace .namespace
.unwrap_or_else(|| crate::models::default_namespace()); .unwrap_or_else(crate::models::default_namespace);
state.project.milestones.modify(|ms| { state.project.milestones.modify(|ms| {
ms.push(crate::models::Milestone { ms.push(crate::models::Milestone {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
@@ -477,16 +477,16 @@ impl McpTool for MilestonesHandler {
for m in ms.iter_mut() { for m in ms.iter_mut() {
if m.id == id { if m.id == id {
m.status = status.clone(); m.status = status.clone();
if status.eq_ignore_ascii_case("completed") || status.eq_ignore_ascii_case("done") { if (status.eq_ignore_ascii_case("completed")
if m.completed_at.is_none() { || status.eq_ignore_ascii_case("done"))
&& m.completed_at.is_none() {
m.completed_at = Some(crate::handlers::utils::now_secs()); m.completed_at = Some(crate::handlers::utils::now_secs());
} }
}
if let Some(task_ids) = &req.task_ids { if let Some(task_ids) = &req.task_ids {
m.task_ids = task_ids.clone(); m.task_ids = task_ids.clone();
if let Some(deliverables) = &req.deliverables { if let Some(deliverables) = &req.deliverables {
m.deliverables = deliverables.clone(); m.deliverables = deliverables.clone();
} }
} }
found = true; found = true;
break; break;
+50 -34
View File
@@ -191,11 +191,10 @@ pub fn html_to_markdown(html: &str) -> String {
out.push_str("* "); out.push_str("* ");
} }
} }
"/li" => { "/li"
if !in_cell { if !in_cell => {
out.push('\n'); out.push('\n');
} }
}
_ => {} _ => {}
} }
} else if c == '&' { } else if c == '&' {
@@ -304,32 +303,40 @@ pub async fn ensure_ocr_engine() -> Option<&'static OcrEngine> {
let det_path = cache_dir.join("text-detection.rten"); let det_path = cache_dir.join("text-detection.rten");
if !det_path.exists() || std::fs::metadata(&det_path).map(|m| m.len()).unwrap_or(0) < 100_000 { if !det_path.exists() || std::fs::metadata(&det_path).map(|m| m.len()).unwrap_or(0) < 100_000 {
match reqwest::get("https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.rten").await { match reqwest::get("https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.rten")
.await
{
Ok(resp) => { Ok(resp) => {
if let Ok(bytes) = resp.bytes().await { if let Ok(bytes) = resp.bytes().await {
if let Err(e) = std::fs::write(&det_path, &bytes) { if let Err(e) = std::fs::write(&det_path, &bytes) {
tracing::error!("Failed to write text-detection.rten to disk: {}", e); tracing::error!("Failed to write text-detection.rten to disk: {}", e);
} }
} else { } else {
tracing::error!("Failed to read bytes from text-detection.rten download response"); tracing::error!(
"Failed to read bytes from text-detection.rten download response"
);
} }
}, }
Err(e) => tracing::error!("Failed to download text-detection.rten: {}", e), Err(e) => tracing::error!("Failed to download text-detection.rten: {}", e),
} }
} }
let rec_path = cache_dir.join("text-recognition.rten"); let rec_path = cache_dir.join("text-recognition.rten");
if !rec_path.exists() || std::fs::metadata(&rec_path).map(|m| m.len()).unwrap_or(0) < 100_000 { if !rec_path.exists() || std::fs::metadata(&rec_path).map(|m| m.len()).unwrap_or(0) < 100_000 {
match reqwest::get("https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.rten").await { match reqwest::get("https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.rten")
.await
{
Ok(resp) => { Ok(resp) => {
if let Ok(bytes) = resp.bytes().await { if let Ok(bytes) = resp.bytes().await {
if let Err(e) = std::fs::write(&rec_path, &bytes) { if let Err(e) = std::fs::write(&rec_path, &bytes) {
tracing::error!("Failed to write text-recognition.rten to disk: {}", e); tracing::error!("Failed to write text-recognition.rten to disk: {}", e);
} }
} else { } else {
tracing::error!("Failed to read bytes from text-recognition.rten download response"); tracing::error!(
"Failed to read bytes from text-recognition.rten download response"
);
} }
}, }
Err(e) => tracing::error!("Failed to download text-recognition.rten: {}", e), Err(e) => tracing::error!("Failed to download text-recognition.rten: {}", e),
} }
} }
@@ -350,10 +357,7 @@ pub async fn ensure_ocr_engine() -> Option<&'static OcrEngine> {
pub fn perform_ocrs_ocr(engine: &OcrEngine, img: &image::DynamicImage) -> Option<String> { pub fn perform_ocrs_ocr(engine: &OcrEngine, img: &image::DynamicImage) -> Option<String> {
let rgb_img = img.to_rgb8(); let rgb_img = img.to_rgb8();
let img_source = ocrs::ImageSource::from_bytes( let img_source = ocrs::ImageSource::from_bytes(rgb_img.as_raw(), rgb_img.dimensions()).ok()?;
rgb_img.as_raw(),
rgb_img.dimensions(),
).ok()?;
let ocr_input = engine.prepare_input(img_source).ok()?; let ocr_input = engine.prepare_input(img_source).ok()?;
let word_rects = engine.detect_words(&ocr_input).ok()?; let word_rects = engine.detect_words(&ocr_input).ok()?;
@@ -482,12 +486,11 @@ pub async fn process_clipboard_snapshot(state: Arc<MemoryState>) {
if let Some(engine) = ensure_ocr_engine().await { if let Some(engine) = ensure_ocr_engine().await {
let img_clone = img.clone(); let img_clone = img.clone();
ocr_text_opt = tokio::task::spawn_blocking(move || { ocr_text_opt =
perform_ocrs_ocr(engine, &img_clone) tokio::task::spawn_blocking(move || perform_ocrs_ocr(engine, &img_clone))
}) .await
.await .ok()
.ok() .flatten();
.flatten();
} }
let mut cache = state.clipboard_cache.write().await; let mut cache = state.clipboard_cache.write().await;
@@ -563,11 +566,10 @@ pub async fn process_clipboard_snapshot(state: Arc<MemoryState>) {
}; };
let mut cache = state.clipboard_cache.write().await; let mut cache = state.clipboard_cache.write().await;
if let Some(ref prev) = cache.last_text { if let Some(ref prev) = cache.last_text
if prev.text == cached.text { && prev.text == cached.text {
return; return;
} }
}
cache.last_text = Some(cached.clone()); cache.last_text = Some(cached.clone());
cache cache
.history .history
@@ -669,7 +671,7 @@ pub fn spawn_clipboard_listener(state: Arc<MemoryState>) {
tokio::spawn(async move { tokio::spawn(async move {
while let Some(()) = rx.recv().await { while let Some(()) = rx.recv().await {
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
while let Ok(_) = rx.try_recv() {} while rx.try_recv().is_ok() {}
process_clipboard_snapshot(Arc::clone(&state)).await; process_clipboard_snapshot(Arc::clone(&state)).await;
} }
@@ -843,8 +845,8 @@ impl McpTool for ClipboardHandler {
ClipboardAction::Read => { ClipboardAction::Read => {
let engine = ensure_ocr_engine().await; let engine = ensure_ocr_engine().await;
let out = let out = tokio::task::spawn_blocking(
tokio::task::spawn_blocking(move || -> crate::error::Result<serde_json::Map<String, Value>> { move || -> crate::error::Result<serde_json::Map<String, Value>> {
let mut out = serde_json::Map::new(); let mut out = serde_json::Map::new();
if let Some(text) = get_native_clipboard_text() { if let Some(text) = get_native_clipboard_text() {
@@ -860,8 +862,19 @@ impl McpTool for ClipboardHandler {
let rgb_img = img.into_rgb8(); let rgb_img = img.into_rgb8();
let mut jpeg_bytes = std::io::Cursor::new(Vec::new()); let mut jpeg_bytes = std::io::Cursor::new(Vec::new());
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg_bytes, 88); let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(
if encoder.encode(&rgb_img, rgb_img.width(), rgb_img.height(), image::ExtendedColorType::Rgb8).is_ok() { &mut jpeg_bytes,
88,
);
if encoder
.encode(
&rgb_img,
rgb_img.width(),
rgb_img.height(),
image::ExtendedColorType::Rgb8,
)
.is_ok()
{
let bytes = jpeg_bytes.into_inner(); let bytes = jpeg_bytes.into_inner();
let cache_dir = dirs::home_dir() let cache_dir = dirs::home_dir()
@@ -877,16 +890,19 @@ impl McpTool for ClipboardHandler {
} }
} }
if let Some(eng) = engine { if let Some(eng) = engine
if let Some(ocr_text) = perform_ocrs_ocr(eng, &dynamic_img) { && let Some(ocr_text) = perform_ocrs_ocr(eng, &dynamic_img) {
out.insert("image_analysis".to_string(), json!(ocr_text.trim())); out.insert(
"image_analysis".to_string(),
json!(ocr_text.trim()),
);
} }
}
} }
Ok(out) Ok(out)
}) },
.await )
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??; .await
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
state.record_activity("clipboard", "Read contents from OS clipboard", None); state.record_activity("clipboard", "Read contents from OS clipboard", None);
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&Value::Object( Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&Value::Object(
+23 -15
View File
@@ -6,14 +6,6 @@ use async_trait::async_trait;
use serde_json::Value; use serde_json::Value;
use std::sync::Arc; use std::sync::Arc;
pub struct SnippetsHandler; pub struct SnippetsHandler;
#[async_trait] #[async_trait]
@@ -57,7 +49,7 @@ impl McpTool for SnippetsHandler {
embedding, embedding,
namespace: req namespace: req
.namespace .namespace
.unwrap_or_else(|| crate::models::default_namespace()), .unwrap_or_else(crate::models::default_namespace),
repo_name: req.repo_name, repo_name: req.repo_name,
origin_file: req.origin_file, origin_file: req.origin_file,
line_range: req.line_range, line_range: req.line_range,
@@ -89,8 +81,9 @@ impl McpTool for SnippetsHandler {
let mut scored: Vec<_> = snippets let mut scored: Vec<_> = snippets
.into_iter() .into_iter()
.filter_map(|s| { .filter_map(|s| {
let text = format!("{} {} {} {}", s.name, s.description, s.language, s.code) let text =
.to_lowercase(); format!("{} {} {} {}", s.name, s.description, s.language, s.code)
.to_lowercase();
let mut score = 0.0; let mut score = 0.0;
for tok in &q_tokens { for tok in &q_tokens {
if text.contains(tok) { if text.contains(tok) {
@@ -143,9 +136,9 @@ impl McpTool for SnippetsHandler {
|| s.tags.iter().any(|t| contains_ignore_ascii_case(t, &query)); || s.tags.iter().any(|t| contains_ignore_ascii_case(t, &query));
let matches_tags = tags_filter.is_empty() let matches_tags = tags_filter.is_empty()
|| tags_filter || tags_filter.iter().any(|tf| {
.iter() s.tags.iter().any(|st| contains_ignore_ascii_case(st, tf))
.any(|tf| s.tags.iter().any(|st| contains_ignore_ascii_case(st, tf))); });
matches_text && matches_tags matches_text && matches_tags
}) })
@@ -352,13 +345,28 @@ impl McpTool for SemanticCodeSearchHandler {
} }
let mut out = format!("Semantic Search Results for '{}':\n", tool_args.query); let mut out = format!("Semantic Search Results for '{}':\n", tool_args.query);
for res in results { for res in &results {
out.push_str(&format!( out.push_str(&format!(
"- [{:.2}] {}: {}\n", "- [{:.2}] {}: {}\n",
res.score, res.title, res.body res.score, res.title, res.body
)); ));
} }
if state.ollama.is_available().await {
let prompt = format!(
"You are an expert software architect. Synthesize a concise GraphRAG summary of the following graph nodes relevant to the query: '{}'.\n\nData:\n{}",
tool_args.query, out
);
if let Ok(summary) = state
.ollama
.generate(&prompt, None, Some("Respond clearly and concisely."))
.await
{
out.push_str("\n\n--- Local GraphRAG Summary ---\n");
out.push_str(&summary);
}
}
Ok(out) Ok(out)
} }
} }
+121 -37
View File
@@ -4,6 +4,7 @@
)] )]
pub mod api; pub mod api;
pub mod config;
pub mod db; pub mod db;
pub mod embedding; pub mod embedding;
pub mod error; pub mod error;
@@ -105,11 +106,10 @@ pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
state.project.tasks.read_with(|tasks| { state.project.tasks.read_with(|tasks| {
for t in tasks.iter() { for t in tasks.iter() {
if let Some(exp) = t.expires_at { if let Some(exp) = t.expires_at
if t.is_active() { && t.is_active() {
next_expiry = Some(next_expiry.map_or(exp, |curr| curr.min(exp))); next_expiry = Some(next_expiry.map_or(exp, |curr| curr.min(exp)));
} }
}
} }
}); });
@@ -160,13 +160,12 @@ pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
let mut expired_tasks = Vec::new(); let mut expired_tasks = Vec::new();
state.project.tasks.modify(|tasks| { state.project.tasks.modify(|tasks| {
for t in tasks.iter_mut() { for t in tasks.iter_mut() {
if let Some(exp) = t.expires_at { if let Some(exp) = t.expires_at
if exp <= now && t.is_active() { && exp <= now && t.is_active() {
t.status = "expired".to_string(); t.status = "expired".to_string();
t.updated_at = now; t.updated_at = now;
expired_tasks.push(t.id.clone()); expired_tasks.push(t.id.clone());
} }
}
} }
}); });
@@ -249,8 +248,8 @@ pub async fn condense_graph_worker(state: Arc<MemoryState>) {
} }
}); });
if let Some((content, names)) = snippet_condensation { if let Some((content, names)) = snippet_condensation
if !content.is_empty() { && !content.is_empty() {
let name = format!("Snippet History {}", now); let name = format!("Snippet History {}", now);
state.modify_graph(|graph| { state.modify_graph(|graph| {
graph.entities.insert( graph.entities.insert(
@@ -271,6 +270,84 @@ pub async fn condense_graph_worker(state: Arc<MemoryState>) {
}); });
tracing::info!("Condensed snippets into Historical Summary."); tracing::info!("Condensed snippets into Historical Summary.");
} }
}
}
pub async fn memory_consolidation_worker(state: Arc<MemoryState>) {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
loop {
tokio::select! {
_ = state.shutdown_notify.notified() => break,
_ = interval.tick() => {},
}
let entities: Vec<_> = state.graph.read_with(|g| {
g.entities
.values()
.map(|e| (e.name.clone(), e.entity_type.clone()))
.collect()
});
if entities.len() < 2 {
continue;
}
let mut entity_summaries = String::new();
for (name, e_type) in entities.iter().take(50) {
entity_summaries.push_str(&format!("- [{}] {}\n", e_type, name));
}
let prompt = format!(
"Analyze the following list of entities and identify exactly TWO that represent the exact same concept or item but have slightly different names (e.g. 'auth_service' and 'AuthService'). Return ONLY a valid JSON array containing exactly two strings: the two names to merge. If no obvious duplicates exist, return an empty array []. Do not output any markdown formatting or extra text.\n\nEntities:\n{}",
entity_summaries
);
if let Ok(response) = state
.ollama
.generate(
&prompt,
None,
Some("You are a helpful JSON-only data deduplication assistant. Output only JSON."),
)
.await
{
let cleaned = response
.trim()
.trim_start_matches("```json")
.trim_start_matches("```")
.trim_end_matches("```")
.trim();
if let Ok(duplicates) = serde_json::from_str::<Vec<String>>(cleaned)
&& duplicates.len() == 2 {
let e1_name = &duplicates[0];
let e2_name = &duplicates[1];
if e1_name != e2_name {
tracing::info!(
"Memory Consolidation Daemon: Merging '{}' into '{}'",
e2_name,
e1_name
);
state.modify_graph(|g| {
if let Some(mut e2) = g.entities.remove(e2_name) {
if let Some(e1) = g.entities.get_mut(e1_name) {
e1.observations.append(&mut e2.observations);
} else {
g.entities.insert(e2_name.clone(), e2);
}
}
for rel in g.relations.iter_mut() {
if rel.from == *e2_name {
rel.from = e1_name.clone();
}
if rel.to == *e2_name {
rel.to = e1_name.clone();
}
}
});
}
}
} }
} }
} }
@@ -287,6 +364,7 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
tokio::spawn(index_committer_worker(Arc::clone(&state))); tokio::spawn(index_committer_worker(Arc::clone(&state)));
tokio::spawn(ttl_sweeper_worker(Arc::clone(&state))); tokio::spawn(ttl_sweeper_worker(Arc::clone(&state)));
tokio::spawn(condense_graph_worker(Arc::clone(&state))); tokio::spawn(condense_graph_worker(Arc::clone(&state)));
tokio::spawn(memory_consolidation_worker(Arc::clone(&state)));
crate::watcher::spawn_watcher(Arc::clone(&state)); crate::watcher::spawn_watcher(Arc::clone(&state));
crate::handlers::vision::spawn_clipboard_listener(Arc::clone(&state)); crate::handlers::vision::spawn_clipboard_listener(Arc::clone(&state));
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
@@ -371,32 +449,29 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
} else if let Ok(json_payload) = serde_json::from_slice::<serde_json::Value>(&buf[..len]) { } else if let Ok(json_payload) = serde_json::from_slice::<serde_json::Value>(&buf[..len]) {
if json_payload.get("type").and_then(|t| t.as_str()) == Some("ping") { if json_payload.get("type").and_then(|t| t.as_str()) == Some("ping") {
let _ = socket.send_to(b"pong", addr).await; let _ = socket.send_to(b"pong", addr).await;
} else if json_payload.get("type").and_then(|t| t.as_str()) == Some("gate_wait") { } else if json_payload.get("type").and_then(|t| t.as_str()) == Some("gate_wait")
if let (Some(action), Some(target)) = ( && let (Some(action), Some(target)) = (
json_payload.get("action").and_then(|a| a.as_str()), json_payload.get("action").and_then(|a| a.as_str()),
json_payload.get("target").and_then(|t| t.as_str()) json_payload.get("target").and_then(|t| t.as_str())
) { ) {
subscribers.insert((action.to_string(), target.to_string()), addr); subscribers.insert((action.to_string(), target.to_string()), addr);
} }
}
} }
} }
} }
Ok(event) = event_rx.recv() => { Ok(event) = event_rx.recv() => {
if event.topic == "gate:event" { if event.topic == "gate:event"
if let (Some(action), Some(target), Some(status)) = ( && let (Some(action), Some(target), Some(status)) = (
event.payload.get("action").and_then(|a| a.as_str()), event.payload.get("action").and_then(|a| a.as_str()),
event.payload.get("target").and_then(|t| t.as_str()), event.payload.get("target").and_then(|t| t.as_str()),
event.payload.get("status").and_then(|s| s.as_str()), event.payload.get("status").and_then(|s| s.as_str()),
) { ) {
if status == "authorized" || status == "blocked" { if (status == "authorized" || status == "blocked")
if let Some(addr) = subscribers.remove(&(action.to_string(), target.to_string())) { && let Some(addr) = subscribers.remove(&(action.to_string(), target.to_string())) {
let response = if status == "authorized" { b"APPROVED" } else { b"REJECTED" }; let response = if status == "authorized" { b"APPROVED" } else { b"REJECTED" };
let _ = socket.send_to(response, addr).await; let _ = socket.send_to(response, addr).await;
} }
}
} }
}
} }
} }
} }
@@ -476,34 +551,42 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
if payload.event.starts_with("agent_") || payload.event.starts_with("diff_") { if payload.event.starts_with("agent_") || payload.event.starts_with("diff_") {
let payload_val = serde_json::json!(&payload); let payload_val = serde_json::json!(&payload);
// 1. General event topic (e.g. nvim:ui:agent_prompt_response, nvim:ui:agent_diff_accepted) // 1. General event topic (e.g. nvim:ui:agent_prompt_response, nvim:ui:agent_diff_accepted)
let _ = nvim_udp_state.handler.state.event_bus_tx.send(crate::state::GenericEvent { let _ = nvim_udp_state.handler.state.event_bus_tx.send(
topic: format!("nvim:ui:{}", payload.event), crate::state::GenericEvent {
session_id: Some(payload.session_id.clone()), topic: format!("nvim:ui:{}", payload.event),
payload: payload_val.clone(), session_id: Some(payload.session_id.clone()),
}); payload: payload_val.clone(),
},
);
// 2. Correlated request_id topic (e.g. nvim:ui:agent_prompt_response:REQ_ID) // 2. Correlated request_id topic (e.g. nvim:ui:agent_prompt_response:REQ_ID)
if let Some(ref req_id) = payload.request_id { if let Some(ref req_id) = payload.request_id {
let _ = nvim_udp_state.handler.state.event_bus_tx.send(crate::state::GenericEvent { let _ = nvim_udp_state.handler.state.event_bus_tx.send(
topic: format!("nvim:ui:{}:{}", payload.event, req_id), crate::state::GenericEvent {
session_id: Some(payload.session_id.clone()), topic: format!("nvim:ui:{}:{}", payload.event, req_id),
payload: payload_val.clone(), session_id: Some(payload.session_id.clone()),
}); payload: payload_val.clone(),
},
);
} }
// 3. Correlated diff_id topics // 3. Correlated diff_id topics
if let Some(ref diff_id) = payload.diff_id { if let Some(ref diff_id) = payload.diff_id {
let _ = nvim_udp_state.handler.state.event_bus_tx.send(crate::state::GenericEvent { let _ = nvim_udp_state.handler.state.event_bus_tx.send(
topic: format!("nvim:ui:{}:{}", payload.event, diff_id), crate::state::GenericEvent {
session_id: Some(payload.session_id.clone()), topic: format!("nvim:ui:{}:{}", payload.event, diff_id),
payload: payload_val.clone(), session_id: Some(payload.session_id.clone()),
}); payload: payload_val.clone(),
},
);
// General diff decision topic // General diff decision topic
let _ = nvim_udp_state.handler.state.event_bus_tx.send(crate::state::GenericEvent { let _ = nvim_udp_state.handler.state.event_bus_tx.send(
topic: format!("nvim:ui:diff_decision:{}", diff_id), crate::state::GenericEvent {
session_id: Some(payload.session_id.clone()), topic: format!("nvim:ui:diff_decision:{}", diff_id),
payload: payload_val.clone(), session_id: Some(payload.session_id.clone()),
}); payload: payload_val.clone(),
},
);
} }
} }
} }
@@ -576,6 +659,7 @@ pub fn init_logging(app_name: &str) -> Option<tracing_appender::non_blocking::Wo
} }
pub fn run_cli() -> Result<(), Box<dyn std::error::Error>> { pub fn run_cli() -> Result<(), Box<dyn std::error::Error>> {
crate::config::load_mcp_config_env();
let _guard = init_logging("mcp-memory-server"); let _guard = init_logging("mcp-memory-server");
let cli = Cli::parse(); let cli = Cli::parse();
+9 -19
View File
@@ -4,18 +4,15 @@ use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum ChangeKind { pub enum ChangeKind {
Added, Added,
#[default]
Modified, Modified,
Deleted, Deleted,
Renamed, Renamed,
} }
impl Default for ChangeKind {
fn default() -> Self {
ChangeKind::Modified
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CodeChange { pub struct CodeChange {
@@ -57,19 +54,17 @@ impl CodeChange {
if let Some(vcs) = &self.vcs_type { if let Some(vcs) = &self.vcs_type {
return vcs.as_str(); return vcs.as_str();
} }
if let Some(rev) = self.revision.as_deref().or(self.git_commit.as_deref()) { if let Some(rev) = self.revision.as_deref().or(self.git_commit.as_deref())
if rev.starts_with('r') && rev[1..].chars().all(|c| c.is_ascii_digit()) { && rev.starts_with('r') && rev[1..].chars().all(|c| c.is_ascii_digit()) {
return "svn"; return "svn";
} }
} if let Some(br) = self.branch.as_deref().or(self.git_branch.as_deref())
if let Some(br) = self.branch.as_deref().or(self.git_branch.as_deref()) { && (br.eq_ignore_ascii_case("trunk")
if br.eq_ignore_ascii_case("trunk")
|| br.starts_with("branches/") || br.starts_with("branches/")
|| br.starts_with("tags/") || br.starts_with("tags/"))
{ {
return "svn"; return "svn";
} }
}
"git" "git"
} }
@@ -266,11 +261,10 @@ impl KnowledgeGraph {
let mut adj: HashMap<&'a str, Vec<(&'a str, &'a str, bool)>> = let mut adj: HashMap<&'a str, Vec<(&'a str, &'a str, bool)>> =
HashMap::with_capacity(self.relations.len() * 2); HashMap::with_capacity(self.relations.len() * 2);
for rel in &self.relations { for rel in &self.relations {
if let Some(rf) = relation_filter { if let Some(rf) = relation_filter
if rel.relation_type != rf { && rel.relation_type != rf {
continue; continue;
} }
}
adj.entry(rel.from.as_str()).or_default().push(( adj.entry(rel.from.as_str()).or_default().push((
rel.to.as_str(), rel.to.as_str(),
rel.relation_type.as_str(), rel.relation_type.as_str(),
@@ -398,7 +392,6 @@ fn default_adr_status() -> String {
"accepted".to_string() "accepted".to_string()
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ErrorFix { pub struct ErrorFix {
pub signature: String, pub signature: String,
@@ -420,7 +413,6 @@ pub struct ErrorFix {
pub toolchain: Option<String>, pub toolchain: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SessionSummary { pub struct SessionSummary {
pub timestamp: u64, pub timestamp: u64,
@@ -527,7 +519,6 @@ pub struct EnvironmentDetail {
pub ssh_host: Option<String>, pub ssh_host: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TechDebt { pub struct TechDebt {
pub id: String, pub id: String,
@@ -554,7 +545,6 @@ pub struct TechDebt {
pub effort_estimate: Option<String>, pub effort_estimate: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GateRecord { pub struct GateRecord {
pub id: String, pub id: String,
-1
View File
@@ -263,4 +263,3 @@ async fn test_ollama_client_invalid_api_key() {
client.base_url = "http://invalid-api-key:11434".to_string(); client.base_url = "http://invalid-api-key:11434".to_string();
assert!(!client.is_available().await); assert!(!client.is_available().await);
} }
+97 -29
View File
@@ -639,12 +639,19 @@ impl MemoryHandler {
if let Some(resource) = self.resources.get(uri) { if let Some(resource) = self.resources.get(uri) {
match resource.read(Arc::clone(&self.state)).await { match resource.read(Arc::clone(&self.state)).await {
Ok(text) => { Ok(text) => {
let mut content_obj = serde_json::json!({
"uri": uri,
"mimeType": resource.mime_type().unwrap_or("application/json"),
"text": &text
});
if text.len() > 4000 {
content_obj["cacheControl"] =
serde_json::json!({"type": "ephemeral"});
}
let payload = serde_json::json!({ let payload = serde_json::json!({
"contents": [{ "contents": [content_obj]
"uri": uri,
"mimeType": resource.mime_type().unwrap_or("application/json"),
"text": text
}]
}); });
Some(crate::mcp::success(id, payload)) Some(crate::mcp::success(id, payload))
} }
@@ -756,8 +763,15 @@ impl MemoryHandler {
match result { match result {
Ok(text) => { Ok(text) => {
let mut content_obj = serde_json::json!({"type": "text", "text": &text});
// Heuristic: If response is large (e.g. > 4000 chars), inject cache_control for Prompt Caching
if text.len() > 4000 {
content_obj["cacheControl"] = serde_json::json!({"type": "ephemeral"});
}
let payload = serde_json::json!({ let payload = serde_json::json!({
"content": [{"type": "text", "text": text}], "content": [content_obj],
"isError": false "isError": false
}); });
Some(crate::mcp::success(id_clone, payload)) Some(crate::mcp::success(id_clone, payload))
@@ -788,8 +802,13 @@ impl MemoryHandler {
} }
}); });
let unblocked = crate::handlers::reconciliation::cascade_unblock_tasks(&self.state); let unblocked = crate::handlers::reconciliation::cascade_unblock_tasks(&self.state);
let milestones = crate::handlers::reconciliation::recalculate_milestones(&self.state); let milestones =
self.state.record_activity("task", &format!("Completed task via WebSocket RPC: {}", task_id), Some("completed")); crate::handlers::reconciliation::recalculate_milestones(&self.state);
self.state.record_activity(
"task",
&format!("Completed task via WebSocket RPC: {}", task_id),
Some("completed"),
);
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent { let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
topic: "task:event".to_string(), topic: "task:event".to_string(),
session_id: None, session_id: None,
@@ -800,11 +819,14 @@ impl MemoryHandler {
"updated_milestones": milestones, "updated_milestones": milestones,
}), }),
}); });
Some(crate::mcp::success(id, serde_json::json!({ Some(crate::mcp::success(
"status": "success", id,
"unblocked_tasks": unblocked, serde_json::json!({
"updated_milestones": milestones "status": "success",
}))) "unblocked_tasks": unblocked,
"updated_milestones": milestones
}),
))
} }
"tech_debts/resolve" => { "tech_debts/resolve" => {
let params = req.get("params").unwrap_or(&serde_json::Value::Null); let params = req.get("params").unwrap_or(&serde_json::Value::Null);
@@ -823,7 +845,11 @@ impl MemoryHandler {
} }
}); });
if found { if found {
self.state.record_activity("tech_debt", &format!("Resolved tech debt via WebSocket RPC: {}", debt_id), Some("resolved")); self.state.record_activity(
"tech_debt",
&format!("Resolved tech debt via WebSocket RPC: {}", debt_id),
Some("resolved"),
);
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent { let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
topic: "tech_debt:event".to_string(), topic: "tech_debt:event".to_string(),
session_id: None, session_id: None,
@@ -832,7 +858,10 @@ impl MemoryHandler {
"status": "resolved" "status": "resolved"
}), }),
}); });
Some(crate::mcp::success(id, serde_json::json!({ "status": "success", "message": format!("Tech debt {} resolved", debt_id) }))) Some(crate::mcp::success(
id,
serde_json::json!({ "status": "success", "message": format!("Tech debt {} resolved", debt_id) }),
))
} else { } else {
Some(crate::mcp::error(id, -32602, "Tech debt not found")) Some(crate::mcp::error(id, -32602, "Tech debt not found"))
} }
@@ -853,13 +882,20 @@ impl MemoryHandler {
} }
}); });
if found { if found {
self.state.record_activity("gate", &format!("Authorized push gate via WebSocket: {}", gate_id), Some("authorized")); self.state.record_activity(
"gate",
&format!("Authorized push gate via WebSocket: {}", gate_id),
Some("authorized"),
);
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent { let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
topic: "gate:event".to_string(), topic: "gate:event".to_string(),
session_id: None, session_id: None,
payload: serde_json::json!({ "id": gate_id, "status": "authorized", "active_gate": "authorized" }), payload: serde_json::json!({ "id": gate_id, "status": "authorized", "active_gate": "authorized" }),
}); });
Some(crate::mcp::success(id, serde_json::json!({ "status": "success" }))) Some(crate::mcp::success(
id,
serde_json::json!({ "status": "success" }),
))
} else { } else {
Some(crate::mcp::error(id, -32602, "Gate not found")) Some(crate::mcp::error(id, -32602, "Gate not found"))
} }
@@ -880,13 +916,20 @@ impl MemoryHandler {
} }
}); });
if found { if found {
self.state.record_activity("gate", &format!("Blocked push gate via WebSocket: {}", gate_id), Some("blocked")); self.state.record_activity(
"gate",
&format!("Blocked push gate via WebSocket: {}", gate_id),
Some("blocked"),
);
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent { let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
topic: "gate:event".to_string(), topic: "gate:event".to_string(),
session_id: None, session_id: None,
payload: serde_json::json!({ "id": gate_id, "status": "blocked", "active_gate": "blocked" }), payload: serde_json::json!({ "id": gate_id, "status": "blocked", "active_gate": "blocked" }),
}); });
Some(crate::mcp::success(id, serde_json::json!({ "status": "success" }))) Some(crate::mcp::success(
id,
serde_json::json!({ "status": "success" }),
))
} else { } else {
Some(crate::mcp::error(id, -32602, "Gate not found")) Some(crate::mcp::error(id, -32602, "Gate not found"))
} }
@@ -902,13 +945,20 @@ impl MemoryHandler {
} }
}); });
if removed { if removed {
self.state.record_activity("gate", &format!("Deleted push gate via WebSocket: {}", gate_id), Some("deleted")); self.state.record_activity(
"gate",
&format!("Deleted push gate via WebSocket: {}", gate_id),
Some("deleted"),
);
let _ = self.state.event_bus_tx.send(crate::state::GenericEvent { let _ = self.state.event_bus_tx.send(crate::state::GenericEvent {
topic: "gate:event".to_string(), topic: "gate:event".to_string(),
session_id: None, session_id: None,
payload: serde_json::json!({ "id": gate_id, "status": "deleted" }), payload: serde_json::json!({ "id": gate_id, "status": "deleted" }),
}); });
Some(crate::mcp::success(id, serde_json::json!({ "status": "success" }))) Some(crate::mcp::success(
id,
serde_json::json!({ "status": "success" }),
))
} else { } else {
Some(crate::mcp::error(id, -32602, "Gate not found")) Some(crate::mcp::error(id, -32602, "Gate not found"))
} }
@@ -1003,7 +1053,10 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
("Create Entities", names) ("Create Entities", names)
} }
"hypotheses" => { "hypotheses" => {
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("manage"); let act = args
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("manage");
let hyp = args let hyp = args
.get("hypothesis") .get("hypothesis")
.or_else(|| args.get("query")) .or_else(|| args.get("query"))
@@ -1011,24 +1064,39 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
.unwrap_or(""); .unwrap_or("");
( (
"Hypotheses", "Hypotheses",
format!("{}: {}", act, hyp).trim_end_matches(": ").to_string(), format!("{}: {}", act, hyp)
.trim_end_matches(": ")
.to_string(),
) )
} }
"agent_signals" => { "agent_signals" => {
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("signal"); let act = args
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("signal");
let sender = args.get("sender").and_then(|v| v.as_str()).unwrap_or(""); let sender = args.get("sender").and_then(|v| v.as_str()).unwrap_or("");
let st = args.get("signal_type").and_then(|v| v.as_str()).unwrap_or(""); let st = args
.get("signal_type")
.and_then(|v| v.as_str())
.unwrap_or("");
( (
"Agent Signals", "Agent Signals",
format!("{}: {} [{}]", act, sender, st).trim_end_matches(": ").to_string(), format!("{}: {} [{}]", act, sender, st)
.trim_end_matches(": ")
.to_string(),
) )
} }
"process_logs" => { "process_logs" => {
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("logs"); let act = args
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("logs");
let file = args.get("file_path").and_then(|v| v.as_str()).unwrap_or(""); let file = args.get("file_path").and_then(|v| v.as_str()).unwrap_or("");
( (
"Process Logs", "Process Logs",
format!("{}: {}", act, file).trim_end_matches(": ").to_string(), format!("{}: {}", act, file)
.trim_end_matches(": ")
.to_string(),
) )
} }
_ => return name.to_string(), _ => return name.to_string(),
@@ -1053,8 +1121,8 @@ async fn execute_tool_span(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use tempfile::tempdir;
use serde_json::json; use serde_json::json;
use tempfile::tempdir;
#[test] #[test]
fn test_format_tool_activity_description() { fn test_format_tool_activity_description() {
+72 -28
View File
@@ -16,6 +16,26 @@ pub struct SearchResult {
pub type SearchResultTuple = (String, String, String, String, f32); pub type SearchResultTuple = (String, String, String, String, f32);
fn calculate_time_decay(timestamp_sec: Option<u64>) -> f32 {
let Some(ts) = timestamp_sec else { return 1.0 };
if ts == 0 {
return 1.0;
} // Avoid decaying default unset timestamps
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if ts > now {
return 1.0;
}
let age_days = (now - ts) as f32 / 86400.0;
// 1% decay per day
(-0.01 * age_days).exp()
}
#[derive(Clone)] #[derive(Clone)]
pub struct MemoryIndex { pub struct MemoryIndex {
pub index: Index, pub index: Index,
@@ -55,7 +75,8 @@ impl MemoryIndex {
.reload_policy(ReloadPolicy::OnCommitWithDelay) .reload_policy(ReloadPolicy::OnCommitWithDelay)
.try_into()?; .try_into()?;
let query_parser = tantivy::query::QueryParser::for_index(&index, vec![title_field, body_field]); let query_parser =
tantivy::query::QueryParser::for_index(&index, vec![title_field, body_field]);
Ok(Self { Ok(Self {
index, index,
@@ -87,7 +108,8 @@ impl MemoryIndex {
.reload_policy(ReloadPolicy::OnCommitWithDelay) .reload_policy(ReloadPolicy::OnCommitWithDelay)
.try_into()?; .try_into()?;
let query_parser = tantivy::query::QueryParser::for_index(&index, vec![title_field, body_field]); let query_parser =
tantivy::query::QueryParser::for_index(&index, vec![title_field, body_field]);
Ok(Self { Ok(Self {
index, index,
@@ -117,7 +139,10 @@ impl MemoryIndex {
self.index_entities_batch(std::slice::from_ref(e)) self.index_entities_batch(std::slice::from_ref(e))
} }
pub fn index_entities_batch(&self, entities: &[Entity]) -> tokio::task::JoinHandle<tantivy::Result<()>> { pub fn index_entities_batch(
&self,
entities: &[Entity],
) -> tokio::task::JoinHandle<tantivy::Result<()>> {
let writer = Arc::clone(&self.writer); let writer = Arc::clone(&self.writer);
let id_field = self.id_field; let id_field = self.id_field;
let needs_commit = Arc::clone(&self.needs_commit); let needs_commit = Arc::clone(&self.needs_commit);
@@ -154,13 +179,16 @@ impl MemoryIndex {
let id_val = t.id.clone(); let id_val = t.id.clone();
let needs_commit = Arc::clone(&self.needs_commit); let needs_commit = Arc::clone(&self.needs_commit);
let criteria_body = t.acceptance_criteria.iter().fold(String::new(), |mut acc, c| { let criteria_body = t
if !acc.is_empty() { .acceptance_criteria
acc.push('\n'); .iter()
} .fold(String::new(), |mut acc, c| {
acc.push_str(&c.description); if !acc.is_empty() {
acc acc.push('\n');
}); }
acc.push_str(&c.description);
acc
});
let doc = doc!( let doc = doc!(
self.id_field => t.id.as_str(), self.id_field => t.id.as_str(),
@@ -231,7 +259,10 @@ impl MemoryIndex {
if let Err(first_err) = writer.commit() { if let Err(first_err) = writer.commit() {
std::thread::sleep(std::time::Duration::from_millis(100)); std::thread::sleep(std::time::Duration::from_millis(100));
writer.commit().map_err(|second_err| { writer.commit().map_err(|second_err| {
tantivy::TantivyError::SystemError(format!("First commit error: {}; Second commit error: {}", first_err, second_err)) tantivy::TantivyError::SystemError(format!(
"First commit error: {}; Second commit error: {}",
first_err, second_err
))
})?; })?;
} }
let _ = reader.reload(); let _ = reader.reload();
@@ -246,9 +277,11 @@ impl MemoryIndex {
}) })
} }
pub fn commit_if_needed(&self) -> tantivy::Result<()> { pub fn commit_if_needed(&self) -> tantivy::Result<()> {
if self.needs_commit.swap(false, std::sync::atomic::Ordering::SeqCst) { if self
.needs_commit
.swap(false, std::sync::atomic::Ordering::SeqCst)
{
let mut writer = self.writer.lock().unwrap_or_else(|e| e.into_inner()); let mut writer = self.writer.lock().unwrap_or_else(|e| e.into_inner());
writer.commit()?; writer.commit()?;
let _ = self.reader.reload(); let _ = self.reader.reload();
@@ -268,8 +301,8 @@ impl MemoryIndex {
let sanitized: String = query let sanitized: String = query
.chars() .chars()
.map(|c| match c { .map(|c| match c {
'+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^' '+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"'
| '"' | '~' | '*' | '?' | ':' | '\\' | '/' => ' ', | '~' | '*' | '?' | ':' | '\\' | '/' => ' ',
_ => c, _ => c,
}) })
.collect(); .collect();
@@ -284,10 +317,11 @@ impl MemoryIndex {
let target_limit = if is_wildcard_fallback { 10 } else { 50 }; let target_limit = if is_wildcard_fallback { 10 } else { 50 };
let final_query: Box<dyn tantivy::query::Query> = if let Some(ns) = namespace { let final_query: Box<dyn tantivy::query::Query> = if let Some(ns) = namespace {
let ns_term_query: Box<dyn tantivy::query::Query> = Box::new(tantivy::query::TermQuery::new( let ns_term_query: Box<dyn tantivy::query::Query> =
tantivy::Term::from_field_text(self.namespace_field, ns), Box::new(tantivy::query::TermQuery::new(
tantivy::schema::IndexRecordOption::Basic, tantivy::Term::from_field_text(self.namespace_field, ns),
)); tantivy::schema::IndexRecordOption::Basic,
));
let ns_filter: Box<dyn tantivy::query::Query> = if ns != "global" { let ns_filter: Box<dyn tantivy::query::Query> = if ns != "global" {
let global_term_query = Box::new(tantivy::query::TermQuery::new( let global_term_query = Box::new(tantivy::query::TermQuery::new(
tantivy::Term::from_field_text(self.namespace_field, "global"), tantivy::Term::from_field_text(self.namespace_field, "global"),
@@ -491,7 +525,8 @@ impl SearchService {
self.state.code.snippets.read_with(|snips| { self.state.code.snippets.read_with(|snips| {
for snippet in snips.iter() { for snippet in snips.iter() {
if let Some(ref emb) = snippet.embedding { if let Some(ref emb) = snippet.embedding {
let sim = cosine_similarity(&query_emb, emb); let base_sim = cosine_similarity(&query_emb, emb);
let sim = base_sim * calculate_time_decay(Some(snippet.updated_at));
results.push(SearchResult { results.push(SearchResult {
id: snippet.name.clone(), id: snippet.name.clone(),
doc_type: "snippet".to_string(), doc_type: "snippet".to_string(),
@@ -508,6 +543,7 @@ impl SearchService {
snippet.name.clone(), snippet.name.clone(),
"snippet".to_string(), "snippet".to_string(),
snippet.description.clone(), snippet.description.clone(),
Some(snippet.updated_at),
)); ));
} }
} }
@@ -515,15 +551,15 @@ impl SearchService {
self.state.read_graph(|graph| { self.state.read_graph(|graph| {
for entity in graph.entities.values() { for entity in graph.entities.values() {
if let Some(ns) = filter_namespace { if let Some(ns) = filter_namespace
if entity.namespace != ns { && entity.namespace != ns {
continue; continue;
} }
}
let obs = entity.observations.join("; "); let obs = entity.observations.join("; ");
let desc = format!("{}: {}", entity.entity_type, obs); let desc = format!("{}: {}", entity.entity_type, obs);
if let Some(ref emb) = entity.embedding { if let Some(ref emb) = entity.embedding {
let sim = cosine_similarity(&query_emb, emb); let base_sim = cosine_similarity(&query_emb, emb);
let sim = base_sim * calculate_time_decay(entity.updated_at);
results.push(SearchResult { results.push(SearchResult {
id: entity.name.clone(), id: entity.name.clone(),
doc_type: "entity".to_string(), doc_type: "entity".to_string(),
@@ -533,7 +569,12 @@ impl SearchService {
}); });
} else if uncached_texts.len() < 50 { } else if uncached_texts.len() < 50 {
uncached_texts.push(format!("{} {} {}", entity.name, entity.entity_type, obs)); uncached_texts.push(format!("{} {} {}", entity.name, entity.entity_type, obs));
uncached_meta.push((entity.name.clone(), "entity".to_string(), desc)); uncached_meta.push((
entity.name.clone(),
"entity".to_string(),
desc,
entity.updated_at,
));
} }
} }
}); });
@@ -541,7 +582,8 @@ impl SearchService {
self.state.code.error_fixes.read_with(|fixes| { self.state.code.error_fixes.read_with(|fixes| {
for fix in fixes.iter() { for fix in fixes.iter() {
if let Some(ref emb) = fix.embedding { if let Some(ref emb) = fix.embedding {
let sim = cosine_similarity(&query_emb, emb); let base_sim = cosine_similarity(&query_emb, emb);
let sim = base_sim * calculate_time_decay(Some(fix.timestamp));
results.push(SearchResult { results.push(SearchResult {
id: fix.signature.clone(), id: fix.signature.clone(),
doc_type: "error_fix".to_string(), doc_type: "error_fix".to_string(),
@@ -555,6 +597,7 @@ impl SearchService {
fix.signature.clone(), fix.signature.clone(),
"error_fix".to_string(), "error_fix".to_string(),
fix.solution.clone(), fix.solution.clone(),
Some(fix.timestamp),
)); ));
} }
} }
@@ -563,8 +606,9 @@ impl SearchService {
if !uncached_texts.is_empty() if !uncached_texts.is_empty()
&& let Ok(embeddings) = generate_embeddings_async(uncached_texts).await && let Ok(embeddings) = generate_embeddings_async(uncached_texts).await
{ {
for (emb, (title, doc_type, body)) in embeddings.into_iter().zip(uncached_meta) { for (emb, (title, doc_type, body, ts)) in embeddings.into_iter().zip(uncached_meta) {
let sim = cosine_similarity(&query_emb, &emb); let base_sim = cosine_similarity(&query_emb, &emb);
let sim = base_sim * calculate_time_decay(ts);
results.push(SearchResult { results.push(SearchResult {
id: title.clone(), id: title.clone(),
doc_type, doc_type,
+4 -6
View File
@@ -86,11 +86,10 @@ impl ClipboardCacheState {
} }
pub fn push_text(&mut self, text: CachedClipboardText) { pub fn push_text(&mut self, text: CachedClipboardText) {
if let Some(ref prev) = self.last_text { if let Some(ref prev) = self.last_text
if prev.text == text.text { && prev.text == text.text {
return; return;
} }
}
self.last_text = Some(text.clone()); self.last_text = Some(text.clone());
self.history.push_front(ClipboardHistoryItem::Text(text)); self.history.push_front(ClipboardHistoryItem::Text(text));
if self.history.len() > 20 { if self.history.len() > 20 {
@@ -142,11 +141,10 @@ impl MemoryState {
pub fn new(base_dir_str: &str) -> Self { pub fn new(base_dir_str: &str) -> Self {
let is_in_memory = base_dir_str == ":memory:"; let is_in_memory = base_dir_str == ":memory:";
let base = std::path::PathBuf::from(base_dir_str); let base = std::path::PathBuf::from(base_dir_str);
if !is_in_memory { if !is_in_memory
if let Err(e) = std::fs::create_dir_all(&base) { && let Err(e) = std::fs::create_dir_all(&base) {
tracing::error!("Failed to create store directory at {:?}: {}", base, e); tracing::error!("Failed to create store directory at {:?}: {}", base, e);
} }
}
let db = crate::db::init_redb(&base); let db = crate::db::init_redb(&base);
+13 -16
View File
@@ -33,11 +33,10 @@ static QUEUE_REGISTRY: std::sync::Mutex<Option<(Arc<Database>, DbWriteQueue)>> =
fn get_or_create_queue(db: Arc<Database>) -> DbWriteQueue { fn get_or_create_queue(db: Arc<Database>) -> DbWriteQueue {
let mut reg = QUEUE_REGISTRY.lock().unwrap_or_else(|e| e.into_inner()); let mut reg = QUEUE_REGISTRY.lock().unwrap_or_else(|e| e.into_inner());
if let Some((ref existing_db, ref queue)) = *reg { if let Some((ref existing_db, ref queue)) = *reg
if Arc::ptr_eq(existing_db, &db) && !queue.tx.is_closed() { && Arc::ptr_eq(existing_db, &db) && !queue.tx.is_closed() {
return queue.clone(); return queue.clone();
} }
}
let new_queue = DbWriteQueue::new(db.clone()); let new_queue = DbWriteQueue::new(db.clone());
*reg = Some((db, new_queue.clone())); *reg = Some((db, new_queue.clone()));
new_queue new_queue
@@ -334,19 +333,17 @@ impl<T: DeserializeOwned + Default + Serialize + Send + Sync + 'static> Store<T>
let mut known = std::collections::HashSet::new(); let mut known = std::collections::HashSet::new();
if let Ok(range) = table.range(prefix.as_str()..) { if let Ok(range) = table.range(prefix.as_str()..) {
for entry in range { for (k, v) in range.flatten() {
if let Ok((k, v)) = entry { let k_str = k.value();
let k_str = k.value(); if !k_str.starts_with(&prefix) {
if !k_str.starts_with(&prefix) { break;
break; }
} found_granular = true;
found_granular = true; known.insert(k_str.to_string());
known.insert(k_str.to_string()); if let Ok(val) = serde_json::from_slice::<serde_json::Value>(v.value()) {
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(v.value()) { let sub_key = &k_str[prefix.len()..];
let sub_key = &k_str[prefix.len()..]; items_array.push(val.clone());
items_array.push(val.clone()); items_map.insert(sub_key.to_string(), val);
items_map.insert(sub_key.to_string(), val);
}
} }
} }
} }
+7 -1
View File
@@ -993,7 +993,13 @@ pub struct EnvironmentTool {
#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq)] #[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ClipboardAction { pub enum ClipboardAction {
#[serde(alias = "image", alias = "IMAGE", alias = "Image", alias = "screenshot", alias = "SCREENSHOT")] #[serde(
alias = "image",
alias = "IMAGE",
alias = "Image",
alias = "screenshot",
alias = "SCREENSHOT"
)]
Image, Image,
#[serde(alias = "text", alias = "TEXT", alias = "Text")] #[serde(alias = "text", alias = "TEXT", alias = "Text")]
Text, Text,
+46 -23
View File
@@ -79,7 +79,11 @@ pub fn spawn_watcher(state: Arc<MemoryState>) {
fn should_review(path: &Path) -> bool { fn should_review(path: &Path) -> bool {
let path_str = path.to_string_lossy(); let path_str = path.to_string_lossy();
if path_str.contains(".git") || path_str.contains("target") || path_str.contains(".gemini") || path_str.contains("node_modules") { if path_str.contains(".git")
|| path_str.contains("target")
|| path_str.contains(".gemini")
|| path_str.contains("node_modules")
{
return false; return false;
} }
@@ -89,13 +93,19 @@ fn should_review(path: &Path) -> bool {
} }
async fn trigger_autonomous_review(path: &Path, state: Arc<MemoryState>) { async fn trigger_autonomous_review(path: &Path, state: Arc<MemoryState>) {
info!("Triggering autonomous review & incremental AST index for {:?}", path); info!(
state.broadcast_activity("AUTONOMOUS", &format!("Modified: {:?}", path.file_name().unwrap_or_default())); "Triggering autonomous review & incremental AST index for {:?}",
path
);
state.broadcast_activity(
"AUTONOMOUS",
&format!("Modified: {:?}", path.file_name().unwrap_or_default()),
);
// ADR-0109: Incremental Background AST Indexing & Differential Graph Updates // ADR-0109: Incremental Background AST Indexing & Differential Graph Updates
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if matches!(ext, "rs" | "ts" | "js" | "py" | "go" | "java" | "c" | "cpp") { if matches!(ext, "rs" | "ts" | "js" | "py" | "go" | "java" | "c" | "cpp")
if let Ok(content) = std::fs::read_to_string(path) { && let Ok(content) = std::fs::read_to_string(path) {
let language = match ext { let language = match ext {
"rs" => Some(tree_sitter_rust::LANGUAGE), "rs" => Some(tree_sitter_rust::LANGUAGE),
"ts" | "js" => Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT), "ts" | "js" => Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT),
@@ -109,12 +119,20 @@ async fn trigger_autonomous_review(path: &Path, state: Arc<MemoryState>) {
if let Some(lang) = language { if let Some(lang) = language {
let mut parser = tree_sitter::Parser::new(); let mut parser = tree_sitter::Parser::new();
if parser.set_language(&lang.into()).is_ok() { if parser.set_language(&lang.into()).is_ok()
if let Some(tree) = parser.parse(&content, None) { && let Some(tree) = parser.parse(&content, None) {
let mut chunks = Vec::new(); let mut chunks = Vec::new();
crate::indexer::extract_chunks(tree.root_node(), &content, &mut chunks, ext); crate::indexer::extract_chunks(
tree.root_node(),
&content,
&mut chunks,
ext,
);
let file_str = path.to_string_lossy().to_string(); let file_str = path.to_string_lossy().to_string();
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(); let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let chunks_count = chunks.len(); let chunks_count = chunks.len();
let file_str_clone = file_str.clone(); let file_str_clone = file_str.clone();
@@ -140,16 +158,21 @@ async fn trigger_autonomous_review(path: &Path, state: Arc<MemoryState>) {
"McpTool".to_string() "McpTool".to_string()
}; };
g.entities.insert(symbol_name.clone(), crate::models::Entity { g.entities.insert(
name: symbol_name.clone(), symbol_name.clone(),
entity_type: symbol_type, crate::models::Entity {
observations: vec![format!("AST definition: {} chars", chunk_code.len())], name: symbol_name.clone(),
namespace: "global".to_string(), entity_type: symbol_type,
file_path: Some(file_str.clone()), observations: vec![format!(
created_at: Some(now), "AST definition: {} chars",
updated_at: Some(now), chunk_code.len()
..Default::default() )],
} namespace: "global".to_string(),
file_path: Some(file_str.clone()),
created_at: Some(now),
updated_at: Some(now),
..Default::default()
},
); );
let rel = crate::models::Relation { let rel = crate::models::Relation {
@@ -183,12 +206,13 @@ async fn trigger_autonomous_review(path: &Path, state: Arc<MemoryState>) {
}), }),
}); });
} }
}
} }
} }
}
info!("Autonomous review & incremental AST index complete for {:?}", path); info!(
"Autonomous review & incremental AST index complete for {:?}",
path
);
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -220,4 +244,3 @@ mod tests {
spawn_watcher(state); spawn_watcher(state);
} }
} }
+7
View File
@@ -0,0 +1,7 @@
use image::DynamicImage;
#[test]
fn test_img() {
let mut img = image::DynamicImage::new_rgb8(10, 10);
img.invert();
let img2 = img.adjust_contrast(20.0);
}
+9 -6
View File
@@ -93,10 +93,9 @@ fn test_dashboard_route_parity() {
"server/src/api/setup.rs" "server/src/api/setup.rs"
}; };
let dashboard_content = std::fs::read_to_string(dashboard_path) let dashboard_content =
.expect("Failed to read dashboard.ts"); std::fs::read_to_string(dashboard_path).expect("Failed to read dashboard.ts");
let setup_content = std::fs::read_to_string(setup_path) let setup_content = std::fs::read_to_string(setup_path).expect("Failed to read setup.rs");
.expect("Failed to read setup.rs");
// 1. Extract routes from setup.rs // 1. Extract routes from setup.rs
let mut registered_routes = HashSet::new(); let mut registered_routes = HashSet::new();
@@ -142,7 +141,8 @@ fn test_dashboard_route_parity() {
} }
} }
if endpoint.is_empty() { if endpoint.is_empty() {
let end_idx = rest.find(['"', '`', '\'', ' ', '\n', '?']) let end_idx = rest
.find(['"', '`', '\'', ' ', '\n', '?'])
.unwrap_or(rest.len()); .unwrap_or(rest.len());
endpoint = rest[..end_idx].to_string(); endpoint = rest[..end_idx].to_string();
} }
@@ -153,7 +153,10 @@ fn test_dashboard_route_parity() {
cursor = start + 5; cursor = start + 5;
} }
assert!(!frontend_endpoints.is_empty(), "No frontend endpoints found in dashboard.ts"); assert!(
!frontend_endpoints.is_empty(),
"No frontend endpoints found in dashboard.ts"
);
for endpoint in &frontend_endpoints { for endpoint in &frontend_endpoints {
assert!( assert!(
+24 -8
View File
@@ -48,28 +48,45 @@ mod tests {
#[test] #[test]
fn test_extract_log_prefix_response() { fn test_extract_log_prefix_response() {
let json_str = r#"{"jsonrpc":"2.0","id":123,"result":{}}"#; let json_str = r#"{"jsonrpc":"2.0","id":123,"result":{}}"#;
assert_eq!(extract_log_prefix(json_str, true), "Response id=123 [Error: false]"); assert_eq!(
extract_log_prefix(json_str, true),
"Response id=123 [Error: false]"
);
let err_json = r#"{"jsonrpc":"2.0","id":456,"error":{"code":-32600,"message":"Invalid Request"}}"#; let err_json =
assert_eq!(extract_log_prefix(err_json, true), "Response id=456 [Error: true]"); r#"{"jsonrpc":"2.0","id":456,"error":{"code":-32600,"message":"Invalid Request"}}"#;
assert_eq!(
extract_log_prefix(err_json, true),
"Response id=456 [Error: true]"
);
let is_err_json = r#"{"jsonrpc":"2.0","id":789,"result":{"isError":true}}"#; let is_err_json = r#"{"jsonrpc":"2.0","id":789,"result":{"isError":true}}"#;
assert_eq!(extract_log_prefix(is_err_json, true), "Response id=789 [Error: true]"); assert_eq!(
extract_log_prefix(is_err_json, true),
"Response id=789 [Error: true]"
);
} }
#[test] #[test]
fn test_extract_log_prefix_tool_call() { fn test_extract_log_prefix_tool_call() {
let json_str = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search"}}"#; let json_str =
r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search"}}"#;
assert_eq!(extract_log_prefix(json_str, false), "ToolCall[search] id=1"); assert_eq!(extract_log_prefix(json_str, false), "ToolCall[search] id=1");
let unknown_tool = r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{}}"#; let unknown_tool = r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{}}"#;
assert_eq!(extract_log_prefix(unknown_tool, false), "ToolCall[unknown] id=2"); assert_eq!(
extract_log_prefix(unknown_tool, false),
"ToolCall[unknown] id=2"
);
} }
#[test] #[test]
fn test_extract_log_prefix_request() { fn test_extract_log_prefix_request() {
let json_str = r#"{"jsonrpc":"2.0","id":10,"method":"initialize"}"#; let json_str = r#"{"jsonrpc":"2.0","id":10,"method":"initialize"}"#;
assert_eq!(extract_log_prefix(json_str, false), "Request[initialize] id=10"); assert_eq!(
extract_log_prefix(json_str, false),
"Request[initialize] id=10"
);
} }
#[test] #[test]
@@ -84,4 +101,3 @@ mod tests {
assert_eq!(extract_log_prefix(json_str, false), "Message"); assert_eq!(extract_log_prefix(json_str, false), "Message");
} }
} }