diff --git a/apps/dashboard/src/lib/stores/api.ts b/apps/dashboard/src/lib/stores/api.ts index c620746..e0cafec 100644 --- a/apps/dashboard/src/lib/stores/api.ts +++ b/apps/dashboard/src/lib/stores/api.ts @@ -10,7 +10,9 @@ import type { ImportanceScore, RetentionDistribution, ConsolidationResult, - IntentionItem + IntentionItem, + SuppressResult, + UnsuppressResult } from '$types'; const BASE = '/api'; @@ -34,7 +36,18 @@ export const api = { get: (id: string) => fetcher(`/memories/${id}`), delete: (id: string) => fetcher<{ deleted: boolean }>(`/memories/${id}`, { method: 'DELETE' }), promote: (id: string) => fetcher(`/memories/${id}/promote`, { method: 'POST' }), - demote: (id: string) => fetcher(`/memories/${id}/demote`, { method: 'POST' }) + demote: (id: string) => fetcher(`/memories/${id}/demote`, { method: 'POST' }), + // v2.0.7: suppress + unsuppress. Anderson 2025 top-down inhibitory + // control. Each suppress call compounds; reversible within 24h. The + // backend emits MemorySuppressed / MemoryUnsuppressed so the 3D graph + // plays the violet implosion / rainbow reversal. + suppress: (id: string, reason?: string) => + fetcher(`/memories/${id}/suppress`, { + method: 'POST', + body: reason ? JSON.stringify({ reason }) : undefined + }), + unsuppress: (id: string) => + fetcher(`/memories/${id}/unsuppress`, { method: 'POST' }) }, // Search diff --git a/apps/dashboard/src/lib/types/index.ts b/apps/dashboard/src/lib/types/index.ts index 8115163..6948d69 100644 --- a/apps/dashboard/src/lib/types/index.ts +++ b/apps/dashboard/src/lib/types/index.ts @@ -173,6 +173,34 @@ export interface VestigeEvent { data: Record; } +// v2.0.7: active-forgetting response shapes. Each suppress call COMPOUNDS; +// `suppressionCount` is the lifetime total. `reversibleUntil` is the ISO +// timestamp after which the labile window closes and the suppression locks in. +export interface SuppressResult { + suppressed: true; + id: string; + suppressionCount: number; + priorCount: number; + retrievalPenalty: number; + retentionStrength: number; + retrievalStrength: number; + stability: number; + estimatedCascadeNeighbors: number; + reversibleUntil: string; + labileWindowHours: number; + reason: string | null; +} + +export interface UnsuppressResult { + unsuppressed: true; + id: string; + suppressionCount: number; + stillSuppressed: boolean; + retentionStrength: number; + retrievalStrength: number; + stability: number; +} + // Intentions (prospective memory) export interface IntentionItem { id: string; diff --git a/apps/dashboard/src/routes/(app)/memories/+page.svelte b/apps/dashboard/src/routes/(app)/memories/+page.svelte index 66ba391..e1000cd 100644 --- a/apps/dashboard/src/routes/(app)/memories/+page.svelte +++ b/apps/dashboard/src/routes/(app)/memories/+page.svelte @@ -130,6 +130,22 @@ { e.stopPropagation(); api.memories.demote(memory.id); }} onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); api.memories.demote(memory.id); } }} class="px-3 py-1.5 bg-decay/20 text-decay text-xs rounded-lg hover:bg-decay/30 cursor-pointer select-none">Demote + + { + e.stopPropagation(); + await api.memories.suppress(memory.id, 'dashboard trigger'); + }} + onkeydown={async (e) => { + if (e.key === 'Enter') { + e.stopPropagation(); + await api.memories.suppress(memory.id, 'dashboard trigger'); + } + }} + title="Top-down inhibition (Anderson 2025). Compounds. Reversible for 24h." + class="px-3 py-1.5 bg-purple-500/20 text-purple-400 text-xs rounded-lg hover:bg-purple-500/30 cursor-pointer select-none">Suppress { e.stopPropagation(); await api.memories.delete(memory.id); loadMemories(); }} onkeydown={async (e) => { if (e.key === 'Enter') { e.stopPropagation(); await api.memories.delete(memory.id); loadMemories(); } }} class="px-3 py-1.5 bg-decay/10 text-decay/60 text-xs rounded-lg hover:bg-decay/20 ml-auto cursor-pointer select-none">Delete diff --git a/crates/vestige-mcp/src/dashboard/handlers.rs b/crates/vestige-mcp/src/dashboard/handlers.rs index b999cae..d1c3187 100644 --- a/crates/vestige-mcp/src/dashboard/handlers.rs +++ b/crates/vestige-mcp/src/dashboard/handlers.rs @@ -217,6 +217,129 @@ pub async fn demote_memory( }))) } +/// Actively suppress a memory via top-down inhibitory control. +/// +/// Optional JSON body: `{"reason": "..."}`. Each call compounds — the +/// `suppression_count` field on the memory increments, FSRS takes a hit, +/// and the background Rac1 worker fades co-activated neighbors over the +/// next 72 hours. Emits a `MemorySuppressed` event so the 3D graph plays +/// the violet implosion animation. +/// +/// Reversible within the 24-hour labile window via `unsuppress_memory`. +/// +/// Fixes the v2.0.5 UI gap: `suppress` had full graph event handlers and +/// MCP tool exposure, but zero HTTP endpoint and no dashboard trigger. +pub async fn suppress_memory( + State(state): State, + Path(id): Path, + body: Option>, +) -> Result, StatusCode> { + use vestige_core::neuroscience::active_forgetting::{ + ActiveForgettingSystem, DEFAULT_LABILE_HOURS, + }; + + let reason = body + .as_ref() + .and_then(|Json(v)| v.get("reason")) + .and_then(|r| r.as_str()) + .map(String::from); + + let sys = ActiveForgettingSystem::new(); + + // Pre-count to surface in the response + for the event payload. + let before_count = state + .storage + .get_node(&id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .map(|n| n.suppression_count) + .unwrap_or(0); + + let node = state + .storage + .suppress_memory(&id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Estimate cascade size for the UX; capped at 100 so the number is + // stable even on highly-connected nodes. + let estimated_cascade = state + .storage + .get_connections_for_memory(&id) + .map(|edges| edges.len().min(100)) + .unwrap_or(0); + + let reversible_until = node + .suppressed_at + .map(|t| sys.reversible_until(t)) + .unwrap_or_else(chrono::Utc::now); + let retrieval_penalty = sys.retrieval_penalty(node.suppression_count); + + tracing::info!( + id = %id, + count = node.suppression_count, + reason = reason.as_deref().unwrap_or(""), + "Memory suppressed via dashboard" + ); + + state.emit(VestigeEvent::MemorySuppressed { + id: node.id.clone(), + suppression_count: node.suppression_count, + estimated_cascade, + reversible_until, + timestamp: chrono::Utc::now(), + }); + + Ok(Json(serde_json::json!({ + "suppressed": true, + "id": node.id, + "suppressionCount": node.suppression_count, + "priorCount": before_count, + "retrievalPenalty": retrieval_penalty, + "retentionStrength": node.retention_strength, + "retrievalStrength": node.retrieval_strength, + "stability": node.stability, + "estimatedCascadeNeighbors": estimated_cascade, + "reversibleUntil": reversible_until.to_rfc3339(), + "labileWindowHours": DEFAULT_LABILE_HOURS, + "reason": reason, + }))) +} + +/// Reverse a prior suppression, if still inside the 24-hour labile +/// window. Emits `MemoryUnsuppressed` so the graph plays the rainbow +/// reversal burst. Returns the current suppression state so the UI +/// knows whether a single click fully cleared the suppression or whether +/// the memory still has compounded suppressions remaining. +pub async fn unsuppress_memory( + State(state): State, + Path(id): Path, +) -> Result, StatusCode> { + use vestige_core::neuroscience::active_forgetting::ActiveForgettingSystem; + + let sys = ActiveForgettingSystem::new(); + let node = state + .storage + .reverse_suppression(&id, sys.labile_hours) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let still_suppressed = node.suppression_count > 0; + + state.emit(VestigeEvent::MemoryUnsuppressed { + id: node.id.clone(), + remaining_count: node.suppression_count, + timestamp: chrono::Utc::now(), + }); + + Ok(Json(serde_json::json!({ + "unsuppressed": true, + "id": node.id, + "suppressionCount": node.suppression_count, + "stillSuppressed": still_suppressed, + "retentionStrength": node.retention_strength, + "retrievalStrength": node.retrieval_strength, + "stability": node.stability, + }))) +} + /// Get system stats pub async fn get_stats(State(state): State) -> Result, StatusCode> { let stats = state diff --git a/crates/vestige-mcp/src/dashboard/mod.rs b/crates/vestige-mcp/src/dashboard/mod.rs index 4a235a6..e45b673 100644 --- a/crates/vestige-mcp/src/dashboard/mod.rs +++ b/crates/vestige-mcp/src/dashboard/mod.rs @@ -138,6 +138,15 @@ fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) { .route("/api/memories/{id}", delete(handlers::delete_memory)) .route("/api/memories/{id}/promote", post(handlers::promote_memory)) .route("/api/memories/{id}/demote", post(handlers::demote_memory)) + // v2.0.7: active-forgetting HTTP surface. `suppress` was MCP-only + // since v2.0.5 despite having full graph event handlers; this closes + // the gap so dashboard users can trigger inhibition without dropping + // to the MCP layer. + .route("/api/memories/{id}/suppress", post(handlers::suppress_memory)) + .route( + "/api/memories/{id}/unsuppress", + post(handlers::unsuppress_memory), + ) // Search .route("/api/search", get(handlers::search_memories)) // Stats & health