vestige/crates/vestige-core/src/fts.rs
Sam Valladares 8178beb961 feat(v2.0.5): Intentional Amnesia — active forgetting via top-down inhibitory control
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
2026-04-14 17:30:30 -05:00

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);
}
}