feat(embedding,vision): candle embeddings with offline fallback, on-demand clipboard vision capture, and concurrency audit
This commit is contained in:
1 parent
5bd8b1587a
commit
e4a0fe72df
47 files changed
+6292
-3503
No files matched your search
+240
-47
@@ -113,11 +113,13 @@ impl McpResource for TasksActiveResource {
|
||||
Some("List of currently active tasks")
|
||||
}
|
||||
async fn read(&self, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let tasks = state.project.tasks.cache.read().unwrap_or_else(|e| e.into_inner());
|
||||
let data: Vec<_> = tasks
|
||||
.iter()
|
||||
.filter(|t| t.is_active())
|
||||
.collect();
|
||||
let tasks = state
|
||||
.project
|
||||
.tasks
|
||||
.cache
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let data: Vec<_> = tasks.iter().filter(|t| t.is_active()).collect();
|
||||
Ok(serde_json::to_string_pretty(&data)?)
|
||||
}
|
||||
}
|
||||
@@ -135,7 +137,12 @@ impl McpResource for DecisionsActiveResource {
|
||||
Some("All accepted Architectural Decision Records (ADRs)")
|
||||
}
|
||||
async fn read(&self, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let adrs = state.code.adrs.cache.read().unwrap_or_else(|e| e.into_inner());
|
||||
let adrs = state
|
||||
.code
|
||||
.adrs
|
||||
.cache
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let data: Vec<_> = adrs.iter().filter(|a| a.status == "accepted").collect();
|
||||
Ok(serde_json::to_string_pretty(&data)?)
|
||||
}
|
||||
@@ -154,7 +161,12 @@ impl McpResource for TechDebtActiveResource {
|
||||
Some("All currently unresolved technical debt items")
|
||||
}
|
||||
async fn read(&self, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let debts = state.code.tech_debts.cache.read().unwrap_or_else(|e| e.into_inner());
|
||||
let debts = state
|
||||
.code
|
||||
.tech_debts
|
||||
.cache
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let data: Vec<_> = debts.iter().filter(|d| !d.is_resolved).collect();
|
||||
Ok(serde_json::to_string_pretty(&data)?)
|
||||
}
|
||||
@@ -233,14 +245,32 @@ impl McpResource for SessionDeltaResource {
|
||||
let lookback = 7200; // last 2 hours
|
||||
let cutoff = now.saturating_sub(lookback);
|
||||
|
||||
let changes = state.code.ledger.cache.read().unwrap_or_else(|e| e.into_inner());
|
||||
let changes = state
|
||||
.code
|
||||
.ledger
|
||||
.cache
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let recent_changes: Vec<_> = changes.iter().filter(|c| c.timestamp >= cutoff).collect();
|
||||
|
||||
let tasks = state.project.tasks.cache.read().unwrap_or_else(|e| e.into_inner());
|
||||
let tasks = state
|
||||
.project
|
||||
.tasks
|
||||
.cache
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let active_tasks: Vec<_> = tasks.iter().filter(|t| t.is_active()).collect();
|
||||
|
||||
let notes = state.code.sticky.cache.read().unwrap_or_else(|e| e.into_inner());
|
||||
let active_notes: Vec<_> = notes.iter().filter(|n| n.expires_at.map_or(true, |exp| exp > now)).collect();
|
||||
let notes = state
|
||||
.code
|
||||
.sticky
|
||||
.cache
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let active_notes: Vec<_> = notes
|
||||
.iter()
|
||||
.filter(|n| n.expires_at.map_or(true, |exp| exp > now))
|
||||
.collect();
|
||||
|
||||
let delta = serde_json::json!({
|
||||
"recent_changes": recent_changes,
|
||||
@@ -260,7 +290,9 @@ impl McpPrompt for ContextWarmupPrompt {
|
||||
"context_warmup"
|
||||
}
|
||||
fn description(&self) -> Option<&'static str> {
|
||||
Some("Consolidated context warmup prompt providing active tasks, pinned files, open tech debt, and sticky notes")
|
||||
Some(
|
||||
"Consolidated context warmup prompt providing active tasks, pinned files, open tech debt, and sticky notes",
|
||||
)
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
@@ -334,7 +366,34 @@ impl MemoryHandler {
|
||||
Some("Recent terminal execution history and exit codes")
|
||||
}
|
||||
async fn read(&self, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let items = state.telemetry.terminal_history.cache.read().unwrap_or_else(|e| e.into_inner());
|
||||
let items = state
|
||||
.telemetry
|
||||
.terminal_history
|
||||
.cache
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
Ok(serde_json::to_string_pretty(&*items)?)
|
||||
}
|
||||
}
|
||||
struct RecentActivitiesResource;
|
||||
#[async_trait]
|
||||
impl McpResource for RecentActivitiesResource {
|
||||
fn uri(&self) -> &'static str {
|
||||
"memory://activity/recent"
|
||||
}
|
||||
fn name(&self) -> &'static str {
|
||||
"Recent Activity"
|
||||
}
|
||||
fn description(&self) -> Option<&'static str> {
|
||||
Some("Recent IDE and developer activity logs")
|
||||
}
|
||||
async fn read(&self, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let items = state
|
||||
.telemetry
|
||||
.recent_activities
|
||||
.cache
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
Ok(serde_json::to_string_pretty(&*items)?)
|
||||
}
|
||||
}
|
||||
@@ -351,7 +410,12 @@ impl MemoryHandler {
|
||||
Some("Currently pinned files in the workspace")
|
||||
}
|
||||
async fn read(&self, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let items = state.project.pinned_files.cache.read().unwrap_or_else(|e| e.into_inner());
|
||||
let items = state
|
||||
.project
|
||||
.pinned_files
|
||||
.cache
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
Ok(serde_json::to_string_pretty(&*items)?)
|
||||
}
|
||||
}
|
||||
@@ -369,7 +433,12 @@ impl MemoryHandler {
|
||||
Some("Project milestones and their status")
|
||||
}
|
||||
async fn read(&self, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let items = state.project.milestones.cache.read().unwrap_or_else(|e| e.into_inner());
|
||||
let items = state
|
||||
.project
|
||||
.milestones
|
||||
.cache
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
Ok(serde_json::to_string_pretty(&*items)?)
|
||||
}
|
||||
}
|
||||
@@ -403,6 +472,7 @@ impl MemoryHandler {
|
||||
}
|
||||
|
||||
register_resource!(TerminalHistoryResource);
|
||||
register_resource!(RecentActivitiesResource);
|
||||
register_resource!(PinnedFilesResource);
|
||||
register_resource!(MilestonesResource);
|
||||
struct ArchiveRoutinePrompt;
|
||||
@@ -679,7 +749,26 @@ impl MemoryHandler {
|
||||
|
||||
let category = match name {
|
||||
"clipboard" => "CLIPBOARD",
|
||||
"create_entities" | "create_relations" | "add_observations" | "delete_entities" | "delete_observations" | "delete_relations" | "read_graph" | "get_subgraph" | "search_graph" | "get_schema" | "search_nodes" | "open_nodes" | "visualize_graph" | "condense_entity" | "merge_entities" | "find_orphans" | "sweep_graph_health" | "summarize_subgraph" | "query_graph_path" | "resolve_stale_symbols" => "GRAPH",
|
||||
"create_entities"
|
||||
| "create_relations"
|
||||
| "add_observations"
|
||||
| "delete_entities"
|
||||
| "delete_observations"
|
||||
| "delete_relations"
|
||||
| "read_graph"
|
||||
| "get_subgraph"
|
||||
| "search_graph"
|
||||
| "get_schema"
|
||||
| "search_nodes"
|
||||
| "open_nodes"
|
||||
| "visualize_graph"
|
||||
| "condense_entity"
|
||||
| "merge_entities"
|
||||
| "find_orphans"
|
||||
| "sweep_graph_health"
|
||||
| "summarize_subgraph"
|
||||
| "query_graph_path"
|
||||
| "resolve_stale_symbols" => "GRAPH",
|
||||
"decisions" => "DECISION",
|
||||
"log_code_change" => "CODE",
|
||||
"log_error_fix" => "ERROR_FIX",
|
||||
@@ -701,7 +790,7 @@ impl MemoryHandler {
|
||||
|
||||
let result: crate::error::Result<String> = if let Some(tool) = self.tools.get(name)
|
||||
{
|
||||
tool.execute(args, self.state.clone()).await
|
||||
execute_tool_span(name, tool.as_ref(), args, self.state.clone()).await
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal(format!(
|
||||
"Unknown tool: {}",
|
||||
@@ -747,40 +836,90 @@ impl MemoryHandler {
|
||||
pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) -> String {
|
||||
let (action, detail) = match name {
|
||||
"tasks" => {
|
||||
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("manage");
|
||||
let title = args.get("title").or_else(|| args.get("id")).and_then(|v| v.as_str()).unwrap_or("");
|
||||
("Tasks", format!("{}: {}", act, title).trim_end_matches(": ").to_string())
|
||||
let act = args
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("manage");
|
||||
let title = args
|
||||
.get("title")
|
||||
.or_else(|| args.get("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
(
|
||||
"Tasks",
|
||||
format!("{}: {}", act, title)
|
||||
.trim_end_matches(": ")
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
"decisions" => {
|
||||
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("log");
|
||||
let title = args.get("title").or_else(|| args.get("query")).and_then(|v| v.as_str()).unwrap_or("");
|
||||
("Decisions", format!("{}: {}", act, title).trim_end_matches(": ").to_string())
|
||||
let title = args
|
||||
.get("title")
|
||||
.or_else(|| args.get("query"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
(
|
||||
"Decisions",
|
||||
format!("{}: {}", act, title)
|
||||
.trim_end_matches(": ")
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
"tech_debt" => {
|
||||
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("log");
|
||||
let desc = args.get("description").or_else(|| args.get("id")).and_then(|v| v.as_str()).unwrap_or("");
|
||||
("Tech Debt", format!("{}: {}", act, desc).trim_end_matches(": ").to_string())
|
||||
let desc = args
|
||||
.get("description")
|
||||
.or_else(|| args.get("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
(
|
||||
"Tech Debt",
|
||||
format!("{}: {}", act, desc)
|
||||
.trim_end_matches(": ")
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
"sticky_notes" => {
|
||||
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("add");
|
||||
let preview = args.get("content").and_then(|v| v.as_str()).map(|c| c.chars().take(40).collect::<String>()).unwrap_or_default();
|
||||
("Sticky Notes", format!("{}: {}", act, preview).trim_end_matches(": ").to_string())
|
||||
let preview = args
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|c| c.chars().take(40).collect::<String>())
|
||||
.unwrap_or_default();
|
||||
(
|
||||
"Sticky Notes",
|
||||
format!("{}: {}", act, preview)
|
||||
.trim_end_matches(": ")
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
"clipboard" => {
|
||||
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("read");
|
||||
let act = args
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("read");
|
||||
("Clipboard", act.to_string())
|
||||
}
|
||||
"snippets" => {
|
||||
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("search");
|
||||
let act = args
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("search");
|
||||
let q = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
|
||||
("Snippets", format!("{}: {}", act, q).trim_end_matches(": ").to_string())
|
||||
(
|
||||
"Snippets",
|
||||
format!("{}: {}", act, q).trim_end_matches(": ").to_string(),
|
||||
)
|
||||
}
|
||||
"log_code_change" => {
|
||||
let file = args.get("file_path")
|
||||
let file = args
|
||||
.get("file_path")
|
||||
.or_else(|| args.get("file"))
|
||||
.or_else(|| args.get("target_file"))
|
||||
.and_then(|v| v.as_str());
|
||||
let summary = args.get("summary")
|
||||
let summary = args
|
||||
.get("summary")
|
||||
.or_else(|| args.get("description"))
|
||||
.and_then(|v| v.as_str());
|
||||
|
||||
@@ -793,7 +932,8 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
|
||||
("Log Code Change", d)
|
||||
}
|
||||
"log_error_fix" => {
|
||||
let d = args.get("error")
|
||||
let d = args
|
||||
.get("error")
|
||||
.or_else(|| args.get("summary"))
|
||||
.or_else(|| args.get("solution"))
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -805,7 +945,11 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
|
||||
let d = if let Some(entities) = args.get("entities").and_then(|v| v.as_array()) {
|
||||
let names: Vec<&str> = entities
|
||||
.iter()
|
||||
.filter_map(|e| e.get("name").or_else(|| e.get("entity_name")).and_then(|n| n.as_str()))
|
||||
.filter_map(|e| {
|
||||
e.get("name")
|
||||
.or_else(|| e.get("entity_name"))
|
||||
.and_then(|n| n.as_str())
|
||||
})
|
||||
.take(3)
|
||||
.collect();
|
||||
if !names.is_empty() {
|
||||
@@ -817,7 +961,11 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
|
||||
} else {
|
||||
format!("{} entity/entities", entities.len())
|
||||
}
|
||||
} else if let Some(name) = args.get("name").or_else(|| args.get("entity_name")).and_then(|v| v.as_str()) {
|
||||
} else if let Some(name) = args
|
||||
.get("name")
|
||||
.or_else(|| args.get("entity_name"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
name.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
@@ -829,7 +977,11 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
|
||||
if let Some(first) = relations.first() {
|
||||
let from = first.get("from").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let to = first.get("to").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let pred = first.get("relation_type").or_else(|| first.get("predicate")).and_then(|v| v.as_str()).unwrap_or("->");
|
||||
let pred = first
|
||||
.get("relation_type")
|
||||
.or_else(|| first.get("predicate"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("->");
|
||||
if relations.len() > 1 {
|
||||
format!("{} {} {} (+{} more)", from, pred, to, relations.len() - 1)
|
||||
} else {
|
||||
@@ -844,7 +996,8 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
|
||||
("Create Relations", d)
|
||||
}
|
||||
"omni_search" | "search_graph" | "search_web" => {
|
||||
let d = args.get("query")
|
||||
let d = args
|
||||
.get("query")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|q| format!("\"{}\"", q))
|
||||
.unwrap_or_default();
|
||||
@@ -881,6 +1034,16 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "mcp_tool_execution", skip(tool, args, state), fields(tool_name = %name))]
|
||||
async fn execute_tool_span(
|
||||
name: &str,
|
||||
tool: &dyn McpTool,
|
||||
args: Value,
|
||||
state: Arc<MemoryState>,
|
||||
) -> crate::error::Result<String> {
|
||||
tool.execute(args, state).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -889,10 +1052,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_activity_description() {
|
||||
let msg1 = format_tool_activity_description("log_code_change", &json!({ "file": "src/lib.rs", "summary": "Refactored library" }));
|
||||
let msg1 = format_tool_activity_description(
|
||||
"log_code_change",
|
||||
&json!({ "file": "src/lib.rs", "summary": "Refactored library" }),
|
||||
);
|
||||
assert_eq!(msg1, "Log Code Change: src/lib.rs: Refactored library");
|
||||
|
||||
let msg2 = format_tool_activity_description("create_entities", &json!({ "entities": [{ "name": "EntityA" }, { "name": "EntityB" }] }));
|
||||
let msg2 = format_tool_activity_description(
|
||||
"create_entities",
|
||||
&json!({ "entities": [{ "name": "EntityA" }, { "name": "EntityB" }] }),
|
||||
);
|
||||
assert_eq!(msg2, "Create Entities: EntityA, EntityB");
|
||||
|
||||
let msg3 = format_tool_activity_description("read_graph", &json!({}));
|
||||
@@ -948,6 +1117,11 @@ mod tests {
|
||||
.iter()
|
||||
.any(|r| r["uri"] == "memory://pinned_files")
|
||||
);
|
||||
assert!(
|
||||
resources_arr
|
||||
.iter()
|
||||
.any(|r| r["uri"] == "memory://activity/recent")
|
||||
);
|
||||
|
||||
// Test resources/read
|
||||
let req_read_res = json!({
|
||||
@@ -1114,17 +1288,27 @@ mod tests {
|
||||
assert_eq!(res["jsonrpc"], "2.0");
|
||||
assert_eq!(res["id"], 100);
|
||||
|
||||
let tools = res["result"]["tools"].as_array().expect("tools array expected");
|
||||
assert!(tools.len() >= 40, "Expected at least 40 registered MCP tools, found {}", tools.len());
|
||||
let tools = res["result"]["tools"]
|
||||
.as_array()
|
||||
.expect("tools array expected");
|
||||
assert!(
|
||||
tools.len() >= 40,
|
||||
"Expected at least 40 registered MCP tools, found {}",
|
||||
tools.len()
|
||||
);
|
||||
|
||||
for tool in tools {
|
||||
let name = tool["name"].as_str().expect("tool name must be string");
|
||||
assert!(!name.is_empty(), "Tool name cannot be empty");
|
||||
|
||||
let desc = tool["description"].as_str().expect("tool description must be string");
|
||||
let desc = tool["description"]
|
||||
.as_str()
|
||||
.expect("tool description must be string");
|
||||
assert!(!desc.is_empty(), "Tool '{}' missing description", name);
|
||||
|
||||
let schema = tool["inputSchema"].as_object().expect("inputSchema must be object");
|
||||
let schema = tool["inputSchema"]
|
||||
.as_object()
|
||||
.expect("inputSchema must be object");
|
||||
assert!(
|
||||
schema.contains_key("properties") || schema.contains_key("type"),
|
||||
"Tool '{}' schema must contain type/properties metadata",
|
||||
@@ -1154,7 +1338,10 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let res = handler.handle_request(invalid_req).await.expect("handler must respond");
|
||||
let res = handler
|
||||
.handle_request(invalid_req)
|
||||
.await
|
||||
.expect("handler must respond");
|
||||
assert_eq!(res["jsonrpc"], "2.0");
|
||||
assert!(res["result"]["content"].is_array());
|
||||
}
|
||||
@@ -1180,7 +1367,10 @@ mod tests {
|
||||
("sweep_graph_health", json!({})),
|
||||
("get_next_actionable_tasks", json!({})),
|
||||
("get_preflight_context", json!({})),
|
||||
("auto_session_checkpoint", json!({"session_summary": "Test summary"})),
|
||||
(
|
||||
"auto_session_checkpoint",
|
||||
json!({"session_summary": "Test summary"}),
|
||||
),
|
||||
];
|
||||
|
||||
for (name, args) in sample_valid_calls {
|
||||
@@ -1194,13 +1384,16 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let res = handler.handle_request(valid_req).await.expect("handler must respond");
|
||||
let res = handler
|
||||
.handle_request(valid_req)
|
||||
.await
|
||||
.expect("handler must respond");
|
||||
assert_eq!(res["jsonrpc"], "2.0");
|
||||
assert_eq!(
|
||||
res["result"]["isError"], false,
|
||||
"Tool '{}' failed valid execution. Result: {:?}", name, res
|
||||
"Tool '{}' failed valid execution. Result: {:?}",
|
||||
name, res
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user