feat: live memory materialization — nodes spawn in 3D graph in real-time

When memories are created, promoted, deleted, or dreamed via MCP tools,
the 3D graph now shows spectacular live animations:

- Rainbow particle burst + elastic scale-up on MemoryCreated
- Ripple wave cascading to nearby nodes
- Green pulse + node growth on MemoryPromoted
- Implosion + dissolution on MemoryDeleted
- Edge growth animation on ConnectionDiscovered
- Purple cascade on DreamStarted/DreamProgress/DreamCompleted
- FIFO eviction at 50 live nodes to guard performance

Also: graph center defaults to most-connected node, legacy HTML
redirects to SvelteKit dashboard, CSS height chain fix in layout.

Testing: 150 unit tests (vitest), 11 e2e tests (Playwright with
MCP Streamable HTTP client), 22 proof screenshots.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-03-03 14:04:31 -06:00
parent 816b577f69
commit 9bdcc69ce3
76 changed files with 5915 additions and 332 deletions

View file

@ -6,6 +6,7 @@
import { api } from '$stores/api';
import { eventFeed } from '$stores/websocket';
import type { GraphResponse, GraphNode, GraphEdge, Memory } from '$types';
import type { GraphMutation } from '$lib/graph/events';
import { filterByDate } from '$lib/graph/temporal';
let graphData: GraphResponse | null = $state(null);
@ -18,6 +19,10 @@
let temporalEnabled = $state(false);
let temporalDate = $state(new Date());
// Live counts that update on mutations
let liveNodeCount = $state(0);
let liveEdgeCount = $state(0);
// Filtered graph data based on temporal mode
let displayNodes = $derived.by((): GraphNode[] => {
if (!graphData) return [];
@ -31,6 +36,40 @@
return filterByDate(graphData.nodes, graphData.edges, temporalDate).visibleEdges;
});
function handleGraphMutation(mutation: GraphMutation) {
if (!graphData) return;
switch (mutation.type) {
case 'nodeAdded':
graphData.nodes = [...graphData.nodes, mutation.node];
graphData.nodeCount = graphData.nodes.length;
liveNodeCount = graphData.nodeCount;
break;
case 'nodeRemoved':
graphData.nodes = graphData.nodes.filter((n) => n.id !== mutation.nodeId);
graphData.nodeCount = graphData.nodes.length;
liveNodeCount = graphData.nodeCount;
break;
case 'edgeAdded':
graphData.edges = [...graphData.edges, mutation.edge];
graphData.edgeCount = graphData.edges.length;
liveEdgeCount = graphData.edgeCount;
break;
case 'edgesRemoved':
graphData.edges = graphData.edges.filter(
(e) => e.source !== mutation.nodeId && e.target !== mutation.nodeId
);
graphData.edgeCount = graphData.edges.length;
liveEdgeCount = graphData.edgeCount;
break;
case 'nodeUpdated': {
const node = graphData.nodes.find((n) => n.id === mutation.nodeId);
if (node) node.retention = mutation.retention;
break;
}
}
}
onMount(() => loadGraph());
async function loadGraph(query?: string, centerId?: string) {
@ -43,6 +82,10 @@
query: query || undefined,
center_id: centerId || undefined
});
if (graphData) {
liveNodeCount = graphData.nodeCount;
liveEdgeCount = graphData.edgeCount;
}
} catch {
error = 'No memories yet. Start using Vestige to populate your graph.';
} finally {
@ -96,6 +139,7 @@
events={$eventFeed}
{isDreaming}
onSelect={onNodeSelect}
onGraphMutation={handleGraphMutation}
/>
{/if}
@ -149,9 +193,9 @@
<!-- Bottom stats -->
<div class="absolute bottom-4 left-4 z-10 text-xs text-dim glass rounded-xl px-3 py-2">
{#if graphData}
<span>{displayNodes.length} nodes</span>
<span>{liveNodeCount} nodes</span>
<span class="mx-2 text-subtle">·</span>
<span>{displayEdges.length} edges</span>
<span>{liveEdgeCount} edges</span>
<span class="mx-2 text-subtle">·</span>
<span>depth {graphData.depth}</span>
{/if}

View file

@ -148,8 +148,8 @@
</nav>
<!-- Main content -->
<main class="flex-1 overflow-y-auto pb-16 md:pb-0">
<div class="animate-page-in">
<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>