mirror of
https://github.com/samvallad33/vestige.git
synced 2026-05-09 07:42:37 +02:00
Initial commit: Vestige v1.0.0 - Cognitive memory MCP server
FSRS-6 spaced repetition, spreading activation, synaptic tagging, hippocampal indexing, and 130 years of memory research. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
f9c60eb5a7
169 changed files with 97206 additions and 0 deletions
304
crates/vestige-mcp/src/tools/codebase.rs
Normal file
304
crates/vestige-mcp/src/tools/codebase.rs
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
//! Codebase Tools
|
||||
//!
|
||||
//! Remember patterns, decisions, and context about codebases.
|
||||
//! This is a differentiating feature for AI-assisted development.
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::{IngestInput, Storage};
|
||||
|
||||
/// Input schema for remember_pattern tool
|
||||
pub fn pattern_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name/title for this pattern"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Detailed description of the pattern"
|
||||
},
|
||||
"files": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Files where this pattern is used"
|
||||
},
|
||||
"codebase": {
|
||||
"type": "string",
|
||||
"description": "Codebase/project identifier (e.g., 'vestige-tauri')"
|
||||
}
|
||||
},
|
||||
"required": ["name", "description"]
|
||||
})
|
||||
}
|
||||
|
||||
/// Input schema for remember_decision tool
|
||||
pub fn decision_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"decision": {
|
||||
"type": "string",
|
||||
"description": "The architectural or design decision made"
|
||||
},
|
||||
"rationale": {
|
||||
"type": "string",
|
||||
"description": "Why this decision was made"
|
||||
},
|
||||
"alternatives": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Alternatives that were considered"
|
||||
},
|
||||
"files": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Files affected by this decision"
|
||||
},
|
||||
"codebase": {
|
||||
"type": "string",
|
||||
"description": "Codebase/project identifier"
|
||||
}
|
||||
},
|
||||
"required": ["decision", "rationale"]
|
||||
})
|
||||
}
|
||||
|
||||
/// Input schema for get_codebase_context tool
|
||||
pub fn context_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"codebase": {
|
||||
"type": "string",
|
||||
"description": "Codebase/project identifier to get context for"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum items per category (default: 10)",
|
||||
"default": 10
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PatternArgs {
|
||||
name: String,
|
||||
description: String,
|
||||
files: Option<Vec<String>>,
|
||||
codebase: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DecisionArgs {
|
||||
decision: String,
|
||||
rationale: String,
|
||||
alternatives: Option<Vec<String>>,
|
||||
files: Option<Vec<String>>,
|
||||
codebase: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ContextArgs {
|
||||
codebase: Option<String>,
|
||||
limit: Option<i32>,
|
||||
}
|
||||
|
||||
pub async fn execute_pattern(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: PatternArgs = match args {
|
||||
Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?,
|
||||
None => return Err("Missing arguments".to_string()),
|
||||
};
|
||||
|
||||
if args.name.trim().is_empty() {
|
||||
return Err("Pattern name cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// Build content with structured format
|
||||
let mut content = format!("# Code Pattern: {}\n\n{}", args.name, args.description);
|
||||
|
||||
if let Some(ref files) = args.files {
|
||||
if !files.is_empty() {
|
||||
content.push_str("\n\n## Files:\n");
|
||||
for f in files {
|
||||
content.push_str(&format!("- {}\n", f));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build tags
|
||||
let mut tags = vec!["pattern".to_string(), "codebase".to_string()];
|
||||
if let Some(ref codebase) = args.codebase {
|
||||
tags.push(format!("codebase:{}", codebase));
|
||||
}
|
||||
|
||||
let input = IngestInput {
|
||||
content,
|
||||
node_type: "pattern".to_string(),
|
||||
source: args.codebase.clone(),
|
||||
sentiment_score: 0.0,
|
||||
sentiment_magnitude: 0.0,
|
||||
tags,
|
||||
valid_from: None,
|
||||
valid_until: None,
|
||||
};
|
||||
|
||||
let mut storage = storage.lock().await;
|
||||
let node = storage.ingest(input).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"nodeId": node.id,
|
||||
"patternName": args.name,
|
||||
"message": format!("Pattern '{}' remembered successfully", args.name),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn execute_decision(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: DecisionArgs = match args {
|
||||
Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?,
|
||||
None => return Err("Missing arguments".to_string()),
|
||||
};
|
||||
|
||||
if args.decision.trim().is_empty() {
|
||||
return Err("Decision cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// Build content with structured format (ADR-like)
|
||||
let mut content = format!(
|
||||
"# Decision: {}\n\n## Context\n\n{}\n\n## Decision\n\n{}",
|
||||
&args.decision[..args.decision.len().min(50)],
|
||||
args.rationale,
|
||||
args.decision
|
||||
);
|
||||
|
||||
if let Some(ref alternatives) = args.alternatives {
|
||||
if !alternatives.is_empty() {
|
||||
content.push_str("\n\n## Alternatives Considered:\n");
|
||||
for alt in alternatives {
|
||||
content.push_str(&format!("- {}\n", alt));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref files) = args.files {
|
||||
if !files.is_empty() {
|
||||
content.push_str("\n\n## Affected Files:\n");
|
||||
for f in files {
|
||||
content.push_str(&format!("- {}\n", f));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build tags
|
||||
let mut tags = vec!["decision".to_string(), "architecture".to_string(), "codebase".to_string()];
|
||||
if let Some(ref codebase) = args.codebase {
|
||||
tags.push(format!("codebase:{}", codebase));
|
||||
}
|
||||
|
||||
let input = IngestInput {
|
||||
content,
|
||||
node_type: "decision".to_string(),
|
||||
source: args.codebase.clone(),
|
||||
sentiment_score: 0.0,
|
||||
sentiment_magnitude: 0.0,
|
||||
tags,
|
||||
valid_from: None,
|
||||
valid_until: None,
|
||||
};
|
||||
|
||||
let mut storage = storage.lock().await;
|
||||
let node = storage.ingest(input).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"nodeId": node.id,
|
||||
"message": "Architectural decision remembered successfully",
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn execute_context(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: ContextArgs = args
|
||||
.map(|v| serde_json::from_value(v))
|
||||
.transpose()
|
||||
.map_err(|e| format!("Invalid arguments: {}", e))?
|
||||
.unwrap_or(ContextArgs {
|
||||
codebase: None,
|
||||
limit: Some(10),
|
||||
});
|
||||
|
||||
let limit = args.limit.unwrap_or(10).clamp(1, 50);
|
||||
let storage = storage.lock().await;
|
||||
|
||||
// Build tag filter for codebase
|
||||
// Tags are stored as: ["pattern", "codebase", "codebase:vestige"]
|
||||
// We search for the "codebase:{name}" tag
|
||||
let tag_filter = args.codebase.as_ref().map(|cb| format!("codebase:{}", cb));
|
||||
|
||||
// Query patterns by node_type and tag
|
||||
let patterns = storage
|
||||
.get_nodes_by_type_and_tag("pattern", tag_filter.as_deref(), limit)
|
||||
.unwrap_or_default();
|
||||
|
||||
// Query decisions by node_type and tag
|
||||
let decisions = storage
|
||||
.get_nodes_by_type_and_tag("decision", tag_filter.as_deref(), limit)
|
||||
.unwrap_or_default();
|
||||
|
||||
let formatted_patterns: Vec<Value> = patterns
|
||||
.iter()
|
||||
.map(|n| {
|
||||
serde_json::json!({
|
||||
"id": n.id,
|
||||
"content": n.content,
|
||||
"tags": n.tags,
|
||||
"retentionStrength": n.retention_strength,
|
||||
"createdAt": n.created_at.to_rfc3339(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let formatted_decisions: Vec<Value> = decisions
|
||||
.iter()
|
||||
.map(|n| {
|
||||
serde_json::json!({
|
||||
"id": n.id,
|
||||
"content": n.content,
|
||||
"tags": n.tags,
|
||||
"retentionStrength": n.retention_strength,
|
||||
"createdAt": n.created_at.to_rfc3339(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"codebase": args.codebase,
|
||||
"patterns": {
|
||||
"count": formatted_patterns.len(),
|
||||
"items": formatted_patterns,
|
||||
},
|
||||
"decisions": {
|
||||
"count": formatted_decisions.len(),
|
||||
"items": formatted_decisions,
|
||||
},
|
||||
}))
|
||||
}
|
||||
38
crates/vestige-mcp/src/tools/consolidate.rs
Normal file
38
crates/vestige-mcp/src/tools/consolidate.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
//! Consolidation Tool
|
||||
//!
|
||||
//! Run memory consolidation cycle with FSRS decay and embedding generation.
|
||||
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::Storage;
|
||||
|
||||
/// Input schema for run_consolidation tool
|
||||
pub fn schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn execute(storage: &Arc<Mutex<Storage>>) -> Result<Value, String> {
|
||||
let mut storage = storage.lock().await;
|
||||
let result = storage.run_consolidation().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"nodesProcessed": result.nodes_processed,
|
||||
"nodesPromoted": result.nodes_promoted,
|
||||
"nodesPruned": result.nodes_pruned,
|
||||
"decayApplied": result.decay_applied,
|
||||
"embeddingsGenerated": result.embeddings_generated,
|
||||
"durationMs": result.duration_ms,
|
||||
"message": format!(
|
||||
"Consolidation complete: {} nodes processed, {} embeddings generated, {}ms",
|
||||
result.nodes_processed,
|
||||
result.embeddings_generated,
|
||||
result.duration_ms
|
||||
),
|
||||
}))
|
||||
}
|
||||
173
crates/vestige-mcp/src/tools/context.rs
Normal file
173
crates/vestige-mcp/src/tools/context.rs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
//! Context-Dependent Memory Tool
|
||||
//!
|
||||
//! Retrieval based on encoding context match.
|
||||
//! Based on Tulving & Thomson's Encoding Specificity Principle (1973).
|
||||
|
||||
use chrono::Utc;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::{RecallInput, SearchMode, Storage};
|
||||
|
||||
/// Input schema for match_context tool
|
||||
pub fn schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query for content matching"
|
||||
},
|
||||
"topics": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Active topics in current context"
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Current project name"
|
||||
},
|
||||
"mood": {
|
||||
"type": "string",
|
||||
"enum": ["positive", "negative", "neutral"],
|
||||
"description": "Current emotional state"
|
||||
},
|
||||
"time_weight": {
|
||||
"type": "number",
|
||||
"description": "Weight for temporal context (0.0-1.0, default: 0.3)"
|
||||
},
|
||||
"topic_weight": {
|
||||
"type": "number",
|
||||
"description": "Weight for topical context (0.0-1.0, default: 0.4)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum results (default: 10)"
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args = args.ok_or("Missing arguments")?;
|
||||
|
||||
let query = args["query"]
|
||||
.as_str()
|
||||
.ok_or("query is required")?;
|
||||
|
||||
let topics: Vec<String> = args["topics"]
|
||||
.as_array()
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let project = args["project"].as_str().map(String::from);
|
||||
let mood = args["mood"].as_str().unwrap_or("neutral");
|
||||
|
||||
let time_weight = args["time_weight"].as_f64().unwrap_or(0.3);
|
||||
let topic_weight = args["topic_weight"].as_f64().unwrap_or(0.4);
|
||||
|
||||
let limit = args["limit"].as_i64().unwrap_or(10) as i32;
|
||||
|
||||
let storage = storage.lock().await;
|
||||
let now = Utc::now();
|
||||
|
||||
// Get candidate memories
|
||||
let recall_input = RecallInput {
|
||||
query: query.to_string(),
|
||||
limit: limit * 2, // Get more, then filter
|
||||
min_retention: 0.0,
|
||||
search_mode: SearchMode::Hybrid,
|
||||
valid_at: None,
|
||||
};
|
||||
let candidates = storage.recall(recall_input)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Score by context match (simplified implementation)
|
||||
let mut scored_results: Vec<_> = candidates.into_iter()
|
||||
.map(|mem| {
|
||||
// Calculate context score based on:
|
||||
// 1. Temporal proximity (how recent)
|
||||
let hours_ago = (now - mem.created_at).num_hours() as f64;
|
||||
let temporal_score = 1.0 / (1.0 + hours_ago / 24.0); // Decay over days
|
||||
|
||||
// 2. Tag overlap with topics
|
||||
let tag_overlap = if topics.is_empty() {
|
||||
0.5 // Neutral if no topics specified
|
||||
} else {
|
||||
let matching = mem.tags.iter()
|
||||
.filter(|t| topics.iter().any(|topic| topic.to_lowercase().contains(&t.to_lowercase())))
|
||||
.count();
|
||||
matching as f64 / topics.len().max(1) as f64
|
||||
};
|
||||
|
||||
// 3. Project match
|
||||
let project_score = match (&project, &mem.source) {
|
||||
(Some(p), Some(s)) if s.to_lowercase().contains(&p.to_lowercase()) => 1.0,
|
||||
(Some(_), None) => 0.0,
|
||||
(None, _) => 0.5,
|
||||
_ => 0.3,
|
||||
};
|
||||
|
||||
// 4. Emotional match (simplified)
|
||||
let mood_score = match mood {
|
||||
"positive" if mem.sentiment_score > 0.0 => 0.8,
|
||||
"negative" if mem.sentiment_score < 0.0 => 0.8,
|
||||
"neutral" if mem.sentiment_score.abs() < 0.3 => 0.8,
|
||||
_ => 0.5,
|
||||
};
|
||||
|
||||
// Combine scores
|
||||
let context_score = temporal_score * time_weight
|
||||
+ tag_overlap * topic_weight
|
||||
+ project_score * 0.2
|
||||
+ mood_score * 0.1;
|
||||
|
||||
let combined_score = mem.retention_strength * 0.5 + context_score * 0.5;
|
||||
|
||||
(mem, context_score, combined_score)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by combined score (handle NaN safely)
|
||||
scored_results.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
|
||||
scored_results.truncate(limit as usize);
|
||||
|
||||
let results: Vec<Value> = scored_results.into_iter()
|
||||
.map(|(mem, ctx_score, combined)| {
|
||||
serde_json::json!({
|
||||
"id": mem.id,
|
||||
"content": mem.content,
|
||||
"retentionStrength": mem.retention_strength,
|
||||
"contextScore": ctx_score,
|
||||
"combinedScore": combined,
|
||||
"tags": mem.tags,
|
||||
"createdAt": mem.created_at.to_rfc3339()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"query": query,
|
||||
"currentContext": {
|
||||
"topics": topics,
|
||||
"project": project,
|
||||
"mood": mood
|
||||
},
|
||||
"weights": {
|
||||
"temporal": time_weight,
|
||||
"topical": topic_weight
|
||||
},
|
||||
"resultCount": results.len(),
|
||||
"results": results,
|
||||
"science": {
|
||||
"theory": "Encoding Specificity Principle (Tulving & Thomson, 1973)",
|
||||
"principle": "Memory retrieval is most effective when retrieval context matches encoding context"
|
||||
}
|
||||
}))
|
||||
}
|
||||
286
crates/vestige-mcp/src/tools/ingest.rs
Normal file
286
crates/vestige-mcp/src/tools/ingest.rs
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
//! Ingest Tool
|
||||
//!
|
||||
//! Add new knowledge to memory.
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::{IngestInput, Storage};
|
||||
|
||||
/// Input schema for ingest tool
|
||||
pub fn schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The content to remember"
|
||||
},
|
||||
"node_type": {
|
||||
"type": "string",
|
||||
"description": "Type of knowledge: fact, concept, event, person, place, note, pattern, decision",
|
||||
"default": "fact"
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Tags for categorization"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Source or reference for this knowledge"
|
||||
}
|
||||
},
|
||||
"required": ["content"]
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct IngestArgs {
|
||||
content: String,
|
||||
node_type: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
source: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: IngestArgs = match args {
|
||||
Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?,
|
||||
None => return Err("Missing arguments".to_string()),
|
||||
};
|
||||
|
||||
// Validate content
|
||||
if args.content.trim().is_empty() {
|
||||
return Err("Content cannot be empty".to_string());
|
||||
}
|
||||
|
||||
if args.content.len() > 1_000_000 {
|
||||
return Err("Content too large (max 1MB)".to_string());
|
||||
}
|
||||
|
||||
let input = IngestInput {
|
||||
content: args.content,
|
||||
node_type: args.node_type.unwrap_or_else(|| "fact".to_string()),
|
||||
source: args.source,
|
||||
sentiment_score: 0.0,
|
||||
sentiment_magnitude: 0.0,
|
||||
tags: args.tags.unwrap_or_default(),
|
||||
valid_from: None,
|
||||
valid_until: None,
|
||||
};
|
||||
|
||||
let mut storage = storage.lock().await;
|
||||
let node = storage.ingest(input).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"nodeId": node.id,
|
||||
"message": format!("Knowledge ingested successfully. Node ID: {}", node.id),
|
||||
"hasEmbedding": node.has_embedding.unwrap_or(false),
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TESTS
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Create a test storage instance with a temporary database
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// INPUT VALIDATION TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ingest_empty_content_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "content": "" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("empty"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ingest_whitespace_only_content_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "content": " \n\t " });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("empty"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ingest_missing_arguments_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, None).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Missing arguments"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ingest_missing_content_field_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "node_type": "fact" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid arguments"));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// LARGE CONTENT TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ingest_large_content_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
// Create content larger than 1MB
|
||||
let large_content = "x".repeat(1_000_001);
|
||||
let args = serde_json::json!({ "content": large_content });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("too large"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ingest_exactly_1mb_succeeds() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
// Create content exactly 1MB
|
||||
let exact_content = "x".repeat(1_000_000);
|
||||
let args = serde_json::json!({ "content": exact_content });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SUCCESSFUL INGEST TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ingest_basic_content_succeeds() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"content": "This is a test fact to remember."
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["success"], true);
|
||||
assert!(value["nodeId"].is_string());
|
||||
assert!(value["message"].as_str().unwrap().contains("successfully"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ingest_with_node_type() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"content": "Error handling should use Result<T, E> pattern.",
|
||||
"node_type": "pattern"
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["success"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ingest_with_tags() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"content": "The Rust programming language emphasizes safety.",
|
||||
"tags": ["rust", "programming", "safety"]
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["success"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ingest_with_source() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"content": "MCP protocol version 2024-11-05 is the current standard.",
|
||||
"source": "https://modelcontextprotocol.io/spec"
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["success"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ingest_with_all_optional_fields() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"content": "Complex memory with all metadata.",
|
||||
"node_type": "decision",
|
||||
"tags": ["architecture", "design"],
|
||||
"source": "team meeting notes"
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["success"], true);
|
||||
assert!(value["nodeId"].is_string());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// NODE TYPE DEFAULTS
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ingest_default_node_type_is_fact() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"content": "Default type test content."
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify node was created - the default type is "fact"
|
||||
let node_id = result.unwrap()["nodeId"].as_str().unwrap().to_string();
|
||||
let storage_lock = storage.lock().await;
|
||||
let node = storage_lock.get_node(&node_id).unwrap().unwrap();
|
||||
assert_eq!(node.node_type, "fact");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SCHEMA TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_schema_has_required_fields() {
|
||||
let schema_value = schema();
|
||||
assert_eq!(schema_value["type"], "object");
|
||||
assert!(schema_value["properties"]["content"].is_object());
|
||||
assert!(schema_value["required"].as_array().unwrap().contains(&serde_json::json!("content")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_has_optional_fields() {
|
||||
let schema_value = schema();
|
||||
assert!(schema_value["properties"]["node_type"].is_object());
|
||||
assert!(schema_value["properties"]["tags"].is_object());
|
||||
assert!(schema_value["properties"]["source"].is_object());
|
||||
}
|
||||
}
|
||||
1057
crates/vestige-mcp/src/tools/intentions.rs
Normal file
1057
crates/vestige-mcp/src/tools/intentions.rs
Normal file
File diff suppressed because it is too large
Load diff
115
crates/vestige-mcp/src/tools/knowledge.rs
Normal file
115
crates/vestige-mcp/src/tools/knowledge.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
//! Knowledge Tools
|
||||
//!
|
||||
//! Get and delete specific knowledge nodes.
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::Storage;
|
||||
|
||||
/// Input schema for get_knowledge tool
|
||||
pub fn get_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The ID of the knowledge node to retrieve"
|
||||
}
|
||||
},
|
||||
"required": ["id"]
|
||||
})
|
||||
}
|
||||
|
||||
/// Input schema for delete_knowledge tool
|
||||
pub fn delete_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The ID of the knowledge node to delete"
|
||||
}
|
||||
},
|
||||
"required": ["id"]
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KnowledgeArgs {
|
||||
id: String,
|
||||
}
|
||||
|
||||
pub async fn execute_get(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: KnowledgeArgs = match args {
|
||||
Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?,
|
||||
None => return Err("Missing arguments".to_string()),
|
||||
};
|
||||
|
||||
// Validate UUID
|
||||
uuid::Uuid::parse_str(&args.id).map_err(|_| "Invalid node ID format".to_string())?;
|
||||
|
||||
let storage = storage.lock().await;
|
||||
let node = storage.get_node(&args.id).map_err(|e| e.to_string())?;
|
||||
|
||||
match node {
|
||||
Some(n) => Ok(serde_json::json!({
|
||||
"found": true,
|
||||
"node": {
|
||||
"id": n.id,
|
||||
"content": n.content,
|
||||
"nodeType": n.node_type,
|
||||
"createdAt": n.created_at.to_rfc3339(),
|
||||
"updatedAt": n.updated_at.to_rfc3339(),
|
||||
"lastAccessed": n.last_accessed.to_rfc3339(),
|
||||
"stability": n.stability,
|
||||
"difficulty": n.difficulty,
|
||||
"reps": n.reps,
|
||||
"lapses": n.lapses,
|
||||
"storageStrength": n.storage_strength,
|
||||
"retrievalStrength": n.retrieval_strength,
|
||||
"retentionStrength": n.retention_strength,
|
||||
"sentimentScore": n.sentiment_score,
|
||||
"sentimentMagnitude": n.sentiment_magnitude,
|
||||
"nextReview": n.next_review.map(|d| d.to_rfc3339()),
|
||||
"source": n.source,
|
||||
"tags": n.tags,
|
||||
"hasEmbedding": n.has_embedding,
|
||||
"embeddingModel": n.embedding_model,
|
||||
}
|
||||
})),
|
||||
None => Ok(serde_json::json!({
|
||||
"found": false,
|
||||
"nodeId": args.id,
|
||||
"message": "Node not found",
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_delete(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: KnowledgeArgs = match args {
|
||||
Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?,
|
||||
None => return Err("Missing arguments".to_string()),
|
||||
};
|
||||
|
||||
// Validate UUID
|
||||
uuid::Uuid::parse_str(&args.id).map_err(|_| "Invalid node ID format".to_string())?;
|
||||
|
||||
let mut storage = storage.lock().await;
|
||||
let deleted = storage.delete_node(&args.id).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": deleted,
|
||||
"nodeId": args.id,
|
||||
"message": if deleted { "Node deleted successfully" } else { "Node not found" },
|
||||
}))
|
||||
}
|
||||
277
crates/vestige-mcp/src/tools/memory_states.rs
Normal file
277
crates/vestige-mcp/src/tools/memory_states.rs
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
//! Memory States Tool
|
||||
//!
|
||||
//! Query and manage memory states (Active, Dormant, Silent, Unavailable).
|
||||
//! Based on accessibility continuum theory.
|
||||
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::{MemoryState, Storage};
|
||||
|
||||
// Accessibility thresholds based on retention strength
|
||||
const ACCESSIBILITY_ACTIVE: f64 = 0.7;
|
||||
const ACCESSIBILITY_DORMANT: f64 = 0.4;
|
||||
const ACCESSIBILITY_SILENT: f64 = 0.1;
|
||||
|
||||
/// Compute accessibility score from memory strengths
|
||||
/// Combines retention, retrieval, and storage strengths
|
||||
fn compute_accessibility(retention: f64, retrieval: f64, storage: f64) -> f64 {
|
||||
// Weighted combination: retention is most important for accessibility
|
||||
retention * 0.5 + retrieval * 0.3 + storage * 0.2
|
||||
}
|
||||
|
||||
/// Determine memory state from accessibility score
|
||||
fn state_from_accessibility(accessibility: f64) -> MemoryState {
|
||||
if accessibility >= ACCESSIBILITY_ACTIVE {
|
||||
MemoryState::Active
|
||||
} else if accessibility >= ACCESSIBILITY_DORMANT {
|
||||
MemoryState::Dormant
|
||||
} else if accessibility >= ACCESSIBILITY_SILENT {
|
||||
MemoryState::Silent
|
||||
} else {
|
||||
MemoryState::Unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/// Input schema for get_memory_state tool
|
||||
pub fn get_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"memory_id": {
|
||||
"type": "string",
|
||||
"description": "The memory ID to check state for"
|
||||
}
|
||||
},
|
||||
"required": ["memory_id"]
|
||||
})
|
||||
}
|
||||
|
||||
/// Input schema for list_by_state tool
|
||||
pub fn list_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": ["active", "dormant", "silent", "unavailable"],
|
||||
"description": "Filter memories by state"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum results (default: 20)"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
})
|
||||
}
|
||||
|
||||
/// Input schema for state_stats tool
|
||||
pub fn stats_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the cognitive state of a specific memory
|
||||
pub async fn execute_get(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args = args.ok_or("Missing arguments")?;
|
||||
|
||||
let memory_id = args["memory_id"]
|
||||
.as_str()
|
||||
.ok_or("memory_id is required")?;
|
||||
|
||||
let storage = storage.lock().await;
|
||||
|
||||
// Get the memory
|
||||
let memory = storage.get_node(memory_id)
|
||||
.map_err(|e| format!("Error: {}", e))?
|
||||
.ok_or("Memory not found")?;
|
||||
|
||||
// Calculate accessibility score
|
||||
let accessibility = compute_accessibility(
|
||||
memory.retention_strength,
|
||||
memory.retrieval_strength,
|
||||
memory.storage_strength,
|
||||
);
|
||||
|
||||
// Determine state
|
||||
let state = state_from_accessibility(accessibility);
|
||||
|
||||
let state_description = match state {
|
||||
MemoryState::Active => "Easily retrievable - this memory is fresh and accessible",
|
||||
MemoryState::Dormant => "Retrievable with effort - may need cues to recall",
|
||||
MemoryState::Silent => "Difficult to retrieve - exists but hard to access",
|
||||
MemoryState::Unavailable => "Cannot be retrieved - needs significant reinforcement",
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"memoryId": memory_id,
|
||||
"content": memory.content,
|
||||
"state": format!("{:?}", state),
|
||||
"accessibility": accessibility,
|
||||
"description": state_description,
|
||||
"components": {
|
||||
"retentionStrength": memory.retention_strength,
|
||||
"retrievalStrength": memory.retrieval_strength,
|
||||
"storageStrength": memory.storage_strength
|
||||
},
|
||||
"thresholds": {
|
||||
"active": ACCESSIBILITY_ACTIVE,
|
||||
"dormant": ACCESSIBILITY_DORMANT,
|
||||
"silent": ACCESSIBILITY_SILENT
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// List memories by state
|
||||
pub async fn execute_list(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args = args.unwrap_or(serde_json::json!({}));
|
||||
|
||||
let state_filter = args["state"].as_str();
|
||||
let limit = args["limit"].as_i64().unwrap_or(20) as usize;
|
||||
|
||||
let storage = storage.lock().await;
|
||||
|
||||
// Get all memories
|
||||
let memories = storage.get_all_nodes(500, 0)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Categorize by state
|
||||
let mut active = Vec::new();
|
||||
let mut dormant = Vec::new();
|
||||
let mut silent = Vec::new();
|
||||
let mut unavailable = Vec::new();
|
||||
|
||||
for memory in memories {
|
||||
let accessibility = compute_accessibility(
|
||||
memory.retention_strength,
|
||||
memory.retrieval_strength,
|
||||
memory.storage_strength,
|
||||
);
|
||||
|
||||
let entry = serde_json::json!({
|
||||
"id": memory.id,
|
||||
"content": memory.content,
|
||||
"accessibility": accessibility,
|
||||
"retentionStrength": memory.retention_strength
|
||||
});
|
||||
|
||||
let state = state_from_accessibility(accessibility);
|
||||
match state {
|
||||
MemoryState::Active => active.push(entry),
|
||||
MemoryState::Dormant => dormant.push(entry),
|
||||
MemoryState::Silent => silent.push(entry),
|
||||
MemoryState::Unavailable => unavailable.push(entry),
|
||||
}
|
||||
}
|
||||
|
||||
// Apply filter and limit
|
||||
let result = match state_filter {
|
||||
Some("active") => serde_json::json!({
|
||||
"state": "active",
|
||||
"count": active.len(),
|
||||
"memories": active.into_iter().take(limit).collect::<Vec<_>>()
|
||||
}),
|
||||
Some("dormant") => serde_json::json!({
|
||||
"state": "dormant",
|
||||
"count": dormant.len(),
|
||||
"memories": dormant.into_iter().take(limit).collect::<Vec<_>>()
|
||||
}),
|
||||
Some("silent") => serde_json::json!({
|
||||
"state": "silent",
|
||||
"count": silent.len(),
|
||||
"memories": silent.into_iter().take(limit).collect::<Vec<_>>()
|
||||
}),
|
||||
Some("unavailable") => serde_json::json!({
|
||||
"state": "unavailable",
|
||||
"count": unavailable.len(),
|
||||
"memories": unavailable.into_iter().take(limit).collect::<Vec<_>>()
|
||||
}),
|
||||
_ => serde_json::json!({
|
||||
"all": true,
|
||||
"active": { "count": active.len(), "memories": active.into_iter().take(limit).collect::<Vec<_>>() },
|
||||
"dormant": { "count": dormant.len(), "memories": dormant.into_iter().take(limit).collect::<Vec<_>>() },
|
||||
"silent": { "count": silent.len(), "memories": silent.into_iter().take(limit).collect::<Vec<_>>() },
|
||||
"unavailable": { "count": unavailable.len(), "memories": unavailable.into_iter().take(limit).collect::<Vec<_>>() }
|
||||
})
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Get memory state statistics
|
||||
pub async fn execute_stats(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
) -> Result<Value, String> {
|
||||
let storage = storage.lock().await;
|
||||
|
||||
let memories = storage.get_all_nodes(1000, 0)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let total = memories.len();
|
||||
let mut active_count = 0;
|
||||
let mut dormant_count = 0;
|
||||
let mut silent_count = 0;
|
||||
let mut unavailable_count = 0;
|
||||
let mut total_accessibility = 0.0;
|
||||
|
||||
for memory in &memories {
|
||||
let accessibility = compute_accessibility(
|
||||
memory.retention_strength,
|
||||
memory.retrieval_strength,
|
||||
memory.storage_strength,
|
||||
);
|
||||
total_accessibility += accessibility;
|
||||
|
||||
let state = state_from_accessibility(accessibility);
|
||||
match state {
|
||||
MemoryState::Active => active_count += 1,
|
||||
MemoryState::Dormant => dormant_count += 1,
|
||||
MemoryState::Silent => silent_count += 1,
|
||||
MemoryState::Unavailable => unavailable_count += 1,
|
||||
}
|
||||
}
|
||||
|
||||
let avg_accessibility = if total > 0 { total_accessibility / total as f64 } else { 0.0 };
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"totalMemories": total,
|
||||
"averageAccessibility": avg_accessibility,
|
||||
"stateDistribution": {
|
||||
"active": {
|
||||
"count": active_count,
|
||||
"percentage": if total > 0 { (active_count as f64 / total as f64) * 100.0 } else { 0.0 }
|
||||
},
|
||||
"dormant": {
|
||||
"count": dormant_count,
|
||||
"percentage": if total > 0 { (dormant_count as f64 / total as f64) * 100.0 } else { 0.0 }
|
||||
},
|
||||
"silent": {
|
||||
"count": silent_count,
|
||||
"percentage": if total > 0 { (silent_count as f64 / total as f64) * 100.0 } else { 0.0 }
|
||||
},
|
||||
"unavailable": {
|
||||
"count": unavailable_count,
|
||||
"percentage": if total > 0 { (unavailable_count as f64 / total as f64) * 100.0 } else { 0.0 }
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"active": ACCESSIBILITY_ACTIVE,
|
||||
"dormant": ACCESSIBILITY_DORMANT,
|
||||
"silent": ACCESSIBILITY_SILENT
|
||||
},
|
||||
"science": {
|
||||
"theory": "Accessibility Continuum (Tulving, 1983)",
|
||||
"principle": "Memories exist on a continuum from highly accessible to completely inaccessible"
|
||||
}
|
||||
}))
|
||||
}
|
||||
18
crates/vestige-mcp/src/tools/mod.rs
Normal file
18
crates/vestige-mcp/src/tools/mod.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
//! MCP Tools
|
||||
//!
|
||||
//! Tool implementations for the Vestige MCP server.
|
||||
|
||||
pub mod codebase;
|
||||
pub mod consolidate;
|
||||
pub mod ingest;
|
||||
pub mod intentions;
|
||||
pub mod knowledge;
|
||||
pub mod recall;
|
||||
pub mod review;
|
||||
pub mod search;
|
||||
pub mod stats;
|
||||
|
||||
// Neuroscience-inspired tools
|
||||
pub mod context;
|
||||
pub mod memory_states;
|
||||
pub mod tagging;
|
||||
403
crates/vestige-mcp/src/tools/recall.rs
Normal file
403
crates/vestige-mcp/src/tools/recall.rs
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
//! Recall Tool
|
||||
//!
|
||||
//! Search and retrieve knowledge from memory.
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::{RecallInput, SearchMode, Storage};
|
||||
|
||||
/// Input schema for recall tool
|
||||
pub fn schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of results (default: 10)",
|
||||
"default": 10,
|
||||
"minimum": 1,
|
||||
"maximum": 100
|
||||
},
|
||||
"min_retention": {
|
||||
"type": "number",
|
||||
"description": "Minimum retention strength (0.0-1.0, default: 0.0)",
|
||||
"default": 0.0,
|
||||
"minimum": 0.0,
|
||||
"maximum": 1.0
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RecallArgs {
|
||||
query: String,
|
||||
limit: Option<i32>,
|
||||
min_retention: Option<f64>,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: RecallArgs = match args {
|
||||
Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?,
|
||||
None => return Err("Missing arguments".to_string()),
|
||||
};
|
||||
|
||||
if args.query.trim().is_empty() {
|
||||
return Err("Query cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let input = RecallInput {
|
||||
query: args.query.clone(),
|
||||
limit: args.limit.unwrap_or(10).clamp(1, 100),
|
||||
min_retention: args.min_retention.unwrap_or(0.0).clamp(0.0, 1.0),
|
||||
search_mode: SearchMode::Hybrid,
|
||||
valid_at: None,
|
||||
};
|
||||
|
||||
let storage = storage.lock().await;
|
||||
let nodes = storage.recall(input).map_err(|e| e.to_string())?;
|
||||
|
||||
let results: Vec<Value> = nodes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
serde_json::json!({
|
||||
"id": n.id,
|
||||
"content": n.content,
|
||||
"nodeType": n.node_type,
|
||||
"retentionStrength": n.retention_strength,
|
||||
"stability": n.stability,
|
||||
"difficulty": n.difficulty,
|
||||
"reps": n.reps,
|
||||
"tags": n.tags,
|
||||
"source": n.source,
|
||||
"createdAt": n.created_at.to_rfc3339(),
|
||||
"lastAccessed": n.last_accessed.to_rfc3339(),
|
||||
"nextReview": n.next_review.map(|d| d.to_rfc3339()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"query": args.query,
|
||||
"total": results.len(),
|
||||
"results": results,
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TESTS
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vestige_core::IngestInput;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Create a test storage instance with a temporary database
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
}
|
||||
|
||||
/// Helper to ingest test content
|
||||
async fn ingest_test_content(storage: &Arc<Mutex<Storage>>, content: &str) -> String {
|
||||
let input = IngestInput {
|
||||
content: content.to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
source: None,
|
||||
sentiment_score: 0.0,
|
||||
sentiment_magnitude: 0.0,
|
||||
tags: vec![],
|
||||
valid_from: None,
|
||||
valid_until: None,
|
||||
};
|
||||
let mut storage_lock = storage.lock().await;
|
||||
let node = storage_lock.ingest(input).unwrap();
|
||||
node.id
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// QUERY VALIDATION TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_recall_empty_query_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "query": "" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("empty"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_recall_whitespace_only_query_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "query": " \t\n " });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("empty"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_recall_missing_arguments_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, None).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Missing arguments"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_recall_missing_query_field_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "limit": 10 });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid arguments"));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// LIMIT CLAMPING TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_recall_limit_clamped_to_minimum() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
// Ingest some content first
|
||||
ingest_test_content(&storage, "Test content for limit clamping").await;
|
||||
|
||||
// Try with limit 0 - should clamp to 1
|
||||
let args = serde_json::json!({
|
||||
"query": "test",
|
||||
"limit": 0
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_recall_limit_clamped_to_maximum() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
// Ingest some content first
|
||||
ingest_test_content(&storage, "Test content for max limit").await;
|
||||
|
||||
// Try with limit 1000 - should clamp to 100
|
||||
let args = serde_json::json!({
|
||||
"query": "test",
|
||||
"limit": 1000
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_recall_negative_limit_clamped() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_test_content(&storage, "Test content for negative limit").await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"query": "test",
|
||||
"limit": -5
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// MIN_RETENTION CLAMPING TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_recall_min_retention_clamped_to_zero() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_test_content(&storage, "Test content for retention clamping").await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"query": "test",
|
||||
"min_retention": -0.5
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_recall_min_retention_clamped_to_one() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_test_content(&storage, "Test content for max retention").await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"query": "test",
|
||||
"min_retention": 1.5
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
// Should succeed but return no results (retention > 1.0 clamped to 1.0)
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SUCCESSFUL RECALL TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_recall_basic_query_succeeds() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_test_content(&storage, "The Rust programming language is memory safe.").await;
|
||||
|
||||
let args = serde_json::json!({ "query": "rust" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["query"], "rust");
|
||||
assert!(value["total"].is_number());
|
||||
assert!(value["results"].is_array());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_recall_returns_matching_content() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let node_id = ingest_test_content(&storage, "Python is a dynamic programming language.").await;
|
||||
|
||||
let args = serde_json::json!({ "query": "python" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
let results = value["results"].as_array().unwrap();
|
||||
assert!(!results.is_empty());
|
||||
assert_eq!(results[0]["id"], node_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_recall_with_limit() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
// Ingest multiple items
|
||||
ingest_test_content(&storage, "Testing content one").await;
|
||||
ingest_test_content(&storage, "Testing content two").await;
|
||||
ingest_test_content(&storage, "Testing content three").await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"query": "testing",
|
||||
"limit": 2
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
let results = value["results"].as_array().unwrap();
|
||||
assert!(results.len() <= 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_recall_empty_database_returns_empty_array() {
|
||||
// With hybrid search (keyword + semantic), any query against content
|
||||
// may return low-similarity matches. The true "no matches" case
|
||||
// is an empty database.
|
||||
let (storage, _dir) = test_storage().await;
|
||||
// Don't ingest anything - database is empty
|
||||
|
||||
let args = serde_json::json!({ "query": "anything" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["total"], 0);
|
||||
assert!(value["results"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_recall_result_contains_expected_fields() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_test_content(&storage, "Testing field presence in recall results.").await;
|
||||
|
||||
let args = serde_json::json!({ "query": "testing" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
let results = value["results"].as_array().unwrap();
|
||||
if !results.is_empty() {
|
||||
let first = &results[0];
|
||||
assert!(first["id"].is_string());
|
||||
assert!(first["content"].is_string());
|
||||
assert!(first["nodeType"].is_string());
|
||||
assert!(first["retentionStrength"].is_number());
|
||||
assert!(first["stability"].is_number());
|
||||
assert!(first["difficulty"].is_number());
|
||||
assert!(first["reps"].is_number());
|
||||
assert!(first["createdAt"].is_string());
|
||||
assert!(first["lastAccessed"].is_string());
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// DEFAULT VALUES TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_recall_default_limit_is_10() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
// Ingest more than 10 items
|
||||
for i in 0..15 {
|
||||
ingest_test_content(&storage, &format!("Item number {}", i)).await;
|
||||
}
|
||||
|
||||
let args = serde_json::json!({ "query": "item" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
let results = value["results"].as_array().unwrap();
|
||||
assert!(results.len() <= 10);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SCHEMA TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_schema_has_required_fields() {
|
||||
let schema_value = schema();
|
||||
assert_eq!(schema_value["type"], "object");
|
||||
assert!(schema_value["properties"]["query"].is_object());
|
||||
assert!(schema_value["required"].as_array().unwrap().contains(&serde_json::json!("query")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_has_optional_fields() {
|
||||
let schema_value = schema();
|
||||
assert!(schema_value["properties"]["limit"].is_object());
|
||||
assert!(schema_value["properties"]["min_retention"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_limit_has_bounds() {
|
||||
let schema_value = schema();
|
||||
let limit_schema = &schema_value["properties"]["limit"];
|
||||
assert_eq!(limit_schema["minimum"], 1);
|
||||
assert_eq!(limit_schema["maximum"], 100);
|
||||
assert_eq!(limit_schema["default"], 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_min_retention_has_bounds() {
|
||||
let schema_value = schema();
|
||||
let retention_schema = &schema_value["properties"]["min_retention"];
|
||||
assert_eq!(retention_schema["minimum"], 0.0);
|
||||
assert_eq!(retention_schema["maximum"], 1.0);
|
||||
assert_eq!(retention_schema["default"], 0.0);
|
||||
}
|
||||
}
|
||||
454
crates/vestige-mcp/src/tools/review.rs
Normal file
454
crates/vestige-mcp/src/tools/review.rs
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
//! Review Tool
|
||||
//!
|
||||
//! Mark memories as reviewed using FSRS-6 algorithm.
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::{Rating, Storage};
|
||||
|
||||
/// Input schema for mark_reviewed tool
|
||||
pub fn schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The ID of the memory to review"
|
||||
},
|
||||
"rating": {
|
||||
"type": "integer",
|
||||
"description": "Review rating: 1=Again (forgot), 2=Hard, 3=Good, 4=Easy",
|
||||
"minimum": 1,
|
||||
"maximum": 4,
|
||||
"default": 3
|
||||
}
|
||||
},
|
||||
"required": ["id"]
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ReviewArgs {
|
||||
id: String,
|
||||
rating: Option<i32>,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: ReviewArgs = match args {
|
||||
Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?,
|
||||
None => return Err("Missing arguments".to_string()),
|
||||
};
|
||||
|
||||
// Validate UUID
|
||||
uuid::Uuid::parse_str(&args.id).map_err(|_| "Invalid node ID format".to_string())?;
|
||||
|
||||
let rating_value = args.rating.unwrap_or(3);
|
||||
if !(1..=4).contains(&rating_value) {
|
||||
return Err("Rating must be between 1 and 4".to_string());
|
||||
}
|
||||
|
||||
let rating = Rating::from_i32(rating_value)
|
||||
.ok_or_else(|| "Invalid rating value".to_string())?;
|
||||
|
||||
let mut storage = storage.lock().await;
|
||||
|
||||
// Get node before review for comparison
|
||||
let before = storage.get_node(&args.id).map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Node not found: {}", args.id))?;
|
||||
|
||||
let node = storage.mark_reviewed(&args.id, rating).map_err(|e| e.to_string())?;
|
||||
|
||||
let rating_name = match rating {
|
||||
Rating::Again => "Again",
|
||||
Rating::Hard => "Hard",
|
||||
Rating::Good => "Good",
|
||||
Rating::Easy => "Easy",
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"nodeId": node.id,
|
||||
"rating": rating_name,
|
||||
"fsrs": {
|
||||
"previousRetention": before.retention_strength,
|
||||
"newRetention": node.retention_strength,
|
||||
"previousStability": before.stability,
|
||||
"newStability": node.stability,
|
||||
"difficulty": node.difficulty,
|
||||
"reps": node.reps,
|
||||
"lapses": node.lapses,
|
||||
},
|
||||
"nextReview": node.next_review.map(|d| d.to_rfc3339()),
|
||||
"message": format!("Memory reviewed with rating '{}'. Retention: {:.2} -> {:.2}",
|
||||
rating_name, before.retention_strength, node.retention_strength),
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TESTS
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vestige_core::IngestInput;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Create a test storage instance with a temporary database
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
}
|
||||
|
||||
/// Helper to ingest test content and return node ID
|
||||
async fn ingest_test_content(storage: &Arc<Mutex<Storage>>, content: &str) -> String {
|
||||
let input = IngestInput {
|
||||
content: content.to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
source: None,
|
||||
sentiment_score: 0.0,
|
||||
sentiment_magnitude: 0.0,
|
||||
tags: vec![],
|
||||
valid_from: None,
|
||||
valid_until: None,
|
||||
};
|
||||
let mut storage_lock = storage.lock().await;
|
||||
let node = storage_lock.ingest(input).unwrap();
|
||||
node.id
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// RATING VALIDATION TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_review_rating_zero_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let node_id = ingest_test_content(&storage, "Test content for rating validation").await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"id": node_id,
|
||||
"rating": 0
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("between 1 and 4"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_review_rating_five_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let node_id = ingest_test_content(&storage, "Test content for high rating").await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"id": node_id,
|
||||
"rating": 5
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("between 1 and 4"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_review_rating_negative_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let node_id = ingest_test_content(&storage, "Test content for negative rating").await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"id": node_id,
|
||||
"rating": -1
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("between 1 and 4"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_review_rating_very_high_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let node_id = ingest_test_content(&storage, "Test content for very high rating").await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"id": node_id,
|
||||
"rating": 100
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("between 1 and 4"));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// VALID RATINGS TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_review_rating_again_succeeds() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let node_id = ingest_test_content(&storage, "Test content for Again rating").await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"id": node_id,
|
||||
"rating": 1
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["rating"], "Again");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_review_rating_hard_succeeds() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let node_id = ingest_test_content(&storage, "Test content for Hard rating").await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"id": node_id,
|
||||
"rating": 2
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["rating"], "Hard");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_review_rating_good_succeeds() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let node_id = ingest_test_content(&storage, "Test content for Good rating").await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"id": node_id,
|
||||
"rating": 3
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["rating"], "Good");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_review_rating_easy_succeeds() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let node_id = ingest_test_content(&storage, "Test content for Easy rating").await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"id": node_id,
|
||||
"rating": 4
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["rating"], "Easy");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// NODE ID VALIDATION TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_review_invalid_uuid_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"id": "not-a-valid-uuid",
|
||||
"rating": 3
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid node ID"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_review_nonexistent_node_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let fake_uuid = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let args = serde_json::json!({
|
||||
"id": fake_uuid,
|
||||
"rating": 3
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_review_missing_id_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"rating": 3
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid arguments"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_review_missing_arguments_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, None).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Missing arguments"));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// FSRS UPDATE TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_review_updates_reps_counter() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let node_id = ingest_test_content(&storage, "Test content for reps counter").await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"id": node_id,
|
||||
"rating": 3
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["fsrs"]["reps"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_review_multiple_times_increases_reps() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let node_id = ingest_test_content(&storage, "Test content for multiple reviews").await;
|
||||
|
||||
// Review first time
|
||||
let args = serde_json::json!({ "id": node_id, "rating": 3 });
|
||||
execute(&storage, Some(args)).await.unwrap();
|
||||
|
||||
// Review second time
|
||||
let args = serde_json::json!({ "id": node_id, "rating": 3 });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["fsrs"]["reps"], 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_same_day_again_does_not_count_as_lapse() {
|
||||
// FSRS-6 treats same-day reviews differently - they don't increment lapses.
|
||||
// This is by design: same-day reviews indicate the user is still learning,
|
||||
// not that they've forgotten and need to re-learn (which is what lapses track).
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let node_id = ingest_test_content(&storage, "Test content for lapses").await;
|
||||
|
||||
// First review to get out of new state
|
||||
let args = serde_json::json!({ "id": node_id, "rating": 3 });
|
||||
execute(&storage, Some(args)).await.unwrap();
|
||||
|
||||
// Immediate "Again" rating (same-day) should NOT count as a lapse
|
||||
let args = serde_json::json!({ "id": node_id, "rating": 1 });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
// Same-day reviews preserve lapse count per FSRS-6 algorithm
|
||||
assert_eq!(value["fsrs"]["lapses"].as_i64().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_review_returns_next_review_date() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let node_id = ingest_test_content(&storage, "Test content for next review").await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"id": node_id,
|
||||
"rating": 3
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
assert!(value["nextReview"].is_string());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// DEFAULT RATING TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_review_default_rating_is_good() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let node_id = ingest_test_content(&storage, "Test content for default rating").await;
|
||||
|
||||
// Omit rating, should default to 3 (Good)
|
||||
let args = serde_json::json!({
|
||||
"id": node_id
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["rating"], "Good");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// RESPONSE FORMAT TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_review_response_contains_expected_fields() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let node_id = ingest_test_content(&storage, "Test content for response format").await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"id": node_id,
|
||||
"rating": 3
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["success"], true);
|
||||
assert!(value["nodeId"].is_string());
|
||||
assert!(value["rating"].is_string());
|
||||
assert!(value["fsrs"].is_object());
|
||||
assert!(value["fsrs"]["previousRetention"].is_number());
|
||||
assert!(value["fsrs"]["newRetention"].is_number());
|
||||
assert!(value["fsrs"]["previousStability"].is_number());
|
||||
assert!(value["fsrs"]["newStability"].is_number());
|
||||
assert!(value["fsrs"]["difficulty"].is_number());
|
||||
assert!(value["fsrs"]["reps"].is_number());
|
||||
assert!(value["fsrs"]["lapses"].is_number());
|
||||
assert!(value["message"].is_string());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SCHEMA TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_schema_has_required_fields() {
|
||||
let schema_value = schema();
|
||||
assert_eq!(schema_value["type"], "object");
|
||||
assert!(schema_value["properties"]["id"].is_object());
|
||||
assert!(schema_value["required"].as_array().unwrap().contains(&serde_json::json!("id")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_rating_has_bounds() {
|
||||
let schema_value = schema();
|
||||
let rating_schema = &schema_value["properties"]["rating"];
|
||||
assert_eq!(rating_schema["minimum"], 1);
|
||||
assert_eq!(rating_schema["maximum"], 4);
|
||||
assert_eq!(rating_schema["default"], 3);
|
||||
}
|
||||
}
|
||||
192
crates/vestige-mcp/src/tools/search.rs
Normal file
192
crates/vestige-mcp/src/tools/search.rs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
//! Search Tools
|
||||
//!
|
||||
//! Semantic and hybrid search implementations.
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::Storage;
|
||||
|
||||
/// Input schema for semantic_search tool
|
||||
pub fn semantic_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query for semantic similarity"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of results (default: 10)",
|
||||
"default": 10,
|
||||
"minimum": 1,
|
||||
"maximum": 50
|
||||
},
|
||||
"min_similarity": {
|
||||
"type": "number",
|
||||
"description": "Minimum similarity threshold (0.0-1.0, default: 0.5)",
|
||||
"default": 0.5,
|
||||
"minimum": 0.0,
|
||||
"maximum": 1.0
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
})
|
||||
}
|
||||
|
||||
/// Input schema for hybrid_search tool
|
||||
pub fn hybrid_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of results (default: 10)",
|
||||
"default": 10,
|
||||
"minimum": 1,
|
||||
"maximum": 50
|
||||
},
|
||||
"keyword_weight": {
|
||||
"type": "number",
|
||||
"description": "Weight for keyword search (0.0-1.0, default: 0.5)",
|
||||
"default": 0.5,
|
||||
"minimum": 0.0,
|
||||
"maximum": 1.0
|
||||
},
|
||||
"semantic_weight": {
|
||||
"type": "number",
|
||||
"description": "Weight for semantic search (0.0-1.0, default: 0.5)",
|
||||
"default": 0.5,
|
||||
"minimum": 0.0,
|
||||
"maximum": 1.0
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SemanticSearchArgs {
|
||||
query: String,
|
||||
limit: Option<i32>,
|
||||
min_similarity: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HybridSearchArgs {
|
||||
query: String,
|
||||
limit: Option<i32>,
|
||||
keyword_weight: Option<f32>,
|
||||
semantic_weight: Option<f32>,
|
||||
}
|
||||
|
||||
pub async fn execute_semantic(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: SemanticSearchArgs = match args {
|
||||
Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?,
|
||||
None => return Err("Missing arguments".to_string()),
|
||||
};
|
||||
|
||||
if args.query.trim().is_empty() {
|
||||
return Err("Query cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let storage = storage.lock().await;
|
||||
|
||||
// Check if embeddings are ready
|
||||
if !storage.is_embedding_ready() {
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Embedding service not ready",
|
||||
"hint": "Run consolidation first to initialize embeddings, or the model may still be loading.",
|
||||
}));
|
||||
}
|
||||
|
||||
let results = storage
|
||||
.semantic_search(
|
||||
&args.query,
|
||||
args.limit.unwrap_or(10).clamp(1, 50),
|
||||
args.min_similarity.unwrap_or(0.5).clamp(0.0, 1.0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let formatted: Vec<Value> = results
|
||||
.iter()
|
||||
.map(|r| {
|
||||
serde_json::json!({
|
||||
"id": r.node.id,
|
||||
"content": r.node.content,
|
||||
"similarity": r.similarity,
|
||||
"nodeType": r.node.node_type,
|
||||
"tags": r.node.tags,
|
||||
"retentionStrength": r.node.retention_strength,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"query": args.query,
|
||||
"method": "semantic",
|
||||
"total": formatted.len(),
|
||||
"results": formatted,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn execute_hybrid(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: HybridSearchArgs = match args {
|
||||
Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?,
|
||||
None => return Err("Missing arguments".to_string()),
|
||||
};
|
||||
|
||||
if args.query.trim().is_empty() {
|
||||
return Err("Query cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let storage = storage.lock().await;
|
||||
|
||||
let results = storage
|
||||
.hybrid_search(
|
||||
&args.query,
|
||||
args.limit.unwrap_or(10).clamp(1, 50),
|
||||
args.keyword_weight.unwrap_or(0.5).clamp(0.0, 1.0),
|
||||
args.semantic_weight.unwrap_or(0.5).clamp(0.0, 1.0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let formatted: Vec<Value> = results
|
||||
.iter()
|
||||
.map(|r| {
|
||||
serde_json::json!({
|
||||
"id": r.node.id,
|
||||
"content": r.node.content,
|
||||
"combinedScore": r.combined_score,
|
||||
"keywordScore": r.keyword_score,
|
||||
"semanticScore": r.semantic_score,
|
||||
"matchType": format!("{:?}", r.match_type),
|
||||
"nodeType": r.node.node_type,
|
||||
"tags": r.node.tags,
|
||||
"retentionStrength": r.node.retention_strength,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"query": args.query,
|
||||
"method": "hybrid",
|
||||
"total": formatted.len(),
|
||||
"results": formatted,
|
||||
}))
|
||||
}
|
||||
123
crates/vestige-mcp/src/tools/stats.rs
Normal file
123
crates/vestige-mcp/src/tools/stats.rs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
//! Stats Tools
|
||||
//!
|
||||
//! Memory statistics and health check.
|
||||
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::{MemoryStats, Storage};
|
||||
|
||||
/// Input schema for get_stats tool
|
||||
pub fn stats_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
})
|
||||
}
|
||||
|
||||
/// Input schema for health_check tool
|
||||
pub fn health_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn execute_stats(storage: &Arc<Mutex<Storage>>) -> Result<Value, String> {
|
||||
let storage = storage.lock().await;
|
||||
let stats = storage.get_stats().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"totalNodes": stats.total_nodes,
|
||||
"nodesDueForReview": stats.nodes_due_for_review,
|
||||
"averageRetention": stats.average_retention,
|
||||
"averageStorageStrength": stats.average_storage_strength,
|
||||
"averageRetrievalStrength": stats.average_retrieval_strength,
|
||||
"oldestMemory": stats.oldest_memory.map(|d| d.to_rfc3339()),
|
||||
"newestMemory": stats.newest_memory.map(|d| d.to_rfc3339()),
|
||||
"nodesWithEmbeddings": stats.nodes_with_embeddings,
|
||||
"embeddingModel": stats.embedding_model,
|
||||
"embeddingServiceReady": storage.is_embedding_ready(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn execute_health(storage: &Arc<Mutex<Storage>>) -> Result<Value, String> {
|
||||
let storage = storage.lock().await;
|
||||
let stats = storage.get_stats().map_err(|e| e.to_string())?;
|
||||
|
||||
// Determine health status
|
||||
let status = if stats.total_nodes == 0 {
|
||||
"empty"
|
||||
} else if stats.average_retention < 0.3 {
|
||||
"critical"
|
||||
} else if stats.average_retention < 0.5 {
|
||||
"degraded"
|
||||
} else {
|
||||
"healthy"
|
||||
};
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
if stats.average_retention < 0.5 && stats.total_nodes > 0 {
|
||||
warnings.push("Low average retention - consider running consolidation or reviewing memories".to_string());
|
||||
}
|
||||
|
||||
if stats.nodes_due_for_review > 10 {
|
||||
warnings.push(format!("{} memories are due for review", stats.nodes_due_for_review));
|
||||
}
|
||||
|
||||
if stats.total_nodes > 0 && stats.nodes_with_embeddings == 0 {
|
||||
warnings.push("No embeddings generated - semantic search unavailable. Run consolidation.".to_string());
|
||||
}
|
||||
|
||||
let embedding_coverage = if stats.total_nodes > 0 {
|
||||
(stats.nodes_with_embeddings as f64 / stats.total_nodes as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
if embedding_coverage < 50.0 && stats.total_nodes > 10 {
|
||||
warnings.push(format!("Only {:.1}% of memories have embeddings", embedding_coverage));
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": status,
|
||||
"totalNodes": stats.total_nodes,
|
||||
"nodesDueForReview": stats.nodes_due_for_review,
|
||||
"averageRetention": stats.average_retention,
|
||||
"embeddingCoverage": format!("{:.1}%", embedding_coverage),
|
||||
"embeddingServiceReady": storage.is_embedding_ready(),
|
||||
"warnings": warnings,
|
||||
"recommendations": get_recommendations(&stats, status),
|
||||
}))
|
||||
}
|
||||
|
||||
fn get_recommendations(
|
||||
stats: &MemoryStats,
|
||||
status: &str,
|
||||
) -> Vec<String> {
|
||||
let mut recommendations = Vec::new();
|
||||
|
||||
if status == "critical" {
|
||||
recommendations.push("CRITICAL: Many memories have very low retention. Review important memories with 'mark_reviewed'.".to_string());
|
||||
}
|
||||
|
||||
if stats.nodes_due_for_review > 5 {
|
||||
recommendations.push("Review due memories to strengthen retention.".to_string());
|
||||
}
|
||||
|
||||
if stats.nodes_with_embeddings < stats.total_nodes {
|
||||
recommendations.push("Run 'run_consolidation' to generate embeddings for better semantic search.".to_string());
|
||||
}
|
||||
|
||||
if stats.total_nodes > 100 && stats.average_retention < 0.7 {
|
||||
recommendations.push("Consider running periodic consolidation to maintain memory health.".to_string());
|
||||
}
|
||||
|
||||
if recommendations.is_empty() {
|
||||
recommendations.push("Memory system is healthy!".to_string());
|
||||
}
|
||||
|
||||
recommendations
|
||||
}
|
||||
250
crates/vestige-mcp/src/tools/tagging.rs
Normal file
250
crates/vestige-mcp/src/tools/tagging.rs
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
//! Synaptic Tagging Tool
|
||||
//!
|
||||
//! Retroactive importance assignment based on Synaptic Tagging & Capture theory.
|
||||
//! Frey & Morris (1997), Redondo & Morris (2011).
|
||||
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::{
|
||||
CaptureWindow, ImportanceEvent, ImportanceEventType,
|
||||
SynapticTaggingConfig, SynapticTaggingSystem, Storage,
|
||||
};
|
||||
|
||||
/// Input schema for trigger_importance tool
|
||||
pub fn trigger_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"event_type": {
|
||||
"type": "string",
|
||||
"enum": ["user_flag", "emotional", "novelty", "repeated_access", "cross_reference"],
|
||||
"description": "Type of importance event"
|
||||
},
|
||||
"memory_id": {
|
||||
"type": "string",
|
||||
"description": "The memory that triggered the importance signal"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Description of why this is important (optional)"
|
||||
},
|
||||
"hours_back": {
|
||||
"type": "number",
|
||||
"description": "How many hours back to look for related memories (default: 9)"
|
||||
},
|
||||
"hours_forward": {
|
||||
"type": "number",
|
||||
"description": "How many hours forward to capture (default: 2)"
|
||||
}
|
||||
},
|
||||
"required": ["event_type", "memory_id"]
|
||||
})
|
||||
}
|
||||
|
||||
/// Input schema for find_tagged tool
|
||||
pub fn find_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min_strength": {
|
||||
"type": "number",
|
||||
"description": "Minimum tag strength (0.0-1.0, default: 0.3)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum results (default: 20)"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
})
|
||||
}
|
||||
|
||||
/// Input schema for tag_stats tool
|
||||
pub fn stats_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
})
|
||||
}
|
||||
|
||||
/// Trigger an importance event to retroactively strengthen recent memories
|
||||
pub async fn execute_trigger(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args = args.ok_or("Missing arguments")?;
|
||||
|
||||
let event_type_str = args["event_type"]
|
||||
.as_str()
|
||||
.ok_or("event_type is required")?;
|
||||
|
||||
let memory_id = args["memory_id"]
|
||||
.as_str()
|
||||
.ok_or("memory_id is required")?;
|
||||
|
||||
let description = args["description"].as_str();
|
||||
let hours_back = args["hours_back"].as_f64().unwrap_or(9.0);
|
||||
let hours_forward = args["hours_forward"].as_f64().unwrap_or(2.0);
|
||||
|
||||
let storage = storage.lock().await;
|
||||
|
||||
// Verify the trigger memory exists
|
||||
let trigger_memory = storage.get_node(memory_id)
|
||||
.map_err(|e| format!("Error: {}", e))?
|
||||
.ok_or("Memory not found")?;
|
||||
|
||||
// Create importance event based on type
|
||||
let _event_type = match event_type_str {
|
||||
"user_flag" => ImportanceEventType::UserFlag,
|
||||
"emotional" => ImportanceEventType::EmotionalContent,
|
||||
"novelty" => ImportanceEventType::NoveltySpike,
|
||||
"repeated_access" => ImportanceEventType::RepeatedAccess,
|
||||
"cross_reference" => ImportanceEventType::CrossReference,
|
||||
_ => return Err(format!("Unknown event type: {}", event_type_str)),
|
||||
};
|
||||
|
||||
// Create event using user_flag constructor (simpler API)
|
||||
let event = ImportanceEvent::user_flag(memory_id, description);
|
||||
|
||||
// Configure capture window
|
||||
let config = SynapticTaggingConfig {
|
||||
capture_window: CaptureWindow::new(hours_back, hours_forward),
|
||||
prp_threshold: 0.5,
|
||||
tag_lifetime_hours: 12.0,
|
||||
min_tag_strength: 0.1,
|
||||
max_cluster_size: 100,
|
||||
enable_clustering: true,
|
||||
auto_decay: true,
|
||||
cleanup_interval_hours: 1.0,
|
||||
};
|
||||
|
||||
let mut stc = SynapticTaggingSystem::with_config(config);
|
||||
|
||||
// Get recent memories to tag
|
||||
let recent = storage.get_all_nodes(100, 0)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Tag all recent memories
|
||||
for mem in &recent {
|
||||
stc.tag_memory(&mem.id);
|
||||
}
|
||||
|
||||
// Trigger PRP (Plasticity-Related Proteins) synthesis
|
||||
let result = stc.trigger_prp(event);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"eventType": event_type_str,
|
||||
"triggerMemory": {
|
||||
"id": memory_id,
|
||||
"content": trigger_memory.content
|
||||
},
|
||||
"captureWindow": {
|
||||
"hoursBack": hours_back,
|
||||
"hoursForward": hours_forward
|
||||
},
|
||||
"result": {
|
||||
"memoriesCaptured": result.captured_count(),
|
||||
"description": description
|
||||
},
|
||||
"explanation": format!(
|
||||
"Importance signal triggered! {} memories within the {:.1}h window have been retroactively strengthened.",
|
||||
result.captured_count(), hours_back
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Find memories with active synaptic tags
|
||||
pub async fn execute_find(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args = args.unwrap_or(serde_json::json!({}));
|
||||
|
||||
let min_strength = args["min_strength"].as_f64().unwrap_or(0.3);
|
||||
let limit = args["limit"].as_i64().unwrap_or(20) as usize;
|
||||
|
||||
let storage = storage.lock().await;
|
||||
|
||||
// Get memories with high retention (proxy for "tagged")
|
||||
let memories = storage.get_all_nodes(200, 0)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Filter by retention strength (tagged memories have higher retention)
|
||||
let tagged: Vec<Value> = memories.into_iter()
|
||||
.filter(|m| m.retention_strength >= min_strength)
|
||||
.take(limit)
|
||||
.map(|m| serde_json::json!({
|
||||
"id": m.id,
|
||||
"content": m.content,
|
||||
"retentionStrength": m.retention_strength,
|
||||
"storageStrength": m.storage_strength,
|
||||
"lastAccessed": m.last_accessed.to_rfc3339(),
|
||||
"tags": m.tags
|
||||
}))
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"minStrength": min_strength,
|
||||
"taggedCount": tagged.len(),
|
||||
"memories": tagged
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get synaptic tagging statistics
|
||||
pub async fn execute_stats(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
) -> Result<Value, String> {
|
||||
let storage = storage.lock().await;
|
||||
|
||||
let memories = storage.get_all_nodes(500, 0)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let total = memories.len();
|
||||
let high_retention = memories.iter().filter(|m| m.retention_strength >= 0.7).count();
|
||||
let medium_retention = memories.iter().filter(|m| m.retention_strength >= 0.4 && m.retention_strength < 0.7).count();
|
||||
let low_retention = memories.iter().filter(|m| m.retention_strength < 0.4).count();
|
||||
|
||||
let avg_retention = if total > 0 {
|
||||
memories.iter().map(|m| m.retention_strength).sum::<f64>() / total as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let avg_storage = if total > 0 {
|
||||
memories.iter().map(|m| m.storage_strength).sum::<f64>() / total as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"totalMemories": total,
|
||||
"averageRetention": avg_retention,
|
||||
"averageStorage": avg_storage,
|
||||
"distribution": {
|
||||
"highRetention": {
|
||||
"count": high_retention,
|
||||
"threshold": 0.7,
|
||||
"percentage": if total > 0 { (high_retention as f64 / total as f64) * 100.0 } else { 0.0 }
|
||||
},
|
||||
"mediumRetention": {
|
||||
"count": medium_retention,
|
||||
"threshold": "0.4-0.7",
|
||||
"percentage": if total > 0 { (medium_retention as f64 / total as f64) * 100.0 } else { 0.0 }
|
||||
},
|
||||
"lowRetention": {
|
||||
"count": low_retention,
|
||||
"threshold": "<0.4",
|
||||
"percentage": if total > 0 { (low_retention as f64 / total as f64) * 100.0 } else { 0.0 }
|
||||
}
|
||||
},
|
||||
"science": {
|
||||
"theory": "Synaptic Tagging and Capture (Frey & Morris 1997)",
|
||||
"principle": "Weak memories can be retroactively strengthened when important events occur within a temporal window",
|
||||
"captureWindow": "Up to 9 hours in biological systems"
|
||||
}
|
||||
}))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue