mirror of
https://github.com/samvallad33/vestige.git
synced 2026-04-30 19:36:22 +02:00
feat(v2.2-pulse): InsightToast + multi-process STORAGE docs
Two independent ship items landing together on the v2.2 branch ahead of
the Tuesday launch — a new UI surface that makes Vestige's cognitive
events visible in real time, and honest documentation of the multi-process
safety story that underpins the Stigmergic Swarm narrative.
**InsightToast** (apps/dashboard/src/lib/components/InsightToast.svelte,
apps/dashboard/src/lib/stores/toast.ts):
The dashboard already had a working WebSocket event stream on
ws://localhost:3927/ws that broadcast every cognitive event (dream
completions, consolidation sweeps, memory promotions/demotions, active-
forgetting suppression and Rac1 cascades, bridge discoveries). None of
that was surfaced to a user looking at anything other than the raw feed
view. InsightToast subscribes to the existing eventFeed derived store,
filters the spammy lifecycle events (Heartbeat, SearchPerformed,
RetentionDecayed, ActivationSpread, ImportanceScored, MemoryCreated),
and translates the narrative events into ephemeral toasts with a
bioluminescent colored accent matching EVENT_TYPE_COLORS.
Design notes:
- Rate-limited ConnectionDiscovered at 1.5s intervals (dreams emit many).
- Max 4 visible toasts, auto-dismiss at 4.5-7s depending on event weight.
- Click or Enter/Space to dismiss early.
- Bottom-right on desktop, top-banner on mobile.
- Reduced-motion honored via prefers-reduced-motion.
- Zero new websocket subscriptions — everything piggybacks on the
existing derived store.
Also added a "Preview Pulse" button to Settings -> Cognitive Operations
that fires a synthetic sequence of four toasts (DreamCompleted,
ConnectionDiscovered, MemorySuppressed, ConsolidationCompleted) so
the animation is demoable without waiting for real cognitive activity.
**Multi-Process Safety section in docs/STORAGE.md**:
Grounds the Stigmergic Swarm story with concrete tables of what the
current WAL + 5s busy_timeout configuration actually supports vs what
remains experimental. Key honest points:
- Shared --data-dir + ONE vestige-mcp + N clients is the shipping
pattern for multi-agent coordination.
- Two vestige-mcp processes writing the same file is experimental —
documented with the lsof + pkill recovery path.
- Roadmap lists the three items that would promote it to "supported":
advisory file lock, retry-with-jitter on SQLITE_BUSY, and a
concurrent-writer load test.
Build + typecheck:
- npm run check: 0 errors, 0 warnings across 583 files
- npm run build: clean static build, adapter-static succeeds
This commit is contained in:
parent
d7e7714f73
commit
f01375b815
5 changed files with 603 additions and 0 deletions
242
apps/dashboard/src/lib/components/InsightToast.svelte
Normal file
242
apps/dashboard/src/lib/components/InsightToast.svelte
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
<!--
|
||||
InsightToast — v2.2 Pulse
|
||||
Renders the toast queue as a floating overlay. Desktop: bottom-right
|
||||
stack. Mobile: top-center stack. Each toast has a colored left border
|
||||
matching its event type, a progress bar showing dwell, and click-to-
|
||||
dismiss. This is the "brain coming alive" surface — when Vestige dreams,
|
||||
consolidates, forgets, or discovers a new bridge, you see it happen.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { toasts, type Toast } from '$stores/toast';
|
||||
import type { VestigeEventType } from '$types';
|
||||
|
||||
const ICONS: Partial<Record<VestigeEventType, string>> = {
|
||||
DreamCompleted: '✦',
|
||||
ConsolidationCompleted: '◉',
|
||||
ConnectionDiscovered: '⟷',
|
||||
MemoryPromoted: '↑',
|
||||
MemoryDemoted: '↓',
|
||||
MemorySuppressed: '◬',
|
||||
MemoryUnsuppressed: '◉',
|
||||
Rac1CascadeSwept: '✺',
|
||||
MemoryDeleted: '✕',
|
||||
};
|
||||
|
||||
function iconFor(type: VestigeEventType): string {
|
||||
return ICONS[type] ?? '◆';
|
||||
}
|
||||
|
||||
function handleClick(t: Toast) {
|
||||
toasts.dismiss(t.id);
|
||||
}
|
||||
|
||||
function handleKey(e: KeyboardEvent, t: Toast) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toasts.dismiss(t.id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="toast-layer"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
>
|
||||
{#each $toasts as t (t.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="toast-item"
|
||||
aria-label="{t.title}: {t.body}. Click to dismiss."
|
||||
onclick={() => handleClick(t)}
|
||||
onkeydown={(e) => handleKey(e, t)}
|
||||
style="--toast-color: {t.color}; --toast-dwell: {t.dwellMs}ms;"
|
||||
>
|
||||
<div class="toast-accent" aria-hidden="true"></div>
|
||||
<div class="toast-body">
|
||||
<div class="toast-head">
|
||||
<span class="toast-icon" aria-hidden="true">{iconFor(t.type)}</span>
|
||||
<span class="toast-title">{t.title}</span>
|
||||
</div>
|
||||
<div class="toast-sub">{t.body}</div>
|
||||
</div>
|
||||
<div class="toast-progress" aria-hidden="true">
|
||||
<div class="toast-progress-fill"></div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toast-layer {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
right: 1.25rem;
|
||||
bottom: 1.25rem;
|
||||
max-width: 22rem;
|
||||
width: calc(100vw - 2.5rem);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.toast-layer {
|
||||
right: 0.75rem;
|
||||
left: 0.75rem;
|
||||
bottom: auto;
|
||||
top: 0.75rem;
|
||||
max-width: none;
|
||||
width: auto;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.toast-item {
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: stretch;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: rgba(12, 14, 22, 0.72);
|
||||
backdrop-filter: blur(14px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(160%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem 0.9rem 0.75rem 0.5rem;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 10px 40px -12px rgba(0, 0, 0, 0.8),
|
||||
0 0 22px -6px var(--toast-color);
|
||||
cursor: pointer;
|
||||
animation: toast-in 0.32s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transform-origin: right center;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.toast-item:hover {
|
||||
transform: translateY(-1px) scale(1.015);
|
||||
box-shadow:
|
||||
0 14px 48px -12px rgba(0, 0, 0, 0.85),
|
||||
0 0 32px -4px var(--toast-color);
|
||||
}
|
||||
|
||||
.toast-item:focus-visible {
|
||||
outline: 1px solid var(--toast-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.toast-accent {
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--toast-color);
|
||||
box-shadow: 0 0 10px var(--toast-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toast-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toast-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
color: var(--toast-color);
|
||||
font-size: 0.95rem;
|
||||
text-shadow: 0 0 8px var(--toast-color);
|
||||
line-height: 1;
|
||||
width: 1rem;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-title {
|
||||
color: #F5F5FA;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.toast-sub {
|
||||
color: #B0B6C4;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.35;
|
||||
padding-left: 1.5rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toast-progress {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.toast-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--toast-color);
|
||||
opacity: 0.55;
|
||||
transform-origin: left center;
|
||||
animation: toast-progress var(--toast-dwell) linear forwards;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(24px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.toast-item {
|
||||
transform-origin: top center;
|
||||
animation: toast-in-mobile 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-in-mobile {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-12px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-progress {
|
||||
from { transform: scaleX(1); }
|
||||
to { transform: scaleX(0); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast-item {
|
||||
animation: none;
|
||||
}
|
||||
.toast-progress-fill {
|
||||
animation: none;
|
||||
transform: scaleX(0.5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
270
apps/dashboard/src/lib/stores/toast.ts
Normal file
270
apps/dashboard/src/lib/stores/toast.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
// Pulse Toast — v2.2
|
||||
// Subscribes to the WebSocket event feed and surfaces meaningful cognitive
|
||||
// events as ephemeral toast notifications. This is the "brain coming alive"
|
||||
// moment — when the dashboard is open you SEE Vestige thinking.
|
||||
//
|
||||
// Design:
|
||||
// - Filter the spammy events (Heartbeat, SearchPerformed, RetentionDecayed,
|
||||
// ActivationSpread, ImportanceScored, MemoryCreated). Those fire during
|
||||
// every ingest cycle and would flood the UI.
|
||||
// - Surface the narrative events: Dreams completing, Consolidation sweeping,
|
||||
// Memories being promoted/demoted/suppressed, Rac1 cascades, new bridges.
|
||||
// - Rate-limit ConnectionDiscovered — dreams fire many of these in seconds.
|
||||
// - Auto-dismiss after 5-6s. Max 4 on screen.
|
||||
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { eventFeed } from '$stores/websocket';
|
||||
import type { VestigeEvent, VestigeEventType } from '$types';
|
||||
import { EVENT_TYPE_COLORS } from '$types';
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
type: VestigeEventType;
|
||||
title: string;
|
||||
body: string;
|
||||
color: string;
|
||||
dwellMs: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 4;
|
||||
const DEFAULT_DWELL_MS = 5500;
|
||||
const CONNECTION_THROTTLE_MS = 1500;
|
||||
|
||||
function createToastStore() {
|
||||
const { subscribe, update } = writable<Toast[]>([]);
|
||||
let nextId = 1;
|
||||
let lastConnectionAt = 0;
|
||||
|
||||
function push(toast: Omit<Toast, 'id' | 'createdAt'>) {
|
||||
const id = nextId++;
|
||||
const createdAt = Date.now();
|
||||
const entry: Toast = { id, createdAt, ...toast };
|
||||
update(list => {
|
||||
const next = [entry, ...list];
|
||||
if (next.length > MAX_VISIBLE) {
|
||||
return next.slice(0, MAX_VISIBLE);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setTimeout(() => dismiss(id), toast.dwellMs);
|
||||
}
|
||||
|
||||
function dismiss(id: number) {
|
||||
update(list => list.filter(t => t.id !== id));
|
||||
}
|
||||
|
||||
function clear() {
|
||||
update(() => []);
|
||||
}
|
||||
|
||||
function translate(event: VestigeEvent): Omit<Toast, 'id' | 'createdAt'> | null {
|
||||
const color = EVENT_TYPE_COLORS[event.type] ?? '#818CF8';
|
||||
const d = event.data as Record<string, unknown>;
|
||||
|
||||
switch (event.type) {
|
||||
case 'DreamCompleted': {
|
||||
const replayed = Number(d.memories_replayed ?? 0);
|
||||
const found = Number(d.connections_found ?? 0);
|
||||
const insights = Number(d.insights_generated ?? 0);
|
||||
const ms = Number(d.duration_ms ?? 0);
|
||||
const parts: string[] = [];
|
||||
parts.push(`Replayed ${replayed} ${replayed === 1 ? 'memory' : 'memories'}`);
|
||||
if (found > 0) parts.push(`${found} new connection${found === 1 ? '' : 's'}`);
|
||||
if (insights > 0) parts.push(`${insights} insight${insights === 1 ? '' : 's'}`);
|
||||
return {
|
||||
type: event.type,
|
||||
title: 'Dream consolidated',
|
||||
body: `${parts.join(' · ')} in ${(ms / 1000).toFixed(1)}s`,
|
||||
color,
|
||||
dwellMs: 7000,
|
||||
};
|
||||
}
|
||||
|
||||
case 'ConsolidationCompleted': {
|
||||
const nodes = Number(d.nodes_processed ?? 0);
|
||||
const decay = Number(d.decay_applied ?? 0);
|
||||
const embeds = Number(d.embeddings_generated ?? 0);
|
||||
const ms = Number(d.duration_ms ?? 0);
|
||||
const tail: string[] = [];
|
||||
if (decay > 0) tail.push(`${decay} decayed`);
|
||||
if (embeds > 0) tail.push(`${embeds} embedded`);
|
||||
return {
|
||||
type: event.type,
|
||||
title: 'Consolidation swept',
|
||||
body: `${nodes} node${nodes === 1 ? '' : 's'}${tail.length ? ' · ' + tail.join(' · ') : ''} in ${(ms / 1000).toFixed(1)}s`,
|
||||
color,
|
||||
dwellMs: 6000,
|
||||
};
|
||||
}
|
||||
|
||||
case 'ConnectionDiscovered': {
|
||||
const now = Date.now();
|
||||
if (now - lastConnectionAt < CONNECTION_THROTTLE_MS) return null;
|
||||
lastConnectionAt = now;
|
||||
const kind = String(d.connection_type ?? 'link');
|
||||
const weight = Number(d.weight ?? 0);
|
||||
return {
|
||||
type: event.type,
|
||||
title: 'Bridge discovered',
|
||||
body: `${kind} · weight ${weight.toFixed(2)}`,
|
||||
color,
|
||||
dwellMs: 4500,
|
||||
};
|
||||
}
|
||||
|
||||
case 'MemoryPromoted': {
|
||||
const r = Number(d.new_retention ?? 0);
|
||||
return {
|
||||
type: event.type,
|
||||
title: 'Memory promoted',
|
||||
body: `retention ${(r * 100).toFixed(0)}%`,
|
||||
color,
|
||||
dwellMs: 4500,
|
||||
};
|
||||
}
|
||||
|
||||
case 'MemoryDemoted': {
|
||||
const r = Number(d.new_retention ?? 0);
|
||||
return {
|
||||
type: event.type,
|
||||
title: 'Memory demoted',
|
||||
body: `retention ${(r * 100).toFixed(0)}%`,
|
||||
color,
|
||||
dwellMs: 4500,
|
||||
};
|
||||
}
|
||||
|
||||
case 'MemorySuppressed': {
|
||||
const count = Number(d.suppression_count ?? 0);
|
||||
const cascade = Number(d.estimated_cascade ?? 0);
|
||||
return {
|
||||
type: event.type,
|
||||
title: 'Forgetting',
|
||||
body: cascade > 0
|
||||
? `suppression #${count} · Rac1 cascade ~${cascade} neighbors`
|
||||
: `suppression #${count}`,
|
||||
color,
|
||||
dwellMs: 5500,
|
||||
};
|
||||
}
|
||||
|
||||
case 'MemoryUnsuppressed': {
|
||||
const remaining = Number(d.remaining_count ?? 0);
|
||||
return {
|
||||
type: event.type,
|
||||
title: 'Recovered',
|
||||
body: remaining > 0 ? `${remaining} suppression${remaining === 1 ? '' : 's'} remain` : 'fully unsuppressed',
|
||||
color,
|
||||
dwellMs: 5000,
|
||||
};
|
||||
}
|
||||
|
||||
case 'Rac1CascadeSwept': {
|
||||
const seeds = Number(d.seeds ?? 0);
|
||||
const neighbors = Number(d.neighbors_affected ?? 0);
|
||||
return {
|
||||
type: event.type,
|
||||
title: 'Rac1 cascade',
|
||||
body: `${seeds} seed${seeds === 1 ? '' : 's'} · ${neighbors} dendritic spine${neighbors === 1 ? '' : 's'} pruned`,
|
||||
color,
|
||||
dwellMs: 6000,
|
||||
};
|
||||
}
|
||||
|
||||
case 'MemoryDeleted': {
|
||||
return {
|
||||
type: event.type,
|
||||
title: 'Memory deleted',
|
||||
body: String(d.id ?? '').slice(0, 8),
|
||||
color,
|
||||
dwellMs: 4000,
|
||||
};
|
||||
}
|
||||
|
||||
// Noise — never toast
|
||||
case 'Heartbeat':
|
||||
case 'SearchPerformed':
|
||||
case 'RetentionDecayed':
|
||||
case 'ActivationSpread':
|
||||
case 'ImportanceScored':
|
||||
case 'MemoryCreated':
|
||||
case 'MemoryUpdated':
|
||||
case 'DreamStarted':
|
||||
case 'DreamProgress':
|
||||
case 'ConsolidationStarted':
|
||||
case 'Connected':
|
||||
return null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Track the latest processed event by object identity. The websocket store
|
||||
// prepends new events to its array, so when a new message arrives, the
|
||||
// first element becomes a new object reference — no IDs or timestamps
|
||||
// required to detect novelty.
|
||||
let lastSeen: VestigeEvent | null = null;
|
||||
|
||||
eventFeed.subscribe(events => {
|
||||
if (events.length === 0) return;
|
||||
const latest = events[0];
|
||||
if (latest === lastSeen) return;
|
||||
lastSeen = latest;
|
||||
const translated = translate(latest);
|
||||
if (translated) push(translated);
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
dismiss,
|
||||
clear,
|
||||
/** Manually fire a toast (test mode / demo button). */
|
||||
push,
|
||||
};
|
||||
}
|
||||
|
||||
export const toasts = createToastStore();
|
||||
|
||||
/** Fire a synthetic event sequence — used by the demo button in settings. */
|
||||
export function fireDemoSequence(): void {
|
||||
const demos: Omit<Toast, 'id' | 'createdAt'>[] = [
|
||||
{
|
||||
type: 'DreamCompleted',
|
||||
title: 'Dream consolidated',
|
||||
body: 'Replayed 127 memories · 43 new connections · 5 insights in 2.4s',
|
||||
color: EVENT_TYPE_COLORS.DreamCompleted,
|
||||
dwellMs: 7000,
|
||||
},
|
||||
{
|
||||
type: 'ConnectionDiscovered',
|
||||
title: 'Bridge discovered',
|
||||
body: 'semantic · weight 0.87',
|
||||
color: EVENT_TYPE_COLORS.ConnectionDiscovered,
|
||||
dwellMs: 4500,
|
||||
},
|
||||
{
|
||||
type: 'MemorySuppressed',
|
||||
title: 'Forgetting',
|
||||
body: 'suppression #2 · Rac1 cascade ~8 neighbors',
|
||||
color: EVENT_TYPE_COLORS.MemorySuppressed,
|
||||
dwellMs: 5500,
|
||||
},
|
||||
{
|
||||
type: 'ConsolidationCompleted',
|
||||
title: 'Consolidation swept',
|
||||
body: '892 nodes · 156 decayed · 48 embedded in 1.1s',
|
||||
color: EVENT_TYPE_COLORS.ConsolidationCompleted,
|
||||
dwellMs: 6000,
|
||||
},
|
||||
];
|
||||
demos.forEach((t, i) => {
|
||||
setTimeout(() => {
|
||||
// access the store getter for type safety — push is on the factory
|
||||
(toasts as unknown as { push: typeof toasts.push }).push(t);
|
||||
}, i * 800);
|
||||
});
|
||||
// satisfy unused-import lint
|
||||
void get(toasts);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue