mirror of
https://github.com/samvallad33/vestige.git
synced 2026-04-25 00:36:22 +02:00
feat: Vestige v1.9.1 AUTONOMIC — self-regulating memory with graph visualization
Retention Target System: auto-GC low-retention memories during consolidation (VESTIGE_RETENTION_TARGET env var, default 0.8). Auto-Promote: memories accessed 3+ times in 24h get frequency-dependent potentiation. Waking SWR Tagging: promoted memories get preferential 70/30 dream replay. Improved Consolidation Scheduler: triggers on 6h staleness or 2h active use. New tools: memory_health (retention dashboard with distribution buckets, trend tracking, recommendations) and memory_graph (subgraph export with Fruchterman-Reingold force-directed layout, up to 200 nodes). Dream connections now persist to database via save_connection(), enabling memory_graph traversal. Schema Migration V8 adds waking_tag, utility_score, times_retrieved/useful columns and retention_snapshots table. 21 MCP tools. v1.9.1 fixes: ConnectionRecord export, UTF-8 safe truncation, link_type normalization, utility_score clamping, only-new-connections persistence, 70/30 split capacity fill, nonexistent center_id error handling. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c29023dd80
commit
5b90a73055
62 changed files with 2922 additions and 931 deletions
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "vestige-core"
|
||||
version = "1.7.0"
|
||||
version = "1.9.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
authors = ["Vestige Team"]
|
||||
|
|
|
|||
|
|
@ -252,20 +252,35 @@ impl ConsolidationScheduler {
|
|||
|
||||
/// Check if consolidation should run
|
||||
///
|
||||
/// Returns true if:
|
||||
/// - Auto consolidation is enabled
|
||||
/// - Sufficient time has passed since last consolidation
|
||||
/// - System is currently idle
|
||||
/// v1.9.0: Improved scheduler with multiple trigger conditions:
|
||||
/// - Full consolidation: >6h stale AND >10 new memories since last
|
||||
/// - Mini-consolidation (decay only): >2h if active
|
||||
/// - System idle AND interval passed
|
||||
pub fn should_consolidate(&self) -> bool {
|
||||
if !self.auto_enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
let time_since_last = Utc::now() - self.last_consolidation;
|
||||
|
||||
// Trigger 1: Standard interval + idle check
|
||||
let interval_passed = time_since_last >= self.consolidation_interval;
|
||||
let is_idle = self.activity_tracker.is_idle();
|
||||
if interval_passed && is_idle {
|
||||
return true;
|
||||
}
|
||||
|
||||
interval_passed && is_idle
|
||||
// Trigger 2: >6h stale (force consolidation regardless of idle)
|
||||
if time_since_last >= Duration::hours(6) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Trigger 3: Mini-consolidation every 2h if active
|
||||
if time_since_last >= Duration::hours(2) && !is_idle {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Force check if consolidation should run (ignoring idle check)
|
||||
|
|
@ -1720,12 +1735,16 @@ fn cosine_similarity(a: &[f32], b: &[f32]) -> f64 {
|
|||
(dot / (mag_a * mag_b)) as f64
|
||||
}
|
||||
|
||||
/// Truncate string to max length
|
||||
/// Truncate string to max length (UTF-8 safe)
|
||||
fn truncate(s: &str, max_len: usize) -> &str {
|
||||
if s.len() <= max_len {
|
||||
s
|
||||
} else {
|
||||
&s[..max_len]
|
||||
let mut end = max_len;
|
||||
while end > 0 && !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
&s[..end]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1905,7 +1924,8 @@ mod tests {
|
|||
|
||||
// Should have completed all stages
|
||||
assert!(report.stage1_replay.is_some());
|
||||
assert!(report.duration_ms >= 0);
|
||||
// duration_ms is u64, so just verify the field is accessible
|
||||
let _ = report.duration_ms;
|
||||
assert!(report.completed_at <= Utc::now());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ pub use dreams::{
|
|||
ConsolidationReport,
|
||||
// Sleep Consolidation types
|
||||
ConsolidationScheduler,
|
||||
DiscoveredConnection,
|
||||
DiscoveredConnectionType,
|
||||
DreamConfig,
|
||||
// DreamMemory - input type for dreaming
|
||||
DreamMemory,
|
||||
|
|
|
|||
|
|
@ -623,7 +623,6 @@ impl UserRepository for SqliteUserRepository {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::codebase::context::ProjectType;
|
||||
|
||||
fn create_test_pattern() -> CodePattern {
|
||||
CodePattern {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ impl CodeEmbedding {
|
|||
}
|
||||
|
||||
/// Initialize the embedding model
|
||||
pub fn init(&mut self) -> Result<(), EmbeddingError> {
|
||||
pub fn init(&self) -> Result<(), EmbeddingError> {
|
||||
self.service.init()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ impl Embedding {
|
|||
|
||||
/// Service for generating and managing embeddings
|
||||
pub struct EmbeddingService {
|
||||
model_loaded: bool,
|
||||
_unused: (),
|
||||
}
|
||||
|
||||
impl Default for EmbeddingService {
|
||||
|
|
@ -214,7 +214,7 @@ impl EmbeddingService {
|
|||
/// Create a new embedding service
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
model_loaded: false,
|
||||
_unused: (),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -235,9 +235,8 @@ impl EmbeddingService {
|
|||
}
|
||||
|
||||
/// Initialize the model (downloads if necessary)
|
||||
pub fn init(&mut self) -> Result<(), EmbeddingError> {
|
||||
pub fn init(&self) -> Result<(), EmbeddingError> {
|
||||
let _model = get_model()?; // Ensures model is loaded and returns any init errors
|
||||
self.model_loaded = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -138,8 +138,8 @@ pub use fsrs::{
|
|||
|
||||
// Storage layer
|
||||
pub use storage::{
|
||||
ConsolidationHistoryRecord, DreamHistoryRecord, InsightRecord, IntentionRecord, Result,
|
||||
SmartIngestResult, StateTransitionRecord, Storage, StorageError,
|
||||
ConnectionRecord, ConsolidationHistoryRecord, DreamHistoryRecord, InsightRecord,
|
||||
IntentionRecord, Result, SmartIngestResult, StateTransitionRecord, Storage, StorageError,
|
||||
};
|
||||
|
||||
// Consolidation (sleep-inspired memory processing)
|
||||
|
|
@ -175,6 +175,8 @@ pub use advanced::{
|
|||
DreamConfig,
|
||||
// DreamMemory - input type for dreaming
|
||||
DreamMemory,
|
||||
DiscoveredConnection,
|
||||
DiscoveredConnectionType,
|
||||
DreamResult,
|
||||
EmbeddingStrategy,
|
||||
ImportanceDecayConfig,
|
||||
|
|
|
|||
|
|
@ -2106,7 +2106,8 @@ mod tests {
|
|||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(barcode.id >= 0);
|
||||
// barcode.id is u64, verify it was assigned
|
||||
let _ = barcode.id;
|
||||
assert_eq!(index.len(), 1);
|
||||
|
||||
let retrieved = index.get_index("test-id").unwrap();
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ impl VectorIndex {
|
|||
let options = IndexOptions {
|
||||
dimensions: config.dimensions,
|
||||
metric: config.metric,
|
||||
quantization: ScalarKind::F16,
|
||||
quantization: ScalarKind::I8,
|
||||
connectivity: config.connectivity,
|
||||
expansion_add: config.expansion_add,
|
||||
expansion_search: config.expansion_search,
|
||||
|
|
@ -325,7 +325,7 @@ impl VectorIndex {
|
|||
let options = IndexOptions {
|
||||
dimensions: config.dimensions,
|
||||
metric: config.metric,
|
||||
quantization: ScalarKind::F16,
|
||||
quantization: ScalarKind::I8,
|
||||
connectivity: config.connectivity,
|
||||
expansion_add: config.expansion_add,
|
||||
expansion_search: config.expansion_search,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,16 @@ pub const MIGRATIONS: &[Migration] = &[
|
|||
description: "Dream history persistence for automation triggers",
|
||||
up: MIGRATION_V6_UP,
|
||||
},
|
||||
Migration {
|
||||
version: 7,
|
||||
description: "Performance: page_size 8192, FTS5 porter tokenizer",
|
||||
up: MIGRATION_V7_UP,
|
||||
},
|
||||
Migration {
|
||||
version: 8,
|
||||
description: "v1.9.0 Autonomic: waking SWR tags, utility scoring, retention tracking",
|
||||
up: MIGRATION_V8_UP,
|
||||
},
|
||||
];
|
||||
|
||||
/// A database migration
|
||||
|
|
@ -472,6 +482,73 @@ CREATE INDEX IF NOT EXISTS idx_dream_history_dreamed_at ON dream_history(dreamed
|
|||
UPDATE schema_version SET version = 6, applied_at = datetime('now');
|
||||
"#;
|
||||
|
||||
/// V7: Performance — FTS5 porter tokenizer for 15-30% better keyword recall (stemming)
|
||||
/// page_size upgrade handled in apply_migrations() since VACUUM can't run inside execute_batch
|
||||
const MIGRATION_V7_UP: &str = r#"
|
||||
-- FTS5 porter tokenizer upgrade (15-30% better keyword recall via stemming)
|
||||
DROP TRIGGER IF EXISTS knowledge_ai;
|
||||
DROP TRIGGER IF EXISTS knowledge_ad;
|
||||
DROP TRIGGER IF EXISTS knowledge_au;
|
||||
DROP TABLE IF EXISTS knowledge_fts;
|
||||
|
||||
CREATE VIRTUAL TABLE knowledge_fts USING fts5(
|
||||
id, content, tags,
|
||||
content='knowledge_nodes',
|
||||
content_rowid='rowid',
|
||||
tokenize='porter ascii'
|
||||
);
|
||||
|
||||
-- Rebuild FTS index from existing data with new tokenizer
|
||||
INSERT INTO knowledge_fts(knowledge_fts) VALUES('rebuild');
|
||||
|
||||
-- Re-create sync triggers
|
||||
CREATE TRIGGER knowledge_ai AFTER INSERT ON knowledge_nodes BEGIN
|
||||
INSERT INTO knowledge_fts(rowid, id, content, tags)
|
||||
VALUES (NEW.rowid, NEW.id, NEW.content, NEW.tags);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER knowledge_ad AFTER DELETE ON knowledge_nodes BEGIN
|
||||
INSERT INTO knowledge_fts(knowledge_fts, rowid, id, content, tags)
|
||||
VALUES ('delete', OLD.rowid, OLD.id, OLD.content, OLD.tags);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER knowledge_au AFTER UPDATE ON knowledge_nodes BEGIN
|
||||
INSERT INTO knowledge_fts(knowledge_fts, rowid, id, content, tags)
|
||||
VALUES ('delete', OLD.rowid, OLD.id, OLD.content, OLD.tags);
|
||||
INSERT INTO knowledge_fts(rowid, id, content, tags)
|
||||
VALUES (NEW.rowid, NEW.id, NEW.content, NEW.tags);
|
||||
END;
|
||||
|
||||
UPDATE schema_version SET version = 7, applied_at = datetime('now');
|
||||
"#;
|
||||
|
||||
/// V8: v1.9.0 Autonomic — Waking SWR tags, utility scoring, retention trend tracking
|
||||
const MIGRATION_V8_UP: &str = r#"
|
||||
-- Waking SWR (Sharp-Wave Ripple) tagging
|
||||
-- Memories tagged during waking operation get preferential replay during dream cycles
|
||||
ALTER TABLE knowledge_nodes ADD COLUMN waking_tag BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE knowledge_nodes ADD COLUMN waking_tag_at TEXT;
|
||||
|
||||
-- Utility scoring (MemRL-inspired: times_useful / times_retrieved)
|
||||
ALTER TABLE knowledge_nodes ADD COLUMN utility_score REAL DEFAULT 0.0;
|
||||
ALTER TABLE knowledge_nodes ADD COLUMN times_retrieved INTEGER DEFAULT 0;
|
||||
ALTER TABLE knowledge_nodes ADD COLUMN times_useful INTEGER DEFAULT 0;
|
||||
|
||||
-- Retention trend tracking (for retention target system)
|
||||
CREATE TABLE IF NOT EXISTS retention_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snapshot_at TEXT NOT NULL,
|
||||
avg_retention REAL NOT NULL,
|
||||
total_memories INTEGER NOT NULL,
|
||||
memories_below_target INTEGER NOT NULL DEFAULT 0,
|
||||
gc_triggered BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_retention_snapshots_at ON retention_snapshots(snapshot_at);
|
||||
|
||||
UPDATE schema_version SET version = 8, applied_at = datetime('now');
|
||||
"#;
|
||||
|
||||
/// Get current schema version from database
|
||||
pub fn get_current_version(conn: &rusqlite::Connection) -> rusqlite::Result<u32> {
|
||||
conn.query_row(
|
||||
|
|
@ -498,6 +575,14 @@ pub fn apply_migrations(conn: &rusqlite::Connection) -> rusqlite::Result<u32> {
|
|||
// Use execute_batch to handle multi-statement SQL including triggers
|
||||
conn.execute_batch(migration.up)?;
|
||||
|
||||
// V7: Upgrade page_size to 8192 (10-30% faster large-row reads)
|
||||
// VACUUM rewrites the DB with the new page size — can't run inside execute_batch
|
||||
if migration.version == 7 {
|
||||
conn.pragma_update(None, "page_size", 8192)?;
|
||||
conn.execute_batch("VACUUM;")?;
|
||||
tracing::info!("Database page_size upgraded to 8192 via VACUUM");
|
||||
}
|
||||
|
||||
applied += 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,6 @@ mod sqlite;
|
|||
|
||||
pub use migrations::MIGRATIONS;
|
||||
pub use sqlite::{
|
||||
ConsolidationHistoryRecord, DreamHistoryRecord, InsightRecord, IntentionRecord, Result,
|
||||
SmartIngestResult, StateTransitionRecord, Storage, StorageError,
|
||||
ConnectionRecord, ConsolidationHistoryRecord, DreamHistoryRecord, InsightRecord,
|
||||
IntentionRecord, Result, SmartIngestResult, StateTransitionRecord, Storage, StorageError,
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "vestige-mcp"
|
||||
version = "1.7.0"
|
||||
version = "1.9.1"
|
||||
edition = "2024"
|
||||
description = "Cognitive memory MCP server for Claude - FSRS-6, spreading activation, synaptic tagging, and 130 years of memory research"
|
||||
authors = ["samvallad33"]
|
||||
|
|
|
|||
|
|
@ -394,7 +394,7 @@ fn run_consolidate() -> anyhow::Result<()> {
|
|||
println!("Running memory consolidation cycle...");
|
||||
println!();
|
||||
|
||||
let mut storage = Storage::new(None)?;
|
||||
let storage = Storage::new(None)?;
|
||||
let result = storage.run_consolidation()?;
|
||||
|
||||
println!("{}: {}", "Nodes Processed".white().bold(), result.nodes_processed);
|
||||
|
|
@ -456,7 +456,7 @@ fn run_restore(backup_path: PathBuf) -> anyhow::Result<()> {
|
|||
|
||||
// Initialize storage
|
||||
println!("Initializing storage...");
|
||||
let mut storage = Storage::new(None)?;
|
||||
let storage = Storage::new(None)?;
|
||||
|
||||
println!("Generating embeddings and ingesting memories...");
|
||||
println!();
|
||||
|
|
@ -728,7 +728,7 @@ fn run_gc(
|
|||
println!("{}", "=== Vestige Garbage Collection ===".cyan().bold());
|
||||
println!();
|
||||
|
||||
let mut storage = Storage::new(None)?;
|
||||
let storage = Storage::new(None)?;
|
||||
let all_nodes = fetch_all_nodes(&storage)?;
|
||||
let now = Utc::now();
|
||||
|
||||
|
|
@ -892,7 +892,7 @@ fn run_ingest(
|
|||
valid_until: None,
|
||||
};
|
||||
|
||||
let mut storage = Storage::new(None)?;
|
||||
let storage = Storage::new(None)?;
|
||||
|
||||
// Try smart_ingest (PE Gating) if available, otherwise regular ingest
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
|
|
@ -943,7 +943,7 @@ fn run_dashboard(port: u16, open_browser: bool) -> anyhow::Result<()> {
|
|||
println!();
|
||||
println!("Starting dashboard at {}...", format!("http://127.0.0.1:{}", port).cyan());
|
||||
|
||||
let mut storage = Storage::new(None)?;
|
||||
let storage = Storage::new(None)?;
|
||||
|
||||
// Try to initialize embeddings for search support
|
||||
#[cfg(feature = "embeddings")]
|
||||
|
|
@ -957,7 +957,7 @@ fn run_dashboard(port: u16, open_browser: bool) -> anyhow::Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
let storage = std::sync::Arc::new(tokio::sync::Mutex::new(storage));
|
||||
let storage = std::sync::Arc::new(storage);
|
||||
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
rt.block_on(async move {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ fn main() -> anyhow::Result<()> {
|
|||
|
||||
// Initialize storage (uses default path)
|
||||
println!("Initializing storage...");
|
||||
let mut storage = Storage::new(None)?;
|
||||
let storage = Storage::new(None)?;
|
||||
|
||||
println!("Generating embeddings and ingesting memories...\n");
|
||||
|
||||
|
|
|
|||
|
|
@ -30,53 +30,50 @@ pub async fn list_memories(
|
|||
State(state): State<AppState>,
|
||||
Query(params): Query<MemoryListParams>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let storage = state.storage.lock().await;
|
||||
let limit = params.limit.unwrap_or(50).clamp(1, 200);
|
||||
let offset = params.offset.unwrap_or(0).max(0);
|
||||
|
||||
if let Some(query) = params.q.as_ref().filter(|q| !q.trim().is_empty()) {
|
||||
{
|
||||
// Use hybrid search
|
||||
let results = storage
|
||||
.hybrid_search(query, limit, 0.3, 0.7)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
// Use hybrid search
|
||||
let results = state.storage
|
||||
.hybrid_search(query, limit, 0.3, 0.7)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let formatted: Vec<Value> = results
|
||||
.into_iter()
|
||||
.filter(|r| {
|
||||
if let Some(min_ret) = params.min_retention {
|
||||
r.node.retention_strength >= min_ret
|
||||
} else {
|
||||
true
|
||||
}
|
||||
let formatted: Vec<Value> = results
|
||||
.into_iter()
|
||||
.filter(|r| {
|
||||
if let Some(min_ret) = params.min_retention {
|
||||
r.node.retention_strength >= min_ret
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.map(|r| {
|
||||
serde_json::json!({
|
||||
"id": r.node.id,
|
||||
"content": r.node.content,
|
||||
"nodeType": r.node.node_type,
|
||||
"tags": r.node.tags,
|
||||
"retentionStrength": r.node.retention_strength,
|
||||
"storageStrength": r.node.storage_strength,
|
||||
"retrievalStrength": r.node.retrieval_strength,
|
||||
"createdAt": r.node.created_at.to_rfc3339(),
|
||||
"updatedAt": r.node.updated_at.to_rfc3339(),
|
||||
"combinedScore": r.combined_score,
|
||||
"source": r.node.source,
|
||||
"reviewCount": r.node.reps,
|
||||
})
|
||||
.map(|r| {
|
||||
serde_json::json!({
|
||||
"id": r.node.id,
|
||||
"content": r.node.content,
|
||||
"nodeType": r.node.node_type,
|
||||
"tags": r.node.tags,
|
||||
"retentionStrength": r.node.retention_strength,
|
||||
"storageStrength": r.node.storage_strength,
|
||||
"retrievalStrength": r.node.retrieval_strength,
|
||||
"createdAt": r.node.created_at.to_rfc3339(),
|
||||
"updatedAt": r.node.updated_at.to_rfc3339(),
|
||||
"combinedScore": r.combined_score,
|
||||
"source": r.node.source,
|
||||
"reviewCount": r.node.reps,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
})
|
||||
.collect();
|
||||
|
||||
return Ok(Json(serde_json::json!({
|
||||
"total": formatted.len(),
|
||||
"memories": formatted,
|
||||
})));
|
||||
}
|
||||
return Ok(Json(serde_json::json!({
|
||||
"total": formatted.len(),
|
||||
"memories": formatted,
|
||||
})));
|
||||
}
|
||||
|
||||
// No search query — list all memories
|
||||
let mut nodes = storage
|
||||
let mut nodes = state.storage
|
||||
.get_all_nodes(limit, offset)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
|
|
@ -121,8 +118,7 @@ pub async fn get_memory(
|
|||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let storage = state.storage.lock().await;
|
||||
let node = storage
|
||||
let node = state.storage
|
||||
.get_node(&id)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
|
@ -153,8 +149,7 @@ pub async fn delete_memory(
|
|||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let mut storage = state.storage.lock().await;
|
||||
let deleted = storage
|
||||
let deleted = state.storage
|
||||
.delete_node(&id)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
|
|
@ -170,8 +165,7 @@ pub async fn promote_memory(
|
|||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let storage = state.storage.lock().await;
|
||||
let node = storage
|
||||
let node = state.storage
|
||||
.promote_memory(&id)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
|
|
@ -187,8 +181,7 @@ pub async fn demote_memory(
|
|||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let storage = state.storage.lock().await;
|
||||
let node = storage
|
||||
let node = state.storage
|
||||
.demote_memory(&id)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
|
|
@ -203,8 +196,7 @@ pub async fn demote_memory(
|
|||
pub async fn get_stats(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let storage = state.storage.lock().await;
|
||||
let stats = storage
|
||||
let stats = state.storage
|
||||
.get_stats()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
|
|
@ -239,12 +231,11 @@ pub async fn get_timeline(
|
|||
State(state): State<AppState>,
|
||||
Query(params): Query<TimelineParams>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let storage = state.storage.lock().await;
|
||||
let days = params.days.unwrap_or(7).clamp(1, 90);
|
||||
let limit = params.limit.unwrap_or(200).clamp(1, 500);
|
||||
|
||||
let start = Utc::now() - Duration::days(days);
|
||||
let nodes = storage
|
||||
let nodes = state.storage
|
||||
.query_time_range(Some(start), Some(Utc::now()), limit)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
|
|
@ -292,8 +283,7 @@ pub async fn get_timeline(
|
|||
pub async fn health_check(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let storage = state.storage.lock().await;
|
||||
let stats = storage
|
||||
let stats = state.storage
|
||||
.get_stats()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ use axum::routing::{delete, get, post};
|
|||
use axum::Router;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::set_header::SetResponseHeaderLayer;
|
||||
|
|
@ -20,7 +19,7 @@ use state::AppState;
|
|||
use vestige_core::Storage;
|
||||
|
||||
/// Build the axum router with all dashboard routes
|
||||
pub fn build_router(storage: Arc<Mutex<Storage>>, port: u16) -> Router {
|
||||
pub fn build_router(storage: Arc<Storage>, port: u16) -> Router {
|
||||
let state = AppState { storage };
|
||||
|
||||
let origin = format!("http://127.0.0.1:{}", port)
|
||||
|
|
@ -59,7 +58,7 @@ pub fn build_router(storage: Arc<Mutex<Storage>>, port: u16) -> Router {
|
|||
|
||||
/// Start the dashboard HTTP server (blocking — use in CLI mode)
|
||||
pub async fn start_dashboard(
|
||||
storage: Arc<Mutex<Storage>>,
|
||||
storage: Arc<Storage>,
|
||||
port: u16,
|
||||
open_browser: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
|
@ -83,7 +82,7 @@ pub async fn start_dashboard(
|
|||
|
||||
/// Start the dashboard as a background task (non-blocking — use in MCP server)
|
||||
pub async fn start_background(
|
||||
storage: Arc<Mutex<Storage>>,
|
||||
storage: Arc<Storage>,
|
||||
port: u16,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let app = build_router(storage, port);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
//! Dashboard shared state
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use vestige_core::Storage;
|
||||
|
||||
/// Shared application state for the dashboard
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub storage: Arc<Mutex<Storage>>,
|
||||
pub storage: Arc<Storage>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ async fn main() {
|
|||
|
||||
// Initialize storage with optional custom data directory
|
||||
let storage = match Storage::new(data_dir) {
|
||||
Ok(mut s) => {
|
||||
Ok(s) => {
|
||||
info!("Storage initialized successfully");
|
||||
|
||||
// Try to initialize embeddings early and log any issues
|
||||
|
|
@ -149,7 +149,7 @@ async fn main() {
|
|||
}
|
||||
}
|
||||
|
||||
Arc::new(Mutex::new(s))
|
||||
Arc::new(s)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to initialize storage: {}", e);
|
||||
|
|
@ -173,35 +173,31 @@ async fn main() {
|
|||
|
||||
loop {
|
||||
// Check whether consolidation is actually needed
|
||||
let should_run = {
|
||||
let storage = storage_clone.lock().await;
|
||||
match storage.get_last_consolidation() {
|
||||
Ok(Some(last)) => {
|
||||
let elapsed = chrono::Utc::now() - last;
|
||||
let stale = elapsed > chrono::Duration::hours(interval_hours as i64);
|
||||
if !stale {
|
||||
info!(
|
||||
last_consolidation = %last,
|
||||
"Skipping auto-consolidation (last run was < {} hours ago)",
|
||||
interval_hours
|
||||
);
|
||||
}
|
||||
stale
|
||||
}
|
||||
Ok(None) => {
|
||||
info!("No previous consolidation found — running first auto-consolidation");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Could not read consolidation history: {} — running anyway", e);
|
||||
true
|
||||
let should_run = match storage_clone.get_last_consolidation() {
|
||||
Ok(Some(last)) => {
|
||||
let elapsed = chrono::Utc::now() - last;
|
||||
let stale = elapsed > chrono::Duration::hours(interval_hours as i64);
|
||||
if !stale {
|
||||
info!(
|
||||
last_consolidation = %last,
|
||||
"Skipping auto-consolidation (last run was < {} hours ago)",
|
||||
interval_hours
|
||||
);
|
||||
}
|
||||
stale
|
||||
}
|
||||
Ok(None) => {
|
||||
info!("No previous consolidation found — running first auto-consolidation");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Could not read consolidation history: {} — running anyway", e);
|
||||
true
|
||||
}
|
||||
};
|
||||
|
||||
if should_run {
|
||||
let mut storage = storage_clone.lock().await;
|
||||
match storage.run_consolidation() {
|
||||
match storage_clone.run_consolidation() {
|
||||
Ok(result) => {
|
||||
info!(
|
||||
nodes_processed = result.nodes_processed,
|
||||
|
|
|
|||
|
|
@ -3,12 +3,11 @@
|
|||
//! codebase:// URI scheme resources for the MCP server.
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::{RecallInput, SearchMode, Storage};
|
||||
|
||||
/// Read a codebase:// resource
|
||||
pub async fn read(storage: &Arc<Mutex<Storage>>, uri: &str) -> Result<String, String> {
|
||||
pub async fn read(storage: &Arc<Storage>, uri: &str) -> Result<String, String> {
|
||||
let path = uri.strip_prefix("codebase://").unwrap_or("");
|
||||
|
||||
// Parse query parameters if present
|
||||
|
|
@ -38,9 +37,7 @@ fn parse_codebase_param(query: Option<&str>) -> Option<String> {
|
|||
})
|
||||
}
|
||||
|
||||
async fn read_structure(storage: &Arc<Mutex<Storage>>) -> Result<String, String> {
|
||||
let storage = storage.lock().await;
|
||||
|
||||
async fn read_structure(storage: &Arc<Storage>) -> Result<String, String> {
|
||||
// Get all pattern and decision nodes to infer structure
|
||||
// NOTE: We run separate queries because FTS5 sanitization removes OR operators
|
||||
// and wraps queries in quotes (phrase search), so "pattern OR decision" would
|
||||
|
|
@ -92,8 +89,7 @@ async fn read_structure(storage: &Arc<Mutex<Storage>>) -> Result<String, String>
|
|||
serde_json::to_string_pretty(&result).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn read_patterns(storage: &Arc<Mutex<Storage>>, query: Option<&str>) -> Result<String, String> {
|
||||
let storage = storage.lock().await;
|
||||
async fn read_patterns(storage: &Arc<Storage>, query: Option<&str>) -> Result<String, String> {
|
||||
let codebase = parse_codebase_param(query);
|
||||
|
||||
let search_query = match &codebase {
|
||||
|
|
@ -135,8 +131,7 @@ async fn read_patterns(storage: &Arc<Mutex<Storage>>, query: Option<&str>) -> Re
|
|||
serde_json::to_string_pretty(&result).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn read_decisions(storage: &Arc<Mutex<Storage>>, query: Option<&str>) -> Result<String, String> {
|
||||
let storage = storage.lock().await;
|
||||
async fn read_decisions(storage: &Arc<Storage>, query: Option<&str>) -> Result<String, String> {
|
||||
let codebase = parse_codebase_param(query);
|
||||
|
||||
let search_query = match &codebase {
|
||||
|
|
|
|||
|
|
@ -3,12 +3,11 @@
|
|||
//! memory:// URI scheme resources for the MCP server.
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::Storage;
|
||||
|
||||
/// Read a memory:// resource
|
||||
pub async fn read(storage: &Arc<Mutex<Storage>>, uri: &str) -> Result<String, String> {
|
||||
pub async fn read(storage: &Arc<Storage>, uri: &str) -> Result<String, String> {
|
||||
let path = uri.strip_prefix("memory://").unwrap_or("");
|
||||
|
||||
// Parse query parameters if present
|
||||
|
|
@ -50,8 +49,7 @@ fn parse_query_param(query: Option<&str>, key: &str, default: i32) -> i32 {
|
|||
.clamp(1, 100)
|
||||
}
|
||||
|
||||
async fn read_stats(storage: &Arc<Mutex<Storage>>) -> Result<String, String> {
|
||||
let storage = storage.lock().await;
|
||||
async fn read_stats(storage: &Arc<Storage>) -> Result<String, String> {
|
||||
let stats = storage.get_stats().map_err(|e| e.to_string())?;
|
||||
|
||||
let embedding_coverage = if stats.total_nodes > 0 {
|
||||
|
|
@ -88,8 +86,7 @@ async fn read_stats(storage: &Arc<Mutex<Storage>>) -> Result<String, String> {
|
|||
serde_json::to_string_pretty(&result).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn read_recent(storage: &Arc<Mutex<Storage>>, limit: i32) -> Result<String, String> {
|
||||
let storage = storage.lock().await;
|
||||
async fn read_recent(storage: &Arc<Storage>, limit: i32) -> Result<String, String> {
|
||||
let nodes = storage.get_all_nodes(limit, 0).map_err(|e| e.to_string())?;
|
||||
|
||||
let items: Vec<serde_json::Value> = nodes
|
||||
|
|
@ -118,9 +115,7 @@ async fn read_recent(storage: &Arc<Mutex<Storage>>, limit: i32) -> Result<String
|
|||
serde_json::to_string_pretty(&result).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn read_decaying(storage: &Arc<Mutex<Storage>>) -> Result<String, String> {
|
||||
let storage = storage.lock().await;
|
||||
|
||||
async fn read_decaying(storage: &Arc<Storage>) -> Result<String, String> {
|
||||
// Get nodes with low retention (below 0.5)
|
||||
let all_nodes = storage.get_all_nodes(100, 0).map_err(|e| e.to_string())?;
|
||||
|
||||
|
|
@ -176,8 +171,7 @@ async fn read_decaying(storage: &Arc<Mutex<Storage>>) -> Result<String, String>
|
|||
serde_json::to_string_pretty(&result).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn read_due(storage: &Arc<Mutex<Storage>>) -> Result<String, String> {
|
||||
let storage = storage.lock().await;
|
||||
async fn read_due(storage: &Arc<Storage>) -> Result<String, String> {
|
||||
let nodes = storage.get_review_queue(20).map_err(|e| e.to_string())?;
|
||||
|
||||
let items: Vec<serde_json::Value> = nodes
|
||||
|
|
@ -208,8 +202,7 @@ async fn read_due(storage: &Arc<Mutex<Storage>>) -> Result<String, String> {
|
|||
serde_json::to_string_pretty(&result).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn read_intentions(storage: &Arc<Mutex<Storage>>) -> Result<String, String> {
|
||||
let storage = storage.lock().await;
|
||||
async fn read_intentions(storage: &Arc<Storage>) -> Result<String, String> {
|
||||
let intentions = storage.get_active_intentions().map_err(|e| e.to_string())?;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
|
|
@ -247,8 +240,7 @@ async fn read_intentions(storage: &Arc<Mutex<Storage>>) -> Result<String, String
|
|||
serde_json::to_string_pretty(&result).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn read_triggered_intentions(storage: &Arc<Mutex<Storage>>) -> Result<String, String> {
|
||||
let storage = storage.lock().await;
|
||||
async fn read_triggered_intentions(storage: &Arc<Storage>) -> Result<String, String> {
|
||||
let overdue = storage.get_overdue_intentions().map_err(|e| e.to_string())?;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
|
|
@ -293,8 +285,7 @@ async fn read_triggered_intentions(storage: &Arc<Mutex<Storage>>) -> Result<Stri
|
|||
serde_json::to_string_pretty(&result).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn read_insights(storage: &Arc<Mutex<Storage>>) -> Result<String, String> {
|
||||
let storage = storage.lock().await;
|
||||
async fn read_insights(storage: &Arc<Storage>) -> Result<String, String> {
|
||||
let insights = storage.get_insights(50).map_err(|e| e.to_string())?;
|
||||
|
||||
let pending: Vec<_> = insights.iter().filter(|i| i.feedback.is_none()).collect();
|
||||
|
|
@ -327,8 +318,7 @@ async fn read_insights(storage: &Arc<Mutex<Storage>>) -> Result<String, String>
|
|||
serde_json::to_string_pretty(&result).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn read_consolidation_log(storage: &Arc<Mutex<Storage>>) -> Result<String, String> {
|
||||
let storage = storage.lock().await;
|
||||
async fn read_consolidation_log(storage: &Arc<Storage>) -> Result<String, String> {
|
||||
let history = storage.get_consolidation_history(20).map_err(|e| e.to_string())?;
|
||||
let last_run = storage.get_last_consolidation().map_err(|e| e.to_string())?;
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ use vestige_core::Storage;
|
|||
|
||||
/// MCP Server implementation
|
||||
pub struct McpServer {
|
||||
storage: Arc<Mutex<Storage>>,
|
||||
storage: Arc<Storage>,
|
||||
cognitive: Arc<Mutex<CognitiveEngine>>,
|
||||
initialized: bool,
|
||||
/// Tool call counter for inline consolidation trigger (every 100 calls)
|
||||
|
|
@ -30,7 +30,7 @@ pub struct McpServer {
|
|||
}
|
||||
|
||||
impl McpServer {
|
||||
pub fn new(storage: Arc<Mutex<Storage>>, cognitive: Arc<Mutex<CognitiveEngine>>) -> Self {
|
||||
pub fn new(storage: Arc<Storage>, cognitive: Arc<Mutex<CognitiveEngine>>) -> Self {
|
||||
Self {
|
||||
storage,
|
||||
cognitive,
|
||||
|
|
@ -131,7 +131,7 @@ impl McpServer {
|
|||
|
||||
/// Handle tools/list request
|
||||
async fn handle_tools_list(&self) -> Result<serde_json::Value, JsonRpcError> {
|
||||
// v1.7: 18 tools. Deprecated tools still work via redirects in handle_tools_call.
|
||||
// v1.8: 19 tools. Deprecated tools still work via redirects in handle_tools_call.
|
||||
let tools = vec![
|
||||
// ================================================================
|
||||
// UNIFIED TOOLS (v1.1+)
|
||||
|
|
@ -244,6 +244,27 @@ impl McpServer {
|
|||
description: Some("Restore memories from a JSON backup file. Supports MCP wrapper format, RecallResult format, and direct memory array format.".to_string()),
|
||||
input_schema: tools::restore::schema(),
|
||||
},
|
||||
// ================================================================
|
||||
// CONTEXT PACKETS (v1.8+)
|
||||
// ================================================================
|
||||
ToolDescription {
|
||||
name: "session_context".to_string(),
|
||||
description: Some("One-call session initialization. Combines search, intentions, status, predictions, and codebase context into a single token-budgeted response. Replaces 5 separate calls at session start.".to_string()),
|
||||
input_schema: tools::session_context::schema(),
|
||||
},
|
||||
// ================================================================
|
||||
// AUTONOMIC TOOLS (v1.9+)
|
||||
// ================================================================
|
||||
ToolDescription {
|
||||
name: "memory_health".to_string(),
|
||||
description: Some("Retention dashboard. Returns avg retention, retention distribution (buckets: 0-20%, 20-40%, etc.), trend (improving/declining/stable), and recommendation. Lightweight alternative to full system_status focused on memory quality.".to_string()),
|
||||
input_schema: tools::health::schema(),
|
||||
},
|
||||
ToolDescription {
|
||||
name: "memory_graph".to_string(),
|
||||
description: Some("Subgraph export for visualization. Input: center_id or query, depth (1-3), max_nodes. Returns nodes with force-directed layout positions and edges with weights. Powers memory graph visualization.".to_string()),
|
||||
input_schema: tools::graph::schema(),
|
||||
},
|
||||
];
|
||||
|
||||
let result = ListToolsResult { tools };
|
||||
|
|
@ -571,6 +592,17 @@ impl McpServer {
|
|||
"predict" => tools::predict::execute(&self.storage, &self.cognitive, request.arguments).await,
|
||||
"restore" => tools::restore::execute(&self.storage, request.arguments).await,
|
||||
|
||||
// ================================================================
|
||||
// CONTEXT PACKETS (v1.8+)
|
||||
// ================================================================
|
||||
"session_context" => tools::session_context::execute(&self.storage, &self.cognitive, request.arguments).await,
|
||||
|
||||
// ================================================================
|
||||
// AUTONOMIC TOOLS (v1.9+)
|
||||
// ================================================================
|
||||
"memory_health" => tools::health::execute(&self.storage, request.arguments).await,
|
||||
"memory_graph" => tools::graph::execute(&self.storage, request.arguments).await,
|
||||
|
||||
name => {
|
||||
return Err(JsonRpcError::method_not_found_with_message(&format!(
|
||||
"Unknown tool: {}",
|
||||
|
|
@ -618,21 +650,19 @@ impl McpServer {
|
|||
let _expired = cog.reconsolidation.reconsolidate_expired();
|
||||
}
|
||||
|
||||
if let Ok(mut storage) = storage_clone.try_lock() {
|
||||
match storage.run_consolidation() {
|
||||
Ok(result) => {
|
||||
tracing::info!(
|
||||
tool_calls = count,
|
||||
decay_applied = result.decay_applied,
|
||||
duplicates_merged = result.duplicates_merged,
|
||||
activations_computed = result.activations_computed,
|
||||
duration_ms = result.duration_ms,
|
||||
"Inline consolidation triggered (scheduler)"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Inline consolidation failed: {}", e);
|
||||
}
|
||||
match storage_clone.run_consolidation() {
|
||||
Ok(result) => {
|
||||
tracing::info!(
|
||||
tool_calls = count,
|
||||
decay_applied = result.decay_applied,
|
||||
duplicates_merged = result.duplicates_merged,
|
||||
activations_computed = result.activations_computed,
|
||||
duration_ms = result.duration_ms,
|
||||
"Inline consolidation triggered (scheduler)"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Inline consolidation failed: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -766,10 +796,10 @@ mod tests {
|
|||
use tempfile::TempDir;
|
||||
|
||||
/// Create a test storage instance with a temporary database
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
async fn test_storage() -> (Arc<Storage>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
(Arc::new(storage), dir)
|
||||
}
|
||||
|
||||
/// Create a test server with temporary storage
|
||||
|
|
@ -913,8 +943,8 @@ mod tests {
|
|||
let result = response.result.unwrap();
|
||||
let tools = result["tools"].as_array().unwrap();
|
||||
|
||||
// v1.7: 18 tools (4 unified + 1 core + 2 temporal + 5 maintenance + 2 auto-save + 3 cognitive + 1 restore)
|
||||
assert_eq!(tools.len(), 18, "Expected exactly 18 tools in v1.7+");
|
||||
// v1.9: 21 tools (4 unified + 1 core + 2 temporal + 5 maintenance + 2 auto-save + 3 cognitive + 1 restore + 1 session_context + 2 autonomic)
|
||||
assert_eq!(tools.len(), 21, "Expected exactly 21 tools in v1.9+");
|
||||
|
||||
let tool_names: Vec<&str> = tools
|
||||
.iter()
|
||||
|
|
@ -958,6 +988,13 @@ mod tests {
|
|||
assert!(tool_names.contains(&"explore_connections"));
|
||||
assert!(tool_names.contains(&"predict"));
|
||||
assert!(tool_names.contains(&"restore"));
|
||||
|
||||
// Context packets (v1.8)
|
||||
assert!(tool_names.contains(&"session_context"));
|
||||
|
||||
// Autonomic tools (v1.9)
|
||||
assert!(tool_names.contains(&"memory_health"));
|
||||
assert!(tool_names.contains(&"memory_graph"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use chrono::{DateTime, Utc};
|
|||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use vestige_core::Storage;
|
||||
|
|
@ -55,7 +55,7 @@ struct ChangelogArgs {
|
|||
|
||||
/// Execute memory_changelog tool
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: ChangelogArgs = match args {
|
||||
|
|
@ -69,7 +69,6 @@ pub async fn execute(
|
|||
};
|
||||
|
||||
let limit = args.limit.unwrap_or(20).clamp(1, 100);
|
||||
let storage = storage.lock().await;
|
||||
|
||||
if let Some(ref memory_id) = args.memory_id {
|
||||
// Per-memory mode: state transitions for a specific memory
|
||||
|
|
@ -196,15 +195,14 @@ mod tests {
|
|||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
async fn test_storage() -> (Arc<Storage>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
(Arc::new(storage), dir)
|
||||
}
|
||||
|
||||
async fn ingest_test_memory(storage: &Arc<Mutex<Storage>>) -> String {
|
||||
let mut s = storage.lock().await;
|
||||
let node = s
|
||||
async fn ingest_test_memory(storage: &Arc<Storage>) -> String {
|
||||
let node = storage
|
||||
.ingest(vestige_core::IngestInput {
|
||||
content: "Changelog test memory".to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
|
||||
use vestige_core::{IngestInput, Storage};
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ struct CheckpointItem {
|
|||
}
|
||||
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: CheckpointArgs = match args {
|
||||
|
|
@ -80,7 +80,6 @@ pub async fn execute(
|
|||
return Err("Maximum 20 items per checkpoint".to_string());
|
||||
}
|
||||
|
||||
let mut storage = storage.lock().await;
|
||||
let mut results = Vec::new();
|
||||
let mut created = 0u32;
|
||||
let mut updated = 0u32;
|
||||
|
|
@ -181,10 +180,10 @@ mod tests {
|
|||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
async fn test_storage() -> (Arc<Storage>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
(Arc::new(storage), dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
|
||||
use vestige_core::{IngestInput, Storage};
|
||||
|
||||
|
|
@ -115,7 +115,7 @@ struct ContextArgs {
|
|||
}
|
||||
|
||||
pub async fn execute_pattern(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: PatternArgs = match args {
|
||||
|
|
@ -156,7 +156,6 @@ pub async fn execute_pattern(
|
|||
valid_until: None,
|
||||
};
|
||||
|
||||
let mut storage = storage.lock().await;
|
||||
let node = storage.ingest(input).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
|
|
@ -168,7 +167,7 @@ pub async fn execute_pattern(
|
|||
}
|
||||
|
||||
pub async fn execute_decision(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: DecisionArgs = match args {
|
||||
|
|
@ -223,7 +222,6 @@ pub async fn execute_decision(
|
|||
valid_until: None,
|
||||
};
|
||||
|
||||
let mut storage = storage.lock().await;
|
||||
let node = storage.ingest(input).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
|
|
@ -234,7 +232,7 @@ pub async fn execute_decision(
|
|||
}
|
||||
|
||||
pub async fn execute_context(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: ContextArgs = args
|
||||
|
|
@ -247,7 +245,6 @@ pub async fn execute_context(
|
|||
});
|
||||
|
||||
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"]
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ struct CodebaseArgs {
|
|||
|
||||
/// Execute the unified codebase tool
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
|
|
@ -107,7 +107,7 @@ pub async fn execute(
|
|||
|
||||
/// Remember a code pattern
|
||||
async fn execute_remember_pattern(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: &CodebaseArgs,
|
||||
) -> Result<Value, String> {
|
||||
|
|
@ -153,10 +153,8 @@ async fn execute_remember_pattern(
|
|||
valid_until: None,
|
||||
};
|
||||
|
||||
let mut storage = storage.lock().await;
|
||||
let node = storage.ingest(input).map_err(|e| e.to_string())?;
|
||||
let node_id = node.id.clone();
|
||||
drop(storage);
|
||||
|
||||
// ====================================================================
|
||||
// COGNITIVE: Cross-project pattern recording
|
||||
|
|
@ -186,7 +184,7 @@ async fn execute_remember_pattern(
|
|||
|
||||
/// Remember an architectural decision
|
||||
async fn execute_remember_decision(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: &CodebaseArgs,
|
||||
) -> Result<Value, String> {
|
||||
|
|
@ -250,10 +248,8 @@ async fn execute_remember_decision(
|
|||
valid_until: None,
|
||||
};
|
||||
|
||||
let mut storage = storage.lock().await;
|
||||
let node = storage.ingest(input).map_err(|e| e.to_string())?;
|
||||
let node_id = node.id.clone();
|
||||
drop(storage);
|
||||
|
||||
// ====================================================================
|
||||
// COGNITIVE: Cross-project decision recording
|
||||
|
|
@ -282,12 +278,11 @@ async fn execute_remember_decision(
|
|||
|
||||
/// Get codebase context (patterns and decisions)
|
||||
async fn execute_get_context(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
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
|
||||
let tag_filter = args
|
||||
|
|
@ -304,7 +299,6 @@ async fn execute_get_context(
|
|||
let decisions = storage
|
||||
.get_nodes_by_type_and_tag("decision", tag_filter.as_deref(), limit)
|
||||
.unwrap_or_default();
|
||||
drop(storage);
|
||||
|
||||
let formatted_patterns: Vec<Value> = patterns
|
||||
.iter()
|
||||
|
|
@ -403,10 +397,10 @@ mod tests {
|
|||
Arc::new(Mutex::new(CognitiveEngine::new()))
|
||||
}
|
||||
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, tempfile::TempDir) {
|
||||
async fn test_storage() -> (Arc<Storage>, tempfile::TempDir) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
(Arc::new(storage), dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::Storage;
|
||||
|
||||
|
|
@ -16,8 +15,7 @@ pub fn schema() -> Value {
|
|||
})
|
||||
}
|
||||
|
||||
pub async fn execute(storage: &Arc<Mutex<Storage>>) -> Result<Value, String> {
|
||||
let mut storage = storage.lock().await;
|
||||
pub async fn execute(storage: &Arc<Storage>) -> Result<Value, String> {
|
||||
let result = storage.run_consolidation().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
use chrono::Utc;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
|
||||
use vestige_core::{RecallInput, SearchMode, Storage};
|
||||
|
||||
|
|
@ -51,7 +51,7 @@ pub fn schema() -> Value {
|
|||
}
|
||||
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args = args.ok_or("Missing arguments")?;
|
||||
|
|
@ -73,7 +73,6 @@ pub async fn execute(
|
|||
|
||||
let limit = args["limit"].as_i64().unwrap_or(10) as i32;
|
||||
|
||||
let storage = storage.lock().await;
|
||||
let now = Utc::now();
|
||||
|
||||
// Get candidate memories
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use serde::Deserialize;
|
|||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
|
||||
use vestige_core::Storage;
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
|
|
@ -89,7 +89,7 @@ impl UnionFind {
|
|||
}
|
||||
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: DedupArgs = match args {
|
||||
|
|
@ -107,7 +107,6 @@ pub async fn execute(
|
|||
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
{
|
||||
let storage = storage.lock().await;
|
||||
|
||||
// Load all embeddings
|
||||
let all_embeddings = storage
|
||||
|
|
@ -300,7 +299,7 @@ mod tests {
|
|||
async fn test_empty_storage() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
let storage = Arc::new(Mutex::new(storage));
|
||||
let storage = Arc::new(storage);
|
||||
let result = execute(&storage, None).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ pub fn schema() -> serde_json::Value {
|
|||
}
|
||||
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
|
|
@ -32,10 +32,42 @@ pub async fn execute(
|
|||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(50) as usize;
|
||||
|
||||
let storage_guard = storage.lock().await;
|
||||
let all_nodes = storage_guard.get_all_nodes(memory_count as i32, 0)
|
||||
// v1.9.0: Waking SWR tagging — preferential replay of tagged memories (70/30 split)
|
||||
let tagged_nodes = storage.get_waking_tagged_memories(memory_count as i32)
|
||||
.unwrap_or_default();
|
||||
let tagged_count = tagged_nodes.len();
|
||||
|
||||
// Calculate how many tagged vs random to include
|
||||
let tagged_target = (memory_count * 7 / 10).min(tagged_count); // 70% tagged
|
||||
let _random_target = memory_count.saturating_sub(tagged_target); // 30% random (used for logging)
|
||||
|
||||
// Build the dream memory set: tagged memories first, then fill with random
|
||||
let tagged_ids: std::collections::HashSet<String> = tagged_nodes.iter()
|
||||
.take(tagged_target)
|
||||
.map(|n| n.id.clone())
|
||||
.collect();
|
||||
|
||||
let random_nodes = storage.get_all_nodes(memory_count as i32, 0)
|
||||
.map_err(|e| format!("Failed to load memories: {}", e))?;
|
||||
|
||||
let mut all_nodes: Vec<_> = tagged_nodes.into_iter().take(tagged_target).collect();
|
||||
for node in random_nodes {
|
||||
if !tagged_ids.contains(&node.id) && all_nodes.len() < memory_count {
|
||||
all_nodes.push(node);
|
||||
}
|
||||
}
|
||||
// If still under capacity (e.g., all memories are tagged), fill from remaining tagged
|
||||
if all_nodes.len() < memory_count {
|
||||
let used_ids: std::collections::HashSet<String> = all_nodes.iter().map(|n| n.id.clone()).collect();
|
||||
let remaining_tagged = storage.get_waking_tagged_memories(memory_count as i32)
|
||||
.unwrap_or_default();
|
||||
for node in remaining_tagged {
|
||||
if !used_ids.contains(&node.id) && all_nodes.len() < memory_count {
|
||||
all_nodes.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if all_nodes.len() < 5 {
|
||||
return Ok(serde_json::json!({
|
||||
"status": "insufficient_memories",
|
||||
|
|
@ -48,23 +80,57 @@ pub async fn execute(
|
|||
vestige_core::DreamMemory {
|
||||
id: n.id.clone(),
|
||||
content: n.content.clone(),
|
||||
embedding: storage_guard.get_node_embedding(&n.id).ok().flatten(),
|
||||
embedding: storage.get_node_embedding(&n.id).ok().flatten(),
|
||||
tags: n.tags.clone(),
|
||||
created_at: n.created_at,
|
||||
access_count: n.reps as u32,
|
||||
}
|
||||
}).collect();
|
||||
// Drop storage lock before taking cognitive lock (strict ordering)
|
||||
drop(storage_guard);
|
||||
|
||||
let cog = cognitive.lock().await;
|
||||
let pre_dream_count = cog.dreamer.get_connections().len();
|
||||
let dream_result = cog.dreamer.dream(&dream_memories).await;
|
||||
let insights = cog.dreamer.synthesize_insights(&dream_memories);
|
||||
let all_connections = cog.dreamer.get_connections();
|
||||
drop(cog);
|
||||
|
||||
// v1.9.0: Persist only NEW connections from this dream (skip accumulated ones)
|
||||
let new_connections = &all_connections[pre_dream_count..];
|
||||
let mut connections_persisted = 0u64;
|
||||
{
|
||||
let now = Utc::now();
|
||||
for conn in new_connections {
|
||||
let link_type = match conn.connection_type {
|
||||
vestige_core::DiscoveredConnectionType::Semantic => "semantic",
|
||||
vestige_core::DiscoveredConnectionType::SharedConcept => "shared_concepts",
|
||||
vestige_core::DiscoveredConnectionType::Temporal => "temporal",
|
||||
vestige_core::DiscoveredConnectionType::Complementary => "complementary",
|
||||
vestige_core::DiscoveredConnectionType::CausalChain => "causal",
|
||||
};
|
||||
let record = vestige_core::ConnectionRecord {
|
||||
source_id: conn.from_id.clone(),
|
||||
target_id: conn.to_id.clone(),
|
||||
strength: conn.similarity,
|
||||
link_type: link_type.to_string(),
|
||||
created_at: now,
|
||||
last_activated: now,
|
||||
activation_count: 1,
|
||||
};
|
||||
if storage.save_connection(&record).is_ok() {
|
||||
connections_persisted += 1;
|
||||
}
|
||||
}
|
||||
if connections_persisted > 0 {
|
||||
tracing::info!(
|
||||
connections_persisted = connections_persisted,
|
||||
"Dream: persisted {} connections to database",
|
||||
connections_persisted
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist dream history (non-fatal on failure — dream still happened)
|
||||
{
|
||||
let mut storage_guard = storage.lock().await;
|
||||
let record = DreamHistoryRecord {
|
||||
dreamed_at: Utc::now(),
|
||||
duration_ms: dream_result.duration_ms as i64,
|
||||
|
|
@ -74,14 +140,19 @@ pub async fn execute(
|
|||
memories_strengthened: dream_result.memories_strengthened as i32,
|
||||
memories_compressed: dream_result.memories_compressed as i32,
|
||||
};
|
||||
if let Err(e) = storage_guard.save_dream_history(&record) {
|
||||
if let Err(e) = storage.save_dream_history(&record) {
|
||||
tracing::warn!("Failed to persist dream history: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// v1.9.0: Clear waking tags after dream processes them
|
||||
let tags_cleared = storage.clear_waking_tags().unwrap_or(0);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "dreamed",
|
||||
"memoriesReplayed": dream_memories.len(),
|
||||
"wakingTagsProcessed": tagged_target,
|
||||
"wakingTagsCleared": tags_cleared,
|
||||
"insights": insights.iter().map(|i| serde_json::json!({
|
||||
"insight_type": format!("{:?}", i.insight_type),
|
||||
"insight": i.insight,
|
||||
|
|
@ -89,8 +160,10 @@ pub async fn execute(
|
|||
"confidence": i.confidence,
|
||||
"novelty_score": i.novelty_score,
|
||||
})).collect::<Vec<_>>(),
|
||||
"connectionsPersisted": connections_persisted,
|
||||
"stats": {
|
||||
"new_connections_found": dream_result.new_connections_found,
|
||||
"connections_persisted": connections_persisted,
|
||||
"memories_strengthened": dream_result.memories_strengthened,
|
||||
"memories_compressed": dream_result.memories_compressed,
|
||||
"insights_generated": dream_result.insights_generated.len(),
|
||||
|
|
@ -109,16 +182,15 @@ mod tests {
|
|||
Arc::new(Mutex::new(CognitiveEngine::new()))
|
||||
}
|
||||
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
async fn test_storage() -> (Arc<Storage>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
(Arc::new(storage), dir)
|
||||
}
|
||||
|
||||
async fn ingest_n_memories(storage: &Arc<Mutex<Storage>>, n: usize) {
|
||||
let mut s = storage.lock().await;
|
||||
async fn ingest_n_memories(storage: &Arc<Storage>, n: usize) {
|
||||
for i in 0..n {
|
||||
s.ingest(vestige_core::IngestInput {
|
||||
storage.ingest(vestige_core::IngestInput {
|
||||
content: format!("Dream test memory number {}", i),
|
||||
node_type: "fact".to_string(),
|
||||
source: None,
|
||||
|
|
@ -216,8 +288,7 @@ mod tests {
|
|||
|
||||
// Before dream: no dream history
|
||||
{
|
||||
let s = storage.lock().await;
|
||||
assert!(s.get_last_dream().unwrap().is_none());
|
||||
assert!(storage.get_last_dream().unwrap().is_none());
|
||||
}
|
||||
|
||||
let result = execute(&storage, &test_cognitive(), None).await;
|
||||
|
|
@ -227,8 +298,7 @@ mod tests {
|
|||
|
||||
// After dream: dream history should exist
|
||||
{
|
||||
let s = storage.lock().await;
|
||||
let last = s.get_last_dream().unwrap();
|
||||
let last = storage.get_last_dream().unwrap();
|
||||
assert!(last.is_some(), "Dream should have been persisted to database");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ pub fn schema() -> serde_json::Value {
|
|||
}
|
||||
|
||||
pub async fn execute(
|
||||
_storage: &Arc<Mutex<Storage>>,
|
||||
_storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
|
|
@ -137,10 +137,10 @@ mod tests {
|
|||
Arc::new(Mutex::new(CognitiveEngine::new()))
|
||||
}
|
||||
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
async fn test_storage() -> (Arc<Storage>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
(Arc::new(storage), dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ struct FeedbackArgs {
|
|||
|
||||
/// Promote a memory (thumbs up) - it led to a good outcome
|
||||
pub async fn execute_promote(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
|
|
@ -73,14 +73,12 @@ pub async fn execute_promote(
|
|||
// Validate UUID
|
||||
uuid::Uuid::parse_str(&args.id).map_err(|_| "Invalid node ID format".to_string())?;
|
||||
|
||||
let storage_guard = storage.lock().await;
|
||||
|
||||
// Get node before for comparison
|
||||
let before = storage_guard.get_node(&args.id).map_err(|e| e.to_string())?
|
||||
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_guard.promote_memory(&args.id).map_err(|e| e.to_string())?;
|
||||
drop(storage_guard);
|
||||
let node = storage.promote_memory(&args.id).map_err(|e| e.to_string())?;
|
||||
|
||||
// ====================================================================
|
||||
// COGNITIVE FEEDBACK PIPELINE (promote)
|
||||
|
|
@ -133,7 +131,7 @@ pub async fn execute_promote(
|
|||
|
||||
/// Demote a memory (thumbs down) - it led to a bad outcome
|
||||
pub async fn execute_demote(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
|
|
@ -145,14 +143,12 @@ pub async fn execute_demote(
|
|||
// Validate UUID
|
||||
uuid::Uuid::parse_str(&args.id).map_err(|_| "Invalid node ID format".to_string())?;
|
||||
|
||||
let storage_guard = storage.lock().await;
|
||||
|
||||
// Get node before for comparison
|
||||
let before = storage_guard.get_node(&args.id).map_err(|e| e.to_string())?
|
||||
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_guard.demote_memory(&args.id).map_err(|e| e.to_string())?;
|
||||
drop(storage_guard);
|
||||
let node = storage.demote_memory(&args.id).map_err(|e| e.to_string())?;
|
||||
|
||||
// ====================================================================
|
||||
// COGNITIVE FEEDBACK PIPELINE (demote)
|
||||
|
|
@ -230,7 +226,7 @@ struct RequestFeedbackArgs {
|
|||
/// Request feedback from the user about a memory's usefulness
|
||||
/// Returns a structured prompt for Claude to ask the user
|
||||
pub async fn execute_request_feedback(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: RequestFeedbackArgs = match args {
|
||||
|
|
@ -241,7 +237,6 @@ pub async fn execute_request_feedback(
|
|||
// 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())?
|
||||
.ok_or_else(|| format!("Node not found: {}", args.id))?;
|
||||
|
|
@ -294,15 +289,14 @@ mod tests {
|
|||
Arc::new(Mutex::new(CognitiveEngine::new()))
|
||||
}
|
||||
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
async fn test_storage() -> (Arc<Storage>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
(Arc::new(storage), dir)
|
||||
}
|
||||
|
||||
async fn ingest_test_memory(storage: &Arc<Mutex<Storage>>) -> String {
|
||||
let mut s = storage.lock().await;
|
||||
let node = s
|
||||
async fn ingest_test_memory(storage: &Arc<Storage>) -> String {
|
||||
let node = storage
|
||||
.ingest(vestige_core::IngestInput {
|
||||
content: "Test memory for feedback".to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
|
|
@ -542,8 +536,7 @@ mod tests {
|
|||
async fn test_request_feedback_truncates_long_content() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let long_content = "A".repeat(200);
|
||||
let mut s = storage.lock().await;
|
||||
let node = s
|
||||
let node = storage
|
||||
.ingest(vestige_core::IngestInput {
|
||||
content: long_content,
|
||||
node_type: "fact".to_string(),
|
||||
|
|
@ -555,9 +548,9 @@ mod tests {
|
|||
valid_until: None,
|
||||
})
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let node_id = node.id.clone();
|
||||
|
||||
let args = serde_json::json!({ "id": node.id });
|
||||
let args = serde_json::json!({ "id": node_id });
|
||||
let result = execute_request_feedback(&storage, Some(args)).await;
|
||||
let value = result.unwrap();
|
||||
let preview = value["memoryPreview"].as_str().unwrap();
|
||||
|
|
|
|||
359
crates/vestige-mcp/src/tools/graph.rs
Normal file
359
crates/vestige-mcp/src/tools/graph.rs
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
//! memory_graph tool — Subgraph export with force-directed layout for visualization.
|
||||
//! v1.9.0: Computes Fruchterman-Reingold layout server-side.
|
||||
|
||||
use std::sync::Arc;
|
||||
use vestige_core::Storage;
|
||||
|
||||
pub fn schema() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"center_id": {
|
||||
"type": "string",
|
||||
"description": "Memory ID to center the graph on. Required if no query."
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query to find center node. Used if center_id not provided."
|
||||
},
|
||||
"depth": {
|
||||
"type": "integer",
|
||||
"description": "How many hops from center to include (1-3, default: 2)",
|
||||
"default": 2,
|
||||
"minimum": 1,
|
||||
"maximum": 3
|
||||
},
|
||||
"max_nodes": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of nodes to include (default: 50)",
|
||||
"default": 50,
|
||||
"maximum": 200
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Simple Fruchterman-Reingold force-directed layout
|
||||
fn fruchterman_reingold(
|
||||
node_count: usize,
|
||||
edges: &[(usize, usize, f64)],
|
||||
width: f64,
|
||||
height: f64,
|
||||
iterations: usize,
|
||||
) -> Vec<(f64, f64)> {
|
||||
if node_count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
if node_count == 1 {
|
||||
return vec![(width / 2.0, height / 2.0)];
|
||||
}
|
||||
|
||||
let area = width * height;
|
||||
let k = (area / node_count as f64).sqrt();
|
||||
|
||||
// Initialize positions in a circle
|
||||
let mut positions: Vec<(f64, f64)> = (0..node_count)
|
||||
.map(|i| {
|
||||
let angle = 2.0 * std::f64::consts::PI * i as f64 / node_count as f64;
|
||||
(
|
||||
width / 2.0 + (width / 3.0) * angle.cos(),
|
||||
height / 2.0 + (height / 3.0) * angle.sin(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut temperature = width / 10.0;
|
||||
let cooling = temperature / iterations as f64;
|
||||
|
||||
for _ in 0..iterations {
|
||||
let mut displacements = vec![(0.0f64, 0.0f64); node_count];
|
||||
|
||||
// Repulsive forces between all pairs
|
||||
for i in 0..node_count {
|
||||
for j in (i + 1)..node_count {
|
||||
let dx = positions[i].0 - positions[j].0;
|
||||
let dy = positions[i].1 - positions[j].1;
|
||||
let dist = (dx * dx + dy * dy).sqrt().max(0.01);
|
||||
let force = k * k / dist;
|
||||
let fx = dx / dist * force;
|
||||
let fy = dy / dist * force;
|
||||
displacements[i].0 += fx;
|
||||
displacements[i].1 += fy;
|
||||
displacements[j].0 -= fx;
|
||||
displacements[j].1 -= fy;
|
||||
}
|
||||
}
|
||||
|
||||
// Attractive forces along edges
|
||||
for &(u, v, weight) in edges {
|
||||
let dx = positions[u].0 - positions[v].0;
|
||||
let dy = positions[u].1 - positions[v].1;
|
||||
let dist = (dx * dx + dy * dy).sqrt().max(0.01);
|
||||
let force = dist * dist / k * weight;
|
||||
let fx = dx / dist * force;
|
||||
let fy = dy / dist * force;
|
||||
displacements[u].0 -= fx;
|
||||
displacements[u].1 -= fy;
|
||||
displacements[v].0 += fx;
|
||||
displacements[v].1 += fy;
|
||||
}
|
||||
|
||||
// Apply displacements with temperature limiting
|
||||
for i in 0..node_count {
|
||||
let dx = displacements[i].0;
|
||||
let dy = displacements[i].1;
|
||||
let dist = (dx * dx + dy * dy).sqrt().max(0.01);
|
||||
let capped = dist.min(temperature);
|
||||
positions[i].0 += dx / dist * capped;
|
||||
positions[i].1 += dy / dist * capped;
|
||||
|
||||
// Clamp to bounds
|
||||
positions[i].0 = positions[i].0.clamp(10.0, width - 10.0);
|
||||
positions[i].1 = positions[i].1.clamp(10.0, height - 10.0);
|
||||
}
|
||||
|
||||
temperature -= cooling;
|
||||
if temperature < 0.1 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
positions
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let depth = args.as_ref()
|
||||
.and_then(|a| a.get("depth"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(2)
|
||||
.min(3) as u32;
|
||||
|
||||
let max_nodes = args.as_ref()
|
||||
.and_then(|a| a.get("max_nodes"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(50)
|
||||
.min(200) as usize;
|
||||
|
||||
// Determine center node
|
||||
let center_id = if let Some(id) = args.as_ref().and_then(|a| a.get("center_id")).and_then(|v| v.as_str()) {
|
||||
id.to_string()
|
||||
} else if let Some(query) = args.as_ref().and_then(|a| a.get("query")).and_then(|v| v.as_str()) {
|
||||
// Search for center node
|
||||
let results = storage.search(query, 1)
|
||||
.map_err(|e| format!("Search failed: {}", e))?;
|
||||
results.first()
|
||||
.map(|n| n.id.clone())
|
||||
.ok_or_else(|| "No memories found matching query".to_string())?
|
||||
} else {
|
||||
// Default: use the most recent memory
|
||||
let recent = storage.get_all_nodes(1, 0)
|
||||
.map_err(|e| format!("Failed to get recent node: {}", e))?;
|
||||
recent.first()
|
||||
.map(|n| n.id.clone())
|
||||
.ok_or_else(|| "No memories in database".to_string())?
|
||||
};
|
||||
|
||||
// Get subgraph
|
||||
let (nodes, edges) = storage.get_memory_subgraph(¢er_id, depth, max_nodes)
|
||||
.map_err(|e| format!("Failed to get subgraph: {}", e))?;
|
||||
|
||||
if nodes.is_empty() || !nodes.iter().any(|n| n.id == center_id) {
|
||||
return Err(format!("Memory '{}' not found or has no accessible data", center_id));
|
||||
}
|
||||
|
||||
// Build index map for FR layout
|
||||
let id_to_idx: std::collections::HashMap<&str, usize> = nodes.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| (n.id.as_str(), i))
|
||||
.collect();
|
||||
|
||||
let layout_edges: Vec<(usize, usize, f64)> = edges.iter()
|
||||
.filter_map(|e| {
|
||||
let u = id_to_idx.get(e.source_id.as_str())?;
|
||||
let v = id_to_idx.get(e.target_id.as_str())?;
|
||||
Some((*u, *v, e.strength))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Compute force-directed layout
|
||||
let positions = fruchterman_reingold(nodes.len(), &layout_edges, 800.0, 600.0, 50);
|
||||
|
||||
// Build response
|
||||
let nodes_json: Vec<serde_json::Value> = nodes.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| {
|
||||
let (x, y) = positions.get(i).copied().unwrap_or((400.0, 300.0));
|
||||
serde_json::json!({
|
||||
"id": n.id,
|
||||
"label": if n.content.chars().count() > 60 {
|
||||
format!("{}...", n.content.chars().take(57).collect::<String>())
|
||||
} else {
|
||||
n.content.clone()
|
||||
},
|
||||
"type": n.node_type,
|
||||
"retention": n.retention_strength,
|
||||
"tags": n.tags,
|
||||
"x": (x * 100.0).round() / 100.0,
|
||||
"y": (y * 100.0).round() / 100.0,
|
||||
"isCenter": n.id == center_id,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let edges_json: Vec<serde_json::Value> = edges.iter()
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"source": e.source_id,
|
||||
"target": e.target_id,
|
||||
"weight": e.strength,
|
||||
"type": e.link_type,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"nodes": nodes_json,
|
||||
"edges": edges_json,
|
||||
"center_id": center_id,
|
||||
"depth": depth,
|
||||
"nodeCount": nodes.len(),
|
||||
"edgeCount": edges.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn test_storage() -> (Arc<Storage>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(storage), dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_is_valid() {
|
||||
let s = schema();
|
||||
assert_eq!(s["type"], "object");
|
||||
assert!(s["properties"]["center_id"].is_object());
|
||||
assert!(s["properties"]["query"].is_object());
|
||||
assert!(s["properties"]["depth"].is_object());
|
||||
assert!(s["properties"]["max_nodes"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fruchterman_reingold_empty() {
|
||||
let positions = fruchterman_reingold(0, &[], 800.0, 600.0, 50);
|
||||
assert!(positions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fruchterman_reingold_single_node() {
|
||||
let positions = fruchterman_reingold(1, &[], 800.0, 600.0, 50);
|
||||
assert_eq!(positions.len(), 1);
|
||||
assert!((positions[0].0 - 400.0).abs() < 0.01);
|
||||
assert!((positions[0].1 - 300.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fruchterman_reingold_two_nodes() {
|
||||
let edges = vec![(0, 1, 1.0)];
|
||||
let positions = fruchterman_reingold(2, &edges, 800.0, 600.0, 50);
|
||||
assert_eq!(positions.len(), 2);
|
||||
// Nodes should be within bounds
|
||||
for (x, y) in &positions {
|
||||
assert!(*x >= 10.0 && *x <= 790.0);
|
||||
assert!(*y >= 10.0 && *y <= 590.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fruchterman_reingold_connected_graph() {
|
||||
let edges = vec![(0, 1, 1.0), (1, 2, 1.0), (2, 0, 1.0)];
|
||||
let positions = fruchterman_reingold(3, &edges, 800.0, 600.0, 50);
|
||||
assert_eq!(positions.len(), 3);
|
||||
// Connected nodes should be closer than disconnected nodes in a larger graph
|
||||
for (x, y) in &positions {
|
||||
assert!(*x >= 10.0 && *x <= 790.0);
|
||||
assert!(*y >= 10.0 && *y <= 590.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graph_empty_database() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, None).await;
|
||||
assert!(result.is_err()); // No memories to center on
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graph_with_center_id() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let node = storage.ingest(vestige_core::IngestInput {
|
||||
content: "Graph test memory".to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
source: None,
|
||||
sentiment_score: 0.0,
|
||||
sentiment_magnitude: 0.0,
|
||||
tags: vec!["test".to_string()],
|
||||
valid_from: None,
|
||||
valid_until: None,
|
||||
}).unwrap();
|
||||
|
||||
let args = serde_json::json!({ "center_id": node.id });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["center_id"], node.id);
|
||||
assert_eq!(value["nodeCount"], 1);
|
||||
let nodes = value["nodes"].as_array().unwrap();
|
||||
assert_eq!(nodes.len(), 1);
|
||||
assert_eq!(nodes[0]["isCenter"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graph_with_query() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
storage.ingest(vestige_core::IngestInput {
|
||||
content: "Quantum computing fundamentals".to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
source: None,
|
||||
sentiment_score: 0.0,
|
||||
sentiment_magnitude: 0.0,
|
||||
tags: vec!["science".to_string()],
|
||||
valid_from: None,
|
||||
valid_until: None,
|
||||
}).unwrap();
|
||||
|
||||
let args = serde_json::json!({ "query": "quantum" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert!(value["nodeCount"].as_u64().unwrap() >= 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graph_node_has_position() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let node = storage.ingest(vestige_core::IngestInput {
|
||||
content: "Position test memory".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,
|
||||
}).unwrap();
|
||||
|
||||
let args = serde_json::json!({ "center_id": node.id });
|
||||
let result = execute(&storage, Some(args)).await.unwrap();
|
||||
let nodes = result["nodes"].as_array().unwrap();
|
||||
assert!(nodes[0]["x"].is_number());
|
||||
assert!(nodes[0]["y"].is_number());
|
||||
}
|
||||
}
|
||||
150
crates/vestige-mcp/src/tools/health.rs
Normal file
150
crates/vestige-mcp/src/tools/health.rs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
//! memory_health tool — Retention dashboard for memory quality monitoring.
|
||||
//! v1.9.0: Lightweight alternative to full system_status focused on memory health.
|
||||
|
||||
use std::sync::Arc;
|
||||
use vestige_core::Storage;
|
||||
|
||||
pub fn schema() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
storage: &Arc<Storage>,
|
||||
_args: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
// Average retention
|
||||
let avg_retention = storage.get_avg_retention()
|
||||
.map_err(|e| format!("Failed to get avg retention: {}", e))?;
|
||||
|
||||
// Retention distribution
|
||||
let distribution = storage.get_retention_distribution()
|
||||
.map_err(|e| format!("Failed to get retention distribution: {}", e))?;
|
||||
|
||||
let distribution_json: serde_json::Value = distribution.iter().map(|(bucket, count)| {
|
||||
serde_json::json!({ "bucket": bucket, "count": count })
|
||||
}).collect();
|
||||
|
||||
// Retention trend
|
||||
let trend = storage.get_retention_trend()
|
||||
.unwrap_or_else(|_| "unknown".to_string());
|
||||
|
||||
// Total memories and those below key thresholds
|
||||
let stats = storage.get_stats()
|
||||
.map_err(|e| format!("Failed to get stats: {}", e))?;
|
||||
|
||||
let below_30 = storage.count_memories_below_retention(0.3).unwrap_or(0);
|
||||
let below_50 = storage.count_memories_below_retention(0.5).unwrap_or(0);
|
||||
|
||||
// Retention target
|
||||
let retention_target: f64 = std::env::var("VESTIGE_RETENTION_TARGET")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0.8);
|
||||
|
||||
let meets_target = avg_retention >= retention_target;
|
||||
|
||||
// Generate recommendation
|
||||
let recommendation = if avg_retention >= 0.8 {
|
||||
"Excellent memory health. Retention is strong across the board."
|
||||
} else if avg_retention >= 0.6 {
|
||||
"Good memory health. Consider reviewing memories in the 0-40% range."
|
||||
} else if avg_retention >= 0.4 {
|
||||
"Fair memory health. Many memories are decaying. Run consolidation and consider GC."
|
||||
} else {
|
||||
"Poor memory health. Urgent: run consolidation, then GC stale memories below 0.3."
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"avgRetention": format!("{:.1}%", avg_retention * 100.0),
|
||||
"avgRetentionRaw": avg_retention,
|
||||
"retentionTarget": retention_target,
|
||||
"meetsTarget": meets_target,
|
||||
"totalMemories": stats.total_nodes,
|
||||
"distribution": distribution_json,
|
||||
"trend": trend,
|
||||
"memoriesBelow30pct": below_30,
|
||||
"memoriesBelow50pct": below_50,
|
||||
"recommendation": recommendation,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn test_storage() -> (Arc<Storage>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(storage), dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_is_valid() {
|
||||
let s = schema();
|
||||
assert_eq!(s["type"], "object");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_empty_database() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, None).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["totalMemories"], 0);
|
||||
assert!(value["avgRetention"].is_string());
|
||||
assert!(value["recommendation"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_with_memories() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
// Ingest some test memories
|
||||
for i in 0..5 {
|
||||
storage.ingest(vestige_core::IngestInput {
|
||||
content: format!("Health test memory {}", i),
|
||||
node_type: "fact".to_string(),
|
||||
source: None,
|
||||
sentiment_score: 0.0,
|
||||
sentiment_magnitude: 0.0,
|
||||
tags: vec!["test".to_string()],
|
||||
valid_from: None,
|
||||
valid_until: None,
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
let result = execute(&storage, None).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["totalMemories"], 5);
|
||||
assert!(value["distribution"].is_array());
|
||||
assert!(value["meetsTarget"].is_boolean());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_distribution_buckets() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
storage.ingest(vestige_core::IngestInput {
|
||||
content: "Test memory for distribution".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,
|
||||
}).unwrap();
|
||||
|
||||
let result = execute(&storage, None).await.unwrap();
|
||||
let dist = result["distribution"].as_array().unwrap();
|
||||
// Should have at least one bucket with data
|
||||
assert!(!dist.is_empty());
|
||||
let total: i64 = dist.iter()
|
||||
.map(|b| b["count"].as_i64().unwrap_or(0))
|
||||
.sum();
|
||||
assert_eq!(total, 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -47,7 +47,7 @@ struct ImportanceArgs {
|
|||
}
|
||||
|
||||
pub async fn execute(
|
||||
_storage: &Arc<Mutex<Storage>>,
|
||||
_storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
|
|
@ -137,18 +137,18 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_empty_content_fails() {
|
||||
let storage = Arc::new(Mutex::new(
|
||||
let storage = Arc::new(
|
||||
Storage::new(Some(std::path::PathBuf::from("/tmp/test_importance.db"))).unwrap(),
|
||||
));
|
||||
);
|
||||
let result = execute(&storage, &test_cognitive(), Some(serde_json::json!({ "content": "" }))).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_basic_importance_score() {
|
||||
let storage = Arc::new(Mutex::new(
|
||||
let storage = Arc::new(
|
||||
Storage::new(Some(std::path::PathBuf::from("/tmp/test_importance2.db"))).unwrap(),
|
||||
));
|
||||
);
|
||||
let result = execute(
|
||||
&storage,
|
||||
&test_cognitive(),
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ struct IngestArgs {
|
|||
}
|
||||
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
|
|
@ -123,20 +123,18 @@ pub async fn execute(
|
|||
// ====================================================================
|
||||
// INGEST (storage lock)
|
||||
// ====================================================================
|
||||
let mut storage_guard = storage.lock().await;
|
||||
|
||||
// Route through smart_ingest when embeddings are available to prevent duplicates.
|
||||
// Falls back to raw ingest only when embeddings aren't ready.
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
{
|
||||
let fallback_input = input.clone();
|
||||
match storage_guard.smart_ingest(input) {
|
||||
match storage.smart_ingest(input) {
|
||||
Ok(result) => {
|
||||
let node_id = result.node.id.clone();
|
||||
let node_content = result.node.content.clone();
|
||||
let node_type = result.node.node_type.clone();
|
||||
let has_embedding = result.node.has_embedding.unwrap_or(false);
|
||||
drop(storage_guard);
|
||||
|
||||
run_post_ingest(cognitive, &node_id, &node_content, &node_type, importance_composite);
|
||||
|
||||
|
|
@ -153,12 +151,11 @@ pub async fn execute(
|
|||
}))
|
||||
}
|
||||
Err(_) => {
|
||||
let node = storage_guard.ingest(fallback_input).map_err(|e| e.to_string())?;
|
||||
let node = storage.ingest(fallback_input).map_err(|e| e.to_string())?;
|
||||
let node_id = node.id.clone();
|
||||
let node_content = node.content.clone();
|
||||
let node_type = node.node_type.clone();
|
||||
let has_embedding = node.has_embedding.unwrap_or(false);
|
||||
drop(storage_guard);
|
||||
|
||||
run_post_ingest(cognitive, &node_id, &node_content, &node_type, importance_composite);
|
||||
|
||||
|
|
@ -178,12 +175,11 @@ pub async fn execute(
|
|||
// Fallback for builds without embedding features
|
||||
#[cfg(not(all(feature = "embeddings", feature = "vector-search")))]
|
||||
{
|
||||
let node = storage_guard.ingest(input).map_err(|e| e.to_string())?;
|
||||
let node = storage.ingest(input).map_err(|e| e.to_string())?;
|
||||
let node_id = node.id.clone();
|
||||
let node_content = node.content.clone();
|
||||
let node_type = node.node_type.clone();
|
||||
let has_embedding = node.has_embedding.unwrap_or(false);
|
||||
drop(storage_guard);
|
||||
|
||||
run_post_ingest(cognitive, &node_id, &node_content, &node_type, importance_composite);
|
||||
|
||||
|
|
@ -249,10 +245,10 @@ mod tests {
|
|||
}
|
||||
|
||||
/// Create a test storage instance with a temporary database
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
async fn test_storage() -> (Arc<Storage>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
(Arc::new(storage), dir)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
|
|
@ -412,8 +408,7 @@ mod tests {
|
|||
|
||||
// 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();
|
||||
let node = storage.get_node(&node_id).unwrap().unwrap();
|
||||
assert_eq!(node.node_type, "fact");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ struct UnifiedIntentionArgs {
|
|||
|
||||
/// Execute the unified intention tool
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
|
|
@ -226,7 +226,7 @@ pub async fn execute(
|
|||
|
||||
/// Execute "set" action - create a new intention
|
||||
async fn execute_set(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: &UnifiedIntentionArgs,
|
||||
) -> Result<Value, String> {
|
||||
|
|
@ -382,7 +382,6 @@ async fn execute_set(
|
|||
source_data: None,
|
||||
};
|
||||
|
||||
let mut storage = storage.lock().await;
|
||||
storage.save_intention(&record).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
|
|
@ -399,7 +398,7 @@ async fn execute_set(
|
|||
|
||||
/// Execute "check" action - find triggered intentions
|
||||
async fn execute_check(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: &UnifiedIntentionArgs,
|
||||
) -> Result<Value, String> {
|
||||
|
|
@ -425,7 +424,6 @@ async fn execute_check(
|
|||
let _ = cog.prospective_memory.update_context(prospective_ctx);
|
||||
}
|
||||
|
||||
let storage = storage.lock().await;
|
||||
|
||||
// Get active intentions
|
||||
let intentions = storage.get_active_intentions().map_err(|e| e.to_string())?;
|
||||
|
|
@ -518,7 +516,7 @@ async fn execute_check(
|
|||
|
||||
/// Execute "update" action - complete, snooze, or cancel an intention
|
||||
async fn execute_update(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: &UnifiedIntentionArgs,
|
||||
) -> Result<Value, String> {
|
||||
let intention_id = args
|
||||
|
|
@ -533,7 +531,6 @@ async fn execute_update(
|
|||
|
||||
match status.as_str() {
|
||||
"complete" => {
|
||||
let mut storage = storage.lock().await;
|
||||
let updated = storage
|
||||
.update_intention_status(intention_id, "fulfilled")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
|
@ -554,7 +551,6 @@ async fn execute_update(
|
|||
let minutes = args.snooze_minutes.unwrap_or(30);
|
||||
let snooze_until = Utc::now() + Duration::minutes(minutes);
|
||||
|
||||
let mut storage = storage.lock().await;
|
||||
let updated = storage
|
||||
.snooze_intention(intention_id, snooze_until)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
|
@ -573,7 +569,6 @@ async fn execute_update(
|
|||
}
|
||||
}
|
||||
"cancel" => {
|
||||
let mut storage = storage.lock().await;
|
||||
let updated = storage
|
||||
.update_intention_status(intention_id, "cancelled")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
|
@ -599,11 +594,10 @@ async fn execute_update(
|
|||
|
||||
/// Execute "list" action - list intentions with optional filtering
|
||||
async fn execute_list(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: &UnifiedIntentionArgs,
|
||||
) -> Result<Value, String> {
|
||||
let filter_status = args.filter_status.as_deref().unwrap_or("active");
|
||||
let storage = storage.lock().await;
|
||||
|
||||
let intentions = if filter_status == "all" {
|
||||
// Get all by combining different statuses
|
||||
|
|
@ -682,14 +676,14 @@ mod tests {
|
|||
}
|
||||
|
||||
/// Create a test storage instance with a temporary database
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
async fn test_storage() -> (Arc<Storage>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
(Arc::new(storage), dir)
|
||||
}
|
||||
|
||||
/// Helper to create an intention and return its ID
|
||||
async fn create_test_intention(storage: &Arc<Mutex<Storage>>, description: &str) -> String {
|
||||
async fn create_test_intention(storage: &Arc<Storage>, description: &str) -> String {
|
||||
let args = serde_json::json!({
|
||||
"action": "set",
|
||||
"description": description
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use chrono::{DateTime, Utc, Duration};
|
||||
use uuid::Uuid;
|
||||
|
||||
|
|
@ -222,7 +222,7 @@ struct ListArgs {
|
|||
|
||||
/// Execute set_intention tool
|
||||
pub async fn execute_set(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: SetIntentionArgs = match args {
|
||||
|
|
@ -290,7 +290,6 @@ pub async fn execute_set(
|
|||
source_data: None,
|
||||
};
|
||||
|
||||
let mut storage = storage.lock().await;
|
||||
storage.save_intention(&record).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
|
|
@ -305,7 +304,7 @@ pub async fn execute_set(
|
|||
|
||||
/// Execute check_intentions tool
|
||||
pub async fn execute_check(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: CheckIntentionsArgs = match args {
|
||||
|
|
@ -314,7 +313,6 @@ pub async fn execute_check(
|
|||
};
|
||||
|
||||
let now = Utc::now();
|
||||
let storage = storage.lock().await;
|
||||
|
||||
// Get active intentions
|
||||
let intentions = storage.get_active_intentions().map_err(|e| e.to_string())?;
|
||||
|
|
@ -400,7 +398,7 @@ pub async fn execute_check(
|
|||
|
||||
/// Execute complete_intention tool
|
||||
pub async fn execute_complete(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: IntentionIdArgs = match args {
|
||||
|
|
@ -408,7 +406,6 @@ pub async fn execute_complete(
|
|||
None => return Err("Missing intention_id".to_string()),
|
||||
};
|
||||
|
||||
let mut storage = storage.lock().await;
|
||||
let updated = storage.update_intention_status(&args.intention_id, "fulfilled")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
|
|
@ -425,7 +422,7 @@ pub async fn execute_complete(
|
|||
|
||||
/// Execute snooze_intention tool
|
||||
pub async fn execute_snooze(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: SnoozeArgs = match args {
|
||||
|
|
@ -436,7 +433,6 @@ pub async fn execute_snooze(
|
|||
let minutes = args.minutes.unwrap_or(30);
|
||||
let snooze_until = Utc::now() + Duration::minutes(minutes);
|
||||
|
||||
let mut storage = storage.lock().await;
|
||||
let updated = storage.snooze_intention(&args.intention_id, snooze_until)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
|
|
@ -454,7 +450,7 @@ pub async fn execute_snooze(
|
|||
|
||||
/// Execute list_intentions tool
|
||||
pub async fn execute_list(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: ListArgs = match args {
|
||||
|
|
@ -463,7 +459,6 @@ pub async fn execute_list(
|
|||
};
|
||||
|
||||
let status = args.status.as_deref().unwrap_or("active");
|
||||
let storage = storage.lock().await;
|
||||
|
||||
let intentions = if status == "all" {
|
||||
// Get all by combining different statuses
|
||||
|
|
@ -522,14 +517,14 @@ mod tests {
|
|||
use tempfile::TempDir;
|
||||
|
||||
/// Create a test storage instance with a temporary database
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
async fn test_storage() -> (Arc<Storage>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
(Arc::new(storage), dir)
|
||||
}
|
||||
|
||||
/// Helper to create an intention and return its ID
|
||||
async fn create_test_intention(storage: &Arc<Mutex<Storage>>, description: &str) -> String {
|
||||
async fn create_test_intention(storage: &Arc<Storage>, description: &str) -> String {
|
||||
let args = serde_json::json!({
|
||||
"description": description
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::Storage;
|
||||
|
||||
|
|
@ -44,7 +43,7 @@ struct KnowledgeArgs {
|
|||
}
|
||||
|
||||
pub async fn execute_get(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: KnowledgeArgs = match args {
|
||||
|
|
@ -55,7 +54,6 @@ pub async fn execute_get(
|
|||
// 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 {
|
||||
|
|
@ -93,7 +91,7 @@ pub async fn execute_get(
|
|||
}
|
||||
|
||||
pub async fn execute_delete(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: KnowledgeArgs = match args {
|
||||
|
|
@ -104,7 +102,6 @@ pub async fn execute_delete(
|
|||
// 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!({
|
||||
|
|
|
|||
|
|
@ -118,12 +118,11 @@ pub fn system_status_schema() -> Value {
|
|||
/// Returns system health status, full statistics, FSRS preview,
|
||||
/// cognitive module health, state distribution, and actionable recommendations.
|
||||
pub async fn execute_system_status(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
_args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let storage_guard = storage.lock().await;
|
||||
let stats = storage_guard.get_stats().map_err(|e| e.to_string())?;
|
||||
let stats = storage.get_stats().map_err(|e| e.to_string())?;
|
||||
|
||||
// === Health assessment ===
|
||||
let status = if stats.total_nodes == 0 {
|
||||
|
|
@ -142,7 +141,7 @@ pub async fn execute_system_status(
|
|||
0.0
|
||||
};
|
||||
|
||||
let embedding_ready = storage_guard.is_embedding_ready();
|
||||
let embedding_ready = storage.is_embedding_ready();
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
if stats.average_retention < 0.5 && stats.total_nodes > 0 {
|
||||
|
|
@ -176,7 +175,7 @@ pub async fn execute_system_status(
|
|||
}
|
||||
|
||||
// === State distribution ===
|
||||
let nodes = storage_guard.get_all_nodes(500, 0).map_err(|e| e.to_string())?;
|
||||
let nodes = storage.get_all_nodes(500, 0).map_err(|e| e.to_string())?;
|
||||
let total = nodes.len();
|
||||
let (active, dormant, silent, unavailable) = if total > 0 {
|
||||
let mut a = 0usize;
|
||||
|
|
@ -246,15 +245,14 @@ pub async fn execute_system_status(
|
|||
};
|
||||
|
||||
// === Automation triggers (for conditional dream/backup/gc at session start) ===
|
||||
let last_consolidation = storage_guard.get_last_consolidation().ok().flatten();
|
||||
let last_dream = storage_guard.get_last_dream().ok().flatten();
|
||||
let last_consolidation = storage.get_last_consolidation().ok().flatten();
|
||||
let last_dream = storage.get_last_dream().ok().flatten();
|
||||
let saves_since_last_dream = match &last_dream {
|
||||
Some(dt) => storage_guard.count_memories_since(*dt).unwrap_or(0),
|
||||
Some(dt) => storage.count_memories_since(*dt).unwrap_or(0),
|
||||
None => stats.total_nodes as i64,
|
||||
};
|
||||
let last_backup = Storage::get_last_backup_timestamp();
|
||||
|
||||
drop(storage_guard);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"tool": "system_status",
|
||||
|
|
@ -299,10 +297,9 @@ pub async fn execute_system_status(
|
|||
/// Health check tool — deprecated in v1.7, use execute_system_status() instead
|
||||
#[allow(dead_code)]
|
||||
pub async fn execute_health_check(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
_args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let storage = storage.lock().await;
|
||||
let stats = storage.get_stats().map_err(|e| e.to_string())?;
|
||||
|
||||
let status = if stats.total_nodes == 0 {
|
||||
|
|
@ -369,10 +366,9 @@ pub async fn execute_health_check(
|
|||
|
||||
/// Consolidate tool
|
||||
pub async fn execute_consolidate(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
_args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let mut storage = storage.lock().await;
|
||||
let result = storage.run_consolidation().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
|
|
@ -392,15 +388,14 @@ pub async fn execute_consolidate(
|
|||
/// Stats tool — deprecated in v1.7, use execute_system_status() instead
|
||||
#[allow(dead_code)]
|
||||
pub async fn execute_stats(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
_args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let storage_guard = storage.lock().await;
|
||||
let stats = storage_guard.get_stats().map_err(|e| e.to_string())?;
|
||||
let stats = storage.get_stats().map_err(|e| e.to_string())?;
|
||||
|
||||
// Compute state distribution from a sample of nodes
|
||||
let nodes = storage_guard.get_all_nodes(500, 0).map_err(|e| e.to_string())?;
|
||||
let nodes = storage.get_all_nodes(500, 0).map_err(|e| e.to_string())?;
|
||||
let total = nodes.len();
|
||||
let (active, dormant, silent, unavailable) = if total > 0 {
|
||||
let mut a = 0usize;
|
||||
|
|
@ -543,7 +538,6 @@ pub async fn execute_stats(
|
|||
} else {
|
||||
None
|
||||
};
|
||||
drop(storage_guard);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"tool": "stats",
|
||||
|
|
@ -573,7 +567,7 @@ pub async fn execute_stats(
|
|||
|
||||
/// Backup tool
|
||||
pub async fn execute_backup(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
_args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
// Determine backup path
|
||||
|
|
@ -591,7 +585,6 @@ pub async fn execute_backup(
|
|||
|
||||
// Use VACUUM INTO for a consistent backup (handles WAL properly)
|
||||
{
|
||||
let storage = storage.lock().await;
|
||||
storage.backup_to(&backup_path)
|
||||
.map_err(|e| format!("Failed to create backup: {}", e))?;
|
||||
}
|
||||
|
|
@ -619,7 +612,7 @@ struct ExportArgs {
|
|||
|
||||
/// Export tool
|
||||
pub async fn execute_export(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: ExportArgs = match args {
|
||||
|
|
@ -650,7 +643,6 @@ pub async fn execute_export(
|
|||
let tag_filter: Vec<String> = args.tags.unwrap_or_default();
|
||||
|
||||
// Fetch all nodes (capped at 100K to prevent OOM)
|
||||
let storage = storage.lock().await;
|
||||
let mut all_nodes = Vec::new();
|
||||
let page_size = 500;
|
||||
let max_nodes = 100_000;
|
||||
|
|
@ -755,7 +747,7 @@ struct GcArgs {
|
|||
|
||||
/// Garbage collection tool
|
||||
pub async fn execute_gc(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: GcArgs = match args {
|
||||
|
|
@ -771,7 +763,6 @@ pub async fn execute_gc(
|
|||
let max_age_days = args.max_age_days;
|
||||
let dry_run = args.dry_run.unwrap_or(true); // Default to dry_run for safety
|
||||
|
||||
let mut storage = storage.lock().await;
|
||||
let now = Utc::now();
|
||||
|
||||
// Fetch all nodes (capped at 100K to prevent OOM)
|
||||
|
|
@ -883,10 +874,10 @@ mod tests {
|
|||
Arc::new(Mutex::new(CognitiveEngine::new()))
|
||||
}
|
||||
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
async fn test_storage() -> (Arc<Storage>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
(Arc::new(storage), dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -912,8 +903,7 @@ mod tests {
|
|||
async fn test_system_status_with_memories() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
{
|
||||
let mut s = storage.lock().await;
|
||||
s.ingest(vestige_core::IngestInput {
|
||||
storage.ingest(vestige_core::IngestInput {
|
||||
content: "Test memory for status".to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
source: None,
|
||||
|
|
@ -961,9 +951,8 @@ mod tests {
|
|||
async fn test_system_status_automation_triggers_with_memories() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
{
|
||||
let mut s = storage.lock().await;
|
||||
for i in 0..3 {
|
||||
s.ingest(vestige_core::IngestInput {
|
||||
storage.ingest(vestige_core::IngestInput {
|
||||
content: format!("Automation trigger test memory {}", i),
|
||||
node_type: "fact".to_string(),
|
||||
source: None,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::{MemoryState, Storage};
|
||||
|
||||
|
|
@ -77,7 +76,7 @@ pub fn stats_schema() -> Value {
|
|||
|
||||
/// Get the cognitive state of a specific memory
|
||||
pub async fn execute_get(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args = args.ok_or("Missing arguments")?;
|
||||
|
|
@ -86,7 +85,6 @@ pub async fn execute_get(
|
|||
.as_str()
|
||||
.ok_or("memory_id is required")?;
|
||||
|
||||
let storage = storage.lock().await;
|
||||
|
||||
// Get the memory
|
||||
let memory = storage.get_node(memory_id)
|
||||
|
|
@ -131,7 +129,7 @@ pub async fn execute_get(
|
|||
|
||||
/// List memories by state
|
||||
pub async fn execute_list(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args = args.unwrap_or(serde_json::json!({}));
|
||||
|
|
@ -139,7 +137,6 @@ pub async fn execute_list(
|
|||
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)
|
||||
|
|
@ -210,9 +207,8 @@ pub async fn execute_list(
|
|||
|
||||
/// Get memory state statistics
|
||||
pub async fn execute_stats(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
) -> Result<Value, String> {
|
||||
let storage = storage.lock().await;
|
||||
|
||||
let memories = storage.get_all_nodes(1000, 0)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ struct MemoryArgs {
|
|||
|
||||
/// Execute the unified memory tool
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
|
|
@ -95,8 +95,7 @@ pub async fn execute(
|
|||
}
|
||||
|
||||
/// 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;
|
||||
async fn execute_get(storage: &Arc<Storage>, id: &str) -> Result<Value, String> {
|
||||
let node = storage.get_node(id).map_err(|e| e.to_string())?;
|
||||
|
||||
match node {
|
||||
|
|
@ -136,8 +135,7 @@ async fn execute_get(storage: &Arc<Mutex<Storage>>, id: &str) -> Result<Value, S
|
|||
}
|
||||
|
||||
/// 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;
|
||||
async fn execute_delete(storage: &Arc<Storage>, id: &str) -> Result<Value, String> {
|
||||
let deleted = storage.delete_node(id).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
|
|
@ -149,8 +147,7 @@ async fn execute_delete(storage: &Arc<Mutex<Storage>>, id: &str) -> Result<Value
|
|||
}
|
||||
|
||||
/// 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;
|
||||
async fn execute_state(storage: &Arc<Storage>, id: &str) -> Result<Value, String> {
|
||||
|
||||
// Get the memory
|
||||
let memory = storage
|
||||
|
|
@ -197,18 +194,16 @@ async fn execute_state(storage: &Arc<Mutex<Storage>>, id: &str) -> Result<Value,
|
|||
|
||||
/// Promote a memory (thumbs up) — increases retrieval strength with cognitive feedback pipeline
|
||||
async fn execute_promote(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
id: &str,
|
||||
reason: Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
let storage_guard = storage.lock().await;
|
||||
|
||||
let before = storage_guard.get_node(id).map_err(|e| e.to_string())?
|
||||
let before = storage.get_node(id).map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Node not found: {}", id))?;
|
||||
|
||||
let node = storage_guard.promote_memory(id).map_err(|e| e.to_string())?;
|
||||
drop(storage_guard);
|
||||
let node = storage.promote_memory(id).map_err(|e| e.to_string())?;
|
||||
|
||||
// Cognitive feedback pipeline
|
||||
if let Ok(mut cog) = cognitive.try_lock() {
|
||||
|
|
@ -254,18 +249,16 @@ async fn execute_promote(
|
|||
|
||||
/// Demote a memory (thumbs down) — decreases retrieval strength with cognitive feedback pipeline
|
||||
async fn execute_demote(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
id: &str,
|
||||
reason: Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
let storage_guard = storage.lock().await;
|
||||
|
||||
let before = storage_guard.get_node(id).map_err(|e| e.to_string())?
|
||||
let before = storage.get_node(id).map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Node not found: {}", id))?;
|
||||
|
||||
let node = storage_guard.demote_memory(id).map_err(|e| e.to_string())?;
|
||||
drop(storage_guard);
|
||||
let node = storage.demote_memory(id).map_err(|e| e.to_string())?;
|
||||
|
||||
// Cognitive feedback pipeline
|
||||
if let Ok(mut cog) = cognitive.try_lock() {
|
||||
|
|
@ -356,15 +349,14 @@ mod tests {
|
|||
Arc::new(Mutex::new(CognitiveEngine::new()))
|
||||
}
|
||||
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, tempfile::TempDir) {
|
||||
async fn test_storage() -> (Arc<Storage>, tempfile::TempDir) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
(Arc::new(storage), dir)
|
||||
}
|
||||
|
||||
async fn ingest_memory(storage: &Arc<Mutex<Storage>>) -> String {
|
||||
let mut s = storage.lock().await;
|
||||
let node = s
|
||||
async fn ingest_memory(storage: &Arc<Storage>) -> String {
|
||||
let node = storage
|
||||
.ingest(vestige_core::IngestInput {
|
||||
content: "Memory unified test content".to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
|
|
|
|||
|
|
@ -30,6 +30,13 @@ pub mod explore;
|
|||
pub mod predict;
|
||||
pub mod restore;
|
||||
|
||||
// v1.8: Context Packets
|
||||
pub mod session_context;
|
||||
|
||||
// v1.9: Autonomic tools
|
||||
pub mod health;
|
||||
pub mod graph;
|
||||
|
||||
// Deprecated tools - kept for internal backwards compatibility
|
||||
// These modules are intentionally unused in the public API
|
||||
#[allow(dead_code)]
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ pub fn schema() -> serde_json::Value {
|
|||
}
|
||||
|
||||
pub async fn execute(
|
||||
_storage: &Arc<Mutex<Storage>>,
|
||||
_storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
|
|
@ -127,10 +127,10 @@ mod tests {
|
|||
Arc::new(Mutex::new(CognitiveEngine::new()))
|
||||
}
|
||||
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
async fn test_storage() -> (Arc<Storage>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
(Arc::new(storage), dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::{RecallInput, SearchMode, Storage};
|
||||
|
||||
|
|
@ -46,7 +45,7 @@ struct RecallArgs {
|
|||
}
|
||||
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: RecallArgs = match args {
|
||||
|
|
@ -66,7 +65,6 @@ pub async fn execute(
|
|||
valid_at: None,
|
||||
};
|
||||
|
||||
let storage = storage.lock().await;
|
||||
let nodes = storage.recall(input).map_err(|e| e.to_string())?;
|
||||
|
||||
let results: Vec<Value> = nodes
|
||||
|
|
@ -107,14 +105,14 @@ mod tests {
|
|||
use tempfile::TempDir;
|
||||
|
||||
/// Create a test storage instance with a temporary database
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
async fn test_storage() -> (Arc<Storage>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
(Arc::new(storage), dir)
|
||||
}
|
||||
|
||||
/// Helper to ingest test content
|
||||
async fn ingest_test_content(storage: &Arc<Mutex<Storage>>, content: &str) -> String {
|
||||
async fn ingest_test_content(storage: &Arc<Storage>, content: &str) -> String {
|
||||
let input = IngestInput {
|
||||
content: content.to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
|
|
@ -125,8 +123,7 @@ mod tests {
|
|||
valid_from: None,
|
||||
valid_until: None,
|
||||
};
|
||||
let mut storage_lock = storage.lock().await;
|
||||
let node = storage_lock.ingest(input).unwrap();
|
||||
let node = storage.ingest(input).unwrap();
|
||||
node.id
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
|
||||
use vestige_core::{IngestInput, Storage};
|
||||
|
||||
|
|
@ -52,7 +52,7 @@ struct MemoryBackup {
|
|||
}
|
||||
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: RestoreArgs = match args {
|
||||
|
|
@ -102,7 +102,6 @@ pub async fn execute(
|
|||
}));
|
||||
}
|
||||
|
||||
let mut storage_guard = storage.lock().await;
|
||||
let mut success_count = 0_usize;
|
||||
let mut error_count = 0_usize;
|
||||
|
||||
|
|
@ -118,7 +117,7 @@ pub async fn execute(
|
|||
valid_until: None,
|
||||
};
|
||||
|
||||
match storage_guard.ingest(input) {
|
||||
match storage.ingest(input) {
|
||||
Ok(_) => success_count += 1,
|
||||
Err(_) => error_count += 1,
|
||||
}
|
||||
|
|
@ -140,10 +139,10 @@ mod tests {
|
|||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
async fn test_storage() -> (Arc<Storage>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
(Arc::new(storage), dir)
|
||||
}
|
||||
|
||||
fn write_temp_file(dir: &TempDir, name: &str, content: &str) -> String {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
|
||||
use vestige_core::{Rating, Storage};
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ struct ReviewArgs {
|
|||
}
|
||||
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: ReviewArgs = match args {
|
||||
|
|
@ -57,7 +57,6 @@ pub async fn execute(
|
|||
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())?
|
||||
|
|
@ -102,14 +101,14 @@ mod tests {
|
|||
use tempfile::TempDir;
|
||||
|
||||
/// Create a test storage instance with a temporary database
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
async fn test_storage() -> (Arc<Storage>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
(Arc::new(storage), dir)
|
||||
}
|
||||
|
||||
/// Helper to ingest test content and return node ID
|
||||
async fn ingest_test_content(storage: &Arc<Mutex<Storage>>, content: &str) -> String {
|
||||
async fn ingest_test_content(storage: &Arc<Storage>, content: &str) -> String {
|
||||
let input = IngestInput {
|
||||
content: content.to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
|
|
@ -120,8 +119,7 @@ mod tests {
|
|||
valid_from: None,
|
||||
valid_until: None,
|
||||
};
|
||||
let mut storage_lock = storage.lock().await;
|
||||
let node = storage_lock.ingest(input).unwrap();
|
||||
let node = storage.ingest(input).unwrap();
|
||||
node.id
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::Storage;
|
||||
|
||||
|
|
@ -90,7 +89,7 @@ struct HybridSearchArgs {
|
|||
}
|
||||
|
||||
pub async fn execute_semantic(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: SemanticSearchArgs = match args {
|
||||
|
|
@ -102,7 +101,6 @@ pub async fn execute_semantic(
|
|||
return Err("Query cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let storage = storage.lock().await;
|
||||
|
||||
// Check if embeddings are ready
|
||||
if !storage.is_embedding_ready() {
|
||||
|
|
@ -143,7 +141,7 @@ pub async fn execute_semantic(
|
|||
}
|
||||
|
||||
pub async fn execute_hybrid(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: HybridSearchArgs = match args {
|
||||
|
|
@ -155,7 +153,6 @@ pub async fn execute_hybrid(
|
|||
return Err("Query cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let storage = storage.lock().await;
|
||||
|
||||
let results = storage
|
||||
.hybrid_search(
|
||||
|
|
|
|||
|
|
@ -65,6 +65,12 @@ pub fn schema() -> Value {
|
|||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Optional topics for context-dependent retrieval boosting"
|
||||
},
|
||||
"token_budget": {
|
||||
"type": "integer",
|
||||
"description": "Max tokens for response. Server truncates content to fit budget. Use memory(action='get') for full content of specific IDs.",
|
||||
"minimum": 100,
|
||||
"maximum": 10000
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
|
|
@ -81,6 +87,8 @@ struct SearchArgs {
|
|||
#[serde(alias = "detail_level")]
|
||||
detail_level: Option<String>,
|
||||
context_topics: Option<Vec<String>>,
|
||||
#[serde(alias = "token_budget")]
|
||||
token_budget: Option<i32>,
|
||||
}
|
||||
|
||||
/// Execute unified search with 7-stage cognitive pipeline.
|
||||
|
|
@ -96,7 +104,7 @@ struct SearchArgs {
|
|||
///
|
||||
/// Also applies Testing Effect (Roediger & Karpicke 2006) by auto-strengthening on access.
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
|
|
@ -135,9 +143,8 @@ pub async fn execute(
|
|||
// STAGE 1: Hybrid search with 3x over-fetch for reranking pool
|
||||
// ====================================================================
|
||||
let overfetch_limit = (limit * 3).min(100); // Cap at 100 to avoid excessive DB load
|
||||
let storage_guard = storage.lock().await;
|
||||
|
||||
let results = storage_guard
|
||||
let results = storage
|
||||
.hybrid_search(&args.query, overfetch_limit, keyword_weight, semantic_weight)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
|
|
@ -327,10 +334,9 @@ pub async fn execute(
|
|||
// Auto-strengthen on access (Testing Effect)
|
||||
// ====================================================================
|
||||
let ids: Vec<&str> = filtered_results.iter().map(|r| r.node.id.as_str()).collect();
|
||||
let _ = storage_guard.strengthen_batch_on_access(&ids);
|
||||
let _ = storage.strengthen_batch_on_access(&ids);
|
||||
|
||||
// Drop storage lock before acquiring cognitive for side effects
|
||||
drop(storage_guard);
|
||||
|
||||
// ====================================================================
|
||||
// STAGE 7: Side effects — predictive memory + reconsolidation
|
||||
|
|
@ -371,11 +377,38 @@ pub async fn execute(
|
|||
// ====================================================================
|
||||
// Format and return
|
||||
// ====================================================================
|
||||
let formatted: Vec<Value> = filtered_results
|
||||
let mut formatted: Vec<Value> = filtered_results
|
||||
.iter()
|
||||
.map(|r| format_search_result(r, detail_level))
|
||||
.collect();
|
||||
|
||||
// ====================================================================
|
||||
// Token budget enforcement (v1.8.0)
|
||||
// ====================================================================
|
||||
let mut budget_expandable: Vec<String> = Vec::new();
|
||||
let mut budget_tokens_used: Option<usize> = None;
|
||||
if let Some(budget) = args.token_budget {
|
||||
let budget = budget.clamp(100, 10000) as usize;
|
||||
let budget_chars = budget * 4;
|
||||
let mut used = 0;
|
||||
let mut budgeted = Vec::new();
|
||||
|
||||
for result in &formatted {
|
||||
let size = serde_json::to_string(result).unwrap_or_default().len();
|
||||
if used + size > budget_chars {
|
||||
if let Some(id) = result.get("id").and_then(|v| v.as_str()) {
|
||||
budget_expandable.push(id.to_string());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
used += size;
|
||||
budgeted.push(result.clone());
|
||||
}
|
||||
|
||||
budget_tokens_used = Some(used / 4);
|
||||
formatted = budgeted;
|
||||
}
|
||||
|
||||
// Check learning mode via attention signal
|
||||
let learning_mode = cognitive.try_lock().ok().map(|cog| cog.attention_signal.is_learning_mode()).unwrap_or(false);
|
||||
|
||||
|
|
@ -403,6 +436,16 @@ pub async fn execute(
|
|||
if learning_mode {
|
||||
response["learningModeDetected"] = serde_json::json!(true);
|
||||
}
|
||||
// Include token budget info (v1.8.0)
|
||||
if !budget_expandable.is_empty() {
|
||||
response["expandable"] = serde_json::json!(budget_expandable);
|
||||
}
|
||||
if let Some(budget) = args.token_budget {
|
||||
response["tokenBudget"] = serde_json::json!(budget);
|
||||
}
|
||||
if let Some(used) = budget_tokens_used {
|
||||
response["tokensUsed"] = serde_json::json!(used);
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
|
@ -516,14 +559,14 @@ mod tests {
|
|||
}
|
||||
|
||||
/// Create a test storage instance with a temporary database
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
async fn test_storage() -> (Arc<Storage>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
(Arc::new(storage), dir)
|
||||
}
|
||||
|
||||
/// Helper to ingest test content
|
||||
async fn ingest_test_content(storage: &Arc<Mutex<Storage>>, content: &str) -> String {
|
||||
async fn ingest_test_content(storage: &Arc<Storage>, content: &str) -> String {
|
||||
let input = IngestInput {
|
||||
content: content.to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
|
|
@ -534,8 +577,7 @@ mod tests {
|
|||
valid_from: None,
|
||||
valid_until: None,
|
||||
};
|
||||
let mut storage_lock = storage.lock().await;
|
||||
let node = storage_lock.ingest(input).unwrap();
|
||||
let node = storage.ingest(input).unwrap();
|
||||
node.id
|
||||
}
|
||||
|
||||
|
|
@ -967,4 +1009,90 @@ mod tests {
|
|||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid detail_level"));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TOKEN BUDGET TESTS (v1.8.0)
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_token_budget_limits_results() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
for i in 0..10 {
|
||||
ingest_test_content(
|
||||
&storage,
|
||||
&format!("Budget test content number {} with some extra text to increase size.", i),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Small budget should reduce results
|
||||
let args = serde_json::json!({
|
||||
"query": "budget test",
|
||||
"token_budget": 200,
|
||||
"min_similarity": 0.0
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
assert!(value["tokenBudget"].as_i64().unwrap() == 200);
|
||||
assert!(value["tokensUsed"].is_number());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_token_budget_expandable() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
for i in 0..15 {
|
||||
ingest_test_content(
|
||||
&storage,
|
||||
&format!(
|
||||
"Expandable budget test number {} with quite a bit of content to ensure we exceed the token budget allocation threshold.",
|
||||
i
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let args = serde_json::json!({
|
||||
"query": "expandable budget test",
|
||||
"token_budget": 150,
|
||||
"min_similarity": 0.0
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
// expandable field should exist if results were dropped
|
||||
if let Some(expandable) = value.get("expandable") {
|
||||
assert!(expandable.is_array());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_budget_unchanged() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_test_content(&storage, "No budget test content.").await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"query": "no budget",
|
||||
"min_similarity": 0.0
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
// No budget fields should be present
|
||||
assert!(value.get("tokenBudget").is_none());
|
||||
assert!(value.get("tokensUsed").is_none());
|
||||
assert!(value.get("expandable").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_has_token_budget() {
|
||||
let schema_value = schema();
|
||||
let tb = &schema_value["properties"]["token_budget"];
|
||||
assert!(tb.is_object());
|
||||
assert_eq!(tb["minimum"], 100);
|
||||
assert_eq!(tb["maximum"], 10000);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
718
crates/vestige-mcp/src/tools/session_context.rs
Normal file
718
crates/vestige-mcp/src/tools/session_context.rs
Normal file
|
|
@ -0,0 +1,718 @@
|
|||
//! Session Context Tool — One-call session initialization (v1.8.0)
|
||||
//!
|
||||
//! Combines search, intentions, status, predictions, and codebase context
|
||||
//! into a single token-budgeted response. Replaces 5 separate calls at
|
||||
//! session start (~15K tokens → ~500-1000 tokens).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use vestige_core::Storage;
|
||||
|
||||
/// Input schema for session_context tool
|
||||
pub fn schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"queries": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Search queries to run (default: [\"user preferences\"])"
|
||||
},
|
||||
"token_budget": {
|
||||
"type": "integer",
|
||||
"description": "Max tokens for response (default: 1000). Server truncates content to fit budget.",
|
||||
"default": 1000,
|
||||
"minimum": 100,
|
||||
"maximum": 10000
|
||||
},
|
||||
"context": {
|
||||
"type": "object",
|
||||
"description": "Current context for intention matching and predictions",
|
||||
"properties": {
|
||||
"codebase": { "type": "string" },
|
||||
"topics": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"file": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"include_status": {
|
||||
"type": "boolean",
|
||||
"description": "Include system health info (default: true)",
|
||||
"default": true
|
||||
},
|
||||
"include_intentions": {
|
||||
"type": "boolean",
|
||||
"description": "Include triggered intentions (default: true)",
|
||||
"default": true
|
||||
},
|
||||
"include_predictions": {
|
||||
"type": "boolean",
|
||||
"description": "Include memory predictions (default: true)",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct SessionContextArgs {
|
||||
queries: Option<Vec<String>>,
|
||||
token_budget: Option<i32>,
|
||||
context: Option<ContextSpec>,
|
||||
include_status: Option<bool>,
|
||||
include_intentions: Option<bool>,
|
||||
include_predictions: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct ContextSpec {
|
||||
codebase: Option<String>,
|
||||
topics: Option<Vec<String>>,
|
||||
file: Option<String>,
|
||||
}
|
||||
|
||||
/// Extract the first sentence or first line from content, capped at 150 chars.
|
||||
fn first_sentence(content: &str) -> String {
|
||||
let content = content.trim();
|
||||
let end = content
|
||||
.find(". ")
|
||||
.map(|i| i + 1)
|
||||
.or_else(|| content.find('\n'))
|
||||
.unwrap_or(content.len())
|
||||
.min(150);
|
||||
// UTF-8 safe boundary
|
||||
let end = content.floor_char_boundary(end);
|
||||
content[..end].to_string()
|
||||
}
|
||||
|
||||
/// Execute session_context tool — one-call session initialization.
|
||||
pub async fn execute(
|
||||
storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: SessionContextArgs = match args {
|
||||
Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?,
|
||||
None => SessionContextArgs::default(),
|
||||
};
|
||||
|
||||
let token_budget = args.token_budget.unwrap_or(1000).clamp(100, 10000) as usize;
|
||||
let budget_chars = token_budget * 4;
|
||||
let include_status = args.include_status.unwrap_or(true);
|
||||
let include_intentions = args.include_intentions.unwrap_or(true);
|
||||
let include_predictions = args.include_predictions.unwrap_or(true);
|
||||
let queries = args.queries.unwrap_or_else(|| vec!["user preferences".to_string()]);
|
||||
|
||||
let mut context_parts: Vec<String> = Vec::new();
|
||||
let mut expandable_ids: Vec<String> = Vec::new();
|
||||
let mut char_count = 0;
|
||||
|
||||
// ====================================================================
|
||||
// 1. Search queries — extract first sentence per result, dedup by ID
|
||||
// ====================================================================
|
||||
let mut seen_ids = HashSet::new();
|
||||
let mut memory_lines: Vec<String> = Vec::new();
|
||||
|
||||
for query in &queries {
|
||||
let results = storage
|
||||
.hybrid_search(query, 5, 0.3, 0.7)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
for r in results {
|
||||
if seen_ids.contains(&r.node.id) {
|
||||
continue;
|
||||
}
|
||||
let summary = first_sentence(&r.node.content);
|
||||
let line = format!("- {}", summary);
|
||||
let line_len = line.len() + 1; // +1 for newline
|
||||
|
||||
if char_count + line_len > budget_chars {
|
||||
expandable_ids.push(r.node.id.clone());
|
||||
} else {
|
||||
memory_lines.push(line);
|
||||
char_count += line_len;
|
||||
}
|
||||
seen_ids.insert(r.node.id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-strengthen accessed memories (Testing Effect)
|
||||
let accessed_ids: Vec<&str> = seen_ids.iter().map(|s| s.as_str()).collect();
|
||||
let _ = storage.strengthen_batch_on_access(&accessed_ids);
|
||||
|
||||
if !memory_lines.is_empty() {
|
||||
context_parts.push(format!("**Memories:**\n{}", memory_lines.join("\n")));
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 2. Intentions — find triggered + pending high-priority
|
||||
// ====================================================================
|
||||
if include_intentions {
|
||||
let intentions = storage.get_active_intentions().map_err(|e| e.to_string())?;
|
||||
let now = Utc::now();
|
||||
let mut triggered_lines: Vec<String> = Vec::new();
|
||||
|
||||
for intention in &intentions {
|
||||
let is_overdue = intention.deadline.map(|d| d < now).unwrap_or(false);
|
||||
|
||||
// Check context-based triggers
|
||||
let is_context_triggered = if let Some(ctx) = &args.context {
|
||||
check_intention_triggered(intention, ctx, now)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if is_overdue || is_context_triggered || intention.priority >= 3 {
|
||||
let priority_str = match intention.priority {
|
||||
4 => " (critical)",
|
||||
3 => " (high)",
|
||||
_ => "",
|
||||
};
|
||||
let deadline_str = intention
|
||||
.deadline
|
||||
.map(|d| format!(" [due {}]", d.format("%b %d")))
|
||||
.unwrap_or_default();
|
||||
let line = format!(
|
||||
"- {}{}{}",
|
||||
first_sentence(&intention.content),
|
||||
priority_str,
|
||||
deadline_str
|
||||
);
|
||||
let line_len = line.len() + 1;
|
||||
if char_count + line_len <= budget_chars {
|
||||
triggered_lines.push(line);
|
||||
char_count += line_len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !triggered_lines.is_empty() {
|
||||
context_parts.push(format!("**Triggered:**\n{}", triggered_lines.join("\n")));
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 3. System status — compact one-liner
|
||||
// ====================================================================
|
||||
let stats = storage.get_stats().map_err(|e| e.to_string())?;
|
||||
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"
|
||||
};
|
||||
|
||||
// Automation triggers
|
||||
let last_dream = storage.get_last_dream().ok().flatten();
|
||||
let saves_since_last_dream = match &last_dream {
|
||||
Some(dt) => storage.count_memories_since(*dt).unwrap_or(0),
|
||||
None => stats.total_nodes as i64,
|
||||
};
|
||||
let last_backup = Storage::get_last_backup_timestamp();
|
||||
let now = Utc::now();
|
||||
|
||||
let needs_dream = last_dream
|
||||
.map(|dt| now - dt > Duration::hours(24) || saves_since_last_dream > 50)
|
||||
.unwrap_or(true);
|
||||
let needs_backup = last_backup
|
||||
.map(|dt| now - dt > Duration::days(7))
|
||||
.unwrap_or(true);
|
||||
let needs_gc = status == "degraded" || status == "critical";
|
||||
|
||||
if include_status {
|
||||
let embedding_pct = if stats.total_nodes > 0 {
|
||||
(stats.nodes_with_embeddings as f64 / stats.total_nodes as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let status_line = format!(
|
||||
"**Status:** {} memories | {} | {:.0}% embeddings",
|
||||
stats.total_nodes, status, embedding_pct
|
||||
);
|
||||
let status_len = status_line.len() + 1;
|
||||
if char_count + status_len <= budget_chars {
|
||||
context_parts.push(status_line);
|
||||
char_count += status_len;
|
||||
}
|
||||
|
||||
// Needs line (only if any automation needed)
|
||||
let mut needs: Vec<&str> = Vec::new();
|
||||
if needs_dream {
|
||||
needs.push("dream");
|
||||
}
|
||||
if needs_backup {
|
||||
needs.push("backup");
|
||||
}
|
||||
if needs_gc {
|
||||
needs.push("gc");
|
||||
}
|
||||
if !needs.is_empty() {
|
||||
let needs_line = format!("**Needs:** {}", needs.join(", "));
|
||||
let needs_len = needs_line.len() + 1;
|
||||
if char_count + needs_len <= budget_chars {
|
||||
context_parts.push(needs_line);
|
||||
char_count += needs_len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 4. Predictions — top 3 with content preview
|
||||
// ====================================================================
|
||||
if include_predictions {
|
||||
let cog = cognitive.lock().await;
|
||||
|
||||
let session_ctx = vestige_core::neuroscience::predictive_retrieval::SessionContext {
|
||||
started_at: Utc::now(),
|
||||
current_focus: args
|
||||
.context
|
||||
.as_ref()
|
||||
.and_then(|c| c.topics.as_ref())
|
||||
.and_then(|t| t.first())
|
||||
.cloned(),
|
||||
active_files: args
|
||||
.context
|
||||
.as_ref()
|
||||
.and_then(|c| c.file.as_ref())
|
||||
.map(|f| vec![f.clone()])
|
||||
.unwrap_or_default(),
|
||||
accessed_memories: Vec::new(),
|
||||
recent_queries: Vec::new(),
|
||||
detected_intent: None,
|
||||
project_context: args
|
||||
.context
|
||||
.as_ref()
|
||||
.and_then(|c| c.codebase.as_ref())
|
||||
.map(|name| vestige_core::neuroscience::predictive_retrieval::ProjectContext {
|
||||
name: name.to_string(),
|
||||
path: String::new(),
|
||||
technologies: Vec::new(),
|
||||
primary_language: None,
|
||||
}),
|
||||
};
|
||||
|
||||
let predictions = cog
|
||||
.predictive_memory
|
||||
.predict_needed_memories(&session_ctx)
|
||||
.unwrap_or_default();
|
||||
|
||||
if !predictions.is_empty() {
|
||||
let pred_lines: Vec<String> = predictions
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|p| {
|
||||
format!(
|
||||
"- {} ({:.0}%)",
|
||||
first_sentence(&p.content_preview),
|
||||
p.confidence * 100.0
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let pred_section = format!("**Predicted:**\n{}", pred_lines.join("\n"));
|
||||
let pred_len = pred_section.len() + 1;
|
||||
if char_count + pred_len <= budget_chars {
|
||||
context_parts.push(pred_section);
|
||||
char_count += pred_len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 5. Codebase patterns/decisions (if codebase specified)
|
||||
// ====================================================================
|
||||
if let Some(ref ctx) = args.context {
|
||||
if let Some(ref codebase) = ctx.codebase {
|
||||
let codebase_tag = format!("codebase:{}", codebase);
|
||||
let mut cb_lines: Vec<String> = Vec::new();
|
||||
|
||||
// Get patterns
|
||||
if let Ok(patterns) = storage.get_nodes_by_type_and_tag("pattern", Some(&codebase_tag), 3) {
|
||||
for p in &patterns {
|
||||
let line = format!("- [pattern] {}", first_sentence(&p.content));
|
||||
let line_len = line.len() + 1;
|
||||
if char_count + line_len <= budget_chars {
|
||||
cb_lines.push(line);
|
||||
char_count += line_len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get decisions
|
||||
if let Ok(decisions) =
|
||||
storage.get_nodes_by_type_and_tag("decision", Some(&codebase_tag), 3)
|
||||
{
|
||||
for d in &decisions {
|
||||
let line = format!("- [decision] {}", first_sentence(&d.content));
|
||||
let line_len = line.len() + 1;
|
||||
if char_count + line_len <= budget_chars {
|
||||
cb_lines.push(line);
|
||||
char_count += line_len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !cb_lines.is_empty() {
|
||||
context_parts.push(format!("**Codebase ({}):**\n{}", codebase, cb_lines.join("\n")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 6. Assemble final response
|
||||
// ====================================================================
|
||||
let header = format!("## Session ({} memories, {})\n", stats.total_nodes, status);
|
||||
let context_text = format!("{}{}", header, context_parts.join("\n\n"));
|
||||
let tokens_used = context_text.len() / 4;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"context": context_text,
|
||||
"tokensUsed": tokens_used,
|
||||
"tokenBudget": token_budget,
|
||||
"expandable": expandable_ids,
|
||||
"automationTriggers": {
|
||||
"needsDream": needs_dream,
|
||||
"needsBackup": needs_backup,
|
||||
"needsGc": needs_gc,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/// Check if an intention should be triggered based on the current context.
|
||||
fn check_intention_triggered(
|
||||
intention: &vestige_core::IntentionRecord,
|
||||
ctx: &ContextSpec,
|
||||
now: DateTime<Utc>,
|
||||
) -> bool {
|
||||
// Parse trigger data
|
||||
let trigger: Option<TriggerData> = serde_json::from_str(&intention.trigger_data).ok();
|
||||
let Some(trigger) = trigger else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match trigger.trigger_type.as_deref() {
|
||||
Some("time") => {
|
||||
if let Some(ref at) = trigger.at {
|
||||
if let Ok(trigger_time) = DateTime::parse_from_rfc3339(at) {
|
||||
return trigger_time.with_timezone(&Utc) <= now;
|
||||
}
|
||||
}
|
||||
if let Some(mins) = trigger.in_minutes {
|
||||
let trigger_time = intention.created_at + Duration::minutes(mins);
|
||||
return trigger_time <= now;
|
||||
}
|
||||
false
|
||||
}
|
||||
Some("context") => {
|
||||
// Check codebase match
|
||||
if let (Some(trigger_cb), Some(current_cb)) = (&trigger.codebase, &ctx.codebase)
|
||||
{
|
||||
if current_cb
|
||||
.to_lowercase()
|
||||
.contains(&trigger_cb.to_lowercase())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Check file pattern match
|
||||
if let (Some(pattern), Some(file)) = (&trigger.file_pattern, &ctx.file) {
|
||||
if file.contains(pattern.as_str()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Check topic match
|
||||
if let (Some(topic), Some(topics)) = (&trigger.topic, &ctx.topics) {
|
||||
if topics
|
||||
.iter()
|
||||
.any(|t| t.to_lowercase().contains(&topic.to_lowercase()))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TriggerData {
|
||||
#[serde(rename = "type")]
|
||||
trigger_type: Option<String>,
|
||||
at: Option<String>,
|
||||
in_minutes: Option<i64>,
|
||||
codebase: Option<String>,
|
||||
file_pattern: Option<String>,
|
||||
topic: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TESTS
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use tempfile::TempDir;
|
||||
use vestige_core::IngestInput;
|
||||
|
||||
fn test_cognitive() -> Arc<Mutex<CognitiveEngine>> {
|
||||
Arc::new(Mutex::new(CognitiveEngine::new()))
|
||||
}
|
||||
|
||||
async fn test_storage() -> (Arc<Storage>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(storage), dir)
|
||||
}
|
||||
|
||||
async fn ingest_test_content(storage: &Arc<Storage>, content: &str, tags: Vec<&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: tags.into_iter().map(|s| s.to_string()).collect(),
|
||||
valid_from: None,
|
||||
valid_until: None,
|
||||
};
|
||||
let node = storage.ingest(input).unwrap();
|
||||
node.id
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SCHEMA TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_schema_has_properties() {
|
||||
let s = schema();
|
||||
assert_eq!(s["type"], "object");
|
||||
assert!(s["properties"]["queries"].is_object());
|
||||
assert!(s["properties"]["token_budget"].is_object());
|
||||
assert!(s["properties"]["context"].is_object());
|
||||
assert!(s["properties"]["include_status"].is_object());
|
||||
assert!(s["properties"]["include_intentions"].is_object());
|
||||
assert!(s["properties"]["include_predictions"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_token_budget_bounds() {
|
||||
let s = schema();
|
||||
let tb = &s["properties"]["token_budget"];
|
||||
assert_eq!(tb["minimum"], 100);
|
||||
assert_eq!(tb["maximum"], 10000);
|
||||
assert_eq!(tb["default"], 1000);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// EXECUTE TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_default_no_args() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, &test_cognitive(), None).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
assert!(value["context"].is_string());
|
||||
assert!(value["tokensUsed"].is_number());
|
||||
assert!(value["tokenBudget"].is_number());
|
||||
assert_eq!(value["tokenBudget"], 1000);
|
||||
assert!(value["expandable"].is_array());
|
||||
assert!(value["automationTriggers"].is_object());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_with_queries() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_test_content(&storage, "Sam prefers Rust and TypeScript for all projects.", vec![]).await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"queries": ["Sam preferences", "project context"]
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
let ctx = value["context"].as_str().unwrap();
|
||||
assert!(ctx.contains("Session"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_token_budget_respected() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
// Ingest several memories to generate content
|
||||
for i in 0..20 {
|
||||
ingest_test_content(
|
||||
&storage,
|
||||
&format!(
|
||||
"Memory number {} contains detailed information about topic {} that is quite long and verbose to fill up the token budget.",
|
||||
i, i
|
||||
),
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let args = serde_json::json!({
|
||||
"queries": ["memory"],
|
||||
"token_budget": 200
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
let ctx = value["context"].as_str().unwrap();
|
||||
// Context should be within budget (200 tokens * 4 = 800 chars + header overhead)
|
||||
// The actual char count of context should be reasonable
|
||||
let tokens_used = value["tokensUsed"].as_u64().unwrap();
|
||||
// Allow some overhead for the header
|
||||
assert!(tokens_used <= 300, "tokens_used {} should be near budget 200", tokens_used);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_expandable_ids() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
// Ingest many memories
|
||||
for i in 0..20 {
|
||||
ingest_test_content(
|
||||
&storage,
|
||||
&format!(
|
||||
"Expandable test memory {} with enough content to take up space in the token budget allocation.",
|
||||
i
|
||||
),
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let args = serde_json::json!({
|
||||
"queries": ["expandable test memory"],
|
||||
"token_budget": 150
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
// expandable should be a valid array (may be empty if all fit within budget)
|
||||
assert!(value["expandable"].is_array());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_automation_triggers_booleans() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, &test_cognitive(), None).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
let triggers = &value["automationTriggers"];
|
||||
assert!(triggers["needsDream"].is_boolean());
|
||||
assert!(triggers["needsBackup"].is_boolean());
|
||||
assert!(triggers["needsGc"].is_boolean());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_disable_sections() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_test_content(&storage, "Test memory for disable sections.", vec![]).await;
|
||||
|
||||
let args = serde_json::json!({
|
||||
"include_status": false,
|
||||
"include_intentions": false,
|
||||
"include_predictions": false
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
let context_str = value["context"].as_str().unwrap();
|
||||
// Should NOT contain status line when disabled
|
||||
assert!(!context_str.contains("**Status:**"));
|
||||
// automationTriggers should still be present (always computed)
|
||||
assert!(value["automationTriggers"].is_object());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_with_codebase_context() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
// Ingest a pattern with codebase tag
|
||||
let input = IngestInput {
|
||||
content: "Code pattern: Use Arc<Mutex<>> for shared state in async contexts.".to_string(),
|
||||
node_type: "pattern".to_string(),
|
||||
source: None,
|
||||
sentiment_score: 0.0,
|
||||
sentiment_magnitude: 0.0,
|
||||
tags: vec!["pattern".to_string(), "codebase:vestige".to_string()],
|
||||
valid_from: None,
|
||||
valid_until: None,
|
||||
};
|
||||
storage.ingest(input).unwrap();
|
||||
|
||||
let args = serde_json::json!({
|
||||
"context": {
|
||||
"codebase": "vestige",
|
||||
"topics": ["performance"]
|
||||
}
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
let ctx = value["context"].as_str().unwrap();
|
||||
// Should contain codebase section
|
||||
assert!(ctx.contains("vestige"));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// HELPER TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_first_sentence_period() {
|
||||
assert_eq!(first_sentence("Hello world. More text here."), "Hello world.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_first_sentence_newline() {
|
||||
assert_eq!(first_sentence("First line\nSecond line"), "First line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_first_sentence_short() {
|
||||
assert_eq!(first_sentence("Short"), "Short");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_first_sentence_long_truncated() {
|
||||
let long = "A".repeat(200);
|
||||
let result = first_sentence(&long);
|
||||
assert!(result.len() <= 150);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_first_sentence_empty() {
|
||||
assert_eq!(first_sentence(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_first_sentence_whitespace() {
|
||||
assert_eq!(first_sentence(" Hello world. "), "Hello world.");
|
||||
}
|
||||
}
|
||||
|
|
@ -114,7 +114,7 @@ struct BatchItem {
|
|||
}
|
||||
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
|
|
@ -184,16 +184,14 @@ pub async fn execute(
|
|||
// ====================================================================
|
||||
// INGEST (storage lock)
|
||||
// ====================================================================
|
||||
let mut storage_guard = storage.lock().await;
|
||||
|
||||
// Check if force_create is enabled
|
||||
if args.force_create.unwrap_or(false) {
|
||||
let node = storage_guard.ingest(input).map_err(|e| e.to_string())?;
|
||||
let node = storage.ingest(input).map_err(|e| e.to_string())?;
|
||||
let node_id = node.id.clone();
|
||||
let node_content = node.content.clone();
|
||||
let node_type = node.node_type.clone();
|
||||
let has_embedding = node.has_embedding.unwrap_or(false);
|
||||
drop(storage_guard);
|
||||
|
||||
// Post-ingest cognitive side effects
|
||||
run_post_ingest(cognitive, &node_id, &node_content, &node_type, importance_composite);
|
||||
|
|
@ -213,12 +211,11 @@ pub async fn execute(
|
|||
// Use smart ingest with prediction error gating
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
{
|
||||
let result = storage_guard.smart_ingest(input).map_err(|e| e.to_string())?;
|
||||
let result = storage.smart_ingest(input).map_err(|e| e.to_string())?;
|
||||
let node_id = result.node.id.clone();
|
||||
let node_content = result.node.content.clone();
|
||||
let node_type = result.node.node_type.clone();
|
||||
let has_embedding = result.node.has_embedding.unwrap_or(false);
|
||||
drop(storage_guard);
|
||||
|
||||
// Post-ingest cognitive side effects
|
||||
run_post_ingest(cognitive, &node_id, &node_content, &node_type, importance_composite);
|
||||
|
|
@ -249,11 +246,10 @@ pub async fn execute(
|
|||
|
||||
#[cfg(not(all(feature = "embeddings", feature = "vector-search")))]
|
||||
{
|
||||
let node = storage_guard.ingest(input).map_err(|e| e.to_string())?;
|
||||
let node = storage.ingest(input).map_err(|e| e.to_string())?;
|
||||
let node_id = node.id.clone();
|
||||
let node_content = node.content.clone();
|
||||
let node_type = node.node_type.clone();
|
||||
drop(storage_guard);
|
||||
|
||||
run_post_ingest(cognitive, &node_id, &node_content, &node_type, importance_composite);
|
||||
|
||||
|
|
@ -276,7 +272,7 @@ pub async fn execute(
|
|||
/// pre-ingest (importance scoring, intent detection) and post-ingest (synaptic
|
||||
/// tagging, novelty update, hippocampal indexing) pipelines per item.
|
||||
async fn execute_batch(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
items: Vec<BatchItem>,
|
||||
) -> Result<Value, String> {
|
||||
|
|
@ -355,16 +351,14 @@ async fn execute_batch(
|
|||
// ================================================================
|
||||
// INGEST (storage lock per item)
|
||||
// ================================================================
|
||||
let mut storage_guard = storage.lock().await;
|
||||
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
{
|
||||
match storage_guard.smart_ingest(input) {
|
||||
match storage.smart_ingest(input) {
|
||||
Ok(result) => {
|
||||
let node_id = result.node.id.clone();
|
||||
let node_content = result.node.content.clone();
|
||||
let node_type = result.node.node_type.clone();
|
||||
drop(storage_guard);
|
||||
|
||||
match result.decision.as_str() {
|
||||
"create" | "supersede" | "replace" => created += 1,
|
||||
|
|
@ -386,7 +380,6 @@ async fn execute_batch(
|
|||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
drop(storage_guard);
|
||||
errors += 1;
|
||||
results.push(serde_json::json!({
|
||||
"index": i,
|
||||
|
|
@ -399,12 +392,11 @@ async fn execute_batch(
|
|||
|
||||
#[cfg(not(all(feature = "embeddings", feature = "vector-search")))]
|
||||
{
|
||||
match storage_guard.ingest(input) {
|
||||
match storage.ingest(input) {
|
||||
Ok(node) => {
|
||||
let node_id = node.id.clone();
|
||||
let node_content = node.content.clone();
|
||||
let node_type = node.node_type.clone();
|
||||
drop(storage_guard);
|
||||
|
||||
created += 1;
|
||||
run_post_ingest(cognitive, &node_id, &node_content, &node_type, importance_composite);
|
||||
|
|
@ -419,7 +411,6 @@ async fn execute_batch(
|
|||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
drop(storage_guard);
|
||||
errors += 1;
|
||||
results.push(serde_json::json!({
|
||||
"index": i,
|
||||
|
|
@ -498,10 +489,10 @@ mod tests {
|
|||
}
|
||||
|
||||
/// Create a test storage instance with a temporary database
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
async fn test_storage() -> (Arc<Storage>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
(Arc::new(storage), dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -662,8 +653,7 @@ mod tests {
|
|||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
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();
|
||||
let node = storage.get_node(&node_id).unwrap().unwrap();
|
||||
assert_eq!(node.node_type, "fact");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::{MemoryStats, Storage};
|
||||
|
||||
|
|
@ -24,8 +23,7 @@ pub fn health_schema() -> Value {
|
|||
})
|
||||
}
|
||||
|
||||
pub async fn execute_stats(storage: &Arc<Mutex<Storage>>) -> Result<Value, String> {
|
||||
let storage = storage.lock().await;
|
||||
pub async fn execute_stats(storage: &Arc<Storage>) -> Result<Value, String> {
|
||||
let stats = storage.get_stats().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
|
|
@ -42,8 +40,7 @@ pub async fn execute_stats(storage: &Arc<Mutex<Storage>>) -> Result<Value, Strin
|
|||
}))
|
||||
}
|
||||
|
||||
pub async fn execute_health(storage: &Arc<Mutex<Storage>>) -> Result<Value, String> {
|
||||
let storage = storage.lock().await;
|
||||
pub async fn execute_health(storage: &Arc<Storage>) -> Result<Value, String> {
|
||||
let stats = storage.get_stats().map_err(|e| e.to_string())?;
|
||||
|
||||
// Determine health status
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::{
|
||||
CaptureWindow, ImportanceEvent, ImportanceEventType,
|
||||
|
|
@ -71,7 +70,7 @@ pub fn stats_schema() -> Value {
|
|||
|
||||
/// Trigger an importance event to retroactively strengthen recent memories
|
||||
pub async fn execute_trigger(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args = args.ok_or("Missing arguments")?;
|
||||
|
|
@ -88,7 +87,6 @@ pub async fn execute_trigger(
|
|||
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)
|
||||
|
|
@ -158,7 +156,7 @@ pub async fn execute_trigger(
|
|||
|
||||
/// Find memories with active synaptic tags
|
||||
pub async fn execute_find(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args = args.unwrap_or(serde_json::json!({}));
|
||||
|
|
@ -166,7 +164,6 @@ pub async fn execute_find(
|
|||
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)
|
||||
|
|
@ -196,9 +193,8 @@ pub async fn execute_find(
|
|||
|
||||
/// Get synaptic tagging statistics
|
||||
pub async fn execute_stats(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
) -> Result<Value, String> {
|
||||
let storage = storage.lock().await;
|
||||
|
||||
let memories = storage.get_all_nodes(500, 0)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use serde::Deserialize;
|
|||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
|
||||
use vestige_core::Storage;
|
||||
|
||||
|
|
@ -89,7 +89,7 @@ fn parse_datetime(s: &str) -> Result<DateTime<Utc>, String> {
|
|||
|
||||
/// Execute memory_timeline tool
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
storage: &Arc<Storage>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: TimelineArgs = match args {
|
||||
|
|
@ -130,7 +130,6 @@ pub async fn execute(
|
|||
|
||||
let limit = args.limit.unwrap_or(50).clamp(1, 200);
|
||||
|
||||
let storage = storage.lock().await;
|
||||
|
||||
// Query memories in time range
|
||||
let mut results = storage
|
||||
|
|
@ -189,15 +188,14 @@ mod tests {
|
|||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
async fn test_storage() -> (Arc<Storage>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
(Arc::new(storage), dir)
|
||||
}
|
||||
|
||||
async fn ingest_test_memory(storage: &Arc<Mutex<Storage>>, content: &str) {
|
||||
let mut s = storage.lock().await;
|
||||
s.ingest(vestige_core::IngestInput {
|
||||
async fn ingest_test_memory(storage: &Arc<Storage>, content: &str) {
|
||||
storage.ingest(vestige_core::IngestInput {
|
||||
content: content.to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
source: None,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue