mirror of
https://github.com/samvallad33/vestige.git
synced 2026-05-08 23:32:37 +02:00
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:
parent
f0dd9c2c83
commit
fc6dca6338
5 changed files with 191 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue