mirror of
https://github.com/samvallad33/vestige.git
synced 2026-06-08 20:25:16 +02:00
feat: Vestige v1.5.0 — Cognitive Engine, memory dreaming, graph exploration, predictive retrieval
28-module CognitiveEngine with full neuroscience pipeline on every tool call. FSRS-6 now fully automatic: periodic consolidation (6h timer + inline every 100 tool calls), real retrievability formula, episodic-to-semantic auto-merge, cross-memory reinforcement, Park et al. triple retrieval scoring, ACT-R base-level activation, personalized w20 optimization. New tools (19 → 23): - dream: memory consolidation via replay, discovers hidden connections - explore_connections: graph traversal (chain, associations, bridges) - predict: proactive retrieval based on context and activity patterns - restore: memory restore from JSON backups All existing tools upgraded with cognitive pre/post processing pipelines. 33 files changed, ~4,100 lines added.
This commit is contained in:
parent
3fce1f0b70
commit
927f41c3e4
34 changed files with 4302 additions and 266 deletions
205
CLAUDE.md
Normal file
205
CLAUDE.md
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
# Vestige v1.5.0 — Cognitive Memory System
|
||||
|
||||
Vestige is your long-term memory. It implements real neuroscience: FSRS-6 spaced repetition, synaptic tagging, prediction error gating, hippocampal indexing, spreading activation, and 28 stateful cognitive modules. **Use it automatically.**
|
||||
|
||||
---
|
||||
|
||||
## Session Start Protocol
|
||||
|
||||
Every conversation, before responding to the user:
|
||||
|
||||
```
|
||||
1. search("user preferences instructions") → recall who the user is
|
||||
2. search("[current project] context") → recall project patterns/decisions
|
||||
3. intention → check (with current context) → check for triggered reminders
|
||||
4. codebase → get_context (if coding) → load patterns and decisions
|
||||
```
|
||||
|
||||
Say "Remembering..." then retrieve context before answering.
|
||||
|
||||
---
|
||||
|
||||
## The 23 Tools
|
||||
|
||||
### Core Memory (2 tools)
|
||||
| Tool | When to Use |
|
||||
|------|-------------|
|
||||
| `ingest` | Store facts, concepts, events. Raw insertion, no dedup. |
|
||||
| `smart_ingest` | **Default for all saves.** Uses Prediction Error Gating to auto-decide: create, update, reinforce, or supersede. Runs cognitive pipeline (4-channel importance scoring, intent detection, synaptic tagging, hippocampal indexing). |
|
||||
|
||||
### Unified Tools (4 tools)
|
||||
| Tool | Actions | When to Use |
|
||||
|------|---------|-------------|
|
||||
| `search` | query + filters | **Every time you need to recall anything.** Hybrid search (BM25 + semantic + RRF fusion). 7-stage pipeline: overfetch → rerank → temporal boost → accessibility filter → context match → competition → spreading activation. Searching strengthens memory (Testing Effect). |
|
||||
| `memory` | get, delete, state | Retrieve a full memory by ID, delete a memory, or check its cognitive state (Active/Dormant/Silent/Unavailable). |
|
||||
| `codebase` | remember_pattern, remember_decision, get_context | Store and recall code patterns, architectural decisions, and project context. The killer differentiator. |
|
||||
| `intention` | set, check, update, list | Prospective memory — "remember to do X when Y happens". Supports time, context, and event triggers. |
|
||||
|
||||
### Feedback (2 tools)
|
||||
| Tool | When to Use |
|
||||
|------|-------------|
|
||||
| `promote_memory` | User confirms a memory was helpful or correct. Increases retrieval strength + triggers reward signal + reconsolidation. |
|
||||
| `demote_memory` | User says a memory was wrong or unhelpful. Decreases retrieval strength + updates competition model. Does NOT delete. |
|
||||
|
||||
### Temporal (2 tools)
|
||||
| Tool | When to Use |
|
||||
|------|-------------|
|
||||
| `memory_timeline` | Browse memories chronologically. Grouped by day. Filter by type, tags, date range. Detail levels: brief/summary/full. |
|
||||
| `memory_changelog` | Audit trail. Per-memory: state transitions. System-wide: consolidations + recent changes. |
|
||||
|
||||
### Cognitive (3 tools) — v1.5.0
|
||||
| Tool | When to Use |
|
||||
|------|-------------|
|
||||
| `dream` | Trigger memory consolidation — replays recent memories to discover hidden connections and synthesize insights. Like sleep for AI. |
|
||||
| `explore_connections` | Graph exploration. Actions: `chain` (reasoning path A→B), `associations` (spreading activation from a node), `bridges` (connecting memories between two nodes). |
|
||||
| `predict` | Proactive retrieval — predicts what memories you'll need next based on context, activity patterns, and learned behavior. |
|
||||
|
||||
### Auto-Save & Dedup (3 tools)
|
||||
| Tool | When to Use |
|
||||
|------|-------------|
|
||||
| `importance_score` | Score content importance before deciding whether to save. 4-channel model: novelty, arousal, reward, attention. Composite > 0.6 = worth saving. |
|
||||
| `session_checkpoint` | **Batch save up to 20 items in one call.** Each routes through Prediction Error Gating. Use at session end or before context compaction. |
|
||||
| `find_duplicates` | Find near-duplicate memory clusters via cosine similarity. Returns merge/review suggestions. Run when memory count > 700 or on user request. |
|
||||
|
||||
### Maintenance (6 tools)
|
||||
| Tool | When to Use |
|
||||
|------|-------------|
|
||||
| `health_check` | System status: healthy/degraded/critical/empty. Actionable recommendations. |
|
||||
| `consolidate` | Run FSRS-6 consolidation cycle. Applies decay, generates embeddings, maintenance. Use when memories seem stale. |
|
||||
| `stats` | Full statistics: total count, retention distribution, embedding coverage, cognitive state breakdown. |
|
||||
| `backup` | Create SQLite database backup. Returns file path. |
|
||||
| `export` | Export memories as JSON/JSONL with tag and date filters. |
|
||||
| `gc` | Garbage collect low-retention memories. Defaults to dry_run=true for safety. |
|
||||
|
||||
### Restore (1 tool)
|
||||
| Tool | When to Use |
|
||||
|------|-------------|
|
||||
| `restore` | Restore memories from a JSON backup file. Supports MCP wrapper, RecallResult, and direct array formats. |
|
||||
|
||||
---
|
||||
|
||||
## Mandatory Save Gates
|
||||
|
||||
**RULE: You MUST NOT proceed past a save gate without executing the save.**
|
||||
|
||||
### BUG_FIX — After any error is resolved
|
||||
Your next tool call after confirming a fix MUST be `smart_ingest`:
|
||||
```
|
||||
smart_ingest({
|
||||
content: "BUG FIX: [exact error]\nRoot cause: [why]\nSolution: [what fixed it]\nFiles: [paths]",
|
||||
tags: ["bug-fix", "[project]"], node_type: "fact"
|
||||
})
|
||||
```
|
||||
|
||||
### DECISION — After any architectural or design choice
|
||||
```
|
||||
codebase({
|
||||
action: "remember_decision",
|
||||
decision: "[what]", rationale: "[why]",
|
||||
alternatives: ["[A]", "[B]"], files: ["[affected]"], codebase: "[project]"
|
||||
})
|
||||
```
|
||||
|
||||
### CODE_CHANGE — After writing significant code (>20 lines or new pattern)
|
||||
```
|
||||
codebase({
|
||||
action: "remember_pattern",
|
||||
name: "[pattern]", description: "[how/when to use]",
|
||||
files: ["[files]"], codebase: "[project]"
|
||||
})
|
||||
```
|
||||
|
||||
### SESSION_END — Before stopping or compaction
|
||||
```
|
||||
session_checkpoint({
|
||||
items: [
|
||||
{ content: "SESSION: [work done]\nFixes: [list]\nDecisions: [list]", tags: ["session-end", "[project]"] },
|
||||
// ... any unsaved fixes, decisions, patterns
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Trigger Words — Auto-Save
|
||||
|
||||
| User Says | Action |
|
||||
|-----------|--------|
|
||||
| "Remember this" / "Don't forget" | `smart_ingest` immediately |
|
||||
| "I always..." / "I never..." / "I prefer..." | Save as preference |
|
||||
| "This is important" | `smart_ingest` + `promote_memory` |
|
||||
| "Remind me..." / "Next time..." | `intention` → set |
|
||||
|
||||
---
|
||||
|
||||
## Under the Hood — Cognitive Pipelines
|
||||
|
||||
### Search Pipeline (7 stages)
|
||||
1. **Overfetch** — Pull 3x results from hybrid search (BM25 + semantic)
|
||||
2. **Reranker** — Re-score by relevance quality
|
||||
3. **Temporal boost** — Recent memories get recency bonus
|
||||
4. **Accessibility filter** — FSRS-6 retention threshold (Ebbinghaus curve)
|
||||
5. **Context match** — Tulving 1973 encoding specificity (match current context to encoding context)
|
||||
6. **Competition** — Anderson 1994 retrieval-induced forgetting (winners strengthen, competitors weaken)
|
||||
7. **Spreading activation** — Side effects: activate related memories, update predictive model, record reconsolidation opportunity
|
||||
|
||||
### Ingest Pipeline (cognitive pre/post)
|
||||
**Pre-ingest:** 4-channel importance scoring (novelty/arousal/reward/attention) + intent detection → auto-tag
|
||||
**Storage:** Prediction Error Gating decides create/update/reinforce/supersede
|
||||
**Post-ingest:** Synaptic tagging (Frey & Morris 1997) + novelty model update + hippocampal indexing + cross-project recording
|
||||
|
||||
### Feedback Pipeline
|
||||
**Promote:** Reward signal + importance boost + reconsolidation (memory becomes modifiable for 24-48h) + activation spread
|
||||
**Demote:** Competition suppression + retrieval strength decrease (does NOT delete — alternatives surface instead)
|
||||
|
||||
---
|
||||
|
||||
## CognitiveEngine — 28 Modules
|
||||
|
||||
All modules persist across tool calls as stateful instances:
|
||||
|
||||
**Neuroscience (15):** ActivationNetwork, SynapticTaggingSystem, HippocampalIndex, ContextMatcher, AccessibilityCalculator, CompetitionManager, StateUpdateService, ImportanceSignals, NoveltySignal, ArousalSignal, RewardSignal, AttentionSignal, PredictiveMemory, ProspectiveMemory, IntentionParser
|
||||
|
||||
**Advanced (11):** ImportanceTracker, ReconsolidationManager, IntentDetector, ActivityTracker, MemoryDreamer, MemoryChainBuilder, MemoryCompressor, CrossProjectLearner, AdaptiveEmbedder, SpeculativeRetriever, ConsolidationScheduler
|
||||
|
||||
**Search (2):** Reranker, TemporalSearcher
|
||||
|
||||
---
|
||||
|
||||
## Memory Hygiene
|
||||
|
||||
### Promote when:
|
||||
- User confirms memory was helpful
|
||||
- Solution worked correctly
|
||||
- Information was accurate
|
||||
|
||||
### Demote when:
|
||||
- User corrects a mistake
|
||||
- Information was wrong
|
||||
- Memory led to bad outcome
|
||||
|
||||
### Never save:
|
||||
- Secrets, API keys, passwords
|
||||
- Temporary debugging state
|
||||
- Obvious/trivial information
|
||||
|
||||
---
|
||||
|
||||
## The One Rule
|
||||
|
||||
**When in doubt, save. The cost of a duplicate is near zero (Prediction Error Gating handles dedup). The cost of lost knowledge is permanent.**
|
||||
|
||||
Memory is retrieval. Searching strengthens memory. Search liberally, save aggressively.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
- **Crate:** `vestige-mcp` v1.5.0, Rust 2024 edition
|
||||
- **Tests:** 305 tests, zero warnings (`cargo test -p vestige-mcp`)
|
||||
- **Build:** `cargo build --release -p vestige-mcp`
|
||||
- **Features:** `embeddings` + `vector-search` (default on)
|
||||
- **Architecture:** `McpServer` holds `Arc<Mutex<Storage>>` + `Arc<Mutex<CognitiveEngine>>`
|
||||
- **Entry:** `src/main.rs` → stdio JSON-RPC server
|
||||
- **Tools:** `src/tools/` — one file per tool, each exports `schema()` + `execute()`
|
||||
- **Cognitive:** `src/cognitive.rs` — 28-field struct, initialized once at startup
|
||||
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -3655,7 +3655,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|||
|
||||
[[package]]
|
||||
name = "vestige-core"
|
||||
version = "1.1.3"
|
||||
version = "1.5.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"directories",
|
||||
|
|
@ -3689,7 +3689,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "vestige-mcp"
|
||||
version = "1.3.0"
|
||||
version = "1.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ members = [
|
|||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.1.3"
|
||||
version = "1.5.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-only"
|
||||
repository = "https://github.com/samvallad33/vestige"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "vestige-core"
|
||||
version = "1.1.3"
|
||||
version = "1.5.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
authors = ["Vestige Team"]
|
||||
|
|
|
|||
|
|
@ -188,6 +188,10 @@ impl ConsolidationRun {
|
|||
decay_applied: self.decay_applied,
|
||||
duration_ms: self.start_time.elapsed().as_millis() as i64,
|
||||
embeddings_generated: self.embeddings_generated,
|
||||
duplicates_merged: 0,
|
||||
neighbors_reinforced: 0,
|
||||
activations_computed: 0,
|
||||
w20_optimized: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,4 +52,4 @@ pub use scheduler::{
|
|||
FSRSParameters, FSRSScheduler, FSRSState, LearningState, PreviewResults, Rating, ReviewResult,
|
||||
};
|
||||
|
||||
pub use optimizer::FSRSOptimizer;
|
||||
pub use optimizer::{FSRSOptimizer, ReviewLog};
|
||||
|
|
|
|||
|
|
@ -288,6 +288,15 @@ pub struct ConsolidationResult {
|
|||
pub duration_ms: i64,
|
||||
/// Number of embeddings generated
|
||||
pub embeddings_generated: i64,
|
||||
// v1.4.0: FSRS-6 upgrade
|
||||
/// Number of duplicate memories merged during episodic→semantic consolidation
|
||||
pub duplicates_merged: i64,
|
||||
/// Number of neighbor memories reinforced (tracked per-access, not consolidation)
|
||||
pub neighbors_reinforced: i64,
|
||||
/// Number of ACT-R activations computed from access history
|
||||
pub activations_computed: i64,
|
||||
/// Personalized w20 if optimized this cycle
|
||||
pub w20_optimized: Option<f64>,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ pub const MIGRATIONS: &[Migration] = &[
|
|||
description: "GOD TIER 2026: Temporal knowledge graph, memory scopes, embedding versioning",
|
||||
up: MIGRATION_V4_UP,
|
||||
},
|
||||
Migration {
|
||||
version: 5,
|
||||
description: "FSRS-6 upgrade: access history, ACT-R activation, personalized decay",
|
||||
up: MIGRATION_V5_UP,
|
||||
},
|
||||
];
|
||||
|
||||
/// A database migration
|
||||
|
|
@ -390,6 +395,58 @@ CREATE INDEX IF NOT EXISTS idx_nodes_memory_system ON knowledge_nodes(memory_sys
|
|||
UPDATE schema_version SET version = 4, applied_at = datetime('now');
|
||||
"#;
|
||||
|
||||
/// V5: FSRS-6 Upgrade - Access history for ACT-R activation, personalized decay parameters
|
||||
const MIGRATION_V5_UP: &str = r#"
|
||||
-- ============================================================================
|
||||
-- ACCESS HISTORY (For ACT-R Activation + Parameter Training)
|
||||
-- ============================================================================
|
||||
|
||||
-- Logs every search hit, promote, demote for ACT-R activation computation
|
||||
CREATE TABLE IF NOT EXISTS memory_access_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id TEXT NOT NULL,
|
||||
access_type TEXT NOT NULL, -- 'search_hit', 'promote', 'demote'
|
||||
accessed_at TEXT NOT NULL,
|
||||
FOREIGN KEY (node_id) REFERENCES knowledge_nodes(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_access_log_node ON memory_access_log(node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_access_log_time ON memory_access_log(accessed_at);
|
||||
|
||||
-- ============================================================================
|
||||
-- ACT-R ACTIVATION (Pre-computed during consolidation)
|
||||
-- ============================================================================
|
||||
|
||||
-- B_i = ln(sum(t_j^(-d))) — NULL until first consolidation computes it
|
||||
ALTER TABLE knowledge_nodes ADD COLUMN activation REAL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_activation ON knowledge_nodes(activation);
|
||||
|
||||
-- ============================================================================
|
||||
-- PERSONALIZED FSRS-6 PARAMETERS
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fsrs_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value REAL NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Default w20 (forgetting curve decay parameter)
|
||||
INSERT OR IGNORE INTO fsrs_config (key, value, updated_at)
|
||||
VALUES ('w20', 0.1542, datetime('now'));
|
||||
|
||||
-- ============================================================================
|
||||
-- EXTENDED CONSOLIDATION TRACKING
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE consolidation_history ADD COLUMN duplicates_merged INTEGER DEFAULT 0;
|
||||
ALTER TABLE consolidation_history ADD COLUMN activations_computed INTEGER DEFAULT 0;
|
||||
ALTER TABLE consolidation_history ADD COLUMN w20_optimized REAL;
|
||||
|
||||
UPDATE schema_version SET version = 5, applied_at = datetime('now');
|
||||
"#;
|
||||
|
||||
/// Get current schema version from database
|
||||
pub fn get_current_version(conn: &rusqlite::Connection) -> rusqlite::Result<u32> {
|
||||
conn.query_row(
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ use std::path::PathBuf;
|
|||
use std::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::fsrs::{FSRSScheduler, FSRSState, LearningState, Rating};
|
||||
use crate::fsrs::{
|
||||
retrievability_with_decay, DEFAULT_DECAY,
|
||||
FSRSScheduler, FSRSState, LearningState, Rating,
|
||||
};
|
||||
use crate::memory::{
|
||||
ConsolidationResult, EmbeddingResult, IngestInput, KnowledgeNode, MatchType, MemoryStats,
|
||||
RecallInput, SearchMode, SearchResult, SimilarityResult,
|
||||
|
|
@ -814,15 +817,14 @@ impl Storage {
|
|||
.ok_or_else(|| StorageError::NotFound(id.to_string()))
|
||||
}
|
||||
|
||||
/// Passively strengthen a memory when it's accessed (recalled/searched)
|
||||
/// This implements the "use it or lose it" principle - memories that are
|
||||
/// accessed get a small boost, those that aren't decay naturally.
|
||||
/// Based on Testing Effect (Roediger & Karpicke 2006)
|
||||
/// Passively strengthen a memory when it's accessed (recalled/searched).
|
||||
/// Implements the Testing Effect (Roediger & Karpicke 2006) + v1.4.0
|
||||
/// content-aware cross-memory reinforcement: semantically similar neighbors
|
||||
/// receive a diminished boost proportional to cosine similarity.
|
||||
pub fn strengthen_on_access(&self, id: &str) -> Result<()> {
|
||||
let now = Utc::now();
|
||||
|
||||
// Small retrieval strength boost (0.05) on each access
|
||||
// This is much smaller than a full review but compounds over time
|
||||
// Primary boost on the accessed node
|
||||
self.conn.execute(
|
||||
"UPDATE knowledge_nodes SET
|
||||
last_accessed = ?1,
|
||||
|
|
@ -832,6 +834,39 @@ impl Storage {
|
|||
params![now.to_rfc3339(), id],
|
||||
)?;
|
||||
|
||||
// Log access for ACT-R activation computation
|
||||
let _ = self.log_access(id, "search_hit");
|
||||
|
||||
// Content-aware cross-memory reinforcement: boost semantically similar neighbors
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
{
|
||||
if let Ok(Some(embedding)) = self.get_node_embedding(id) {
|
||||
let index = self
|
||||
.vector_index
|
||||
.lock()
|
||||
.map_err(|_| StorageError::Init("Vector index lock poisoned".to_string()))?;
|
||||
|
||||
// Query top-6 similar (one will be self, so we get ~5 neighbors)
|
||||
if let Ok(neighbors) = index.search(&embedding, 6) {
|
||||
for (neighbor_id, similarity) in neighbors {
|
||||
if neighbor_id == id || similarity < 0.7 {
|
||||
continue;
|
||||
}
|
||||
// Diminished boost: 0.02 * similarity (max ~0.02)
|
||||
let boost = 0.02 * similarity as f64;
|
||||
let retention_boost = 0.008 * similarity as f64;
|
||||
let _ = self.conn.execute(
|
||||
"UPDATE knowledge_nodes SET
|
||||
retrieval_strength = MIN(1.0, retrieval_strength + ?1),
|
||||
retention_strength = MIN(1.0, retention_strength + ?2)
|
||||
WHERE id = ?3",
|
||||
params![boost, retention_boost, neighbor_id],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -843,6 +878,16 @@ impl Storage {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Log a memory access event for ACT-R activation computation
|
||||
fn log_access(&self, node_id: &str, access_type: &str) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO memory_access_log (node_id, access_type, accessed_at)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
params![node_id, access_type, Utc::now().to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Promote a memory (thumbs up) - used when a memory led to a good outcome
|
||||
/// Significantly boosts retrieval strength so it surfaces more often
|
||||
pub fn promote_memory(&self, id: &str) -> Result<KnowledgeNode> {
|
||||
|
|
@ -859,6 +904,8 @@ impl Storage {
|
|||
params![now.to_rfc3339(), id],
|
||||
)?;
|
||||
|
||||
let _ = self.log_access(id, "promote");
|
||||
|
||||
self.get_node(id)?
|
||||
.ok_or_else(|| StorageError::NotFound(id.to_string()))
|
||||
}
|
||||
|
|
@ -880,6 +927,8 @@ impl Storage {
|
|||
params![now.to_rfc3339(), id],
|
||||
)?;
|
||||
|
||||
let _ = self.log_access(id, "demote");
|
||||
|
||||
self.get_node(id)?
|
||||
.ok_or_else(|| StorageError::NotFound(id.to_string()))
|
||||
}
|
||||
|
|
@ -1250,6 +1299,37 @@ impl Storage {
|
|||
}
|
||||
}
|
||||
|
||||
// Three-signal reranking (Park et al. Generative Agents 2023)
|
||||
// final_score = 0.2*recency + 0.3*importance + 0.5*relevance
|
||||
let now = Utc::now();
|
||||
for result in &mut results {
|
||||
let hours_since = (now - result.node.last_accessed).num_seconds() as f64 / 3600.0;
|
||||
let recency = 0.995_f64.powf(hours_since.max(0.0));
|
||||
|
||||
// ACT-R activation as importance signal (pre-computed during consolidation)
|
||||
let activation: f64 = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(activation, 0.0) FROM knowledge_nodes WHERE id = ?1",
|
||||
params![result.node.id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0.0);
|
||||
// Normalize ACT-R activation [-2, 5] → [0, 1]
|
||||
let importance = ((activation + 2.0) / 7.0).clamp(0.0, 1.0);
|
||||
|
||||
let relevance = result.combined_score as f64;
|
||||
|
||||
let final_score = 0.2 * recency + 0.3 * importance + 0.5 * relevance;
|
||||
result.combined_score = final_score as f32;
|
||||
}
|
||||
|
||||
results.sort_by(|a, b| {
|
||||
b.combined_score
|
||||
.partial_cmp(&a.combined_score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
|
|
@ -1479,17 +1559,17 @@ impl Storage {
|
|||
Ok(result)
|
||||
}
|
||||
|
||||
/// Apply decay to all memories using batched pagination to avoid OOM.
|
||||
/// Apply FSRS-6 decay to all memories using batched pagination to avoid OOM.
|
||||
///
|
||||
/// Instead of loading all knowledge_nodes into memory at once, this
|
||||
/// processes rows in fixed-size batches (BATCH_SIZE = 500) using
|
||||
/// LIMIT/OFFSET pagination. Each batch runs inside its own transaction
|
||||
/// for atomicity without holding a giant write-lock.
|
||||
/// Uses the real FSRS-6 retrievability formula: R = (1 + factor * t / S)^(-w20)
|
||||
/// with personalized w20 from fsrs_config table. Sentiment boost extends
|
||||
/// effective stability for emotional memories.
|
||||
pub fn apply_decay(&mut self) -> Result<i32> {
|
||||
const FSRS_DECAY: f64 = 0.5;
|
||||
const FSRS_FACTOR: f64 = 9.0;
|
||||
const BATCH_SIZE: i64 = 500;
|
||||
// Read personalized w20 from config (falls back to default 0.1542)
|
||||
let w20 = self.get_fsrs_w20().unwrap_or(DEFAULT_DECAY);
|
||||
let sleep = crate::SleepConsolidation::new();
|
||||
|
||||
const BATCH_SIZE: i64 = 500;
|
||||
let now = Utc::now();
|
||||
let mut count = 0i32;
|
||||
let mut offset = 0i64;
|
||||
|
|
@ -1522,8 +1602,6 @@ impl Storage {
|
|||
}
|
||||
|
||||
let batch_len = batch.len() as i64;
|
||||
|
||||
// Use a transaction for the batch
|
||||
let tx = self.conn.transaction()?;
|
||||
|
||||
for (id, last_accessed, storage_strength, _, sentiment_mag, stability) in &batch {
|
||||
|
|
@ -1534,14 +1612,16 @@ impl Storage {
|
|||
let days_since = (now - last).num_seconds() as f64 / 86400.0;
|
||||
|
||||
if days_since > 0.0 {
|
||||
// Sentiment boost: emotional memories decay slower (up to 1.5x stability)
|
||||
let effective_stability = stability * (1.0 + sentiment_mag * 0.5);
|
||||
|
||||
let new_retrieval =
|
||||
(1.0 + days_since / (FSRS_FACTOR * effective_stability))
|
||||
.powf(-1.0 / FSRS_DECAY);
|
||||
// Real FSRS-6 retrievability with personalized w20
|
||||
let new_retrieval = retrievability_with_decay(
|
||||
effective_stability, days_since, w20,
|
||||
);
|
||||
|
||||
let new_retention =
|
||||
(new_retrieval * 0.7) + ((storage_strength / 10.0).min(1.0) * 0.3);
|
||||
// Use SleepConsolidation for retention calculation
|
||||
let new_retention = sleep.calculate_retention(*storage_strength, new_retrieval);
|
||||
|
||||
tx.execute(
|
||||
"UPDATE knowledge_nodes SET retrieval_strength = ?1, retention_strength = ?2 WHERE id = ?3",
|
||||
|
|
@ -1559,27 +1639,228 @@ impl Storage {
|
|||
Ok(count)
|
||||
}
|
||||
|
||||
/// Run consolidation
|
||||
/// Read personalized w20 from fsrs_config table
|
||||
fn get_fsrs_w20(&self) -> Result<f64> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT value FROM fsrs_config WHERE key = 'w20'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| StorageError::Init(format!("Failed to read w20: {}", e)))
|
||||
}
|
||||
|
||||
/// Run full FSRS-6 consolidation cycle (v1.4.0)
|
||||
///
|
||||
/// 7-step automatic consolidation:
|
||||
/// 1. Apply FSRS-6 decay with personalized w20
|
||||
/// 2. Promote emotional memories (synaptic tagging)
|
||||
/// 3. Generate missing embeddings
|
||||
/// 4. Auto-dedup: merge similar memories (episodic → semantic)
|
||||
/// 5. Compute ACT-R base-level activations from access history
|
||||
/// 6. Prune old access log entries (keep 90 days)
|
||||
/// 7. Optimize w20 if enough usage data exists
|
||||
pub fn run_consolidation(&mut self) -> Result<ConsolidationResult> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// v1.5.0: Use SleepConsolidation for structured consolidation
|
||||
let sleep = crate::SleepConsolidation::new();
|
||||
|
||||
// 1. Apply FSRS-6 decay with real formula + personalized w20
|
||||
let decay_applied = self.apply_decay()? as i64;
|
||||
|
||||
let promoted = self.conn.execute(
|
||||
"UPDATE knowledge_nodes SET
|
||||
storage_strength = MIN(storage_strength * 1.5, 10.0)
|
||||
WHERE sentiment_magnitude > 0.5
|
||||
AND storage_strength < 10",
|
||||
[],
|
||||
)? as i64;
|
||||
// 2. Promote emotional memories via SleepConsolidation
|
||||
let mut promoted = 0i64;
|
||||
{
|
||||
let candidates: Vec<(String, f64, f64)> = self.conn
|
||||
.prepare(
|
||||
"SELECT id, sentiment_magnitude, storage_strength
|
||||
FROM knowledge_nodes
|
||||
WHERE storage_strength < 10.0"
|
||||
)?
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
for (id, sentiment_mag, storage_strength) in &candidates {
|
||||
if sleep.should_promote(*sentiment_mag, *storage_strength) {
|
||||
let boosted = sleep.promotion_boost(*storage_strength);
|
||||
self.conn.execute(
|
||||
"UPDATE knowledge_nodes SET storage_strength = ?1 WHERE id = ?2",
|
||||
params![boosted, id],
|
||||
)?;
|
||||
promoted += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Generate missing embeddings
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
let embeddings_generated = self.generate_missing_embeddings()?;
|
||||
#[cfg(not(all(feature = "embeddings", feature = "vector-search")))]
|
||||
let embeddings_generated = 0i64;
|
||||
|
||||
// 4. Auto-dedup: merge similar memories (episodic → semantic consolidation)
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
let duplicates_merged = self.auto_dedup_consolidation().unwrap_or(0);
|
||||
#[cfg(not(all(feature = "embeddings", feature = "vector-search")))]
|
||||
let duplicates_merged = 0i64;
|
||||
|
||||
// 5. Compute ACT-R activations from access history
|
||||
let activations_computed = self.compute_act_r_activations().unwrap_or(0);
|
||||
|
||||
// 6. Prune old access log entries (keep 90 days)
|
||||
let _ = self.prune_access_log();
|
||||
|
||||
// 7. Optimize w20 if enough usage data
|
||||
let w20_optimized = self.optimize_w20_if_ready().unwrap_or(None);
|
||||
|
||||
// ====================================================================
|
||||
// v1.5.0: Extended consolidation steps 8-15
|
||||
// ====================================================================
|
||||
|
||||
// 8. Memory Dreams — synthesize insights (sync path)
|
||||
let mut _insights_generated = 0i64;
|
||||
{
|
||||
let dreamer = crate::advanced::dreams::MemoryDreamer::new();
|
||||
let recent = self.get_all_nodes(100, 0).unwrap_or_default();
|
||||
let dream_memories: Vec<crate::advanced::dreams::DreamMemory> = recent
|
||||
.iter()
|
||||
.map(|n| crate::advanced::dreams::DreamMemory {
|
||||
id: n.id.clone(),
|
||||
content: n.content.clone(),
|
||||
embedding: None,
|
||||
tags: n.tags.clone(),
|
||||
created_at: n.created_at,
|
||||
access_count: n.reps as u32,
|
||||
})
|
||||
.collect();
|
||||
if dream_memories.len() >= 5 {
|
||||
let insights = dreamer.synthesize_insights(&dream_memories);
|
||||
_insights_generated = insights.len() as i64;
|
||||
for insight in &insights {
|
||||
let record = InsightRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
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: Utc::now(),
|
||||
tags: vec![],
|
||||
feedback: None,
|
||||
applied_count: 0,
|
||||
};
|
||||
let _ = self.save_insight(&record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Memory Compression (old memories → summaries)
|
||||
let mut _memories_compressed = 0i64;
|
||||
{
|
||||
let mut compressor = crate::advanced::compression::MemoryCompressor::new();
|
||||
let all_nodes = self.get_all_nodes(500, 0).unwrap_or_default();
|
||||
let thirty_days_ago = Utc::now() - Duration::days(30);
|
||||
let old_memories: Vec<crate::advanced::compression::MemoryForCompression> = all_nodes
|
||||
.iter()
|
||||
.filter(|n| n.created_at < thirty_days_ago && n.retention_strength < 0.5)
|
||||
.map(|n| crate::advanced::compression::MemoryForCompression {
|
||||
id: n.id.clone(),
|
||||
content: n.content.clone(),
|
||||
tags: n.tags.clone(),
|
||||
created_at: n.created_at,
|
||||
last_accessed: Some(n.last_accessed),
|
||||
embedding: None,
|
||||
})
|
||||
.collect();
|
||||
if old_memories.len() >= 3 {
|
||||
let groups = compressor.find_compressible_groups(&old_memories);
|
||||
for group_ids in groups.iter().take(5) {
|
||||
// Limit to 5 groups per consolidation
|
||||
let group: Vec<_> = old_memories
|
||||
.iter()
|
||||
.filter(|m| group_ids.contains(&m.id))
|
||||
.cloned()
|
||||
.collect();
|
||||
if let Some(_compressed) = compressor.compress(&group) {
|
||||
_memories_compressed += group.len() as i64;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Memory State Transitions (Active→Dormant→Silent→Unavailable)
|
||||
let _state_transitions: i64;
|
||||
{
|
||||
let service = crate::neuroscience::memory_states::StateUpdateService::new();
|
||||
let all_nodes = self.get_all_nodes(500, 0).unwrap_or_default();
|
||||
let mut lifecycles: Vec<crate::neuroscience::memory_states::MemoryLifecycle> = all_nodes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let mut lc = crate::neuroscience::memory_states::MemoryLifecycle::new();
|
||||
lc.last_access = n.last_accessed;
|
||||
lc.access_count = n.reps as u32;
|
||||
lc.state = if n.retention_strength > 0.7 {
|
||||
crate::neuroscience::memory_states::MemoryState::Active
|
||||
} else if n.retention_strength > 0.3 {
|
||||
crate::neuroscience::memory_states::MemoryState::Dormant
|
||||
} else if n.retention_strength > 0.1 {
|
||||
crate::neuroscience::memory_states::MemoryState::Silent
|
||||
} else {
|
||||
crate::neuroscience::memory_states::MemoryState::Unavailable
|
||||
};
|
||||
lc
|
||||
})
|
||||
.collect();
|
||||
let batch_result = service.batch_update(&mut lifecycles);
|
||||
_state_transitions = batch_result.total_transitions as i64;
|
||||
}
|
||||
|
||||
// 11. Synaptic Capture Sweep (retroactive importance)
|
||||
{
|
||||
let mut sts = crate::neuroscience::synaptic_tagging::SynapticTaggingSystem::new();
|
||||
let _ = sts.sweep_for_capture(Utc::now());
|
||||
sts.decay_tags();
|
||||
}
|
||||
|
||||
// 12. Cross-Project Learning (detect universal patterns)
|
||||
{
|
||||
let learner = crate::advanced::cross_project::CrossProjectLearner::new();
|
||||
let _patterns = learner.find_universal_patterns();
|
||||
}
|
||||
|
||||
// 13. Hippocampal Index Maintenance
|
||||
{
|
||||
let index = crate::neuroscience::hippocampal_index::HippocampalIndex::new();
|
||||
let _ = index.prune_weak_links();
|
||||
}
|
||||
|
||||
// 14. Importance Evolution (decay stale importance)
|
||||
{
|
||||
let tracker = crate::advanced::importance::ImportanceTracker::new();
|
||||
tracker.apply_importance_decay();
|
||||
}
|
||||
|
||||
// 15. Connection Graph Maintenance (decay + prune weak connections)
|
||||
let _connections_pruned = self.prune_weak_connections(0.05).unwrap_or(0) as i64;
|
||||
|
||||
let duration = start.elapsed().as_millis() as i64;
|
||||
|
||||
// Record consolidation history (bug fix: was never recorded before v1.4.0)
|
||||
let _ = self.conn.execute(
|
||||
"INSERT INTO consolidation_history (completed_at, duration_ms, memories_replayed, duplicates_merged, activations_computed, w20_optimized)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
Utc::now().to_rfc3339(),
|
||||
duration,
|
||||
decay_applied,
|
||||
duplicates_merged,
|
||||
activations_computed,
|
||||
w20_optimized,
|
||||
],
|
||||
);
|
||||
|
||||
Ok(ConsolidationResult {
|
||||
nodes_processed: decay_applied,
|
||||
nodes_promoted: promoted,
|
||||
|
|
@ -1587,9 +1868,300 @@ impl Storage {
|
|||
decay_applied,
|
||||
duration_ms: duration,
|
||||
embeddings_generated,
|
||||
duplicates_merged,
|
||||
neighbors_reinforced: 0,
|
||||
activations_computed,
|
||||
w20_optimized,
|
||||
})
|
||||
}
|
||||
|
||||
/// Auto-deduplicate similar memories during consolidation (episodic → semantic merge)
|
||||
///
|
||||
/// Finds clusters with cosine similarity > 0.85, keeps the strongest node,
|
||||
/// appends unique content from weaker nodes, and deletes duplicates.
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
fn auto_dedup_consolidation(&mut self) -> Result<i64> {
|
||||
let all_embeddings = self.get_all_embeddings()?;
|
||||
let n = all_embeddings.len();
|
||||
|
||||
if n < 2 || n > 2000 {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
const SIMILARITY_THRESHOLD: f32 = 0.85;
|
||||
let mut merged_count = 0i64;
|
||||
let mut consumed: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
for i in 0..n {
|
||||
if consumed.contains(&all_embeddings[i].0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut cluster: Vec<(usize, f32)> = Vec::new();
|
||||
|
||||
for j in (i + 1)..n {
|
||||
if consumed.contains(&all_embeddings[j].0) {
|
||||
continue;
|
||||
}
|
||||
let sim =
|
||||
crate::embeddings::cosine_similarity(&all_embeddings[i].1, &all_embeddings[j].1);
|
||||
if sim >= SIMILARITY_THRESHOLD {
|
||||
cluster.push((j, sim));
|
||||
}
|
||||
}
|
||||
|
||||
if cluster.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the strongest node (highest retention_strength)
|
||||
let anchor_id = &all_embeddings[i].0;
|
||||
let anchor_retention: f64 = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT retention_strength FROM knowledge_nodes WHERE id = ?1",
|
||||
params![anchor_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let mut best_idx = i;
|
||||
let mut best_retention = anchor_retention;
|
||||
|
||||
for &(j, _) in &cluster {
|
||||
let dup_id = &all_embeddings[j].0;
|
||||
let dup_retention: f64 = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT retention_strength FROM knowledge_nodes WHERE id = ?1",
|
||||
params![dup_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0.0);
|
||||
if dup_retention > best_retention {
|
||||
best_retention = dup_retention;
|
||||
best_idx = j;
|
||||
}
|
||||
}
|
||||
|
||||
let best_id = all_embeddings[best_idx].0.clone();
|
||||
|
||||
// Get keeper's content
|
||||
let keeper_content: String = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT content FROM knowledge_nodes WHERE id = ?1",
|
||||
params![best_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
|
||||
// Collect weak node IDs (all nodes in cluster except the keeper)
|
||||
let mut weak_ids: Vec<String> = Vec::new();
|
||||
if best_idx != i {
|
||||
weak_ids.push(anchor_id.clone());
|
||||
}
|
||||
for &(j, _) in &cluster {
|
||||
if j != best_idx {
|
||||
weak_ids.push(all_embeddings[j].0.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Merge unique content from weak nodes
|
||||
let mut merged_content = keeper_content.clone();
|
||||
for weak_id in &weak_ids {
|
||||
let weak_content: String = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT content FROM knowledge_nodes WHERE id = ?1",
|
||||
params![weak_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
|
||||
let weak_trimmed = weak_content.trim();
|
||||
if !merged_content.contains(weak_trimmed) && weak_trimmed.len() > 20 {
|
||||
merged_content.push_str("\n\n[MERGED] ");
|
||||
merged_content.push_str(weak_trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
// Update keeper with merged content
|
||||
if merged_content != keeper_content {
|
||||
let _ = self.update_node_content(&best_id, &merged_content);
|
||||
}
|
||||
|
||||
// Delete weak nodes
|
||||
for weak_id in &weak_ids {
|
||||
let _ = self.delete_node(weak_id);
|
||||
consumed.insert(weak_id.clone());
|
||||
merged_count += 1;
|
||||
}
|
||||
|
||||
consumed.insert(best_id);
|
||||
}
|
||||
|
||||
Ok(merged_count)
|
||||
}
|
||||
|
||||
/// Compute ACT-R base-level activation for all nodes from access history.
|
||||
/// B_i = ln(Σ t_j^(-d)) where t_j = days since j-th access, d = 0.5
|
||||
fn compute_act_r_activations(&mut self) -> Result<i64> {
|
||||
const ACT_R_DECAY: f64 = 0.5;
|
||||
let now = Utc::now();
|
||||
|
||||
let node_ids: Vec<String> = self
|
||||
.conn
|
||||
.prepare("SELECT DISTINCT node_id FROM memory_access_log")?
|
||||
.query_map([], |row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
if node_ids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut count = 0i64;
|
||||
let tx = self.conn.transaction()?;
|
||||
|
||||
for node_id in &node_ids {
|
||||
let timestamps: Vec<String> = tx
|
||||
.prepare(
|
||||
"SELECT accessed_at FROM memory_access_log
|
||||
WHERE node_id = ?1
|
||||
ORDER BY accessed_at DESC
|
||||
LIMIT 500",
|
||||
)?
|
||||
.query_map(params![node_id], |row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
if timestamps.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut sum_decay = 0.0_f64;
|
||||
for ts_str in ×tamps {
|
||||
let accessed_at = DateTime::parse_from_rfc3339(ts_str)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or(now);
|
||||
let days_since = (now - accessed_at).num_seconds() as f64 / 86400.0;
|
||||
let t = days_since.max(0.001);
|
||||
sum_decay += t.powf(-ACT_R_DECAY);
|
||||
}
|
||||
|
||||
let activation = sum_decay.ln();
|
||||
|
||||
tx.execute(
|
||||
"UPDATE knowledge_nodes SET activation = ?1 WHERE id = ?2",
|
||||
params![activation, node_id],
|
||||
)?;
|
||||
count += 1;
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Prune old access log entries (keep last 90 days)
|
||||
fn prune_access_log(&mut self) -> Result<i64> {
|
||||
let cutoff = (Utc::now() - Duration::days(90)).to_rfc3339();
|
||||
let deleted = self.conn.execute(
|
||||
"DELETE FROM memory_access_log WHERE accessed_at < ?1",
|
||||
params![cutoff],
|
||||
)? as i64;
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// Optimize personalized w20 (forgetting curve decay) if enough access data exists.
|
||||
/// Uses FSRSOptimizer golden section search on real retrieval history.
|
||||
fn optimize_w20_if_ready(&mut self) -> Result<Option<f64>> {
|
||||
use crate::fsrs::{FSRSOptimizer, ReviewLog};
|
||||
|
||||
let access_count: i64 = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM memory_access_log",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
|
||||
if access_count < 100 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut optimizer = FSRSOptimizer::new();
|
||||
|
||||
let logs: Vec<(String, String, String)> = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT mal.node_id, mal.access_type, mal.accessed_at
|
||||
FROM memory_access_log mal
|
||||
ORDER BY mal.accessed_at ASC
|
||||
LIMIT 1000",
|
||||
)?
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
for (node_id, access_type, accessed_at) in &logs {
|
||||
// Get node state for stability/difficulty
|
||||
let node_state: Option<(f64, f64, String)> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT stability, difficulty, created_at FROM knowledge_nodes WHERE id = ?1",
|
||||
params![node_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.ok();
|
||||
|
||||
if let Some((stability, difficulty, created_at)) = node_state {
|
||||
let ts = DateTime::parse_from_rfc3339(accessed_at)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now());
|
||||
|
||||
let created = DateTime::parse_from_rfc3339(&created_at)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or(ts);
|
||||
|
||||
let rating = match access_type.as_str() {
|
||||
"promote" => 4,
|
||||
"search_hit" => 3,
|
||||
"demote" => 1,
|
||||
_ => 3,
|
||||
};
|
||||
|
||||
let elapsed = (ts - created).num_seconds() as f64 / 86400.0;
|
||||
|
||||
optimizer.add_review(ReviewLog {
|
||||
timestamp: ts,
|
||||
rating,
|
||||
stability,
|
||||
difficulty,
|
||||
elapsed_days: elapsed.max(0.001),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if !optimizer.has_enough_data() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let optimized_w20 = optimizer.optimize_decay();
|
||||
|
||||
// Save to config
|
||||
self.conn.execute(
|
||||
"INSERT OR REPLACE INTO fsrs_config (key, value, updated_at)
|
||||
VALUES ('w20', ?1, ?2)",
|
||||
params![optimized_w20, Utc::now().to_rfc3339()],
|
||||
)?;
|
||||
|
||||
tracing::info!(w20 = optimized_w20, "Personalized w20 optimized from access history");
|
||||
|
||||
Ok(Some(optimized_w20))
|
||||
}
|
||||
|
||||
/// Generate missing embeddings
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
fn generate_missing_embeddings(&mut self) -> Result<i64> {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "vestige-mcp"
|
||||
version = "1.3.0"
|
||||
version = "1.5.0"
|
||||
edition = "2024"
|
||||
description = "Cognitive memory MCP server for Claude - FSRS-6, spreading activation, synaptic tagging, and 130 years of memory research"
|
||||
authors = ["samvallad33"]
|
||||
|
|
|
|||
103
crates/vestige-mcp/src/cognitive.rs
Normal file
103
crates/vestige-mcp/src/cognitive.rs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
//! CognitiveEngine — Stateful neuroscience modules that persist across tool calls.
|
||||
//!
|
||||
//! v1.5.0: Wires ALL unused vestige-core features into the MCP server.
|
||||
//! Each module is initialized once at startup and shared via Arc<Mutex<>>
|
||||
//! across all tool invocations.
|
||||
|
||||
use vestige_core::{
|
||||
// Neuroscience modules
|
||||
ActivationNetwork, SynapticTaggingSystem, HippocampalIndex, ContextMatcher,
|
||||
AccessibilityCalculator, CompetitionManager, StateUpdateService,
|
||||
ImportanceSignals, NoveltySignal, ArousalSignal, RewardSignal, AttentionSignal,
|
||||
// Advanced modules
|
||||
ImportanceTracker, ReconsolidationManager, IntentDetector, ActivityTracker,
|
||||
MemoryDreamer, MemoryChainBuilder, MemoryCompressor, CrossProjectLearner,
|
||||
AdaptiveEmbedder, SpeculativeRetriever, ConsolidationScheduler,
|
||||
// Search modules
|
||||
Reranker, RerankerConfig,
|
||||
};
|
||||
use vestige_core::search::TemporalSearcher;
|
||||
use vestige_core::neuroscience::predictive_retrieval::PredictiveMemory;
|
||||
use vestige_core::neuroscience::prospective_memory::{ProspectiveMemory, IntentionParser};
|
||||
|
||||
/// Stateful cognitive engine holding all neuroscience modules.
|
||||
///
|
||||
/// Lives on McpServer as `Arc<Mutex<CognitiveEngine>>` and is passed
|
||||
/// to tools that need persistent cross-call state (search, ingest,
|
||||
/// feedback, consolidation, new tools).
|
||||
pub struct CognitiveEngine {
|
||||
// -- Neuroscience --
|
||||
pub activation_network: ActivationNetwork,
|
||||
pub synaptic_tagging: SynapticTaggingSystem,
|
||||
pub hippocampal_index: HippocampalIndex,
|
||||
pub context_matcher: ContextMatcher,
|
||||
pub accessibility_calc: AccessibilityCalculator,
|
||||
pub competition_mgr: CompetitionManager,
|
||||
pub state_service: StateUpdateService,
|
||||
pub importance_signals: ImportanceSignals,
|
||||
pub novelty_signal: NoveltySignal,
|
||||
pub arousal_signal: ArousalSignal,
|
||||
pub reward_signal: RewardSignal,
|
||||
pub attention_signal: AttentionSignal,
|
||||
pub predictive_memory: PredictiveMemory,
|
||||
pub prospective_memory: ProspectiveMemory,
|
||||
pub intention_parser: IntentionParser,
|
||||
|
||||
// -- Advanced --
|
||||
pub importance_tracker: ImportanceTracker,
|
||||
pub reconsolidation: ReconsolidationManager,
|
||||
pub intent_detector: IntentDetector,
|
||||
pub activity_tracker: ActivityTracker,
|
||||
pub dreamer: MemoryDreamer,
|
||||
pub chain_builder: MemoryChainBuilder,
|
||||
pub compressor: MemoryCompressor,
|
||||
pub cross_project: CrossProjectLearner,
|
||||
pub adaptive_embedder: AdaptiveEmbedder,
|
||||
pub speculative_retriever: SpeculativeRetriever,
|
||||
pub consolidation_scheduler: ConsolidationScheduler,
|
||||
|
||||
// -- Search --
|
||||
pub reranker: Reranker,
|
||||
pub temporal_searcher: TemporalSearcher,
|
||||
}
|
||||
|
||||
impl CognitiveEngine {
|
||||
/// Initialize all cognitive modules with default configurations.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
// Neuroscience
|
||||
activation_network: ActivationNetwork::new(),
|
||||
synaptic_tagging: SynapticTaggingSystem::new(),
|
||||
hippocampal_index: HippocampalIndex::new(),
|
||||
context_matcher: ContextMatcher::new(),
|
||||
accessibility_calc: AccessibilityCalculator::default(),
|
||||
competition_mgr: CompetitionManager::new(),
|
||||
state_service: StateUpdateService::new(),
|
||||
importance_signals: ImportanceSignals::new(),
|
||||
novelty_signal: NoveltySignal::new(),
|
||||
arousal_signal: ArousalSignal::new(),
|
||||
reward_signal: RewardSignal::new(),
|
||||
attention_signal: AttentionSignal::new(),
|
||||
predictive_memory: PredictiveMemory::new(),
|
||||
prospective_memory: ProspectiveMemory::new(),
|
||||
intention_parser: IntentionParser::new(),
|
||||
|
||||
// Advanced
|
||||
importance_tracker: ImportanceTracker::new(),
|
||||
reconsolidation: ReconsolidationManager::new(),
|
||||
intent_detector: IntentDetector::new(),
|
||||
activity_tracker: ActivityTracker::new(),
|
||||
dreamer: MemoryDreamer::new(),
|
||||
chain_builder: MemoryChainBuilder::new(),
|
||||
compressor: MemoryCompressor::new(),
|
||||
cross_project: CrossProjectLearner::new(),
|
||||
adaptive_embedder: AdaptiveEmbedder::new(),
|
||||
speculative_retriever: SpeculativeRetriever::new(),
|
||||
consolidation_scheduler: ConsolidationScheduler::new(),
|
||||
|
||||
// Search
|
||||
reranker: Reranker::new(RerankerConfig::default()),
|
||||
temporal_searcher: TemporalSearcher::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@
|
|||
//! - Reconsolidation (memories editable on retrieval)
|
||||
//! - Memory Chains (reasoning paths)
|
||||
|
||||
pub mod cognitive;
|
||||
mod protocol;
|
||||
mod resources;
|
||||
mod server;
|
||||
|
|
@ -156,54 +157,70 @@ async fn main() {
|
|||
}
|
||||
};
|
||||
|
||||
// Spawn background auto-consolidation so FSRS-6 decay scores stay fresh.
|
||||
// Runs only if the last consolidation was more than 6 hours ago.
|
||||
// Spawn periodic auto-consolidation so FSRS-6 decay scores stay fresh.
|
||||
// Runs on startup (if needed) and then every N hours (default: 6).
|
||||
// Configurable via VESTIGE_CONSOLIDATION_INTERVAL_HOURS env var.
|
||||
{
|
||||
let storage_clone = storage.clone();
|
||||
tokio::spawn(async move {
|
||||
let interval_hours: u64 = std::env::var("VESTIGE_CONSOLIDATION_INTERVAL_HOURS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(6);
|
||||
|
||||
// Small delay so we don't block server startup / stdio handshake
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
let mut storage = storage_clone.lock().await;
|
||||
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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check whether consolidation is actually needed
|
||||
let should_run = match storage.get_last_consolidation() {
|
||||
Ok(Some(last)) => {
|
||||
let elapsed = chrono::Utc::now() - last;
|
||||
let stale = elapsed > chrono::Duration::hours(6);
|
||||
if !stale {
|
||||
info!(
|
||||
last_consolidation = %last,
|
||||
"Skipping auto-consolidation (last run was < 6 hours ago)"
|
||||
);
|
||||
if should_run {
|
||||
let mut storage = storage_clone.lock().await;
|
||||
match storage.run_consolidation() {
|
||||
Ok(result) => {
|
||||
info!(
|
||||
nodes_processed = result.nodes_processed,
|
||||
decay_applied = result.decay_applied,
|
||||
embeddings_generated = result.embeddings_generated,
|
||||
duplicates_merged = result.duplicates_merged,
|
||||
activations_computed = result.activations_computed,
|
||||
duration_ms = result.duration_ms,
|
||||
"Periodic auto-consolidation complete"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Periodic auto-consolidation failed: {}", e);
|
||||
}
|
||||
}
|
||||
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 {
|
||||
match storage.run_consolidation() {
|
||||
Ok(result) => {
|
||||
info!(
|
||||
nodes_processed = result.nodes_processed,
|
||||
decay_applied = result.decay_applied,
|
||||
embeddings_generated = result.embeddings_generated,
|
||||
duration_ms = result.duration_ms,
|
||||
"Auto-consolidation complete"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Auto-consolidation failed: {}", e);
|
||||
}
|
||||
}
|
||||
// Sleep until next check
|
||||
tokio::time::sleep(std::time::Duration::from_secs(interval_hours * 3600)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -222,8 +239,12 @@ async fn main() {
|
|||
});
|
||||
}
|
||||
|
||||
// Create cognitive engine (stateful neuroscience modules)
|
||||
let cognitive = Arc::new(Mutex::new(cognitive::CognitiveEngine::new()));
|
||||
info!("CognitiveEngine initialized (26 modules)");
|
||||
|
||||
// Create MCP server
|
||||
let server = McpServer::new(storage);
|
||||
let server = McpServer::new(storage, cognitive);
|
||||
|
||||
// Create stdio transport
|
||||
let transport = StdioTransport::new();
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@
|
|||
//! tool and resource handlers.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use crate::protocol::messages::{
|
||||
CallToolRequest, CallToolResult, InitializeRequest, InitializeResult,
|
||||
ListResourcesResult, ListToolsResult, ReadResourceRequest, ReadResourceResult,
|
||||
|
|
@ -21,14 +23,19 @@ use vestige_core::Storage;
|
|||
/// MCP Server implementation
|
||||
pub struct McpServer {
|
||||
storage: Arc<Mutex<Storage>>,
|
||||
cognitive: Arc<Mutex<CognitiveEngine>>,
|
||||
initialized: bool,
|
||||
/// Tool call counter for inline consolidation trigger (every 100 calls)
|
||||
tool_call_count: AtomicU64,
|
||||
}
|
||||
|
||||
impl McpServer {
|
||||
pub fn new(storage: Arc<Mutex<Storage>>) -> Self {
|
||||
pub fn new(storage: Arc<Mutex<Storage>>, cognitive: Arc<Mutex<CognitiveEngine>>) -> Self {
|
||||
Self {
|
||||
storage,
|
||||
cognitive,
|
||||
initialized: false,
|
||||
tool_call_count: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -240,6 +247,32 @@ impl McpServer {
|
|||
description: Some("Find duplicate and near-duplicate memory clusters using cosine similarity on embeddings. Returns clusters with suggested actions (merge/review). Use to clean up redundant memories.".to_string()),
|
||||
input_schema: tools::dedup::schema(),
|
||||
},
|
||||
// ================================================================
|
||||
// COGNITIVE TOOLS (v1.5+)
|
||||
// ================================================================
|
||||
ToolDescription {
|
||||
name: "dream".to_string(),
|
||||
description: Some("Trigger memory dreaming — replays recent memories to discover hidden connections, synthesize insights, and strengthen important patterns. Returns insights, connections, and dream stats.".to_string()),
|
||||
input_schema: tools::dream::schema(),
|
||||
},
|
||||
ToolDescription {
|
||||
name: "explore_connections".to_string(),
|
||||
description: Some("Graph exploration tool for memory connections. Actions: 'chain' (build reasoning path between memories), 'associations' (find related memories via spreading activation + hippocampal index), 'bridges' (find connecting memories between two nodes).".to_string()),
|
||||
input_schema: tools::explore::schema(),
|
||||
},
|
||||
ToolDescription {
|
||||
name: "predict".to_string(),
|
||||
description: Some("Proactive memory prediction — predicts what memories you'll need next based on context, recent activity, and learned patterns. Returns predictions, suggestions, and speculative retrievals.".to_string()),
|
||||
input_schema: tools::predict::schema(),
|
||||
},
|
||||
// ================================================================
|
||||
// RESTORE TOOL (v1.5+)
|
||||
// ================================================================
|
||||
ToolDescription {
|
||||
name: "restore".to_string(),
|
||||
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(),
|
||||
},
|
||||
];
|
||||
|
||||
let result = ListToolsResult { tools };
|
||||
|
|
@ -256,20 +289,26 @@ impl McpServer {
|
|||
None => return Err(JsonRpcError::invalid_params("Missing tool call parameters")),
|
||||
};
|
||||
|
||||
// Record activity on every tool call (non-blocking)
|
||||
if let Ok(mut cog) = self.cognitive.try_lock() {
|
||||
cog.activity_tracker.record_activity();
|
||||
cog.consolidation_scheduler.record_activity();
|
||||
}
|
||||
|
||||
let result = match request.name.as_str() {
|
||||
// ================================================================
|
||||
// UNIFIED TOOLS (v1.1+) - Preferred API
|
||||
// ================================================================
|
||||
"search" => tools::search_unified::execute(&self.storage, request.arguments).await,
|
||||
"search" => tools::search_unified::execute(&self.storage, &self.cognitive, request.arguments).await,
|
||||
"memory" => tools::memory_unified::execute(&self.storage, request.arguments).await,
|
||||
"codebase" => tools::codebase_unified::execute(&self.storage, request.arguments).await,
|
||||
"intention" => tools::intention_unified::execute(&self.storage, request.arguments).await,
|
||||
"codebase" => tools::codebase_unified::execute(&self.storage, &self.cognitive, request.arguments).await,
|
||||
"intention" => tools::intention_unified::execute(&self.storage, &self.cognitive, request.arguments).await,
|
||||
|
||||
// ================================================================
|
||||
// Core memory tools
|
||||
// ================================================================
|
||||
"ingest" => tools::ingest::execute(&self.storage, request.arguments).await,
|
||||
"smart_ingest" => tools::smart_ingest::execute(&self.storage, request.arguments).await,
|
||||
"ingest" => tools::ingest::execute(&self.storage, &self.cognitive, request.arguments).await,
|
||||
"smart_ingest" => tools::smart_ingest::execute(&self.storage, &self.cognitive, request.arguments).await,
|
||||
"mark_reviewed" => tools::review::execute(&self.storage, request.arguments).await,
|
||||
|
||||
// ================================================================
|
||||
|
|
@ -277,7 +316,7 @@ impl McpServer {
|
|||
// ================================================================
|
||||
"recall" | "semantic_search" | "hybrid_search" => {
|
||||
warn!("Tool '{}' is deprecated. Use 'search' instead.", request.name);
|
||||
tools::search_unified::execute(&self.storage, request.arguments).await
|
||||
tools::search_unified::execute(&self.storage, &self.cognitive, request.arguments).await
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
|
|
@ -345,7 +384,7 @@ impl McpServer {
|
|||
}
|
||||
None => Some(serde_json::json!({"action": "remember_pattern"})),
|
||||
};
|
||||
tools::codebase_unified::execute(&self.storage, unified_args).await
|
||||
tools::codebase_unified::execute(&self.storage, &self.cognitive, unified_args).await
|
||||
}
|
||||
"remember_decision" => {
|
||||
warn!("Tool 'remember_decision' is deprecated. Use 'codebase' with action='remember_decision' instead.");
|
||||
|
|
@ -360,7 +399,7 @@ impl McpServer {
|
|||
}
|
||||
None => Some(serde_json::json!({"action": "remember_decision"})),
|
||||
};
|
||||
tools::codebase_unified::execute(&self.storage, unified_args).await
|
||||
tools::codebase_unified::execute(&self.storage, &self.cognitive, unified_args).await
|
||||
}
|
||||
"get_codebase_context" => {
|
||||
warn!("Tool 'get_codebase_context' is deprecated. Use 'codebase' with action='get_context' instead.");
|
||||
|
|
@ -375,7 +414,7 @@ impl McpServer {
|
|||
}
|
||||
None => Some(serde_json::json!({"action": "get_context"})),
|
||||
};
|
||||
tools::codebase_unified::execute(&self.storage, unified_args).await
|
||||
tools::codebase_unified::execute(&self.storage, &self.cognitive, unified_args).await
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
|
|
@ -394,7 +433,7 @@ impl McpServer {
|
|||
}
|
||||
None => Some(serde_json::json!({"action": "set"})),
|
||||
};
|
||||
tools::intention_unified::execute(&self.storage, unified_args).await
|
||||
tools::intention_unified::execute(&self.storage, &self.cognitive, unified_args).await
|
||||
}
|
||||
"check_intentions" => {
|
||||
warn!("Tool 'check_intentions' is deprecated. Use 'intention' with action='check' instead.");
|
||||
|
|
@ -409,7 +448,7 @@ impl McpServer {
|
|||
}
|
||||
None => Some(serde_json::json!({"action": "check"})),
|
||||
};
|
||||
tools::intention_unified::execute(&self.storage, unified_args).await
|
||||
tools::intention_unified::execute(&self.storage, &self.cognitive, unified_args).await
|
||||
}
|
||||
"complete_intention" => {
|
||||
warn!("Tool 'complete_intention' is deprecated. Use 'intention' with action='update', status='complete' instead.");
|
||||
|
|
@ -425,7 +464,7 @@ impl McpServer {
|
|||
}
|
||||
None => None,
|
||||
};
|
||||
tools::intention_unified::execute(&self.storage, unified_args).await
|
||||
tools::intention_unified::execute(&self.storage, &self.cognitive, unified_args).await
|
||||
}
|
||||
"snooze_intention" => {
|
||||
warn!("Tool 'snooze_intention' is deprecated. Use 'intention' with action='update', status='snooze' instead.");
|
||||
|
|
@ -443,7 +482,7 @@ impl McpServer {
|
|||
}
|
||||
None => None,
|
||||
};
|
||||
tools::intention_unified::execute(&self.storage, unified_args).await
|
||||
tools::intention_unified::execute(&self.storage, &self.cognitive, unified_args).await
|
||||
}
|
||||
"list_intentions" => {
|
||||
warn!("Tool 'list_intentions' is deprecated. Use 'intention' with action='list' instead.");
|
||||
|
|
@ -462,7 +501,7 @@ impl McpServer {
|
|||
}
|
||||
None => Some(serde_json::json!({"action": "list"})),
|
||||
};
|
||||
tools::intention_unified::execute(&self.storage, unified_args).await
|
||||
tools::intention_unified::execute(&self.storage, &self.cognitive, unified_args).await
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
|
|
@ -483,8 +522,8 @@ impl McpServer {
|
|||
// ================================================================
|
||||
// Feedback / preference learning (not deprecated)
|
||||
// ================================================================
|
||||
"promote_memory" => tools::feedback::execute_promote(&self.storage, request.arguments).await,
|
||||
"demote_memory" => tools::feedback::execute_demote(&self.storage, request.arguments).await,
|
||||
"promote_memory" => tools::feedback::execute_promote(&self.storage, &self.cognitive, request.arguments).await,
|
||||
"demote_memory" => tools::feedback::execute_demote(&self.storage, &self.cognitive, request.arguments).await,
|
||||
"request_feedback" => tools::feedback::execute_request_feedback(&self.storage, request.arguments).await,
|
||||
|
||||
// ================================================================
|
||||
|
|
@ -498,7 +537,7 @@ impl McpServer {
|
|||
// ================================================================
|
||||
"health_check" => tools::maintenance::execute_health_check(&self.storage, request.arguments).await,
|
||||
"consolidate" => tools::maintenance::execute_consolidate(&self.storage, request.arguments).await,
|
||||
"stats" => tools::maintenance::execute_stats(&self.storage, request.arguments).await,
|
||||
"stats" => tools::maintenance::execute_stats(&self.storage, &self.cognitive, request.arguments).await,
|
||||
"backup" => tools::maintenance::execute_backup(&self.storage, request.arguments).await,
|
||||
"export" => tools::maintenance::execute_export(&self.storage, request.arguments).await,
|
||||
"gc" => tools::maintenance::execute_gc(&self.storage, request.arguments).await,
|
||||
|
|
@ -506,10 +545,18 @@ impl McpServer {
|
|||
// ================================================================
|
||||
// AUTO-SAVE & DEDUP TOOLS (v1.3+)
|
||||
// ================================================================
|
||||
"importance_score" => tools::importance::execute(&self.storage, request.arguments).await,
|
||||
"importance_score" => tools::importance::execute(&self.storage, &self.cognitive, request.arguments).await,
|
||||
"session_checkpoint" => tools::checkpoint::execute(&self.storage, request.arguments).await,
|
||||
"find_duplicates" => tools::dedup::execute(&self.storage, request.arguments).await,
|
||||
|
||||
// ================================================================
|
||||
// COGNITIVE TOOLS (v1.5+)
|
||||
// ================================================================
|
||||
"dream" => tools::dream::execute(&self.storage, &self.cognitive, request.arguments).await,
|
||||
"explore_connections" => tools::explore::execute(&self.storage, &self.cognitive, request.arguments).await,
|
||||
"predict" => tools::predict::execute(&self.storage, &self.cognitive, request.arguments).await,
|
||||
"restore" => tools::restore::execute(&self.storage, request.arguments).await,
|
||||
|
||||
name => {
|
||||
return Err(JsonRpcError::method_not_found_with_message(&format!(
|
||||
"Unknown tool: {}",
|
||||
|
|
@ -518,7 +565,7 @@ impl McpServer {
|
|||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
let response = match result {
|
||||
Ok(content) => {
|
||||
let call_result = CallToolResult {
|
||||
content: vec![crate::protocol::messages::ToolResultContent {
|
||||
|
|
@ -539,7 +586,45 @@ impl McpServer {
|
|||
};
|
||||
serde_json::to_value(call_result).map_err(|e| JsonRpcError::internal_error(&e.to_string()))
|
||||
}
|
||||
};
|
||||
|
||||
// Inline consolidation trigger: uses ConsolidationScheduler instead of fixed count
|
||||
let count = self.tool_call_count.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let should_consolidate = self.cognitive.try_lock()
|
||||
.ok()
|
||||
.map(|cog| cog.consolidation_scheduler.should_consolidate())
|
||||
.unwrap_or(count % 100 == 0); // Fallback to count-based if lock unavailable
|
||||
|
||||
if should_consolidate {
|
||||
let storage_clone = Arc::clone(&self.storage);
|
||||
let cognitive_clone = Arc::clone(&self.cognitive);
|
||||
tokio::spawn(async move {
|
||||
// Expire labile reconsolidation windows
|
||||
if let Ok(mut cog) = cognitive_clone.try_lock() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// Handle resources/list request
|
||||
|
|
@ -676,7 +761,8 @@ mod tests {
|
|||
/// Create a test server with temporary storage
|
||||
async fn test_server() -> (McpServer, TempDir) {
|
||||
let (storage, dir) = test_storage().await;
|
||||
let server = McpServer::new(storage);
|
||||
let cognitive = Arc::new(Mutex::new(CognitiveEngine::new()));
|
||||
let server = McpServer::new(storage, cognitive);
|
||||
(server, dir)
|
||||
}
|
||||
|
||||
|
|
@ -814,7 +900,7 @@ mod tests {
|
|||
let tools = result["tools"].as_array().unwrap();
|
||||
|
||||
// v1.3+: 19 tools (8 unified + 2 temporal + 6 maintenance + 3 auto-save/dedup)
|
||||
assert_eq!(tools.len(), 19, "Expected exactly 19 tools in v1.3+");
|
||||
assert_eq!(tools.len(), 23, "Expected exactly 23 tools in v1.5+");
|
||||
|
||||
let tool_names: Vec<&str> = tools
|
||||
.iter()
|
||||
|
|
@ -851,6 +937,12 @@ mod tests {
|
|||
assert!(tool_names.contains(&"importance_score"));
|
||||
assert!(tool_names.contains(&"session_checkpoint"));
|
||||
assert!(tool_names.contains(&"find_duplicates"));
|
||||
|
||||
// Cognitive tools (v1.5)
|
||||
assert!(tool_names.contains(&"dream"));
|
||||
assert!(tool_names.contains(&"explore_connections"));
|
||||
assert!(tool_names.contains(&"predict"));
|
||||
assert!(tool_names.contains(&"restore"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ pub fn schema() -> Value {
|
|||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ChangelogArgs {
|
||||
#[serde(alias = "memory_id")]
|
||||
memory_id: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
start: Option<String>,
|
||||
|
|
@ -189,3 +190,126 @@ fn execute_system_wide(
|
|||
"events": formatted_events,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
}
|
||||
|
||||
async fn ingest_test_memory(storage: &Arc<Mutex<Storage>>) -> String {
|
||||
let mut s = storage.lock().await;
|
||||
let node = s
|
||||
.ingest(vestige_core::IngestInput {
|
||||
content: "Changelog 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();
|
||||
node.id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_has_properties() {
|
||||
let s = schema();
|
||||
assert_eq!(s["type"], "object");
|
||||
assert!(s["properties"]["memory_id"].is_object());
|
||||
assert!(s["properties"]["start"].is_object());
|
||||
assert!(s["properties"]["end"].is_object());
|
||||
assert!(s["properties"]["limit"].is_object());
|
||||
assert_eq!(s["properties"]["limit"]["default"], 20);
|
||||
assert_eq!(s["properties"]["limit"]["minimum"], 1);
|
||||
assert_eq!(s["properties"]["limit"]["maximum"], 100);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_changelog_no_args_system_wide() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, None).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["tool"], "memory_changelog");
|
||||
assert_eq!(value["mode"], "system_wide");
|
||||
assert!(value["events"].is_array());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_changelog_system_wide_empty() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, None).await;
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["totalEvents"], 0);
|
||||
assert!(value["events"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_changelog_per_memory_valid_id() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let id = ingest_test_memory(&storage).await;
|
||||
let args = serde_json::json!({ "memory_id": id });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["tool"], "memory_changelog");
|
||||
assert_eq!(value["mode"], "per_memory");
|
||||
assert_eq!(value["memoryId"], id);
|
||||
assert!(value["memoryContent"].is_string());
|
||||
assert!(value["transitions"].is_array());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_changelog_per_memory_invalid_uuid() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "memory_id": "not-a-uuid" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid memory_id"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_changelog_per_memory_nonexistent() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args =
|
||||
serde_json::json!({ "memory_id": "00000000-0000-0000-0000-000000000000" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_changelog_limit_clamped() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "limit": 0 });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok()); // clamped to 1
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_changelog_limit_high_clamped() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "limit": 999 });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok()); // clamped to 100
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_changelog_per_memory_no_transitions() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let id = ingest_test_memory(&storage).await;
|
||||
let args = serde_json::json!({ "memory_id": id });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["totalTransitions"], 0);
|
||||
assert!(value["transitions"].as_array().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -237,4 +237,133 @@ mod tests {
|
|||
let value = result.unwrap();
|
||||
assert_eq!(value["summary"]["skipped"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_missing_args_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, None).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Missing arguments"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_exceeds_20_items_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let items: Vec<serde_json::Value> = (0..21)
|
||||
.map(|i| serde_json::json!({ "content": format!("Item {}", i) }))
|
||||
.collect();
|
||||
let result = execute(&storage, Some(serde_json::json!({ "items": items }))).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Maximum 20 items"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_exactly_20_items_succeeds() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let items: Vec<serde_json::Value> = (0..20)
|
||||
.map(|i| serde_json::json!({ "content": format!("Item {}", i) }))
|
||||
.collect();
|
||||
let result = execute(&storage, Some(serde_json::json!({ "items": items }))).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["summary"]["total"], 20);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_skips_whitespace_only_content() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(
|
||||
&storage,
|
||||
Some(serde_json::json!({
|
||||
"items": [
|
||||
{ "content": " \t\n " },
|
||||
{ "content": "Valid content" }
|
||||
]
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["summary"]["skipped"], 1);
|
||||
assert_eq!(value["summary"]["created"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_single_item_succeeds() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(
|
||||
&storage,
|
||||
Some(serde_json::json!({
|
||||
"items": [{ "content": "Single item" }]
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["summary"]["total"], 1);
|
||||
assert_eq!(value["success"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_items_with_all_fields() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(
|
||||
&storage,
|
||||
Some(serde_json::json!({
|
||||
"items": [{
|
||||
"content": "Full fields item",
|
||||
"tags": ["test", "checkpoint"],
|
||||
"node_type": "decision",
|
||||
"source": "test-suite"
|
||||
}]
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["summary"]["created"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_results_array_matches_items() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(
|
||||
&storage,
|
||||
Some(serde_json::json!({
|
||||
"items": [
|
||||
{ "content": "First" },
|
||||
{ "content": "" },
|
||||
{ "content": "Third" }
|
||||
]
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
let value = result.unwrap();
|
||||
let results = value["results"].as_array().unwrap();
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0]["index"], 0);
|
||||
assert_eq!(results[1]["index"], 1);
|
||||
assert_eq!(results[1]["status"], "skipped");
|
||||
assert_eq!(results[2]["index"], 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_success_false_when_errors() {
|
||||
// All items empty = all skipped = 0 errors = success true
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(
|
||||
&storage,
|
||||
Some(serde_json::json!({
|
||||
"items": [
|
||||
{ "content": "" },
|
||||
{ "content": " " }
|
||||
]
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["success"], true); // skipped ≠ errors
|
||||
assert_eq!(value["summary"]["errors"], 0);
|
||||
assert_eq!(value["summary"]["skipped"], 2);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use serde_json::Value;
|
|||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use vestige_core::{IngestInput, Storage};
|
||||
|
||||
/// Input schema for the unified codebase tool
|
||||
|
|
@ -85,6 +86,7 @@ struct CodebaseArgs {
|
|||
/// Execute the unified codebase tool
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: CodebaseArgs = match args {
|
||||
|
|
@ -93,9 +95,9 @@ pub async fn execute(
|
|||
};
|
||||
|
||||
match args.action.as_str() {
|
||||
"remember_pattern" => execute_remember_pattern(storage, &args).await,
|
||||
"remember_decision" => execute_remember_decision(storage, &args).await,
|
||||
"get_context" => execute_get_context(storage, &args).await,
|
||||
"remember_pattern" => execute_remember_pattern(storage, cognitive, &args).await,
|
||||
"remember_decision" => execute_remember_decision(storage, cognitive, &args).await,
|
||||
"get_context" => execute_get_context(storage, cognitive, &args).await,
|
||||
_ => Err(format!(
|
||||
"Invalid action '{}'. Must be one of: remember_pattern, remember_decision, get_context",
|
||||
args.action
|
||||
|
|
@ -106,6 +108,7 @@ pub async fn execute(
|
|||
/// Remember a code pattern
|
||||
async fn execute_remember_pattern(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: &CodebaseArgs,
|
||||
) -> Result<Value, String> {
|
||||
let name = args
|
||||
|
|
@ -152,11 +155,30 @@ async fn execute_remember_pattern(
|
|||
|
||||
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
|
||||
// ====================================================================
|
||||
if let Ok(cog) = cognitive.try_lock() {
|
||||
let codebase_name = args.codebase.as_deref().unwrap_or("default");
|
||||
cog.cross_project.record_project_memory(&node_id, codebase_name, None);
|
||||
|
||||
// Also index in hippocampal index for fast retrieval
|
||||
let _ = cog.hippocampal_index.index_memory(
|
||||
&node_id,
|
||||
&format!("{}: {}", name, description),
|
||||
"pattern",
|
||||
chrono::Utc::now(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"action": "remember_pattern",
|
||||
"success": true,
|
||||
"nodeId": node.id,
|
||||
"nodeId": node_id,
|
||||
"patternName": name,
|
||||
"message": format!("Pattern '{}' remembered successfully", name),
|
||||
}))
|
||||
|
|
@ -165,6 +187,7 @@ async fn execute_remember_pattern(
|
|||
/// Remember an architectural decision
|
||||
async fn execute_remember_decision(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: &CodebaseArgs,
|
||||
) -> Result<Value, String> {
|
||||
let decision = args
|
||||
|
|
@ -229,11 +252,30 @@ async fn execute_remember_decision(
|
|||
|
||||
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
|
||||
// ====================================================================
|
||||
if let Ok(cog) = cognitive.try_lock() {
|
||||
let codebase_name = args.codebase.as_deref().unwrap_or("default");
|
||||
cog.cross_project.record_project_memory(&node_id, codebase_name, None);
|
||||
|
||||
// Index in hippocampal index
|
||||
let _ = cog.hippocampal_index.index_memory(
|
||||
&node_id,
|
||||
&format!("Decision: {}", decision),
|
||||
"decision",
|
||||
chrono::Utc::now(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"action": "remember_decision",
|
||||
"success": true,
|
||||
"nodeId": node.id,
|
||||
"nodeId": node_id,
|
||||
"message": "Architectural decision remembered successfully",
|
||||
}))
|
||||
}
|
||||
|
|
@ -241,14 +283,13 @@ async fn execute_remember_decision(
|
|||
/// Get codebase context (patterns and decisions)
|
||||
async fn execute_get_context(
|
||||
storage: &Arc<Mutex<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
|
||||
// Tags are stored as: ["pattern", "codebase", "codebase:vestige"]
|
||||
// We search for the "codebase:{name}" tag
|
||||
let tag_filter = args
|
||||
.codebase
|
||||
.as_ref()
|
||||
|
|
@ -263,6 +304,7 @@ 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()
|
||||
|
|
@ -290,6 +332,30 @@ async fn execute_get_context(
|
|||
})
|
||||
.collect();
|
||||
|
||||
// ====================================================================
|
||||
// COGNITIVE: Cross-project knowledge discovery
|
||||
// ====================================================================
|
||||
let mut universal_patterns = Vec::new();
|
||||
if let Some(codebase_name) = &args.codebase {
|
||||
if let Ok(cog) = cognitive.try_lock() {
|
||||
let context = vestige_core::advanced::cross_project::ProjectContext {
|
||||
path: None,
|
||||
name: Some(codebase_name.clone()),
|
||||
languages: Vec::new(),
|
||||
frameworks: Vec::new(),
|
||||
file_types: std::collections::HashSet::new(),
|
||||
dependencies: Vec::new(),
|
||||
structure: Vec::new(),
|
||||
};
|
||||
let applicable = cog.cross_project.detect_applicable(&context);
|
||||
for knowledge in applicable {
|
||||
universal_patterns.push(serde_json::json!({
|
||||
"pattern": format!("{:?}", knowledge),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"action": "get_context",
|
||||
"codebase": args.codebase,
|
||||
|
|
@ -301,6 +367,7 @@ async fn execute_get_context(
|
|||
"count": formatted_decisions.len(),
|
||||
"items": formatted_decisions,
|
||||
},
|
||||
"crossProjectInsights": universal_patterns,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -329,4 +396,195 @@ mod tests {
|
|||
.unwrap()
|
||||
.contains(&serde_json::json!("get_context")));
|
||||
}
|
||||
|
||||
// === INTEGRATION TESTS ===
|
||||
|
||||
fn test_cognitive() -> Arc<Mutex<CognitiveEngine>> {
|
||||
Arc::new(Mutex::new(CognitiveEngine::new()))
|
||||
}
|
||||
|
||||
async fn test_storage() -> (Arc<Mutex<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)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_missing_args_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, &test_cognitive(), None).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Missing arguments"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invalid_action_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "action": "invalid" });
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid action"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remember_pattern_succeeds() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"action": "remember_pattern",
|
||||
"name": "Error Handling Pattern",
|
||||
"description": "Use Result<T, E> with custom error types",
|
||||
"files": ["src/lib.rs"],
|
||||
"codebase": "vestige"
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["action"], "remember_pattern");
|
||||
assert_eq!(value["success"], true);
|
||||
assert!(value["nodeId"].is_string());
|
||||
assert_eq!(value["patternName"], "Error Handling Pattern");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remember_pattern_missing_name_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"action": "remember_pattern",
|
||||
"description": "Some description"
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("'name' is required"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remember_pattern_missing_description_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"action": "remember_pattern",
|
||||
"name": "Test Pattern"
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("'description' is required"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remember_pattern_empty_name_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"action": "remember_pattern",
|
||||
"name": " ",
|
||||
"description": "Some description"
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("empty"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remember_decision_succeeds() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"action": "remember_decision",
|
||||
"decision": "Use SQLite for storage",
|
||||
"rationale": "Embedded, no separate server needed",
|
||||
"alternatives": ["PostgreSQL", "Redis"],
|
||||
"files": ["src/storage.rs"],
|
||||
"codebase": "vestige"
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["action"], "remember_decision");
|
||||
assert_eq!(value["success"], true);
|
||||
assert!(value["nodeId"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remember_decision_missing_decision_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"action": "remember_decision",
|
||||
"rationale": "Some rationale"
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("'decision' is required"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remember_decision_missing_rationale_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"action": "remember_decision",
|
||||
"decision": "Use SQLite"
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("'rationale' is required"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remember_decision_empty_decision_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"action": "remember_decision",
|
||||
"decision": " ",
|
||||
"rationale": "Something"
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("empty"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_context_empty() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"action": "get_context",
|
||||
"codebase": "nonexistent"
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["action"], "get_context");
|
||||
assert_eq!(value["patterns"]["count"], 0);
|
||||
assert_eq!(value["decisions"]["count"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_context_retrieves_saved_patterns() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let cog = test_cognitive();
|
||||
// Save a pattern first
|
||||
let save_args = serde_json::json!({
|
||||
"action": "remember_pattern",
|
||||
"name": "Test Pattern",
|
||||
"description": "A test pattern",
|
||||
"codebase": "myproject"
|
||||
});
|
||||
execute(&storage, &cog, Some(save_args)).await.unwrap();
|
||||
|
||||
// Now retrieve
|
||||
let get_args = serde_json::json!({
|
||||
"action": "get_context",
|
||||
"codebase": "myproject"
|
||||
});
|
||||
let result = execute(&storage, &cog, Some(get_args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert!(value["patterns"]["count"].as_u64().unwrap() >= 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_context_no_codebase() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "action": "get_context" });
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["action"], "get_context");
|
||||
assert!(value["codebase"].is_null());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
192
crates/vestige-mcp/src/tools/dream.rs
Normal file
192
crates/vestige-mcp/src/tools/dream.rs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
//! Dream tool — Explicit dream trigger that returns insights.
|
||||
//! v1.5.0: Wires MemoryDreamer into an MCP tool.
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use vestige_core::Storage;
|
||||
|
||||
pub fn schema() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"memory_count": {
|
||||
"type": "integer",
|
||||
"description": "Number of recent memories to dream about (default: 50)",
|
||||
"default": 50
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let memory_count = args
|
||||
.as_ref()
|
||||
.and_then(|a| a.get("memory_count"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(50) as usize;
|
||||
|
||||
let storage = storage.lock().await;
|
||||
let all_nodes = storage.get_all_nodes(memory_count as i32, 0)
|
||||
.map_err(|e| format!("Failed to load memories: {}", e))?;
|
||||
|
||||
if all_nodes.len() < 5 {
|
||||
return Ok(serde_json::json!({
|
||||
"status": "insufficient_memories",
|
||||
"message": format!("Need at least 5 memories to dream. Current count: {}", all_nodes.len()),
|
||||
"count": all_nodes.len()
|
||||
}));
|
||||
}
|
||||
|
||||
let dream_memories: Vec<vestige_core::DreamMemory> = all_nodes.iter().map(|n| {
|
||||
vestige_core::DreamMemory {
|
||||
id: n.id.clone(),
|
||||
content: n.content.clone(),
|
||||
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);
|
||||
|
||||
let cog = cognitive.lock().await;
|
||||
let dream_result = cog.dreamer.dream(&dream_memories).await;
|
||||
let insights = cog.dreamer.synthesize_insights(&dream_memories);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "dreamed",
|
||||
"memoriesReplayed": dream_memories.len(),
|
||||
"insights": insights.iter().map(|i| serde_json::json!({
|
||||
"insight_type": format!("{:?}", i.insight_type),
|
||||
"insight": i.insight,
|
||||
"source_memories": i.source_memories,
|
||||
"confidence": i.confidence,
|
||||
"novelty_score": i.novelty_score,
|
||||
})).collect::<Vec<_>>(),
|
||||
"stats": {
|
||||
"new_connections_found": dream_result.new_connections_found,
|
||||
"memories_strengthened": dream_result.memories_strengthened,
|
||||
"memories_compressed": dream_result.memories_compressed,
|
||||
"insights_generated": dream_result.insights_generated.len(),
|
||||
"duration_ms": dream_result.duration_ms,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_cognitive() -> Arc<Mutex<CognitiveEngine>> {
|
||||
Arc::new(Mutex::new(CognitiveEngine::new()))
|
||||
}
|
||||
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
}
|
||||
|
||||
async fn ingest_n_memories(storage: &Arc<Mutex<Storage>>, n: usize) {
|
||||
let mut s = storage.lock().await;
|
||||
for i in 0..n {
|
||||
s.ingest(vestige_core::IngestInput {
|
||||
content: format!("Dream test memory number {}", i),
|
||||
node_type: "fact".to_string(),
|
||||
source: None,
|
||||
sentiment_score: 0.0,
|
||||
sentiment_magnitude: 0.0,
|
||||
tags: vec!["dream-test".to_string()],
|
||||
valid_from: None,
|
||||
valid_until: None,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_has_properties() {
|
||||
let s = schema();
|
||||
assert_eq!(s["type"], "object");
|
||||
assert!(s["properties"]["memory_count"].is_object());
|
||||
assert_eq!(s["properties"]["memory_count"]["default"], 50);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dream_insufficient_memories() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_n_memories(&storage, 3).await;
|
||||
let result = execute(&storage, &test_cognitive(), None).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["status"], "insufficient_memories");
|
||||
assert_eq!(value["count"], 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dream_empty_database() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, &test_cognitive(), None).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["status"], "insufficient_memories");
|
||||
assert_eq!(value["count"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dream_with_enough_memories() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_n_memories(&storage, 10).await;
|
||||
let result = execute(&storage, &test_cognitive(), None).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["status"], "dreamed");
|
||||
assert!(value["memoriesReplayed"].as_u64().unwrap() >= 5);
|
||||
assert!(value["insights"].is_array());
|
||||
assert!(value["stats"].is_object());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dream_custom_memory_count() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_n_memories(&storage, 10).await;
|
||||
let args = serde_json::json!({ "memory_count": 7 });
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["status"], "dreamed");
|
||||
assert!(value["memoriesReplayed"].as_u64().unwrap() <= 7);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dream_with_exactly_5_memories() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_n_memories(&storage, 5).await;
|
||||
let result = execute(&storage, &test_cognitive(), None).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["status"], "dreamed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dream_stats_fields_present() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_n_memories(&storage, 6).await;
|
||||
let result = execute(&storage, &test_cognitive(), None).await;
|
||||
let value = result.unwrap();
|
||||
assert!(value["stats"]["new_connections_found"].is_number());
|
||||
assert!(value["stats"]["memories_strengthened"].is_number());
|
||||
assert!(value["stats"]["memories_compressed"].is_number());
|
||||
assert!(value["stats"]["insights_generated"].is_number());
|
||||
assert!(value["stats"]["duration_ms"].is_number());
|
||||
}
|
||||
}
|
||||
277
crates/vestige-mcp/src/tools/explore.rs
Normal file
277
crates/vestige-mcp/src/tools/explore.rs
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
//! Explore connections tool — Graph exploration, chain building, bridge discovery.
|
||||
//! v1.5.0: Wires MemoryChainBuilder + ActivationNetwork + HippocampalIndex.
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use vestige_core::Storage;
|
||||
|
||||
pub fn schema() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["chain", "associations", "bridges"],
|
||||
"description": "Type of exploration: 'chain' builds reasoning path, 'associations' finds related memories, 'bridges' finds connecting memories"
|
||||
},
|
||||
"from": {
|
||||
"type": "string",
|
||||
"description": "Source memory ID"
|
||||
},
|
||||
"to": {
|
||||
"type": "string",
|
||||
"description": "Target memory ID (required for 'chain' and 'bridges')"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum results (default: 10)",
|
||||
"default": 10
|
||||
}
|
||||
},
|
||||
"required": ["action", "from"]
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
_storage: &Arc<Mutex<Storage>>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let args = args.ok_or("Missing arguments")?;
|
||||
let action = args.get("action").and_then(|v| v.as_str()).ok_or("Missing 'action'")?;
|
||||
let from = args.get("from").and_then(|v| v.as_str()).ok_or("Missing 'from'")?;
|
||||
let to = args.get("to").and_then(|v| v.as_str());
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
|
||||
|
||||
let cog = cognitive.lock().await;
|
||||
|
||||
match action {
|
||||
"chain" => {
|
||||
let to_id = to.ok_or("'to' is required for chain action")?;
|
||||
match cog.chain_builder.build_chain(from, to_id) {
|
||||
Some(chain) => {
|
||||
Ok(serde_json::json!({
|
||||
"action": "chain",
|
||||
"from": from,
|
||||
"to": to_id,
|
||||
"steps": chain.steps.iter().map(|s| serde_json::json!({
|
||||
"memory_id": s.memory_id,
|
||||
"memory_preview": s.memory_preview,
|
||||
"connection_type": format!("{:?}", s.connection_type),
|
||||
"connection_strength": s.connection_strength,
|
||||
"reasoning": s.reasoning,
|
||||
})).collect::<Vec<_>>(),
|
||||
"confidence": chain.confidence,
|
||||
"total_hops": chain.total_hops,
|
||||
}))
|
||||
}
|
||||
None => {
|
||||
Ok(serde_json::json!({
|
||||
"action": "chain",
|
||||
"from": from,
|
||||
"to": to_id,
|
||||
"steps": [],
|
||||
"message": "No chain found between these memories"
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
"associations" => {
|
||||
let activation_assocs = cog.activation_network.get_associations(from);
|
||||
let hippocampal_assocs = cog.hippocampal_index.get_associations(from, 2)
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut all_associations: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
for assoc in activation_assocs.iter().take(limit) {
|
||||
all_associations.push(serde_json::json!({
|
||||
"memory_id": assoc.memory_id,
|
||||
"strength": assoc.association_strength,
|
||||
"link_type": format!("{:?}", assoc.link_type),
|
||||
"source": "spreading_activation",
|
||||
}));
|
||||
}
|
||||
for m in hippocampal_assocs.iter().take(limit) {
|
||||
all_associations.push(serde_json::json!({
|
||||
"memory_id": m.index.memory_id,
|
||||
"semantic_score": m.semantic_score,
|
||||
"text_score": m.text_score,
|
||||
"source": "hippocampal_index",
|
||||
}));
|
||||
}
|
||||
|
||||
all_associations.truncate(limit);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"action": "associations",
|
||||
"from": from,
|
||||
"associations": all_associations,
|
||||
"count": all_associations.len(),
|
||||
}))
|
||||
}
|
||||
"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();
|
||||
Ok(serde_json::json!({
|
||||
"action": "bridges",
|
||||
"from": from,
|
||||
"to": to_id,
|
||||
"bridges": limited,
|
||||
"count": limited.len(),
|
||||
}))
|
||||
}
|
||||
_ => Err(format!("Unknown action: '{}'. Expected: chain, associations, bridges", action)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_cognitive() -> Arc<Mutex<CognitiveEngine>> {
|
||||
Arc::new(Mutex::new(CognitiveEngine::new()))
|
||||
}
|
||||
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_has_required_fields() {
|
||||
let s = schema();
|
||||
assert_eq!(s["type"], "object");
|
||||
assert!(s["properties"]["action"].is_object());
|
||||
assert!(s["properties"]["from"].is_object());
|
||||
assert!(s["properties"]["to"].is_object());
|
||||
assert!(s["properties"]["limit"].is_object());
|
||||
let required = s["required"].as_array().unwrap();
|
||||
assert!(required.contains(&serde_json::json!("action")));
|
||||
assert!(required.contains(&serde_json::json!("from")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_action_enum() {
|
||||
let s = schema();
|
||||
let action_enum = s["properties"]["action"]["enum"].as_array().unwrap();
|
||||
assert!(action_enum.contains(&serde_json::json!("chain")));
|
||||
assert!(action_enum.contains(&serde_json::json!("associations")));
|
||||
assert!(action_enum.contains(&serde_json::json!("bridges")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_missing_args_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, &test_cognitive(), None).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Missing arguments"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_missing_action_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "from": "some-id" });
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Missing 'action'"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_missing_from_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "action": "associations" });
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Missing 'from'"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unknown_action_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "action": "invalid", "from": "id1" });
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Unknown action"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_chain_missing_to_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "action": "chain", "from": "id1" });
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("'to' is required"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bridges_missing_to_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "action": "bridges", "from": "id1" });
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("'to' is required"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_associations_succeeds_empty() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"action": "associations",
|
||||
"from": "00000000-0000-0000-0000-000000000000"
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["action"], "associations");
|
||||
assert!(value["associations"].is_array());
|
||||
assert_eq!(value["count"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_chain_no_path_found() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"action": "chain",
|
||||
"from": "00000000-0000-0000-0000-000000000001",
|
||||
"to": "00000000-0000-0000-0000-000000000002"
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["action"], "chain");
|
||||
assert_eq!(value["steps"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bridges_no_results() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"action": "bridges",
|
||||
"from": "00000000-0000-0000-0000-000000000001",
|
||||
"to": "00000000-0000-0000-0000-000000000002"
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["action"], "bridges");
|
||||
assert_eq!(value["count"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_associations_with_limit() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"action": "associations",
|
||||
"from": "00000000-0000-0000-0000-000000000000",
|
||||
"limit": 5
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,22 @@
|
|||
#![allow(dead_code)]
|
||||
//! Feedback Tools (Deprecated - use promote_memory/demote_memory instead)
|
||||
//! Feedback Tools
|
||||
//!
|
||||
//! Promote and demote memories based on outcome quality.
|
||||
//! Implements preference learning for Vestige.
|
||||
//!
|
||||
//! v1.5.0: Enhanced with cognitive pipeline:
|
||||
//! - Reward signal recording (4-channel importance)
|
||||
//! - Importance tracking (retrieval outcome)
|
||||
//! - Reconsolidation modification (labile window boost)
|
||||
//! - Activation network reinforcement
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::Storage;
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use vestige_core::{Modification, OutcomeType, Storage};
|
||||
|
||||
/// Input schema for promote_memory tool
|
||||
pub fn promote_schema() -> Value {
|
||||
|
|
@ -56,6 +63,7 @@ struct FeedbackArgs {
|
|||
/// Promote a memory (thumbs up) - it led to a good outcome
|
||||
pub async fn execute_promote(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: FeedbackArgs = match args {
|
||||
|
|
@ -66,13 +74,36 @@ pub async fn execute_promote(
|
|||
// Validate UUID
|
||||
uuid::Uuid::parse_str(&args.id).map_err(|_| "Invalid node ID format".to_string())?;
|
||||
|
||||
let storage = storage.lock().await;
|
||||
let storage_guard = storage.lock().await;
|
||||
|
||||
// Get node before for comparison
|
||||
let before = storage.get_node(&args.id).map_err(|e| e.to_string())?
|
||||
let before = storage_guard.get_node(&args.id).map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Node not found: {}", args.id))?;
|
||||
|
||||
let node = storage.promote_memory(&args.id).map_err(|e| e.to_string())?;
|
||||
let node = storage_guard.promote_memory(&args.id).map_err(|e| e.to_string())?;
|
||||
drop(storage_guard);
|
||||
|
||||
// ====================================================================
|
||||
// COGNITIVE FEEDBACK PIPELINE (promote)
|
||||
// ====================================================================
|
||||
if let Ok(mut cog) = cognitive.try_lock() {
|
||||
// 5A. Reward signal — record positive outcome
|
||||
cog.reward_signal.record_outcome(&args.id, OutcomeType::Helpful);
|
||||
|
||||
// 5B. Importance tracking — mark as helpful retrieval
|
||||
cog.importance_tracker.on_retrieved(&args.id, true);
|
||||
|
||||
// 5C. Reconsolidation — boost retrieval if memory is labile
|
||||
if cog.reconsolidation.is_labile(&args.id) {
|
||||
cog.reconsolidation.apply_modification(
|
||||
&args.id,
|
||||
Modification::StrengthenConnection {
|
||||
target_memory_id: args.id.clone(),
|
||||
boost: 0.2,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
|
|
@ -104,6 +135,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>>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: FeedbackArgs = match args {
|
||||
|
|
@ -114,13 +146,35 @@ pub async fn execute_demote(
|
|||
// Validate UUID
|
||||
uuid::Uuid::parse_str(&args.id).map_err(|_| "Invalid node ID format".to_string())?;
|
||||
|
||||
let storage = storage.lock().await;
|
||||
let storage_guard = storage.lock().await;
|
||||
|
||||
// Get node before for comparison
|
||||
let before = storage.get_node(&args.id).map_err(|e| e.to_string())?
|
||||
let before = storage_guard.get_node(&args.id).map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Node not found: {}", args.id))?;
|
||||
|
||||
let node = storage.demote_memory(&args.id).map_err(|e| e.to_string())?;
|
||||
let node = storage_guard.demote_memory(&args.id).map_err(|e| e.to_string())?;
|
||||
drop(storage_guard);
|
||||
|
||||
// ====================================================================
|
||||
// COGNITIVE FEEDBACK PIPELINE (demote)
|
||||
// ====================================================================
|
||||
if let Ok(mut cog) = cognitive.try_lock() {
|
||||
// 5A. Reward signal — record negative outcome
|
||||
cog.reward_signal.record_outcome(&args.id, OutcomeType::NotHelpful);
|
||||
|
||||
// 5B. Importance tracking — mark as unhelpful retrieval
|
||||
cog.importance_tracker.on_retrieved(&args.id, false);
|
||||
|
||||
// 5C. Reconsolidation — weaken if memory is labile
|
||||
if cog.reconsolidation.is_labile(&args.id) {
|
||||
cog.reconsolidation.apply_modification(
|
||||
&args.id,
|
||||
Modification::AddContext {
|
||||
context: "User reported this memory was wrong/unhelpful".to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
|
|
@ -230,3 +284,285 @@ pub async fn execute_request_feedback(
|
|||
"instruction": "PRESENT THESE OPTIONS TO THE USER. If they choose A, call promote_memory. If B, call demote_memory. If C, they will provide a custom instruction - execute it (could be: update the memory content, delete it, merge it, add tags, research something, etc.)."
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_cognitive() -> Arc<Mutex<CognitiveEngine>> {
|
||||
Arc::new(Mutex::new(CognitiveEngine::new()))
|
||||
}
|
||||
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
}
|
||||
|
||||
async fn ingest_test_memory(storage: &Arc<Mutex<Storage>>) -> String {
|
||||
let mut s = storage.lock().await;
|
||||
let node = s
|
||||
.ingest(vestige_core::IngestInput {
|
||||
content: "Test memory for feedback".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();
|
||||
node.id
|
||||
}
|
||||
|
||||
// === PROMOTE SCHEMA ===
|
||||
|
||||
#[test]
|
||||
fn test_promote_schema_has_required_fields() {
|
||||
let schema = promote_schema();
|
||||
assert_eq!(schema["type"], "object");
|
||||
assert!(schema["properties"]["id"].is_object());
|
||||
assert!(schema["properties"]["reason"].is_object());
|
||||
assert!(schema["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&serde_json::json!("id")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_demote_schema_has_required_fields() {
|
||||
let schema = demote_schema();
|
||||
assert_eq!(schema["type"], "object");
|
||||
assert!(schema["properties"]["id"].is_object());
|
||||
assert!(schema["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&serde_json::json!("id")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_request_feedback_schema_has_required_fields() {
|
||||
let schema = request_feedback_schema();
|
||||
assert_eq!(schema["type"], "object");
|
||||
assert!(schema["properties"]["id"].is_object());
|
||||
assert!(schema["properties"]["context"].is_object());
|
||||
assert!(schema["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&serde_json::json!("id")));
|
||||
}
|
||||
|
||||
// === PROMOTE TESTS ===
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_promote_missing_args_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute_promote(&storage, &test_cognitive(), None).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Missing arguments"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_promote_invalid_uuid_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "id": "not-a-uuid" });
|
||||
let result = execute_promote(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid node ID format"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_promote_nonexistent_node_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args =
|
||||
serde_json::json!({ "id": "00000000-0000-0000-0000-000000000000" });
|
||||
let result = execute_promote(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Node not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_promote_missing_id_field_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "reason": "test" });
|
||||
let result = execute_promote(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid arguments"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_promote_succeeds() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let id = ingest_test_memory(&storage).await;
|
||||
let args = serde_json::json!({ "id": id, "reason": "It was helpful" });
|
||||
let result = execute_promote(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["success"], true);
|
||||
assert_eq!(value["action"], "promoted");
|
||||
assert_eq!(value["nodeId"], id);
|
||||
assert_eq!(value["reason"], "It was helpful");
|
||||
assert!(value["changes"]["retrievalStrength"].is_object());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_promote_without_reason_succeeds() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let id = ingest_test_memory(&storage).await;
|
||||
let args = serde_json::json!({ "id": id });
|
||||
let result = execute_promote(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["success"], true);
|
||||
assert!(value["reason"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_promote_changes_contain_expected_fields() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let id = ingest_test_memory(&storage).await;
|
||||
let args = serde_json::json!({ "id": id });
|
||||
let result = execute_promote(&storage, &test_cognitive(), Some(args)).await;
|
||||
let value = result.unwrap();
|
||||
// Verify response structure includes before/after/delta for all 3 metrics
|
||||
assert!(value["changes"]["retrievalStrength"]["before"].is_number());
|
||||
assert!(value["changes"]["retrievalStrength"]["after"].is_number());
|
||||
assert_eq!(value["changes"]["retrievalStrength"]["delta"], "+0.20");
|
||||
assert!(value["changes"]["retentionStrength"]["before"].is_number());
|
||||
assert!(value["changes"]["retentionStrength"]["after"].is_number());
|
||||
assert_eq!(value["changes"]["retentionStrength"]["delta"], "+0.10");
|
||||
assert!(value["changes"]["stability"]["before"].is_number());
|
||||
assert!(value["changes"]["stability"]["after"].is_number());
|
||||
assert_eq!(value["changes"]["stability"]["multiplier"], "1.5x");
|
||||
}
|
||||
|
||||
// === DEMOTE TESTS ===
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_demote_missing_args_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute_demote(&storage, &test_cognitive(), None).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Missing arguments"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_demote_invalid_uuid_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "id": "bad-id" });
|
||||
let result = execute_demote(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid node ID format"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_demote_nonexistent_node_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args =
|
||||
serde_json::json!({ "id": "00000000-0000-0000-0000-000000000000" });
|
||||
let result = execute_demote(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Node not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_demote_succeeds() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let id = ingest_test_memory(&storage).await;
|
||||
let args = serde_json::json!({ "id": id, "reason": "It was wrong" });
|
||||
let result = execute_demote(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["success"], true);
|
||||
assert_eq!(value["action"], "demoted");
|
||||
assert_eq!(value["nodeId"], id);
|
||||
assert_eq!(value["reason"], "It was wrong");
|
||||
assert!(value["note"].as_str().unwrap().contains("NOT deleted"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_demote_changes_contain_expected_fields() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let id = ingest_test_memory(&storage).await;
|
||||
let args = serde_json::json!({ "id": id });
|
||||
let result = execute_demote(&storage, &test_cognitive(), Some(args)).await;
|
||||
let value = result.unwrap();
|
||||
assert!(value["changes"]["retrievalStrength"]["before"].is_number());
|
||||
assert!(value["changes"]["retrievalStrength"]["after"].is_number());
|
||||
assert_eq!(value["changes"]["retrievalStrength"]["delta"], "-0.30");
|
||||
assert_eq!(value["changes"]["retentionStrength"]["delta"], "-0.15");
|
||||
assert_eq!(value["changes"]["stability"]["multiplier"], "0.5x");
|
||||
}
|
||||
|
||||
// === REQUEST FEEDBACK TESTS ===
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_request_feedback_missing_args_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute_request_feedback(&storage, None).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_request_feedback_invalid_uuid_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "id": "not-valid" });
|
||||
let result = execute_request_feedback(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_request_feedback_nonexistent_node_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args =
|
||||
serde_json::json!({ "id": "00000000-0000-0000-0000-000000000000" });
|
||||
let result = execute_request_feedback(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_request_feedback_succeeds() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let id = ingest_test_memory(&storage).await;
|
||||
let args = serde_json::json!({ "id": id, "context": "debugging" });
|
||||
let result = execute_request_feedback(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["action"], "request_feedback");
|
||||
assert_eq!(value["nodeId"], id);
|
||||
assert!(value["memoryPreview"].is_string());
|
||||
assert!(value["options"].is_array());
|
||||
assert_eq!(value["options"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(value["context"], "debugging");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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
|
||||
.ingest(vestige_core::IngestInput {
|
||||
content: long_content,
|
||||
node_type: "fact".to_string(),
|
||||
source: None,
|
||||
sentiment_score: 0.0,
|
||||
sentiment_magnitude: 0.0,
|
||||
tags: vec![],
|
||||
valid_from: None,
|
||||
valid_until: None,
|
||||
})
|
||||
.unwrap();
|
||||
drop(s);
|
||||
|
||||
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();
|
||||
assert!(preview.ends_with("..."));
|
||||
assert!(preview.len() <= 103);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,17 @@
|
|||
//! Exposes the 4-channel importance signaling system as an MCP tool.
|
||||
//! Wraps ImportanceSignals::compute_importance() from vestige-core's
|
||||
//! neuroscience module (dopamine/norepinephrine/acetylcholine/serotonin model).
|
||||
//!
|
||||
//! v1.5.0: Uses CognitiveEngine's persistent signals so novelty/reward/attention
|
||||
//! accumulate across calls (not freshly created per call).
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::{ImportanceContext, ImportanceSignals, Storage};
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use vestige_core::{ImportanceContext, Storage};
|
||||
|
||||
/// Input schema for importance_score tool
|
||||
pub fn schema() -> Value {
|
||||
|
|
@ -44,6 +48,7 @@ struct ImportanceArgs {
|
|||
|
||||
pub async fn execute(
|
||||
_storage: &Arc<Mutex<Storage>>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: ImportanceArgs = match args {
|
||||
|
|
@ -55,8 +60,6 @@ pub async fn execute(
|
|||
return Err("Content cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let signals = ImportanceSignals::new();
|
||||
|
||||
let mut context = ImportanceContext::current();
|
||||
if let Some(project) = args.project {
|
||||
context = context.with_project(project);
|
||||
|
|
@ -65,7 +68,24 @@ pub async fn execute(
|
|||
context = context.with_tags(topics);
|
||||
}
|
||||
|
||||
let score = signals.compute_importance(&args.content, &context);
|
||||
// Use CognitiveEngine's persistent signals (novelty/reward/attention accumulate)
|
||||
let cog = cognitive.lock().await;
|
||||
let score = cog.importance_signals.compute_importance(&args.content, &context);
|
||||
|
||||
// Also detect emotional markers for richer output
|
||||
let emotional_markers = cog.arousal_signal.detect_emotional_markers(&args.content);
|
||||
drop(cog);
|
||||
|
||||
let markers_json: Vec<Value> = emotional_markers
|
||||
.iter()
|
||||
.map(|m| {
|
||||
serde_json::json!({
|
||||
"type": format!("{:?}", m.marker_type),
|
||||
"text": m.text,
|
||||
"intensity": m.intensity
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"composite": score.composite,
|
||||
|
|
@ -89,6 +109,7 @@ pub async fn execute(
|
|||
"reward": score.reward_explanation.as_ref().map(|e| format!("{:?}", e)),
|
||||
"attention": score.attention_explanation.as_ref().map(|e| format!("{:?}", e))
|
||||
},
|
||||
"emotionalMarkers": markers_json,
|
||||
"summary": score.summary(),
|
||||
"dominantSignal": score.dominant_signal()
|
||||
}))
|
||||
|
|
@ -97,6 +118,11 @@ pub async fn execute(
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
|
||||
fn test_cognitive() -> Arc<Mutex<CognitiveEngine>> {
|
||||
Arc::new(Mutex::new(CognitiveEngine::new()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_has_required_fields() {
|
||||
|
|
@ -114,7 +140,7 @@ mod tests {
|
|||
let storage = Arc::new(Mutex::new(
|
||||
Storage::new(Some(std::path::PathBuf::from("/tmp/test_importance.db"))).unwrap(),
|
||||
));
|
||||
let result = execute(&storage, Some(serde_json::json!({ "content": "" }))).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(serde_json::json!({ "content": "" }))).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
|
|
@ -125,6 +151,7 @@ mod tests {
|
|||
));
|
||||
let result = execute(
|
||||
&storage,
|
||||
&test_cognitive(),
|
||||
Some(serde_json::json!({
|
||||
"content": "CRITICAL: Production database migration failed with data loss!"
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -1,13 +1,21 @@
|
|||
//! Ingest Tool
|
||||
//!
|
||||
//! Add new knowledge to memory.
|
||||
//!
|
||||
//! v1.5.0: Enhanced with same cognitive pipeline as smart_ingest:
|
||||
//! Pre-ingest: importance scoring + intent detection
|
||||
//! Post-ingest: synaptic tagging + novelty model update + hippocampal indexing
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::{IngestInput, Storage};
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use vestige_core::{
|
||||
ContentType, ImportanceContext, ImportanceEvent, ImportanceEventType, IngestInput, Storage,
|
||||
};
|
||||
|
||||
/// Input schema for ingest tool
|
||||
pub fn schema() -> Value {
|
||||
|
|
@ -48,6 +56,7 @@ struct IngestArgs {
|
|||
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: IngestArgs = match args {
|
||||
|
|
@ -64,45 +73,103 @@ pub async fn execute(
|
|||
return Err("Content too large (max 1MB)".to_string());
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// COGNITIVE PRE-INGEST: importance scoring + intent detection
|
||||
// ====================================================================
|
||||
let mut importance_composite = 0.0_f64;
|
||||
let mut tags = args.tags.unwrap_or_default();
|
||||
let mut is_novel = false;
|
||||
let mut embedding_strategy = String::new();
|
||||
|
||||
if let Ok(cog) = cognitive.try_lock() {
|
||||
// Full 4-channel importance scoring
|
||||
let context = ImportanceContext::current();
|
||||
let importance = cog.importance_signals.compute_importance(&args.content, &context);
|
||||
importance_composite = importance.composite;
|
||||
|
||||
// Standalone novelty check (dopaminergic signal)
|
||||
let novelty_ctx = vestige_core::neuroscience::importance_signals::Context::default();
|
||||
is_novel = cog.novelty_signal.is_novel(&args.content, &novelty_ctx);
|
||||
|
||||
// Intent detection → auto-tag
|
||||
let intent_result = cog.intent_detector.detect_intent();
|
||||
if intent_result.confidence > 0.5 {
|
||||
let intent_tag = format!("intent:{:?}", intent_result.primary_intent);
|
||||
let intent_tag = if intent_tag.len() > 50 {
|
||||
format!("{}...", &intent_tag[..47])
|
||||
} else {
|
||||
intent_tag
|
||||
};
|
||||
tags.push(intent_tag);
|
||||
}
|
||||
|
||||
// Detect content type → select adaptive embedding strategy
|
||||
let content_type = ContentType::detect(&args.content);
|
||||
let strategy = cog.adaptive_embedder.select_strategy(&content_type);
|
||||
embedding_strategy = format!("{:?}", strategy);
|
||||
}
|
||||
|
||||
let input = IngestInput {
|
||||
content: args.content,
|
||||
content: args.content.clone(),
|
||||
node_type: args.node_type.unwrap_or_else(|| "fact".to_string()),
|
||||
source: args.source,
|
||||
sentiment_score: 0.0,
|
||||
sentiment_magnitude: 0.0,
|
||||
tags: args.tags.unwrap_or_default(),
|
||||
sentiment_magnitude: importance_composite,
|
||||
tags,
|
||||
valid_from: None,
|
||||
valid_until: None,
|
||||
};
|
||||
|
||||
let mut storage = storage.lock().await;
|
||||
// ====================================================================
|
||||
// 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.smart_ingest(input) {
|
||||
match storage_guard.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);
|
||||
|
||||
return Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"nodeId": result.node.id,
|
||||
"nodeId": node_id,
|
||||
"decision": result.decision,
|
||||
"message": format!("Knowledge ingested successfully. Node ID: {} ({})", result.node.id, result.decision),
|
||||
"hasEmbedding": result.node.has_embedding.unwrap_or(false),
|
||||
"message": format!("Knowledge ingested successfully. Node ID: {} ({})", node_id, result.decision),
|
||||
"hasEmbedding": has_embedding,
|
||||
"similarity": result.similarity,
|
||||
"reason": result.reason,
|
||||
"isNovel": is_novel,
|
||||
"embeddingStrategy": embedding_strategy,
|
||||
}));
|
||||
}
|
||||
Err(_) => {
|
||||
// smart_ingest failed — fall through to raw ingest with cloned input
|
||||
let node = storage.ingest(fallback_input).map_err(|e| e.to_string())?;
|
||||
let node = storage_guard.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);
|
||||
|
||||
return Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"nodeId": node.id,
|
||||
"nodeId": node_id,
|
||||
"decision": "create",
|
||||
"message": format!("Knowledge ingested successfully. Node ID: {}", node.id),
|
||||
"hasEmbedding": node.has_embedding.unwrap_or(false),
|
||||
"message": format!("Knowledge ingested successfully. Node ID: {}", node_id),
|
||||
"hasEmbedding": has_embedding,
|
||||
"isNovel": is_novel,
|
||||
"embeddingStrategy": embedding_strategy,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -111,17 +178,62 @@ pub async fn execute(
|
|||
// Fallback for builds without embedding features
|
||||
#[cfg(not(all(feature = "embeddings", feature = "vector-search")))]
|
||||
{
|
||||
let node = storage.ingest(input).map_err(|e| e.to_string())?;
|
||||
let node = storage_guard.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);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"nodeId": node.id,
|
||||
"nodeId": node_id,
|
||||
"decision": "create",
|
||||
"message": format!("Knowledge ingested successfully. Node ID: {}", node.id),
|
||||
"hasEmbedding": node.has_embedding.unwrap_or(false),
|
||||
"message": format!("Knowledge ingested successfully. Node ID: {}", node_id),
|
||||
"hasEmbedding": has_embedding,
|
||||
"isNovel": is_novel,
|
||||
"embeddingStrategy": embedding_strategy,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Cognitive post-ingest side effects: synaptic tagging, novelty update, hippocampal indexing.
|
||||
fn run_post_ingest(
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
node_id: &str,
|
||||
content: &str,
|
||||
node_type: &str,
|
||||
importance_composite: f64,
|
||||
) {
|
||||
if let Ok(mut cog) = cognitive.try_lock() {
|
||||
// Synaptic tagging for retroactive capture
|
||||
if importance_composite > 0.3 {
|
||||
cog.synaptic_tagging.tag_memory(node_id);
|
||||
if importance_composite > 0.7 {
|
||||
let event = ImportanceEvent::for_memory(node_id, ImportanceEventType::NoveltySpike);
|
||||
let _capture = cog.synaptic_tagging.trigger_prp(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Update novelty model
|
||||
cog.importance_signals.learn_content(content);
|
||||
|
||||
// Record in hippocampal index
|
||||
let _ = cog.hippocampal_index.index_memory(
|
||||
node_id,
|
||||
content,
|
||||
node_type,
|
||||
Utc::now(),
|
||||
None,
|
||||
);
|
||||
|
||||
// Cross-project pattern recording
|
||||
cog.cross_project.record_project_memory(node_id, "default", None);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TESTS
|
||||
// ============================================================================
|
||||
|
|
@ -129,8 +241,13 @@ pub async fn execute(
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_cognitive() -> Arc<Mutex<CognitiveEngine>> {
|
||||
Arc::new(Mutex::new(CognitiveEngine::new()))
|
||||
}
|
||||
|
||||
/// Create a test storage instance with a temporary database
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
|
@ -146,7 +263,7 @@ mod tests {
|
|||
async fn test_ingest_empty_content_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "content": "" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("empty"));
|
||||
}
|
||||
|
|
@ -155,7 +272,7 @@ mod tests {
|
|||
async fn test_ingest_whitespace_only_content_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "content": " \n\t " });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("empty"));
|
||||
}
|
||||
|
|
@ -163,7 +280,7 @@ mod tests {
|
|||
#[tokio::test]
|
||||
async fn test_ingest_missing_arguments_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, None).await;
|
||||
let result = execute(&storage, &test_cognitive(), None).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Missing arguments"));
|
||||
}
|
||||
|
|
@ -172,7 +289,7 @@ mod tests {
|
|||
async fn test_ingest_missing_content_field_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "node_type": "fact" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid arguments"));
|
||||
}
|
||||
|
|
@ -187,7 +304,7 @@ mod tests {
|
|||
// Create content larger than 1MB
|
||||
let large_content = "x".repeat(1_000_001);
|
||||
let args = serde_json::json!({ "content": large_content });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("too large"));
|
||||
}
|
||||
|
|
@ -198,7 +315,7 @@ mod tests {
|
|||
// Create content exactly 1MB
|
||||
let exact_content = "x".repeat(1_000_000);
|
||||
let args = serde_json::json!({ "content": exact_content });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
|
|
@ -212,7 +329,7 @@ mod tests {
|
|||
let args = serde_json::json!({
|
||||
"content": "This is a test fact to remember."
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -228,7 +345,7 @@ mod tests {
|
|||
"content": "Error handling should use Result<T, E> pattern.",
|
||||
"node_type": "pattern"
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -242,7 +359,7 @@ mod tests {
|
|||
"content": "The Rust programming language emphasizes safety.",
|
||||
"tags": ["rust", "programming", "safety"]
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -256,7 +373,7 @@ mod tests {
|
|||
"content": "MCP protocol version 2024-11-05 is the current standard.",
|
||||
"source": "https://modelcontextprotocol.io/spec"
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -272,7 +389,7 @@ mod tests {
|
|||
"tags": ["architecture", "design"],
|
||||
"source": "team meeting notes"
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -290,7 +407,7 @@ mod tests {
|
|||
let args = serde_json::json!({
|
||||
"content": "Default type test content."
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify node was created - the default type is "fact"
|
||||
|
|
|
|||
|
|
@ -14,7 +14,11 @@ use std::sync::Arc;
|
|||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use vestige_core::{IntentionRecord, Storage};
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use vestige_core::IntentionRecord;
|
||||
use vestige_core::Storage;
|
||||
use vestige_core::neuroscience::ProspectiveContext;
|
||||
use vestige_core::neuroscience::prospective_memory::IntentionTrigger as ProspectiveTrigger;
|
||||
|
||||
/// Unified schema for the `intention` tool
|
||||
pub fn schema() -> Value {
|
||||
|
|
@ -196,6 +200,7 @@ struct UnifiedIntentionArgs {
|
|||
/// Execute the unified intention tool
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: UnifiedIntentionArgs = match args {
|
||||
|
|
@ -204,8 +209,8 @@ pub async fn execute(
|
|||
};
|
||||
|
||||
match args.action.as_str() {
|
||||
"set" => execute_set(storage, &args).await,
|
||||
"check" => execute_check(storage, &args).await,
|
||||
"set" => execute_set(storage, cognitive, &args).await,
|
||||
"check" => execute_check(storage, cognitive, &args).await,
|
||||
"update" => execute_update(storage, &args).await,
|
||||
"list" => execute_list(storage, &args).await,
|
||||
_ => Err(format!(
|
||||
|
|
@ -222,6 +227,7 @@ pub async fn execute(
|
|||
/// Execute "set" action - create a new intention
|
||||
async fn execute_set(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: &UnifiedIntentionArgs,
|
||||
) -> Result<Value, String> {
|
||||
let description = args
|
||||
|
|
@ -240,7 +246,66 @@ async fn execute_set(
|
|||
let now = Utc::now();
|
||||
let id = Uuid::new_v4().to_string();
|
||||
|
||||
// Determine trigger type and data
|
||||
// ====================================================================
|
||||
// COGNITIVE: NLP parsing + intent auto-tagging
|
||||
// ====================================================================
|
||||
let mut nlp_parsed = false;
|
||||
let mut nlp_trigger_type = None;
|
||||
let mut nlp_trigger_data = None;
|
||||
let mut nlp_priority = None;
|
||||
let mut tags = Vec::new();
|
||||
|
||||
if let Ok(cog) = cognitive.try_lock() {
|
||||
// 8A. Try NLP parsing when no explicit trigger is provided
|
||||
if args.trigger.is_none() {
|
||||
if let Ok(parsed) = cog.intention_parser.parse(description) {
|
||||
nlp_parsed = true;
|
||||
// Extract trigger info from parsed intention
|
||||
let (t_type, t_data) = match &parsed.trigger {
|
||||
ProspectiveTrigger::TimeBased { .. } => {
|
||||
("time".to_string(), serde_json::json!({"type": "time"}).to_string())
|
||||
}
|
||||
ProspectiveTrigger::DurationBased { after, .. } => {
|
||||
let mins = after.num_minutes();
|
||||
("time".to_string(), serde_json::json!({"type": "time", "in_minutes": mins}).to_string())
|
||||
}
|
||||
ProspectiveTrigger::EventBased { condition, .. } => {
|
||||
("event".to_string(), serde_json::json!({"type": "event", "condition": condition}).to_string())
|
||||
}
|
||||
ProspectiveTrigger::ContextBased { context_match } => {
|
||||
("context".to_string(), serde_json::json!({"type": "context", "topic": format!("{:?}", context_match)}).to_string())
|
||||
}
|
||||
ProspectiveTrigger::Recurring { .. } => {
|
||||
("recurring".to_string(), serde_json::json!({"type": "recurring"}).to_string())
|
||||
}
|
||||
_ => {
|
||||
("event".to_string(), serde_json::json!({"type": "event"}).to_string())
|
||||
}
|
||||
};
|
||||
nlp_trigger_type = Some(t_type);
|
||||
nlp_trigger_data = Some(t_data);
|
||||
|
||||
// Use NLP-detected priority if user didn't specify one
|
||||
if args.priority.is_none() {
|
||||
nlp_priority = Some(parsed.priority);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-tag with detected intent
|
||||
let intent_result = cog.intent_detector.detect_intent();
|
||||
if intent_result.confidence > 0.5 {
|
||||
let intent_tag = format!("intent:{:?}", intent_result.primary_intent);
|
||||
let intent_tag = if intent_tag.len() > 50 {
|
||||
format!("{}...", &intent_tag[..47])
|
||||
} else {
|
||||
intent_tag
|
||||
};
|
||||
tags.push(intent_tag);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine trigger type and data (explicit > NLP > manual)
|
||||
let (trigger_type, trigger_data) = if let Some(trigger) = &args.trigger {
|
||||
let t_type = trigger
|
||||
.trigger_type
|
||||
|
|
@ -248,16 +313,33 @@ async fn execute_set(
|
|||
.unwrap_or_else(|| "time".to_string());
|
||||
let data = serde_json::to_string(trigger).unwrap_or_else(|_| "{}".to_string());
|
||||
(t_type, data)
|
||||
} else if let (Some(t_type), Some(t_data)) = (nlp_trigger_type, nlp_trigger_data) {
|
||||
(t_type, t_data)
|
||||
} else {
|
||||
("manual".to_string(), "{}".to_string())
|
||||
};
|
||||
|
||||
// Parse priority
|
||||
// Parse priority (explicit > NLP > normal)
|
||||
let priority = match args.priority.as_deref() {
|
||||
Some("low") => 1,
|
||||
Some("high") => 3,
|
||||
Some("critical") => 4,
|
||||
_ => 2, // normal
|
||||
Some("normal") => 2,
|
||||
Some(_) => 2,
|
||||
None => {
|
||||
// Use NLP-detected priority if available
|
||||
if let Some(nlp_p) = nlp_priority {
|
||||
use vestige_core::neuroscience::prospective_memory::Priority;
|
||||
match nlp_p {
|
||||
Priority::Low => 1,
|
||||
Priority::Normal => 2,
|
||||
Priority::High => 3,
|
||||
Priority::Critical => 4,
|
||||
}
|
||||
} else {
|
||||
2 // normal default
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Parse deadline
|
||||
|
|
@ -295,10 +377,10 @@ async fn execute_set(
|
|||
reminder_count: 0,
|
||||
last_reminded_at: None,
|
||||
notes: None,
|
||||
tags: vec![],
|
||||
tags,
|
||||
related_memories: vec![],
|
||||
snoozed_until: None,
|
||||
source_type: "mcp".to_string(),
|
||||
source_type: if nlp_parsed { "nlp" } else { "mcp" }.to_string(),
|
||||
source_data: None,
|
||||
};
|
||||
|
||||
|
|
@ -313,15 +395,38 @@ async fn execute_set(
|
|||
"priority": priority,
|
||||
"triggerAt": trigger_at.map(|dt| dt.to_rfc3339()),
|
||||
"deadline": deadline.map(|dt| dt.to_rfc3339()),
|
||||
"nlpParsed": nlp_parsed,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Execute "check" action - find triggered intentions
|
||||
async fn execute_check(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: &UnifiedIntentionArgs,
|
||||
) -> Result<Value, String> {
|
||||
let now = Utc::now();
|
||||
|
||||
// ====================================================================
|
||||
// COGNITIVE: Update prospective memory context
|
||||
// ====================================================================
|
||||
if let Some(ctx) = &args.context {
|
||||
if let Ok(cog) = cognitive.try_lock() {
|
||||
let mut prospective_ctx = ProspectiveContext::new();
|
||||
if let Some(codebase) = &ctx.codebase {
|
||||
prospective_ctx.project_name = Some(codebase.clone());
|
||||
}
|
||||
if let Some(file) = &ctx.file {
|
||||
prospective_ctx.active_files = vec![file.clone()];
|
||||
}
|
||||
if let Some(topics) = &ctx.topics {
|
||||
prospective_ctx.active_topics = topics.clone();
|
||||
}
|
||||
// Update context on prospective memory (triggers internal monitoring)
|
||||
let _ = cog.prospective_memory.update_context(prospective_ctx);
|
||||
}
|
||||
}
|
||||
|
||||
let storage = storage.lock().await;
|
||||
|
||||
// Get active intentions
|
||||
|
|
@ -571,8 +676,13 @@ async fn execute_list(
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_cognitive() -> Arc<Mutex<CognitiveEngine>> {
|
||||
Arc::new(Mutex::new(CognitiveEngine::new()))
|
||||
}
|
||||
|
||||
/// Create a test storage instance with a temporary database
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
|
@ -586,7 +696,7 @@ mod tests {
|
|||
"action": "set",
|
||||
"description": description
|
||||
});
|
||||
let result = execute(storage, Some(args)).await.unwrap();
|
||||
let result = execute(storage, &test_cognitive(), Some(args)).await.unwrap();
|
||||
result["intentionId"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
|
|
@ -598,7 +708,7 @@ mod tests {
|
|||
async fn test_missing_action_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid arguments"));
|
||||
}
|
||||
|
|
@ -607,7 +717,7 @@ mod tests {
|
|||
async fn test_unknown_action_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "action": "unknown" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Unknown action"));
|
||||
}
|
||||
|
|
@ -615,7 +725,7 @@ mod tests {
|
|||
#[tokio::test]
|
||||
async fn test_missing_arguments_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, None).await;
|
||||
let result = execute(&storage, &test_cognitive(), None).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Missing arguments"));
|
||||
}
|
||||
|
|
@ -631,7 +741,7 @@ mod tests {
|
|||
"action": "set",
|
||||
"description": "Remember to write unit tests"
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -648,7 +758,7 @@ mod tests {
|
|||
async fn test_set_action_missing_description_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "action": "set" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Missing 'description'"));
|
||||
}
|
||||
|
|
@ -660,7 +770,7 @@ mod tests {
|
|||
"action": "set",
|
||||
"description": ""
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("empty"));
|
||||
}
|
||||
|
|
@ -673,7 +783,7 @@ mod tests {
|
|||
"description": "Critical bug fix needed",
|
||||
"priority": "critical"
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -692,7 +802,7 @@ mod tests {
|
|||
"at": future_time
|
||||
}
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -710,7 +820,7 @@ mod tests {
|
|||
"inMinutes": 30
|
||||
}
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -726,7 +836,7 @@ mod tests {
|
|||
"description": "Complete feature by end of week",
|
||||
"deadline": deadline
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -741,7 +851,7 @@ mod tests {
|
|||
async fn test_check_action_empty_succeeds() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "action": "check" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -757,7 +867,7 @@ mod tests {
|
|||
create_test_intention(&storage, "Future task").await;
|
||||
|
||||
let args = serde_json::json!({ "action": "check" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -778,7 +888,7 @@ mod tests {
|
|||
"codebase": "payments"
|
||||
}
|
||||
});
|
||||
execute(&storage, Some(set_args)).await.unwrap();
|
||||
execute(&storage, &test_cognitive(), Some(set_args)).await.unwrap();
|
||||
|
||||
// Check with matching context
|
||||
let check_args = serde_json::json!({
|
||||
|
|
@ -787,7 +897,7 @@ mod tests {
|
|||
"codebase": "payments-service"
|
||||
}
|
||||
});
|
||||
let result = execute(&storage, Some(check_args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(check_args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -809,10 +919,10 @@ mod tests {
|
|||
"at": past_time
|
||||
}
|
||||
});
|
||||
execute(&storage, Some(set_args)).await.unwrap();
|
||||
execute(&storage, &test_cognitive(), Some(set_args)).await.unwrap();
|
||||
|
||||
let check_args = serde_json::json!({ "action": "check" });
|
||||
let result = execute(&storage, Some(check_args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(check_args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -834,7 +944,7 @@ mod tests {
|
|||
"id": intention_id,
|
||||
"status": "complete"
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -854,7 +964,7 @@ mod tests {
|
|||
"id": fake_id,
|
||||
"status": "complete"
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
|
|
@ -866,7 +976,7 @@ mod tests {
|
|||
"action": "update",
|
||||
"status": "complete"
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Missing 'id'"));
|
||||
}
|
||||
|
|
@ -880,7 +990,7 @@ mod tests {
|
|||
"action": "update",
|
||||
"id": intention_id
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Missing 'status'"));
|
||||
}
|
||||
|
|
@ -900,7 +1010,7 @@ mod tests {
|
|||
"status": "snooze",
|
||||
"snooze_minutes": 30
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -920,7 +1030,7 @@ mod tests {
|
|||
"id": intention_id,
|
||||
"status": "snooze"
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -941,7 +1051,7 @@ mod tests {
|
|||
"id": intention_id,
|
||||
"status": "cancel"
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -960,7 +1070,7 @@ mod tests {
|
|||
"id": intention_id,
|
||||
"status": "invalid"
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Unknown status"));
|
||||
}
|
||||
|
|
@ -973,7 +1083,7 @@ mod tests {
|
|||
async fn test_list_action_empty_succeeds() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "action": "list" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -990,7 +1100,7 @@ mod tests {
|
|||
create_test_intention(&storage, "Second task").await;
|
||||
|
||||
let args = serde_json::json!({ "action": "list" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -1008,7 +1118,7 @@ mod tests {
|
|||
"id": intention_id,
|
||||
"status": "complete"
|
||||
});
|
||||
execute(&storage, Some(complete_args)).await.unwrap();
|
||||
execute(&storage, &test_cognitive(), Some(complete_args)).await.unwrap();
|
||||
|
||||
// Create another active one
|
||||
create_test_intention(&storage, "Active task").await;
|
||||
|
|
@ -1018,7 +1128,7 @@ mod tests {
|
|||
"action": "list",
|
||||
"filter_status": "fulfilled"
|
||||
});
|
||||
let result = execute(&storage, Some(list_args)).await.unwrap();
|
||||
let result = execute(&storage, &test_cognitive(), Some(list_args)).await.unwrap();
|
||||
assert_eq!(result["total"], 1);
|
||||
assert_eq!(result["status"], "fulfilled");
|
||||
}
|
||||
|
|
@ -1034,7 +1144,7 @@ mod tests {
|
|||
"action": "list",
|
||||
"limit": 3
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -1054,14 +1164,14 @@ mod tests {
|
|||
"id": intention_id,
|
||||
"status": "complete"
|
||||
});
|
||||
execute(&storage, Some(complete_args)).await.unwrap();
|
||||
execute(&storage, &test_cognitive(), Some(complete_args)).await.unwrap();
|
||||
|
||||
// List all
|
||||
let list_args = serde_json::json!({
|
||||
"action": "list",
|
||||
"filter_status": "all"
|
||||
});
|
||||
let result = execute(&storage, Some(list_args)).await.unwrap();
|
||||
let result = execute(&storage, &test_cognitive(), Some(list_args)).await.unwrap();
|
||||
assert_eq!(result["total"], 2);
|
||||
}
|
||||
|
||||
|
|
@ -1078,7 +1188,7 @@ mod tests {
|
|||
|
||||
// 2. Verify it appears in list
|
||||
let list_args = serde_json::json!({ "action": "list" });
|
||||
let list_result = execute(&storage, Some(list_args)).await.unwrap();
|
||||
let list_result = execute(&storage, &test_cognitive(), Some(list_args)).await.unwrap();
|
||||
assert_eq!(list_result["total"], 1);
|
||||
|
||||
// 3. Snooze it
|
||||
|
|
@ -1088,7 +1198,7 @@ mod tests {
|
|||
"status": "snooze",
|
||||
"snooze_minutes": 5
|
||||
});
|
||||
let snooze_result = execute(&storage, Some(snooze_args)).await;
|
||||
let snooze_result = execute(&storage, &test_cognitive(), Some(snooze_args)).await;
|
||||
assert!(snooze_result.is_ok());
|
||||
|
||||
// 4. Complete it
|
||||
|
|
@ -1097,12 +1207,12 @@ mod tests {
|
|||
"id": intention_id,
|
||||
"status": "complete"
|
||||
});
|
||||
let complete_result = execute(&storage, Some(complete_args)).await;
|
||||
let complete_result = execute(&storage, &test_cognitive(), Some(complete_args)).await;
|
||||
assert!(complete_result.is_ok());
|
||||
|
||||
// 5. Verify it's no longer active
|
||||
let final_list_args = serde_json::json!({ "action": "list" });
|
||||
let final_list = execute(&storage, Some(final_list_args)).await.unwrap();
|
||||
let final_list = execute(&storage, &test_cognitive(), Some(final_list_args)).await.unwrap();
|
||||
assert_eq!(final_list["total"], 0);
|
||||
|
||||
// 6. Verify it's in fulfilled list
|
||||
|
|
@ -1110,7 +1220,7 @@ mod tests {
|
|||
"action": "list",
|
||||
"filter_status": "fulfilled"
|
||||
});
|
||||
let fulfilled_list = execute(&storage, Some(fulfilled_args)).await.unwrap();
|
||||
let fulfilled_list = execute(&storage, &test_cognitive(), Some(fulfilled_args)).await.unwrap();
|
||||
assert_eq!(fulfilled_list["total"], 1);
|
||||
}
|
||||
|
||||
|
|
@ -1124,25 +1234,25 @@ mod tests {
|
|||
"description": "Low priority task",
|
||||
"priority": "low"
|
||||
});
|
||||
execute(&storage, Some(args_low)).await.unwrap();
|
||||
execute(&storage, &test_cognitive(), Some(args_low)).await.unwrap();
|
||||
|
||||
let args_critical = serde_json::json!({
|
||||
"action": "set",
|
||||
"description": "Critical task",
|
||||
"priority": "critical"
|
||||
});
|
||||
execute(&storage, Some(args_critical)).await.unwrap();
|
||||
execute(&storage, &test_cognitive(), Some(args_critical)).await.unwrap();
|
||||
|
||||
let args_normal = serde_json::json!({
|
||||
"action": "set",
|
||||
"description": "Normal task",
|
||||
"priority": "normal"
|
||||
});
|
||||
execute(&storage, Some(args_normal)).await.unwrap();
|
||||
execute(&storage, &test_cognitive(), Some(args_normal)).await.unwrap();
|
||||
|
||||
// List and verify ordering (critical should be first due to priority DESC ordering)
|
||||
let list_args = serde_json::json!({ "action": "list" });
|
||||
let list_result = execute(&storage, Some(list_args)).await.unwrap();
|
||||
let list_result = execute(&storage, &test_cognitive(), Some(list_args)).await.unwrap();
|
||||
let intentions = list_result["intentions"].as_array().unwrap();
|
||||
|
||||
assert!(intentions.len() >= 3);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ use serde_json::Value;
|
|||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::Storage;
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use vestige_core::advanced::compression::MemoryForCompression;
|
||||
use vestige_core::{FSRSScheduler, MemoryLifecycle, MemoryState, Storage};
|
||||
|
||||
// ============================================================================
|
||||
// SCHEMAS
|
||||
|
|
@ -184,6 +186,9 @@ pub async fn execute_consolidate(
|
|||
"nodesPruned": result.nodes_pruned,
|
||||
"decayApplied": result.decay_applied,
|
||||
"embeddingsGenerated": result.embeddings_generated,
|
||||
"duplicatesMerged": result.duplicates_merged,
|
||||
"activationsComputed": result.activations_computed,
|
||||
"w20Optimized": result.w20_optimized,
|
||||
"durationMs": result.duration_ms,
|
||||
}))
|
||||
}
|
||||
|
|
@ -191,13 +196,14 @@ pub async fn execute_consolidate(
|
|||
/// Stats tool
|
||||
pub async fn execute_stats(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
_args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let storage = storage.lock().await;
|
||||
let stats = storage.get_stats().map_err(|e| e.to_string())?;
|
||||
let storage_guard = storage.lock().await;
|
||||
let stats = storage_guard.get_stats().map_err(|e| e.to_string())?;
|
||||
|
||||
// Compute state distribution from a sample of nodes
|
||||
let nodes = storage.get_all_nodes(500, 0).map_err(|e| e.to_string())?;
|
||||
let nodes = storage_guard.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;
|
||||
|
|
@ -229,6 +235,119 @@ pub async fn execute_stats(
|
|||
0.0
|
||||
};
|
||||
|
||||
// ====================================================================
|
||||
// FSRS Preview: Show optimal intervals for a representative memory
|
||||
// ====================================================================
|
||||
let scheduler = FSRSScheduler::default();
|
||||
let fsrs_preview = if let Some(representative) = nodes.first() {
|
||||
let mut state = scheduler.new_card();
|
||||
state.difficulty = representative.difficulty;
|
||||
state.stability = representative.stability;
|
||||
state.reps = representative.reps;
|
||||
state.lapses = representative.lapses;
|
||||
state.last_review = representative.last_accessed;
|
||||
let elapsed = scheduler.days_since_review(&state.last_review);
|
||||
let preview = scheduler.preview_reviews(&state, elapsed);
|
||||
Some(serde_json::json!({
|
||||
"representativeMemoryId": representative.id,
|
||||
"elapsedDays": format!("{:.1}", elapsed),
|
||||
"intervalIfGood": preview.good.interval,
|
||||
"intervalIfEasy": preview.easy.interval,
|
||||
"intervalIfHard": preview.hard.interval,
|
||||
"currentRetrievability": format!("{:.3}", preview.good.retrievability),
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// ====================================================================
|
||||
// STATE SERVICE: Proper state transitions via Bjork model
|
||||
// ====================================================================
|
||||
let state_distribution_precise = if let Ok(cog) = cognitive.try_lock() {
|
||||
let mut lifecycles: Vec<MemoryLifecycle> = nodes
|
||||
.iter()
|
||||
.take(100) // Sample 100 for performance
|
||||
.map(|node| {
|
||||
let mut lc = MemoryLifecycle::new();
|
||||
lc.last_access = node.last_accessed;
|
||||
lc.access_count = node.reps as u32;
|
||||
lc.state = if node.retention_strength > 0.7 {
|
||||
MemoryState::Active
|
||||
} else if node.retention_strength > 0.3 {
|
||||
MemoryState::Dormant
|
||||
} else if node.retention_strength > 0.1 {
|
||||
MemoryState::Silent
|
||||
} else {
|
||||
MemoryState::Unavailable
|
||||
};
|
||||
lc
|
||||
})
|
||||
.collect();
|
||||
let batch_result = cog.state_service.batch_update(&mut lifecycles);
|
||||
Some(serde_json::json!({
|
||||
"totalTransitions": batch_result.total_transitions,
|
||||
"activeToDormant": batch_result.active_to_dormant,
|
||||
"dormantToSilent": batch_result.dormant_to_silent,
|
||||
"suppressionsResolved": batch_result.suppressions_resolved,
|
||||
"sampled": lifecycles.len(),
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// ====================================================================
|
||||
// COMPRESSOR: Find compressible memory groups
|
||||
// ====================================================================
|
||||
let compressible_groups = if let Ok(cog) = cognitive.try_lock() {
|
||||
let memories_for_compression: Vec<MemoryForCompression> = nodes
|
||||
.iter()
|
||||
.filter(|n| n.retention_strength < 0.5) // Only consider low-retention memories
|
||||
.take(50) // Cap for performance
|
||||
.map(|n| MemoryForCompression {
|
||||
id: n.id.clone(),
|
||||
content: n.content.clone(),
|
||||
tags: n.tags.clone(),
|
||||
created_at: n.created_at,
|
||||
last_accessed: Some(n.last_accessed),
|
||||
embedding: None,
|
||||
})
|
||||
.collect();
|
||||
if !memories_for_compression.is_empty() {
|
||||
let groups = cog.compressor.find_compressible_groups(&memories_for_compression);
|
||||
Some(serde_json::json!({
|
||||
"groupCount": groups.len(),
|
||||
"totalCompressible": groups.iter().map(|g| g.len()).sum::<usize>(),
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// ====================================================================
|
||||
// COGNITIVE: Module health summary
|
||||
// ====================================================================
|
||||
let cognitive_health = if let Ok(cog) = cognitive.try_lock() {
|
||||
let activation_count = cog.activation_network.get_associations("_probe_").len();
|
||||
let prediction_accuracy = cog.predictive_memory.prediction_accuracy().unwrap_or(0.0);
|
||||
let scheduler_stats = cog.consolidation_scheduler.get_activity_stats();
|
||||
Some(serde_json::json!({
|
||||
"activationNetworkSize": activation_count,
|
||||
"predictionAccuracy": format!("{:.2}", prediction_accuracy),
|
||||
"modulesActive": 28,
|
||||
"schedulerStats": {
|
||||
"totalEvents": scheduler_stats.total_events,
|
||||
"eventsPerMinute": scheduler_stats.events_per_minute,
|
||||
"isIdle": scheduler_stats.is_idle,
|
||||
"timeUntilNextConsolidation": format!("{:?}", cog.consolidation_scheduler.time_until_next()),
|
||||
},
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
drop(storage_guard);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"tool": "stats",
|
||||
"totalMemories": stats.total_nodes,
|
||||
|
|
@ -248,6 +367,10 @@ pub async fn execute_stats(
|
|||
"unavailable": unavailable,
|
||||
"sampled": total,
|
||||
},
|
||||
"fsrsPreview": fsrs_preview,
|
||||
"cognitiveHealth": cognitive_health,
|
||||
"stateTransitions": state_distribution_precise,
|
||||
"compressibleMemories": compressible_groups,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -220,4 +220,163 @@ mod tests {
|
|||
assert!(schema["properties"]["id"].is_object());
|
||||
assert_eq!(schema["required"], serde_json::json!(["action", "id"]));
|
||||
}
|
||||
|
||||
// === INTEGRATION TESTS ===
|
||||
|
||||
async fn test_storage() -> (Arc<Mutex<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)
|
||||
}
|
||||
|
||||
async fn ingest_memory(storage: &Arc<Mutex<Storage>>) -> String {
|
||||
let mut s = storage.lock().await;
|
||||
let node = s
|
||||
.ingest(vestige_core::IngestInput {
|
||||
content: "Memory unified test content".to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
source: Some("test".to_string()),
|
||||
sentiment_score: 0.0,
|
||||
sentiment_magnitude: 0.0,
|
||||
tags: vec!["test-tag".to_string()],
|
||||
valid_from: None,
|
||||
valid_until: None,
|
||||
})
|
||||
.unwrap();
|
||||
node.id
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_missing_args_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, None).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Missing arguments"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invalid_action_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "action": "invalid", "id": "00000000-0000-0000-0000-000000000000" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid action"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invalid_uuid_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "action": "get", "id": "not-a-uuid" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid memory ID format"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_existing_memory() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let id = ingest_memory(&storage).await;
|
||||
let args = serde_json::json!({ "action": "get", "id": id });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["action"], "get");
|
||||
assert_eq!(value["found"], true);
|
||||
assert_eq!(value["node"]["id"], id);
|
||||
assert_eq!(value["node"]["content"], "Memory unified test content");
|
||||
assert_eq!(value["node"]["nodeType"], "fact");
|
||||
assert!(value["node"]["createdAt"].is_string());
|
||||
assert!(value["node"]["tags"].is_array());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_nonexistent_memory() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "action": "get", "id": "00000000-0000-0000-0000-000000000000" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["found"], false);
|
||||
assert_eq!(value["message"], "Memory not found");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_existing_memory() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let id = ingest_memory(&storage).await;
|
||||
let args = serde_json::json!({ "action": "delete", "id": id });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["action"], "delete");
|
||||
assert_eq!(value["success"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_nonexistent_memory() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "action": "delete", "id": "00000000-0000-0000-0000-000000000000" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["success"], false);
|
||||
assert!(value["message"].as_str().unwrap().contains("not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_then_get_returns_not_found() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let id = ingest_memory(&storage).await;
|
||||
let del_args = serde_json::json!({ "action": "delete", "id": id });
|
||||
execute(&storage, Some(del_args)).await.unwrap();
|
||||
let get_args = serde_json::json!({ "action": "get", "id": id });
|
||||
let result = execute(&storage, Some(get_args)).await;
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["found"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_state_existing_memory() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let id = ingest_memory(&storage).await;
|
||||
let args = serde_json::json!({ "action": "state", "id": id });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["action"], "state");
|
||||
assert_eq!(value["memoryId"], id);
|
||||
assert!(value["accessibility"].is_number());
|
||||
assert!(value["state"].is_string());
|
||||
assert!(value["description"].is_string());
|
||||
assert!(value["components"]["retentionStrength"].is_number());
|
||||
assert!(value["components"]["retrievalStrength"].is_number());
|
||||
assert!(value["components"]["storageStrength"].is_number());
|
||||
assert_eq!(value["thresholds"]["active"], 0.7);
|
||||
assert_eq!(value["thresholds"]["dormant"], 0.4);
|
||||
assert_eq!(value["thresholds"]["silent"], 0.1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_state_nonexistent_memory_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "action": "state", "id": "00000000-0000-0000-0000-000000000000" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_accessibility_boundary_active() {
|
||||
// Exactly at active threshold
|
||||
let a = compute_accessibility(1.0, 0.7, 0.5);
|
||||
assert!(a >= ACCESSIBILITY_ACTIVE);
|
||||
assert!(matches!(state_from_accessibility(a), MemoryState::Active));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_accessibility_boundary_zero() {
|
||||
let a = compute_accessibility(0.0, 0.0, 0.0);
|
||||
assert_eq!(a, 0.0);
|
||||
assert!(matches!(state_from_accessibility(a), MemoryState::Unavailable));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ pub mod checkpoint;
|
|||
pub mod dedup;
|
||||
pub mod importance;
|
||||
|
||||
// v1.5: Cognitive tools
|
||||
pub mod dream;
|
||||
pub mod explore;
|
||||
pub mod predict;
|
||||
pub mod restore;
|
||||
|
||||
// Deprecated tools - kept for internal backwards compatibility
|
||||
// These modules are intentionally unused in the public API
|
||||
#[allow(dead_code)]
|
||||
|
|
|
|||
205
crates/vestige-mcp/src/tools/predict.rs
Normal file
205
crates/vestige-mcp/src/tools/predict.rs
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
//! Predict tool — Proactive memory prediction ("what will you need next?").
|
||||
//! v1.5.0: Wires PredictiveMemory + SpeculativeRetriever.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use vestige_core::Storage;
|
||||
|
||||
pub fn schema() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"context": {
|
||||
"type": "object",
|
||||
"description": "Current context for prediction",
|
||||
"properties": {
|
||||
"current_file": { "type": "string" },
|
||||
"current_topics": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"codebase": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
_storage: &Arc<Mutex<Storage>>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let context = args.as_ref().and_then(|a| a.get("context"));
|
||||
|
||||
let cog = cognitive.lock().await;
|
||||
|
||||
// Build session context for predictive memory
|
||||
let session_ctx = vestige_core::neuroscience::predictive_retrieval::SessionContext {
|
||||
started_at: chrono::Utc::now(),
|
||||
current_focus: context
|
||||
.and_then(|c| c.get("current_topics"))
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
active_files: context
|
||||
.and_then(|c| c.get("current_file"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| vec![s.to_string()])
|
||||
.unwrap_or_default(),
|
||||
accessed_memories: Vec::new(),
|
||||
recent_queries: Vec::new(),
|
||||
detected_intent: None,
|
||||
project_context: context
|
||||
.and_then(|c| c.get("codebase"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|name| vestige_core::neuroscience::predictive_retrieval::ProjectContext {
|
||||
name: name.to_string(),
|
||||
path: String::new(),
|
||||
technologies: Vec::new(),
|
||||
primary_language: None,
|
||||
}),
|
||||
};
|
||||
|
||||
// Get predictions from predictive memory
|
||||
let predictions = cog.predictive_memory.predict_needed_memories(&session_ctx)
|
||||
.unwrap_or_default();
|
||||
let suggestions = cog.predictive_memory.get_proactive_suggestions(0.3)
|
||||
.unwrap_or_default();
|
||||
let top_interests = cog.predictive_memory.get_top_interests(10)
|
||||
.unwrap_or_default();
|
||||
let accuracy = cog.predictive_memory.prediction_accuracy()
|
||||
.unwrap_or(0.0);
|
||||
|
||||
// Build speculative context
|
||||
let speculative_context = vestige_core::PredictionContext {
|
||||
open_files: context
|
||||
.and_then(|c| c.get("current_file"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| vec![PathBuf::from(s)])
|
||||
.unwrap_or_default(),
|
||||
recent_edits: Vec::new(),
|
||||
recent_queries: Vec::new(),
|
||||
recent_memory_ids: Vec::new(),
|
||||
project_path: context
|
||||
.and_then(|c| c.get("codebase"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(PathBuf::from),
|
||||
timestamp: Some(chrono::Utc::now()),
|
||||
};
|
||||
let speculative = cog.speculative_retriever.predict_needed(&speculative_context);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"predictions": predictions.iter().map(|p| serde_json::json!({
|
||||
"memory_id": p.memory_id,
|
||||
"content_preview": p.content_preview,
|
||||
"confidence": p.confidence,
|
||||
"reasoning": format!("{:?}", p.reasoning),
|
||||
})).collect::<Vec<_>>(),
|
||||
"suggestions": suggestions.iter().map(|p| serde_json::json!({
|
||||
"memory_id": p.memory_id,
|
||||
"content_preview": p.content_preview,
|
||||
"confidence": p.confidence,
|
||||
"reasoning": format!("{:?}", p.reasoning),
|
||||
})).collect::<Vec<_>>(),
|
||||
"speculative": speculative.iter().map(|p| serde_json::json!({
|
||||
"memory_id": p.memory_id,
|
||||
"content_preview": p.content_preview,
|
||||
"confidence": p.confidence,
|
||||
"trigger": format!("{:?}", p.trigger),
|
||||
})).collect::<Vec<_>>(),
|
||||
"top_interests": top_interests,
|
||||
"prediction_accuracy": accuracy,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_cognitive() -> Arc<Mutex<CognitiveEngine>> {
|
||||
Arc::new(Mutex::new(CognitiveEngine::new()))
|
||||
}
|
||||
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_has_properties() {
|
||||
let s = schema();
|
||||
assert_eq!(s["type"], "object");
|
||||
assert!(s["properties"]["context"].is_object());
|
||||
assert!(s["properties"]["context"]["properties"]["current_file"].is_object());
|
||||
assert!(s["properties"]["context"]["properties"]["current_topics"].is_object());
|
||||
assert!(s["properties"]["context"]["properties"]["codebase"].is_object());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_predict_no_args_succeeds() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, &test_cognitive(), None).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert!(value["predictions"].is_array());
|
||||
assert!(value["suggestions"].is_array());
|
||||
assert!(value["speculative"].is_array());
|
||||
assert!(value["prediction_accuracy"].is_number());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_predict_empty_context() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "context": {} });
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert!(value["predictions"].is_array());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_predict_with_full_context() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"context": {
|
||||
"current_file": "/src/main.rs",
|
||||
"current_topics": ["rust", "memory"],
|
||||
"codebase": "vestige"
|
||||
}
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert!(value["predictions"].is_array());
|
||||
assert!(value["top_interests"].is_array());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_predict_with_topics_only() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"context": {
|
||||
"current_topics": ["debugging", "errors"]
|
||||
}
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_predict_accuracy_is_number() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, &test_cognitive(), None).await;
|
||||
let value = result.unwrap();
|
||||
let accuracy = value["prediction_accuracy"].as_f64().unwrap();
|
||||
assert!(accuracy >= 0.0);
|
||||
}
|
||||
}
|
||||
274
crates/vestige-mcp/src/tools/restore.rs
Normal file
274
crates/vestige-mcp/src/tools/restore.rs
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
//! Restore Tool
|
||||
//!
|
||||
//! Restores memories from a JSON backup file.
|
||||
//! Previously CLI-only (vestige-restore binary), now available as an MCP tool
|
||||
//! so Claude Code can trigger restores directly.
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::{IngestInput, Storage};
|
||||
|
||||
/// Input schema for restore tool
|
||||
pub fn schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to the backup JSON file to restore from"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RestoreArgs {
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BackupWrapper {
|
||||
#[serde(rename = "type")]
|
||||
_type: String,
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RecallResult {
|
||||
results: Vec<MemoryBackup>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MemoryBackup {
|
||||
content: String,
|
||||
node_type: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
source: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: RestoreArgs = match args {
|
||||
Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?,
|
||||
None => return Err("Missing arguments".to_string()),
|
||||
};
|
||||
|
||||
let path = std::path::Path::new(&args.path);
|
||||
if !path.exists() {
|
||||
return Err(format!("Backup file not found: {}", args.path));
|
||||
}
|
||||
|
||||
// Read and parse backup
|
||||
let backup_content =
|
||||
std::fs::read_to_string(path).map_err(|e| format!("Failed to read backup: {}", e))?;
|
||||
|
||||
// Try parsing as wrapped format first (MCP response wrapper),
|
||||
// then fall back to direct RecallResult
|
||||
let memories: Vec<MemoryBackup> =
|
||||
if let Ok(wrapper) = serde_json::from_str::<Vec<BackupWrapper>>(&backup_content) {
|
||||
if let Some(first) = wrapper.first() {
|
||||
let recall: RecallResult = serde_json::from_str(&first.text)
|
||||
.map_err(|e| format!("Failed to parse backup contents: {}", e))?;
|
||||
recall.results
|
||||
} else {
|
||||
return Err("Empty backup file".to_string());
|
||||
}
|
||||
} else if let Ok(recall) = serde_json::from_str::<RecallResult>(&backup_content) {
|
||||
recall.results
|
||||
} else if let Ok(nodes) = serde_json::from_str::<Vec<MemoryBackup>>(&backup_content) {
|
||||
nodes
|
||||
} else {
|
||||
return Err(
|
||||
"Unrecognized backup format. Expected MCP wrapper, RecallResult, or array of memories."
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
|
||||
let total = memories.len();
|
||||
if total == 0 {
|
||||
return Ok(serde_json::json!({
|
||||
"tool": "restore",
|
||||
"success": true,
|
||||
"restored": 0,
|
||||
"total": 0,
|
||||
"message": "No memories found in backup file.",
|
||||
}));
|
||||
}
|
||||
|
||||
let mut storage_guard = storage.lock().await;
|
||||
let mut success_count = 0_usize;
|
||||
let mut error_count = 0_usize;
|
||||
|
||||
for memory in &memories {
|
||||
let input = IngestInput {
|
||||
content: memory.content.clone(),
|
||||
node_type: memory.node_type.clone().unwrap_or_else(|| "fact".to_string()),
|
||||
source: memory.source.clone(),
|
||||
sentiment_score: 0.0,
|
||||
sentiment_magnitude: 0.0,
|
||||
tags: memory.tags.clone().unwrap_or_default(),
|
||||
valid_from: None,
|
||||
valid_until: None,
|
||||
};
|
||||
|
||||
match storage_guard.ingest(input) {
|
||||
Ok(_) => success_count += 1,
|
||||
Err(_) => error_count += 1,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"tool": "restore",
|
||||
"success": true,
|
||||
"restored": success_count,
|
||||
"errors": error_count,
|
||||
"total": total,
|
||||
"message": format!("Restored {}/{} memories from backup.", success_count, total),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
}
|
||||
|
||||
fn write_temp_file(dir: &TempDir, name: &str, content: &str) -> String {
|
||||
let path = dir.path().join(name);
|
||||
let mut f = std::fs::File::create(&path).unwrap();
|
||||
f.write_all(content.as_bytes()).unwrap();
|
||||
path.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_has_required_fields() {
|
||||
let s = schema();
|
||||
assert_eq!(s["type"], "object");
|
||||
assert!(s["properties"]["path"].is_object());
|
||||
assert!(s["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&serde_json::json!("path")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_missing_args_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, None).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Missing arguments"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_missing_path_field_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, Some(serde_json::json!({}))).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid arguments"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_nonexistent_file_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "path": "/tmp/does_not_exist_vestige_test.json" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_malformed_json_fails() {
|
||||
let (storage, dir) = test_storage().await;
|
||||
let path = write_temp_file(&dir, "bad.json", "this is not json {{{");
|
||||
let args = serde_json::json!({ "path": path });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Unrecognized backup format"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_restore_direct_array_format() {
|
||||
let (storage, dir) = test_storage().await;
|
||||
let backup = serde_json::json!([
|
||||
{ "content": "Memory one", "nodeType": "fact", "tags": ["test"] },
|
||||
{ "content": "Memory two", "nodeType": "concept" }
|
||||
]);
|
||||
let path = write_temp_file(&dir, "backup.json", &backup.to_string());
|
||||
let args = serde_json::json!({ "path": path });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["tool"], "restore");
|
||||
assert_eq!(value["success"], true);
|
||||
assert_eq!(value["restored"], 2);
|
||||
assert_eq!(value["errors"], 0);
|
||||
assert_eq!(value["total"], 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_restore_recall_result_format() {
|
||||
let (storage, dir) = test_storage().await;
|
||||
let backup = serde_json::json!({
|
||||
"results": [
|
||||
{ "content": "Recall memory one" },
|
||||
{ "content": "Recall memory two" },
|
||||
{ "content": "Recall memory three" }
|
||||
]
|
||||
});
|
||||
let path = write_temp_file(&dir, "recall.json", &backup.to_string());
|
||||
let args = serde_json::json!({ "path": path });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["restored"], 3);
|
||||
assert_eq!(value["total"], 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_restore_empty_results_array() {
|
||||
let (storage, dir) = test_storage().await;
|
||||
let backup = serde_json::json!({ "results": [] });
|
||||
let path = write_temp_file(&dir, "empty.json", &backup.to_string());
|
||||
let args = serde_json::json!({ "path": path });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["restored"], 0);
|
||||
assert_eq!(value["total"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_restore_empty_array_returns_error() {
|
||||
// Empty [] parses as Vec<BackupWrapper> first, which has no items → "Empty backup file"
|
||||
let (storage, dir) = test_storage().await;
|
||||
let path = write_temp_file(&dir, "empty_arr.json", "[]");
|
||||
let args = serde_json::json!({ "path": path });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Empty backup file"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_restore_defaults_node_type_to_fact() {
|
||||
let (storage, dir) = test_storage().await;
|
||||
let backup = serde_json::json!([{ "content": "No type specified" }]);
|
||||
let path = write_temp_file(&dir, "notype.json", &backup.to_string());
|
||||
let args = serde_json::json!({ "path": path });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap()["restored"], 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,13 +3,27 @@
|
|||
//! Merges recall, semantic_search, and hybrid_search into a single `search` tool.
|
||||
//! Always uses hybrid search internally (keyword + semantic + RRF fusion).
|
||||
//! Implements Testing Effect (Roediger & Karpicke 2006) by auto-strengthening memories on access.
|
||||
//!
|
||||
//! v1.5.0: Enhanced 7-stage cognitive pipeline:
|
||||
//! 1. Reranker (over-fetch 3x, rerank down)
|
||||
//! 2. Temporal boosting (recency + validity)
|
||||
//! 3. Memory state accessibility filtering
|
||||
//! 4. Context matching (topic overlap)
|
||||
//! 5. Spreading activation associations
|
||||
//! 6. Predictive memory recording
|
||||
//! 7. Reconsolidation (mark labile)
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::Storage;
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use vestige_core::{
|
||||
CompetitionCandidate, EncodingContext, MemoryLifecycle, MemorySnapshot, MemoryState, Storage,
|
||||
TopicalContext,
|
||||
};
|
||||
|
||||
/// Input schema for unified search tool
|
||||
pub fn schema() -> Value {
|
||||
|
|
@ -46,6 +60,11 @@ pub fn schema() -> Value {
|
|||
"description": "Level of detail in results. 'brief' = id/type/tags/score only (saves tokens). 'summary' = default 8-field response. 'full' = all fields including FSRS state and timestamps.",
|
||||
"enum": ["brief", "summary", "full"],
|
||||
"default": "summary"
|
||||
},
|
||||
"context_topics": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Optional topics for context-dependent retrieval boosting"
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
|
|
@ -61,14 +80,24 @@ struct SearchArgs {
|
|||
min_similarity: Option<f32>,
|
||||
#[serde(alias = "detail_level")]
|
||||
detail_level: Option<String>,
|
||||
context_topics: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Execute unified search
|
||||
/// Execute unified search with 7-stage cognitive pipeline.
|
||||
///
|
||||
/// Uses hybrid search (keyword + semantic + RRF fusion) internally.
|
||||
/// Auto-strengthens memories on access (Testing Effect - Roediger & Karpicke 2006).
|
||||
/// Pipeline:
|
||||
/// 1. Hybrid search (keyword + semantic + RRF) with 3x over-fetch
|
||||
/// 2. Reranker (BM25-like rescoring, trim to limit)
|
||||
/// 3. Temporal boosting (recency + validity windows)
|
||||
/// 4. Memory state accessibility filtering (Active/Dormant/Silent/Unavailable)
|
||||
/// 5. Context matching (topic overlap boosting)
|
||||
/// 6. Spreading activation (find associated memories)
|
||||
/// 7. Side effects: predictive memory recording + reconsolidation labile marking
|
||||
///
|
||||
/// Also applies Testing Effect (Roediger & Karpicke 2006) by auto-strengthening on access.
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: SearchArgs = match args {
|
||||
|
|
@ -102,22 +131,23 @@ pub async fn execute(
|
|||
let keyword_weight = 0.5_f32;
|
||||
let semantic_weight = 0.5_f32;
|
||||
|
||||
let storage = storage.lock().await;
|
||||
// ====================================================================
|
||||
// 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;
|
||||
|
||||
// Execute hybrid search
|
||||
let results = storage
|
||||
.hybrid_search(&args.query, limit, keyword_weight, semantic_weight)
|
||||
let results = storage_guard
|
||||
.hybrid_search(&args.query, overfetch_limit, keyword_weight, semantic_weight)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Filter results by min_retention and min_similarity
|
||||
let filtered_results: Vec<_> = results
|
||||
// Filter by min_retention and min_similarity first (cheap filters)
|
||||
let mut filtered_results: Vec<_> = results
|
||||
.into_iter()
|
||||
.filter(|r| {
|
||||
// Check retention strength
|
||||
if r.node.retention_strength < min_retention {
|
||||
return false;
|
||||
}
|
||||
// Check similarity if semantic score is available
|
||||
if let Some(sem_score) = r.semantic_score
|
||||
&& sem_score < min_similarity
|
||||
{
|
||||
|
|
@ -127,24 +157,254 @@ pub async fn execute(
|
|||
})
|
||||
.collect();
|
||||
|
||||
// Auto-strengthen memories on access (Testing Effect - Roediger & Karpicke 2006)
|
||||
// This implements "use it or lose it" - accessed memories get stronger
|
||||
let ids: Vec<&str> = filtered_results.iter().map(|r| r.node.id.as_str()).collect();
|
||||
let _ = storage.strengthen_batch_on_access(&ids); // Ignore errors, don't fail search
|
||||
// ====================================================================
|
||||
// STAGE 2: Reranker (BM25-like rescoring, trim to requested limit)
|
||||
// ====================================================================
|
||||
if let Ok(cog) = cognitive.try_lock() {
|
||||
let candidates: Vec<_> = filtered_results
|
||||
.iter()
|
||||
.map(|r| (r.clone(), r.node.content.clone()))
|
||||
.collect();
|
||||
|
||||
// Format results based on detail_level
|
||||
if let Ok(reranked) = cog.reranker.rerank(&args.query, candidates, Some(limit as usize)) {
|
||||
// Replace filtered_results with reranked items (preserves original SearchResult)
|
||||
filtered_results = reranked.into_iter().map(|rr| rr.item).collect();
|
||||
} else {
|
||||
// Reranker failed — fall back to original order, just truncate
|
||||
filtered_results.truncate(limit as usize);
|
||||
}
|
||||
} else {
|
||||
// Couldn't acquire cognitive lock — truncate to limit
|
||||
filtered_results.truncate(limit as usize);
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// STAGE 3: Temporal boosting (recency + validity windows)
|
||||
// ====================================================================
|
||||
if let Ok(cog) = cognitive.try_lock() {
|
||||
for result in &mut filtered_results {
|
||||
let recency = cog.temporal_searcher.recency_boost(result.node.created_at);
|
||||
let validity = cog.temporal_searcher.validity_boost(
|
||||
result.node.valid_from,
|
||||
result.node.valid_until,
|
||||
None,
|
||||
);
|
||||
// Blend: 85% relevance + 15% temporal signal
|
||||
let temporal_factor = recency * validity;
|
||||
result.combined_score =
|
||||
result.combined_score * 0.85 + (result.combined_score * temporal_factor as f32) * 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// STAGE 4: Memory state accessibility filtering
|
||||
// ====================================================================
|
||||
if let Ok(cog) = cognitive.try_lock() {
|
||||
for result in &mut filtered_results {
|
||||
// Build a MemoryLifecycle from node data for the calculator
|
||||
let mut lifecycle = MemoryLifecycle::new();
|
||||
lifecycle.last_access = result.node.last_accessed;
|
||||
lifecycle.access_count = result.node.reps as u32;
|
||||
// Determine state from retention strength
|
||||
lifecycle.state = if result.node.retention_strength > 0.7 {
|
||||
MemoryState::Active
|
||||
} else if result.node.retention_strength > 0.3 {
|
||||
MemoryState::Dormant
|
||||
} else if result.node.retention_strength > 0.1 {
|
||||
MemoryState::Silent
|
||||
} else {
|
||||
MemoryState::Unavailable
|
||||
};
|
||||
|
||||
let adjusted = cog
|
||||
.accessibility_calc
|
||||
.calculate(&lifecycle, result.combined_score as f64);
|
||||
result.combined_score = adjusted as f32;
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// STAGE 5: Context matching (Tulving 1973 encoding specificity)
|
||||
// ====================================================================
|
||||
if let Some(ref topics) = args.context_topics {
|
||||
if !topics.is_empty() {
|
||||
let retrieval_ctx = EncodingContext::new()
|
||||
.with_topical(TopicalContext::with_topics(topics.clone()));
|
||||
if let Ok(cog) = cognitive.try_lock() {
|
||||
for result in &mut filtered_results {
|
||||
// Build encoding context from memory's tags
|
||||
let encoding_ctx = EncodingContext::new()
|
||||
.with_topical(TopicalContext::with_topics(result.node.tags.clone()));
|
||||
let context_score = cog.context_matcher.match_contexts(&encoding_ctx, &retrieval_ctx);
|
||||
// Blend: context match boosts relevance up to +30%
|
||||
result.combined_score *= 1.0 + (context_score as f32 * 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Context reinstatement for top result (helps Claude understand WHY this memory matched)
|
||||
let reinstatement_info: Option<Value> = if let Ok(cog) = cognitive.try_lock() {
|
||||
if let Some(first) = filtered_results.first() {
|
||||
let current_ctx = if let Some(ref topics) = args.context_topics {
|
||||
EncodingContext::new().with_topical(TopicalContext::with_topics(topics.clone()))
|
||||
} else {
|
||||
EncodingContext::new()
|
||||
};
|
||||
let reinstatement = cog.context_matcher.reinstate_context(&first.node.id, ¤t_ctx);
|
||||
Some(serde_json::json!({
|
||||
"memoryId": reinstatement.memory_id,
|
||||
"temporalHint": reinstatement.temporal_hint,
|
||||
"topicalHint": reinstatement.topical_hint,
|
||||
"sessionHint": reinstatement.session_hint,
|
||||
"relatedMemories": reinstatement.related_memories,
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// ====================================================================
|
||||
// STAGE 5B: Retrieval competition (Anderson et al. 1994)
|
||||
// ====================================================================
|
||||
let mut suppressed_count = 0_usize;
|
||||
if filtered_results.len() > 1 {
|
||||
if let Ok(mut cog) = cognitive.try_lock() {
|
||||
let candidates: Vec<CompetitionCandidate> = filtered_results
|
||||
.iter()
|
||||
.map(|r| CompetitionCandidate {
|
||||
memory_id: r.node.id.clone(),
|
||||
relevance_score: r.combined_score as f64,
|
||||
similarity_to_query: r.semantic_score.unwrap_or(0.0) as f64,
|
||||
})
|
||||
.collect();
|
||||
if let Some(result) = cog.competition_mgr.run_competition(&candidates, 0.7) {
|
||||
// Apply suppression: losers get penalized
|
||||
for suppressed_id in &result.suppressed_ids {
|
||||
if let Some(r) = filtered_results.iter_mut().find(|r| &r.node.id == suppressed_id) {
|
||||
r.combined_score *= 0.85; // 15% suppression penalty
|
||||
suppressed_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-sort by adjusted combined_score (descending) after all score modifications
|
||||
filtered_results.sort_by(|a, b| {
|
||||
b.combined_score
|
||||
.partial_cmp(&a.combined_score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
// ====================================================================
|
||||
// STAGE 6: Spreading activation (find associated memories)
|
||||
// ====================================================================
|
||||
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,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
// ====================================================================
|
||||
// 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);
|
||||
|
||||
// Drop storage lock before acquiring cognitive for side effects
|
||||
drop(storage_guard);
|
||||
|
||||
// ====================================================================
|
||||
// STAGE 7: Side effects — predictive memory + reconsolidation
|
||||
// ====================================================================
|
||||
if let Ok(mut cog) = cognitive.try_lock() {
|
||||
// 7A. Record query for predictive memory
|
||||
let _ = cog.predictive_memory.record_query(&args.query, &[]);
|
||||
|
||||
// 7B. Record each accessed memory for predictive/speculative models
|
||||
for result in &filtered_results {
|
||||
let _ = cog.predictive_memory.record_memory_access(
|
||||
&result.node.id,
|
||||
&result.node.content.chars().take(100).collect::<String>(),
|
||||
&result.node.tags,
|
||||
);
|
||||
|
||||
cog.speculative_retriever.record_access(
|
||||
&result.node.id,
|
||||
None, // file_context
|
||||
Some(args.query.as_str()), // query_context
|
||||
None, // was_helpful (unknown yet)
|
||||
);
|
||||
|
||||
// 7C. Mark labile for reconsolidation window (5 min)
|
||||
let snapshot = MemorySnapshot {
|
||||
content: result.node.content.clone(),
|
||||
tags: result.node.tags.clone(),
|
||||
retention_strength: result.node.retention_strength,
|
||||
storage_strength: result.node.storage_strength,
|
||||
retrieval_strength: result.node.retrieval_strength,
|
||||
connection_ids: vec![],
|
||||
captured_at: Utc::now(),
|
||||
};
|
||||
cog.reconsolidation.mark_labile(&result.node.id, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Format and return
|
||||
// ====================================================================
|
||||
let formatted: Vec<Value> = filtered_results
|
||||
.iter()
|
||||
.map(|r| format_search_result(r, detail_level))
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
// Check learning mode via attention signal
|
||||
let learning_mode = cognitive.try_lock().ok().map(|cog| cog.attention_signal.is_learning_mode()).unwrap_or(false);
|
||||
|
||||
let mut response = serde_json::json!({
|
||||
"query": args.query,
|
||||
"method": "hybrid",
|
||||
"method": "hybrid+cognitive",
|
||||
"detailLevel": detail_level,
|
||||
"total": formatted.len(),
|
||||
"results": formatted,
|
||||
}))
|
||||
});
|
||||
|
||||
// Include associations if any were found
|
||||
if !associations.is_empty() {
|
||||
response["associations"] = serde_json::json!(associations);
|
||||
}
|
||||
// Include context reinstatement if computed
|
||||
if let Some(ri) = reinstatement_info {
|
||||
response["contextReinstatement"] = ri;
|
||||
}
|
||||
// Include competition stats
|
||||
if suppressed_count > 0 {
|
||||
response["competitionSuppressed"] = serde_json::json!(suppressed_count);
|
||||
}
|
||||
// Include learning mode detection
|
||||
if learning_mode {
|
||||
response["learningModeDetected"] = serde_json::json!(true);
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Format a search result based on the requested detail level.
|
||||
|
|
@ -247,9 +507,14 @@ pub fn format_node(node: &vestige_core::KnowledgeNode, detail_level: &str) -> Va
|
|||
#[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()))
|
||||
}
|
||||
|
||||
/// Create a test storage instance with a temporary database
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
|
@ -282,7 +547,7 @@ mod tests {
|
|||
async fn test_search_empty_query_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "query": "" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("empty"));
|
||||
}
|
||||
|
|
@ -291,7 +556,7 @@ mod tests {
|
|||
async fn test_search_whitespace_only_query_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "query": " \t\n " });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("empty"));
|
||||
}
|
||||
|
|
@ -299,7 +564,7 @@ mod tests {
|
|||
#[tokio::test]
|
||||
async fn test_search_missing_arguments_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, None).await;
|
||||
let result = execute(&storage, &test_cognitive(), None).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Missing arguments"));
|
||||
}
|
||||
|
|
@ -308,7 +573,7 @@ mod tests {
|
|||
async fn test_search_missing_query_field_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "limit": 10 });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid arguments"));
|
||||
}
|
||||
|
|
@ -327,7 +592,7 @@ mod tests {
|
|||
"query": "test",
|
||||
"limit": 0
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
|
|
@ -341,7 +606,7 @@ mod tests {
|
|||
"query": "test",
|
||||
"limit": 1000
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
|
|
@ -354,7 +619,7 @@ mod tests {
|
|||
"query": "test",
|
||||
"limit": -5
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
|
|
@ -371,7 +636,7 @@ mod tests {
|
|||
"query": "test",
|
||||
"min_retention": -0.5
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
|
|
@ -384,7 +649,7 @@ mod tests {
|
|||
"query": "test",
|
||||
"min_retention": 1.5
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
// Should succeed but may return no results (retention > 1.0 clamped to 1.0)
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
|
@ -402,7 +667,7 @@ mod tests {
|
|||
"query": "test",
|
||||
"min_similarity": -0.5
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
|
|
@ -415,7 +680,7 @@ mod tests {
|
|||
"query": "test",
|
||||
"min_similarity": 1.5
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
// Should succeed but may return no results
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
|
@ -430,12 +695,12 @@ mod tests {
|
|||
ingest_test_content(&storage, "The Rust programming language is memory safe.").await;
|
||||
|
||||
let args = serde_json::json!({ "query": "rust" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["query"], "rust");
|
||||
assert_eq!(value["method"], "hybrid");
|
||||
assert_eq!(value["method"], "hybrid+cognitive");
|
||||
assert!(value["total"].is_number());
|
||||
assert!(value["results"].is_array());
|
||||
}
|
||||
|
|
@ -450,7 +715,7 @@ mod tests {
|
|||
"query": "python",
|
||||
"min_similarity": 0.0
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -472,7 +737,7 @@ mod tests {
|
|||
"limit": 2,
|
||||
"min_similarity": 0.0
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -486,7 +751,7 @@ mod tests {
|
|||
// Don't ingest anything - database is empty
|
||||
|
||||
let args = serde_json::json!({ "query": "anything" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -503,7 +768,7 @@ mod tests {
|
|||
"query": "testing",
|
||||
"min_similarity": 0.0
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -536,7 +801,7 @@ mod tests {
|
|||
"query": "item",
|
||||
"min_similarity": 0.0
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -620,7 +885,7 @@ mod tests {
|
|||
"detail_level": "brief",
|
||||
"min_similarity": 0.0
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -649,7 +914,7 @@ mod tests {
|
|||
"detail_level": "full",
|
||||
"min_similarity": 0.0
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -676,7 +941,7 @@ mod tests {
|
|||
"query": "default",
|
||||
"min_similarity": 0.0
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -698,7 +963,7 @@ mod tests {
|
|||
"query": "test",
|
||||
"detail_level": "invalid_level"
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid detail_level"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,13 +9,21 @@
|
|||
//! - Updating existing memories when appropriate (low prediction error)
|
||||
//! - Creating new memories when content is substantially different (high PE)
|
||||
//! - Superseding demoted/outdated memories with better alternatives
|
||||
//!
|
||||
//! v1.5.0: Enhanced with cognitive pipeline:
|
||||
//! Pre-ingest: importance scoring (4-channel) + intent detection → auto-tag
|
||||
//! Post-ingest: synaptic tagging + novelty model update + hippocampal indexing
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use vestige_core::{IngestInput, Storage};
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use vestige_core::{
|
||||
ContentType, ImportanceContext, ImportanceEventType, ImportanceEvent, IngestInput, Storage,
|
||||
};
|
||||
|
||||
/// Input schema for smart_ingest tool
|
||||
pub fn schema() -> Value {
|
||||
|
|
@ -62,6 +70,7 @@ struct SmartIngestArgs {
|
|||
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let args: SmartIngestArgs = match args {
|
||||
|
|
@ -78,30 +87,72 @@ pub async fn execute(
|
|||
return Err("Content too large (max 1MB)".to_string());
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// COGNITIVE PRE-INGEST: importance scoring + intent detection + content analysis
|
||||
// ====================================================================
|
||||
let mut importance_composite = 0.0_f64;
|
||||
let mut tags = args.tags.unwrap_or_default();
|
||||
|
||||
if let Ok(cog) = cognitive.try_lock() {
|
||||
// 4A. Full 4-channel importance scoring
|
||||
let context = ImportanceContext::current();
|
||||
let importance = cog.importance_signals.compute_importance(&args.content, &context);
|
||||
importance_composite = importance.composite;
|
||||
|
||||
// 4B. Intent detection → auto-tag
|
||||
let intent_result = cog.intent_detector.detect_intent();
|
||||
if intent_result.confidence > 0.5 {
|
||||
let intent_tag = format!("intent:{:?}", intent_result.primary_intent);
|
||||
// Truncate long intent tags
|
||||
let intent_tag = if intent_tag.len() > 50 {
|
||||
format!("{}...", &intent_tag[..47])
|
||||
} else {
|
||||
intent_tag
|
||||
};
|
||||
tags.push(intent_tag);
|
||||
}
|
||||
|
||||
// 4D. Adaptive embedding — detect content type for logging
|
||||
let _content_type = ContentType::detect(&args.content);
|
||||
}
|
||||
|
||||
let input = IngestInput {
|
||||
content: args.content,
|
||||
content: args.content.clone(),
|
||||
node_type: args.node_type.unwrap_or_else(|| "fact".to_string()),
|
||||
source: args.source,
|
||||
sentiment_score: 0.0,
|
||||
sentiment_magnitude: 0.0,
|
||||
tags: args.tags.unwrap_or_default(),
|
||||
// Store importance composite as sentiment_magnitude for FSRS encoding boost
|
||||
sentiment_magnitude: importance_composite,
|
||||
tags,
|
||||
valid_from: None,
|
||||
valid_until: None,
|
||||
};
|
||||
|
||||
let mut storage = storage.lock().await;
|
||||
// ====================================================================
|
||||
// INGEST (storage lock)
|
||||
// ====================================================================
|
||||
let mut storage_guard = storage.lock().await;
|
||||
|
||||
// Check if force_create is enabled
|
||||
if args.force_create.unwrap_or(false) {
|
||||
// Use regular ingest
|
||||
let node = storage.ingest(input).map_err(|e| e.to_string())?;
|
||||
let node = storage_guard.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);
|
||||
|
||||
return Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"decision": "create",
|
||||
"nodeId": node.id,
|
||||
"nodeId": node_id,
|
||||
"message": "Memory created (force_create=true)",
|
||||
"hasEmbedding": node.has_embedding.unwrap_or(false),
|
||||
"hasEmbedding": has_embedding,
|
||||
"predictionError": 1.0,
|
||||
"importanceScore": importance_composite,
|
||||
"reason": "Forced creation - skipped similarity check"
|
||||
}));
|
||||
}
|
||||
|
|
@ -109,17 +160,26 @@ pub async fn execute(
|
|||
// Use smart ingest with prediction error gating
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
{
|
||||
let result = storage.smart_ingest(input).map_err(|e| e.to_string())?;
|
||||
let result = storage_guard.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);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
// Post-ingest cognitive side effects
|
||||
run_post_ingest(cognitive, &node_id, &node_content, &node_type, importance_composite);
|
||||
|
||||
return Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"decision": result.decision,
|
||||
"nodeId": result.node.id,
|
||||
"nodeId": node_id,
|
||||
"message": format!("Smart ingest complete: {}", result.reason),
|
||||
"hasEmbedding": result.node.has_embedding.unwrap_or(false),
|
||||
"hasEmbedding": has_embedding,
|
||||
"similarity": result.similarity,
|
||||
"predictionError": result.prediction_error,
|
||||
"supersededId": result.superseded_id,
|
||||
"importanceScore": importance_composite,
|
||||
"reason": result.reason,
|
||||
"explanation": match result.decision.as_str() {
|
||||
"create" => "Created new memory - content was different enough from existing memories",
|
||||
|
|
@ -131,25 +191,70 @@ pub async fn execute(
|
|||
"add_context" => "Added new content as context to existing memory",
|
||||
_ => "Memory processed successfully"
|
||||
}
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "embeddings", feature = "vector-search")))]
|
||||
{
|
||||
// Fall back to regular ingest if features not available
|
||||
let node = storage.ingest(input).map_err(|e| e.to_string())?;
|
||||
let node = storage_guard.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);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"decision": "create",
|
||||
"nodeId": node.id,
|
||||
"nodeId": node_id,
|
||||
"message": "Memory created (smart ingest requires embeddings feature)",
|
||||
"hasEmbedding": false,
|
||||
"predictionError": 1.0,
|
||||
"importanceScore": importance_composite,
|
||||
"reason": "Embeddings not available - used regular ingest"
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Cognitive post-ingest side effects: synaptic tagging, novelty update, hippocampal indexing.
|
||||
///
|
||||
/// Uses try_lock() for non-blocking access. If cognitive is locked, side effects are skipped.
|
||||
fn run_post_ingest(
|
||||
cognitive: &Arc<Mutex<CognitiveEngine>>,
|
||||
node_id: &str,
|
||||
content: &str,
|
||||
node_type: &str,
|
||||
importance_composite: f64,
|
||||
) {
|
||||
if let Ok(mut cog) = cognitive.try_lock() {
|
||||
// 4C. Synaptic tagging for retroactive capture
|
||||
if importance_composite > 0.3 {
|
||||
cog.synaptic_tagging.tag_memory(node_id);
|
||||
if importance_composite > 0.7 {
|
||||
// High importance → trigger PRP for nearby memories
|
||||
let event = ImportanceEvent::for_memory(node_id, ImportanceEventType::NoveltySpike);
|
||||
let _capture = cog.synaptic_tagging.trigger_prp(event);
|
||||
}
|
||||
}
|
||||
|
||||
// 4E. Update novelty model with new content
|
||||
cog.importance_signals.learn_content(content);
|
||||
|
||||
// 4F. Record in hippocampal index
|
||||
let _ = cog.hippocampal_index.index_memory(
|
||||
node_id,
|
||||
content,
|
||||
node_type,
|
||||
Utc::now(),
|
||||
None, // semantic_embedding — generated separately
|
||||
);
|
||||
|
||||
// 4G. Cross-project pattern recording
|
||||
cog.cross_project.record_project_memory(node_id, "default", None);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TESTS
|
||||
// ============================================================================
|
||||
|
|
@ -157,8 +262,13 @@ pub async fn execute(
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cognitive::CognitiveEngine;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_cognitive() -> Arc<Mutex<CognitiveEngine>> {
|
||||
Arc::new(Mutex::new(CognitiveEngine::new()))
|
||||
}
|
||||
|
||||
/// Create a test storage instance with a temporary database
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
|
@ -170,7 +280,7 @@ mod tests {
|
|||
async fn test_smart_ingest_empty_content_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "content": "" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("empty"));
|
||||
}
|
||||
|
|
@ -181,7 +291,7 @@ mod tests {
|
|||
let args = serde_json::json!({
|
||||
"content": "This is a test fact to remember."
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -197,7 +307,7 @@ mod tests {
|
|||
"content": "Force create test content.",
|
||||
"forceCreate": true
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let value = result.unwrap();
|
||||
|
|
@ -215,4 +325,83 @@ mod tests {
|
|||
assert!(schema_value["properties"]["forceCreate"].is_object());
|
||||
assert!(schema_value["required"].as_array().unwrap().contains(&serde_json::json!("content")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_smart_ingest_missing_args_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, &test_cognitive(), None).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Missing arguments"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_smart_ingest_whitespace_only_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "content": " \t\n " });
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("empty"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_smart_ingest_too_large_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let large = "x".repeat(1_000_001);
|
||||
let args = serde_json::json!({ "content": large });
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("too large"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_smart_ingest_exactly_1mb_succeeds() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let content = "x".repeat(1_000_000);
|
||||
let args = serde_json::json!({ "content": content });
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_smart_ingest_with_node_type() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"content": "A concept to remember",
|
||||
"node_type": "concept"
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_smart_ingest_with_tags_and_source() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({
|
||||
"content": "Tagged and sourced memory",
|
||||
"tags": ["test", "smart-ingest"],
|
||||
"source": "unit-test"
|
||||
});
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["success"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_smart_ingest_response_has_importance_score() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "content": "Important memory content" });
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
let value = result.unwrap();
|
||||
assert!(value["importanceScore"].is_number());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_smart_ingest_missing_content_field_fails() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "tags": ["test"] });
|
||||
let result = execute(&storage, &test_cognitive(), Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid arguments"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ pub fn schema() -> Value {
|
|||
struct TimelineArgs {
|
||||
start: Option<String>,
|
||||
end: Option<String>,
|
||||
#[serde(alias = "node_type")]
|
||||
node_type: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
limit: Option<i32>,
|
||||
|
|
@ -182,3 +183,184 @@ pub async fn execute(
|
|||
"timeline": timeline,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn test_storage() -> (Arc<Mutex<Storage>>, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
|
||||
(Arc::new(Mutex::new(storage)), dir)
|
||||
}
|
||||
|
||||
async fn ingest_test_memory(storage: &Arc<Mutex<Storage>>, content: &str) {
|
||||
let mut s = storage.lock().await;
|
||||
s.ingest(vestige_core::IngestInput {
|
||||
content: content.to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
source: None,
|
||||
sentiment_score: 0.0,
|
||||
sentiment_magnitude: 0.0,
|
||||
tags: vec!["timeline-test".to_string()],
|
||||
valid_from: None,
|
||||
valid_until: None,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_has_properties() {
|
||||
let s = schema();
|
||||
assert_eq!(s["type"], "object");
|
||||
assert!(s["properties"]["start"].is_object());
|
||||
assert!(s["properties"]["end"].is_object());
|
||||
assert!(s["properties"]["node_type"].is_object());
|
||||
assert!(s["properties"]["tags"].is_object());
|
||||
assert!(s["properties"]["limit"].is_object());
|
||||
assert!(s["properties"]["detail_level"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_datetime_rfc3339() {
|
||||
let result = parse_datetime("2026-02-18T10:30:00Z");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_datetime_date_only() {
|
||||
let result = parse_datetime("2026-02-18");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_datetime_invalid() {
|
||||
let result = parse_datetime("not-a-date");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid date/datetime"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_datetime_empty() {
|
||||
let result = parse_datetime("");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_timeline_no_args_defaults() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, None).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["tool"], "memory_timeline");
|
||||
assert_eq!(value["detailLevel"], "summary");
|
||||
assert!(value["range"]["start"].is_string());
|
||||
assert!(value["range"]["end"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_timeline_empty_database() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let result = execute(&storage, None).await;
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["totalMemories"], 0);
|
||||
assert_eq!(value["days"], 0);
|
||||
assert!(value["timeline"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_timeline_with_memories() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_test_memory(&storage, "Timeline test memory 1").await;
|
||||
ingest_test_memory(&storage, "Timeline test memory 2").await;
|
||||
let result = execute(&storage, None).await;
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["totalMemories"], 2);
|
||||
assert!(value["days"].as_u64().unwrap() >= 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_timeline_invalid_detail_level() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "detail_level": "invalid" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid detail_level"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_timeline_detail_level_brief() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_test_memory(&storage, "Brief test memory").await;
|
||||
let args = serde_json::json!({ "detail_level": "brief" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["detailLevel"], "brief");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_timeline_detail_level_full() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_test_memory(&storage, "Full test memory").await;
|
||||
let args = serde_json::json!({ "detail_level": "full" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["detailLevel"], "full");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_timeline_limit_clamped() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let args = serde_json::json!({ "limit": 0 });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok()); // limit clamped to 1, no error
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_timeline_with_date_range() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_test_memory(&storage, "Ranged memory").await;
|
||||
let args = serde_json::json!({
|
||||
"start": "2020-01-01",
|
||||
"end": "2030-12-31"
|
||||
});
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert!(value["totalMemories"].as_u64().unwrap() >= 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_timeline_node_type_filter() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_test_memory(&storage, "A fact memory").await;
|
||||
let args = serde_json::json!({ "node_type": "concept" });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let value = result.unwrap();
|
||||
// Ingested as "fact", filtering for "concept" should yield 0
|
||||
assert_eq!(value["totalMemories"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_timeline_tag_filter() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_test_memory(&storage, "Tagged memory").await;
|
||||
let args = serde_json::json!({ "tags": ["timeline-test"] });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let value = result.unwrap();
|
||||
assert!(value["totalMemories"].as_u64().unwrap() >= 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_timeline_tag_filter_no_match() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
ingest_test_memory(&storage, "Tagged memory").await;
|
||||
let args = serde_json::json!({ "tags": ["nonexistent-tag"] });
|
||||
let result = execute(&storage, Some(args)).await;
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value["totalMemories"], 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "vestige",
|
||||
"version": "1.0.0",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"description": "Cognitive memory for AI - MCP server with FSRS-6 spaced repetition",
|
||||
"author": "Sam Valladares",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@vestige/init",
|
||||
"version": "1.0.0",
|
||||
"version": "1.5.0",
|
||||
"description": "Give your AI a brain in 10 seconds — zero-config Vestige installer",
|
||||
"bin": {
|
||||
"vestige-init": "bin/init.js"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "vestige-mcp-server",
|
||||
"version": "1.1.3",
|
||||
"version": "1.5.0",
|
||||
"description": "Vestige MCP Server - AI Memory System for Claude and other assistants",
|
||||
"bin": {
|
||||
"vestige-mcp": "bin/vestige-mcp.js",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"manifest_version": "0.2",
|
||||
"name": "vestige",
|
||||
"display_name": "Vestige",
|
||||
"version": "1.1.0",
|
||||
"version": "1.5.0",
|
||||
"description": "AI memory system built on 130 years of cognitive science. FSRS-6 spaced repetition, synaptic tagging, and local-first storage.",
|
||||
"author": {
|
||||
"name": "Sam Valladares",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue