mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-02 22:01:01 +02:00
feat(blackbox): Agent Black Box + Receipts + risk-gated Memory PRs
Watch the agent think. Watch memory change. Watch the receipt prove why.
Make Vestige the first memory server where you can replay an agent run,
audit every retrieval, and review changes to the agent's brain like code.
Phase 0 — the trace-correlation spine. One runId threads, unbroken, through
every layer: MCP tool output (runId + traceUri) -> SQLite agent_traces rows ->
WebSocket TraceEvent -> dashboard pulse -> /api/traces/:runId ->
vestige://trace/{runId} -> .vestige-trace.json export -> Cinema replay input.
Proven end to end by a real JSON-RPC round-trip integration test.
Core (vestige-core):
- trace/ module: MemoryTraceEvent (7 variants incl. contradiction.detected),
Receipt, and classify_write — the pure, DB-free immune-system logic.
- Risk taxonomy: contradiction-vs-high-trust, supersede/forget/merge/protect,
identity/preference/workflow/positioning, auth/security/money/legal,
dream consolidation, decay resurrection, low-confidence batch, weak-provenance
connector. Fast / Risk-Gated (default) / Paranoid modes.
- V18 migration: agent_traces, agent_runs, memory_receipts, memory_prs.
- trace_store.rs: CRUD following the established store idiom.
MCP (vestige-mcp):
- trace_recorder.rs: records mcp.call + downstream retrieve/suppress/write/
contradiction/veto/dream events; builds + persists receipts; risk-gates
writes into Memory PRs. Args are hashed, never stored raw.
- server.rs dispatch stamps runId/traceUri/receipt onto every tool result and
routes risky writes to the PR queue; trace events broadcast over WebSocket.
- vestige://trace/{runId} resource; /api/traces, /api/receipts, /api/memory-prs.
Dashboard:
- Black Box tab: live spine header + Proof Mode, run picker, timeline scrubber,
per-event detail, memory pulse, full event log, .vestige-trace.json export.
- Memory PRs tab: GitHub-style cognition diff, self-explaining risk signals,
Promote/Merge/Supersede/Quarantine/Forget/Ask-Agent-Why, mode toggle.
- ReceiptCard with "Open receipt in Cinema" (deep-links graph; Cinema untouched).
Gates: 987 lib tests pass, clippy -D warnings clean, dashboard check + build
clean. Live proof in blackbox-proof-2026-06-22/.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9e92a5999a
commit
80c823a3ca
32 changed files with 5575 additions and 18 deletions
|
|
@ -13,6 +13,8 @@
|
|||
// ◎ ◈ ◉ ◷ across multiple items; that bug is dead here).
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
export type IconName =
|
||||
| 'blackbox'
|
||||
| 'memorypr'
|
||||
| 'graph'
|
||||
| 'reasoning'
|
||||
| 'memories'
|
||||
|
|
@ -41,6 +43,10 @@
|
|||
// Each entry is the inner markup of a 24×24 SVG. Strokes inherit
|
||||
// currentColor; fills are explicit where a solid accent reads better.
|
||||
export const ICON_PATHS: Record<IconName, string> = {
|
||||
// Flight recorder — a radar-pulse sweep inside a recorder box.
|
||||
blackbox: `<rect x="3.5" y="6" width="17" height="12" rx="2.2"/><circle cx="12" cy="12" r="3.4"/><path d="M12 12 14.6 9.4" /><circle cx="12" cy="12" r="0.9" fill="currentColor" stroke="none"/><path d="M7 19.5h10" opacity=".5"/>`,
|
||||
// Git-branch with a review check — approve changes to the brain.
|
||||
memorypr: `<circle cx="6.5" cy="6" r="2"/><circle cx="6.5" cy="18" r="2"/><circle cx="17" cy="9" r="2"/><path d="M6.5 8v8M6.5 12h6.2A2.3 2.3 0 0 0 15 9.7V11"/><path d="m14.8 17 1.5 1.6 3-3.4" fill="none"/>`,
|
||||
// Connected nodes — a literal knowledge graph.
|
||||
graph: `<circle cx="6" cy="7" r="2.1"/><circle cx="18" cy="6" r="2.1"/><circle cx="12" cy="17.5" r="2.3"/><path d="M7.7 8.4 10.6 15.5M16.4 7.6 13.2 15.6M8 7l8-1"/>`,
|
||||
// Branching logic tree with a spark — deduction.
|
||||
|
|
|
|||
218
apps/dashboard/src/lib/components/ReceiptCard.svelte
Normal file
218
apps/dashboard/src/lib/components/ReceiptCard.svelte
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
<script lang="ts">
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// MEMORY RECEIPT CARD — the nutrition label for a retrieval.
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
// Shows what was retrieved, what was suppressed and why, the activation
|
||||
// path, the trust floor (the weakest link the answer rests on), and the
|
||||
// decay risk. "Open receipt in Cinema" deep-links to the graph centered on
|
||||
// the receipt's primary memory, starting the (protected) Cinema flythrough
|
||||
// over the exact memory set the receipt names.
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
import { goto } from '$app/navigation';
|
||||
import Icon from './Icon.svelte';
|
||||
import type { Receipt } from '$lib/stores/api';
|
||||
|
||||
interface Props {
|
||||
receipt: Receipt;
|
||||
compact?: boolean;
|
||||
}
|
||||
let { receipt, compact = false }: Props = $props();
|
||||
|
||||
const riskColor: Record<Receipt['decay_risk'], string> = {
|
||||
low: 'var(--color-recall, #10b981)',
|
||||
medium: '#f59e0b',
|
||||
high: '#f43f5e'
|
||||
};
|
||||
|
||||
function openInCinema() {
|
||||
const primary = receipt.retrieved[0];
|
||||
if (!primary) return;
|
||||
const focus = receipt.retrieved.join(',');
|
||||
goto(`/graph?center=${encodeURIComponent(primary)}&focus=${encodeURIComponent(focus)}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="receipt" class:compact style:--risk={riskColor[receipt.decay_risk]}>
|
||||
<div class="r-head">
|
||||
<code class="r-id">{receipt.receipt_id}</code>
|
||||
<span class="r-risk" style:color={riskColor[receipt.decay_risk]}>
|
||||
decay: {receipt.decay_risk}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="r-metrics">
|
||||
<div class="metric">
|
||||
<span class="m-val">{receipt.retrieved.length}</span>
|
||||
<span class="m-label">retrieved</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="m-val">{receipt.suppressed.length}</span>
|
||||
<span class="m-label">suppressed</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="m-val">{(receipt.trust_floor * 100).toFixed(0)}%</span>
|
||||
<span class="m-label">trust floor</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !compact}
|
||||
{#if receipt.activation_path.length}
|
||||
<div class="r-section">
|
||||
<span class="r-section-title">Activation path</span>
|
||||
{#each receipt.activation_path as path (path)}
|
||||
<div class="path">{path}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if receipt.retrieved.length}
|
||||
<div class="r-section">
|
||||
<span class="r-section-title">Retrieved</span>
|
||||
<div class="chips">
|
||||
{#each receipt.retrieved as id (id)}
|
||||
<code class="chip recall">{id.slice(0, 8)}</code>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if receipt.suppressed.length}
|
||||
<div class="r-section">
|
||||
<span class="r-section-title">Suppressed</span>
|
||||
<div class="chips">
|
||||
{#each receipt.suppressed as s (s.id)}
|
||||
<code class="chip suppress" title={s.reason}>
|
||||
{s.id.slice(0, 8)} · {s.reason.replace('_', ' ')}
|
||||
</code>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<button class="cinema-btn" onclick={openInCinema} disabled={!receipt.retrieved.length}>
|
||||
<Icon name="sparkle" size={14} />
|
||||
Open receipt in Cinema
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.receipt {
|
||||
border: 1px solid color-mix(in oklab, var(--risk) 30%, transparent);
|
||||
border-left: 3px solid var(--risk);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
background: color-mix(in oklab, var(--color-void, #050510) 50%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.receipt.compact {
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.r-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.r-id {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-synapse-glow, #818cf8);
|
||||
word-break: break-all;
|
||||
}
|
||||
.r-risk {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.r-metrics {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
.metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.m-val {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.m-label {
|
||||
font-size: 0.64rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
}
|
||||
.r-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.r-section-title {
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
}
|
||||
.path {
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--color-text, #e2e2f0);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in oklab, var(--color-synapse) 8%, transparent);
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
.chip {
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.chip.recall {
|
||||
color: var(--color-recall, #10b981);
|
||||
background: color-mix(in oklab, var(--color-recall) 12%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--color-recall) 28%, transparent);
|
||||
}
|
||||
.chip.suppress {
|
||||
color: #a78bfa;
|
||||
background: color-mix(in oklab, #a78bfa 12%, transparent);
|
||||
border: 1px solid color-mix(in oklab, #a78bfa 28%, transparent);
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: color-mix(in oklab, #a78bfa 50%, transparent);
|
||||
}
|
||||
.cinema-btn {
|
||||
margin-top: 2px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 8px 14px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
border-radius: 9px;
|
||||
border: 1px solid color-mix(in oklab, var(--color-synapse) 40%, transparent);
|
||||
background: color-mix(in oklab, var(--color-synapse) 12%, transparent);
|
||||
color: var(--color-synapse-glow, #818cf8);
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
.cinema-btn:hover:not(:disabled) {
|
||||
background: color-mix(in oklab, var(--color-synapse) 24%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.cinema-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
134
apps/dashboard/src/lib/components/blackbox-helpers.ts
Normal file
134
apps/dashboard/src/lib/components/blackbox-helpers.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// AGENT BLACK BOX — presentation helpers
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Pure functions that turn a raw `TraceEvent` into the label, color, glyph,
|
||||
// and one-line summary the Black Box timeline renders. Kept out of the
|
||||
// component so they are unit-testable and reused by the Proof Mode header.
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
import type { TraceEvent } from '$lib/stores/api';
|
||||
|
||||
export type TraceKind = TraceEvent['type'];
|
||||
|
||||
/** The accent color for each trace-event kind (CSS color value). */
|
||||
export function eventColor(kind: TraceKind): string {
|
||||
switch (kind) {
|
||||
case 'mcp.call':
|
||||
return 'var(--color-synapse-glow, #818cf8)';
|
||||
case 'memory.retrieve':
|
||||
return 'var(--color-recall, #10b981)';
|
||||
case 'memory.suppress':
|
||||
return '#a78bfa'; // violet — the forgetting hue
|
||||
case 'memory.write':
|
||||
return '#38bdf8'; // sky — a new write
|
||||
case 'contradiction.detected':
|
||||
return '#fb7185'; // rose — tension
|
||||
case 'sanhedrin.veto':
|
||||
return '#f43f5e'; // red — a block
|
||||
case 'dream.patch':
|
||||
return '#c084fc'; // purple — dream
|
||||
default:
|
||||
return 'var(--color-synapse, #6366f1)';
|
||||
}
|
||||
}
|
||||
|
||||
/** A short human label for each kind. */
|
||||
export function eventLabel(kind: TraceKind): string {
|
||||
switch (kind) {
|
||||
case 'mcp.call':
|
||||
return 'Tool Call';
|
||||
case 'memory.retrieve':
|
||||
return 'Retrieved';
|
||||
case 'memory.suppress':
|
||||
return 'Suppressed';
|
||||
case 'memory.write':
|
||||
return 'Wrote';
|
||||
case 'contradiction.detected':
|
||||
return 'Contradiction';
|
||||
case 'sanhedrin.veto':
|
||||
return 'Veto';
|
||||
case 'dream.patch':
|
||||
return 'Dream Patch';
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
/** A single glyph (emoji-free SVG path is overkill here; a compact symbol). */
|
||||
export function eventGlyph(kind: TraceKind): string {
|
||||
switch (kind) {
|
||||
case 'mcp.call':
|
||||
return '⟐';
|
||||
case 'memory.retrieve':
|
||||
return '◉';
|
||||
case 'memory.suppress':
|
||||
return '⊘';
|
||||
case 'memory.write':
|
||||
return '✎';
|
||||
case 'contradiction.detected':
|
||||
return '⚡';
|
||||
case 'sanhedrin.veto':
|
||||
return '⛔';
|
||||
case 'dream.patch':
|
||||
return '☾';
|
||||
default:
|
||||
return '•';
|
||||
}
|
||||
}
|
||||
|
||||
/** A one-line summary of what an event did, for the timeline row. */
|
||||
export function eventSummary(ev: TraceEvent): string {
|
||||
switch (ev.type) {
|
||||
case 'mcp.call':
|
||||
return `${ev.tool} · args ${ev.argsHash.slice(0, 8)}`;
|
||||
case 'memory.retrieve':
|
||||
return `${ev.ids.length} ${ev.ids.length === 1 ? 'memory' : 'memories'} surfaced`;
|
||||
case 'memory.suppress':
|
||||
return `${ev.id.slice(0, 8)} — ${ev.reason.replace('_', ' ')}`;
|
||||
case 'memory.write':
|
||||
return `${ev.id.slice(0, 8)} — ${ev.source}`;
|
||||
case 'contradiction.detected':
|
||||
return ev.detail;
|
||||
case 'sanhedrin.veto':
|
||||
return `"${ev.claim}" (conf ${(ev.confidence * 100).toFixed(0)}%)`;
|
||||
case 'dream.patch':
|
||||
return `${ev.proposalIds.length} consolidation proposal(s)`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** The memory ids an event touched (for graph-pulse replay). */
|
||||
export function eventMemoryIds(ev: TraceEvent): string[] {
|
||||
switch (ev.type) {
|
||||
case 'memory.retrieve':
|
||||
return ev.ids;
|
||||
case 'memory.suppress':
|
||||
case 'memory.write':
|
||||
return [ev.id];
|
||||
case 'contradiction.detected':
|
||||
return ev.ids;
|
||||
case 'sanhedrin.veto':
|
||||
return ev.evidenceIds;
|
||||
case 'dream.patch':
|
||||
return ev.proposalIds;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Format a millisecond timestamp as a clock time. */
|
||||
export function formatAt(at: number): string {
|
||||
if (!Number.isFinite(at) || at <= 0) return '—';
|
||||
const d = new Date(at);
|
||||
return d.toLocaleTimeString(undefined, {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
/** Elapsed milliseconds of an event relative to the run's first event. */
|
||||
export function relativeMs(at: number, startAt: number): number {
|
||||
return Math.max(0, at - startAt);
|
||||
}
|
||||
|
|
@ -133,5 +133,117 @@ export const api = {
|
|||
method: 'POST',
|
||||
body: JSON.stringify({ reason, note, claimId, receiptId })
|
||||
})
|
||||
},
|
||||
|
||||
// Agent Black Box (v2.2): replayable agent-run traces. The runId in a tool
|
||||
// result threads through here unchanged — one id, end to end.
|
||||
traces: {
|
||||
list: (limit = 50) => fetcher<TraceRunListResponse>(`/traces?limit=${limit}`),
|
||||
get: (runId: string) => fetcher<TraceDetail>(`/traces/${encodeURIComponent(runId)}`),
|
||||
exportUrl: (runId: string) => `${BASE}/traces/${encodeURIComponent(runId)}/export`
|
||||
},
|
||||
|
||||
// Memory Receipts (v2.2): the nutrition label for a retrieval.
|
||||
receipts: {
|
||||
list: (limit = 50) => fetcher<ReceiptListResponse>(`/receipts?limit=${limit}`),
|
||||
get: (receiptId: string) => fetcher<Receipt>(`/receipts/${encodeURIComponent(receiptId)}`)
|
||||
},
|
||||
|
||||
// Memory PRs (v2.2): the risk-gated brain-change review queue.
|
||||
memoryPrs: {
|
||||
list: (status?: string, limit = 100) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (status) qs.set('status', status);
|
||||
qs.set('limit', String(limit));
|
||||
return fetcher<MemoryPrListResponse>(`/memory-prs?${qs.toString()}`);
|
||||
},
|
||||
get: (id: string) => fetcher<MemoryPr>(`/memory-prs/${encodeURIComponent(id)}`),
|
||||
act: (id: string, action: MemoryPrAction) =>
|
||||
fetcher<Record<string, unknown>>(`/memory-prs/${encodeURIComponent(id)}/${action}`, {
|
||||
method: 'POST'
|
||||
}),
|
||||
getMode: () => fetcher<{ mode: ReviewMode; pendingCount: number }>('/memory-prs/mode'),
|
||||
setMode: (mode: ReviewMode) =>
|
||||
fetcher<{ mode: ReviewMode }>('/memory-prs/mode', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode })
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Black Box / Receipts / Memory PR types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TraceRunSummary = {
|
||||
runId: string;
|
||||
firstTool: string | null;
|
||||
eventCount: number;
|
||||
retrievedCount: number;
|
||||
suppressedCount: number;
|
||||
writeCount: number;
|
||||
vetoCount: number;
|
||||
startedAt: number;
|
||||
lastAt: number;
|
||||
};
|
||||
|
||||
export type TraceRunListResponse = { total: number; runs: TraceRunSummary[] };
|
||||
|
||||
/** One trace event — discriminated on `type`, matching the Rust schema. */
|
||||
export type TraceEvent =
|
||||
| { type: 'mcp.call'; runId: string; tool: string; argsHash: string; at: number }
|
||||
| { type: 'memory.retrieve'; runId: string; ids: string[]; activation: Record<string, number>; at: number }
|
||||
| { type: 'memory.suppress'; runId: string; id: string; reason: string; at: number }
|
||||
| { type: 'memory.write'; runId: string; id: string; diff: unknown; source: string; at: number }
|
||||
| { type: 'contradiction.detected'; runId: string; ids: string[]; winnerId?: string; detail: string; at: number }
|
||||
| { type: 'sanhedrin.veto'; runId: string; claim: string; evidenceIds: string[]; confidence: number; at: number }
|
||||
| { type: 'dream.patch'; runId: string; proposalIds: string[]; at: number };
|
||||
|
||||
export type TraceDetail = {
|
||||
runId: string;
|
||||
summary: Omit<TraceRunSummary, 'runId'> | null;
|
||||
events: TraceEvent[];
|
||||
};
|
||||
|
||||
export type Receipt = {
|
||||
receipt_id: string;
|
||||
retrieved: string[];
|
||||
suppressed: { id: string; reason: string }[];
|
||||
activation_path: string[];
|
||||
trust_floor: number;
|
||||
decay_risk: 'low' | 'medium' | 'high';
|
||||
mutations: { id: string; kind: string; note?: string }[];
|
||||
};
|
||||
|
||||
export type ReceiptListResponse = { total: number; receipts: Receipt[] };
|
||||
|
||||
export type MemoryPrAction =
|
||||
| 'promote'
|
||||
| 'merge'
|
||||
| 'supersede'
|
||||
| 'quarantine'
|
||||
| 'forget'
|
||||
| 'ask_agent_why';
|
||||
|
||||
export type ReviewMode = 'fast' | 'risk_gated' | 'paranoid';
|
||||
|
||||
export type MemoryPr = {
|
||||
id: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
title: string;
|
||||
diff: Record<string, unknown>;
|
||||
signals: { code: string; detail: string }[];
|
||||
subject_id?: string;
|
||||
run_id?: string;
|
||||
created_at: string;
|
||||
decided_at?: string;
|
||||
decision?: string;
|
||||
};
|
||||
|
||||
export type MemoryPrListResponse = {
|
||||
total: number;
|
||||
pendingCount: number;
|
||||
mode: ReviewMode;
|
||||
prs: MemoryPr[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -132,6 +132,30 @@ export const uptimeSeconds = derived(websocket, $ws =>
|
|||
($ws.lastHeartbeat?.data?.uptime_secs as number) ?? 0
|
||||
);
|
||||
|
||||
// Agent Black Box (v2.2): the live stream of trace events, newest first. Each
|
||||
// is a real `VestigeEvent::TraceEvent` backed by a persisted `agent_traces`
|
||||
// row — the dashboard pulse is only ever driven by these, never by fakes.
|
||||
export const traceEvents = derived(websocket, $ws =>
|
||||
$ws.events.filter((e) => e.type === 'TraceEvent')
|
||||
);
|
||||
|
||||
// The most recent runId seen on the live feed — the "current run" indicator in
|
||||
// Proof Mode / the Black Box live header.
|
||||
export const liveRunId = derived(websocket, $ws => {
|
||||
const latest = $ws.events.find((e) => e.type === 'TraceEvent');
|
||||
return (latest?.data?.run_id as string) ?? null;
|
||||
});
|
||||
|
||||
// The single most recent trace event (for the "last event" readout).
|
||||
export const lastTraceEvent = derived(websocket, $ws =>
|
||||
$ws.events.find((e) => e.type === 'TraceEvent') ?? null
|
||||
);
|
||||
|
||||
// Live Memory PR notifications (opened / decided) for the queue badge + toasts.
|
||||
export const memoryPrEvents = derived(websocket, $ws =>
|
||||
$ws.events.filter((e) => e.type === 'MemoryPrOpened' || e.type === 'MemoryPrDecided')
|
||||
);
|
||||
|
||||
export function formatUptime(secs: number): string {
|
||||
if (!Number.isFinite(secs) || secs < 0) return '—';
|
||||
const d = Math.floor(secs / 86_400);
|
||||
|
|
|
|||
|
|
@ -168,6 +168,9 @@ export type VestigeEventType =
|
|||
| 'ImportanceScored'
|
||||
| 'DeepReferenceCompleted'
|
||||
| 'HookVerdictRecorded'
|
||||
| 'TraceEvent'
|
||||
| 'MemoryPrOpened'
|
||||
| 'MemoryPrDecided'
|
||||
| 'Heartbeat';
|
||||
|
||||
export interface VestigeEvent {
|
||||
|
|
|
|||
833
apps/dashboard/src/routes/(app)/blackbox/+page.svelte
Normal file
833
apps/dashboard/src/routes/(app)/blackbox/+page.svelte
Normal file
|
|
@ -0,0 +1,833 @@
|
|||
<script lang="ts">
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// AGENT BLACK BOX — the flight recorder for agent cognition.
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
// Watch the agent think. Watch memory change. Watch the receipt prove why.
|
||||
//
|
||||
// Every MCP tool call carries a runId that threads, unbroken, through the
|
||||
// tool output → SQLite trace rows → WebSocket → this page → the export →
|
||||
// Cinema. This tab replays that exact run: a timeline scrubber, per-event
|
||||
// detail, the suppressed memories, trust scores, contradiction decisions,
|
||||
// and a one-click `.vestige-trace.json` export.
|
||||
//
|
||||
// Live events are real — they arrive over the WebSocket backed by trace
|
||||
// rows. No fake demo events.
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
import { onMount } from 'svelte';
|
||||
import PageHeader from '$components/PageHeader.svelte';
|
||||
import Icon from '$components/Icon.svelte';
|
||||
import AnimatedNumber from '$components/AnimatedNumber.svelte';
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import { api, type TraceRunSummary, type TraceEvent, type TraceDetail } from '$lib/stores/api';
|
||||
import { isConnected, liveRunId, lastTraceEvent, traceEvents } from '$lib/stores/websocket';
|
||||
import {
|
||||
eventColor,
|
||||
eventLabel,
|
||||
eventGlyph,
|
||||
eventSummary,
|
||||
eventMemoryIds,
|
||||
formatAt,
|
||||
relativeMs
|
||||
} from '$components/blackbox-helpers';
|
||||
|
||||
// ---- state ----------------------------------------------------------
|
||||
let runs = $state<TraceRunSummary[]>([]);
|
||||
let selectedRunId = $state<string | null>(null);
|
||||
let detail = $state<TraceDetail | null>(null);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let scrubIndex = $state(0); // index into detail.events
|
||||
let proofMode = $state(false);
|
||||
|
||||
// The events up to and including the scrubber position — what the agent had
|
||||
// "experienced" at that moment in the run.
|
||||
const visibleEvents = $derived(detail ? detail.events.slice(0, scrubIndex + 1) : []);
|
||||
const currentEvent = $derived<TraceEvent | null>(
|
||||
detail && detail.events.length ? detail.events[scrubIndex] : null
|
||||
);
|
||||
const startAt = $derived(detail?.events[0]?.at ?? 0);
|
||||
|
||||
// Memory ids that have been touched up to the scrubber — the live pulse set.
|
||||
const pulsedIds = $derived(
|
||||
Array.from(new Set(visibleEvents.flatMap(eventMemoryIds)))
|
||||
);
|
||||
|
||||
async function loadRuns() {
|
||||
try {
|
||||
const res = await api.traces.list(100);
|
||||
runs = res.runs;
|
||||
if (!selectedRunId && runs.length) selectRun(runs[0].runId);
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectRun(runId: string) {
|
||||
selectedRunId = runId;
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
detail = await api.traces.get(runId);
|
||||
scrubIndex = Math.max(0, (detail.events.length || 1) - 1);
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
detail = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function exportTrace() {
|
||||
if (!selectedRunId) return;
|
||||
// Direct browser download of the .vestige-trace.json artifact.
|
||||
window.location.href = api.traces.exportUrl(selectedRunId);
|
||||
}
|
||||
|
||||
// Live: when a trace event for the *currently open* run arrives, refresh it
|
||||
// so the timeline grows in real time. Also refresh the run list so new runs
|
||||
// appear at the top.
|
||||
$effect(() => {
|
||||
const last = $lastTraceEvent;
|
||||
if (!last) return;
|
||||
const evRunId = last.data?.run_id as string | undefined;
|
||||
if (evRunId && evRunId === selectedRunId) {
|
||||
// Re-fetch the open run (cheap; trace rows are local SQLite).
|
||||
api.traces.get(selectedRunId).then((d) => {
|
||||
detail = d;
|
||||
// Keep the scrubber pinned to the newest event in live mode.
|
||||
scrubIndex = Math.max(0, d.events.length - 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onMount(loadRuns);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-5 py-6">
|
||||
<PageHeader
|
||||
icon="blackbox"
|
||||
title="Agent Black Box"
|
||||
subtitle="Watch the agent think. Watch memory change. Watch the receipt prove why."
|
||||
accent="synapse"
|
||||
>
|
||||
<button
|
||||
class="mode-toggle"
|
||||
class:on={proofMode}
|
||||
onclick={() => (proofMode = !proofMode)}
|
||||
title="Proof Mode: a clean launch-footage view"
|
||||
>
|
||||
<Icon name="sparkle" size={14} />
|
||||
Proof Mode
|
||||
</button>
|
||||
<button class="export-btn" onclick={exportTrace} disabled={!selectedRunId}>
|
||||
<Icon name="feed" size={14} />
|
||||
Export .vestige-trace.json
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
<!-- ░░ LIVE SPINE HEADER — the proof line: one runId, end to end ░░ -->
|
||||
<div class="spine glass" use:reveal>
|
||||
<div class="spine-item">
|
||||
<span class="spine-label">WebSocket</span>
|
||||
<span class="spine-value" class:live={$isConnected}>
|
||||
<span class="dot" class:live={$isConnected}></span>
|
||||
{$isConnected ? 'Connected' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="spine-item">
|
||||
<span class="spine-label">Live runId</span>
|
||||
<code class="spine-run">{$liveRunId ?? '—'}</code>
|
||||
</div>
|
||||
<div class="spine-item">
|
||||
<span class="spine-label">Last event</span>
|
||||
<span class="spine-value">
|
||||
{#if $lastTraceEvent}
|
||||
<span class="ev-chip" style:--c={eventColor(($lastTraceEvent.data?.event as TraceEvent)?.type)}>
|
||||
{eventLabel(($lastTraceEvent.data?.event as TraceEvent)?.type)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-dim">awaiting…</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="spine-item">
|
||||
<span class="spine-label">Events seen</span>
|
||||
<span class="spine-value">
|
||||
<AnimatedNumber value={$traceEvents.length} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !proofMode}
|
||||
<div class="layout">
|
||||
<!-- ░░ RUN PICKER ░░ -->
|
||||
<aside class="runs glass" use:reveal>
|
||||
<h2 class="panel-title">Runs</h2>
|
||||
{#if runs.length === 0}
|
||||
<p class="empty">
|
||||
No agent runs recorded yet. Make an MCP tool call — every call is
|
||||
recorded here.
|
||||
</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each runs as run (run.runId)}
|
||||
<li>
|
||||
<button
|
||||
class="run-row"
|
||||
class:active={run.runId === selectedRunId}
|
||||
onclick={() => selectRun(run.runId)}
|
||||
>
|
||||
<div class="run-top">
|
||||
<code class="run-id">{run.runId.replace('run_', '').slice(0, 10)}</code>
|
||||
<span class="run-tool">{run.firstTool ?? '—'}</span>
|
||||
</div>
|
||||
<div class="run-stats">
|
||||
<span title="events">{run.eventCount} ev</span>
|
||||
{#if run.retrievedCount}<span class="s-recall">↑{run.retrievedCount}</span>{/if}
|
||||
{#if run.suppressedCount}<span class="s-suppress">⊘{run.suppressedCount}</span>{/if}
|
||||
{#if run.writeCount}<span class="s-write">✎{run.writeCount}</span>{/if}
|
||||
{#if run.vetoCount}<span class="s-veto">⛔{run.vetoCount}</span>{/if}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<!-- ░░ REPLAY ░░ -->
|
||||
<section class="replay">
|
||||
{#if loading}
|
||||
<div class="glass center-msg">Loading trace…</div>
|
||||
{:else if error}
|
||||
<div class="glass center-msg err">{error}</div>
|
||||
{:else if !detail}
|
||||
<div class="glass center-msg">Select a run to replay.</div>
|
||||
{:else}
|
||||
<!-- Scrubber -->
|
||||
<div class="scrubber glass" use:reveal>
|
||||
<div class="scrub-head">
|
||||
<span class="scrub-title">
|
||||
Step <strong>{scrubIndex + 1}</strong> / {detail.events.length}
|
||||
</span>
|
||||
{#if currentEvent}
|
||||
<span class="scrub-time">+{relativeMs(currentEvent.at, startAt)}ms</span>
|
||||
{/if}
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={Math.max(0, detail.events.length - 1)}
|
||||
bind:value={scrubIndex}
|
||||
class="scrub-range"
|
||||
/>
|
||||
<!-- A tick row colored by event kind — the run at a glance. -->
|
||||
<div class="ticks">
|
||||
{#each detail.events as ev, i (i)}
|
||||
<button
|
||||
class="tick"
|
||||
class:past={i <= scrubIndex}
|
||||
style:--c={eventColor(ev.type)}
|
||||
onclick={() => (scrubIndex = i)}
|
||||
title={eventLabel(ev.type)}
|
||||
aria-label={`Step ${i + 1}: ${eventLabel(ev.type)}`}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current event detail -->
|
||||
{#if currentEvent}
|
||||
<div class="event-detail glass" use:reveal style:--c={eventColor(currentEvent.type)}>
|
||||
<div class="ed-head">
|
||||
<span class="ed-glyph">{eventGlyph(currentEvent.type)}</span>
|
||||
<span class="ed-label">{eventLabel(currentEvent.type)}</span>
|
||||
<code class="ed-time">{formatAt(currentEvent.at)}</code>
|
||||
</div>
|
||||
<p class="ed-summary">{eventSummary(currentEvent)}</p>
|
||||
|
||||
{#if currentEvent.type === 'memory.retrieve'}
|
||||
<div class="ids-grid">
|
||||
{#each currentEvent.ids as id (id)}
|
||||
<span class="id-chip" style:--a={currentEvent.activation[id] ?? 0}>
|
||||
<code>{id.slice(0, 8)}</code>
|
||||
{#if currentEvent.activation[id] != null}
|
||||
<small>{(currentEvent.activation[id] * 100).toFixed(0)}%</small>
|
||||
{/if}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if currentEvent.type === 'contradiction.detected'}
|
||||
<div class="contra">
|
||||
<span class="winner">kept {currentEvent.winnerId?.slice(0, 8)}</span>
|
||||
<span class="vs">vs</span>
|
||||
{#each currentEvent.ids.filter((i) => i !== currentEvent.winnerId) as id (id)}
|
||||
<span class="loser">{id.slice(0, 8)}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if currentEvent.type === 'sanhedrin.veto'}
|
||||
<div class="veto-evidence">
|
||||
{#each currentEvent.evidenceIds as id (id)}
|
||||
<code>{id.slice(0, 8)}</code>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Pulse set: the memories touched so far -->
|
||||
<div class="pulse glass" use:reveal>
|
||||
<h3 class="panel-title">
|
||||
Memory pulse <span class="text-dim">— touched this run</span>
|
||||
</h3>
|
||||
{#if pulsedIds.length === 0}
|
||||
<p class="empty">No memories touched yet.</p>
|
||||
{:else}
|
||||
<div class="pulse-grid">
|
||||
{#each pulsedIds as id (id)}
|
||||
<code class="pulse-node">{id.slice(0, 8)}</code>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Full event log -->
|
||||
<div class="log glass" use:reveal>
|
||||
<h3 class="panel-title">Event log</h3>
|
||||
<ol class="log-list">
|
||||
{#each detail.events as ev, i (i)}
|
||||
<li
|
||||
class="log-row"
|
||||
class:active={i === scrubIndex}
|
||||
class:dim={i > scrubIndex}
|
||||
style:--c={eventColor(ev.type)}
|
||||
>
|
||||
<button class="log-btn" onclick={() => (scrubIndex = i)}>
|
||||
<span class="log-glyph">{eventGlyph(ev.type)}</span>
|
||||
<span class="log-label">{eventLabel(ev.type)}</span>
|
||||
<span class="log-summary">{eventSummary(ev)}</span>
|
||||
<span class="log-t">+{relativeMs(ev.at, startAt)}ms</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- ░░ PROOF MODE — clean launch-footage view ░░ -->
|
||||
<div class="proof-stage glass" use:reveal>
|
||||
<div class="proof-headline">
|
||||
<span class="dot big" class:live={$isConnected}></span>
|
||||
<code class="proof-run">{$liveRunId ?? 'awaiting run…'}</code>
|
||||
</div>
|
||||
{#if $lastTraceEvent}
|
||||
{@const ev = $lastTraceEvent.data?.event as TraceEvent}
|
||||
<div class="proof-event" style:--c={eventColor(ev?.type)}>
|
||||
<span class="proof-glyph">{eventGlyph(ev?.type)}</span>
|
||||
<div>
|
||||
<div class="proof-ev-label">{eventLabel(ev?.type)}</div>
|
||||
<div class="proof-ev-sum">{eventSummary(ev)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="proof-counter">
|
||||
<AnimatedNumber value={$traceEvents.length} />
|
||||
<span class="proof-counter-label">trace events</span>
|
||||
</div>
|
||||
<p class="proof-tagline">Watch the agent think. Watch memory change. Watch the receipt prove why.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.mode-toggle,
|
||||
.export-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
border-radius: 9px;
|
||||
border: 1px solid color-mix(in oklab, var(--color-synapse) 30%, transparent);
|
||||
background: color-mix(in oklab, var(--color-synapse) 8%, transparent);
|
||||
color: var(--color-synapse-glow);
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
.mode-toggle:hover,
|
||||
.export-btn:hover:not(:disabled) {
|
||||
background: color-mix(in oklab, var(--color-synapse) 18%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.mode-toggle.on {
|
||||
background: var(--color-synapse);
|
||||
color: white;
|
||||
}
|
||||
.export-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Spine header */
|
||||
.spine {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1px;
|
||||
border-radius: 14px;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.spine-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 2px 14px;
|
||||
}
|
||||
.spine-label {
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
}
|
||||
.spine-value {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.spine-run {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-synapse-glow);
|
||||
word-break: break-all;
|
||||
}
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #64748b;
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
}
|
||||
.dot.live {
|
||||
background: var(--color-recall, #10b981);
|
||||
animation: ping 2s ease-in-out infinite;
|
||||
}
|
||||
.dot.big {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
@keyframes ping {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in oklab, var(--color-recall) 60%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px transparent;
|
||||
}
|
||||
}
|
||||
.ev-chip {
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
color: var(--c);
|
||||
background: color-mix(in oklab, var(--c) 14%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--c) 35%, transparent);
|
||||
}
|
||||
|
||||
/* Two-column layout */
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: color-mix(in oklab, var(--color-void, #050510) 55%, transparent);
|
||||
border: 1px solid color-mix(in oklab, white 8%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.text-dim {
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
font-weight: 400;
|
||||
}
|
||||
.empty {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runs {
|
||||
padding: 16px;
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.runs ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.run-row {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 9px 11px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
background: color-mix(in oklab, white 3%, transparent);
|
||||
cursor: pointer;
|
||||
transition: all 0.16s ease;
|
||||
}
|
||||
.run-row:hover {
|
||||
background: color-mix(in oklab, var(--color-synapse) 12%, transparent);
|
||||
}
|
||||
.run-row.active {
|
||||
border-color: color-mix(in oklab, var(--color-synapse) 45%, transparent);
|
||||
background: color-mix(in oklab, var(--color-synapse) 16%, transparent);
|
||||
}
|
||||
.run-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.run-id {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-synapse-glow);
|
||||
}
|
||||
.run-tool {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.run-stats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 5px;
|
||||
font-size: 0.68rem;
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
}
|
||||
.s-recall {
|
||||
color: var(--color-recall, #10b981);
|
||||
}
|
||||
.s-suppress {
|
||||
color: #a78bfa;
|
||||
}
|
||||
.s-write {
|
||||
color: #38bdf8;
|
||||
}
|
||||
.s-veto {
|
||||
color: #f43f5e;
|
||||
}
|
||||
|
||||
.replay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
.center-msg {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
}
|
||||
.center-msg.err {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* Scrubber */
|
||||
.scrubber {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.scrub-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 10px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.scrub-time {
|
||||
color: var(--color-synapse-glow);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.scrub-range {
|
||||
width: 100%;
|
||||
accent-color: var(--color-synapse);
|
||||
}
|
||||
.ticks {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-top: 10px;
|
||||
height: 22px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.tick {
|
||||
flex: 1;
|
||||
min-width: 2px;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in oklab, var(--c) 22%, transparent);
|
||||
cursor: pointer;
|
||||
transition: all 0.16s ease;
|
||||
padding: 0;
|
||||
}
|
||||
.tick.past {
|
||||
background: var(--c);
|
||||
box-shadow: 0 0 6px -1px var(--c);
|
||||
}
|
||||
.tick:hover {
|
||||
transform: scaleY(1.25);
|
||||
}
|
||||
|
||||
/* Event detail */
|
||||
.event-detail {
|
||||
padding: 16px 18px;
|
||||
border-left: 3px solid var(--c);
|
||||
}
|
||||
.ed-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.ed-glyph {
|
||||
font-size: 1.2rem;
|
||||
color: var(--c);
|
||||
}
|
||||
.ed-label {
|
||||
font-weight: 700;
|
||||
color: var(--c);
|
||||
}
|
||||
.ed-time {
|
||||
margin-left: auto;
|
||||
font-size: 0.74rem;
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
}
|
||||
.ed-summary {
|
||||
margin: 10px 0 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ids-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.id-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 7px;
|
||||
background: color-mix(in oklab, var(--color-recall) calc(var(--a, 0) * 30%), transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--color-recall) 30%, transparent);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
.id-chip small {
|
||||
color: var(--color-recall, #10b981);
|
||||
font-weight: 700;
|
||||
}
|
||||
.contra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.winner {
|
||||
color: var(--color-recall, #10b981);
|
||||
font-weight: 700;
|
||||
}
|
||||
.vs {
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
}
|
||||
.loser {
|
||||
color: #fb7185;
|
||||
text-decoration: line-through;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.veto-evidence {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.veto-evidence code {
|
||||
padding: 2px 7px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in oklab, #f43f5e 12%, transparent);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
/* Pulse */
|
||||
.pulse {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.pulse-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.pulse-node {
|
||||
padding: 3px 8px;
|
||||
border-radius: 7px;
|
||||
background: color-mix(in oklab, var(--color-synapse) 12%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--color-synapse) 28%, transparent);
|
||||
font-size: 0.74rem;
|
||||
color: var(--color-synapse-glow);
|
||||
animation: pulse-in 0.4s ease;
|
||||
}
|
||||
@keyframes pulse-in {
|
||||
from {
|
||||
transform: scale(0.85);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Log */
|
||||
.log {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.log-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.log-row {
|
||||
border-radius: 8px;
|
||||
border-left: 2px solid var(--c);
|
||||
transition: all 0.16s ease;
|
||||
}
|
||||
.log-row.active {
|
||||
background: color-mix(in oklab, var(--c) 14%, transparent);
|
||||
}
|
||||
.log-row.dim {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.log-btn {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 22px 110px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.log-glyph {
|
||||
color: var(--c);
|
||||
text-align: center;
|
||||
}
|
||||
.log-label {
|
||||
font-weight: 600;
|
||||
color: var(--c);
|
||||
}
|
||||
.log-summary {
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.log-t {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Proof mode */
|
||||
.proof-stage {
|
||||
padding: 60px 40px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 26px;
|
||||
min-height: 50vh;
|
||||
justify-content: center;
|
||||
}
|
||||
.proof-headline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.proof-run {
|
||||
font-size: 1.4rem;
|
||||
color: var(--color-synapse-glow);
|
||||
font-weight: 700;
|
||||
}
|
||||
.proof-event {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 18px 26px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid color-mix(in oklab, var(--c) 40%, transparent);
|
||||
background: color-mix(in oklab, var(--c) 10%, transparent);
|
||||
}
|
||||
.proof-glyph {
|
||||
font-size: 2rem;
|
||||
color: var(--c);
|
||||
}
|
||||
.proof-ev-label {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--c);
|
||||
}
|
||||
.proof-ev-sum {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
}
|
||||
.proof-counter {
|
||||
font-size: 3.4rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
color: var(--color-synapse-glow);
|
||||
}
|
||||
.proof-counter-label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
margin-top: 8px;
|
||||
}
|
||||
.proof-tagline {
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-dim, #c0c0d8);
|
||||
max-width: 32ch;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -102,11 +102,17 @@
|
|||
}
|
||||
|
||||
onMount(() => {
|
||||
const requestedMode = new URLSearchParams(window.location.search).get('colorMode');
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
const requestedMode = sp.get('colorMode');
|
||||
if (isColorMode(requestedMode)) {
|
||||
colorMode = requestedMode;
|
||||
}
|
||||
void loadGraph();
|
||||
// "Open receipt in Cinema" deep-links here with ?center=<memoryId>, so
|
||||
// the graph loads centered on the receipt's primary memory and the
|
||||
// (protected) Cinema flythrough starts from that exact node. We do not
|
||||
// touch MemoryCinema itself — only seed the graph it renders.
|
||||
const center = sp.get('center');
|
||||
void loadGraph(undefined, center || undefined);
|
||||
});
|
||||
|
||||
function isColorMode(value: string | null): value is ColorMode {
|
||||
|
|
|
|||
726
apps/dashboard/src/routes/(app)/memory-prs/+page.svelte
Normal file
726
apps/dashboard/src/routes/(app)/memory-prs/+page.svelte
Normal file
|
|
@ -0,0 +1,726 @@
|
|||
<script lang="ts">
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// MEMORY PRs — approve changes to an agent's brain like code.
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
// Vestige auto-remembers ordinary context, but opens a Memory PR when the
|
||||
// agent tries to rewrite its own brain. This is the cognitive immune
|
||||
// system: a GitHub-style diff UI for cognition, with Promote / Merge /
|
||||
// Supersede / Quarantine / Forget / Ask Agent Why, plus a one-click
|
||||
// Fast / Risk-Gated / Paranoid mode toggle.
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
import { onMount } from 'svelte';
|
||||
import PageHeader from '$components/PageHeader.svelte';
|
||||
import Icon from '$components/Icon.svelte';
|
||||
import AnimatedNumber from '$components/AnimatedNumber.svelte';
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import {
|
||||
api,
|
||||
type MemoryPr,
|
||||
type MemoryPrAction,
|
||||
type ReviewMode
|
||||
} from '$lib/stores/api';
|
||||
import { memoryPrEvents } from '$lib/stores/websocket';
|
||||
|
||||
let prs = $state<MemoryPr[]>([]);
|
||||
let pendingCount = $state(0);
|
||||
let mode = $state<ReviewMode>('risk_gated');
|
||||
let statusFilter = $state<string>('pending');
|
||||
let selected = $state<MemoryPr | null>(null);
|
||||
let why = $state<{ code: string; detail: string }[] | null>(null);
|
||||
let loading = $state(false);
|
||||
let busy = $state<string | null>(null);
|
||||
|
||||
const modes: { id: ReviewMode; label: string; blurb: string }[] = [
|
||||
{ id: 'fast', label: 'Fast', blurb: 'Every write auto-lands. No review.' },
|
||||
{
|
||||
id: 'risk_gated',
|
||||
label: 'Risk-Gated',
|
||||
blurb: 'Ordinary writes land; risky ones open a PR.'
|
||||
},
|
||||
{ id: 'paranoid', label: 'Paranoid', blurb: 'Every write waits for approval.' }
|
||||
];
|
||||
|
||||
const statuses = ['pending', 'promoted', 'merged', 'superseded', 'quarantined', 'forgotten'];
|
||||
|
||||
const kindLabel: Record<string, string> = {
|
||||
new_fact: 'New fact',
|
||||
strengthened_fact: 'Strengthened',
|
||||
contradiction_detected: 'Contradiction',
|
||||
memory_superseded: 'Supersede',
|
||||
edge_added: 'New edge',
|
||||
node_decayed: 'Decayed',
|
||||
dream_consolidation: 'Dream merge'
|
||||
};
|
||||
|
||||
const actions: { id: MemoryPrAction; label: string; cls: string }[] = [
|
||||
{ id: 'promote', label: 'Promote', cls: 'promote' },
|
||||
{ id: 'merge', label: 'Merge', cls: 'merge' },
|
||||
{ id: 'supersede', label: 'Supersede', cls: 'supersede' },
|
||||
{ id: 'quarantine', label: 'Quarantine', cls: 'quarantine' },
|
||||
{ id: 'forget', label: 'Forget', cls: 'forget' },
|
||||
{ id: 'ask_agent_why', label: 'Ask Agent Why', cls: 'why' }
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await api.memoryPrs.list(statusFilter || undefined, 100);
|
||||
prs = res.prs;
|
||||
pendingCount = res.pendingCount;
|
||||
mode = res.mode;
|
||||
if (selected) selected = prs.find((p) => p.id === selected!.id) ?? null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setMode(m: ReviewMode) {
|
||||
const res = await api.memoryPrs.setMode(m);
|
||||
mode = res.mode;
|
||||
toasts.push({
|
||||
type: 'MemoryPromoted',
|
||||
title: 'Review mode updated',
|
||||
body: `Memory PR gating is now ${modes.find((x) => x.id === m)?.label}.`,
|
||||
color: '#6366f1',
|
||||
dwellMs: 4000
|
||||
});
|
||||
}
|
||||
|
||||
async function act(pr: MemoryPr, action: MemoryPrAction) {
|
||||
busy = `${pr.id}:${action}`;
|
||||
try {
|
||||
if (action === 'ask_agent_why') {
|
||||
const res = (await api.memoryPrs.act(pr.id, action)) as {
|
||||
why: { code: string; detail: string }[];
|
||||
};
|
||||
why = res.why;
|
||||
selected = pr;
|
||||
return;
|
||||
}
|
||||
await api.memoryPrs.act(pr.id, action);
|
||||
toasts.push({
|
||||
type: 'MemoryPromoted',
|
||||
title: `PR ${action}d`,
|
||||
body: pr.title,
|
||||
color: actionColor(action),
|
||||
dwellMs: 4500
|
||||
});
|
||||
why = null;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toasts.push({
|
||||
type: 'MemoryDemoted',
|
||||
title: 'Action failed',
|
||||
body: String(e),
|
||||
color: '#f43f5e',
|
||||
dwellMs: 5000
|
||||
});
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
function actionColor(action: MemoryPrAction): string {
|
||||
switch (action) {
|
||||
case 'promote':
|
||||
return '#10b981';
|
||||
case 'merge':
|
||||
return '#6366f1';
|
||||
case 'supersede':
|
||||
return '#38bdf8';
|
||||
case 'quarantine':
|
||||
return '#f59e0b';
|
||||
case 'forget':
|
||||
return '#f43f5e';
|
||||
default:
|
||||
return '#818cf8';
|
||||
}
|
||||
}
|
||||
|
||||
function select(pr: MemoryPr) {
|
||||
selected = pr;
|
||||
why = null;
|
||||
}
|
||||
|
||||
// Live: a Memory PR opened or was decided elsewhere — refresh the queue.
|
||||
$effect(() => {
|
||||
if ($memoryPrEvents.length) void load();
|
||||
});
|
||||
|
||||
onMount(load);
|
||||
|
||||
// Diff rendering helpers
|
||||
function diffContent(pr: MemoryPr): string {
|
||||
const node = pr.diff?.node as { content?: string } | undefined;
|
||||
return node?.content ?? '';
|
||||
}
|
||||
function diffNodeType(pr: MemoryPr): string {
|
||||
const node = pr.diff?.node as { nodeType?: string } | undefined;
|
||||
return node?.nodeType ?? '';
|
||||
}
|
||||
function diffTags(pr: MemoryPr): string[] {
|
||||
const node = pr.diff?.node as { tags?: string[] } | undefined;
|
||||
return node?.tags ?? [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-5 py-6">
|
||||
<PageHeader
|
||||
icon="memorypr"
|
||||
title="Memory PRs"
|
||||
subtitle="Approve changes to the agent's brain like code."
|
||||
accent="synapse"
|
||||
>
|
||||
<span class="pending-badge" class:has={pendingCount > 0}>
|
||||
<AnimatedNumber value={pendingCount} /> pending
|
||||
</span>
|
||||
</PageHeader>
|
||||
|
||||
<!-- ░░ THE KILLER LINE ░░ -->
|
||||
<div class="manifesto" use:reveal>
|
||||
Vestige <strong>auto-remembers ordinary context</strong>, but opens a
|
||||
<strong>Memory PR</strong> when the agent tries to <strong>rewrite its own brain</strong>.
|
||||
</div>
|
||||
|
||||
<!-- ░░ MODE TOGGLE ░░ -->
|
||||
<div class="modes glass" use:reveal>
|
||||
{#each modes as m (m.id)}
|
||||
<button class="mode" class:on={mode === m.id} onclick={() => setMode(m.id)}>
|
||||
<span class="mode-label">{m.label}</span>
|
||||
<span class="mode-blurb">{m.blurb}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- ░░ STATUS FILTER ░░ -->
|
||||
<div class="filters" use:reveal>
|
||||
{#each statuses as s (s)}
|
||||
<button
|
||||
class="filter"
|
||||
class:on={statusFilter === s}
|
||||
onclick={() => {
|
||||
statusFilter = s;
|
||||
load();
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
class="filter"
|
||||
class:on={statusFilter === ''}
|
||||
onclick={() => {
|
||||
statusFilter = '';
|
||||
load();
|
||||
}}
|
||||
>
|
||||
all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<!-- ░░ PR LIST ░░ -->
|
||||
<aside class="pr-list glass" use:reveal>
|
||||
{#if loading}
|
||||
<p class="empty">Loading…</p>
|
||||
{:else if prs.length === 0}
|
||||
<p class="empty">
|
||||
{#if statusFilter === 'pending'}
|
||||
No pending Memory PRs. The brain is up to date — ordinary writes
|
||||
are landing automatically.
|
||||
{:else}
|
||||
No {statusFilter || ''} PRs.
|
||||
{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each prs as pr (pr.id)}
|
||||
<li>
|
||||
<button
|
||||
class="pr-row"
|
||||
class:active={selected?.id === pr.id}
|
||||
onclick={() => select(pr)}
|
||||
>
|
||||
<div class="pr-row-top">
|
||||
<span class="pr-kind kind-{pr.kind}">{kindLabel[pr.kind] ?? pr.kind}</span>
|
||||
<span class="pr-status st-{pr.status}">{pr.status}</span>
|
||||
</div>
|
||||
<div class="pr-title">{pr.title}</div>
|
||||
{#if pr.signals.length}
|
||||
<div class="pr-sig-count">⚠ {pr.signals.length} risk signal{pr.signals.length > 1 ? 's' : ''}</div>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<!-- ░░ PR DIFF DETAIL ░░ -->
|
||||
<section class="pr-detail">
|
||||
{#if !selected}
|
||||
<div class="glass center-msg">Select a Memory PR to review the diff.</div>
|
||||
{:else}
|
||||
<div class="glass diff-card" use:reveal>
|
||||
<div class="diff-head">
|
||||
<span class="pr-kind kind-{selected.kind}">{kindLabel[selected.kind] ?? selected.kind}</span>
|
||||
<span class="pr-status st-{selected.status}">{selected.status}</span>
|
||||
{#if selected.run_id}
|
||||
<a class="run-link" href={`/blackbox`} title="View the run that produced this">
|
||||
<Icon name="blackbox" size={13} /> {selected.run_id.replace('run_', '').slice(0, 8)}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<h2 class="diff-title">{selected.title}</h2>
|
||||
|
||||
<!-- The cognition diff -->
|
||||
<div class="diff-body">
|
||||
<div class="diff-meta">
|
||||
{#if diffNodeType(selected)}
|
||||
<span class="meta-pill">type: {diffNodeType(selected)}</span>
|
||||
{/if}
|
||||
{#each diffTags(selected) as t (t)}
|
||||
<span class="meta-pill tag">#{t}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{#if diffContent(selected)}
|
||||
<pre class="diff-add"><span class="gutter">+</span>{diffContent(selected)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Self-explaining risk signals -->
|
||||
{#if selected.signals.length}
|
||||
<div class="signals">
|
||||
<span class="signals-title">Why this opened</span>
|
||||
{#each selected.signals as sig (sig.code)}
|
||||
<div class="signal">
|
||||
<code class="sig-code">{sig.code}</code>
|
||||
<span class="sig-detail">{sig.detail}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Ask Agent Why response -->
|
||||
{#if why}
|
||||
<div class="why-box">
|
||||
<span class="why-title">Agent's reasoning</span>
|
||||
{#each why as w (w.code)}
|
||||
<div class="signal">
|
||||
<code class="sig-code">{w.code}</code>
|
||||
<span class="sig-detail">{w.detail}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action buttons -->
|
||||
{#if selected.status === 'pending'}
|
||||
<div class="actions">
|
||||
{#each actions as a (a.id)}
|
||||
<button
|
||||
class="action {a.cls}"
|
||||
disabled={busy === `${selected.id}:${a.id}`}
|
||||
onclick={() => act(selected!, a.id)}
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="decided">
|
||||
Decided: <strong>{selected.decision ?? selected.status}</strong>
|
||||
{#if selected.decided_at}
|
||||
<span class="text-dim">· {new Date(selected.decided_at).toLocaleString()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pending-badge {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
padding: 5px 11px;
|
||||
border-radius: 8px;
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
border: 1px solid color-mix(in oklab, white 10%, transparent);
|
||||
}
|
||||
.pending-badge.has {
|
||||
color: #f59e0b;
|
||||
border-color: color-mix(in oklab, #f59e0b 40%, transparent);
|
||||
background: color-mix(in oklab, #f59e0b 10%, transparent);
|
||||
}
|
||||
|
||||
.manifesto {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.55;
|
||||
color: var(--color-text, #e2e2f0);
|
||||
padding: 16px 20px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
color-mix(in oklab, var(--color-synapse) 14%, transparent),
|
||||
transparent 70%
|
||||
);
|
||||
border: 1px solid color-mix(in oklab, var(--color-synapse) 20%, transparent);
|
||||
}
|
||||
.manifesto strong {
|
||||
color: var(--color-synapse-glow, #818cf8);
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: color-mix(in oklab, var(--color-void, #050510) 55%, transparent);
|
||||
border: 1px solid color-mix(in oklab, white 8%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.modes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.modes {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.mode {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 11px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
background: color-mix(in oklab, white 3%, transparent);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.16s ease;
|
||||
}
|
||||
.mode:hover {
|
||||
background: color-mix(in oklab, var(--color-synapse) 10%, transparent);
|
||||
}
|
||||
.mode.on {
|
||||
border-color: color-mix(in oklab, var(--color-synapse) 50%, transparent);
|
||||
background: color-mix(in oklab, var(--color-synapse) 18%, transparent);
|
||||
}
|
||||
.mode-label {
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.mode-blurb {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.filter {
|
||||
font-size: 0.74rem;
|
||||
padding: 5px 11px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid color-mix(in oklab, white 8%, transparent);
|
||||
background: transparent;
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
cursor: pointer;
|
||||
text-transform: capitalize;
|
||||
transition: all 0.16s ease;
|
||||
}
|
||||
.filter:hover {
|
||||
color: var(--color-text, #e2e2f0);
|
||||
}
|
||||
.filter.on {
|
||||
color: var(--color-synapse-glow, #818cf8);
|
||||
border-color: color-mix(in oklab, var(--color-synapse) 45%, transparent);
|
||||
background: color-mix(in oklab, var(--color-synapse) 12%, transparent);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.pr-list {
|
||||
padding: 12px;
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.pr-list ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.empty {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
line-height: 1.5;
|
||||
padding: 8px;
|
||||
}
|
||||
.pr-row {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
background: color-mix(in oklab, white 3%, transparent);
|
||||
cursor: pointer;
|
||||
transition: all 0.16s ease;
|
||||
}
|
||||
.pr-row:hover {
|
||||
background: color-mix(in oklab, var(--color-synapse) 10%, transparent);
|
||||
}
|
||||
.pr-row.active {
|
||||
border-color: color-mix(in oklab, var(--color-synapse) 45%, transparent);
|
||||
background: color-mix(in oklab, var(--color-synapse) 15%, transparent);
|
||||
}
|
||||
.pr-row-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.pr-kind {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 5px;
|
||||
background: color-mix(in oklab, var(--color-synapse) 16%, transparent);
|
||||
color: var(--color-synapse-glow, #818cf8);
|
||||
}
|
||||
.kind-contradiction_detected {
|
||||
background: color-mix(in oklab, #fb7185 16%, transparent);
|
||||
color: #fb7185;
|
||||
}
|
||||
.kind-memory_superseded {
|
||||
background: color-mix(in oklab, #38bdf8 16%, transparent);
|
||||
color: #38bdf8;
|
||||
}
|
||||
.kind-dream_consolidation {
|
||||
background: color-mix(in oklab, #c084fc 16%, transparent);
|
||||
color: #c084fc;
|
||||
}
|
||||
.pr-status {
|
||||
font-size: 0.64rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
align-self: center;
|
||||
}
|
||||
.st-pending {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.st-promoted {
|
||||
color: #10b981;
|
||||
}
|
||||
.st-forgotten,
|
||||
.st-quarantined {
|
||||
color: #f43f5e;
|
||||
}
|
||||
.pr-title {
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.35;
|
||||
color: var(--color-text, #e2e2f0);
|
||||
}
|
||||
.pr-sig-count {
|
||||
font-size: 0.7rem;
|
||||
color: #f59e0b;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.pr-detail {
|
||||
min-width: 0;
|
||||
}
|
||||
.center-msg {
|
||||
padding: 50px;
|
||||
text-align: center;
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
}
|
||||
.diff-card {
|
||||
padding: 20px 22px;
|
||||
}
|
||||
.diff-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.run-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
font-size: 0.74rem;
|
||||
color: var(--color-synapse-glow, #818cf8);
|
||||
text-decoration: none;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in oklab, var(--color-synapse) 10%, transparent);
|
||||
}
|
||||
.diff-title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
margin: 12px 0 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.diff-body {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.diff-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.meta-pill {
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in oklab, white 6%, transparent);
|
||||
color: var(--color-text-dim, #c0c0d8);
|
||||
}
|
||||
.meta-pill.tag {
|
||||
color: var(--color-synapse-glow, #818cf8);
|
||||
}
|
||||
.diff-add {
|
||||
margin: 0;
|
||||
padding: 12px 14px 12px 36px;
|
||||
position: relative;
|
||||
border-radius: 9px;
|
||||
background: color-mix(in oklab, #10b981 9%, transparent);
|
||||
border-left: 3px solid #10b981;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--color-text, #e2e2f0);
|
||||
}
|
||||
.diff-add .gutter {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
color: #10b981;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.signals,
|
||||
.why-box {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in oklab, #f59e0b 8%, transparent);
|
||||
border: 1px solid color-mix(in oklab, #f59e0b 22%, transparent);
|
||||
}
|
||||
.why-box {
|
||||
background: color-mix(in oklab, var(--color-synapse) 8%, transparent);
|
||||
border-color: color-mix(in oklab, var(--color-synapse) 22%, transparent);
|
||||
}
|
||||
.signals-title,
|
||||
.why-title {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #f59e0b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.why-title {
|
||||
color: var(--color-synapse-glow, #818cf8);
|
||||
}
|
||||
.signal {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.sig-code {
|
||||
font-size: 0.7rem;
|
||||
color: #f59e0b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sig-detail {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text, #e2e2f0);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.action {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 8px 14px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid color-mix(in oklab, var(--ac, #6366f1) 40%, transparent);
|
||||
background: color-mix(in oklab, var(--ac, #6366f1) 10%, transparent);
|
||||
color: var(--ac, #818cf8);
|
||||
cursor: pointer;
|
||||
transition: all 0.16s ease;
|
||||
}
|
||||
.action:hover:not(:disabled) {
|
||||
background: color-mix(in oklab, var(--ac, #6366f1) 22%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.action:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
.action.promote {
|
||||
--ac: #10b981;
|
||||
}
|
||||
.action.merge {
|
||||
--ac: #6366f1;
|
||||
}
|
||||
.action.supersede {
|
||||
--ac: #38bdf8;
|
||||
}
|
||||
.action.quarantine {
|
||||
--ac: #f59e0b;
|
||||
}
|
||||
.action.forget {
|
||||
--ac: #f43f5e;
|
||||
}
|
||||
.action.why {
|
||||
--ac: #818cf8;
|
||||
}
|
||||
.decided {
|
||||
font-size: 0.88rem;
|
||||
padding: 10px 14px;
|
||||
border-radius: 9px;
|
||||
background: color-mix(in oklab, white 4%, transparent);
|
||||
}
|
||||
.text-dim {
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -98,6 +98,8 @@
|
|||
// set reused the same Unicode glyph across multiple items; every entry here
|
||||
// now has a distinct silhouette that reads instantly.
|
||||
const nav: { href: string; label: string; icon: IconName; shortcut: string }[] = [
|
||||
{ href: '/blackbox', label: 'Black Box', icon: 'blackbox', shortcut: 'B' },
|
||||
{ href: '/memory-prs', label: 'Memory PRs', icon: 'memorypr', shortcut: 'Q' },
|
||||
{ href: '/graph', label: 'Graph', icon: 'graph', shortcut: 'G' },
|
||||
{ href: '/reasoning', label: 'Reasoning', icon: 'reasoning', shortcut: 'R' },
|
||||
{ href: '/memories', label: 'Memories', icon: 'memories', shortcut: 'M' },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue