mirror of
https://github.com/samvallad33/vestige.git
synced 2026-05-12 09:12:37 +02:00
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
244 lines
8.8 KiB
Svelte
244 lines
8.8 KiB
Svelte
<script lang="ts">
|
|
import '../app.css';
|
|
import { onMount } from 'svelte';
|
|
import { page } from '$app/stores';
|
|
import { goto } from '$app/navigation';
|
|
import { base } from '$app/paths';
|
|
import { websocket, isConnected, memoryCount, avgRetention, suppressedCount } from '$stores/websocket';
|
|
import ForgettingIndicator from '$lib/components/ForgettingIndicator.svelte';
|
|
|
|
let { children } = $props();
|
|
let showCommandPalette = $state(false);
|
|
let cmdQuery = $state('');
|
|
let cmdInput = $state<HTMLInputElement>(undefined as unknown as HTMLInputElement);
|
|
|
|
onMount(() => {
|
|
websocket.connect();
|
|
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
e.preventDefault();
|
|
showCommandPalette = !showCommandPalette;
|
|
cmdQuery = '';
|
|
if (showCommandPalette) {
|
|
requestAnimationFrame(() => cmdInput?.focus());
|
|
}
|
|
return;
|
|
}
|
|
if (e.key === 'Escape' && showCommandPalette) {
|
|
showCommandPalette = false;
|
|
return;
|
|
}
|
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
|
if (e.key === '/') {
|
|
e.preventDefault();
|
|
const searchInput = document.querySelector<HTMLInputElement>('input[type="text"]');
|
|
searchInput?.focus();
|
|
return;
|
|
}
|
|
// Single-key navigation shortcuts
|
|
const shortcutMap: Record<string, string> = {
|
|
g: '/graph', m: '/memories', t: '/timeline', f: '/feed',
|
|
e: '/explore', i: '/intentions', s: '/stats',
|
|
};
|
|
const target = shortcutMap[e.key.toLowerCase()];
|
|
if (target && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
e.preventDefault();
|
|
goto(`${base}${target}`);
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', onKeyDown);
|
|
return () => {
|
|
websocket.disconnect();
|
|
window.removeEventListener('keydown', onKeyDown);
|
|
};
|
|
});
|
|
|
|
const nav = [
|
|
{ href: '/graph', label: 'Graph', icon: '◎', shortcut: 'G' },
|
|
{ href: '/memories', label: 'Memories', icon: '◈', shortcut: 'M' },
|
|
{ href: '/timeline', label: 'Timeline', icon: '◷', shortcut: 'T' },
|
|
{ href: '/feed', label: 'Feed', icon: '◉', shortcut: 'F' },
|
|
{ href: '/explore', label: 'Explore', icon: '◬', shortcut: 'E' },
|
|
{ href: '/intentions', label: 'Intentions', icon: '◇', shortcut: 'I' },
|
|
{ href: '/stats', label: 'Stats', icon: '◫', shortcut: 'S' },
|
|
{ href: '/settings', label: 'Settings', icon: '⚙', shortcut: ',' },
|
|
];
|
|
|
|
// Mobile nav shows top 5 items
|
|
const mobileNav = nav.slice(0, 5);
|
|
|
|
function isActive(href: string, currentPath: string): boolean {
|
|
const path = currentPath.startsWith(base) ? currentPath.slice(base.length) || '/' : currentPath;
|
|
if (href === '/graph') return path === '/' || path === '/graph';
|
|
return path.startsWith(href);
|
|
}
|
|
|
|
let filteredNav = $derived(
|
|
cmdQuery
|
|
? nav.filter(n => n.label.toLowerCase().includes(cmdQuery.toLowerCase()))
|
|
: nav
|
|
);
|
|
|
|
function cmdNavigate(href: string) {
|
|
showCommandPalette = false;
|
|
cmdQuery = '';
|
|
goto(`${base}${href}`);
|
|
}
|
|
</script>
|
|
|
|
<!-- Ambient background orbs -->
|
|
<div class="ambient-orb ambient-orb-1" aria-hidden="true"></div>
|
|
<div class="ambient-orb ambient-orb-2" aria-hidden="true"></div>
|
|
<div class="ambient-orb ambient-orb-3" aria-hidden="true"></div>
|
|
|
|
<!-- Desktop: sidebar + content -->
|
|
<!-- Mobile: content + bottom nav -->
|
|
<div class="flex flex-col md:flex-row h-screen overflow-hidden bg-void relative z-[1]">
|
|
<!-- Desktop Sidebar (hidden on mobile) -->
|
|
<nav class="hidden md:flex w-16 lg:w-56 flex-shrink-0 glass-sidebar flex-col">
|
|
<!-- Logo -->
|
|
<a href="{base}/graph" class="flex items-center gap-3 px-4 py-5 border-b border-synapse/10">
|
|
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-dream to-synapse flex items-center justify-center text-bright text-sm font-bold shadow-lg shadow-synapse/20">
|
|
V
|
|
</div>
|
|
<span class="hidden lg:block text-sm font-semibold text-bright tracking-wide">VESTIGE</span>
|
|
</a>
|
|
|
|
<!-- Nav items -->
|
|
<div class="flex-1 py-3 flex flex-col gap-1 px-2">
|
|
{#each nav as item}
|
|
{@const active = isActive(item.href, $page.url.pathname)}
|
|
<a
|
|
href="{base}{item.href}"
|
|
class="flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 text-sm
|
|
{active
|
|
? 'bg-synapse/15 text-synapse-glow border border-synapse/30 shadow-[0_0_12px_rgba(99,102,241,0.15)] nav-active-border'
|
|
: 'text-dim hover:text-text hover:bg-white/[0.03] border border-transparent'}"
|
|
>
|
|
<span class="text-base w-5 text-center">{item.icon}</span>
|
|
<span class="hidden lg:block">{item.label}</span>
|
|
<span class="hidden lg:block ml-auto text-[10px] text-muted/50 font-mono">{item.shortcut}</span>
|
|
</a>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Quick action -->
|
|
<div class="px-2 pb-2">
|
|
<button
|
|
onclick={() => { showCommandPalette = true; cmdQuery = ''; requestAnimationFrame(() => cmdInput?.focus()); }}
|
|
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-muted hover:text-dim hover:bg-white/[0.03] transition border border-subtle/15"
|
|
>
|
|
<span class="text-[10px] font-mono bg-white/[0.04] px-1.5 py-0.5 rounded">⌘K</span>
|
|
<span class="hidden lg:block">Command</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Status footer -->
|
|
<div class="px-3 py-4 border-t border-synapse/10 space-y-2">
|
|
<div class="flex items-center gap-2 text-xs">
|
|
<div class="w-2 h-2 rounded-full {$isConnected ? 'bg-recall animate-pulse-glow' : 'bg-decay'}"></div>
|
|
<span class="hidden lg:block text-dim">{$isConnected ? 'Connected' : 'Offline'}</span>
|
|
</div>
|
|
<div class="hidden lg:block text-xs text-muted">
|
|
<div>{$memoryCount} memories</div>
|
|
<div>{($avgRetention * 100).toFixed(0)}% retention</div>
|
|
</div>
|
|
{#if $suppressedCount > 0}
|
|
<div class="hidden lg:block pt-1">
|
|
<ForgettingIndicator />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- Main content -->
|
|
<main class="flex-1 flex flex-col min-h-0 pb-16 md:pb-0">
|
|
<div class="animate-page-in flex-1 min-h-0 overflow-y-auto">
|
|
{@render children()}
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Mobile Bottom Nav (hidden on desktop) -->
|
|
<nav class="md:hidden fixed bottom-0 inset-x-0 glass border-t border-synapse/10 z-40 safe-bottom">
|
|
<div class="flex items-center justify-around px-2 py-1">
|
|
{#each mobileNav as item}
|
|
{@const active = isActive(item.href, $page.url.pathname)}
|
|
<a
|
|
href="{base}{item.href}"
|
|
class="flex flex-col items-center gap-0.5 px-3 py-2 rounded-lg transition-all min-w-[3.5rem]
|
|
{active ? 'text-synapse-glow' : 'text-muted'}"
|
|
>
|
|
<span class="text-lg">{item.icon}</span>
|
|
<span class="text-[9px]">{item.label}</span>
|
|
</a>
|
|
{/each}
|
|
<!-- More button opens command palette on mobile -->
|
|
<button
|
|
onclick={() => { showCommandPalette = true; cmdQuery = ''; requestAnimationFrame(() => cmdInput?.focus()); }}
|
|
class="flex flex-col items-center gap-0.5 px-3 py-2 rounded-lg text-muted min-w-[3.5rem]"
|
|
>
|
|
<span class="text-lg">⋯</span>
|
|
<span class="text-[9px]">More</span>
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
</div>
|
|
|
|
<!-- Command Palette overlay -->
|
|
{#if showCommandPalette}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] md:pt-[15vh] px-4 bg-void/60 backdrop-blur-sm"
|
|
onkeydown={(e) => { if (e.key === 'Escape') showCommandPalette = false; }}
|
|
onclick={(e) => { if (e.target === e.currentTarget) showCommandPalette = false; }}
|
|
>
|
|
<div class="w-full max-w-lg glass-panel rounded-xl shadow-2xl shadow-synapse/10 overflow-hidden">
|
|
<div class="flex items-center gap-3 px-4 py-3 border-b border-synapse/10">
|
|
<span class="text-synapse text-sm">◎</span>
|
|
<input
|
|
bind:this={cmdInput}
|
|
bind:value={cmdQuery}
|
|
type="text"
|
|
placeholder="Navigate to..."
|
|
class="flex-1 bg-transparent text-text text-sm placeholder:text-muted focus:outline-none"
|
|
onkeydown={(e) => {
|
|
if (e.key === 'Enter' && filteredNav.length > 0) {
|
|
cmdNavigate(filteredNav[0].href);
|
|
}
|
|
}}
|
|
/>
|
|
<span class="text-[10px] text-muted font-mono bg-white/[0.04] px-1.5 py-0.5 rounded">esc</span>
|
|
</div>
|
|
<div class="max-h-72 overflow-y-auto py-1">
|
|
{#each filteredNav as item}
|
|
<button
|
|
onclick={() => cmdNavigate(item.href)}
|
|
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-dim hover:text-text hover:bg-white/[0.04] transition"
|
|
>
|
|
<span class="text-base w-5 text-center">{item.icon}</span>
|
|
<span>{item.label}</span>
|
|
<span class="ml-auto text-[10px] text-muted/50 font-mono hidden md:block">{item.shortcut}</span>
|
|
</button>
|
|
{/each}
|
|
{#if filteredNav.length === 0}
|
|
<div class="px-4 py-6 text-center text-sm text-muted">No matches</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.safe-bottom {
|
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
}
|
|
@keyframes page-in {
|
|
from { opacity: 0; transform: translateY(4px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
.animate-page-in {
|
|
animation: page-in 0.2s ease-out;
|
|
}
|
|
</style>
|