mirror of
https://github.com/samvallad33/vestige.git
synced 2026-05-08 23:32:37 +02:00
feat: Vestige v2.0.0 "Cognitive Leap" — 3D dashboard, HyDE search, WebSocket events
The biggest release in Vestige history. Complete visual and cognitive overhaul. Dashboard: - SvelteKit 2 + Three.js 3D neural visualization at localhost:3927/dashboard - 7 interactive pages: Graph, Memories, Timeline, Feed, Explore, Intentions, Stats - WebSocket event bus with 16 event types, real-time 3D animations - Bloom post-processing, GPU instanced rendering, force-directed layout - Dream visualization mode, FSRS retention curves, command palette (Cmd+K) - Keyboard shortcuts, responsive mobile layout, PWA installable - Single binary deployment via include_dir! (22MB) Engine: - HyDE query expansion (intent classification + 3-5 semantic variants + centroid) - fastembed 5.11 with optional Nomic v2 MoE + Qwen3 reranker + Metal GPU - Emotional memory module (#29) - Criterion benchmark suite Backend: - Axum WebSocket at /ws with heartbeat + event broadcast - 7 new REST endpoints for cognitive operations - Event emission from MCP tools via shared broadcast channel - CORS for SvelteKit dev mode Distribution: - GitHub issue templates (bug report, feature request) - CHANGELOG with comprehensive v2.0 release notes - README updated with dashboard docs, architecture diagram, comparison table 734 tests passing, zero warnings, 22MB release binary. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
26cee040a5
commit
c2d28f3433
321 changed files with 32695 additions and 4727 deletions
|
|
@ -139,6 +139,13 @@ pub async fn execute(
|
|||
insights_generated: dream_result.insights_generated.len() as i32,
|
||||
memories_strengthened: dream_result.memories_strengthened as i32,
|
||||
memories_compressed: dream_result.memories_compressed as i32,
|
||||
phase_nrem1_ms: None,
|
||||
phase_nrem3_ms: None,
|
||||
phase_rem_ms: None,
|
||||
phase_integration_ms: None,
|
||||
summaries_generated: None,
|
||||
emotional_memories_processed: None,
|
||||
creative_connections_found: None,
|
||||
};
|
||||
if let Err(e) = storage.save_dream_history(&record) {
|
||||
tracing::warn!("Failed to persist dream history: {}", e);
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ pub fn schema() -> Value {
|
|||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["get", "delete", "state", "promote", "demote"],
|
||||
"description": "Action to perform: 'get' retrieves full memory node, 'delete' removes memory, 'state' returns accessibility state, 'promote' increases retrieval strength (thumbs up), 'demote' decreases retrieval strength (thumbs down)"
|
||||
"enum": ["get", "delete", "state", "promote", "demote", "edit"],
|
||||
"description": "Action to perform: 'get' retrieves full memory node, 'delete' removes memory, 'state' returns accessibility state, 'promote' increases retrieval strength (thumbs up), 'demote' decreases retrieval strength (thumbs down), 'edit' updates content in-place (preserves FSRS state)"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
|
|
@ -53,6 +53,10 @@ pub fn schema() -> Value {
|
|||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Why this memory is being promoted/demoted (optional, for logging). Only used with promote/demote actions."
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "New content for edit action. Replaces existing content, regenerates embedding, preserves FSRS state."
|
||||
}
|
||||
},
|
||||
"required": ["action", "id"]
|
||||
|
|
@ -65,6 +69,7 @@ struct MemoryArgs {
|
|||
action: String,
|
||||
id: String,
|
||||
reason: Option<String>,
|
||||
content: Option<String>,
|
||||
}
|
||||
|
||||
/// Execute the unified memory tool
|
||||
|
|
@ -87,8 +92,9 @@ pub async fn execute(
|
|||
"state" => execute_state(storage, &args.id).await,
|
||||
"promote" => execute_promote(storage, cognitive, &args.id, args.reason).await,
|
||||
"demote" => execute_demote(storage, cognitive, &args.id, args.reason).await,
|
||||
"edit" => execute_edit(storage, &args.id, args.content).await,
|
||||
_ => Err(format!(
|
||||
"Invalid action '{}'. Must be one of: get, delete, state, promote, demote",
|
||||
"Invalid action '{}'. Must be one of: get, delete, state, promote, demote, edit",
|
||||
args.action
|
||||
)),
|
||||
}
|
||||
|
|
@ -302,6 +308,53 @@ async fn execute_demote(
|
|||
}))
|
||||
}
|
||||
|
||||
/// Edit a memory's content in-place — preserves FSRS state, regenerates embedding
|
||||
async fn execute_edit(
|
||||
storage: &Arc<Storage>,
|
||||
id: &str,
|
||||
content: Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
let new_content = content.ok_or("Missing 'content' field. Required for edit action.")?;
|
||||
|
||||
if new_content.trim().is_empty() {
|
||||
return Err("Content cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// Get existing node to capture old content
|
||||
let old_node = storage
|
||||
.get_node(id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Memory not found: {}", id))?;
|
||||
|
||||
// Update content (regenerates embedding, syncs FTS5)
|
||||
storage
|
||||
.update_node_content(id, &new_content)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Truncate previews for response (char-safe to avoid UTF-8 panics)
|
||||
let old_preview = if old_node.content.chars().count() > 200 {
|
||||
let truncated: String = old_node.content.chars().take(197).collect();
|
||||
format!("{}...", truncated)
|
||||
} else {
|
||||
old_node.content.clone()
|
||||
};
|
||||
let new_preview = if new_content.chars().count() > 200 {
|
||||
let truncated: String = new_content.chars().take(197).collect();
|
||||
format!("{}...", truncated)
|
||||
} else {
|
||||
new_content.clone()
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"action": "edit",
|
||||
"nodeId": id,
|
||||
"oldContentPreview": old_preview,
|
||||
"newContentPreview": new_preview,
|
||||
"note": "FSRS state preserved (stability, difficulty, reps, lapses unchanged). Embedding regenerated for new content."
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -336,9 +389,10 @@ mod tests {
|
|||
assert!(schema["properties"]["id"].is_object());
|
||||
assert!(schema["properties"]["reason"].is_object());
|
||||
assert_eq!(schema["required"], serde_json::json!(["action", "id"]));
|
||||
// Verify all 5 actions are in enum
|
||||
// Verify all 6 actions are in enum
|
||||
let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
|
||||
assert_eq!(actions.len(), 5);
|
||||
assert_eq!(actions.len(), 6);
|
||||
assert!(actions.contains(&serde_json::json!("edit")));
|
||||
assert!(actions.contains(&serde_json::json!("promote")));
|
||||
assert!(actions.contains(&serde_json::json!("demote")));
|
||||
}
|
||||
|
|
@ -440,6 +494,13 @@ mod tests {
|
|||
#[tokio::test]
|
||||
async fn test_delete_nonexistent_memory() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
// Ingest+delete a throwaway memory to warm writer after WAL migration
|
||||
let warmup_id = storage.ingest(vestige_core::IngestInput {
|
||||
content: "warmup".to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
..Default::default()
|
||||
}).unwrap().id;
|
||||
let _ = storage.delete_node(&warmup_id);
|
||||
let args = serde_json::json!({ "action": "delete", "id": "00000000-0000-0000-0000-000000000000" });
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
|
@ -613,4 +674,107 @@ mod tests {
|
|||
assert_eq!(value["changes"]["retentionStrength"]["delta"], "-0.15");
|
||||
assert_eq!(value["changes"]["stability"]["multiplier"], "0.5x");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// EDIT TESTS (v1.9.2)
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_edit_succeeds() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let id = ingest_memory(&storage).await;
|
||||
let args = serde_json::json!({
|
||||
"action": "edit",
|
||||
"id": id,
|
||||
"content": "Updated memory content"
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["success"], true);
|
||||
assert_eq!(value["action"], "edit");
|
||||
assert_eq!(value["nodeId"], id);
|
||||
assert!(value["oldContentPreview"].as_str().unwrap().contains("Memory unified test content"));
|
||||
assert!(value["newContentPreview"].as_str().unwrap().contains("Updated memory content"));
|
||||
assert!(value["note"].as_str().unwrap().contains("FSRS state preserved"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_edit_preserves_fsrs_state() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let id = ingest_memory(&storage).await;
|
||||
|
||||
// Get FSRS state before edit
|
||||
let before = storage.get_node(&id).unwrap().unwrap();
|
||||
|
||||
// Edit content
|
||||
let args = serde_json::json!({
|
||||
"action": "edit",
|
||||
"id": id,
|
||||
"content": "Completely new content after edit"
|
||||
});
|
||||
execute(&storage, &test_cognitive(), Some(args)).await.unwrap();
|
||||
|
||||
// Verify FSRS state preserved
|
||||
let after = storage.get_node(&id).unwrap().unwrap();
|
||||
assert_eq!(after.stability, before.stability);
|
||||
assert_eq!(after.difficulty, before.difficulty);
|
||||
assert_eq!(after.reps, before.reps);
|
||||
assert_eq!(after.lapses, before.lapses);
|
||||
assert_eq!(after.retention_strength, before.retention_strength);
|
||||
// Content should be updated
|
||||
assert_eq!(after.content, "Completely new content after edit");
|
||||
assert_ne!(after.content, before.content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_edit_missing_content_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let id = ingest_memory(&storage).await;
|
||||
let args = serde_json::json!({ "action": "edit", "id": id });
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("content"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_edit_empty_content_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let id = ingest_memory(&storage).await;
|
||||
let args = serde_json::json!({ "action": "edit", "id": id, "content": " " });
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("empty"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_edit_nonexistent_memory_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"action": "edit",
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"content": "New content"
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_edit_with_multibyte_utf8_content() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let id = ingest_memory(&storage).await;
|
||||
// Content with emoji and CJK characters (multi-byte UTF-8)
|
||||
let long_content = "🧠".repeat(100); // 100 brain emoji = 400 bytes but only 100 chars
|
||||
let args = serde_json::json!({
|
||||
"action": "edit",
|
||||
"id": id,
|
||||
"content": long_content
|
||||
});
|
||||
// This must NOT panic (previous code would panic on byte-level truncation)
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["success"], true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -299,6 +299,19 @@ pub async fn execute(
|
|||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// STAGE 5C: Utility-based ranking (MemRL-inspired)
|
||||
// Memories that proved useful in past sessions get a retrieval boost.
|
||||
// utility_score = times_useful / times_retrieved (0.0 to 1.0)
|
||||
// ====================================================================
|
||||
for result in &mut filtered_results {
|
||||
let utility = result.node.utility_score.unwrap_or(0.0) as f32;
|
||||
if utility > 0.0 {
|
||||
// Utility boost: up to +15% for memories with utility_score = 1.0
|
||||
result.combined_score *= 1.0 + (utility * 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-sort by adjusted combined_score (descending) after all score modifications
|
||||
filtered_results.sort_by(|a, b| {
|
||||
b.combined_score
|
||||
|
|
|
|||
|
|
@ -81,6 +81,11 @@ pub fn schema() -> Value {
|
|||
"source": {
|
||||
"type": "string",
|
||||
"description": "Source reference"
|
||||
},
|
||||
"forceCreate": {
|
||||
"type": "boolean",
|
||||
"description": "Force creation of this item even if similar content exists",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["content"]
|
||||
|
|
@ -111,6 +116,7 @@ struct BatchItem {
|
|||
#[serde(alias = "node_type")]
|
||||
node_type: Option<String>,
|
||||
source: Option<String>,
|
||||
force_create: Option<bool>,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
|
|
@ -125,7 +131,8 @@ pub async fn execute(
|
|||
|
||||
// Detect mode: batch (items present) vs single (content present)
|
||||
if let Some(items) = args.items {
|
||||
return execute_batch(storage, cognitive, items).await;
|
||||
let global_force = args.force_create.unwrap_or(false);
|
||||
return execute_batch(storage, cognitive, items, global_force).await;
|
||||
}
|
||||
|
||||
// Single mode: content is required
|
||||
|
|
@ -275,6 +282,7 @@ async fn execute_batch(
|
|||
storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
items: Vec<BatchItem>,
|
||||
global_force_create: bool,
|
||||
) -> Result<Value, String> {
|
||||
if items.is_empty() {
|
||||
return Err("Items array cannot be empty".to_string());
|
||||
|
|
@ -312,6 +320,9 @@ async fn execute_batch(
|
|||
continue;
|
||||
}
|
||||
|
||||
// Extract per-item force_create before consuming other fields
|
||||
let item_force_create = item.force_create.unwrap_or(false);
|
||||
|
||||
// ================================================================
|
||||
// COGNITIVE PRE-INGEST (per item)
|
||||
// ================================================================
|
||||
|
|
@ -352,6 +363,39 @@ async fn execute_batch(
|
|||
// INGEST (storage lock per item)
|
||||
// ================================================================
|
||||
|
||||
// Check force_create: global flag OR per-item flag
|
||||
let item_force = global_force_create || item_force_create;
|
||||
if item_force {
|
||||
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();
|
||||
|
||||
created += 1;
|
||||
run_post_ingest(cognitive, &node_id, &node_content, &node_type, importance_composite);
|
||||
|
||||
results.push(serde_json::json!({
|
||||
"index": i,
|
||||
"status": "saved",
|
||||
"decision": "create",
|
||||
"nodeId": node_id,
|
||||
"importanceScore": importance_composite,
|
||||
"reason": "Forced creation - skipped similarity check"
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
errors += 1;
|
||||
results.push(serde_json::json!({
|
||||
"index": i,
|
||||
"status": "error",
|
||||
"reason": e.to_string()
|
||||
}));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
{
|
||||
match storage.smart_ingest(input) {
|
||||
|
|
@ -863,6 +907,62 @@ mod tests {
|
|||
assert!(results[0]["importanceScore"].is_number());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_batch_force_create_global() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
// Three items with very similar content + global forceCreate
|
||||
let result = execute(
|
||||
&storage, &test_cognitive(),
|
||||
Some(serde_json::json!({
|
||||
"forceCreate": true,
|
||||
"items": [
|
||||
{ "content": "Physics question about quantum mechanics and wave functions" },
|
||||
{ "content": "Physics question about quantum mechanics and wave equations" },
|
||||
{ "content": "Physics question about quantum mechanics and wave behavior" }
|
||||
]
|
||||
})),
|
||||
).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["mode"], "batch");
|
||||
// All 3 should be created separately, not merged
|
||||
assert_eq!(value["summary"]["created"], 3);
|
||||
assert_eq!(value["summary"]["updated"], 0);
|
||||
// Each result should say "Forced creation"
|
||||
let results = value["results"].as_array().unwrap();
|
||||
for r in results {
|
||||
assert_eq!(r["decision"], "create");
|
||||
assert!(r["reason"].as_str().unwrap().contains("Forced"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_batch_force_create_per_item() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
// Mix of forced and non-forced items
|
||||
let result = execute(
|
||||
&storage, &test_cognitive(),
|
||||
Some(serde_json::json!({
|
||||
"items": [
|
||||
{ "content": "Forced item one", "forceCreate": true },
|
||||
{ "content": "Normal item two" },
|
||||
{ "content": "Forced item three", "forceCreate": true }
|
||||
]
|
||||
})),
|
||||
).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
let results = value["results"].as_array().unwrap();
|
||||
// Forced items should say "Forced creation"
|
||||
assert_eq!(results[0]["decision"], "create");
|
||||
assert!(results[0]["reason"].as_str().unwrap().contains("Forced"));
|
||||
// Non-forced item gets normal processing
|
||||
assert_eq!(results[1]["status"], "saved");
|
||||
// Third forced item
|
||||
assert_eq!(results[2]["decision"], "create");
|
||||
assert!(results[2]["reason"].as_str().unwrap().contains("Forced"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_content_no_items_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue