mirror of
https://github.com/samvallad33/vestige.git
synced 2026-04-25 00:36:22 +02:00
feat(v1.1): consolidate 29 tools → 8 unified tools + CLI
Tool Consolidation: - search: merges recall, semantic_search, hybrid_search - memory: merges get_knowledge, delete_knowledge, get_memory_state - codebase: merges remember_pattern, remember_decision, get_codebase_context - intention: merges all 5 intention tools into action-based API New CLI Binary: - vestige stats [--tagging] [--states] - vestige health - vestige consolidate - vestige restore <file> Documentation: - Verify all neuroscience claims against codebase - Fix Memory States table: "Retention" → "Accessibility" with formula - Clarify Spreading Activation: embedding similarity vs full network module - Update Synaptic Tagging: clarify 9h/2h implementation vs biology - Add comprehensive FAQ with 30+ questions - Add storage modes: global, per-project, multi-Claude household - Add CLAUDE.md setup instructions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
29130c3068
commit
8bb6500985
11 changed files with 4152 additions and 90 deletions
99
Cargo.lock
generated
99
Cargo.lock
generated
|
|
@ -64,12 +64,56 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.100"
|
||||
|
|
@ -303,6 +347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -311,11 +356,24 @@ version = "4.5.54"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "0.7.7"
|
||||
|
|
@ -339,6 +397,21 @@ version = "1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.9.0"
|
||||
|
|
@ -1052,6 +1125,12 @@ dependencies = [
|
|||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hf-hub"
|
||||
version = "0.4.3"
|
||||
|
|
@ -1440,6 +1519,12 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
|
|
@ -1928,6 +2013,12 @@ version = "1.21.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "onig"
|
||||
version = "6.5.1"
|
||||
|
|
@ -3327,6 +3418,12 @@ version = "1.0.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.19.0"
|
||||
|
|
@ -3408,6 +3505,8 @@ version = "1.0.0"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"colored",
|
||||
"directories",
|
||||
"rmcp",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ path = "src/main.rs"
|
|||
name = "vestige-restore"
|
||||
path = "src/bin/restore.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "vestige"
|
||||
path = "src/bin/cli.rs"
|
||||
|
||||
[dependencies]
|
||||
# ============================================================================
|
||||
# VESTIGE CORE - The cognitive science engine
|
||||
|
|
@ -55,5 +59,9 @@ directories = "6"
|
|||
# Official Anthropic MCP Rust SDK
|
||||
rmcp = "0.14"
|
||||
|
||||
# CLI
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
colored = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
436
crates/vestige-mcp/src/bin/cli.rs
Normal file
436
crates/vestige-mcp/src/bin/cli.rs
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
//! Vestige CLI
|
||||
//!
|
||||
//! Command-line interface for managing cognitive memory system.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use colored::Colorize;
|
||||
use vestige_core::{IngestInput, Storage};
|
||||
|
||||
/// Vestige - Cognitive Memory System CLI
|
||||
#[derive(Parser)]
|
||||
#[command(name = "vestige")]
|
||||
#[command(author = "samvallad33")]
|
||||
#[command(version = "1.0.0")]
|
||||
#[command(about = "CLI for the Vestige cognitive memory system")]
|
||||
#[command(long_about = "Vestige is a cognitive memory system based on 130 years of memory research.\n\nIt implements FSRS-6, spreading activation, synaptic tagging, and more.")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Show memory statistics
|
||||
Stats {
|
||||
/// Show tagging/retention distribution
|
||||
#[arg(long)]
|
||||
tagging: bool,
|
||||
|
||||
/// Show cognitive state distribution
|
||||
#[arg(long)]
|
||||
states: bool,
|
||||
},
|
||||
|
||||
/// Run health check with warnings and recommendations
|
||||
Health,
|
||||
|
||||
/// Run memory consolidation cycle
|
||||
Consolidate,
|
||||
|
||||
/// Restore memories from backup file
|
||||
Restore {
|
||||
/// Path to backup JSON file
|
||||
file: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Stats { tagging, states } => run_stats(tagging, states),
|
||||
Commands::Health => run_health(),
|
||||
Commands::Consolidate => run_consolidate(),
|
||||
Commands::Restore { file } => run_restore(file),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run stats command
|
||||
fn run_stats(show_tagging: bool, show_states: bool) -> anyhow::Result<()> {
|
||||
let storage = Storage::new(None)?;
|
||||
let stats = storage.get_stats()?;
|
||||
|
||||
println!("{}", "=== Vestige Memory Statistics ===".cyan().bold());
|
||||
println!();
|
||||
|
||||
// Basic stats
|
||||
println!("{}: {}", "Total Memories".white().bold(), stats.total_nodes);
|
||||
println!("{}: {}", "Due for Review".white().bold(), stats.nodes_due_for_review);
|
||||
println!("{}: {:.1}%", "Average Retention".white().bold(), stats.average_retention * 100.0);
|
||||
println!("{}: {:.2}", "Average Storage Strength".white().bold(), stats.average_storage_strength);
|
||||
println!("{}: {:.2}", "Average Retrieval Strength".white().bold(), stats.average_retrieval_strength);
|
||||
println!("{}: {}", "With Embeddings".white().bold(), stats.nodes_with_embeddings);
|
||||
|
||||
if let Some(model) = &stats.embedding_model {
|
||||
println!("{}: {}", "Embedding Model".white().bold(), model);
|
||||
}
|
||||
|
||||
if let Some(oldest) = stats.oldest_memory {
|
||||
println!("{}: {}", "Oldest Memory".white().bold(), oldest.format("%Y-%m-%d %H:%M:%S"));
|
||||
}
|
||||
if let Some(newest) = stats.newest_memory {
|
||||
println!("{}: {}", "Newest Memory".white().bold(), newest.format("%Y-%m-%d %H:%M:%S"));
|
||||
}
|
||||
|
||||
// Embedding coverage
|
||||
let embedding_coverage = if stats.total_nodes > 0 {
|
||||
(stats.nodes_with_embeddings as f64 / stats.total_nodes as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
println!("{}: {:.1}%", "Embedding Coverage".white().bold(), embedding_coverage);
|
||||
|
||||
// Tagging distribution (retention levels)
|
||||
if show_tagging {
|
||||
println!();
|
||||
println!("{}", "=== Retention Distribution ===".yellow().bold());
|
||||
|
||||
let memories = storage.get_all_nodes(500, 0)?;
|
||||
let total = memories.len();
|
||||
|
||||
if total > 0 {
|
||||
let high = memories.iter().filter(|m| m.retention_strength >= 0.7).count();
|
||||
let medium = memories.iter().filter(|m| m.retention_strength >= 0.4 && m.retention_strength < 0.7).count();
|
||||
let low = memories.iter().filter(|m| m.retention_strength < 0.4).count();
|
||||
|
||||
print_distribution_bar("High (>=70%)", high, total, "green");
|
||||
print_distribution_bar("Medium (40-70%)", medium, total, "yellow");
|
||||
print_distribution_bar("Low (<40%)", low, total, "red");
|
||||
} else {
|
||||
println!("{}", "No memories found.".dimmed());
|
||||
}
|
||||
}
|
||||
|
||||
// State distribution
|
||||
if show_states {
|
||||
println!();
|
||||
println!("{}", "=== Cognitive State Distribution ===".magenta().bold());
|
||||
|
||||
let memories = storage.get_all_nodes(500, 0)?;
|
||||
let total = memories.len();
|
||||
|
||||
if total > 0 {
|
||||
let (active, dormant, silent, unavailable) = compute_state_distribution(&memories);
|
||||
|
||||
print_distribution_bar("Active", active, total, "green");
|
||||
print_distribution_bar("Dormant", dormant, total, "yellow");
|
||||
print_distribution_bar("Silent", silent, total, "red");
|
||||
print_distribution_bar("Unavailable", unavailable, total, "magenta");
|
||||
|
||||
println!();
|
||||
println!("{}", "State Thresholds:".dimmed());
|
||||
println!(" {} >= 0.70 accessibility", "Active".green());
|
||||
println!(" {} >= 0.40 accessibility", "Dormant".yellow());
|
||||
println!(" {} >= 0.10 accessibility", "Silent".red());
|
||||
println!(" {} < 0.10 accessibility", "Unavailable".magenta());
|
||||
} else {
|
||||
println!("{}", "No memories found.".dimmed());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compute cognitive state distribution for memories
|
||||
fn compute_state_distribution(memories: &[vestige_core::KnowledgeNode]) -> (usize, usize, usize, usize) {
|
||||
let mut active = 0;
|
||||
let mut dormant = 0;
|
||||
let mut silent = 0;
|
||||
let mut unavailable = 0;
|
||||
|
||||
for memory in memories {
|
||||
// Accessibility = 0.5*retention + 0.3*retrieval + 0.2*storage
|
||||
let accessibility = memory.retention_strength * 0.5
|
||||
+ memory.retrieval_strength * 0.3
|
||||
+ memory.storage_strength * 0.2;
|
||||
|
||||
if accessibility >= 0.7 {
|
||||
active += 1;
|
||||
} else if accessibility >= 0.4 {
|
||||
dormant += 1;
|
||||
} else if accessibility >= 0.1 {
|
||||
silent += 1;
|
||||
} else {
|
||||
unavailable += 1;
|
||||
}
|
||||
}
|
||||
|
||||
(active, dormant, silent, unavailable)
|
||||
}
|
||||
|
||||
/// Print a distribution bar
|
||||
fn print_distribution_bar(label: &str, count: usize, total: usize, color: &str) {
|
||||
let percentage = if total > 0 {
|
||||
(count as f64 / total as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let bar_width: usize = 30;
|
||||
let filled = ((percentage / 100.0) * bar_width as f64) as usize;
|
||||
let empty = bar_width.saturating_sub(filled);
|
||||
|
||||
let bar = format!("{}{}", "#".repeat(filled), "-".repeat(empty));
|
||||
let colored_bar = match color {
|
||||
"green" => bar.green(),
|
||||
"yellow" => bar.yellow(),
|
||||
"red" => bar.red(),
|
||||
"magenta" => bar.magenta(),
|
||||
_ => bar.white(),
|
||||
};
|
||||
|
||||
println!(
|
||||
" {:15} [{:30}] {:>4} ({:>5.1}%)",
|
||||
label,
|
||||
colored_bar,
|
||||
count,
|
||||
percentage
|
||||
);
|
||||
}
|
||||
|
||||
/// Run health check
|
||||
fn run_health() -> anyhow::Result<()> {
|
||||
let storage = Storage::new(None)?;
|
||||
let stats = storage.get_stats()?;
|
||||
|
||||
println!("{}", "=== Vestige Health Check ===".cyan().bold());
|
||||
println!();
|
||||
|
||||
// Determine health status
|
||||
let (status, status_color) = if stats.total_nodes == 0 {
|
||||
("EMPTY", "white")
|
||||
} else if stats.average_retention < 0.3 {
|
||||
("CRITICAL", "red")
|
||||
} else if stats.average_retention < 0.5 {
|
||||
("DEGRADED", "yellow")
|
||||
} else {
|
||||
("HEALTHY", "green")
|
||||
};
|
||||
|
||||
let colored_status = match status_color {
|
||||
"green" => status.green().bold(),
|
||||
"yellow" => status.yellow().bold(),
|
||||
"red" => status.red().bold(),
|
||||
_ => status.white().bold(),
|
||||
};
|
||||
|
||||
println!("{}: {}", "Status".white().bold(), colored_status);
|
||||
println!("{}: {}", "Total Memories".white(), stats.total_nodes);
|
||||
println!("{}: {}", "Due for Review".white(), stats.nodes_due_for_review);
|
||||
println!("{}: {:.1}%", "Average Retention".white(), stats.average_retention * 100.0);
|
||||
|
||||
// Embedding coverage
|
||||
let embedding_coverage = if stats.total_nodes > 0 {
|
||||
(stats.nodes_with_embeddings as f64 / stats.total_nodes as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
println!("{}: {:.1}%", "Embedding Coverage".white(), embedding_coverage);
|
||||
println!("{}: {}", "Embedding Service".white(),
|
||||
if storage.is_embedding_ready() { "Ready".green() } else { "Not Ready".red() });
|
||||
|
||||
// Warnings
|
||||
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");
|
||||
}
|
||||
|
||||
if stats.nodes_due_for_review > 10 {
|
||||
warnings.push("Many memories are due for review");
|
||||
}
|
||||
|
||||
if stats.total_nodes > 0 && stats.nodes_with_embeddings == 0 {
|
||||
warnings.push("No embeddings generated - semantic search unavailable");
|
||||
}
|
||||
|
||||
if embedding_coverage < 50.0 && stats.total_nodes > 10 {
|
||||
warnings.push("Low embedding coverage - run consolidation to improve semantic search");
|
||||
}
|
||||
|
||||
if !warnings.is_empty() {
|
||||
println!();
|
||||
println!("{}", "Warnings:".yellow().bold());
|
||||
for warning in &warnings {
|
||||
println!(" {} {}", "!".yellow().bold(), warning.yellow());
|
||||
}
|
||||
}
|
||||
|
||||
// Recommendations
|
||||
let mut recommendations = Vec::new();
|
||||
|
||||
if status == "CRITICAL" {
|
||||
recommendations.push("CRITICAL: Many memories have very low retention. Review important memories.");
|
||||
}
|
||||
|
||||
if stats.nodes_due_for_review > 5 {
|
||||
recommendations.push("Review due memories to strengthen retention.");
|
||||
}
|
||||
|
||||
if stats.nodes_with_embeddings < stats.total_nodes {
|
||||
recommendations.push("Run 'vestige consolidate' to generate embeddings for better semantic search.");
|
||||
}
|
||||
|
||||
if stats.total_nodes > 100 && stats.average_retention < 0.7 {
|
||||
recommendations.push("Consider running periodic consolidation to maintain memory health.");
|
||||
}
|
||||
|
||||
if recommendations.is_empty() && status == "HEALTHY" {
|
||||
recommendations.push("Memory system is healthy!");
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "Recommendations:".cyan().bold());
|
||||
for rec in &recommendations {
|
||||
let icon = if rec.starts_with("CRITICAL") { "!".red().bold() } else { ">".cyan() };
|
||||
let text = if rec.starts_with("CRITICAL") { rec.red().to_string() } else { rec.to_string() };
|
||||
println!(" {} {}", icon, text);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run consolidation cycle
|
||||
fn run_consolidate() -> anyhow::Result<()> {
|
||||
println!("{}", "=== Vestige Consolidation ===".cyan().bold());
|
||||
println!();
|
||||
println!("Running memory consolidation cycle...");
|
||||
println!();
|
||||
|
||||
let mut storage = Storage::new(None)?;
|
||||
let result = storage.run_consolidation()?;
|
||||
|
||||
println!("{}: {}", "Nodes Processed".white().bold(), result.nodes_processed);
|
||||
println!("{}: {}", "Nodes Promoted".white().bold(), result.nodes_promoted);
|
||||
println!("{}: {}", "Nodes Pruned".white().bold(), result.nodes_pruned);
|
||||
println!("{}: {}", "Decay Applied".white().bold(), result.decay_applied);
|
||||
println!("{}: {}", "Embeddings Generated".white().bold(), result.embeddings_generated);
|
||||
println!("{}: {}ms", "Duration".white().bold(), result.duration_ms);
|
||||
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"Consolidation complete: {} nodes processed, {} embeddings generated in {}ms",
|
||||
result.nodes_processed, result.embeddings_generated, result.duration_ms
|
||||
)
|
||||
.green()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run restore from backup
|
||||
fn run_restore(backup_path: PathBuf) -> anyhow::Result<()> {
|
||||
println!("{}", "=== Vestige Restore ===".cyan().bold());
|
||||
println!();
|
||||
println!("Loading backup from: {}", backup_path.display());
|
||||
|
||||
// Read and parse backup
|
||||
let backup_content = std::fs::read_to_string(&backup_path)?;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct BackupWrapper {
|
||||
#[serde(rename = "type")]
|
||||
_type: String,
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct RecallResult {
|
||||
results: Vec<MemoryBackup>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MemoryBackup {
|
||||
content: String,
|
||||
node_type: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
source: Option<String>,
|
||||
}
|
||||
|
||||
let wrapper: Vec<BackupWrapper> = serde_json::from_str(&backup_content)?;
|
||||
let recall_result: RecallResult = serde_json::from_str(&wrapper[0].text)?;
|
||||
let memories = recall_result.results;
|
||||
|
||||
println!("Found {} memories to restore", memories.len());
|
||||
println!();
|
||||
|
||||
// Initialize storage
|
||||
println!("Initializing storage...");
|
||||
let mut storage = Storage::new(None)?;
|
||||
|
||||
println!("Generating embeddings and ingesting memories...");
|
||||
println!();
|
||||
|
||||
let total = memories.len();
|
||||
let mut success_count = 0;
|
||||
|
||||
for (i, memory) in memories.into_iter().enumerate() {
|
||||
let input = IngestInput {
|
||||
content: memory.content.clone(),
|
||||
node_type: memory.node_type.unwrap_or_else(|| "fact".to_string()),
|
||||
source: memory.source,
|
||||
sentiment_score: 0.0,
|
||||
sentiment_magnitude: 0.0,
|
||||
tags: memory.tags.unwrap_or_default(),
|
||||
valid_from: None,
|
||||
valid_until: None,
|
||||
};
|
||||
|
||||
match storage.ingest(input) {
|
||||
Ok(_node) => {
|
||||
success_count += 1;
|
||||
println!(
|
||||
"[{}/{}] {} {}",
|
||||
i + 1,
|
||||
total,
|
||||
"OK".green(),
|
||||
truncate(&memory.content, 60)
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("[{}/{}] {} {}", i + 1, total, "FAIL".red(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!(
|
||||
"Restore complete: {}/{} memories restored",
|
||||
success_count.to_string().green().bold(),
|
||||
total
|
||||
);
|
||||
|
||||
// Show stats
|
||||
let stats = storage.get_stats()?;
|
||||
println!();
|
||||
println!("{}: {}", "Total Nodes".white(), stats.total_nodes);
|
||||
println!("{}: {}", "With Embeddings".white(), stats.nodes_with_embeddings);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Truncate a string for display (UTF-8 safe)
|
||||
fn truncate(s: &str, max_chars: usize) -> String {
|
||||
let s = s.replace('\n', " ");
|
||||
if s.chars().count() <= max_chars {
|
||||
s
|
||||
} else {
|
||||
let truncated: String = s.chars().take(max_chars).collect();
|
||||
format!("{}...", truncated)
|
||||
}
|
||||
}
|
||||
|
|
@ -83,11 +83,13 @@ fn main() -> anyhow::Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max_len: usize) -> String {
|
||||
/// Truncate a string for display (UTF-8 safe)
|
||||
fn truncate(s: &str, max_chars: usize) -> String {
|
||||
let s = s.replace('\n', " ");
|
||||
if s.len() <= max_len {
|
||||
if s.chars().count() <= max_chars {
|
||||
s
|
||||
} else {
|
||||
format!("{}...", &s[..max_len])
|
||||
let truncated: String = s.chars().take(max_chars).collect();
|
||||
format!("{}...", truncated)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,32 @@ impl McpServer {
|
|||
/// Handle tools/list request
|
||||
async fn handle_tools_list(&self) -> Result<serde_json::Value, JsonRpcError> {
|
||||
let tools = vec![
|
||||
// ================================================================
|
||||
// UNIFIED TOOLS (v1.1+) - Preferred API
|
||||
// ================================================================
|
||||
ToolDescription {
|
||||
name: "search".to_string(),
|
||||
description: Some("Unified search tool. Uses hybrid search (keyword + semantic + RRF fusion) internally. Auto-strengthens memories on access (Testing Effect).".to_string()),
|
||||
input_schema: tools::search_unified::schema(),
|
||||
},
|
||||
ToolDescription {
|
||||
name: "memory".to_string(),
|
||||
description: Some("Unified memory management tool. Actions: 'get' (retrieve full node), 'delete' (remove memory), 'state' (get accessibility state).".to_string()),
|
||||
input_schema: tools::memory_unified::schema(),
|
||||
},
|
||||
ToolDescription {
|
||||
name: "codebase".to_string(),
|
||||
description: Some("Unified codebase tool. Actions: 'remember_pattern' (store code pattern), 'remember_decision' (store architectural decision), 'get_context' (retrieve patterns and decisions).".to_string()),
|
||||
input_schema: tools::codebase_unified::schema(),
|
||||
},
|
||||
ToolDescription {
|
||||
name: "intention".to_string(),
|
||||
description: Some("Unified intention management tool. Actions: 'set' (create), 'check' (find triggered), 'update' (complete/snooze/cancel), 'list' (show intentions).".to_string()),
|
||||
input_schema: tools::intention_unified::schema(),
|
||||
},
|
||||
// ================================================================
|
||||
// Core memory tools
|
||||
// ================================================================
|
||||
ToolDescription {
|
||||
name: "ingest".to_string(),
|
||||
description: Some("Add new knowledge to memory. Use for facts, concepts, decisions, or any information worth remembering.".to_string()),
|
||||
|
|
@ -127,27 +152,27 @@ impl McpServer {
|
|||
},
|
||||
ToolDescription {
|
||||
name: "recall".to_string(),
|
||||
description: Some("Search and retrieve knowledge from memory. Returns matches ranked by relevance and retention strength.".to_string()),
|
||||
description: Some("(deprecated) Use 'search' instead. Search and retrieve knowledge from memory.".to_string()),
|
||||
input_schema: tools::recall::schema(),
|
||||
},
|
||||
ToolDescription {
|
||||
name: "semantic_search".to_string(),
|
||||
description: Some("Search memories using semantic similarity. Finds conceptually related content even without keyword matches.".to_string()),
|
||||
description: Some("(deprecated) Use 'search' instead. Search memories using semantic similarity.".to_string()),
|
||||
input_schema: tools::search::semantic_schema(),
|
||||
},
|
||||
ToolDescription {
|
||||
name: "hybrid_search".to_string(),
|
||||
description: Some("Combined keyword + semantic search with RRF fusion. Best for comprehensive retrieval.".to_string()),
|
||||
description: Some("(deprecated) Use 'search' instead. Combined keyword + semantic search with RRF fusion.".to_string()),
|
||||
input_schema: tools::search::hybrid_schema(),
|
||||
},
|
||||
ToolDescription {
|
||||
name: "get_knowledge".to_string(),
|
||||
description: Some("Retrieve a specific memory by ID.".to_string()),
|
||||
description: Some("(deprecated) Use 'memory' with action='get' instead. Retrieve a specific memory by ID.".to_string()),
|
||||
input_schema: tools::knowledge::get_schema(),
|
||||
},
|
||||
ToolDescription {
|
||||
name: "delete_knowledge".to_string(),
|
||||
description: Some("Delete a memory by ID.".to_string()),
|
||||
description: Some("(deprecated) Use 'memory' with action='delete' instead. Delete a memory by ID.".to_string()),
|
||||
input_schema: tools::knowledge::delete_schema(),
|
||||
},
|
||||
ToolDescription {
|
||||
|
|
@ -171,52 +196,52 @@ impl McpServer {
|
|||
description: Some("Run memory consolidation cycle. Applies decay, promotes important memories, generates embeddings.".to_string()),
|
||||
input_schema: tools::consolidate::schema(),
|
||||
},
|
||||
// Codebase tools
|
||||
// Codebase tools (deprecated - use unified 'codebase' tool)
|
||||
ToolDescription {
|
||||
name: "remember_pattern".to_string(),
|
||||
description: Some("Remember a code pattern or convention used in this codebase.".to_string()),
|
||||
description: Some("(deprecated) Use 'codebase' with action='remember_pattern' instead. Remember a code pattern or convention.".to_string()),
|
||||
input_schema: tools::codebase::pattern_schema(),
|
||||
},
|
||||
ToolDescription {
|
||||
name: "remember_decision".to_string(),
|
||||
description: Some("Remember an architectural or design decision with its rationale.".to_string()),
|
||||
description: Some("(deprecated) Use 'codebase' with action='remember_decision' instead. Remember an architectural decision.".to_string()),
|
||||
input_schema: tools::codebase::decision_schema(),
|
||||
},
|
||||
ToolDescription {
|
||||
name: "get_codebase_context".to_string(),
|
||||
description: Some("Get remembered patterns and decisions for the current codebase.".to_string()),
|
||||
description: Some("(deprecated) Use 'codebase' with action='get_context' instead. Get remembered patterns and decisions.".to_string()),
|
||||
input_schema: tools::codebase::context_schema(),
|
||||
},
|
||||
// Prospective memory (intentions)
|
||||
// Prospective memory (intentions) - deprecated, use unified 'intention' tool
|
||||
ToolDescription {
|
||||
name: "set_intention".to_string(),
|
||||
description: Some("Remember to do something in the future. Supports time, context, or event triggers. Example: 'Remember to review error handling when I'm in the payments module'.".to_string()),
|
||||
description: Some("(deprecated) Use 'intention' with action='set' instead. Remember to do something in the future.".to_string()),
|
||||
input_schema: tools::intentions::set_schema(),
|
||||
},
|
||||
ToolDescription {
|
||||
name: "check_intentions".to_string(),
|
||||
description: Some("Check if any intentions should be triggered based on current context. Returns triggered and pending intentions.".to_string()),
|
||||
description: Some("(deprecated) Use 'intention' with action='check' instead. Check if any intentions should be triggered.".to_string()),
|
||||
input_schema: tools::intentions::check_schema(),
|
||||
},
|
||||
ToolDescription {
|
||||
name: "complete_intention".to_string(),
|
||||
description: Some("Mark an intention as complete/fulfilled.".to_string()),
|
||||
description: Some("(deprecated) Use 'intention' with action='update', status='complete' instead. Mark an intention as complete.".to_string()),
|
||||
input_schema: tools::intentions::complete_schema(),
|
||||
},
|
||||
ToolDescription {
|
||||
name: "snooze_intention".to_string(),
|
||||
description: Some("Snooze an intention for a specified number of minutes.".to_string()),
|
||||
description: Some("(deprecated) Use 'intention' with action='update', status='snooze' instead. Snooze an intention.".to_string()),
|
||||
input_schema: tools::intentions::snooze_schema(),
|
||||
},
|
||||
ToolDescription {
|
||||
name: "list_intentions".to_string(),
|
||||
description: Some("List all intentions, optionally filtered by status.".to_string()),
|
||||
description: Some("(deprecated) Use 'intention' with action='list' instead. List all intentions.".to_string()),
|
||||
input_schema: tools::intentions::list_schema(),
|
||||
},
|
||||
// Neuroscience tools
|
||||
ToolDescription {
|
||||
name: "get_memory_state".to_string(),
|
||||
description: Some("Get the cognitive state (Active/Dormant/Silent/Unavailable) of a memory based on accessibility.".to_string()),
|
||||
description: Some("(deprecated) Use 'memory' with action='state' instead. Get the cognitive state of a memory.".to_string()),
|
||||
input_schema: tools::memory_states::get_schema(),
|
||||
},
|
||||
ToolDescription {
|
||||
|
|
@ -282,38 +307,234 @@ impl McpServer {
|
|||
};
|
||||
|
||||
let result = match request.name.as_str() {
|
||||
// ================================================================
|
||||
// UNIFIED TOOLS (v1.1+) - Preferred API
|
||||
// ================================================================
|
||||
"search" => tools::search_unified::execute(&self.storage, request.arguments).await,
|
||||
"memory" => tools::memory_unified::execute(&self.storage, request.arguments).await,
|
||||
"codebase" => tools::codebase_unified::execute(&self.storage, request.arguments).await,
|
||||
"intention" => tools::intention_unified::execute(&self.storage, request.arguments).await,
|
||||
|
||||
// ================================================================
|
||||
// Core memory tools
|
||||
// ================================================================
|
||||
"ingest" => tools::ingest::execute(&self.storage, request.arguments).await,
|
||||
"smart_ingest" => tools::smart_ingest::execute(&self.storage, request.arguments).await,
|
||||
"recall" => tools::recall::execute(&self.storage, request.arguments).await,
|
||||
"semantic_search" => tools::search::execute_semantic(&self.storage, request.arguments).await,
|
||||
"hybrid_search" => tools::search::execute_hybrid(&self.storage, request.arguments).await,
|
||||
"get_knowledge" => tools::knowledge::execute_get(&self.storage, request.arguments).await,
|
||||
"delete_knowledge" => tools::knowledge::execute_delete(&self.storage, request.arguments).await,
|
||||
"mark_reviewed" => tools::review::execute(&self.storage, request.arguments).await,
|
||||
// Stats and maintenance
|
||||
|
||||
// ================================================================
|
||||
// DEPRECATED: Search tools - redirect to unified 'search'
|
||||
// ================================================================
|
||||
"recall" | "semantic_search" | "hybrid_search" => {
|
||||
warn!("Tool '{}' is deprecated. Use 'search' instead.", request.name);
|
||||
tools::search_unified::execute(&self.storage, request.arguments).await
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// DEPRECATED: Memory tools - redirect to unified 'memory'
|
||||
// ================================================================
|
||||
"get_knowledge" => {
|
||||
warn!("Tool 'get_knowledge' is deprecated. Use 'memory' with action='get' instead.");
|
||||
// Transform arguments to unified format
|
||||
let unified_args = match request.arguments {
|
||||
Some(ref args) => {
|
||||
let id = args.get("id").cloned().unwrap_or(serde_json::Value::Null);
|
||||
Some(serde_json::json!({
|
||||
"action": "get",
|
||||
"id": id
|
||||
}))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
tools::memory_unified::execute(&self.storage, unified_args).await
|
||||
}
|
||||
"delete_knowledge" => {
|
||||
warn!("Tool 'delete_knowledge' is deprecated. Use 'memory' with action='delete' instead.");
|
||||
// Transform arguments to unified format
|
||||
let unified_args = match request.arguments {
|
||||
Some(ref args) => {
|
||||
let id = args.get("id").cloned().unwrap_or(serde_json::Value::Null);
|
||||
Some(serde_json::json!({
|
||||
"action": "delete",
|
||||
"id": id
|
||||
}))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
tools::memory_unified::execute(&self.storage, unified_args).await
|
||||
}
|
||||
"get_memory_state" => {
|
||||
warn!("Tool 'get_memory_state' is deprecated. Use 'memory' with action='state' instead.");
|
||||
// Transform arguments to unified format
|
||||
let unified_args = match request.arguments {
|
||||
Some(ref args) => {
|
||||
let id = args.get("memory_id").cloned().unwrap_or(serde_json::Value::Null);
|
||||
Some(serde_json::json!({
|
||||
"action": "state",
|
||||
"id": id
|
||||
}))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
tools::memory_unified::execute(&self.storage, unified_args).await
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// DEPRECATED: Codebase tools - redirect to unified 'codebase'
|
||||
// ================================================================
|
||||
"remember_pattern" => {
|
||||
warn!("Tool 'remember_pattern' is deprecated. Use 'codebase' with action='remember_pattern' instead.");
|
||||
// Transform arguments to unified format
|
||||
let unified_args = match request.arguments {
|
||||
Some(ref args) => {
|
||||
let mut new_args = args.clone();
|
||||
if let Some(obj) = new_args.as_object_mut() {
|
||||
obj.insert("action".to_string(), serde_json::json!("remember_pattern"));
|
||||
}
|
||||
Some(new_args)
|
||||
}
|
||||
None => Some(serde_json::json!({"action": "remember_pattern"})),
|
||||
};
|
||||
tools::codebase_unified::execute(&self.storage, unified_args).await
|
||||
}
|
||||
"remember_decision" => {
|
||||
warn!("Tool 'remember_decision' is deprecated. Use 'codebase' with action='remember_decision' instead.");
|
||||
// Transform arguments to unified format
|
||||
let unified_args = match request.arguments {
|
||||
Some(ref args) => {
|
||||
let mut new_args = args.clone();
|
||||
if let Some(obj) = new_args.as_object_mut() {
|
||||
obj.insert("action".to_string(), serde_json::json!("remember_decision"));
|
||||
}
|
||||
Some(new_args)
|
||||
}
|
||||
None => Some(serde_json::json!({"action": "remember_decision"})),
|
||||
};
|
||||
tools::codebase_unified::execute(&self.storage, unified_args).await
|
||||
}
|
||||
"get_codebase_context" => {
|
||||
warn!("Tool 'get_codebase_context' is deprecated. Use 'codebase' with action='get_context' instead.");
|
||||
// Transform arguments to unified format
|
||||
let unified_args = match request.arguments {
|
||||
Some(ref args) => {
|
||||
let mut new_args = args.clone();
|
||||
if let Some(obj) = new_args.as_object_mut() {
|
||||
obj.insert("action".to_string(), serde_json::json!("get_context"));
|
||||
}
|
||||
Some(new_args)
|
||||
}
|
||||
None => Some(serde_json::json!({"action": "get_context"})),
|
||||
};
|
||||
tools::codebase_unified::execute(&self.storage, unified_args).await
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// DEPRECATED: Intention tools - redirect to unified 'intention'
|
||||
// ================================================================
|
||||
"set_intention" => {
|
||||
warn!("Tool 'set_intention' is deprecated. Use 'intention' with action='set' instead.");
|
||||
// Transform arguments to unified format
|
||||
let unified_args = match request.arguments {
|
||||
Some(ref args) => {
|
||||
let mut new_args = args.clone();
|
||||
if let Some(obj) = new_args.as_object_mut() {
|
||||
obj.insert("action".to_string(), serde_json::json!("set"));
|
||||
}
|
||||
Some(new_args)
|
||||
}
|
||||
None => Some(serde_json::json!({"action": "set"})),
|
||||
};
|
||||
tools::intention_unified::execute(&self.storage, unified_args).await
|
||||
}
|
||||
"check_intentions" => {
|
||||
warn!("Tool 'check_intentions' is deprecated. Use 'intention' with action='check' instead.");
|
||||
// Transform arguments to unified format
|
||||
let unified_args = match request.arguments {
|
||||
Some(ref args) => {
|
||||
let mut new_args = args.clone();
|
||||
if let Some(obj) = new_args.as_object_mut() {
|
||||
obj.insert("action".to_string(), serde_json::json!("check"));
|
||||
}
|
||||
Some(new_args)
|
||||
}
|
||||
None => Some(serde_json::json!({"action": "check"})),
|
||||
};
|
||||
tools::intention_unified::execute(&self.storage, unified_args).await
|
||||
}
|
||||
"complete_intention" => {
|
||||
warn!("Tool 'complete_intention' is deprecated. Use 'intention' with action='update', status='complete' instead.");
|
||||
// Transform arguments to unified format
|
||||
let unified_args = match request.arguments {
|
||||
Some(ref args) => {
|
||||
let id = args.get("intentionId").cloned().unwrap_or(serde_json::Value::Null);
|
||||
Some(serde_json::json!({
|
||||
"action": "update",
|
||||
"id": id,
|
||||
"status": "complete"
|
||||
}))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
tools::intention_unified::execute(&self.storage, unified_args).await
|
||||
}
|
||||
"snooze_intention" => {
|
||||
warn!("Tool 'snooze_intention' is deprecated. Use 'intention' with action='update', status='snooze' instead.");
|
||||
// Transform arguments to unified format
|
||||
let unified_args = match request.arguments {
|
||||
Some(ref args) => {
|
||||
let id = args.get("intentionId").cloned().unwrap_or(serde_json::Value::Null);
|
||||
let minutes = args.get("minutes").cloned().unwrap_or(serde_json::json!(30));
|
||||
Some(serde_json::json!({
|
||||
"action": "update",
|
||||
"id": id,
|
||||
"status": "snooze",
|
||||
"snooze_minutes": minutes
|
||||
}))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
tools::intention_unified::execute(&self.storage, unified_args).await
|
||||
}
|
||||
"list_intentions" => {
|
||||
warn!("Tool 'list_intentions' is deprecated. Use 'intention' with action='list' instead.");
|
||||
// Transform arguments to unified format
|
||||
let unified_args = match request.arguments {
|
||||
Some(ref args) => {
|
||||
let mut new_args = args.clone();
|
||||
if let Some(obj) = new_args.as_object_mut() {
|
||||
obj.insert("action".to_string(), serde_json::json!("list"));
|
||||
// Rename 'status' to 'filter_status' if present
|
||||
if let Some(status) = obj.remove("status") {
|
||||
obj.insert("filter_status".to_string(), status);
|
||||
}
|
||||
}
|
||||
Some(new_args)
|
||||
}
|
||||
None => Some(serde_json::json!({"action": "list"})),
|
||||
};
|
||||
tools::intention_unified::execute(&self.storage, unified_args).await
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Stats and maintenance (not deprecated)
|
||||
// ================================================================
|
||||
"get_stats" => tools::stats::execute_stats(&self.storage).await,
|
||||
"health_check" => tools::stats::execute_health(&self.storage).await,
|
||||
"run_consolidation" => tools::consolidate::execute(&self.storage).await,
|
||||
// Codebase tools
|
||||
"remember_pattern" => tools::codebase::execute_pattern(&self.storage, request.arguments).await,
|
||||
"remember_decision" => tools::codebase::execute_decision(&self.storage, request.arguments).await,
|
||||
"get_codebase_context" => tools::codebase::execute_context(&self.storage, request.arguments).await,
|
||||
// Prospective memory (intentions)
|
||||
"set_intention" => tools::intentions::execute_set(&self.storage, request.arguments).await,
|
||||
"check_intentions" => tools::intentions::execute_check(&self.storage, request.arguments).await,
|
||||
"complete_intention" => tools::intentions::execute_complete(&self.storage, request.arguments).await,
|
||||
"snooze_intention" => tools::intentions::execute_snooze(&self.storage, request.arguments).await,
|
||||
"list_intentions" => tools::intentions::execute_list(&self.storage, request.arguments).await,
|
||||
// Neuroscience tools
|
||||
"get_memory_state" => tools::memory_states::execute_get(&self.storage, request.arguments).await,
|
||||
|
||||
// ================================================================
|
||||
// Neuroscience tools (not deprecated, except get_memory_state above)
|
||||
// ================================================================
|
||||
"list_by_state" => tools::memory_states::execute_list(&self.storage, request.arguments).await,
|
||||
"state_stats" => tools::memory_states::execute_stats(&self.storage).await,
|
||||
"trigger_importance" => tools::tagging::execute_trigger(&self.storage, request.arguments).await,
|
||||
"trigger_importance" => tools::tagging::execute_trigger(&self.storage, request.arguments).await,
|
||||
"find_tagged" => tools::tagging::execute_find(&self.storage, request.arguments).await,
|
||||
"tagging_stats" => tools::tagging::execute_stats(&self.storage).await,
|
||||
"match_context" => tools::context::execute(&self.storage, request.arguments).await,
|
||||
// Feedback / preference learning
|
||||
|
||||
// ================================================================
|
||||
// Feedback / preference learning (not deprecated)
|
||||
// ================================================================
|
||||
"promote_memory" => tools::feedback::execute_promote(&self.storage, request.arguments).await,
|
||||
"demote_memory" => tools::feedback::execute_demote(&self.storage, request.arguments).await,
|
||||
"request_feedback" => tools::feedback::execute_request_feedback(&self.storage, request.arguments).await,
|
||||
|
|
@ -608,6 +829,13 @@ mod tests {
|
|||
.map(|t| t["name"].as_str().unwrap())
|
||||
.collect();
|
||||
|
||||
// Unified tools (v1.1+)
|
||||
assert!(tool_names.contains(&"search"));
|
||||
assert!(tool_names.contains(&"memory"));
|
||||
assert!(tool_names.contains(&"codebase"));
|
||||
assert!(tool_names.contains(&"intention"));
|
||||
|
||||
// Core tools
|
||||
assert!(tool_names.contains(&"ingest"));
|
||||
assert!(tool_names.contains(&"recall"));
|
||||
assert!(tool_names.contains(&"semantic_search"));
|
||||
|
|
|
|||
332
crates/vestige-mcp/src/tools/codebase_unified.rs
Normal file
332
crates/vestige-mcp/src/tools/codebase_unified.rs
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
//! Unified Codebase Tool
|
||||
//!
|
||||
//! Merges remember_pattern, remember_decision, and get_codebase_context into a single
|
||||
//! `codebase` tool with action-based dispatch.
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::{IngestInput, Storage};
|
||||
|
||||
/// Input schema for the unified codebase tool
|
||||
pub fn schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["remember_pattern", "remember_decision", "get_context"],
|
||||
"description": "Action to perform: 'remember_pattern' stores a code pattern, 'remember_decision' stores an architectural decision, 'get_context' retrieves patterns and decisions for a codebase"
|
||||
},
|
||||
// remember_pattern fields
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name/title for the pattern (required for remember_pattern)"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Detailed description of the pattern (required for remember_pattern)"
|
||||
},
|
||||
// remember_decision fields
|
||||
"decision": {
|
||||
"type": "string",
|
||||
"description": "The architectural or design decision made (required for remember_decision)"
|
||||
},
|
||||
"rationale": {
|
||||
"type": "string",
|
||||
"description": "Why this decision was made (required for remember_decision)"
|
||||
},
|
||||
"alternatives": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Alternatives that were considered (optional for remember_decision)"
|
||||
},
|
||||
// Shared fields
|
||||
"files": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Files where this pattern is used or affected by this decision"
|
||||
},
|
||||
"codebase": {
|
||||
"type": "string",
|
||||
"description": "Codebase/project identifier (e.g., 'vestige-tauri')"
|
||||
},
|
||||
// get_context fields
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum items per category (default: 10, for get_context)",
|
||||
"default": 10
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodebaseArgs {
|
||||
action: String,
|
||||
// Pattern fields
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
// Decision fields
|
||||
decision: Option<String>,
|
||||
rationale: Option<String>,
|
||||
alternatives: Option<Vec<String>>,
|
||||
// Shared fields
|
||||
files: Option<Vec<String>>,
|
||||
codebase: Option<String>,
|
||||
// Context fields
|
||||
limit: Option<i32>,
|
||||
}
|
||||
|
||||
/// Execute the unified codebase tool
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: CodebaseArgs = match args {
|
||||
Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?,
|
||||
None => return Err("Missing arguments".to_string()),
|
||||
};
|
||||
|
||||
match args.action.as_str() {
|
||||
"remember_pattern" => execute_remember_pattern(storage, &args).await,
|
||||
"remember_decision" => execute_remember_decision(storage, &args).await,
|
||||
"get_context" => execute_get_context(storage, &args).await,
|
||||
_ => Err(format!(
|
||||
"Invalid action '{}'. Must be one of: remember_pattern, remember_decision, get_context",
|
||||
args.action
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remember a code pattern
|
||||
async fn execute_remember_pattern(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: &CodebaseArgs,
|
||||
) -> Result<Value, String> {
|
||||
let name = args
|
||||
.name
|
||||
.as_ref()
|
||||
.ok_or("'name' is required for remember_pattern action")?;
|
||||
let description = args
|
||||
.description
|
||||
.as_ref()
|
||||
.ok_or("'description' is required for remember_pattern action")?;
|
||||
|
||||
if 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{}", name, 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!({
|
||||
"action": "remember_pattern",
|
||||
"success": true,
|
||||
"nodeId": node.id,
|
||||
"patternName": name,
|
||||
"message": format!("Pattern '{}' remembered successfully", name),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Remember an architectural decision
|
||||
async fn execute_remember_decision(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: &CodebaseArgs,
|
||||
) -> Result<Value, String> {
|
||||
let decision = args
|
||||
.decision
|
||||
.as_ref()
|
||||
.ok_or("'decision' is required for remember_decision action")?;
|
||||
let rationale = args
|
||||
.rationale
|
||||
.as_ref()
|
||||
.ok_or("'rationale' is required for remember_decision action")?;
|
||||
|
||||
if 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{}",
|
||||
&decision[..decision.len().min(50)],
|
||||
rationale,
|
||||
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!({
|
||||
"action": "remember_decision",
|
||||
"success": true,
|
||||
"nodeId": node.id,
|
||||
"message": "Architectural decision remembered successfully",
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get codebase context (patterns and decisions)
|
||||
async fn execute_get_context(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: &CodebaseArgs,
|
||||
) -> Result<Value, String> {
|
||||
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!({
|
||||
"action": "get_context",
|
||||
"codebase": args.codebase,
|
||||
"patterns": {
|
||||
"count": formatted_patterns.len(),
|
||||
"items": formatted_patterns,
|
||||
},
|
||||
"decisions": {
|
||||
"count": formatted_decisions.len(),
|
||||
"items": formatted_decisions,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_schema_structure() {
|
||||
let schema = schema();
|
||||
assert!(schema["properties"]["action"].is_object());
|
||||
assert_eq!(schema["required"], serde_json::json!(["action"]));
|
||||
|
||||
// Check action enum values
|
||||
let action_enum = &schema["properties"]["action"]["enum"];
|
||||
assert!(action_enum
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&serde_json::json!("remember_pattern")));
|
||||
assert!(action_enum
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&serde_json::json!("remember_decision")));
|
||||
assert!(action_enum
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&serde_json::json!("get_context")));
|
||||
}
|
||||
}
|
||||
1207
crates/vestige-mcp/src/tools/intention_unified.rs
Normal file
1207
crates/vestige-mcp/src/tools/intention_unified.rs
Normal file
File diff suppressed because it is too large
Load diff
223
crates/vestige-mcp/src/tools/memory_unified.rs
Normal file
223
crates/vestige-mcp/src/tools/memory_unified.rs
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
//! Unified Memory Tool
|
||||
//!
|
||||
//! Merges get_knowledge, delete_knowledge, and get_memory_state into a single
|
||||
//! `memory` tool with action-based dispatch.
|
||||
|
||||
use serde::Deserialize;
|
||||
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 the unified memory tool
|
||||
pub fn schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["get", "delete", "state"],
|
||||
"description": "Action to perform: 'get' retrieves full memory node, 'delete' removes memory, 'state' returns accessibility state"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The ID of the memory node"
|
||||
}
|
||||
},
|
||||
"required": ["action", "id"]
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MemoryArgs {
|
||||
action: String,
|
||||
id: String,
|
||||
}
|
||||
|
||||
/// Execute the unified memory tool
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: MemoryArgs = 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 format
|
||||
uuid::Uuid::parse_str(&args.id).map_err(|_| "Invalid memory ID format".to_string())?;
|
||||
|
||||
match args.action.as_str() {
|
||||
"get" => execute_get(storage, &args.id).await,
|
||||
"delete" => execute_delete(storage, &args.id).await,
|
||||
"state" => execute_state(storage, &args.id).await,
|
||||
_ => Err(format!(
|
||||
"Invalid action '{}'. Must be one of: get, delete, state",
|
||||
args.action
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get full memory node with all metadata
|
||||
async fn execute_get(storage: &Arc<Mutex<Storage>>, id: &str) -> Result<Value, String> {
|
||||
let storage = storage.lock().await;
|
||||
let node = storage.get_node(id).map_err(|e| e.to_string())?;
|
||||
|
||||
match node {
|
||||
Some(n) => Ok(serde_json::json!({
|
||||
"action": "get",
|
||||
"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!({
|
||||
"action": "get",
|
||||
"found": false,
|
||||
"nodeId": id,
|
||||
"message": "Memory not found",
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a memory and return success status
|
||||
async fn execute_delete(storage: &Arc<Mutex<Storage>>, id: &str) -> Result<Value, String> {
|
||||
let mut storage = storage.lock().await;
|
||||
let deleted = storage.delete_node(id).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"action": "delete",
|
||||
"success": deleted,
|
||||
"nodeId": id,
|
||||
"message": if deleted { "Memory deleted successfully" } else { "Memory not found" },
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get accessibility state of a memory (Active/Dormant/Silent/Unavailable)
|
||||
async fn execute_state(storage: &Arc<Mutex<Storage>>, id: &str) -> Result<Value, String> {
|
||||
let storage = storage.lock().await;
|
||||
|
||||
// Get the memory
|
||||
let memory = storage
|
||||
.get_node(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!({
|
||||
"action": "state",
|
||||
"memoryId": 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
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_accessibility_thresholds() {
|
||||
// Test Active state
|
||||
let accessibility = compute_accessibility(0.9, 0.8, 0.7);
|
||||
assert!(accessibility >= ACCESSIBILITY_ACTIVE);
|
||||
assert!(matches!(state_from_accessibility(accessibility), MemoryState::Active));
|
||||
|
||||
// Test Dormant state
|
||||
let accessibility = compute_accessibility(0.5, 0.5, 0.5);
|
||||
assert!(accessibility >= ACCESSIBILITY_DORMANT && accessibility < ACCESSIBILITY_ACTIVE);
|
||||
assert!(matches!(state_from_accessibility(accessibility), MemoryState::Dormant));
|
||||
|
||||
// Test Silent state
|
||||
let accessibility = compute_accessibility(0.2, 0.2, 0.2);
|
||||
assert!(accessibility >= ACCESSIBILITY_SILENT && accessibility < ACCESSIBILITY_DORMANT);
|
||||
assert!(matches!(state_from_accessibility(accessibility), MemoryState::Silent));
|
||||
|
||||
// Test Unavailable state
|
||||
let accessibility = compute_accessibility(0.05, 0.05, 0.05);
|
||||
assert!(accessibility < ACCESSIBILITY_SILENT);
|
||||
assert!(matches!(state_from_accessibility(accessibility), MemoryState::Unavailable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_structure() {
|
||||
let schema = schema();
|
||||
assert!(schema["properties"]["action"].is_object());
|
||||
assert!(schema["properties"]["id"].is_object());
|
||||
assert_eq!(schema["required"], serde_json::json!(["action", "id"]));
|
||||
}
|
||||
}
|
||||
|
|
@ -20,3 +20,9 @@ pub mod tagging;
|
|||
|
||||
// Feedback / preference learning
|
||||
pub mod feedback;
|
||||
|
||||
// Unified tools (consolidate multiple operations into single tools)
|
||||
pub mod codebase_unified;
|
||||
pub mod intention_unified;
|
||||
pub mod memory_unified;
|
||||
pub mod search_unified;
|
||||
|
|
|
|||
492
crates/vestige-mcp/src/tools/search_unified.rs
Normal file
492
crates/vestige-mcp/src/tools/search_unified.rs
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
//! Unified Search Tool
|
||||
//!
|
||||
//! Merges recall, semantic_search, and hybrid_search into a single `search` tool.
|
||||
//! Always uses hybrid search internally (keyword + semantic + RRF fusion).
|
||||
//! Implements Testing Effect (Roediger & Karpicke 2006) by auto-strengthening memories on access.
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::Storage;
|
||||
|
||||
/// Input schema for unified search 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
|
||||
},
|
||||
"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"]
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SearchArgs {
|
||||
query: String,
|
||||
limit: Option<i32>,
|
||||
min_retention: Option<f64>,
|
||||
min_similarity: Option<f32>,
|
||||
}
|
||||
|
||||
/// Execute unified search
|
||||
///
|
||||
/// Uses hybrid search (keyword + semantic + RRF fusion) internally.
|
||||
/// Auto-strengthens memories on access (Testing Effect - Roediger & Karpicke 2006).
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: SearchArgs = 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());
|
||||
}
|
||||
|
||||
// Clamp all parameters to valid ranges
|
||||
let limit = args.limit.unwrap_or(10).clamp(1, 100);
|
||||
let min_retention = args.min_retention.unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
let min_similarity = args.min_similarity.unwrap_or(0.5).clamp(0.0, 1.0);
|
||||
|
||||
// Use balanced weights for hybrid search (keyword + semantic)
|
||||
let keyword_weight = 0.5_f32;
|
||||
let semantic_weight = 0.5_f32;
|
||||
|
||||
let storage = storage.lock().await;
|
||||
|
||||
// Execute hybrid search
|
||||
let results = storage
|
||||
.hybrid_search(&args.query, limit, keyword_weight, semantic_weight)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Filter results by min_retention and min_similarity
|
||||
let filtered_results: Vec<_> = results
|
||||
.into_iter()
|
||||
.filter(|r| {
|
||||
// Check retention strength
|
||||
if r.node.retention_strength < min_retention {
|
||||
return false;
|
||||
}
|
||||
// Check similarity if semantic score is available
|
||||
if let Some(sem_score) = r.semantic_score {
|
||||
if sem_score < min_similarity {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Auto-strengthen memories on access (Testing Effect - Roediger & Karpicke 2006)
|
||||
// This implements "use it or lose it" - accessed memories get stronger
|
||||
let ids: Vec<&str> = filtered_results.iter().map(|r| r.node.id.as_str()).collect();
|
||||
let _ = storage.strengthen_batch_on_access(&ids); // Ignore errors, don't fail search
|
||||
|
||||
// Format results
|
||||
let formatted: Vec<Value> = filtered_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,
|
||||
"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,
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TESTS
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
use vestige_core::IngestInput;
|
||||
|
||||
/// 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_search_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_search_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_search_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_search_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_search_limit_clamped_to_minimum() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
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_search_limit_clamped_to_maximum() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
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_search_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_search_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_search_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 may return no results (retention > 1.0 clamped to 1.0)
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// MIN_SIMILARITY CLAMPING TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_min_similarity_clamped_to_zero() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_test_content(&storage, "Test content for similarity clamping").await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"query": "test",
|
||||
"min_similarity": -0.5
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_min_similarity_clamped_to_one() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_test_content(&storage, "Test content for max similarity").await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"query": "test",
|
||||
"min_similarity": 1.5
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
// Should succeed but may return no results
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SUCCESSFUL SEARCH TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_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_eq!(value["method"], "hybrid");
|
||||
assert!(value["total"].is_number());
|
||||
assert!(value["results"].is_array());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_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",
|
||||
"min_similarity": 0.0
|
||||
});
|
||||
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_search_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,
|
||||
"min_similarity": 0.0
|
||||
});
|
||||
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_search_empty_database_returns_empty_array() {
|
||||
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_search_result_contains_expected_fields() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_test_content(&storage, "Testing field presence in search results.").await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"query": "testing",
|
||||
"min_similarity": 0.0
|
||||
});
|
||||
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["combinedScore"].is_number());
|
||||
// keywordScore and semanticScore may be null if not matched
|
||||
assert!(first["nodeType"].is_string());
|
||||
assert!(first["tags"].is_array());
|
||||
assert!(first["retentionStrength"].is_number());
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// DEFAULT VALUES TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_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",
|
||||
"min_similarity": 0.0
|
||||
});
|
||||
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());
|
||||
assert!(schema_value["properties"]["min_similarity"].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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_min_similarity_has_bounds() {
|
||||
let schema_value = schema();
|
||||
let similarity_schema = &schema_value["properties"]["min_similarity"];
|
||||
assert_eq!(similarity_schema["minimum"], 0.0);
|
||||
assert_eq!(similarity_schema["maximum"], 1.0);
|
||||
assert_eq!(similarity_schema["default"], 0.5);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue