mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-24 23:41:01 +02:00
feat(dashboard): /memories is now a real-data Parallax MSDF field
Every line is a REAL memory from api.memories.list, rendered as glowing MSDF: content snippet | id[:8] | retention%. Depth=retrievalStrength, stroke weight=retentionStrength — a memory near the forgetting horizon renders dimmer and thinner. Cursor-reactive (inherits the Parallax field). ASCII- sanitized (real content has em-dashes/curly quotes the atlas can't draw). Zero DOM, zero hardcoded text. check 0 errors. Verified live: renders real memory ids (8bf0bb5a, 9070cece, 6d54f085) and real retention (100%, 100%, 89% — the 89% line visibly dimmer). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c12b21ae45
commit
b65721c2f0
1 changed files with 171 additions and 219 deletions
|
|
@ -1,247 +1,199 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import ObservatoryCanvas from '$lib/components/ObservatoryCanvas.svelte';
|
||||
import { api } from '$stores/api';
|
||||
import type { Memory } from '$types';
|
||||
import { NODE_TYPE_COLORS } from '$types';
|
||||
import MemoryAuditTrail from '$lib/components/MemoryAuditTrail.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import AnimatedNumber from '$lib/components/AnimatedNumber.svelte';
|
||||
import Dropdown, { type DropdownOption } from '$lib/components/Dropdown.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import { spotlight } from '$lib/actions/interactions';
|
||||
import type { ObservatoryEngine } from '$lib/observatory/engine';
|
||||
import { rgb01 } from '$lib/observatory/cognitive-palette';
|
||||
import { TextLayerPass, type TextLayerItem } from '$lib/observatory/text/text-layer';
|
||||
|
||||
type MemoryTextItem = TextLayerItem & { memoryId?: string };
|
||||
|
||||
const CYAN = [...rgb01('#22C7DE'), 1] satisfies [number, number, number, number];
|
||||
const SCARLET = [...rgb01('#FF3B30'), 0.92] satisfies [number, number, number, number];
|
||||
const MUTED = [...rgb01('#29F2A9'), 0.62] satisfies [number, number, number, number];
|
||||
const MEMORY_LIMIT = 40;
|
||||
const ROW_LIMIT = 36;
|
||||
|
||||
let hostEl: HTMLDivElement | null = $state(null);
|
||||
let engineRef: ObservatoryEngine | null = null;
|
||||
let textPass: TextLayerPass | null = null;
|
||||
let cursorSmoothed: { x: number; y: number } | null = null;
|
||||
let memories: Memory[] = $state([]);
|
||||
let searchQuery = $state('');
|
||||
let selectedType = $state('');
|
||||
let selectedTag = $state('');
|
||||
let minRetention = $state(0);
|
||||
let total = $state(0);
|
||||
let loading = $state(true);
|
||||
let selectedMemory: Memory | null = $state(null);
|
||||
// Which inner tab of the expanded card is active. Keyed by memory id so
|
||||
// switching between cards remembers each one's last view independently.
|
||||
let expandedTab: Record<string, 'content' | 'audit'> = $state({});
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
let error: string | null = $state(null);
|
||||
let activeRun: string | null = null;
|
||||
|
||||
onMount(() => loadMemories());
|
||||
onMount(() => {
|
||||
void loadMemories();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
textPass?.dispose();
|
||||
textPass = null;
|
||||
engineRef = null;
|
||||
});
|
||||
|
||||
async function handleReady(engine: ObservatoryEngine) {
|
||||
engineRef = engine;
|
||||
const pass = new TextLayerPass(engine);
|
||||
textPass = pass;
|
||||
await pass.init();
|
||||
pass.setText(buildTextItems());
|
||||
engine.addPass(pass);
|
||||
engine.demoClock.reset();
|
||||
}
|
||||
|
||||
async function loadMemories() {
|
||||
loading = true;
|
||||
error = null;
|
||||
textPass?.setText(buildTextItems());
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (searchQuery) params.q = searchQuery;
|
||||
if (selectedType) params.node_type = selectedType;
|
||||
if (selectedTag) params.tag = selectedTag;
|
||||
if (minRetention > 0) params.min_retention = String(minRetention);
|
||||
const res = await api.memories.list(params);
|
||||
const res = await api.memories.list({ limit: String(MEMORY_LIMIT) });
|
||||
memories = res.memories;
|
||||
} catch {
|
||||
total = res.total;
|
||||
} catch (err) {
|
||||
memories = [];
|
||||
total = 0;
|
||||
error = err instanceof Error ? err.message : 'UNKNOWN MEMORY FETCH ERROR';
|
||||
} finally {
|
||||
loading = false;
|
||||
textPass?.setText(buildTextItems());
|
||||
engineRef?.demoClock.reset();
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(loadMemories, 300);
|
||||
function sanitizeAscii(value: string): string {
|
||||
return value
|
||||
.replace(/[\u2014\u2013]/g, '-')
|
||||
.replace(/[\u2018\u2019]/g, "'")
|
||||
.replace(/[\u201C\u201D]/g, '"')
|
||||
.replace(/\u2026/g, '...')
|
||||
.replace(/[^\x20-\x7E]/g, '?');
|
||||
}
|
||||
|
||||
function retentionColor(r: number): string {
|
||||
if (r > 0.7) return '#10b981';
|
||||
if (r > 0.4) return '#f59e0b';
|
||||
return '#ef4444';
|
||||
function memoryLine(memory: Memory): string {
|
||||
const snippet = sanitizeAscii(memory.content).replace(/\s+/g, ' ').trim().slice(0, 52);
|
||||
return sanitizeAscii(
|
||||
`${snippet} | ${memory.id.slice(0, 8)} | ${Math.round(memory.retentionStrength * 100)}%`
|
||||
);
|
||||
}
|
||||
|
||||
// Clear, labelled dropdown options replace the dead native <select>.
|
||||
const typeOptions: DropdownOption[] = [
|
||||
{ value: '', label: 'All types' },
|
||||
{ value: 'fact', label: 'Fact', color: NODE_TYPE_COLORS.fact },
|
||||
{ value: 'concept', label: 'Concept', color: NODE_TYPE_COLORS.concept },
|
||||
{ value: 'event', label: 'Event', color: NODE_TYPE_COLORS.event },
|
||||
{ value: 'person', label: 'Person', color: NODE_TYPE_COLORS.person },
|
||||
{ value: 'place', label: 'Place', color: NODE_TYPE_COLORS.place },
|
||||
{ value: 'note', label: 'Note', color: NODE_TYPE_COLORS.note },
|
||||
{ value: 'pattern', label: 'Pattern', color: NODE_TYPE_COLORS.pattern },
|
||||
{ value: 'decision', label: 'Decision', color: NODE_TYPE_COLORS.decision },
|
||||
];
|
||||
function clamp01(value: number): number {
|
||||
return Math.min(1, Math.max(0, Number.isFinite(value) ? value : 0.5));
|
||||
}
|
||||
|
||||
// Retention filter as a clear dropdown of thresholds (was a bare range slider).
|
||||
const retentionOptions: DropdownOption[] = [
|
||||
{ value: '0', label: 'Any retention' },
|
||||
{ value: '0.3', label: '≥ 30% — fading & up' },
|
||||
{ value: '0.5', label: '≥ 50% — half-strength' },
|
||||
{ value: '0.7', label: '≥ 70% — well-retained' },
|
||||
{ value: '0.9', label: '≥ 90% — core memories' },
|
||||
];
|
||||
let retentionChoice = $state('0');
|
||||
function onRetentionChange(v: string) {
|
||||
minRetention = parseFloat(v);
|
||||
loadMemories();
|
||||
function statusItem(text: string, color = MUTED): MemoryTextItem {
|
||||
return {
|
||||
id: 'memories:status',
|
||||
kind: 'memory-status',
|
||||
text: sanitizeAscii(text),
|
||||
x: -0.58,
|
||||
y: 0.02,
|
||||
size: 0.044,
|
||||
color,
|
||||
depth: 0.78,
|
||||
weight: 0.62,
|
||||
revealSpan: 32,
|
||||
maxWidthEm: 50
|
||||
};
|
||||
}
|
||||
|
||||
function buildTextItems(): MemoryTextItem[] {
|
||||
if (loading) return [statusItem('LOADING MEMORY FIELD...', CYAN)];
|
||||
if (error) return [statusItem(`ERROR - ${error}`.slice(0, 72), SCARLET)];
|
||||
if (memories.length === 0) return [statusItem('EMPTY MEMORY FIELD', MUTED)];
|
||||
|
||||
const rows = memories.slice(0, ROW_LIMIT);
|
||||
const top = 0.72;
|
||||
const rowStep = 1.5 / Math.max(1, ROW_LIMIT - 1);
|
||||
return rows.map((memory, i) => {
|
||||
const retrieval = clamp01(memory.retrievalStrength);
|
||||
const retention = clamp01(memory.retentionStrength);
|
||||
return {
|
||||
id: `mem:${memory.id}`,
|
||||
kind: 'memory',
|
||||
memoryId: memory.id,
|
||||
text: memoryLine(memory),
|
||||
x: -0.88,
|
||||
y: top - i * rowStep,
|
||||
size: 0.026,
|
||||
color: CYAN,
|
||||
depth: retrieval,
|
||||
weight: retention,
|
||||
startFrame: i * 2,
|
||||
revealSpan: 20,
|
||||
maxWidthEm: 46
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function pointerToNdc(e: PointerEvent | MouseEvent): { x: number; y: number } | null {
|
||||
if (!hostEl) return null;
|
||||
const rect = hostEl.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return null;
|
||||
return {
|
||||
x: ((e.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
y: -(((e.clientY - rect.top) / rect.height) * 2 - 1)
|
||||
};
|
||||
}
|
||||
|
||||
function writeCursorLens(ndc: { x: number; y: number }) {
|
||||
if (!hostEl || !engineRef) return;
|
||||
const rect = hostEl.getBoundingClientRect();
|
||||
const aspect = Math.max(0.0001, rect.width / Math.max(1, rect.height));
|
||||
const raw = { x: ndc.x * Math.max(aspect, 1), y: ndc.y / Math.min(aspect, 1) };
|
||||
const prev = cursorSmoothed ?? raw;
|
||||
const next = { x: prev.x + (raw.x - prev.x) * 0.35, y: prev.y + (raw.y - prev.y) * 0.35 };
|
||||
cursorSmoothed = next;
|
||||
engineRef.setCursorPreNdc(next.x, next.y, next.x - prev.x, next.y - prev.y);
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
const ndc = pointerToNdc(e);
|
||||
if (!ndc) return;
|
||||
writeCursorLens(ndc);
|
||||
const hit = textPass?.pickAt(ndc.x, ndc.y) ?? null;
|
||||
const nextRun = hit?.kind === 'memory' ? hit.id : null;
|
||||
if (nextRun !== activeRun) {
|
||||
activeRun = nextRun;
|
||||
textPass?.setRunDepth(nextRun, 1);
|
||||
}
|
||||
if (hostEl) hostEl.style.cursor = nextRun ? 'crosshair' : 'default';
|
||||
}
|
||||
|
||||
function handlePointerLeave() {
|
||||
cursorSmoothed = null;
|
||||
activeRun = null;
|
||||
engineRef?.setCursorPreNdc(999, 999, 0, 0);
|
||||
textPass?.setRunDepth(null);
|
||||
if (hostEl) hostEl.style.cursor = 'default';
|
||||
}
|
||||
|
||||
async function handlePointerDown(e: PointerEvent) {
|
||||
const ndc = pointerToNdc(e);
|
||||
if (!ndc || !textPass) return;
|
||||
const hit = textPass.pickAt(ndc.x, ndc.y);
|
||||
if (hit?.kind !== 'memory') return;
|
||||
const item = hit.payload as MemoryTextItem;
|
||||
if (!item.memoryId) return;
|
||||
try {
|
||||
const promoted = await api.memories.promote(item.memoryId);
|
||||
memories = memories.map((memory) => (memory.id === promoted.id ? promoted : memory));
|
||||
textPass.setText(buildTextItems());
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'UNKNOWN MEMORY PROMOTE ERROR';
|
||||
textPass.setText(buildTextItems());
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-6xl mx-auto space-y-6">
|
||||
<PageHeader
|
||||
icon="memories"
|
||||
title="Memories"
|
||||
subtitle="Search, filter, and curate everything Vestige remembers"
|
||||
accent="memory"
|
||||
>
|
||||
<span class="text-dim text-sm tabular-nums">
|
||||
<AnimatedNumber value={memories.length} /> results
|
||||
</span>
|
||||
</PageHeader>
|
||||
<svelte:head>
|
||||
<title>Memories · Vestige</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Search & Filters -->
|
||||
<div class="flex gap-3 flex-wrap items-end enter">
|
||||
<div class="relative flex-1 min-w-64">
|
||||
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-muted pointer-events-none">
|
||||
<Icon name="search" size={16} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search memories… (press / to focus)"
|
||||
bind:value={searchQuery}
|
||||
oninput={onSearch}
|
||||
class="w-full pl-10 pr-4 py-2.5 bg-white/[0.03] border border-synapse/10 rounded-xl text-text text-sm
|
||||
placeholder:text-muted focus:outline-none focus:border-synapse/40 focus:ring-2 focus:ring-synapse/15 transition backdrop-blur-sm"
|
||||
/>
|
||||
</div>
|
||||
<Dropdown
|
||||
options={typeOptions}
|
||||
bind:value={selectedType}
|
||||
label="Type"
|
||||
icon="filter"
|
||||
placeholder="All types"
|
||||
onChange={loadMemories}
|
||||
/>
|
||||
<Dropdown
|
||||
options={retentionOptions}
|
||||
bind:value={retentionChoice}
|
||||
label="Retention"
|
||||
icon="pulse"
|
||||
onChange={onRetentionChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Memory grid -->
|
||||
{#if loading}
|
||||
<div class="grid gap-3">
|
||||
{#each Array(8) as _}
|
||||
<div class="h-24 glass-subtle rounded-xl shimmer"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if memories.length === 0}
|
||||
<div class="enter flex flex-col items-center justify-center text-center py-20 gap-4">
|
||||
<div class="text-dim opacity-60 breathe"><Icon name="memories" size={48} strokeWidth={1.2} /></div>
|
||||
<p class="text-dim text-sm max-w-sm">
|
||||
{searchQuery || selectedType || minRetention > 0
|
||||
? 'No memories match these filters yet. Try widening your search.'
|
||||
: 'No memories yet — once Vestige starts remembering, they will surface here, alive and searchable.'}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-3">
|
||||
{#each memories as memory, i (memory.id)}
|
||||
<button
|
||||
use:reveal={{ delay: Math.min(i * 35, 350), y: 12 }}
|
||||
use:spotlight
|
||||
onclick={() => selectedMemory = selectedMemory?.id === memory.id ? null : memory}
|
||||
class="spotlight-surface text-left p-4 glass-subtle rounded-xl hover:bg-white/[0.04]
|
||||
transition-all duration-200 group
|
||||
{selectedMemory?.id === memory.id ? '!border-synapse/40 glow-synapse live-border' : ''}"
|
||||
>
|
||||
<div class="relative z-[1] flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="w-2 h-2 rounded-full" style="background: {NODE_TYPE_COLORS[memory.nodeType] || '#8B95A5'}; box-shadow: 0 0 6px {NODE_TYPE_COLORS[memory.nodeType] || '#8B95A5'}99"></span>
|
||||
<span class="text-xs text-dim capitalize">{memory.nodeType}</span>
|
||||
{#each memory.tags.slice(0, 3) as tag}
|
||||
<span class="text-xs px-1.5 py-0.5 bg-white/[0.04] rounded text-muted">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-sm text-text leading-relaxed line-clamp-2">{memory.content}</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-1 flex-shrink-0">
|
||||
<div class="w-12 h-1.5 bg-deep rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full" style="width: {memory.retentionStrength * 100}%; background: {retentionColor(memory.retentionStrength)}"></div>
|
||||
</div>
|
||||
<span class="text-xs text-muted">{(memory.retentionStrength * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedMemory?.id === memory.id}
|
||||
{@const activeTab = expandedTab[memory.id] ?? 'content'}
|
||||
<div class="relative z-[1] mt-4 pt-4 border-t border-synapse/10 space-y-3">
|
||||
<!-- Inner tab switcher: Content (default) vs Audit Trail. -->
|
||||
<div class="flex gap-1 text-[11px] uppercase tracking-wider">
|
||||
<span
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={(e) => { e.stopPropagation(); expandedTab[memory.id] = 'content'; }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); expandedTab[memory.id] = 'content'; } }}
|
||||
class="px-3 py-1.5 rounded-lg cursor-pointer select-none transition
|
||||
{activeTab === 'content' ? 'bg-synapse/20 text-synapse-glow border border-synapse/40' : 'bg-white/[0.03] text-dim hover:text-text border border-transparent'}"
|
||||
>Content</span>
|
||||
<span
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={(e) => { e.stopPropagation(); expandedTab[memory.id] = 'audit'; }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); expandedTab[memory.id] = 'audit'; } }}
|
||||
class="px-3 py-1.5 rounded-lg cursor-pointer select-none transition
|
||||
{activeTab === 'audit' ? 'bg-synapse/20 text-synapse-glow border border-synapse/40' : 'bg-white/[0.03] text-dim hover:text-text border border-transparent'}"
|
||||
>Audit Trail</span>
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'content'}
|
||||
<p class="text-sm text-text whitespace-pre-wrap">{memory.content}</p>
|
||||
<div class="grid grid-cols-3 gap-3 text-xs text-dim">
|
||||
<div>Storage: {(memory.storageStrength * 100).toFixed(1)}%</div>
|
||||
<div>Retrieval: {(memory.retrievalStrength * 100).toFixed(1)}%</div>
|
||||
<div>Created: {new Date(memory.createdAt).toLocaleDateString()}</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
role="presentation"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MemoryAuditTrail memoryId={memory.id} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<span role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); api.memories.promote(memory.id); }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); api.memories.promote(memory.id); } }}
|
||||
class="px-3 py-1.5 bg-recall/20 text-recall text-xs rounded-lg hover:bg-recall/30 cursor-pointer select-none">Promote</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div bind:this={hostEl} class="fixed inset-0 bg-[#020307]" onpointerdown={handlePointerDown} onpointermove={handlePointerMove} onpointerleave={handlePointerLeave}>
|
||||
<ObservatoryCanvas demo="recall-path" seed={`real-memory-field:${total}`} onready={handleReady} />
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue