Compare commits

..
2 Commits
34 changed files with 966 additions and 450 deletions

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;
}
if trimmed_line.as_bytes().len() >= 15
if trimmed_line.len() >= 15
&& trimmed_line.as_bytes()[..15].eq_ignore_ascii_case(b"content-length:")
{
length = trimmed_line[15..].trim().parse().unwrap_or(0);
+29 -19
View File
@@ -214,7 +214,7 @@ pub struct NvimRequest {
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)]
pub enum CircuitState {
@@ -275,7 +275,9 @@ impl NvimCircuitBreaker {
pub fn record_success(&self) {
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 {
tracing::info!("Neovim RPC circuit breaker transitioned to Closed (recovered)");
}
@@ -299,7 +301,8 @@ impl NvimCircuitBreaker {
pub fn reset(&self) {
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,19 +377,16 @@ async fn get_nvim_connection() -> Result<mpsc::Sender<NvimRequest>, String> {
// without spawning any external nvim.exe child process!
{
let conn_lock = NVIM_CONN.lock().await;
if let Some(sender) = conn_lock.as_ref() {
if !sender.is_closed() {
if let Some(sender) = conn_lock.as_ref()
&& !sender.is_closed() {
let active_lock = ACTIVE_SOCKET.lock().await;
if let Some(ref current_socket) = *active_lock {
if let Ok(target) = read_active_socket_file().await {
if !target.is_empty() && &target == current_socket {
if let Some(ref current_socket) = *active_lock
&& let Ok(target) = read_active_socket_file().await
&& !target.is_empty() && &target == current_socket {
return Ok(sender.clone());
}
}
}
}
}
}
let interactive_socket = get_socket_path().await.ok();
let mut should_kill_headless = false;
@@ -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> {
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 {
@@ -1749,7 +1752,8 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
.to_string()
});
let options_arr = match args.get("options").and_then(|v| v.as_array()) {
let options_arr =
match args.get("options").and_then(|v| v.as_array()) {
Some(arr) => {
let rmp_arr: Vec<rmpv::Value> = arr
.iter()
@@ -1782,7 +1786,8 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
)
);
} else {
send_error(id, -32602, "Missing 'prompt' or 'prompt_text'").await;
send_error(id, -32602, "Missing 'prompt' or 'prompt_text'")
.await;
}
}
"extmark" => {
@@ -1812,11 +1817,10 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
}
"highlight" | "clear_highlight" => {
let mut modified_args = args.clone();
if action == "clear_highlight" {
if let Some(obj) = modified_args.as_object_mut() {
if action == "clear_highlight"
&& let Some(obj) = modified_args.as_object_mut() {
obj.insert("clear_only".to_string(), json!(true));
}
}
let args_rmp = json_to_rmpv(&modified_args);
let code = "
local args = ...
@@ -2216,13 +2220,19 @@ mod tests {
// 2nd failure - trips to Open
cb.record_failure();
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
std::thread::sleep(std::time::Duration::from_millis(60));
// 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);
// 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)) => {
tracing::warn!("Event bus receiver lagged by {} messages; continuing wait.", skipped);
tracing::warn!(
"Event bus receiver lagged by {} messages; continuing wait.",
skipped
);
continue;
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
+27 -9
View File
@@ -44,8 +44,7 @@ pub fn find_projected_knowledge(
|| normalized_file.ends_with(&format!("/{}", ndfp.trim_start_matches('/')))
|| ndfp.ends_with(&format!("/{}", normalized_file.trim_start_matches('/')))
|| (!file_name.is_empty()
&& (ndfp == file_name
|| ndfp.ends_with(&format!("/{}", file_name))))
&& (ndfp == file_name || ndfp.ends_with(&format!("/{}", file_name))))
{
return true;
}
@@ -73,8 +72,7 @@ pub fn find_projected_knowledge(
all_adrs
.iter()
.filter(|a| {
a.status.eq_ignore_ascii_case("accepted")
|| a.status.eq_ignore_ascii_case("active")
a.status.eq_ignore_ascii_case("accepted") || a.status.eq_ignore_ascii_case("active")
})
.filter(|a| {
for comp in &a.affected_components {
@@ -189,14 +187,22 @@ pub async fn nvim_telemetry_handler(
if payload.event.starts_with("agent_") || payload.event.starts_with("diff_") {
let payload_val = serde_json::json!(&payload);
let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
let _ = state
.handler
.state
.event_bus_tx
.send(crate::state::GenericEvent {
topic: format!("nvim:ui:{}", payload.event),
session_id: Some(payload.session_id.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 {
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(),
@@ -204,12 +210,20 @@ pub async fn nvim_telemetry_handler(
}
if let Some(ref diff_id) = payload.diff_id {
let _ = state.handler.state.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),
session_id: Some(payload.session_id.clone()),
payload: payload_val.clone(),
});
let _ = state.handler.state.event_bus_tx.send(crate::state::GenericEvent {
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
.iter()
.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())
});
+16 -8
View File
@@ -66,9 +66,11 @@ pub async fn handle_socket(socket: WebSocket, state: Arc<AppState>, _client_type
activities
.iter()
.filter_map(|act_val| {
if act_val["category"] == "TASK_EVENT" {
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 act_val["category"] == "TASK_EVENT"
&& let Some(details_str) = act_val["details"].as_str()
&& let Ok(event_val) =
serde_json::from_str::<serde_json::Value>(details_str)
{
return Some(
serde_json::json!({
"jsonrpc": "2.0",
@@ -78,8 +80,6 @@ pub async fn handle_socket(socket: WebSocket, state: Arc<AppState>, _client_type
.to_string(),
);
}
}
}
None
})
.take(5)
@@ -262,7 +262,10 @@ mod tests {
mem_state.broadcast_task_event(task_event.clone());
// 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_eq!(recorded[0]["category"], "TASK_EVENT");
@@ -293,7 +296,9 @@ mod tests {
.filter_map(|act_val| {
if act_val["category"] == "TASK_EVENT" {
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(
serde_json::json!({
"jsonrpc": "2.0",
@@ -315,7 +320,10 @@ mod tests {
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();
assert_eq!(parsed["method"], "notifications/task/completed");
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::path::PathBuf;
use std::sync::Arc;
use mcp_memory_server::router::MemoryHandler;
use mcp_memory_server::state::MemoryState;
#[tokio::main]
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 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)?;
if !wsl_dir.exists() {
@@ -41,38 +42,37 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
if let Ok(entries) = fs::read_dir(&win_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "json") {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
if !active_tools.contains(stem) {
if path.extension().is_some_and(|ext| ext == "json")
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
&& !active_tools.contains(stem) {
println!(" [STALE REMOVED Win] {}", path.display());
let _ = fs::remove_file(&path);
}
}
}
}
}
// Clean up stale JSON files in WSL directory
if let Ok(entries) = fs::read_dir(&wsl_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "json") {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
if !active_tools.contains(stem) {
if path.extension().is_some_and(|ext| ext == "json")
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
&& !active_tools.contains(stem) {
println!(" [STALE REMOVED WSL] {}", path.display());
let _ = fs::remove_file(&path);
}
}
}
}
}
// 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() {
let _ = fs::copy(&instructions_src, win_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);
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,8 +167,8 @@ pub fn init_redb(base: &Path) -> Arc<Database> {
);
} else {
let migrated_path = json_path.with_extension("json.migrated");
if let Err(_) = std::fs::rename(&json_path, &migrated_path) {
if migrated_path.exists() {
if std::fs::rename(&json_path, &migrated_path).is_err()
&& migrated_path.exists() {
let _ = std::fs::remove_file(&migrated_path);
let _ = std::fs::rename(&json_path, &migrated_path);
}
@@ -178,13 +178,11 @@ pub fn init_redb(base: &Path) -> Arc<Database> {
}
}
}
}
if opened_ok {
if let Err(e) = write_txn.commit() {
if opened_ok
&& let Err(e) = write_txn.commit() {
tracing::error!("Failed to commit database migration transaction: {}", e);
}
}
}
Err(e) => {
tracing::error!(
"Failed to begin write transaction for redb migration: {}",
+13 -3
View File
@@ -17,7 +17,11 @@ pub enum AppError {
EntityNotFound { name: String, namespace: String },
#[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}")]
DatabaseError { key: String, message: String },
@@ -50,11 +54,17 @@ impl IntoResponse for AppError {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
AppError::EntityNotFound { name, namespace } => (
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 } => (
StatusCode::BAD_REQUEST,
format!("Invalid relation between '{}' and '{}': {}", from, to, reason),
format!(
"Invalid relation between '{}' and '{}': {}",
from, to, reason
),
),
AppError::DatabaseError { key, message } => (
StatusCode::INTERNAL_SERVER_ERROR,
+4 -6
View File
@@ -119,11 +119,10 @@ impl McpTool for ReadFileSkeletonHandler {
header.push('\n');
}
}
if header.is_empty() {
if let Some(first_line) = node_text.lines().next() {
if header.is_empty()
&& let Some(first_line) = node_text.lines().next() {
header = first_line.to_string();
}
}
out.push_str(&indent);
out.push_str(header.trim());
@@ -425,11 +424,10 @@ fn scan_workspace_for_symbol(
if scanned_files > 500 {
break;
}
if let Ok(meta) = std::fs::metadata(path) {
if meta.len() > 1024 * 1024 {
if let Ok(meta) = std::fs::metadata(path)
&& meta.len() > 1024 * 1024 {
continue;
}
}
if let Ok(content) = std::fs::read_to_string(path) {
for (line_num, line) in content.lines().enumerate() {
let is_match = if filter_fn_call {
+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> {
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 {
EnvAction::UpdateFingerprint => {
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_shell = req.shell
let detected_shell = req
.shell
.or_else(|| std::env::var("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(|| if std::env::var("COMSPEC").is_ok() { Some("cmd".to_string()) } else { None })
.or_else(|| {
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());
state.env.env_fingerprints.modify(|fps| {
@@ -102,10 +117,7 @@ impl McpTool for EnvironmentHandler {
}
EnvAction::GetDetails => {
let data = state.env.environments.read_with(|envs| {
let filtered: Vec<_> = envs
.iter()
.filter(|e| e.namespace == ns)
.collect();
let filtered: Vec<_> = envs.iter().filter(|e| e.namespace == ns).collect();
Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?)
})?;
Ok(data)
@@ -114,7 +126,6 @@ impl McpTool for EnvironmentHandler {
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -143,7 +154,10 @@ mod tests {
assert_eq!(res, "Env fingerprint updated");
let res2 = handler
.execute(json!({"action": "read_fingerprint", "namespace": "global"}), state.clone())
.execute(
json!({"action": "read_fingerprint", "namespace": "global"}),
state.clone(),
)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
@@ -171,7 +185,10 @@ mod tests {
let handler = EnvironmentHandler;
let res = handler
.execute(json!({"action": "get_details", "namespace": "global"}), state.clone())
.execute(
json!({"action": "get_details", "namespace": "global"}),
state.clone(),
)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
@@ -215,7 +232,10 @@ mod tests {
assert_eq!(res2, "Environment registered");
let res3 = handler
.execute(json!({"action": "get_details", "namespace": "global"}), state.clone())
.execute(
json!({"action": "get_details", "namespace": "global"}),
state.clone(),
)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
@@ -237,12 +257,8 @@ mod tests {
}
});
let res = handler
.execute(args, state.clone())
.await;
let res = handler.execute(args, state.clone()).await;
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 mut results = Vec::new();
let cwd = env::current_dir().unwrap_or_default();
if let Ok(repo) = git2::Repository::discover(&cwd) {
if let Ok(mut revwalk) = repo.revwalk() {
if let Ok(repo) = git2::Repository::discover(&cwd)
&& let Ok(mut revwalk) = repo.revwalk() {
let _ = revwalk.push_head();
let mut count = 0;
for oid in revwalk.flatten() {
@@ -161,7 +161,6 @@ impl McpTool for QueryGitDiffsHandler {
}
}
}
}
results
})
.await
@@ -215,4 +214,3 @@ mod tests {
assert!(parsed.get("branch").is_some() || parsed.is_object());
}
}
+145 -61
View File
@@ -62,8 +62,8 @@ impl<'a> GraphQueryBuilder<'a> {
break;
}
nodes_at_current_depth -= 1;
if current_depth < self.max_depth {
if let Some(neighbors) = adj.get(current) {
if current_depth < self.max_depth
&& let Some(neighbors) = adj.get(current) {
for &(neighbor, rel_type, is_inverse) in neighbors {
if !visited.contains(neighbor) {
visited.insert(neighbor);
@@ -73,7 +73,6 @@ impl<'a> GraphQueryBuilder<'a> {
}
}
}
}
if nodes_at_current_depth == 0 {
current_depth += 1;
nodes_at_current_depth = nodes_at_next_depth;
@@ -138,7 +137,9 @@ impl McpTool for QueryGraphPathHandler {
})
})
.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() {
let idx = state.get_search_index().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 {
name: node_name.clone(),
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(),
git_branch: None,
..Default::default()
@@ -247,16 +257,28 @@ impl McpTool for CreateRelationsHandler {
}
for mut relation in req.relations {
if !relation.from.is_empty() && !relation.to.is_empty() {
relation.relation_type = crate::models::normalize_relation_type(&relation.relation_type);
added_relations.push(format!("{} -[{}]-> {}", relation.from, relation.relation_type, relation.to));
relation.relation_type =
crate::models::normalize_relation_type(&relation.relation_type);
added_relations.push(format!(
"{} -[{}]-> {}",
relation.from, relation.relation_type, relation.to
));
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() {
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)
}
@@ -462,7 +484,7 @@ impl McpTool for ReadGraphHandler {
let filtered_entities: Vec<(&String, &crate::models::Entity)> = full
.entities
.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();
let total_count = filtered_entities.len();
@@ -481,7 +503,7 @@ impl McpTool for ReadGraphHandler {
.relations
.iter()
.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))
})
.collect();
@@ -499,7 +521,7 @@ impl McpTool for ReadGraphHandler {
let rel_vec: Vec<&crate::models::Relation> = full
.relations
.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();
(ent_map, rel_vec, None)
};
@@ -549,21 +571,19 @@ impl McpTool for ReadGraphHandler {
}
});
if let Some(meta) = &meta_json {
if let Some(obj) = truncated_val.as_object_mut() {
if let Some(meta) = &meta_json
&& let Some(obj) = truncated_val.as_object_mut() {
obj.insert("total_count".to_string(), meta["total_count"].clone());
obj.insert("offset".to_string(), meta["offset"].clone());
obj.insert("limit".to_string(), meta["limit"].clone());
obj.insert("has_more".to_string(), serde_json::Value::Bool(true));
}
}
if let Ok(trunc_str) = serde_json::to_string(&truncated_val) {
if trunc_str.len() <= max_chars || keep_count == 1 {
if let Ok(trunc_str) = serde_json::to_string(&truncated_val)
&& (trunc_str.len() <= max_chars || keep_count == 1) {
result_json = trunc_str;
break;
}
}
keep_count = keep_count.saturating_sub(keep_count / 3 + 1);
}
}
@@ -820,7 +840,10 @@ impl McpTool for CondenseEntityHandler {
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| {
@@ -953,10 +976,9 @@ impl McpTool for GetSubgraphHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: GetSubgraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let root = req
.root_entity
.or(req.root_node)
.ok_or_else(|| crate::error::AppError::Internal("root_entity or root_node is required".to_string()))?;
let root = req.root_entity.or(req.root_node).ok_or_else(|| {
crate::error::AppError::Internal("root_entity or root_node is required".to_string())
})?;
let depth = req.depth.unwrap_or(2);
let format = req.format.unwrap_or(SubgraphFormat::Json);
@@ -974,8 +996,8 @@ impl McpTool for GetSubgraphHandler {
visited.insert(root.clone());
while let Some((curr, d)) = queue.pop_front() {
if d < depth {
if let Some(neighbors) = adj.get(curr) {
if d < depth
&& let Some(neighbors) = adj.get(curr) {
for &next in neighbors {
if visited.insert(next.to_string()) {
queue.push_back((next, d + 1));
@@ -983,7 +1005,6 @@ impl McpTool for GetSubgraphHandler {
}
}
}
}
let ent: std::collections::HashMap<String, crate::models::Entity> = visited
.iter()
@@ -1011,7 +1032,10 @@ impl McpTool for GetSubgraphHandler {
markdown.push_str("\n## Relations\n");
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);
}
@@ -1038,7 +1062,10 @@ impl McpTool for SweepGraphHealthHandler {
}
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> {
@@ -1132,26 +1159,31 @@ impl McpTool for ResolveStaleSymbolsHandler {
}
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> {
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 mut checked = 0;
let mut stale_entities = Vec::new();
state.modify_graph(|g| {
for (name, _entity) in g.entities.iter_mut() {
if let Some(target) = &req.target {
if name != target && !name.contains(target) {
for name in g.entities.keys() {
if let Some(target) = &req.target
&& name != target && !name.contains(target) {
continue;
}
}
checked += 1;
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());
}
}
@@ -1184,7 +1216,10 @@ impl McpTool for SummarizeSubgraphHandler {
}
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> {
@@ -1212,8 +1247,18 @@ impl McpTool for SummarizeSubgraphHandler {
}
}
let ent: Vec<_> = g.entities.values().filter(|e| visited.contains(&e.name)).cloned().collect();
let rel: Vec<_> = g.relations.iter().filter(|r| visited.contains(&r.from) && visited.contains(&r.to)).cloned().collect();
let ent: Vec<_> = g
.entities
.values()
.filter(|e| visited.contains(&e.name))
.cloned()
.collect();
let rel: Vec<_> = g
.relations
.iter()
.filter(|r| visited.contains(&r.from) && visited.contains(&r.to))
.cloned()
.collect();
(ent, rel)
});
@@ -1228,7 +1273,10 @@ impl McpTool for SummarizeSubgraphHandler {
markdown.push_str("\n## Relations\n");
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);
@@ -1249,7 +1297,6 @@ mod tests {
use crate::handlers::meta::AgentSignalsHandler;
use serde_json::json;
#[tokio::test]
async fn test_create_and_read_entities() {
let state = Arc::new(MemoryState::new_in_memory());
@@ -1324,10 +1371,7 @@ mod tests {
{"source": "A", "target": "B", "relationType": "knows"}
]
});
let alias_res = handler
.execute(alias_args, state.clone())
.await
.unwrap();
let alias_res = handler.execute(alias_args, state.clone()).await.unwrap();
assert!(alias_res.contains("Successfully created 1 relation(s)"));
// Test semantic LLM schema feedback on missing fields
@@ -1499,7 +1543,10 @@ mod tests {
{"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 args_rel = json!({
@@ -1510,48 +1557,86 @@ mod tests {
rel_handler.execute(args_rel, state.clone()).await.unwrap();
let path_handler = QueryGraphPathHandler;
let path_res = path_handler.execute(json!({"start_node": "Alpha", "end_node": "Beta"}), state.clone()).await.unwrap();
let path_res = path_handler
.execute(
json!({"start_node": "Alpha", "end_node": "Beta"}),
state.clone(),
)
.await
.unwrap();
assert!(path_res.contains("Alpha"));
let sub_handler = GetSubgraphHandler;
let sub_res = sub_handler.execute(json!({"root_entity": "Alpha"}), state.clone()).await.unwrap();
let sub_res = sub_handler
.execute(json!({"root_entity": "Alpha"}), state.clone())
.await
.unwrap();
assert!(sub_res.contains("Alpha"));
let sum_handler = SummarizeSubgraphHandler;
let sum_res = sum_handler.execute(json!({"root_entity": "Alpha"}), state.clone()).await.unwrap();
let sum_res = sum_handler
.execute(json!({"root_entity": "Alpha"}), state.clone())
.await
.unwrap();
assert!(!sum_res.is_empty());
let sweep_handler = SweepGraphHealthHandler;
let sweep_res = sweep_handler.execute(json!({}), state.clone()).await.unwrap();
let sweep_res = sweep_handler
.execute(json!({}), state.clone())
.await
.unwrap();
assert!(!sweep_res.is_empty());
let del_obs_handler = DeleteObservationsHandler;
let del_obs_res = del_obs_handler.execute(json!({
let del_obs_res = del_obs_handler
.execute(
json!({
"deletions": [{"entity_name": "Alpha", "observations": ["Obs1"]}]
}), state.clone()).await.unwrap();
}),
state.clone(),
)
.await
.unwrap();
assert_eq!(del_obs_res, "Observations deleted");
let del_rel_handler = DeleteRelationsHandler;
let del_rel_res = del_rel_handler.execute(json!({
let del_rel_res = del_rel_handler
.execute(
json!({
"relations": [{"from": "Alpha", "to": "Beta", "relation_type": "links_to"}]
}), state.clone()).await.unwrap();
}),
state.clone(),
)
.await
.unwrap();
assert_eq!(del_rel_res, "Relations deleted");
let bcast_handler = AgentSignalsHandler;
let bcast_res = bcast_handler.execute(json!({
let bcast_res = bcast_handler
.execute(
json!({
"action": "broadcast",
"sender": "agent1",
"signal_type": "task_completed",
"payload": "fix_bug"
}), state.clone()).await.unwrap();
}),
state.clone(),
)
.await
.unwrap();
assert!(bcast_res.contains("Broadcasted signal"));
let qsignal_handler = AgentSignalsHandler;
let qsignal_res = qsignal_handler.execute(json!({
let qsignal_res = qsignal_handler
.execute(
json!({
"action": "query",
"sender": "agent1"
}), state.clone()).await.unwrap();
}),
state.clone(),
)
.await
.unwrap();
assert!(qsignal_res.contains("task_completed"));
let read_paged_handler = ReadGraphHandler;
let paged_res = read_paged_handler
@@ -1563,4 +1648,3 @@ mod tests {
assert_eq!(parsed_paged["has_more"], true);
}
}
+6 -4
View File
@@ -38,7 +38,8 @@ impl McpTool for ProcessLogsHandler {
}
ProcessLogAction::Get => {
let max_lines = tool_args.max_lines.unwrap_or(100);
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
let result =
tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
let mut file = File::open(&safe_path).map_err(|e| {
crate::error::AppError::Internal(format!("Failed to open file: {}", e))
})?;
@@ -49,8 +50,7 @@ impl McpTool for ProcessLogsHandler {
.map_err(|e| e.to_string())?;
let mut vec_buf = Vec::new();
file.read_to_end(&mut vec_buf)
.map_err(|e| e.to_string())?;
file.read_to_end(&mut vec_buf).map_err(|e| e.to_string())?;
let buffer = String::from_utf8_lossy(&vec_buf).to_string();
let lines: Vec<&str> = buffer.lines().collect();
@@ -63,7 +63,9 @@ impl McpTool for ProcessLogsHandler {
Ok(recent_lines)
})
.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)
}
+119 -88
View File
@@ -110,11 +110,10 @@ impl McpTool for SearchErrorFixesHandler {
} else if st_lower.contains(&sol_lower) || sol_lower.contains(&st_lower) {
score += 0.5;
}
if let Some(emb) = &f.embedding {
if !query_emb.is_empty() {
if let Some(emb) = &f.embedding
&& !query_emb.is_empty() {
score += crate::embedding::cosine_similarity(&query_emb, emb);
}
}
(f, score)
})
.filter(|(_, score)| *score > 0.1)
@@ -198,11 +197,10 @@ impl McpTool for LogCodeChangeHandler {
if let Some(range) = &req.line_range {
description = format!("{} [Line Range: {}]", description, range);
}
if let Some(symbols) = &req.symbol_references {
if !symbols.is_empty() {
if let Some(symbols) = &req.symbol_references
&& !symbols.is_empty() {
description = format!("{} [Symbols: {}]", description, symbols.join(", "));
}
}
if state.ollama.is_available().await {
let prompt = format!(
@@ -323,7 +321,8 @@ impl McpTool for QueryRecentChangesHandler {
}
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 =
serde_json::from_value(args).unwrap_or(QueryRecentChangesTool {
namespace: None,
repo_name: None,
vcs_type: None,
@@ -337,25 +336,22 @@ impl McpTool for QueryRecentChangesHandler {
.iter()
.rev()
.filter(|c| {
if let Some(ns) = &req.namespace {
if !c.namespace.eq_ignore_ascii_case(ns) {
if let Some(ns) = &req.namespace
&& !c.namespace.eq_ignore_ascii_case(ns) {
return false;
}
}
if let Some(repo) = &req.repo_name {
if c.repo_name
if let Some(repo) = &req.repo_name
&& c.repo_name
.as_ref()
.map(|rn| !rn.eq_ignore_ascii_case(repo))
.unwrap_or(true)
{
return false;
}
}
if let Some(vcs) = &req.vcs_type {
if !c.effective_vcs().eq_ignore_ascii_case(vcs) {
if let Some(vcs) = &req.vcs_type
&& !c.effective_vcs().eq_ignore_ascii_case(vcs) {
return false;
}
}
true
})
.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 ns = req
.namespace
.unwrap_or_else(|| crate::models::default_namespace());
.unwrap_or_else(crate::models::default_namespace);
match req.action {
DecisionAction::Log => {
let title = req.title.ok_or_else(|| {
@@ -464,14 +460,14 @@ impl McpTool for DecisionsHandler {
state.code.adrs.modify(|adrs| {
let target_pos = adrs.iter().position(|a| a.id.eq_ignore_ascii_case(&id));
if let Some(pos) = target_pos {
if let Some(superseded_id) = &req.supersedes {
if let Some(s_pos) = adrs.iter().position(|a| a.id.eq_ignore_ascii_case(superseded_id)) {
if s_pos != pos {
if let Some(superseded_id) = &req.supersedes
&& let Some(s_pos) = adrs
.iter()
.position(|a| a.id.eq_ignore_ascii_case(superseded_id))
&& s_pos != pos {
adrs[s_pos].status = "superseded".to_string();
adrs_to_index.push(adrs[s_pos].clone());
}
}
}
let a = &mut adrs[pos];
if let Some(t) = req.title {
@@ -488,7 +484,9 @@ impl McpTool for DecisionsHandler {
}
if let Some(s) = req.status {
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());
} else if s_lower != "implemented" && s_lower != "resolved" {
a.resolved_at = None;
@@ -535,11 +533,15 @@ impl McpTool for DecisionsHandler {
&format!("Updated {}: {}", adr.id, adr.title),
Some(&adr.status),
);
Ok(format!("Updated decision {}: {} (status: {})", adr.id, adr.title, adr.status))
} else {
Err(crate::error::AppError::Internal(
format!("Decision with id '{}' not found", id),
Ok(format!(
"Updated decision {}: {} (status: {})",
adr.id, adr.title, adr.status
))
} else {
Err(crate::error::AppError::Internal(format!(
"Decision with id '{}' not found",
id
)))
}
}
DecisionAction::Query => {
@@ -554,9 +556,17 @@ impl McpTool for DecisionsHandler {
}
if let Some(q) = &req.query {
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(&a.decision, q)
|| crate::handlers::utils::contains_ignore_ascii_case(&a.consequence, q)
|| crate::handlers::utils::contains_ignore_ascii_case(
&a.context, q,
)
|| crate::handlers::utils::contains_ignore_ascii_case(
&a.decision,
q,
)
|| crate::handlers::utils::contains_ignore_ascii_case(
&a.consequence,
q,
)
} else {
true
}
@@ -632,17 +642,14 @@ impl McpTool for TechDebtHandler {
let req: TechDebtTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let ns = req
.namespace
.unwrap_or_else(|| crate::models::default_namespace());
.unwrap_or_else(crate::models::default_namespace);
match req.action {
TechDebtAction::Log => {
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())
})?;
let ideal = req.ideal_solution.unwrap_or_default();
let text_to_embed = format!(
"Description: {}\nIdeal Solution: {}",
desc, ideal
);
let text_to_embed = format!("Description: {}\nIdeal Solution: {}", desc, ideal);
let embedding = crate::embedding::generate_embedding_async(text_to_embed)
.await
.ok();
@@ -665,18 +672,21 @@ impl McpTool for TechDebtHandler {
effort_estimate: req.effort_estimate,
});
if debts.len() > 300 {
let severity_rank = |sev: Option<&str>| match sev.unwrap_or("").to_lowercase().as_str() {
let severity_rank =
|sev: Option<&str>| match sev.unwrap_or("").to_lowercase().as_str() {
"critical" => 4,
"high" => 3,
"medium" => 2,
"low" => 1,
_ => 1,
};
if let Some((idx_to_remove, _)) = debts.iter().enumerate().min_by_key(|(_, d)| {
if let Some((idx_to_remove, _)) =
debts.iter().enumerate().min_by_key(|(_, d)| {
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);
}
}
@@ -712,9 +722,7 @@ impl McpTool for TechDebtHandler {
let data = state.code.tech_debts.read_with(|debts| {
let filtered: Vec<_> = debts
.iter()
.filter(|d| {
d.namespace == ns && (inc || !d.is_resolved)
})
.filter(|d| d.namespace == ns && (inc || !d.is_resolved))
.map(|d| match level {
"compact" => serde_json::json!({
"id": d.id,
@@ -811,7 +819,8 @@ impl McpTool for OmniSearchHandler {
let mut ranked_items: Vec<_> = rrf_scores.into_values().collect();
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 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) = serde_json::from_str(&graph_content) {
if let Ok(graph_content) = std::fs::read_to_string(target_dir.join("graph.json"))
&& let Ok(graph) = serde_json::from_str(&graph_content) {
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) = serde_json::from_str(&tasks_content) {
if let Ok(tasks_content) = std::fs::read_to_string(target_dir.join("tasks.json"))
&& let Ok(tasks) = serde_json::from_str(&tasks_content) {
state.project.tasks.modify(|t| *t = tasks);
}
}
if let Ok(debts_content) =
std::fs::read_to_string(target_dir.join("tech_debts.json"))
{
if let Ok(debts) = serde_json::from_str(&debts_content) {
&& let Ok(debts) = serde_json::from_str(&debts_content) {
state.code.tech_debts.modify(|d| *d = debts);
}
}
Ok(format!("Checkpoint '{}' restored successfully.", name))
}
@@ -1290,13 +1295,12 @@ impl McpTool for ManageCheckpointHandler {
let checkpoints_dir = state.base_dir.join("checkpoints");
if let Ok(entries) = std::fs::read_dir(&checkpoints_dir) {
for entry in entries.flatten() {
if entry.path().is_dir() {
if let Some(n) = entry.file_name().to_str() {
if entry.path().is_dir()
&& let Some(n) = entry.file_name().to_str() {
list.push(serde_json::json!({"type": "checkpoint", "name": n}));
}
}
}
}
let snaps = state.project.snapshots.read_with(|snaps| snaps.clone());
for s in snaps {
list.push(serde_json::json!({"type": "snapshot", "id": s.id, "description": s.description, "namespace": s.namespace}));
@@ -1461,13 +1465,11 @@ impl McpTool for GetNextActionableTasksHandler {
continue;
}
if let Some(branch) = &req.git_branch {
if let Some(tb) = &task.git_branch {
if tb != branch {
if let Some(branch) = &req.git_branch
&& let Some(tb) = &task.git_branch
&& tb != branch {
continue;
}
}
}
let unblocked = task.dependencies.is_empty()
|| task.dependencies.iter().all(|d| completed_ids.contains(d));
@@ -1509,7 +1511,9 @@ impl McpTool for HypothesesHandler {
match req.action {
HypothesisAction::Log => {
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!(
"HYP-{}",
@@ -1536,17 +1540,16 @@ impl McpTool for HypothesesHandler {
let filtered: Vec<_> = hypotheses
.into_iter()
.filter(|h| {
if let Some(tid) = &req.task_id {
if h.task_id.as_ref() != Some(tid) {
if let Some(tid) = &req.task_id
&& h.task_id.as_ref() != Some(tid) {
return false;
}
}
if let Some(q) = &req.query {
let lq = q.to_lowercase();
return h.hypothesis.to_lowercase().contains(&lq)
|| h.evidence
.as_ref()
.map_or(false, |e| e.to_lowercase().contains(&lq));
.is_some_and(|e| e.to_lowercase().contains(&lq));
}
true
})
@@ -1656,13 +1659,19 @@ impl McpTool for AgentSignalsHandler {
match req.action {
AgentSignalAction::Broadcast => {
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(|| {
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(|| {
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()
@@ -1715,25 +1724,21 @@ impl McpTool for AgentSignalsHandler {
signals
.iter()
.filter(|s| {
if let Some(ttl) = s.ttl_seconds {
if now > s.timestamp + ttl {
if let Some(ttl) = s.ttl_seconds
&& now > s.timestamp + ttl {
return false;
}
}
if let Some(sender) = &req.sender {
if s.sender.to_lowercase() != sender.to_lowercase() {
if let Some(sender) = &req.sender
&& s.sender.to_lowercase() != sender.to_lowercase() {
return false;
}
}
if let Some(st) = &req.signal_type {
if s.signal_type.to_lowercase() != st.to_lowercase() {
if let Some(st) = &req.signal_type
&& s.signal_type.to_lowercase() != st.to_lowercase() {
return false;
}
}
true
})
.cloned()
.take(req.limit.unwrap_or(20))
.take(req.limit.unwrap_or(20)).cloned()
.collect::<Vec<_>>()
});
@@ -2110,7 +2115,10 @@ mod tests {
assert!(q_dec_res.contains("Fast async API routing"));
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
.unwrap();
assert!(q_by_consequence.contains("Use Axum"));
@@ -2133,7 +2141,10 @@ mod tests {
assert!(update_res.contains("implemented"));
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
.unwrap();
assert!(q_after_update.contains("implemented"));
@@ -2143,7 +2154,10 @@ mod tests {
assert!(q_after_update.contains("resolved_at"));
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;
assert!(del_dec_res.is_ok());
@@ -2163,14 +2177,20 @@ mod tests {
assert!(td_res.contains("Tech debt logged"));
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
.unwrap();
assert!(list_td_res.contains("Replace unwraps"));
let debt_id = state.code.tech_debts.read_with(|debts| debts[0].id.clone());
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;
assert!(res_td_res.is_ok());
@@ -2394,7 +2414,10 @@ mod tests {
)
.await
.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
.execute(serde_json::json!({"action": "list"}), state.clone())
@@ -2607,7 +2630,10 @@ mod tests {
// DeleteDecision non-existent
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;
assert!(del_dec_err.is_err());
@@ -2882,9 +2908,14 @@ mod tests {
state.code.tech_debts.read_with(|debts| {
assert_eq!(debts.len(), 300);
assert!(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");
assert!(
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 vision;
pub mod workspaces;
+27 -11
View File
@@ -29,7 +29,9 @@ impl McpTool for HandoffMemosHandler {
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())
})?;
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| {
memos.push(crate::models::HandoffMemo {
id: uuid::Uuid::new_v4().to_string(),
@@ -109,11 +111,13 @@ impl McpTool for AddSessionSummaryHandler {
});
if summaries.len() > 200 {
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, _)) =
summaries.iter().enumerate().min_by_key(|(_, s)| {
let is_expired = s.expires_at.map(|exp| exp < now).unwrap_or(false);
let status_score = if is_expired { 0 } else { 1 };
(status_score, s.timestamp)
}) {
})
{
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 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| {
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| {
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!({
@@ -178,8 +194,7 @@ impl McpTool for PromoteToEntityHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: PromoteToEntityTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: PromoteToEntityTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let entity_name = req.entity_name.clone();
let obs = req.content.clone();
@@ -236,7 +251,10 @@ mod tests {
assert_eq!(res, "Handoff memo left.");
let res2 = handoff_handler
.execute(json!({"action": "read", "namespace": "global"}), state.clone())
.execute(
json!({"action": "read", "namespace": "global"}),
state.clone(),
)
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
@@ -266,5 +284,3 @@ mod tests {
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'] {
let mut cursor = i + 3;
// Skip optional '-', ':', or whitespace
while cursor < len && (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ') {
while cursor < len
&& (chars[cursor] == '-' || chars[cursor] == ':' || chars[cursor] == ' ')
{
cursor += 1;
}
// Collect consecutive digits
@@ -80,11 +82,17 @@ pub fn extract_tech_debts(text: &str) -> Vec<String> {
while i < len {
if i + 2 <= len && lower_chars[i..i + 2] == ['t', 'd'] {
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;
}
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;
}
if cursor > start {
@@ -117,11 +125,17 @@ pub fn extract_tasks(text: &str) -> Vec<String> {
while i < len {
if i + 4 <= len && lower_chars[i..i + 4] == ['t', 'a', 's', 'k'] {
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;
}
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;
}
if cursor > start {
@@ -162,8 +176,7 @@ pub fn cascade_unblock_tasks(state: &MemoryState) -> Vec<String> {
} else {
let all_deps_completed = t.dependencies.iter().all(|dep_id| {
tasks_snapshot.iter().any(|other| {
(other.id == *dep_id || other.title == *dep_id)
&& !other.is_active()
(other.id == *dep_id || other.title == *dep_id) && !other.is_active()
})
});
if all_deps_completed {
@@ -226,20 +239,18 @@ pub fn recalculate_milestones(state: &MemoryState) -> Vec<String> {
}
updated.push(m.id.clone());
}
} else if completed > 0
} else if (completed > 0
|| tasks_snapshot.iter().any(|t| {
m.task_ids.iter().any(|tid| tid == &t.id || tid == &t.title)
&& (t.status == "in_progress" || t.status == "active")
})
{
if !m.status.eq_ignore_ascii_case("in_progress")
}))
&& !m.status.eq_ignore_ascii_case("in_progress")
&& !m.status.eq_ignore_ascii_case("completed")
{
m.status = "in_progress".to_string();
updated.push(m.id.clone());
}
}
}
});
for mid in &updated {
@@ -275,11 +286,10 @@ pub async fn reconcile_commit_or_code_change(
if adr.id.eq_ignore_ascii_case(cand) {
return true;
}
if let (Some(n1), Some(n2)) = (adr_numeric_id(&adr.id), adr_numeric_id(cand)) {
if n1 == n2 {
if let (Some(n1), Some(n2)) = (adr_numeric_id(&adr.id), adr_numeric_id(cand))
&& n1 == n2 {
return true;
}
}
false
});
@@ -324,7 +334,9 @@ pub async fn reconcile_commit_or_code_change(
if d.is_resolved {
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
.map(|fp| d.file_path.as_deref() == Some(fp))
.unwrap_or(false)
+5 -5
View File
@@ -366,7 +366,7 @@ impl McpTool for TasksHandler {
let mut updated_task: Option<Task> = None;
state.project.tasks.modify(|tasks| {
if let Some(task) = tasks.iter_mut().find(|t| t.id == id || t.title == id) {
if let Some(ac) = task.acceptance_criteria.iter_mut().next() {
if let Some(ac) = task.acceptance_criteria.first_mut() {
if ac.is_met {
already_met = true;
} else {
@@ -444,7 +444,7 @@ impl McpTool for MilestonesHandler {
})?;
let ns = req
.namespace
.unwrap_or_else(|| crate::models::default_namespace());
.unwrap_or_else(crate::models::default_namespace);
state.project.milestones.modify(|ms| {
ms.push(crate::models::Milestone {
id: uuid::Uuid::new_v4().to_string(),
@@ -477,11 +477,11 @@ impl McpTool for MilestonesHandler {
for m in ms.iter_mut() {
if m.id == id {
m.status = status.clone();
if status.eq_ignore_ascii_case("completed") || status.eq_ignore_ascii_case("done") {
if m.completed_at.is_none() {
if (status.eq_ignore_ascii_case("completed")
|| status.eq_ignore_ascii_case("done"))
&& m.completed_at.is_none() {
m.completed_at = Some(crate::handlers::utils::now_secs());
}
}
if let Some(task_ids) = &req.task_ids {
m.task_ids = task_ids.clone();
if let Some(deliverables) = &req.deliverables {
+45 -29
View File
@@ -191,11 +191,10 @@ pub fn html_to_markdown(html: &str) -> String {
out.push_str("* ");
}
}
"/li" => {
if !in_cell {
"/li"
if !in_cell => {
out.push('\n');
}
}
_ => {}
}
} 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");
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) => {
if let Ok(bytes) = resp.bytes().await {
if let Err(e) = std::fs::write(&det_path, &bytes) {
tracing::error!("Failed to write text-detection.rten to disk: {}", e);
}
} 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),
}
}
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 {
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) => {
if let Ok(bytes) = resp.bytes().await {
if let Err(e) = std::fs::write(&rec_path, &bytes) {
tracing::error!("Failed to write text-recognition.rten to disk: {}", e);
}
} 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),
}
}
@@ -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> {
let rgb_img = img.to_rgb8();
let img_source = ocrs::ImageSource::from_bytes(
rgb_img.as_raw(),
rgb_img.dimensions(),
).ok()?;
let img_source = ocrs::ImageSource::from_bytes(rgb_img.as_raw(), rgb_img.dimensions()).ok()?;
let ocr_input = engine.prepare_input(img_source).ok()?;
let word_rects = engine.detect_words(&ocr_input).ok()?;
@@ -482,9 +486,8 @@ pub async fn process_clipboard_snapshot(state: Arc<MemoryState>) {
if let Some(engine) = ensure_ocr_engine().await {
let img_clone = img.clone();
ocr_text_opt = tokio::task::spawn_blocking(move || {
perform_ocrs_ocr(engine, &img_clone)
})
ocr_text_opt =
tokio::task::spawn_blocking(move || perform_ocrs_ocr(engine, &img_clone))
.await
.ok()
.flatten();
@@ -563,11 +566,10 @@ pub async fn process_clipboard_snapshot(state: Arc<MemoryState>) {
};
let mut cache = state.clipboard_cache.write().await;
if let Some(ref prev) = cache.last_text {
if prev.text == cached.text {
if let Some(ref prev) = cache.last_text
&& prev.text == cached.text {
return;
}
}
cache.last_text = Some(cached.clone());
cache
.history
@@ -669,7 +671,7 @@ pub fn spawn_clipboard_listener(state: Arc<MemoryState>) {
tokio::spawn(async move {
while let Some(()) = rx.recv().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;
}
@@ -843,8 +845,8 @@ impl McpTool for ClipboardHandler {
ClipboardAction::Read => {
let engine = ensure_ocr_engine().await;
let out =
tokio::task::spawn_blocking(move || -> crate::error::Result<serde_json::Map<String, Value>> {
let out = tokio::task::spawn_blocking(
move || -> crate::error::Result<serde_json::Map<String, Value>> {
let mut out = serde_json::Map::new();
if let Some(text) = get_native_clipboard_text() {
@@ -860,8 +862,19 @@ impl McpTool for ClipboardHandler {
let rgb_img = img.into_rgb8();
let mut jpeg_bytes = std::io::Cursor::new(Vec::new());
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg_bytes, 88);
if encoder.encode(&rgb_img, rgb_img.width(), rgb_img.height(), image::ExtendedColorType::Rgb8).is_ok() {
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(
&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 cache_dir = dirs::home_dir()
@@ -877,14 +890,17 @@ impl McpTool for ClipboardHandler {
}
}
if let Some(eng) = engine {
if let Some(ocr_text) = perform_ocrs_ocr(eng, &dynamic_img) {
out.insert("image_analysis".to_string(), json!(ocr_text.trim()));
}
if let Some(eng) = engine
&& let Some(ocr_text) = perform_ocrs_ocr(eng, &dynamic_img) {
out.insert(
"image_analysis".to_string(),
json!(ocr_text.trim()),
);
}
}
Ok(out)
})
},
)
.await
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
+22 -14
View File
@@ -6,14 +6,6 @@ use async_trait::async_trait;
use serde_json::Value;
use std::sync::Arc;
pub struct SnippetsHandler;
#[async_trait]
@@ -57,7 +49,7 @@ impl McpTool for SnippetsHandler {
embedding,
namespace: req
.namespace
.unwrap_or_else(|| crate::models::default_namespace()),
.unwrap_or_else(crate::models::default_namespace),
repo_name: req.repo_name,
origin_file: req.origin_file,
line_range: req.line_range,
@@ -89,7 +81,8 @@ impl McpTool for SnippetsHandler {
let mut scored: Vec<_> = snippets
.into_iter()
.filter_map(|s| {
let text = format!("{} {} {} {}", s.name, s.description, s.language, s.code)
let text =
format!("{} {} {} {}", s.name, s.description, s.language, s.code)
.to_lowercase();
let mut score = 0.0;
for tok in &q_tokens {
@@ -143,9 +136,9 @@ impl McpTool for SnippetsHandler {
|| s.tags.iter().any(|t| contains_ignore_ascii_case(t, &query));
let matches_tags = tags_filter.is_empty()
|| tags_filter
.iter()
.any(|tf| s.tags.iter().any(|st| contains_ignore_ascii_case(st, tf)));
|| tags_filter.iter().any(|tf| {
s.tags.iter().any(|st| contains_ignore_ascii_case(st, tf))
});
matches_text && matches_tags
})
@@ -352,13 +345,28 @@ impl McpTool for SemanticCodeSearchHandler {
}
let mut out = format!("Semantic Search Results for '{}':\n", tool_args.query);
for res in results {
for res in &results {
out.push_str(&format!(
"- [{:.2}] {}: {}\n",
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)
}
}
+109 -25
View File
@@ -4,6 +4,7 @@
)]
pub mod api;
pub mod config;
pub mod db;
pub mod embedding;
pub mod error;
@@ -105,12 +106,11 @@ pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
state.project.tasks.read_with(|tasks| {
for t in tasks.iter() {
if let Some(exp) = t.expires_at {
if t.is_active() {
if let Some(exp) = t.expires_at
&& t.is_active() {
next_expiry = Some(next_expiry.map_or(exp, |curr| curr.min(exp)));
}
}
}
});
state.telemetry.handoff_memos.read_with(|memos| {
@@ -160,14 +160,13 @@ pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
let mut expired_tasks = Vec::new();
state.project.tasks.modify(|tasks| {
for t in tasks.iter_mut() {
if let Some(exp) = t.expires_at {
if exp <= now && t.is_active() {
if let Some(exp) = t.expires_at
&& exp <= now && t.is_active() {
t.status = "expired".to_string();
t.updated_at = now;
expired_tasks.push(t.id.clone());
}
}
}
});
for tid in expired_tasks {
@@ -249,8 +248,8 @@ pub async fn condense_graph_worker(state: Arc<MemoryState>) {
}
});
if let Some((content, names)) = snippet_condensation {
if !content.is_empty() {
if let Some((content, names)) = snippet_condensation
&& !content.is_empty() {
let name = format!("Snippet History {}", now);
state.modify_graph(|graph| {
graph.entities.insert(
@@ -273,6 +272,84 @@ pub async fn condense_graph_worker(state: Arc<MemoryState>) {
}
}
}
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();
}
}
});
}
}
}
}
}
pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>> {
@@ -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(ttl_sweeper_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::handlers::vision::spawn_clipboard_listener(Arc::clone(&state));
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
@@ -371,8 +449,8 @@ 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]) {
if json_payload.get("type").and_then(|t| t.as_str()) == Some("ping") {
let _ = socket.send_to(b"pong", addr).await;
} else if json_payload.get("type").and_then(|t| t.as_str()) == Some("gate_wait") {
if let (Some(action), Some(target)) = (
} else if json_payload.get("type").and_then(|t| t.as_str()) == Some("gate_wait")
&& let (Some(action), Some(target)) = (
json_payload.get("action").and_then(|a| a.as_str()),
json_payload.get("target").and_then(|t| t.as_str())
) {
@@ -381,16 +459,15 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
}
}
}
}
Ok(event) = event_rx.recv() => {
if event.topic == "gate:event" {
if let (Some(action), Some(target), Some(status)) = (
if event.topic == "gate:event"
&& let (Some(action), Some(target), Some(status)) = (
event.payload.get("action").and_then(|a| a.as_str()),
event.payload.get("target").and_then(|t| t.as_str()),
event.payload.get("status").and_then(|s| s.as_str()),
) {
if status == "authorized" || status == "blocked" {
if let Some(addr) = subscribers.remove(&(action.to_string(), target.to_string())) {
if (status == "authorized" || status == "blocked")
&& let Some(addr) = subscribers.remove(&(action.to_string(), target.to_string())) {
let response = if status == "authorized" { b"APPROVED" } else { b"REJECTED" };
let _ = socket.send_to(response, addr).await;
}
@@ -399,8 +476,6 @@ pub async fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::erro
}
}
}
}
}
});
let nvim_udp_state = Arc::clone(&app_state);
@@ -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_") {
let payload_val = serde_json::json!(&payload);
// 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(
crate::state::GenericEvent {
topic: format!("nvim:ui:{}", payload.event),
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)
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(
crate::state::GenericEvent {
topic: format!("nvim:ui:{}:{}", payload.event, req_id),
session_id: Some(payload.session_id.clone()),
payload: payload_val.clone(),
});
},
);
}
// 3. Correlated diff_id topics
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(
crate::state::GenericEvent {
topic: format!("nvim:ui:{}:{}", payload.event, diff_id),
session_id: Some(payload.session_id.clone()),
payload: payload_val.clone(),
});
},
);
// 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(
crate::state::GenericEvent {
topic: format!("nvim:ui:diff_decision:{}", diff_id),
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>> {
crate::config::load_mcp_config_env();
let _guard = init_logging("mcp-memory-server");
let cli = Cli::parse();
+9 -19
View File
@@ -4,18 +4,15 @@ use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum ChangeKind {
Added,
#[default]
Modified,
Deleted,
Renamed,
}
impl Default for ChangeKind {
fn default() -> Self {
ChangeKind::Modified
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CodeChange {
@@ -57,19 +54,17 @@ impl CodeChange {
if let Some(vcs) = &self.vcs_type {
return vcs.as_str();
}
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()) {
if let Some(rev) = self.revision.as_deref().or(self.git_commit.as_deref())
&& rev.starts_with('r') && rev[1..].chars().all(|c| c.is_ascii_digit()) {
return "svn";
}
}
if let Some(br) = self.branch.as_deref().or(self.git_branch.as_deref()) {
if br.eq_ignore_ascii_case("trunk")
if let Some(br) = self.branch.as_deref().or(self.git_branch.as_deref())
&& (br.eq_ignore_ascii_case("trunk")
|| br.starts_with("branches/")
|| br.starts_with("tags/")
|| br.starts_with("tags/"))
{
return "svn";
}
}
"git"
}
@@ -266,11 +261,10 @@ impl KnowledgeGraph {
let mut adj: HashMap<&'a str, Vec<(&'a str, &'a str, bool)>> =
HashMap::with_capacity(self.relations.len() * 2);
for rel in &self.relations {
if let Some(rf) = relation_filter {
if rel.relation_type != rf {
if let Some(rf) = relation_filter
&& rel.relation_type != rf {
continue;
}
}
adj.entry(rel.from.as_str()).or_default().push((
rel.to.as_str(),
rel.relation_type.as_str(),
@@ -398,7 +392,6 @@ fn default_adr_status() -> String {
"accepted".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ErrorFix {
pub signature: String,
@@ -420,7 +413,6 @@ pub struct ErrorFix {
pub toolchain: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SessionSummary {
pub timestamp: u64,
@@ -527,7 +519,6 @@ pub struct EnvironmentDetail {
pub ssh_host: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TechDebt {
pub id: String,
@@ -554,7 +545,6 @@ pub struct TechDebt {
pub effort_estimate: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GateRecord {
pub id: String,
+7 -1
View File
@@ -25,6 +25,8 @@ struct GenerateRequest<'a> {
images: Option<Vec<&'a str>>,
#[serde(skip_serializing_if = "Option::is_none")]
options: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
keep_alive: Option<&'a str>,
}
#[derive(Deserialize)]
@@ -36,6 +38,8 @@ struct GenerateResponse {
struct EmbeddingRequest<'a> {
model: &'a str,
prompt: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
keep_alive: Option<&'a str>,
}
#[derive(Deserialize)]
@@ -123,6 +127,7 @@ impl OllamaClient {
"num_ctx": 32768, // Massive context window win
"num_predict": 4096 // Give reasoning models plenty of output room
})),
keep_alive: Some("1h"),
};
let res = self
@@ -166,6 +171,7 @@ impl OllamaClient {
"num_ctx": 8192,
"num_predict": 1024
})),
keep_alive: Some("1h"),
};
let res = self
@@ -196,6 +202,7 @@ impl OllamaClient {
let body = EmbeddingRequest {
model: &self.embed_model,
prompt: text,
keep_alive: Some("1h"),
};
let res = self
@@ -256,4 +263,3 @@ async fn test_ollama_client_invalid_api_key() {
client.base_url = "http://invalid-api-key:11434".to_string();
assert!(!client.is_available().await);
}
+93 -25
View File
@@ -639,12 +639,19 @@ impl MemoryHandler {
if let Some(resource) = self.resources.get(uri) {
match resource.read(Arc::clone(&self.state)).await {
Ok(text) => {
let payload = serde_json::json!({
"contents": [{
let mut content_obj = serde_json::json!({
"uri": uri,
"mimeType": resource.mime_type().unwrap_or("application/json"),
"text": text
}]
"text": &text
});
if text.len() > 4000 {
content_obj["cacheControl"] =
serde_json::json!({"type": "ephemeral"});
}
let payload = serde_json::json!({
"contents": [content_obj]
});
Some(crate::mcp::success(id, payload))
}
@@ -756,8 +763,15 @@ impl MemoryHandler {
match result {
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!({
"content": [{"type": "text", "text": text}],
"content": [content_obj],
"isError": false
});
Some(crate::mcp::success(id_clone, payload))
@@ -788,8 +802,13 @@ impl MemoryHandler {
}
});
let unblocked = crate::handlers::reconciliation::cascade_unblock_tasks(&self.state);
let milestones = crate::handlers::reconciliation::recalculate_milestones(&self.state);
self.state.record_activity("task", &format!("Completed task via WebSocket RPC: {}", task_id), Some("completed"));
let milestones =
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 {
topic: "task:event".to_string(),
session_id: None,
@@ -800,11 +819,14 @@ impl MemoryHandler {
"updated_milestones": milestones,
}),
});
Some(crate::mcp::success(id, serde_json::json!({
Some(crate::mcp::success(
id,
serde_json::json!({
"status": "success",
"unblocked_tasks": unblocked,
"updated_milestones": milestones
})))
}),
))
}
"tech_debts/resolve" => {
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
@@ -823,7 +845,11 @@ impl MemoryHandler {
}
});
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 {
topic: "tech_debt:event".to_string(),
session_id: None,
@@ -832,7 +858,10 @@ impl MemoryHandler {
"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 {
Some(crate::mcp::error(id, -32602, "Tech debt not found"))
}
@@ -853,13 +882,20 @@ impl MemoryHandler {
}
});
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 {
topic: "gate:event".to_string(),
session_id: None,
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 {
Some(crate::mcp::error(id, -32602, "Gate not found"))
}
@@ -880,13 +916,20 @@ impl MemoryHandler {
}
});
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 {
topic: "gate:event".to_string(),
session_id: None,
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 {
Some(crate::mcp::error(id, -32602, "Gate not found"))
}
@@ -902,13 +945,20 @@ impl MemoryHandler {
}
});
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 {
topic: "gate:event".to_string(),
session_id: None,
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 {
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)
}
"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
.get("hypothesis")
.or_else(|| args.get("query"))
@@ -1011,24 +1064,39 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
.unwrap_or("");
(
"Hypotheses",
format!("{}: {}", act, hyp).trim_end_matches(": ").to_string(),
format!("{}: {}", act, hyp)
.trim_end_matches(": ")
.to_string(),
)
}
"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 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",
format!("{}: {} [{}]", act, sender, st).trim_end_matches(": ").to_string(),
format!("{}: {} [{}]", act, sender, st)
.trim_end_matches(": ")
.to_string(),
)
}
"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("");
(
"Process Logs",
format!("{}: {}", act, file).trim_end_matches(": ").to_string(),
format!("{}: {}", act, file)
.trim_end_matches(": ")
.to_string(),
)
}
_ => return name.to_string(),
@@ -1053,8 +1121,8 @@ async fn execute_tool_span(
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
use serde_json::json;
use tempfile::tempdir;
#[test]
fn test_format_tool_activity_description() {
+63 -19
View File
@@ -16,6 +16,26 @@ pub struct SearchResult {
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)]
pub struct MemoryIndex {
pub index: Index,
@@ -55,7 +75,8 @@ impl MemoryIndex {
.reload_policy(ReloadPolicy::OnCommitWithDelay)
.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 {
index,
@@ -87,7 +108,8 @@ impl MemoryIndex {
.reload_policy(ReloadPolicy::OnCommitWithDelay)
.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 {
index,
@@ -117,7 +139,10 @@ impl MemoryIndex {
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 id_field = self.id_field;
let needs_commit = Arc::clone(&self.needs_commit);
@@ -154,7 +179,10 @@ impl MemoryIndex {
let id_val = t.id.clone();
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
.acceptance_criteria
.iter()
.fold(String::new(), |mut acc, c| {
if !acc.is_empty() {
acc.push('\n');
}
@@ -231,7 +259,10 @@ impl MemoryIndex {
if let Err(first_err) = writer.commit() {
std::thread::sleep(std::time::Duration::from_millis(100));
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();
@@ -246,9 +277,11 @@ impl MemoryIndex {
})
}
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());
writer.commit()?;
let _ = self.reader.reload();
@@ -268,8 +301,8 @@ impl MemoryIndex {
let sanitized: String = query
.chars()
.map(|c| match c {
'+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^'
| '"' | '~' | '*' | '?' | ':' | '\\' | '/' => ' ',
'+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"'
| '~' | '*' | '?' | ':' | '\\' | '/' => ' ',
_ => c,
})
.collect();
@@ -284,7 +317,8 @@ impl MemoryIndex {
let target_limit = if is_wildcard_fallback { 10 } else { 50 };
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> =
Box::new(tantivy::query::TermQuery::new(
tantivy::Term::from_field_text(self.namespace_field, ns),
tantivy::schema::IndexRecordOption::Basic,
));
@@ -491,7 +525,8 @@ impl SearchService {
self.state.code.snippets.read_with(|snips| {
for snippet in snips.iter() {
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 {
id: snippet.name.clone(),
doc_type: "snippet".to_string(),
@@ -508,6 +543,7 @@ impl SearchService {
snippet.name.clone(),
"snippet".to_string(),
snippet.description.clone(),
Some(snippet.updated_at),
));
}
}
@@ -515,15 +551,15 @@ impl SearchService {
self.state.read_graph(|graph| {
for entity in graph.entities.values() {
if let Some(ns) = filter_namespace {
if entity.namespace != ns {
if let Some(ns) = filter_namespace
&& entity.namespace != ns {
continue;
}
}
let obs = entity.observations.join("; ");
let desc = format!("{}: {}", entity.entity_type, obs);
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 {
id: entity.name.clone(),
doc_type: "entity".to_string(),
@@ -533,7 +569,12 @@ impl SearchService {
});
} else if uncached_texts.len() < 50 {
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| {
for fix in fixes.iter() {
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 {
id: fix.signature.clone(),
doc_type: "error_fix".to_string(),
@@ -555,6 +597,7 @@ impl SearchService {
fix.signature.clone(),
"error_fix".to_string(),
fix.solution.clone(),
Some(fix.timestamp),
));
}
}
@@ -563,8 +606,9 @@ impl SearchService {
if !uncached_texts.is_empty()
&& let Ok(embeddings) = generate_embeddings_async(uncached_texts).await
{
for (emb, (title, doc_type, body)) in embeddings.into_iter().zip(uncached_meta) {
let sim = cosine_similarity(&query_emb, &emb);
for (emb, (title, doc_type, body, ts)) in embeddings.into_iter().zip(uncached_meta) {
let base_sim = cosine_similarity(&query_emb, &emb);
let sim = base_sim * calculate_time_decay(ts);
results.push(SearchResult {
id: title.clone(),
doc_type,
+4 -6
View File
@@ -86,11 +86,10 @@ impl ClipboardCacheState {
}
pub fn push_text(&mut self, text: CachedClipboardText) {
if let Some(ref prev) = self.last_text {
if prev.text == text.text {
if let Some(ref prev) = self.last_text
&& prev.text == text.text {
return;
}
}
self.last_text = Some(text.clone());
self.history.push_front(ClipboardHistoryItem::Text(text));
if self.history.len() > 20 {
@@ -142,11 +141,10 @@ impl MemoryState {
pub fn new(base_dir_str: &str) -> Self {
let is_in_memory = base_dir_str == ":memory:";
let base = std::path::PathBuf::from(base_dir_str);
if !is_in_memory {
if let Err(e) = std::fs::create_dir_all(&base) {
if !is_in_memory
&& let Err(e) = std::fs::create_dir_all(&base) {
tracing::error!("Failed to create store directory at {:?}: {}", base, e);
}
}
let db = crate::db::init_redb(&base);
+3 -6
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 {
let mut reg = QUEUE_REGISTRY.lock().unwrap_or_else(|e| e.into_inner());
if let Some((ref existing_db, ref queue)) = *reg {
if Arc::ptr_eq(existing_db, &db) && !queue.tx.is_closed() {
if let Some((ref existing_db, ref queue)) = *reg
&& Arc::ptr_eq(existing_db, &db) && !queue.tx.is_closed() {
return queue.clone();
}
}
let new_queue = DbWriteQueue::new(db.clone());
*reg = Some((db, new_queue.clone()));
new_queue
@@ -334,8 +333,7 @@ impl<T: DeserializeOwned + Default + Serialize + Send + Sync + 'static> Store<T>
let mut known = std::collections::HashSet::new();
if let Ok(range) = table.range(prefix.as_str()..) {
for entry in range {
if let Ok((k, v)) = entry {
for (k, v) in range.flatten() {
let k_str = k.value();
if !k_str.starts_with(&prefix) {
break;
@@ -349,7 +347,6 @@ impl<T: DeserializeOwned + Default + Serialize + Send + Sync + 'static> Store<T>
}
}
}
}
if found_granular {
if let Ok(parsed) = serde_json::from_value::<T>(serde_json::Value::Array(items_array)) {
+7 -1
View File
@@ -993,7 +993,13 @@ pub struct EnvironmentTool {
#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq)]
#[serde(rename_all = "snake_case")]
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,
#[serde(alias = "text", alias = "TEXT", alias = "Text")]
Text,
+39 -16
View File
@@ -79,7 +79,11 @@ pub fn spawn_watcher(state: Arc<MemoryState>) {
fn should_review(path: &Path) -> bool {
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;
}
@@ -89,13 +93,19 @@ fn should_review(path: &Path) -> bool {
}
async fn trigger_autonomous_review(path: &Path, state: Arc<MemoryState>) {
info!("Triggering autonomous review & incremental AST index for {:?}", path);
state.broadcast_activity("AUTONOMOUS", &format!("Modified: {:?}", path.file_name().unwrap_or_default()));
info!(
"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
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if matches!(ext, "rs" | "ts" | "js" | "py" | "go" | "java" | "c" | "cpp") {
if let Ok(content) = std::fs::read_to_string(path) {
if matches!(ext, "rs" | "ts" | "js" | "py" | "go" | "java" | "c" | "cpp")
&& let Ok(content) = std::fs::read_to_string(path) {
let language = match ext {
"rs" => Some(tree_sitter_rust::LANGUAGE),
"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 {
let mut parser = tree_sitter::Parser::new();
if parser.set_language(&lang.into()).is_ok() {
if let Some(tree) = parser.parse(&content, None) {
if parser.set_language(&lang.into()).is_ok()
&& let Some(tree) = parser.parse(&content, None) {
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 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 file_str_clone = file_str.clone();
@@ -140,16 +158,21 @@ async fn trigger_autonomous_review(path: &Path, state: Arc<MemoryState>) {
"McpTool".to_string()
};
g.entities.insert(symbol_name.clone(), crate::models::Entity {
g.entities.insert(
symbol_name.clone(),
crate::models::Entity {
name: symbol_name.clone(),
entity_type: symbol_type,
observations: vec![format!("AST definition: {} chars", chunk_code.len())],
observations: vec![format!(
"AST definition: {} chars",
chunk_code.len()
)],
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 {
@@ -185,10 +208,11 @@ 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)]
mod tests {
@@ -220,4 +244,3 @@ mod tests {
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"
};
let dashboard_content = std::fs::read_to_string(dashboard_path)
.expect("Failed to read dashboard.ts");
let setup_content = std::fs::read_to_string(setup_path)
.expect("Failed to read setup.rs");
let dashboard_content =
std::fs::read_to_string(dashboard_path).expect("Failed to read dashboard.ts");
let setup_content = std::fs::read_to_string(setup_path).expect("Failed to read setup.rs");
// 1. Extract routes from setup.rs
let mut registered_routes = HashSet::new();
@@ -142,7 +141,8 @@ fn test_dashboard_route_parity() {
}
}
if endpoint.is_empty() {
let end_idx = rest.find(['"', '`', '\'', ' ', '\n', '?'])
let end_idx = rest
.find(['"', '`', '\'', ' ', '\n', '?'])
.unwrap_or(rest.len());
endpoint = rest[..end_idx].to_string();
}
@@ -153,7 +153,10 @@ fn test_dashboard_route_parity() {
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 {
assert!(
+24 -8
View File
@@ -48,28 +48,45 @@ mod tests {
#[test]
fn test_extract_log_prefix_response() {
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"}}"#;
assert_eq!(extract_log_prefix(err_json, true), "Response id=456 [Error: true]");
let err_json =
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}}"#;
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]
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");
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]
fn test_extract_log_prefix_request() {
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]
@@ -84,4 +101,3 @@ mod tests {
assert_eq!(extract_log_prefix(json_str, false), "Message");
}
}