diff --git a/apps/dashboard/src/lib/components/InsightToast.svelte b/apps/dashboard/src/lib/components/InsightToast.svelte new file mode 100644 index 0000000..eb55576 --- /dev/null +++ b/apps/dashboard/src/lib/components/InsightToast.svelte @@ -0,0 +1,242 @@ + + + +
+ {#each $toasts as t (t.id)} + + {/each} +
+ + diff --git a/apps/dashboard/src/lib/stores/toast.ts b/apps/dashboard/src/lib/stores/toast.ts new file mode 100644 index 0000000..c7e58ae --- /dev/null +++ b/apps/dashboard/src/lib/stores/toast.ts @@ -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([]); + let nextId = 1; + let lastConnectionAt = 0; + + function push(toast: Omit) { + 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 | null { + const color = EVENT_TYPE_COLORS[event.type] ?? '#818CF8'; + const d = event.data as Record; + + 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[] = [ + { + 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); +} diff --git a/apps/dashboard/src/routes/(app)/settings/+page.svelte b/apps/dashboard/src/routes/(app)/settings/+page.svelte index 90d141e..9257ba2 100644 --- a/apps/dashboard/src/routes/(app)/settings/+page.svelte +++ b/apps/dashboard/src/routes/(app)/settings/+page.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import { api } from '$stores/api'; import { isConnected, memoryCount, avgRetention } from '$stores/websocket'; + import { fireDemoSequence } from '$stores/toast'; // Operation states let consolidating = $state(false); @@ -93,6 +94,20 @@ Cognitive Operations + +
+
+
+
Pulse Toast Preview
+
Fire a synthetic event sequence — useful for UI demos
+
+ +
+
+
diff --git a/apps/dashboard/src/routes/+layout.svelte b/apps/dashboard/src/routes/+layout.svelte index 48eb79a..52ea759 100644 --- a/apps/dashboard/src/routes/+layout.svelte +++ b/apps/dashboard/src/routes/+layout.svelte @@ -14,6 +14,7 @@ formatUptime, } from '$stores/websocket'; import ForgettingIndicator from '$lib/components/ForgettingIndicator.svelte'; + import InsightToast from '$lib/components/InsightToast.svelte'; let { children } = $props(); let showCommandPalette = $state(false); @@ -199,6 +200,9 @@
+ + + {#if showCommandPalette} diff --git a/docs/STORAGE.md b/docs/STORAGE.md index cefca33..8b3b8c5 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -169,3 +169,75 @@ SELECT COUNT(*) FROM knowledge_nodes WHERE retention_strength < 0.1; ``` **Caution**: Don't modify the database while Vestige is running. + +--- + +## Multi-Process Safety + +Vestige's SQLite configuration is tuned for **safe concurrent reads alongside a single writer**. Multiple `vestige-mcp` processes pointed at the same database file is a supported *read-heavy* pattern; concurrent heavy writes from multiple processes is **experimental** and documented here honestly. + +### What's shipped + +Every `Storage::new()` call executes these pragmas on both the reader and writer connection (`crates/vestige-core/src/storage/sqlite.rs`): + +```sql +PRAGMA journal_mode = WAL; -- readers don't block writers, writers don't block readers +PRAGMA synchronous = NORMAL; -- durable across app crashes, not across OS crashes +PRAGMA cache_size = -64000; -- 64 MiB page cache per connection +PRAGMA temp_store = MEMORY; +PRAGMA foreign_keys = ON; +PRAGMA busy_timeout = 5000; -- wait 5s on SQLITE_BUSY before surfacing the error +PRAGMA mmap_size = 268435456; -- 256 MiB memory-mapped I/O window +PRAGMA journal_size_limit = 67108864; +PRAGMA optimize = 0x10002; +``` + +Internally the `Storage` type holds **separate reader and writer connections**, each guarded by its own `Mutex`. Within a single process this means: + +- Any number of concurrent readers share the read connection lock. +- Writers serialize on the writer connection lock. +- WAL lets readers continue while a writer commits — they don't block each other at the SQLite level. + +### What works today + +| Pattern | Status | Notes | +|---------|--------|-------| +| One `vestige-mcp` + one Claude client | **Supported** | The default case. Zero contention. | +| Multiple Claude clients, separate `--data-dir` | **Supported** | Each process owns its own DB file. No shared state. | +| Multiple Claude clients, **shared** `--data-dir`, **one** `vestige-mcp` | **Supported** | Clients talk to a single MCP process that owns the DB. Recommended for multi-agent setups. | +| CLI (`vestige` binary) reading while `vestige-mcp` runs | **Supported** | WAL makes this safe — queries see a consistent snapshot. | +| Time Machine / `rsync` backup during writes | **Supported** | WAL journal gets copied with the main file; recovery handles it. | + +### What's experimental + +| Pattern | Status | Notes | +|---------|--------|-------| +| **Two `vestige-mcp` processes** writing the same DB concurrently | **Experimental** | SQLite serializes writers via a lock; if contention exceeds the 5s `busy_timeout`, writes surface `SQLITE_BUSY`. No exponential backoff or inter-process coordination layer beyond the pragma. | +| External writers (another SQLite client holding a write transaction open) | **Experimental** | Same concern as above — the 5s window is the only safety net. | +| Corrupted WAL recovery after hard-kill | **Supported by SQLite** | WAL is designed for crash recovery, but we do not explicitly test the `PRAGMA wal_checkpoint(RESTART)` path under load. | + +If you hit `database is locked` errors: + +```bash +# Identify the holder +lsof ~/Library/Application\ Support/com.vestige.core/vestige.db + +# Clean shutdown of all vestige processes +pkill -INT vestige-mcp +``` + +### Why the "Stigmergic Swarm" story is honest + +Multi-agent coordination through a shared memory graph — where agents alter the graph and other agents later *sense* those changes rather than passing explicit messages — is a first-class pattern on the **shared `--data-dir` + one `vestige-mcp`** setup above. In that configuration, every write flows through a single MCP process: WAL gives readers (agents querying state) a consistent view while the writer commits atomically, and the broadcast channel in `dashboard/events.rs` surfaces each cognitive event (dream, consolidation, promotion, suppression, Rac1 cascade) to every connected client in real time. No inter-process write coordination is required because there is one writer. + +Running two or more `vestige-mcp` processes against the same file is where "experimental" kicks in. For the swarm narrative, point every agent at one MCP instance — that's the shipping pattern. + +### Roadmap + +Things we haven't shipped yet, tracked for a future release: + +1. **File-based advisory lock** (`fs2` / `fcntl`) to detect and refuse startup when another `vestige-mcp` already owns the DB, instead of failing later with a lock error. +2. **Retry with jitter on `SQLITE_BUSY`** in addition to the pragma's blocking wait. +3. **Load test**: two `vestige-mcp` instances hammering the same file with mixed read/write traffic, verifying zero corruption and bounded write latency. + +Until those land, treat "two writer processes on one file" as experimental. For everything else on this page, WAL + the 5s busy timeout is the shipping story.