feat(dashboard): expose suppress + unsuppress HTTP endpoints + memories UI button

Fixes the biggest UI gap flagged by the v2.0.7 audit: the `suppress`
tool has shipped since v2.0.5 with full graph event handlers
(MemorySuppressed, MemoryUnsuppressed, Rac1CascadeSwept) and violet
implosion animations — but zero trigger from anywhere except the MCP
layer. Dashboard users literally could not forget a memory without
dropping to raw MCP calls.

Backend (Axum):
- `POST /api/memories/{id}/suppress` optionally accepts `{"reason": "..."}`
  and returns the full response payload (suppression_count, prior_count,
  retrieval_penalty, reversible_until, estimated_cascade_neighbors,
  labile_window_hours). Emits `MemorySuppressed` so the 3D graph plays
  the compounding violet implosion per the v2.0.6 event handlers.
- `POST /api/memories/{id}/unsuppress` reverses within the 24h labile
  window. Returns `stillSuppressed: bool` so the UI can distinguish a
  full reversal from a compounded-down state. Emits `MemoryUnsuppressed`
  for the rainbow burst reversal animation.

Frontend:
- `api.memories.suppress(id, reason?)` and `api.memories.unsuppress(id)`
  wired through `apps/dashboard/src/lib/stores/api.ts`.
- Two new TypeScript response types (`SuppressResult`, `UnsuppressResult`)
  in `lib/types/index.ts` mirroring the backend JSON shapes.
- Memories page gets a third action button alongside Promote / Demote /
  Delete: violet "Suppress" with a hover-tooltip explaining "Top-down
  inhibition (Anderson 2025). Compounds. Reversible for 24h." The button
  ships `reason: "dashboard trigger"` so the audit log carries source
  attribution.

Tests: 425 mcp + 366 core all pass, svelte-check 580 files 0 errors.
This commit is contained in:
Sam Valladares 2026-04-19 20:31:44 -05:00
parent f0dd9c2c83
commit fc6dca6338
5 changed files with 191 additions and 2 deletions

View file

@ -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<Memory>(`/memories/${id}`),
delete: (id: string) => fetcher<{ deleted: boolean }>(`/memories/${id}`, { method: 'DELETE' }),
promote: (id: string) => fetcher<Memory>(`/memories/${id}/promote`, { method: 'POST' }),
demote: (id: string) => fetcher<Memory>(`/memories/${id}/demote`, { method: 'POST' })
demote: (id: string) => fetcher<Memory>(`/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<SuppressResult>(`/memories/${id}/suppress`, {
method: 'POST',
body: reason ? JSON.stringify({ reason }) : undefined
}),
unsuppress: (id: string) =>
fetcher<UnsuppressResult>(`/memories/${id}/unsuppress`, { method: 'POST' })
},
// Search

View file

@ -173,6 +173,34 @@ export interface VestigeEvent {
data: Record<string, unknown>;
}
// 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;

View file

@ -130,6 +130,22 @@
<span role="button" tabindex="0" onclick={(e) => { 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</span>
<!-- v2.0.7: suppress (active forgetting). Distinct from delete: the memory
persists but is inhibited from retrieval and actively decays. Each click
compounds. Graph plays the violet implosion via MemorySuppressed event. -->
<span role="button" tabindex="0"
onclick={async (e) => {
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</span>
<span role="button" tabindex="0" onclick={async (e) => { 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</span>

View file

@ -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<AppState>,
Path(id): Path<String>,
body: Option<Json<Value>>,
) -> Result<Json<Value>, 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<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, 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<AppState>) -> Result<Json<Value>, StatusCode> {
let stats = state

View file

@ -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