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:
Sam Valladares 2026-01-25 01:31:03 -06:00
commit f9c60eb5a7
169 changed files with 97206 additions and 0 deletions

View 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,
},
}))
}

View 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
),
}))
}

View 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"
}
}))
}

View 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());
}
}

File diff suppressed because it is too large Load diff

View 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" },
}))
}

View 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"
}
}))
}

View 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;

View 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);
}
}

View 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);
}
}

View 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,
}))
}

View 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
}

View 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"
}
}))
}