mirror of
https://github.com/samvallad33/vestige.git
synced 2026-05-09 07:42:37 +02:00
feat: v2.0.4 "Deep Reference" — cognitive reasoning engine + 10 bug fixes
New features: - deep_reference tool (#22): 8-stage cognitive reasoning pipeline with FSRS-6 trust scoring, intent classification (FactCheck/Timeline/RootCause/Comparison/ Synthesis), spreading activation expansion, temporal supersession, trust-weighted contradiction analysis, relation assessment, dream insight integration, and algorithmic reasoning chain generation — all without calling an LLM - cross_reference (#23): backward-compatible alias for deep_reference - retrieval_mode parameter on search (precise/balanced/exhaustive) - get_batch action on memory tool (up to 20 IDs per call) - Token budget raised from 10K to 100K on search + session_context - Dates (createdAt/updatedAt) on all search results and session_context lines Bug fixes (GitHub Issue #25 — all 10 resolved): - state_transitions empty: wired record_memory_access into strengthen_batch - chain/bridges no storage fallback: added with edge deduplication - knowledge_edges dead schema: documented as deprecated - insights not persisted from dream: wired save_insight after generation - find_duplicates threshold dropped: serde alias fix - search min_retention ignored: serde aliases for snake_case params - intention time triggers null: removed dead trigger_at embedding - changelog missing dreams: added get_dream_history + event integration - phantom Related IDs: clarified message text - fsrs_cards empty: documented as harmless dead schema Security hardening: - HTTP transport CORS: permissive() → localhost-only - Auth token panic guard: &token[..8] → safe min(8) slice - UTF-8 boundary fix: floor_char_boundary on content truncation - All unwrap() removed from HTTP transport (unwrap_or_else fallback) - Dream memory_count capped at 500 (prevents O(N²) hang) - Dormant state threshold aligned (0.3 → 0.4) Stats: 23 tools, 758 tests, 0 failures, 0 warnings, 0 unwraps in production Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
61091e06b9
commit
04781a95e2
28 changed files with 1797 additions and 102 deletions
|
|
@ -140,6 +140,11 @@ fn execute_system_wide(
|
|||
.get_recent_state_transitions(limit)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Get dream history (Bug #9 fix — dreams were invisible to audit trail)
|
||||
let dreams = storage
|
||||
.get_dream_history(limit)
|
||||
.unwrap_or_default();
|
||||
|
||||
// Build unified event list
|
||||
let mut events: Vec<(DateTime<Utc>, Value)> = Vec::new();
|
||||
|
||||
|
|
@ -174,6 +179,20 @@ fn execute_system_wide(
|
|||
));
|
||||
}
|
||||
|
||||
for d in &dreams {
|
||||
events.push((
|
||||
d.dreamed_at,
|
||||
serde_json::json!({
|
||||
"type": "dream",
|
||||
"timestamp": d.dreamed_at.to_rfc3339(),
|
||||
"durationMs": d.duration_ms,
|
||||
"memoriesReplayed": d.memories_replayed,
|
||||
"connectionFound": d.connections_found,
|
||||
"insightsGenerated": d.insights_generated,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Sort by timestamp descending
|
||||
events.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
|
||||
|
|
|
|||
809
crates/vestige-mcp/src/tools/cross_reference.rs
Normal file
809
crates/vestige-mcp/src/tools/cross_reference.rs
Normal file
|
|
@ -0,0 +1,809 @@
|
|||
//! Deep Reference Tool (v2.0.4)
|
||||
//!
|
||||
//! Cognitive reasoning engine across memories. Combines:
|
||||
//! 1. Broad retrieval (hybrid search + reranking)
|
||||
//! 2. Spreading activation expansion (connected memories)
|
||||
//! 3. FSRS-6 trust scoring (retention, stability, reps, lapses)
|
||||
//! 4. Temporal supersession (newer = current truth)
|
||||
//! 5. Contradiction analysis (trust-weighted)
|
||||
//! 6. Dream insight integration (persisted insights)
|
||||
//! 7. Structured synthesis (recommended answer + evidence)
|
||||
//!
|
||||
//! Research grounding: MAGMA (multi-graph), Kumiho (AGM belief revision),
|
||||
//! InfMem (System-2 memory control), D-Mem (dual-process retrieval).
|
||||
//!
|
||||
//! Replaces cross_reference with full cognitive reasoning. cross_reference
|
||||
//! is kept as a backward-compatible alias.
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use vestige_core::Storage;
|
||||
|
||||
/// Input schema for deep_reference / cross_reference tool
|
||||
pub fn schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The question, claim, or topic to reason about across all memories"
|
||||
},
|
||||
"depth": {
|
||||
"type": "integer",
|
||||
"description": "How many memories to analyze (default: 20, max: 50). Higher = more thorough.",
|
||||
"default": 20,
|
||||
"minimum": 5,
|
||||
"maximum": 50
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DeepRefArgs {
|
||||
query: String,
|
||||
depth: Option<i32>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FSRS-6 Trust Score
|
||||
// ============================================================================
|
||||
|
||||
/// Compute trust score from FSRS-6 memory state.
|
||||
/// Higher = more trustworthy (frequently accessed, high retention, stable, few lapses).
|
||||
fn compute_trust(retention: f64, stability: f64, reps: i32, lapses: i32) -> f64 {
|
||||
let retention_factor = retention * 0.4;
|
||||
let stability_factor = (stability / 30.0).min(1.0) * 0.2;
|
||||
let reps_factor = (reps as f64 / 10.0).min(1.0) * 0.2;
|
||||
let lapses_penalty = (1.0 - (lapses as f64 / 5.0)).max(0.0) * 0.2;
|
||||
(retention_factor + stability_factor + reps_factor + lapses_penalty).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SYSTEM 1: Intent Classification (MAGMA-inspired query routing)
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum QueryIntent {
|
||||
FactCheck, // "Is X true?" → find support/contradiction evidence
|
||||
Timeline, // "When did X happen?" → temporal ordering + pattern detection
|
||||
RootCause, // "Why did X happen?" → causal chain backward
|
||||
Comparison, // "How does X differ from Y?" → diff two memory clusters
|
||||
Synthesis, // Default: "What do I know about X?" → cluster + best per cluster
|
||||
}
|
||||
|
||||
fn classify_intent(query: &str) -> QueryIntent {
|
||||
let q = query.to_lowercase();
|
||||
let patterns: &[(QueryIntent, &[&str])] = &[
|
||||
(QueryIntent::RootCause, &["why did", "root cause", "what caused", "because of", "reason for", "why is", "why was"]),
|
||||
(QueryIntent::Timeline, &["when did", "timeline", "history of", "over time", "how has", "evolution of", "sequence of"]),
|
||||
(QueryIntent::Comparison, &["differ", "compare", "versus", " vs ", "difference between", "changed from"]),
|
||||
(QueryIntent::FactCheck, &["is it true", "did i", "was there", "verify", "confirm", "is this correct", "should i use", "should we"]),
|
||||
];
|
||||
for (intent, keywords) in patterns {
|
||||
if keywords.iter().any(|kw| q.contains(kw)) {
|
||||
return intent.clone();
|
||||
}
|
||||
}
|
||||
QueryIntent::Synthesis
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SYSTEM 2: Relation Assessment (embedding similarity + sentiment + temporal)
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Relation {
|
||||
Supports,
|
||||
Contradicts,
|
||||
Supersedes,
|
||||
Irrelevant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
struct RelationAssessment {
|
||||
relation: Relation,
|
||||
confidence: f64,
|
||||
reasoning: String,
|
||||
}
|
||||
|
||||
/// Assess the relationship between two memories using embedding similarity,
|
||||
/// correction signals, temporal ordering, and trust comparison.
|
||||
/// No LLM needed — pure algorithmic assessment.
|
||||
fn assess_relation(a_content: &str, b_content: &str, a_trust: f64, b_trust: f64,
|
||||
a_date: chrono::DateTime<Utc>, b_date: chrono::DateTime<Utc>,
|
||||
topic_sim: f32) -> RelationAssessment {
|
||||
// Irrelevant: different topics
|
||||
if topic_sim < 0.15 {
|
||||
return RelationAssessment {
|
||||
relation: Relation::Irrelevant,
|
||||
confidence: 1.0 - topic_sim as f64,
|
||||
reasoning: format!("Different topics (similarity {:.2})", topic_sim),
|
||||
};
|
||||
}
|
||||
|
||||
let time_delta_days = (b_date - a_date).num_days().abs();
|
||||
let trust_diff = b_trust - a_trust;
|
||||
let has_correction = appears_contradictory(a_content, b_content);
|
||||
|
||||
// Supersession: same topic + newer + higher trust
|
||||
if topic_sim > 0.4 && time_delta_days > 0 && trust_diff > 0.05 && !has_correction {
|
||||
let (newer, older) = if b_date > a_date { ("B", "A") } else { ("A", "B") };
|
||||
return RelationAssessment {
|
||||
relation: Relation::Supersedes,
|
||||
confidence: topic_sim as f64 * (0.5 + trust_diff.min(0.5)),
|
||||
reasoning: format!("{} supersedes {} (newer by {}d, trust +{:.0}%)",
|
||||
newer, older, time_delta_days, trust_diff * 100.0),
|
||||
};
|
||||
}
|
||||
|
||||
// Contradiction: same topic + correction signals detected
|
||||
if has_correction && topic_sim > 0.15 {
|
||||
return RelationAssessment {
|
||||
relation: Relation::Contradicts,
|
||||
confidence: topic_sim as f64 * 0.8,
|
||||
reasoning: format!("Contradiction detected (similarity {:.2}, correction signals present)", topic_sim),
|
||||
};
|
||||
}
|
||||
|
||||
// Support: same topic + no contradiction
|
||||
if topic_sim > 0.3 {
|
||||
return RelationAssessment {
|
||||
relation: Relation::Supports,
|
||||
confidence: topic_sim as f64,
|
||||
reasoning: format!("Topically aligned (similarity {:.2}), consistent stance", topic_sim),
|
||||
};
|
||||
}
|
||||
|
||||
RelationAssessment {
|
||||
relation: Relation::Irrelevant,
|
||||
confidence: 0.3,
|
||||
reasoning: "Weak relationship".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SYSTEM 3: Template Reasoning Chain Generator (no LLM needed)
|
||||
// ============================================================================
|
||||
|
||||
/// Generate a natural language reasoning chain from structured evidence.
|
||||
/// The AI reads this and validates/extends it — System 1 prepares, System 2 refines.
|
||||
fn generate_reasoning_chain(
|
||||
query: &str,
|
||||
intent: &QueryIntent,
|
||||
primary: &ScoredMemory,
|
||||
relations: &[(String, f64, RelationAssessment)], // (preview, trust, relation)
|
||||
confidence: f64,
|
||||
) -> String {
|
||||
let mut chain = String::new();
|
||||
|
||||
// Intent-specific opening
|
||||
match intent {
|
||||
QueryIntent::FactCheck => {
|
||||
chain.push_str(&format!(
|
||||
"FACT CHECK: \"{}\"\n\n", query
|
||||
));
|
||||
}
|
||||
QueryIntent::Timeline => {
|
||||
chain.push_str(&format!(
|
||||
"TIMELINE: \"{}\"\n\n", query
|
||||
));
|
||||
}
|
||||
QueryIntent::RootCause => {
|
||||
chain.push_str(&format!(
|
||||
"ROOT CAUSE ANALYSIS: \"{}\"\n\n", query
|
||||
));
|
||||
}
|
||||
QueryIntent::Comparison => {
|
||||
chain.push_str(&format!(
|
||||
"COMPARISON: \"{}\"\n\n", query
|
||||
));
|
||||
}
|
||||
QueryIntent::Synthesis => {
|
||||
chain.push_str(&format!(
|
||||
"SYNTHESIS: \"{}\"\n\n", query
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Primary finding
|
||||
chain.push_str(&format!(
|
||||
"PRIMARY FINDING (trust {:.0}%, {}): {}\n",
|
||||
primary.trust * 100.0,
|
||||
primary.updated_at.format("%b %d, %Y"),
|
||||
primary.content.chars().take(150).collect::<String>(),
|
||||
));
|
||||
|
||||
// Superseded memories
|
||||
let superseded: Vec<_> = relations.iter()
|
||||
.filter(|(_, _, r)| matches!(r.relation, Relation::Supersedes))
|
||||
.collect();
|
||||
for (preview, trust, rel) in &superseded {
|
||||
chain.push_str(&format!(
|
||||
" SUPERSEDES (trust {:.0}%): \"{}\" — {}\n",
|
||||
trust * 100.0,
|
||||
preview.chars().take(80).collect::<String>(),
|
||||
rel.reasoning,
|
||||
));
|
||||
}
|
||||
|
||||
// Supporting evidence
|
||||
let supporting: Vec<_> = relations.iter()
|
||||
.filter(|(_, _, r)| matches!(r.relation, Relation::Supports))
|
||||
.collect();
|
||||
if !supporting.is_empty() {
|
||||
chain.push_str(&format!("\nSUPPORTED BY {} MEMOR{}:\n",
|
||||
supporting.len(),
|
||||
if supporting.len() == 1 { "Y" } else { "IES" },
|
||||
));
|
||||
for (preview, trust, _) in supporting.iter().take(5) {
|
||||
chain.push_str(&format!(
|
||||
" + (trust {:.0}%): \"{}\"\n",
|
||||
trust * 100.0,
|
||||
preview.chars().take(80).collect::<String>(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Contradicting evidence
|
||||
let contradicting: Vec<_> = relations.iter()
|
||||
.filter(|(_, _, r)| matches!(r.relation, Relation::Contradicts))
|
||||
.collect();
|
||||
if !contradicting.is_empty() {
|
||||
chain.push_str(&format!("\nCONTRADICTING EVIDENCE ({}):\n", contradicting.len()));
|
||||
for (preview, trust, rel) in contradicting.iter().take(3) {
|
||||
chain.push_str(&format!(
|
||||
" ! (trust {:.0}%): \"{}\" — {}\n",
|
||||
trust * 100.0,
|
||||
preview.chars().take(80).collect::<String>(),
|
||||
rel.reasoning,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Confidence summary
|
||||
chain.push_str(&format!("\nOVERALL CONFIDENCE: {:.0}%\n", confidence * 100.0));
|
||||
|
||||
chain
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Contradiction Detection (enhanced with relation assessment)
|
||||
// ============================================================================
|
||||
|
||||
const NEGATION_PAIRS: &[(&str, &str)] = &[
|
||||
("don't", "do"), ("never", "always"), ("avoid", "use"),
|
||||
("wrong", "right"), ("incorrect", "correct"),
|
||||
("deprecated", "recommended"), ("outdated", "current"),
|
||||
("removed", "added"), ("disabled", "enabled"),
|
||||
("not ", ""), ("no longer", ""),
|
||||
];
|
||||
|
||||
const CORRECTION_SIGNALS: &[&str] = &[
|
||||
"actually", "correction", "update:", "updated:", "fixed",
|
||||
"was wrong", "changed to", "now uses", "replaced by",
|
||||
"superseded", "no longer", "instead of", "switched to", "migrated to",
|
||||
];
|
||||
|
||||
fn appears_contradictory(a: &str, b: &str) -> bool {
|
||||
let a_lower = a.to_lowercase();
|
||||
let b_lower = b.to_lowercase();
|
||||
|
||||
let a_words: std::collections::HashSet<&str> = a_lower.split_whitespace().filter(|w| w.len() > 3).collect();
|
||||
let b_words: std::collections::HashSet<&str> = b_lower.split_whitespace().filter(|w| w.len() > 3).collect();
|
||||
let shared_words = a_words.intersection(&b_words).count();
|
||||
|
||||
if shared_words < 2 { return false; }
|
||||
|
||||
for (neg, _) in NEGATION_PAIRS {
|
||||
if (a_lower.contains(neg) && !b_lower.contains(neg))
|
||||
|| (b_lower.contains(neg) && !a_lower.contains(neg))
|
||||
{ return true; }
|
||||
}
|
||||
for signal in CORRECTION_SIGNALS {
|
||||
if a_lower.contains(signal) || b_lower.contains(signal) { return true; }
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn topic_overlap(a: &str, b: &str) -> f32 {
|
||||
let a_lower = a.to_lowercase();
|
||||
let b_lower = b.to_lowercase();
|
||||
let a_words: std::collections::HashSet<&str> = a_lower.split_whitespace().filter(|w| w.len() > 3).collect();
|
||||
let b_words: std::collections::HashSet<&str> = b_lower.split_whitespace().filter(|w| w.len() > 3).collect();
|
||||
if a_words.is_empty() || b_words.is_empty() { return 0.0; }
|
||||
let intersection = a_words.intersection(&b_words).count();
|
||||
let union = a_words.union(&b_words).count();
|
||||
if union == 0 { 0.0 } else { intersection as f32 / union as f32 }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Scored Memory (used across pipeline stages)
|
||||
// ============================================================================
|
||||
|
||||
#[allow(dead_code)]
|
||||
struct ScoredMemory {
|
||||
id: String,
|
||||
content: String,
|
||||
tags: Vec<String>,
|
||||
trust: f64,
|
||||
updated_at: chrono::DateTime<Utc>,
|
||||
created_at: chrono::DateTime<Utc>,
|
||||
retention: f64,
|
||||
combined_score: f32,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Execute — 8-Stage Pipeline
|
||||
// ============================================================================
|
||||
|
||||
pub async fn execute(
|
||||
storage: &Arc<Storage>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: DeepRefArgs = match args {
|
||||
Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?,
|
||||
None => return Err("Missing arguments".to_string()),
|
||||
};
|
||||
|
||||
if args.query.trim().is_empty() {
|
||||
return Err("Query cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let depth = args.depth.unwrap_or(20).clamp(5, 50) as usize;
|
||||
|
||||
// ====================================================================
|
||||
// STAGE 0: Intent Classification (MAGMA-inspired query routing)
|
||||
// ====================================================================
|
||||
let intent = classify_intent(&args.query);
|
||||
|
||||
// ====================================================================
|
||||
// STAGE 1: Broad Retrieval + Reranking
|
||||
// ====================================================================
|
||||
let results = storage
|
||||
.hybrid_search(&args.query, depth as i32, 0.3, 0.7)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if results.is_empty() {
|
||||
return Ok(serde_json::json!({
|
||||
"query": args.query,
|
||||
"status": "no_memories",
|
||||
"confidence": 0.0,
|
||||
"guidance": "No memories found. Use smart_ingest to add memories.",
|
||||
"memoriesAnalyzed": 0,
|
||||
}));
|
||||
}
|
||||
|
||||
let mut ranked = results;
|
||||
if let Ok(mut cog) = cognitive.try_lock() {
|
||||
let candidates: Vec<_> = ranked.iter().map(|r| (r.clone(), r.node.content.clone())).collect();
|
||||
if let Ok(reranked) = cog.reranker.rerank(&args.query, candidates, Some(depth)) {
|
||||
ranked = reranked.into_iter().map(|rr| rr.item).collect();
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// STAGE 2: Spreading Activation Expansion
|
||||
// ====================================================================
|
||||
let mut activation_expanded = 0usize;
|
||||
let existing_ids: std::collections::HashSet<String> = ranked.iter().map(|r| r.node.id.clone()).collect();
|
||||
|
||||
if let Ok(mut cog) = cognitive.try_lock() {
|
||||
let mut expanded_ids = Vec::new();
|
||||
for r in ranked.iter().take(3) {
|
||||
let activated = cog.activation_network.activate(&r.node.id, 1.0);
|
||||
for a in activated.iter().take(3) {
|
||||
if !existing_ids.contains(&a.memory_id) && !expanded_ids.contains(&a.memory_id) {
|
||||
expanded_ids.push(a.memory_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fetch expanded memories from storage
|
||||
for id in &expanded_ids {
|
||||
if let Ok(Some(node)) = storage.get_node(id) {
|
||||
// Create a minimal SearchResult-like entry
|
||||
ranked.push(vestige_core::SearchResult {
|
||||
node,
|
||||
combined_score: 0.3, // lower score since these are expanded, not direct matches
|
||||
keyword_score: None,
|
||||
semantic_score: None,
|
||||
match_type: vestige_core::MatchType::Semantic,
|
||||
});
|
||||
activation_expanded += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// STAGE 3: FSRS-6 Trust Scoring
|
||||
// ====================================================================
|
||||
|
||||
let scored: Vec<ScoredMemory> = ranked.iter().map(|r| {
|
||||
let trust = compute_trust(
|
||||
r.node.retention_strength,
|
||||
r.node.stability,
|
||||
r.node.reps,
|
||||
r.node.lapses,
|
||||
);
|
||||
ScoredMemory {
|
||||
id: r.node.id.clone(),
|
||||
content: r.node.content.clone(),
|
||||
tags: r.node.tags.clone(),
|
||||
trust,
|
||||
updated_at: r.node.updated_at,
|
||||
created_at: r.node.created_at,
|
||||
retention: r.node.retention_strength,
|
||||
combined_score: r.combined_score,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
// ====================================================================
|
||||
// STAGE 4: Temporal Supersession
|
||||
// ====================================================================
|
||||
let mut superseded: Vec<Value> = Vec::new();
|
||||
let mut superseded_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
// Sort by date descending for supersession
|
||||
let mut by_date = scored.iter().collect::<Vec<_>>();
|
||||
by_date.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
|
||||
|
||||
for i in 0..by_date.len() {
|
||||
for j in (i + 1)..by_date.len() {
|
||||
let newer = by_date[i];
|
||||
let older = by_date[j];
|
||||
let overlap = topic_overlap(&newer.content, &older.content);
|
||||
if overlap > 0.3 && newer.trust > older.trust && !superseded_ids.contains(&older.id) {
|
||||
superseded_ids.insert(older.id.clone());
|
||||
superseded.push(serde_json::json!({
|
||||
"id": older.id,
|
||||
"preview": older.content.chars().take(150).collect::<String>(),
|
||||
"trust": (older.trust * 100.0).round() / 100.0,
|
||||
"date": older.updated_at.to_rfc3339(),
|
||||
"superseded_by": newer.id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// STAGE 5: Trust-Weighted Contradiction Analysis
|
||||
// ====================================================================
|
||||
let mut contradictions: Vec<Value> = Vec::new();
|
||||
|
||||
for i in 0..scored.len() {
|
||||
for j in (i + 1)..scored.len() {
|
||||
let a = &scored[i];
|
||||
let b = &scored[j];
|
||||
let overlap = topic_overlap(&a.content, &b.content);
|
||||
if overlap < 0.15 { continue; }
|
||||
|
||||
let is_contradiction = appears_contradictory(&a.content, &b.content);
|
||||
if !is_contradiction { continue; }
|
||||
|
||||
// Only flag as real contradiction if BOTH have decent trust
|
||||
let min_trust = a.trust.min(b.trust);
|
||||
if min_trust < 0.3 { continue; } // Low-trust memory isn't worth flagging
|
||||
|
||||
let (stronger, weaker) = if a.trust >= b.trust { (a, b) } else { (b, a) };
|
||||
contradictions.push(serde_json::json!({
|
||||
"stronger": {
|
||||
"id": stronger.id,
|
||||
"preview": stronger.content.chars().take(150).collect::<String>(),
|
||||
"trust": (stronger.trust * 100.0).round() / 100.0,
|
||||
"date": stronger.updated_at.to_rfc3339(),
|
||||
},
|
||||
"weaker": {
|
||||
"id": weaker.id,
|
||||
"preview": weaker.content.chars().take(150).collect::<String>(),
|
||||
"trust": (weaker.trust * 100.0).round() / 100.0,
|
||||
"date": weaker.updated_at.to_rfc3339(),
|
||||
},
|
||||
"topic_overlap": overlap,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// STAGE 6: Dream Insight Integration
|
||||
// ====================================================================
|
||||
let mut related_insights: Vec<Value> = Vec::new();
|
||||
if let Ok(insights) = storage.get_insights(20) {
|
||||
let memory_ids: std::collections::HashSet<&str> = scored.iter().map(|s| s.id.as_str()).collect();
|
||||
for insight in insights {
|
||||
let overlaps = insight.source_memories.iter()
|
||||
.any(|src_id| memory_ids.contains(src_id.as_str()));
|
||||
if overlaps {
|
||||
related_insights.push(serde_json::json!({
|
||||
"insight": insight.insight,
|
||||
"type": insight.insight_type,
|
||||
"confidence": insight.confidence,
|
||||
"source_memories": insight.source_memories,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// STAGE 7: Relation Assessment (per-pair, using trust + temporal + similarity)
|
||||
// ====================================================================
|
||||
let mut pair_relations: Vec<(String, f64, RelationAssessment)> = Vec::new();
|
||||
if let Some(primary) = scored.iter()
|
||||
.filter(|s| !superseded_ids.contains(&s.id))
|
||||
.max_by(|a, b| a.trust.partial_cmp(&b.trust).unwrap_or(std::cmp::Ordering::Equal))
|
||||
{
|
||||
for other in scored.iter().filter(|s| s.id != primary.id).take(15) {
|
||||
let sim = topic_overlap(&primary.content, &other.content);
|
||||
let rel = assess_relation(
|
||||
&primary.content, &other.content,
|
||||
primary.trust, other.trust,
|
||||
primary.updated_at, other.updated_at,
|
||||
sim,
|
||||
);
|
||||
if !matches!(rel.relation, Relation::Irrelevant) {
|
||||
pair_relations.push((
|
||||
other.content.chars().take(100).collect(),
|
||||
other.trust,
|
||||
rel,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// STAGE 8: Synthesis + Reasoning Chain Generation
|
||||
// ====================================================================
|
||||
// Find the recommended answer: highest trust, not superseded, most recent
|
||||
let recommended = scored.iter()
|
||||
.filter(|s| !superseded_ids.contains(&s.id))
|
||||
.max_by(|a, b| {
|
||||
// Primary: trust. Secondary: date.
|
||||
a.trust.partial_cmp(&b.trust)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| a.updated_at.cmp(&b.updated_at))
|
||||
});
|
||||
|
||||
// Build evidence list (top memories by trust, not superseded)
|
||||
let mut non_superseded: Vec<&ScoredMemory> = scored.iter()
|
||||
.filter(|s| !superseded_ids.contains(&s.id))
|
||||
.collect();
|
||||
non_superseded.sort_by(|a, b| b.trust.partial_cmp(&a.trust).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let evidence: Vec<Value> = non_superseded.iter()
|
||||
.take(10)
|
||||
.enumerate()
|
||||
.map(|(i, s)| serde_json::json!({
|
||||
"id": s.id,
|
||||
"preview": s.content.chars().take(200).collect::<String>(),
|
||||
"trust": (s.trust * 100.0).round() / 100.0,
|
||||
"date": s.updated_at.to_rfc3339(),
|
||||
"role": if i == 0 { "primary" } else { "supporting" },
|
||||
}))
|
||||
.collect();
|
||||
|
||||
// Build evolution timeline
|
||||
let mut evolution: Vec<Value> = by_date.iter().rev()
|
||||
.map(|s| serde_json::json!({
|
||||
"date": s.updated_at.format("%b %d, %Y").to_string(),
|
||||
"preview": s.content.chars().take(100).collect::<String>(),
|
||||
"trust": (s.trust * 100.0).round() / 100.0,
|
||||
}))
|
||||
.collect();
|
||||
evolution.truncate(15); // cap timeline length
|
||||
|
||||
// Confidence scoring
|
||||
let base_confidence = recommended.map(|r| r.trust).unwrap_or(0.0);
|
||||
let agreement_boost = (evidence.len() as f64 * 0.03).min(0.2);
|
||||
let contradiction_penalty = contradictions.len() as f64 * 0.1;
|
||||
let confidence = (base_confidence + agreement_boost - contradiction_penalty).clamp(0.0, 1.0);
|
||||
|
||||
let status = if contradictions.is_empty() && confidence > 0.7 {
|
||||
"resolved"
|
||||
} else if !contradictions.is_empty() {
|
||||
"contradictions_found"
|
||||
} else if scored.is_empty() {
|
||||
"no_evidence"
|
||||
} else {
|
||||
"partial_evidence"
|
||||
};
|
||||
|
||||
let guidance = if let Some(rec) = recommended {
|
||||
if contradictions.is_empty() {
|
||||
format!(
|
||||
"High confidence ({:.0}%). Recommended memory (trust {:.0}%, {}) is the most reliable source.",
|
||||
confidence * 100.0, rec.trust * 100.0, rec.updated_at.format("%b %d, %Y")
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"WARNING: {} contradiction(s) detected. Recommended memory has trust {:.0}% but conflicts exist. Review contradictions below.",
|
||||
contradictions.len(), rec.trust * 100.0
|
||||
)
|
||||
}
|
||||
} else {
|
||||
"No strong evidence found. Verify with external sources.".to_string()
|
||||
};
|
||||
|
||||
// Auto-strengthen accessed memories (Testing Effect)
|
||||
let ids: Vec<&str> = scored.iter().map(|s| s.id.as_str()).collect();
|
||||
let _ = storage.strengthen_batch_on_access(&ids);
|
||||
|
||||
// Generate reasoning chain (the key differentiator — no LLM needed)
|
||||
let reasoning_chain = if let Some(rec) = recommended {
|
||||
generate_reasoning_chain(&args.query, &intent, rec, &pair_relations, confidence)
|
||||
} else {
|
||||
"No strong evidence found for reasoning.".to_string()
|
||||
};
|
||||
|
||||
// Build response
|
||||
let mut response = serde_json::json!({
|
||||
"query": args.query,
|
||||
"intent": format!("{:?}", intent),
|
||||
"status": status,
|
||||
"confidence": (confidence * 100.0).round() / 100.0,
|
||||
"reasoning": reasoning_chain,
|
||||
"guidance": guidance,
|
||||
"memoriesAnalyzed": scored.len(),
|
||||
"activationExpanded": activation_expanded,
|
||||
});
|
||||
|
||||
if let Some(rec) = recommended {
|
||||
response["recommended"] = serde_json::json!({
|
||||
"answer_preview": rec.content.chars().take(300).collect::<String>(),
|
||||
"memory_id": rec.id,
|
||||
"trust_score": (rec.trust * 100.0).round() / 100.0,
|
||||
"date": rec.updated_at.to_rfc3339(),
|
||||
});
|
||||
}
|
||||
|
||||
if !evidence.is_empty() { response["evidence"] = serde_json::json!(evidence); }
|
||||
if !contradictions.is_empty() { response["contradictions"] = serde_json::json!(contradictions); }
|
||||
if !superseded.is_empty() { response["superseded"] = serde_json::json!(superseded); }
|
||||
if !evolution.is_empty() { response["evolution"] = serde_json::json!(evolution); }
|
||||
if !related_insights.is_empty() { response["related_insights"] = serde_json::json!(related_insights); }
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TESTS
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_schema_structure() {
|
||||
let s = schema();
|
||||
assert!(s["properties"]["query"].is_object());
|
||||
assert!(s["properties"]["depth"].is_object());
|
||||
assert_eq!(s["required"], serde_json::json!(["query"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_score_high() {
|
||||
// High retention, high stability, many reps, no lapses → high trust
|
||||
let trust = compute_trust(0.95, 60.0, 20, 0);
|
||||
assert!(trust > 0.8, "Expected >0.8, got {}", trust);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_score_low() {
|
||||
// Low retention, low stability, few reps, many lapses → low trust
|
||||
let trust = compute_trust(0.2, 1.0, 1, 10);
|
||||
assert!(trust < 0.3, "Expected <0.3, got {}", trust);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_score_medium() {
|
||||
// Medium everything
|
||||
let trust = compute_trust(0.6, 15.0, 5, 2);
|
||||
assert!(trust > 0.4 && trust < 0.7, "Expected 0.4-0.7, got {}", trust);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_score_clamped() {
|
||||
// Even extreme values stay in [0, 1]
|
||||
assert!(compute_trust(1.0, 1000.0, 100, 0) <= 1.0);
|
||||
assert!(compute_trust(0.0, 0.0, 0, 100) >= 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contradiction_requires_shared_words() {
|
||||
assert!(!appears_contradictory("not sure about weather", "Rust is fast"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contradiction_with_shared_context() {
|
||||
assert!(appears_contradictory(
|
||||
"Don't use FAISS for vector search in production",
|
||||
"Use FAISS for vector search in production always"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_topic_overlap_similar() {
|
||||
let overlap = topic_overlap("Vestige uses USearch for vector search", "Vestige vector search powered by USearch HNSW");
|
||||
assert!(overlap > 0.3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_topic_overlap_different() {
|
||||
let overlap = topic_overlap("The weather is sunny today", "Rust compile times improving");
|
||||
assert!(overlap < 0.15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_depth_clamped() {
|
||||
let s = schema();
|
||||
assert_eq!(s["properties"]["depth"]["minimum"], 5);
|
||||
assert_eq!(s["properties"]["depth"]["maximum"], 50);
|
||||
}
|
||||
|
||||
// === Intent Classification Tests ===
|
||||
|
||||
#[test]
|
||||
fn test_intent_fact_check() {
|
||||
assert_eq!(classify_intent("Is it true that Vestige uses USearch?"), QueryIntent::FactCheck);
|
||||
assert_eq!(classify_intent("Did I switch to port 3002?"), QueryIntent::FactCheck);
|
||||
assert_eq!(classify_intent("Should I use prefix caching?"), QueryIntent::FactCheck);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intent_timeline() {
|
||||
assert_eq!(classify_intent("When did the port change happen?"), QueryIntent::Timeline);
|
||||
assert_eq!(classify_intent("How has the AIMO3 score evolved over time?"), QueryIntent::Timeline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intent_root_cause() {
|
||||
assert_eq!(classify_intent("Why did the build fail?"), QueryIntent::RootCause);
|
||||
assert_eq!(classify_intent("What caused the score regression?"), QueryIntent::RootCause);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intent_comparison() {
|
||||
assert_eq!(classify_intent("How does USearch differ from FAISS?"), QueryIntent::Comparison);
|
||||
assert_eq!(classify_intent("Compare FSRS versus SM-2"), QueryIntent::Comparison);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intent_synthesis_default() {
|
||||
assert_eq!(classify_intent("Tell me about Sam's projects"), QueryIntent::Synthesis);
|
||||
assert_eq!(classify_intent("What is Vestige?"), QueryIntent::Synthesis);
|
||||
}
|
||||
|
||||
// === Relation Assessment Tests ===
|
||||
|
||||
#[test]
|
||||
fn test_relation_irrelevant() {
|
||||
let rel = assess_relation("Rust is fast", "The weather is nice", 0.8, 0.8,
|
||||
Utc::now(), Utc::now(), 0.05);
|
||||
assert!(matches!(rel.relation, Relation::Irrelevant));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relation_supports() {
|
||||
let rel = assess_relation(
|
||||
"Vestige uses USearch for vector search",
|
||||
"USearch provides fast HNSW indexing for Vestige",
|
||||
0.8, 0.7, Utc::now(), Utc::now(), 0.6);
|
||||
assert!(matches!(rel.relation, Relation::Supports));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relation_contradicts() {
|
||||
let rel = assess_relation(
|
||||
"Don't use FAISS for vector search in production anymore",
|
||||
"Use FAISS for vector search in production always",
|
||||
0.8, 0.5, Utc::now(), Utc::now(), 0.7);
|
||||
assert!(matches!(rel.relation, Relation::Contradicts));
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +45,7 @@ pub fn schema() -> Value {
|
|||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DedupArgs {
|
||||
#[serde(alias = "similarity_threshold")]
|
||||
similarity_threshold: Option<f64>,
|
||||
limit: Option<usize>,
|
||||
tags: Option<Vec<String>>,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use tokio::sync::Mutex;
|
|||
|
||||
use chrono::Utc;
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use vestige_core::{DreamHistoryRecord, LinkType, Storage};
|
||||
use vestige_core::{DreamHistoryRecord, InsightRecord, LinkType, Storage};
|
||||
|
||||
pub fn schema() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
|
|
@ -30,7 +30,8 @@ pub async fn execute(
|
|||
.as_ref()
|
||||
.and_then(|a| a.get("memory_count"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(50) as usize;
|
||||
.unwrap_or(50)
|
||||
.min(500) as usize; // Cap at 500 to prevent O(N^2) hang
|
||||
|
||||
// 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)
|
||||
|
|
@ -94,8 +95,28 @@ pub async fn execute(
|
|||
let all_connections = cog.dreamer.get_connections();
|
||||
drop(cog);
|
||||
|
||||
// v2.1.0: Persist dream insights to database (Bug #4 fix)
|
||||
let mut insights_persisted = 0u64;
|
||||
for insight in &insights {
|
||||
let record = InsightRecord {
|
||||
id: insight.id.clone(),
|
||||
insight: insight.insight.clone(),
|
||||
source_memories: insight.source_memories.clone(),
|
||||
confidence: insight.confidence,
|
||||
novelty_score: insight.novelty_score,
|
||||
insight_type: format!("{:?}", insight.insight_type),
|
||||
generated_at: insight.generated_at,
|
||||
tags: insight.tags.clone(),
|
||||
feedback: None,
|
||||
applied_count: 0,
|
||||
};
|
||||
if storage.save_insight(&record).is_ok() {
|
||||
insights_persisted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// v1.9.0: Persist only NEW connections from this dream (skip accumulated ones)
|
||||
let new_connections = &all_connections[pre_dream_count..];
|
||||
let new_connections = all_connections.get(pre_dream_count..).unwrap_or(&[]);
|
||||
let mut connections_persisted = 0u64;
|
||||
{
|
||||
let now = Utc::now();
|
||||
|
|
@ -197,9 +218,11 @@ pub async fn execute(
|
|||
"novelty_score": i.novelty_score,
|
||||
})).collect::<Vec<_>>(),
|
||||
"connectionsPersisted": connections_persisted,
|
||||
"insightsPersisted": insights_persisted,
|
||||
"stats": {
|
||||
"new_connections_found": dream_result.new_connections_found,
|
||||
"connections_persisted": connections_persisted,
|
||||
"insights_persisted": insights_persisted,
|
||||
"memories_strengthened": dream_result.memories_strengthened,
|
||||
"memories_compressed": dream_result.memories_compressed,
|
||||
"insights_generated": dream_result.insights_generated.len(),
|
||||
|
|
@ -532,4 +555,55 @@ mod tests {
|
|||
persisted
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dream_persists_insights() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
|
||||
// Create diverse tagged memories to encourage insight generation
|
||||
let topics = [
|
||||
("Rust borrow checker prevents data races", vec!["rust", "safety"]),
|
||||
("Rust ownership model ensures memory safety", vec!["rust", "safety"]),
|
||||
("Cargo manages Rust project dependencies", vec!["rust", "cargo"]),
|
||||
("Cargo.toml defines project configuration", vec!["rust", "cargo"]),
|
||||
("Unit tests use the #[test] attribute", vec!["rust", "testing"]),
|
||||
("Integration tests live in the tests directory", vec!["rust", "testing"]),
|
||||
("Clippy catches common Rust mistakes", vec!["rust", "tooling"]),
|
||||
("Rustfmt automatically formats code", vec!["rust", "tooling"]),
|
||||
];
|
||||
for (content, tags) in &topics {
|
||||
storage.ingest(vestige_core::IngestInput {
|
||||
content: content.to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
source: None, sentiment_score: 0.0, sentiment_magnitude: 0.0,
|
||||
tags: tags.iter().map(|t| t.to_string()).collect(),
|
||||
valid_from: None, valid_until: None,
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
let result = execute(&storage, &test_cognitive(), None).await.unwrap();
|
||||
assert_eq!(result["status"], "dreamed");
|
||||
|
||||
let response_insights = result["insights"].as_array().unwrap();
|
||||
let persisted_count = result["insightsPersisted"].as_u64().unwrap_or(0);
|
||||
|
||||
// If insights were generated, they should be persisted
|
||||
if !response_insights.is_empty() {
|
||||
assert!(persisted_count > 0, "Generated insights should be persisted to database");
|
||||
let stored = storage.get_insights(100).unwrap();
|
||||
assert_eq!(
|
||||
stored.len(), persisted_count as usize,
|
||||
"All {} persisted insights should be retrievable", persisted_count
|
||||
);
|
||||
// Verify insight fields
|
||||
for insight in &stored {
|
||||
assert!(!insight.id.is_empty(), "Insight ID should not be empty");
|
||||
assert!(!insight.insight.is_empty(), "Insight text should not be empty");
|
||||
assert!(insight.confidence >= 0.0 && insight.confidence <= 1.0);
|
||||
assert!(insight.novelty_score >= 0.0);
|
||||
assert!(insight.feedback.is_none(), "Fresh insight should have no feedback");
|
||||
assert_eq!(insight.applied_count, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use std::sync::Arc;
|
|||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use vestige_core::advanced::{Connection, ConnectionType, MemoryChainBuilder, MemoryNode};
|
||||
use vestige_core::Storage;
|
||||
|
||||
pub fn schema() -> serde_json::Value {
|
||||
|
|
@ -50,12 +51,24 @@ pub async fn execute(
|
|||
match action {
|
||||
"chain" => {
|
||||
let to_id = to.ok_or("'to' is required for chain action")?;
|
||||
match cog.chain_builder.build_chain(from, to_id) {
|
||||
let chain_result = cog.chain_builder.build_chain(from, to_id);
|
||||
let from_owned = from.to_string();
|
||||
let to_owned = to_id.to_string();
|
||||
drop(cog); // release lock before potential storage fallback
|
||||
|
||||
let chain_opt = if chain_result.is_some() {
|
||||
chain_result
|
||||
} else {
|
||||
// Storage fallback: build temporary chain from persisted connections
|
||||
build_chain_from_storage(storage, &from_owned, &to_owned)
|
||||
};
|
||||
|
||||
match chain_opt {
|
||||
Some(chain) => {
|
||||
Ok(serde_json::json!({
|
||||
"action": "chain",
|
||||
"from": from,
|
||||
"to": to_id,
|
||||
"from": from_owned,
|
||||
"to": to_owned,
|
||||
"steps": chain.steps.iter().map(|s| serde_json::json!({
|
||||
"memory_id": s.memory_id,
|
||||
"memory_preview": s.memory_preview,
|
||||
|
|
@ -70,8 +83,8 @@ pub async fn execute(
|
|||
None => {
|
||||
Ok(serde_json::json!({
|
||||
"action": "chain",
|
||||
"from": from,
|
||||
"to": to_id,
|
||||
"from": from_owned,
|
||||
"to": to_owned,
|
||||
"steps": [],
|
||||
"message": "No chain found between these memories"
|
||||
}))
|
||||
|
|
@ -82,6 +95,8 @@ pub async fn execute(
|
|||
let activation_assocs = cog.activation_network.get_associations(from);
|
||||
let hippocampal_assocs = cog.hippocampal_index.get_associations(from, 2)
|
||||
.unwrap_or_default();
|
||||
let from_owned = from.to_string();
|
||||
drop(cog); // release lock consistently (matches chain/bridges pattern)
|
||||
|
||||
let mut all_associations: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
|
|
@ -106,10 +121,9 @@ pub async fn execute(
|
|||
|
||||
// Fallback: if in-memory modules are empty, query storage directly
|
||||
if all_associations.is_empty() {
|
||||
drop(cog); // release cognitive lock before storage call
|
||||
if let Ok(connections) = storage.get_connections_for_memory(from) {
|
||||
if let Ok(connections) = storage.get_connections_for_memory(&from_owned) {
|
||||
for conn in connections.iter().take(limit) {
|
||||
let other_id = if conn.source_id == from {
|
||||
let other_id = if conn.source_id == from_owned {
|
||||
&conn.target_id
|
||||
} else {
|
||||
&conn.source_id
|
||||
|
|
@ -126,7 +140,7 @@ pub async fn execute(
|
|||
|
||||
Ok(serde_json::json!({
|
||||
"action": "associations",
|
||||
"from": from,
|
||||
"from": from_owned,
|
||||
"associations": all_associations,
|
||||
"count": all_associations.len(),
|
||||
}))
|
||||
|
|
@ -134,11 +148,23 @@ pub async fn execute(
|
|||
"bridges" => {
|
||||
let to_id = to.ok_or("'to' is required for bridges action")?;
|
||||
let bridges = cog.chain_builder.find_bridge_memories(from, to_id);
|
||||
let limited: Vec<_> = bridges.iter().take(limit).collect();
|
||||
let from_owned = from.to_string();
|
||||
let to_owned = to_id.to_string();
|
||||
drop(cog); // release lock before potential storage fallback
|
||||
|
||||
let final_bridges = if !bridges.is_empty() {
|
||||
bridges
|
||||
} else {
|
||||
// Storage fallback: build temporary graph and find bridges
|
||||
let temp_builder = build_temp_chain_builder(storage, &from_owned, &to_owned);
|
||||
temp_builder.find_bridge_memories(&from_owned, &to_owned)
|
||||
};
|
||||
|
||||
let limited: Vec<_> = final_bridges.iter().take(limit).collect();
|
||||
Ok(serde_json::json!({
|
||||
"action": "bridges",
|
||||
"from": from,
|
||||
"to": to_id,
|
||||
"from": from_owned,
|
||||
"to": to_owned,
|
||||
"bridges": limited,
|
||||
"count": limited.len(),
|
||||
}))
|
||||
|
|
@ -147,6 +173,73 @@ pub async fn execute(
|
|||
}
|
||||
}
|
||||
|
||||
/// Build a temporary MemoryChainBuilder from persisted connections for fallback queries.
|
||||
fn build_temp_chain_builder(storage: &Arc<Storage>, from_id: &str, to_id: &str) -> MemoryChainBuilder {
|
||||
let mut builder = MemoryChainBuilder::new();
|
||||
|
||||
// Load connections involving either endpoint
|
||||
let mut all_conns = Vec::new();
|
||||
if let Ok(conns) = storage.get_connections_for_memory(from_id) {
|
||||
all_conns.extend(conns);
|
||||
}
|
||||
if let Ok(conns) = storage.get_connections_for_memory(to_id) {
|
||||
all_conns.extend(conns);
|
||||
}
|
||||
|
||||
// Deduplicate edges and load referenced memory nodes
|
||||
let mut seen_edges = std::collections::HashSet::new();
|
||||
all_conns.retain(|c| seen_edges.insert((c.source_id.clone(), c.target_id.clone())));
|
||||
|
||||
let mut seen_ids = std::collections::HashSet::new();
|
||||
for conn in &all_conns {
|
||||
for id in [&conn.source_id, &conn.target_id] {
|
||||
if seen_ids.insert(id.clone()) {
|
||||
if let Ok(Some(node)) = storage.get_node(id) {
|
||||
builder.add_memory(MemoryNode {
|
||||
id: node.id.clone(),
|
||||
content_preview: node.content.chars().take(100).collect(),
|
||||
tags: node.tags.clone(),
|
||||
connections: vec![],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add edges
|
||||
for conn in &all_conns {
|
||||
builder.add_connection(Connection {
|
||||
from_id: conn.source_id.clone(),
|
||||
to_id: conn.target_id.clone(),
|
||||
connection_type: link_type_to_connection_type(&conn.link_type),
|
||||
strength: conn.strength,
|
||||
created_at: conn.created_at,
|
||||
});
|
||||
}
|
||||
|
||||
builder
|
||||
}
|
||||
|
||||
/// Build a chain from storage when in-memory chain_builder is empty.
|
||||
fn build_chain_from_storage(
|
||||
storage: &Arc<Storage>,
|
||||
from_id: &str,
|
||||
to_id: &str,
|
||||
) -> Option<vestige_core::advanced::ReasoningChain> {
|
||||
let builder = build_temp_chain_builder(storage, from_id, to_id);
|
||||
builder.build_chain(from_id, to_id)
|
||||
}
|
||||
|
||||
/// Convert storage link_type string to ConnectionType enum.
|
||||
fn link_type_to_connection_type(link_type: &str) -> ConnectionType {
|
||||
match link_type {
|
||||
"temporal" => ConnectionType::TemporalProximity,
|
||||
"causal" => ConnectionType::Causal,
|
||||
"part_of" => ConnectionType::PartOf,
|
||||
_ => ConnectionType::SemanticSimilarity,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -351,4 +444,71 @@ mod tests {
|
|||
assert_eq!(associations[0]["source"], "persistent_graph");
|
||||
assert_eq!(associations[0]["memory_id"], id2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_chain_storage_fallback() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
|
||||
// Create 3 memories: A -> B -> C
|
||||
let make = |content: &str| vestige_core::IngestInput {
|
||||
content: content.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,
|
||||
};
|
||||
let id_a = storage.ingest(make("Memory A about databases")).unwrap().id;
|
||||
let id_b = storage.ingest(make("Memory B about indexes")).unwrap().id;
|
||||
let id_c = storage.ingest(make("Memory C about performance")).unwrap().id;
|
||||
|
||||
// Save connections A->B and B->C to storage
|
||||
let now = chrono::Utc::now();
|
||||
for (src, tgt) in [(&id_a, &id_b), (&id_b, &id_c)] {
|
||||
storage.save_connection(&vestige_core::ConnectionRecord {
|
||||
source_id: src.clone(), target_id: tgt.clone(),
|
||||
strength: 0.9, link_type: "semantic".to_string(),
|
||||
created_at: now, last_activated: now, activation_count: 1,
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
// Execute chain with empty cognitive engine — should fall back to storage
|
||||
let args = serde_json::json!({ "action": "chain", "from": id_a, "to": id_c });
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["action"], "chain");
|
||||
let steps = value["steps"].as_array().unwrap();
|
||||
assert!(!steps.is_empty(), "Chain should find path A->B->C via storage fallback");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bridges_storage_fallback() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
|
||||
// Create 3 memories: A -> B -> C (B is the bridge)
|
||||
let make = |content: &str| vestige_core::IngestInput {
|
||||
content: content.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,
|
||||
};
|
||||
let id_a = storage.ingest(make("Bridge test memory A")).unwrap().id;
|
||||
let id_b = storage.ingest(make("Bridge test memory B")).unwrap().id;
|
||||
let id_c = storage.ingest(make("Bridge test memory C")).unwrap().id;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
for (src, tgt) in [(&id_a, &id_b), (&id_b, &id_c)] {
|
||||
storage.save_connection(&vestige_core::ConnectionRecord {
|
||||
source_id: src.clone(), target_id: tgt.clone(),
|
||||
strength: 0.9, link_type: "semantic".to_string(),
|
||||
created_at: now, last_activated: now, activation_count: 1,
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
// Execute bridges with empty cognitive engine
|
||||
let args = serde_json::json!({ "action": "bridges", "from": id_a, "to": id_c });
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["action"], "bridges");
|
||||
let bridges = value["bridges"].as_array().unwrap();
|
||||
assert!(!bridges.is_empty(), "Should find B as bridge between A and C via storage fallback");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,12 +43,17 @@ pub fn schema() -> Value {
|
|||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"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)"
|
||||
"enum": ["get", "get_batch", "delete", "state", "promote", "demote", "edit"],
|
||||
"description": "Action to perform: 'get' retrieves full memory node, 'get_batch' retrieves multiple memories by IDs (use 'ids' array), '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",
|
||||
"description": "The ID of the memory node"
|
||||
"description": "The ID of the memory node (for single-memory actions)"
|
||||
},
|
||||
"ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Array of memory IDs (for get_batch action). Max 20 IDs per call."
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
|
|
@ -59,7 +64,7 @@ pub fn schema() -> Value {
|
|||
"description": "New content for edit action. Replaces existing content, regenerates embedding, preserves FSRS state."
|
||||
}
|
||||
},
|
||||
"required": ["action", "id"]
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -67,7 +72,8 @@ pub fn schema() -> Value {
|
|||
#[serde(rename_all = "camelCase")]
|
||||
struct MemoryArgs {
|
||||
action: String,
|
||||
id: String,
|
||||
id: Option<String>,
|
||||
ids: Option<Vec<String>>,
|
||||
reason: Option<String>,
|
||||
content: Option<String>,
|
||||
}
|
||||
|
|
@ -83,18 +89,34 @@ pub async fn execute(
|
|||
None => return Err("Missing arguments".to_string()),
|
||||
};
|
||||
|
||||
// Validate UUID format
|
||||
uuid::Uuid::parse_str(&args.id).map_err(|_| "Invalid memory ID format".to_string())?;
|
||||
// get_batch uses 'ids' array, all other actions use 'id'
|
||||
if args.action == "get_batch" {
|
||||
let ids = args.ids.ok_or("get_batch requires 'ids' array")?;
|
||||
if ids.is_empty() {
|
||||
return Err("ids array cannot be empty".to_string());
|
||||
}
|
||||
if ids.len() > 20 {
|
||||
return Err("get_batch supports max 20 IDs per call".to_string());
|
||||
}
|
||||
for id in &ids {
|
||||
uuid::Uuid::parse_str(id).map_err(|_| format!("Invalid memory ID format: {}", id))?;
|
||||
}
|
||||
return execute_get_batch(storage, &ids).await;
|
||||
}
|
||||
|
||||
// All other actions require 'id'
|
||||
let id = args.id.ok_or("This action requires 'id' parameter")?;
|
||||
uuid::Uuid::parse_str(&id).map_err(|_| "Invalid memory ID format".to_string())?;
|
||||
|
||||
match args.action.as_str() {
|
||||
"get" => execute_get(storage, &args.id).await,
|
||||
"delete" => execute_delete(storage, &args.id).await,
|
||||
"state" => execute_state(storage, &args.id).await,
|
||||
"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,
|
||||
"get" => execute_get(storage, &id).await,
|
||||
"delete" => execute_delete(storage, &id).await,
|
||||
"state" => execute_state(storage, &id).await,
|
||||
"promote" => execute_promote(storage, cognitive, &id, args.reason).await,
|
||||
"demote" => execute_demote(storage, cognitive, &id, args.reason).await,
|
||||
"edit" => execute_edit(storage, &id, args.content).await,
|
||||
_ => Err(format!(
|
||||
"Invalid action '{}'. Must be one of: get, delete, state, promote, demote, edit",
|
||||
"Invalid action '{}'. Must be one of: get, get_batch, delete, state, promote, demote, edit",
|
||||
args.action
|
||||
)),
|
||||
}
|
||||
|
|
@ -140,6 +162,49 @@ async fn execute_get(storage: &Arc<Storage>, id: &str) -> Result<Value, String>
|
|||
}
|
||||
}
|
||||
|
||||
/// Get multiple full memory nodes by ID (batch retrieval for expandable IDs)
|
||||
async fn execute_get_batch(storage: &Arc<Storage>, ids: &[String]) -> Result<Value, String> {
|
||||
let mut results = Vec::with_capacity(ids.len());
|
||||
let mut found_count = 0;
|
||||
|
||||
for id in ids {
|
||||
match storage.get_node(id) {
|
||||
Ok(Some(n)) => {
|
||||
found_count += 1;
|
||||
results.push(serde_json::json!({
|
||||
"id": n.id,
|
||||
"content": n.content,
|
||||
"nodeType": n.node_type,
|
||||
"createdAt": n.created_at.to_rfc3339(),
|
||||
"updatedAt": n.updated_at.to_rfc3339(),
|
||||
"tags": n.tags,
|
||||
"retentionStrength": n.retention_strength,
|
||||
"source": n.source,
|
||||
}));
|
||||
}
|
||||
Ok(None) => {
|
||||
results.push(serde_json::json!({
|
||||
"id": id,
|
||||
"found": false,
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
results.push(serde_json::json!({
|
||||
"id": id,
|
||||
"error": e.to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"action": "get_batch",
|
||||
"requested": ids.len(),
|
||||
"found": found_count,
|
||||
"results": results,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Delete a memory and return success status
|
||||
async fn execute_delete(storage: &Arc<Storage>, id: &str) -> Result<Value, String> {
|
||||
let deleted = storage.delete_node(id).map_err(|e| e.to_string())?;
|
||||
|
|
@ -388,10 +453,12 @@ mod tests {
|
|||
assert!(schema["properties"]["action"].is_object());
|
||||
assert!(schema["properties"]["id"].is_object());
|
||||
assert!(schema["properties"]["reason"].is_object());
|
||||
assert_eq!(schema["required"], serde_json::json!(["action", "id"]));
|
||||
// Verify all 6 actions are in enum
|
||||
assert_eq!(schema["required"], serde_json::json!(["action"]));
|
||||
assert!(schema["properties"]["ids"].is_object()); // get_batch support
|
||||
// Verify all 7 actions are in enum
|
||||
let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
|
||||
assert_eq!(actions.len(), 6);
|
||||
assert_eq!(actions.len(), 7);
|
||||
assert!(actions.contains(&serde_json::json!("get_batch")));
|
||||
assert!(actions.contains(&serde_json::json!("edit")));
|
||||
assert!(actions.contains(&serde_json::json!("promote")));
|
||||
assert!(actions.contains(&serde_json::json!("demote")));
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ pub mod session_context;
|
|||
pub mod health;
|
||||
pub mod graph;
|
||||
|
||||
// v2.1: Cross-reference (connect the dots)
|
||||
pub mod cross_reference;
|
||||
|
||||
// Deprecated/internal tools — not advertised in the public MCP tools/list,
|
||||
// but some functions are actively dispatched for backwards compatibility
|
||||
// and internal cognitive operations. #[allow(dead_code)] suppresses warnings
|
||||
|
|
|
|||
|
|
@ -68,9 +68,15 @@ pub fn schema() -> Value {
|
|||
},
|
||||
"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.",
|
||||
"description": "Max tokens for response. Server truncates content to fit budget. Use memory(action='get') for full content of specific IDs. With 1M context models, budgets up to 100K are practical.",
|
||||
"minimum": 100,
|
||||
"maximum": 10000
|
||||
"maximum": 100000
|
||||
},
|
||||
"retrieval_mode": {
|
||||
"type": "string",
|
||||
"description": "precise: top results only (fast, token-efficient, skips activation/competition). balanced: full 7-stage cognitive pipeline (default). exhaustive: maximum recall with 5x overfetch, deep graph traversal, no competition suppression.",
|
||||
"enum": ["precise", "balanced", "exhaustive"],
|
||||
"default": "balanced"
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
|
|
@ -82,13 +88,18 @@ pub fn schema() -> Value {
|
|||
struct SearchArgs {
|
||||
query: String,
|
||||
limit: Option<i32>,
|
||||
#[serde(alias = "min_retention")]
|
||||
min_retention: Option<f64>,
|
||||
#[serde(alias = "min_similarity")]
|
||||
min_similarity: Option<f32>,
|
||||
#[serde(alias = "detail_level")]
|
||||
detail_level: Option<String>,
|
||||
#[serde(alias = "context_topics")]
|
||||
context_topics: Option<Vec<String>>,
|
||||
#[serde(alias = "token_budget")]
|
||||
token_budget: Option<i32>,
|
||||
#[serde(alias = "retrieval_mode")]
|
||||
retrieval_mode: Option<String>,
|
||||
}
|
||||
|
||||
/// Execute unified search with 7-stage cognitive pipeline.
|
||||
|
|
@ -135,14 +146,32 @@ pub async fn execute(
|
|||
let min_retention = args.min_retention.unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
let min_similarity = args.min_similarity.unwrap_or(0.5).clamp(0.0, 1.0);
|
||||
|
||||
// Validate retrieval_mode
|
||||
let retrieval_mode = match args.retrieval_mode.as_deref() {
|
||||
Some("precise") => "precise",
|
||||
Some("exhaustive") => "exhaustive",
|
||||
Some("balanced") | None => "balanced",
|
||||
Some(invalid) => {
|
||||
return Err(format!(
|
||||
"Invalid retrieval_mode '{}'. Must be 'precise', 'balanced', or 'exhaustive'.",
|
||||
invalid
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Favor semantic search — research shows 0.3/0.7 outperforms equal weights
|
||||
let keyword_weight = 0.3_f32;
|
||||
let semantic_weight = 0.7_f32;
|
||||
|
||||
// ====================================================================
|
||||
// STAGE 1: Hybrid search with 3x over-fetch for reranking pool
|
||||
// STAGE 1: Hybrid search with Nx over-fetch for reranking pool
|
||||
// ====================================================================
|
||||
let overfetch_limit = (limit * 3).min(100); // Cap at 100 to avoid excessive DB load
|
||||
let overfetch_multiplier = match retrieval_mode {
|
||||
"precise" => 1, // No overfetch — return exactly what's asked
|
||||
"exhaustive" => 5, // Deep overfetch for maximum recall
|
||||
_ => 3, // Balanced default
|
||||
};
|
||||
let overfetch_limit = (limit * overfetch_multiplier).min(100); // Cap at 100 to avoid excessive DB load
|
||||
|
||||
let results = storage
|
||||
.hybrid_search(&args.query, overfetch_limit, keyword_weight, semantic_weight)
|
||||
|
|
@ -215,7 +244,7 @@ pub async fn execute(
|
|||
// Determine state from retention strength
|
||||
lifecycle.state = if result.node.retention_strength > 0.7 {
|
||||
MemoryState::Active
|
||||
} else if result.node.retention_strength > 0.3 {
|
||||
} else if result.node.retention_strength > 0.4 {
|
||||
MemoryState::Dormant
|
||||
} else if result.node.retention_strength > 0.1 {
|
||||
MemoryState::Silent
|
||||
|
|
@ -275,9 +304,11 @@ pub async fn execute(
|
|||
|
||||
// ====================================================================
|
||||
// STAGE 5B: Retrieval competition (Anderson et al. 1994)
|
||||
// Skipped in precise mode (no need) and exhaustive mode (want all results)
|
||||
// ====================================================================
|
||||
let mut suppressed_count = 0_usize;
|
||||
if filtered_results.len() > 1
|
||||
if retrieval_mode == "balanced"
|
||||
&& filtered_results.len() > 1
|
||||
&& let Ok(mut cog) = cognitive.try_lock()
|
||||
{
|
||||
let candidates: Vec<CompetitionCandidate> = filtered_results
|
||||
|
|
@ -321,21 +352,31 @@ pub async fn execute(
|
|||
|
||||
// ====================================================================
|
||||
// STAGE 6: Spreading activation (find associated memories)
|
||||
// Skipped in precise mode. Deeper (5 results) in exhaustive mode.
|
||||
// ====================================================================
|
||||
let associations: Vec<Value> = if let Ok(mut cog) = cognitive.try_lock() {
|
||||
if let Some(first) = filtered_results.first() {
|
||||
let activated = cog.activation_network.activate(&first.node.id, 1.0);
|
||||
activated
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|a| {
|
||||
serde_json::json!({
|
||||
"memoryId": a.memory_id,
|
||||
"activation": a.activation,
|
||||
"distance": a.distance,
|
||||
let activation_take = match retrieval_mode {
|
||||
"precise" => 0, // Skip entirely
|
||||
"exhaustive" => 5, // Deeper graph traversal
|
||||
_ => 3, // Balanced default
|
||||
};
|
||||
let associations: Vec<Value> = if activation_take > 0 {
|
||||
if let Ok(mut cog) = cognitive.try_lock() {
|
||||
if let Some(first) = filtered_results.first() {
|
||||
let activated = cog.activation_network.activate(&first.node.id, 1.0);
|
||||
activated
|
||||
.iter()
|
||||
.take(activation_take)
|
||||
.map(|a| {
|
||||
serde_json::json!({
|
||||
"memoryId": a.memory_id,
|
||||
"activation": a.activation,
|
||||
"distance": a.distance,
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
|
|
@ -401,7 +442,7 @@ pub async fn execute(
|
|||
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 = budget.clamp(100, 100000) as usize;
|
||||
let budget_chars = budget * 4;
|
||||
let mut used = 0;
|
||||
let mut budgeted = Vec::new();
|
||||
|
|
@ -428,11 +469,17 @@ pub async fn execute(
|
|||
let mut response = serde_json::json!({
|
||||
"query": args.query,
|
||||
"method": "hybrid+cognitive",
|
||||
"retrievalMode": retrieval_mode,
|
||||
"detailLevel": detail_level,
|
||||
"total": formatted.len(),
|
||||
"results": formatted,
|
||||
});
|
||||
|
||||
// Helpful hint when no results found
|
||||
if formatted.is_empty() {
|
||||
response["hint"] = serde_json::json!("No memories found. Use smart_ingest to add memories, or try a broader query.");
|
||||
}
|
||||
|
||||
// Include associations if any were found
|
||||
if !associations.is_empty() {
|
||||
response["associations"] = serde_json::json!(associations);
|
||||
|
|
@ -499,7 +546,7 @@ fn format_search_result(r: &vestige_core::SearchResult, detail_level: &str) -> V
|
|||
"validUntil": r.node.valid_until.map(|dt| dt.to_rfc3339()),
|
||||
"matchType": format!("{:?}", r.match_type),
|
||||
}),
|
||||
// "summary" (default) — backwards compatible
|
||||
// "summary" (default) — includes dates so AI never has to guess when a memory is from
|
||||
_ => serde_json::json!({
|
||||
"id": r.node.id,
|
||||
"content": r.node.content,
|
||||
|
|
@ -509,6 +556,8 @@ fn format_search_result(r: &vestige_core::SearchResult, detail_level: &str) -> V
|
|||
"nodeType": r.node.node_type,
|
||||
"tags": r.node.tags,
|
||||
"retentionStrength": r.node.retention_strength,
|
||||
"createdAt": r.node.created_at.to_rfc3339(),
|
||||
"updatedAt": r.node.updated_at.to_rfc3339(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
@ -1004,10 +1053,11 @@ mod tests {
|
|||
let results = value["results"].as_array().unwrap();
|
||||
if !results.is_empty() {
|
||||
let first = &results[0];
|
||||
// Summary should have content but not timestamps
|
||||
// Summary should have content AND timestamps (v2.1: dates always visible)
|
||||
assert!(first["content"].is_string());
|
||||
assert!(first["id"].is_string());
|
||||
assert!(first.get("createdAt").is_none() || first["createdAt"].is_null());
|
||||
assert!(first["createdAt"].is_string(), "summary must include createdAt");
|
||||
assert!(first["updatedAt"].is_string(), "summary must include updatedAt");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1106,6 +1156,6 @@ mod tests {
|
|||
let tb = &schema_value["properties"]["token_budget"];
|
||||
assert!(tb.is_object());
|
||||
assert_eq!(tb["minimum"], 100);
|
||||
assert_eq!(tb["maximum"], 10000);
|
||||
assert_eq!(tb["maximum"], 100000);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,10 +27,10 @@ pub fn schema() -> Value {
|
|||
},
|
||||
"token_budget": {
|
||||
"type": "integer",
|
||||
"description": "Max tokens for response (default: 1000). Server truncates content to fit budget.",
|
||||
"description": "Max tokens for response (default: 1000). Server truncates content to fit budget. With 1M context models, budgets up to 100K are practical.",
|
||||
"default": 1000,
|
||||
"minimum": 100,
|
||||
"maximum": 10000
|
||||
"maximum": 100000
|
||||
},
|
||||
"context": {
|
||||
"type": "object",
|
||||
|
|
@ -105,7 +105,7 @@ pub async fn execute(
|
|||
None => SessionContextArgs::default(),
|
||||
};
|
||||
|
||||
let token_budget = args.token_budget.unwrap_or(1000).clamp(100, 10000) as usize;
|
||||
let token_budget = args.token_budget.unwrap_or(1000).clamp(100, 100000) 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);
|
||||
|
|
@ -132,7 +132,8 @@ pub async fn execute(
|
|||
continue;
|
||||
}
|
||||
let summary = first_sentence(&r.node.content);
|
||||
let line = format!("- {}", summary);
|
||||
let date_str = r.node.updated_at.format("%b %d, %Y").to_string();
|
||||
let line = format!("- ({}) {}", date_str, summary);
|
||||
let line_len = line.len() + 1; // +1 for newline
|
||||
|
||||
if char_count + line_len > budget_chars {
|
||||
|
|
@ -510,7 +511,7 @@ mod tests {
|
|||
let s = schema();
|
||||
let tb = &s["properties"]["token_budget"];
|
||||
assert_eq!(tb["minimum"], 100);
|
||||
assert_eq!(tb["maximum"], 10000);
|
||||
assert_eq!(tb["maximum"], 100000);
|
||||
assert_eq!(tb["default"], 1000);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue