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
This commit is contained in:
Sam Valladares 2026-04-14 17:30:30 -05:00
parent 95bde93b49
commit 8178beb961
359 changed files with 8277 additions and 3416 deletions

View file

@ -17,17 +17,17 @@ use axum::response::IntoResponse;
use axum::routing::{delete, post};
use axum::{Json, Router};
use subtle::ConstantTimeEq;
use tokio::sync::{broadcast, Mutex, RwLock};
use tokio::sync::{Mutex, RwLock, broadcast};
use tower::ServiceBuilder;
use tower::limit::ConcurrencyLimitLayer;
use tower_http::cors::CorsLayer;
use tracing::{info, warn};
use crate::cognitive::CognitiveEngine;
use crate::dashboard::events::VestigeEvent;
use crate::protocol::types::JsonRpcRequest;
use crate::server::McpServer;
use vestige_core::Storage;
use crate::dashboard::events::VestigeEvent;
/// Maximum concurrent sessions.
const MAX_SESSIONS: usize = 100;
@ -95,7 +95,11 @@ pub async fn start_http_transport(
});
let removed = before - map.len();
if removed > 0 {
info!("Session reaper: removed {} idle sessions ({} active)", removed, map.len());
info!(
"Session reaper: removed {} idle sessions ({} active)",
removed,
map.len()
);
}
}
});
@ -119,8 +123,15 @@ pub async fn start_http_transport(
.filter_map(|s| s.parse().ok())
.collect::<Vec<_>>(),
)
.allow_methods([axum::http::Method::POST, axum::http::Method::DELETE, axum::http::Method::OPTIONS])
.allow_headers([axum::http::header::CONTENT_TYPE, axum::http::header::AUTHORIZATION])
.allow_methods([
axum::http::Method::POST,
axum::http::Method::DELETE,
axum::http::Method::OPTIONS,
])
.allow_headers([
axum::http::header::CONTENT_TYPE,
axum::http::header::AUTHORIZATION,
]),
),
)
.with_state(state);
@ -156,9 +167,10 @@ fn validate_auth(headers: &HeaderMap, expected: &str) -> Result<(), (StatusCode,
.and_then(|v| v.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?;
let token = header
.strip_prefix("Bearer ")
.ok_or((StatusCode::UNAUTHORIZED, "Invalid Authorization scheme (expected Bearer)"))?;
let token = header.strip_prefix("Bearer ").ok_or((
StatusCode::UNAUTHORIZED,
"Invalid Authorization scheme (expected Bearer)",
))?;
// Constant-time comparison: prevents timing side-channel attacks.
// We first check lengths match (length itself is not secret since UUIDs
@ -209,11 +221,7 @@ async fn post_mcp(
// Take write lock immediately to avoid TOCTOU race on MAX_SESSIONS check.
let mut sessions = state.sessions.write().await;
if sessions.len() >= MAX_SESSIONS {
return (
StatusCode::SERVICE_UNAVAILABLE,
"Too many active sessions",
)
.into_response();
return (StatusCode::SERVICE_UNAVAILABLE, "Too many active sessions").into_response();
}
let server = McpServer::new_with_events(
@ -242,13 +250,23 @@ async fn post_mcp(
match response {
Some(resp) => {
let mut resp_headers = HeaderMap::new();
resp_headers.insert("mcp-session-id", session_id.parse().unwrap_or_else(|_| axum::http::HeaderValue::from_static("invalid")));
resp_headers.insert(
"mcp-session-id",
session_id
.parse()
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("invalid")),
);
(StatusCode::OK, resp_headers, Json(resp)).into_response()
}
None => {
// Notifications return 202
let mut resp_headers = HeaderMap::new();
resp_headers.insert("mcp-session-id", session_id.parse().unwrap_or_else(|_| axum::http::HeaderValue::from_static("invalid")));
resp_headers.insert(
"mcp-session-id",
session_id
.parse()
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("invalid")),
);
(StatusCode::ACCEPTED, resp_headers).into_response()
}
}
@ -273,11 +291,7 @@ async fn post_mcp(
let session = match session {
Some(s) => s,
None => {
return (
StatusCode::NOT_FOUND,
"Session not found or expired",
)
.into_response();
return (StatusCode::NOT_FOUND, "Session not found or expired").into_response();
}
};
@ -288,7 +302,12 @@ async fn post_mcp(
};
let mut resp_headers = HeaderMap::new();
resp_headers.insert("mcp-session-id", session_id.parse().unwrap_or_else(|_| axum::http::HeaderValue::from_static("invalid")));
resp_headers.insert(
"mcp-session-id",
session_id
.parse()
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("invalid")),
);
match response {
Some(resp) => (StatusCode::OK, resp_headers, Json(resp)).into_response(),
@ -308,7 +327,13 @@ async fn delete_mcp(
let session_id = match session_id_from_headers(&headers) {
Some(id) => id,
None => return (StatusCode::BAD_REQUEST, "Missing or invalid Mcp-Session-Id header").into_response(),
None => {
return (
StatusCode::BAD_REQUEST,
"Missing or invalid Mcp-Session-Id header",
)
.into_response();
}
};
let mut sessions = state.sessions.write().await;

View file

@ -25,7 +25,6 @@ pub struct JsonRpcRequest {
pub params: Option<Value>,
}
/// JSON-RPC Response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcResponse {
@ -129,7 +128,10 @@ impl JsonRpcError {
#[allow(dead_code)] // Reserved for future resource handling
pub fn resource_not_found(uri: &str) -> Self {
Self::new(ErrorCode::ResourceNotFound, &format!("Resource not found: {}", uri))
Self::new(
ErrorCode::ResourceNotFound,
&format!("Resource not found: {}", uri),
)
}
}