mirror of
https://github.com/samvallad33/vestige.git
synced 2026-06-02 19:55:15 +02:00
First AI memory system to model forgetting as a neuroscience-grounded PROCESS rather than passive decay. Adds the `suppress` MCP tool (#24), Rac1 cascade worker, migration V10, and dashboard forgetting indicators. Based on: - Anderson, Hanslmayr & Quaegebeur (2025), Nat Rev Neurosci — right lateral PFC as the domain-general inhibitory controller; SIF compounds with each stopping attempt. - Cervantes-Sandoval et al. (2020), Front Cell Neurosci PMC7477079 — Rac1 GTPase as the active synaptic destabilization mechanism. What's new: * `suppress` MCP tool — each call compounds `suppression_count` and subtracts a `0.15 × count` penalty (saturating at 80%) from retrieval scores during hybrid search. Distinct from delete (removes) and demote (one-shot). * Rac1 cascade worker — background sweep piggybacks the 6h consolidation loop, walks `memory_connections` edges from recently-suppressed seeds, applies attenuated FSRS decay to co-activated neighbors. You don't just forget Jake — you fade the café, the roommate, the birthday. * 24h labile window — reversible via `suppress({id, reverse: true})` within 24 hours. Matches Nader reconsolidation semantics. * Migration V10 — additive-only (`suppression_count`, `suppressed_at` + partial indices). All v2.0.x DBs upgrade seamlessly on first launch. * Dashboard: `ForgettingIndicator.svelte` pulses when suppressions are active. 3D graph nodes dim to 20% opacity when suppressed. New WebSocket events: `MemorySuppressed`, `MemoryUnsuppressed`, `Rac1CascadeSwept`. Heartbeat carries `suppressed_count`. * Search pipeline: SIF penalty inserted into the accessibility stage so it stacks on top of passive FSRS decay. * Tool count bumped 23 → 24. Cognitive modules 29 → 30. Memories persist — they are INHIBITED, not erased. `memory.get(id)` returns full content through any number of suppressions. The 24h labile window is a grace period for regret. Also fixes issue #31 (dashboard graph view buggy) as a companion UI bug discovered during the v2.0.5 audit cycle: * Root cause: node glow `SpriteMaterial` had no `map`, so `THREE.Sprite` rendered as a solid-coloured 1×1 plane. Additive blending + `UnrealBloomPass(0.8, 0.4, 0.85)` amplified the square edges into hard-edged glowing cubes. * Fix: shared 128×128 radial-gradient `CanvasTexture` singleton used as the sprite map. Retuned bloom to `(0.55, 0.6, 0.2)`. Halved fog density (0.008 → 0.0035). Edges bumped from dark navy `0x4a4a7a` to brand violet `0x8b5cf6` with higher opacity. Added explicit `scene.background` and a 2000-point starfield for depth. * 21 regression tests added in `ui-fixes.test.ts` locking every invariant in (shared texture singleton, depthWrite:false, scale ×6, bloom magic numbers via source regex, starfield presence). Tests: 1,284 Rust (+47) + 171 Vitest (+21) = 1,455 total, 0 failed Clippy: clean across all targets, zero warnings Release binary: 22.6MB, `cargo build --release -p vestige-mcp` green Versions: workspace aligned at 2.0.5 across all 6 crates/packages Closes #31
104 lines
3.6 KiB
Rust
104 lines
3.6 KiB
Rust
//! FTS5 Query Sanitization
|
|
//!
|
|
//! Always-available utilities for SQLite FTS5 full-text search.
|
|
//! Separated from the `search` module (which requires the `vector-search` feature)
|
|
//! because FTS5 keyword search is a core capability that works without embeddings.
|
|
|
|
/// Dangerous FTS5 operators that could be used for injection or DoS
|
|
const FTS5_OPERATORS: &[&str] = &["OR", "AND", "NOT", "NEAR"];
|
|
|
|
/// Sanitize input for FTS5 MATCH queries
|
|
///
|
|
/// Prevents:
|
|
/// - Boolean operator injection (OR, AND, NOT, NEAR)
|
|
/// - Column targeting attacks (content:secret)
|
|
/// - Prefix/suffix wildcards for data extraction
|
|
/// - DoS via complex query patterns
|
|
pub fn sanitize_fts5_query(query: &str) -> String {
|
|
// Limit query length to prevent DoS (char-aware to avoid UTF-8 boundary issues)
|
|
let limited: String = query.chars().take(1000).collect();
|
|
|
|
// Remove FTS5 special characters and operators
|
|
let mut sanitized = limited.to_string();
|
|
|
|
// Remove special characters: * : ^ - " ( )
|
|
sanitized = sanitized
|
|
.chars()
|
|
.map(|c| match c {
|
|
'*' | ':' | '^' | '-' | '"' | '(' | ')' | '{' | '}' | '[' | ']' => ' ',
|
|
_ => c,
|
|
})
|
|
.collect();
|
|
|
|
// Remove FTS5 boolean operators (case-insensitive)
|
|
for op in FTS5_OPERATORS {
|
|
// Use word boundary replacement to avoid partial matches
|
|
let pattern = format!(" {} ", op);
|
|
sanitized = sanitized.replace(&pattern, " ");
|
|
sanitized = sanitized.replace(&pattern.to_lowercase(), " ");
|
|
|
|
// Handle operators at start/end (using char-aware operations)
|
|
let upper = sanitized.to_uppercase();
|
|
let start_pattern = format!("{} ", op);
|
|
if upper.starts_with(&start_pattern) {
|
|
sanitized = sanitized.chars().skip(op.len()).collect();
|
|
}
|
|
let end_pattern = format!(" {}", op);
|
|
if upper.ends_with(&end_pattern) {
|
|
let char_count = sanitized.chars().count();
|
|
sanitized = sanitized
|
|
.chars()
|
|
.take(char_count.saturating_sub(op.len()))
|
|
.collect();
|
|
}
|
|
}
|
|
|
|
// Collapse multiple spaces and trim
|
|
let sanitized = sanitized.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
|
|
// If empty after sanitization, return a safe default
|
|
if sanitized.is_empty() {
|
|
return "\"\"".to_string(); // Empty phrase - matches nothing safely
|
|
}
|
|
|
|
// Wrap in quotes to treat as literal phrase search
|
|
format!("\"{}\"", sanitized)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_sanitize_fts5_query_basic() {
|
|
assert_eq!(sanitize_fts5_query("hello world"), "\"hello world\"");
|
|
}
|
|
|
|
#[test]
|
|
fn test_sanitize_fts5_query_operators() {
|
|
assert_eq!(sanitize_fts5_query("hello OR world"), "\"hello world\"");
|
|
assert_eq!(sanitize_fts5_query("hello AND world"), "\"hello world\"");
|
|
assert_eq!(sanitize_fts5_query("NOT hello"), "\"hello\"");
|
|
}
|
|
|
|
#[test]
|
|
fn test_sanitize_fts5_query_special_chars() {
|
|
assert_eq!(sanitize_fts5_query("hello* world"), "\"hello world\"");
|
|
assert_eq!(sanitize_fts5_query("content:secret"), "\"content secret\"");
|
|
assert_eq!(sanitize_fts5_query("^boost"), "\"boost\"");
|
|
}
|
|
|
|
#[test]
|
|
fn test_sanitize_fts5_query_empty() {
|
|
assert_eq!(sanitize_fts5_query(""), "\"\"");
|
|
assert_eq!(sanitize_fts5_query(" "), "\"\"");
|
|
assert_eq!(sanitize_fts5_query("* : ^"), "\"\"");
|
|
}
|
|
|
|
#[test]
|
|
fn test_sanitize_fts5_query_length_limit() {
|
|
let long_query = "a".repeat(2000);
|
|
let sanitized = sanitize_fts5_query(&long_query);
|
|
assert!(sanitized.len() <= 1004);
|
|
}
|
|
}
|