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,
|
ImportanceScore,
|
||||||
RetentionDistribution,
|
RetentionDistribution,
|
||||||
ConsolidationResult,
|
ConsolidationResult,
|
||||||
IntentionItem
|
IntentionItem,
|
||||||
|
SuppressResult,
|
||||||
|
UnsuppressResult
|
||||||
} from '$types';
|
} from '$types';
|
||||||
|
|
||||||
const BASE = '/api';
|
const BASE = '/api';
|
||||||
|
|
@ -34,7 +36,18 @@ export const api = {
|
||||||
get: (id: string) => fetcher<Memory>(`/memories/${id}`),
|
get: (id: string) => fetcher<Memory>(`/memories/${id}`),
|
||||||
delete: (id: string) => fetcher<{ deleted: boolean }>(`/memories/${id}`, { method: 'DELETE' }),
|
delete: (id: string) => fetcher<{ deleted: boolean }>(`/memories/${id}`, { method: 'DELETE' }),
|
||||||
promote: (id: string) => fetcher<Memory>(`/memories/${id}/promote`, { method: 'POST' }),
|
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
|
// Search
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,34 @@ export interface VestigeEvent {
|
||||||
data: Record<string, unknown>;
|
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)
|
// Intentions (prospective memory)
|
||||||
export interface IntentionItem {
|
export interface IntentionItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,22 @@
|
||||||
<span role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); api.memories.demote(memory.id); }}
|
<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); } }}
|
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>
|
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(); }}
|
<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(); } }}
|
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>
|
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
|
/// Get system stats
|
||||||
pub async fn get_stats(State(state): State<AppState>) -> Result<Json<Value>, StatusCode> {
|
pub async fn get_stats(State(state): State<AppState>) -> Result<Json<Value>, StatusCode> {
|
||||||
let stats = state
|
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}", delete(handlers::delete_memory))
|
||||||
.route("/api/memories/{id}/promote", post(handlers::promote_memory))
|
.route("/api/memories/{id}/promote", post(handlers::promote_memory))
|
||||||
.route("/api/memories/{id}/demote", post(handlers::demote_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
|
// Search
|
||||||
.route("/api/search", get(handlers::search_memories))
|
.route("/api/search", get(handlers::search_memories))
|
||||||
// Stats & health
|
// Stats & health
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue