mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-24 23:41:01 +02:00
merge: black box launch spine (lane 2/5)
This commit is contained in:
commit
d5002c314b
43 changed files with 6857 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,122 @@ 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}`),
|
||||
// B5: scope to one run so the Black Box panel shows that run's receipts.
|
||||
listForRun: (runId: string, limit = 50) =>
|
||||
fetcher<ReceiptListResponse>(
|
||||
`/receipts?run=${encodeURIComponent(runId)}&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 {
|
||||
|
|
|
|||
954
apps/dashboard/src/routes/(app)/blackbox/+page.svelte
Normal file
954
apps/dashboard/src/routes/(app)/blackbox/+page.svelte
Normal file
|
|
@ -0,0 +1,954 @@
|
|||
<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 ReceiptCard from '$components/ReceiptCard.svelte';
|
||||
import {
|
||||
api,
|
||||
type TraceRunSummary,
|
||||
type TraceEvent,
|
||||
type TraceDetail,
|
||||
type Receipt
|
||||
} 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);
|
||||
let receipts = $state<Receipt[]>([]);
|
||||
|
||||
// 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)))
|
||||
);
|
||||
|
||||
// Honest producer status for this run. Two event kinds depend on optional
|
||||
// upstream producers that are off by default — we say so explicitly instead
|
||||
// of rendering a confusing empty space.
|
||||
const hasVeto = $derived(detail?.events.some((e) => e.type === 'sanhedrin.veto') ?? false);
|
||||
const hasDream = $derived(detail?.events.some((e) => e.type === 'dream.patch') ?? false);
|
||||
const hasContradiction = $derived(
|
||||
detail?.events.some((e) => e.type === 'contradiction.detected') ?? false
|
||||
);
|
||||
|
||||
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);
|
||||
// Receipts are the proof behind THIS run's retrievals — scoped to
|
||||
// the selected run (B5), not the global latest.
|
||||
receipts = (await api.receipts.listForRun(runId, 8)).receipts;
|
||||
} 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>
|
||||
|
||||
<!-- Producer status — honest about what's live vs. off-by-default -->
|
||||
<div class="producers glass" use:reveal>
|
||||
<h3 class="panel-title">Event producers <span class="text-dim">— this run</span></h3>
|
||||
<ul class="producer-list">
|
||||
<li class="producer ok">
|
||||
<span class="p-dot"></span> mcp.call · memory.write · memory.retrieve · memory.suppress
|
||||
<span class="p-state">live</span>
|
||||
</li>
|
||||
<li class="producer" class:ok={hasContradiction}>
|
||||
<span class="p-dot"></span> contradiction.detected
|
||||
<span class="p-state">
|
||||
{hasContradiction ? 'fired this run' : 'no contradiction in this run'}
|
||||
</span>
|
||||
</li>
|
||||
<li class="producer caveat" class:ok={hasDream}>
|
||||
<span class="p-dot"></span> dream.patch
|
||||
<span class="p-state">
|
||||
{hasDream ? 'fired this run' : 'No dream run in this trace'}
|
||||
</span>
|
||||
</li>
|
||||
<li class="producer caveat" class:ok={hasVeto}>
|
||||
<span class="p-dot"></span> sanhedrin.veto
|
||||
<span class="p-state">
|
||||
{hasVeto ? 'fired this run' : 'No veto producer connected (optional Sanhedrin hook, off by default)'}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Receipts — the nutrition label behind this run's retrievals -->
|
||||
{#if receipts.length}
|
||||
<div class="receipts-panel glass" use:reveal>
|
||||
<h3 class="panel-title">
|
||||
Receipts <span class="text-dim">— proof behind retrievals</span>
|
||||
</h3>
|
||||
<div class="receipts-grid">
|
||||
{#each receipts.slice(0, 2) as r (r.receipt_id)}
|
||||
<ReceiptCard receipt={r} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Receipts panel */
|
||||
.receipts-panel {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.receipts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Producers — honest event-source status */
|
||||
.producers {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.producer-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
.producer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
}
|
||||
.producer .p-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #475569;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.producer.ok {
|
||||
color: var(--color-text, #e2e2f0);
|
||||
}
|
||||
.producer.ok .p-dot {
|
||||
background: var(--color-recall, #10b981);
|
||||
box-shadow: 0 0 6px -1px var(--color-recall, #10b981);
|
||||
}
|
||||
.producer.caveat:not(.ok) .p-dot {
|
||||
background: #f59e0b;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.p-state {
|
||||
margin-left: auto;
|
||||
font-size: 0.7rem;
|
||||
font-style: italic;
|
||||
text-align: right;
|
||||
color: var(--color-text-dim, #8b8ba7);
|
||||
}
|
||||
.producer.caveat:not(.ok) .p-state {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
|
|
|
|||
740
apps/dashboard/src/routes/(app)/memory-prs/+page.svelte
Normal file
740
apps/dashboard/src/routes/(app)/memory-prs/+page.svelte
Normal file
|
|
@ -0,0 +1,740 @@
|
|||
<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>.
|
||||
<span class="manifesto-note">
|
||||
Risky writes are <strong>quarantine-reviewed</strong>: recorded for audit, but held
|
||||
out of retrieval until you decide — influence suspended, history preserved.
|
||||
</span>
|
||||
</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);
|
||||
}
|
||||
.manifesto-note {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-dim, #c0c0d8);
|
||||
}
|
||||
.manifesto-note strong {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.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' },
|
||||
|
|
|
|||
87
blackbox-proof-2026-06-22/PROOF.md
Normal file
87
blackbox-proof-2026-06-22/PROOF.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# Vestige Agent Black Box — Proof Pack (2026-06-22)
|
||||
|
||||
> **Public claim (frozen):** Vestige records real MCP memory activity into a
|
||||
> replayable local trace, with receipts and reviewable risky writes.
|
||||
>
|
||||
> We do **not** claim Sanhedrin vetoes or dream patches are live by default.
|
||||
> Those producers are optional and off by default — the UI says so explicitly.
|
||||
|
||||
This pack is captured from a **live** Vestige build on branch
|
||||
`feat/agent-black-box` — a real `vestige-mcp` process with the dashboard
|
||||
enabled, driven by real MCP `tools/call` traffic. Nothing here is mocked.
|
||||
|
||||
## The receipt chain — one runId, every hop
|
||||
|
||||
The money guarantee: a single `runId` (`run_proof`) crosses every layer,
|
||||
byte-identical. Verified two ways — by the files in this folder, and by the
|
||||
deterministic regression test `test_full_spine_one_runid_crosses_every_hop`
|
||||
(crates/vestige-mcp/src/server.rs).
|
||||
|
||||
| Hop | Layer | Evidence in this pack |
|
||||
|----|-------|------|
|
||||
| 1 | MCP tool output (`runId` + `traceUri`) | every tool result; see test HOP 1 |
|
||||
| 2 | SQLite `agent_traces` rows | `trace.json` (`runId: run_proof`, 10 events) |
|
||||
| 3 | WebSocket broadcast | `websocket-events.jsonl` (6 `TraceEvent` lines, each with `run_id`) |
|
||||
| 4 | `/api/traces/:runId` response | `trace.json` is the export of that endpoint |
|
||||
| 5 | dashboard render | screenshots (Black Box timeline = the 10 events) |
|
||||
| 6 | `vestige://trace/{runId}` MCP resource | test HOP 5 resolves the same id |
|
||||
|
||||
## Files
|
||||
|
||||
| File | What it proves |
|
||||
|------|----------------|
|
||||
| `status.json` | the live server health at capture time |
|
||||
| `trace.json` | the full `.vestige-trace.json` export — 10 real events in order |
|
||||
| `receipt.json` | a real retrieval receipt (`r_2026_06_22_runproof`, 5 retrieved, decay medium) |
|
||||
| `memory_pr.json` | the risky auth write → Memory PR, **promoted** through UI→API→SQLite, signal `sensitive_topic` |
|
||||
| `websocket-events.jsonl` | the live WS stream: `TraceEvent`×6, `MemoryPrOpened`, `MemoryPrDecided`, `MemoryCreated`, `MemoryUpdated` |
|
||||
| `screenshots/` | Graph, Black Box, Receipts (in PR), Memory PRs — see `screenshots/README.md` |
|
||||
|
||||
## Per-feature honesty: real / caveat / stub
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| `mcp.call` trace | **REAL** | every tools/call records one; args **hashed**, never stored raw |
|
||||
| `memory.write` trace | **REAL** | fires on smart_ingest/ingest, memory promote/demote/edit, codebase remember_*, AND destructive purge/delete |
|
||||
| `memory.retrieve` trace | **REAL** | fires on deep_reference/search, with per-id activation |
|
||||
| `memory.suppress` trace | **REAL** | recorded path; fires when retrieval suppresses |
|
||||
| `contradiction.detected` trace | **REAL** | fires when deep_reference surfaces a contradiction pair; UI says "no contradiction in this run" when none |
|
||||
| Memory Receipts | **REAL** | built from real scored memories + trust, persisted, attached to output |
|
||||
| Risk-gated Memory PRs | **REAL** | quarantine review: commit-then-suppress, audit preserved, influence suspended. Promote verified end-to-end (releases the memory, even past the 24h window). Destructive purge/delete also open a PR. PR content is **redacted** for sensitive writes (preview + hash, never the raw secret) |
|
||||
| Fast / Risk-Gated / Paranoid modes | **REAL** | persisted to `<data_dir>/review_mode.json`; Risk-Gated is the default |
|
||||
| WebSocket broadcast | **REAL** | proven by `websocket-events.jsonl` + a unit test |
|
||||
| `vestige://trace/{runId}` resource | **REAL** | proven by the full-spine test |
|
||||
| `sanhedrin.veto` trace | **CAVEAT** | extraction code is real + unit-tested, but the Sanhedrin verifier is an optional hook, **off by default** — no producer is connected, and the UI says exactly that |
|
||||
| `dream.patch` trace | **REAL** (proven 2026-06-22) | a real `dream` run over 6 memories produced one `dream.patch` event under `run_dream_proof` — see `dream-trace.json` (last event), `dream-websocket-events.jsonl`, and `screenshots/dream-producers.png` where the row flips to "fired this run". The UI still shows "No dream run in this trace" for runs where no dream executed. |
|
||||
| Graph-pulse "Open receipt in Cinema" | **REAL (deep-link)** | navigates the graph centered on the receipt's primary memory; MemoryCinema itself is unchanged |
|
||||
|
||||
No feature is stubbed. The two CAVEATs are real plumbing whose upstream
|
||||
producer is intentionally off by default — surfaced as explicit UI states, not
|
||||
empty mystery.
|
||||
|
||||
## dream.patch — proven with a real dream run (2026-06-22)
|
||||
|
||||
Bounded follow-up: a single real `dream` consolidation flipped the `dream.patch`
|
||||
producer from "quiet" to a recorded live event, same runId, every hop.
|
||||
|
||||
- 6 related memories seeded under `run_dream_proof`, then one `dream` call.
|
||||
- The dream produced one consolidation insight → one `dream.patch` event:
|
||||
`dream:RecurringPattern:5d941c7f+a41aca72+b029fe53+6167f2c3+1117dd4e+e0782442`
|
||||
(the real insight type + the six source memories it bridged).
|
||||
- SQLite: `dream-trace.json` (14 events, last is `dream.patch`).
|
||||
- API: `/api/traces/run_dream_proof/export` → `dream-trace.json`.
|
||||
- WebSocket: `dream-websocket-events.jsonl` (the `dream.patch` TraceEvent).
|
||||
- Dashboard: `screenshots/black-box-dream.png` + `screenshots/dream-producers.png`
|
||||
(the producers row shows **dream.patch · fired this run**).
|
||||
|
||||
`dream.patch` is real but not live-by-default: it fires only when a dream
|
||||
actually runs. The UI says so for runs where it didn't.
|
||||
|
||||
## Reproduce
|
||||
|
||||
1. `VESTIGE_DATA_DIR=<tmp> VESTIGE_DASHBOARD_ENABLED=true vestige-mcp` (stdio).
|
||||
2. `initialize`, then drive `smart_ingest` / `deep_reference` calls with a
|
||||
`runId` argument.
|
||||
3. A sensitive-topic write (auth/security/money/identity/…) opens a Memory PR.
|
||||
4. `curl /api/traces/<runId>/export` → the `.vestige-trace.json`.
|
||||
5. `cargo test -p vestige-mcp test_full_spine_one_runid_crosses_every_hop`.
|
||||
198
blackbox-proof-2026-06-22/REVIEW.md
Normal file
198
blackbox-proof-2026-06-22/REVIEW.md
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
# Agent Black Box — Review Bundle
|
||||
|
||||
**Branch:** `feat/agent-black-box` (head = branch tip)
|
||||
**Base (review against):** `9e92a5999ada37bed9b4820bb25b7748b417411c` (the
|
||||
`feat/dashboard-bleeding-edge` tip this branched from)
|
||||
**Status:** feature work **frozen**. No quarantine-constellation work has
|
||||
started.
|
||||
|
||||
Start here, then read `PROOF.md` (the per-feature real/caveat/stub ledger) and
|
||||
open the screenshots.
|
||||
|
||||
> **Update — review findings addressed.** A full multi-agent review found 7
|
||||
> real issues (4 blockers). All are fixed and tested; this bundle was
|
||||
> **re-captured from a single run (`run_proof`)** so trace.json,
|
||||
> websocket-events.jsonl, and memory_pr.json now all carry the same runId. See
|
||||
> "Review findings addressed" below.
|
||||
|
||||
---
|
||||
|
||||
## Frozen public claim
|
||||
|
||||
> Vestige records real MCP memory activity into a replayable local trace, with
|
||||
> receipts and reviewable risky writes.
|
||||
|
||||
We do **not** claim Sanhedrin vetoes are live by default. `dream.patch` is real
|
||||
but fires only when a dream actually runs (proven below; the UI says so when no
|
||||
dream ran).
|
||||
|
||||
---
|
||||
|
||||
## What's in this bundle
|
||||
|
||||
| File | What it is |
|
||||
|------|------------|
|
||||
| `REVIEW.md` | this file — the entry point |
|
||||
| `PROOF.md` | per-feature real/caveat/stub ledger + reproduce steps |
|
||||
| `status.json` | live server `/api/health` at capture time |
|
||||
| `trace.json` | `.vestige-trace.json` export of `run_proof` (10 real events) |
|
||||
| `receipt.json` | a real retrieval receipt (`r_2026_06_22_runproof`) |
|
||||
| `memory_pr.json` | the risky auth write → Memory PR, **promoted** UI→API→SQLite |
|
||||
| `websocket-events.jsonl` | live WS stream: `TraceEvent`×6, `MemoryPrOpened`, `MemoryPrDecided`, … |
|
||||
| `dream-trace.json` | `run_dream_proof` export — 14 events, last is `dream.patch` |
|
||||
| `dream-websocket-events.jsonl` | live WS stream containing the `dream.patch` `TraceEvent` |
|
||||
| `screenshots/black-box.png` | Black Box tab (spine header, scrubber, producers, log) |
|
||||
| `screenshots/receipts.png` | the `ReceiptCard` with real data + "Open receipt in Cinema" |
|
||||
| `screenshots/memory-prs.png` | Memory PRs: diff, "Why this opened", `Decided: promote` |
|
||||
| `screenshots/graph.png` | live graph constellation + Memory Cinema (unchanged) |
|
||||
| `screenshots/black-box-dream.png` | Black Box on the dream run |
|
||||
| `screenshots/dream-producers.png` | producers panel — `dream.patch · fired this run` |
|
||||
|
||||
---
|
||||
|
||||
## Caveat ledger (the honest part)
|
||||
|
||||
| Producer | Status | Why |
|
||||
|----------|--------|-----|
|
||||
| `mcp.call`, `memory.write`, `memory.retrieve`, `memory.suppress` | **REAL** | fire on real tool traffic; args hashed, never stored raw |
|
||||
| `contradiction.detected` | **REAL** | fires when deep_reference surfaces a contradiction; UI shows "no contradiction in this run" otherwise |
|
||||
| Memory Receipts | **REAL** | built from real scored memories + trust, persisted, attached to output |
|
||||
| Risk-gated Memory PRs (quarantine review) | **REAL** | commit-then-suppress; audit preserved, influence suspended; Promote verified end-to-end |
|
||||
| WebSocket broadcast | **REAL** | `websocket-events.jsonl` + unit test |
|
||||
| `vestige://trace/{runId}` resource | **REAL** | full-spine test hop 5 |
|
||||
| `dream.patch` | **REAL** (not live-by-default) | proven by `run_dream_proof`; fires only when a dream runs; UI says so otherwise |
|
||||
| `sanhedrin.veto` | **CAVEAT** | extraction is real + unit-tested, but the Sanhedrin verifier is an **optional hook, off by default** — no producer connected; UI says exactly that |
|
||||
|
||||
No feature is stubbed. The one remaining caveat is surfaced as an explicit UI
|
||||
state, not an empty space.
|
||||
|
||||
---
|
||||
|
||||
## Command receipts (run live at 2026-06-22T22:57:59Z)
|
||||
|
||||
Toolchain: `rustc 1.95.0` · `cargo 1.95.0` · `node v24.12.0` · `pnpm 10.33.0`.
|
||||
|
||||
```
|
||||
$ cargo test --workspace --lib
|
||||
test result: ok. 529 passed; 0 failed; 0 ignored; 0 measured (vestige-core)
|
||||
test result: ok. 33 passed; 0 failed; 0 ignored; 0 measured (tests/e2e unit)
|
||||
test result: ok. 428 passed; 0 failed; 0 ignored; 0 measured (vestige-mcp)
|
||||
# 990 lib tests, 0 failures
|
||||
|
||||
$ cargo clippy --workspace -- -D warnings
|
||||
Finished `dev` profile ... (EXIT 0, zero warnings)
|
||||
|
||||
$ pnpm --filter @vestige/dashboard check
|
||||
COMPLETED 905 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS
|
||||
|
||||
$ pnpm --filter @vestige/dashboard build
|
||||
✓ built in 4.15s ... ✔ done
|
||||
|
||||
$ cargo test -p vestige-mcp test_full_spine_one_runid_crosses_every_hop
|
||||
test server::tests::test_full_spine_one_runid_crosses_every_hop ... ok
|
||||
|
||||
$ cargo test -p vestige-mcp trace_recorder::tests::extract_dream
|
||||
test ...extract_dream_proposals_empty_when_not_dream_tool ... ok
|
||||
test ...extract_dream_proposals_from_real_insights_shape ... ok
|
||||
|
||||
$ cargo test -p vestige-core trace
|
||||
test result: ok. 27 passed; 0 failed (trace schema, receipt, review)
|
||||
```
|
||||
|
||||
Only statuses with a receipt above are credited. Nothing is claimed from memory.
|
||||
|
||||
---
|
||||
|
||||
## Review surface (what changed)
|
||||
|
||||
The Black Box work sits in a series of commits on top of the base. To see the
|
||||
exact, current diff (build artifacts + this proof bundle excluded):
|
||||
|
||||
```
|
||||
$ git diff --stat 9e92a59..HEAD -- ':!apps/dashboard/build' ':!blackbox-proof-2026-06-22'
|
||||
# ~27 source files (Rust + SvelteKit). Run this against the branch tip for the
|
||||
# live count — it grows as review fixes land.
|
||||
```
|
||||
|
||||
Commits (oldest first) — run `git log --oneline 9e92a59..HEAD` for the live,
|
||||
authoritative list; the series so far:
|
||||
- `80c823a` feat: Agent Black Box + Receipts + risk-gated Memory PRs
|
||||
- `b89beee` proof: Proof Lock — full-spine test, honest UI states, proof pack
|
||||
- `140b15f` proof: dream.patch proven live with a real dream run
|
||||
- `cadffb4` docs: package the review bundle — REVIEW.md entry point
|
||||
- `8f7bed0` fix: review blockers B1–B7 + re-capture proof bundle
|
||||
- `6a0173d` fix: C1 unconditional quarantine release + C2 trace destructive writes
|
||||
- `…` fix: C2-deep (gate destructive writes post-delete) + PRIV (redact PR content)
|
||||
|
||||
The hashes above are point-in-time; the branch tip is the source of truth.
|
||||
|
||||
Key files to review:
|
||||
- **Core (pure logic):** `crates/vestige-core/src/trace/{mod,receipt,review}.rs`
|
||||
- **Persistence:** `crates/vestige-core/src/storage/trace_store.rs` + `migrations.rs` (V18)
|
||||
- **MCP wiring:** `crates/vestige-mcp/src/trace_recorder.rs`, `server.rs` (dispatch),
|
||||
`resources/trace.rs`
|
||||
- **Dashboard API:** `crates/vestige-mcp/src/dashboard/{handlers,events,mod}.rs`
|
||||
- **UI:** `apps/dashboard/src/routes/(app)/{blackbox,memory-prs}/+page.svelte`,
|
||||
`lib/components/{ReceiptCard.svelte,blackbox-helpers.ts}`
|
||||
|
||||
---
|
||||
|
||||
## Suggested review checklist
|
||||
|
||||
- [ ] **Spine integrity:** read `test_full_spine_one_runid_crosses_every_hop`
|
||||
(crates/vestige-mcp/src/server.rs) — does it actually assert the runId is
|
||||
byte-identical at all five hops?
|
||||
- [ ] **Privacy:** confirm `mcp.call` stores only a hash of args
|
||||
(`trace_recorder::hash_args`), never raw args.
|
||||
- [ ] **Risk taxonomy:** review `classify_write` + `WriteContext`
|
||||
(crates/vestige-core/src/trace/review.rs) — is the sensitive-topic /
|
||||
contradiction / supersede gating correct and not over-broad?
|
||||
- [ ] **Quarantine semantics:** confirm risky writes are committed-then-suppressed
|
||||
(audit preserved), not silently dropped, and the copy says so.
|
||||
- [ ] **Migration safety:** V18 is additive; `MIGRATIONS.last().version` is used
|
||||
by the migration tests (no hard-coded version literals left).
|
||||
- [ ] **Local-first defaults:** Risk-Gated is default; Sanhedrin/dream producers
|
||||
stay optional and off by default; nothing forces heavy models.
|
||||
- [ ] **No protected code touched:** `MemoryCinema.svelte` and `graph/cinema/*`
|
||||
are unchanged; the graph page only gained an additive `?center=` param.
|
||||
|
||||
---
|
||||
|
||||
## Review findings addressed (2026-06-22)
|
||||
|
||||
A full read-only review (multiple parallel agents, both Rust and dashboard)
|
||||
found 7 real issues — 4 blockers. All fixed and tested:
|
||||
|
||||
| # | Severity | Finding | Fix | Proof |
|
||||
|---|----------|---------|-----|-------|
|
||||
| B1 | blocker | Promoting a Memory PR didn't unsuppress the quarantined memory — UI said "promoted" while the memory stayed out of retrieval | `act_on_memory_pr` now calls `reverse_suppression(subject_id)` on accept actions (promote/merge/supersede); `MemoryPrAction::releases_memory()` encodes the rule | live: PR response `subjectReleased: true`, SQLite `suppression_count: 0`; tests `promote_releases_a_quarantined_memory_end_to_end`, `only_accept_actions_release_the_memory` |
|
||||
| B2 | blocker | memory promote/demote (returns `action`, not `decision`) and `codebase` writes bypassed the write-trace + gate | `extract_writes` reads `action` too, filtered by `is_write_decision`; `is_write_tool` includes `codebase` | tests `extract_writes_recognizes_action_shape_b2`, `extract_writes_ignores_read_actions_b2`, `write_tool_set_includes_codebase_b2` |
|
||||
| B3 | blocker | Receipt ids collided within a run (`r_<date>_<runId>` + `INSERT OR REPLACE`) — later receipt overwrote earlier | id is now `r_<date>_<runId8>_<unique6>` | live: two receipts in `run_proof` have distinct ids; test `receipt_ids_unique_within_a_run_b3` |
|
||||
| B4 | blocker | Proof bundle mis-assembled: `trace.json`=`run_proof` but `websocket-events.jsonl`=`run_proof2` | re-captured the whole bundle from one run | all artifacts now carry `run_proof` (verified) |
|
||||
| B5 | P2 | Black Box receipts panel showed global latest, not the selected run's | `list_receipts_for_run` + `/api/receipts?run=` + page uses `listForRun(runId)` | live: `?run=run_proof` returns only that run; test `receipts_are_listable_per_run_b5` |
|
||||
| B6 | P2 | `SENSITIVE_TOPICS` substring match false-fired ("tokenizer"→token, "author"→auth) | word-boundary matching | tests `sensitive_topic_word_boundary_no_false_positives_b6`, `..._still_catches_real_b6` |
|
||||
| B7 | P3 | `set_review_mode` non-atomic write; export filename used raw `run_id` | `write_atomic` (temp+rename); filename sanitized; static routes declared before dynamic | covered by build + the atomic-write helper's existing use |
|
||||
| C1 | blocker | B1's release used `reverse_suppression`, which **refuses past the 24h labile window** — a PR promoted late stayed suppressed | new `release_quarantine(id)`: unconditional release (no time limit), used by the PR handler instead | test `release_quarantine_works_past_the_labile_window_c1` (proves reverse_suppression refuses but release_quarantine succeeds at +100h) |
|
||||
| C2 | blocker | `memory` `purge`/`delete` (destructive removal) bypassed the write-trace + gate | added purge/purged/delete/deleted/forget/forgotten to `is_write_decision` | test `extract_writes_recognizes_destructive_actions_c2` |
|
||||
| C2-deep | blocker | C2 made purge *trace*, but `gate_writes` did `get_node→skip` on the (already-deleted) row, so a destructive write still **never opened a PR** | gate now treats a missing node as gateable for destructive decisions (builds the context from the decision, marks `forgets`); the PR records the removal with `deleted:true` | test `gate_opens_pr_for_destructive_write_after_node_deleted_c2`; **live:** purging a node opened a PR (`kind: node_decayed`, `deleted: true`) |
|
||||
| PRIV | blocker | `gate_writes` copied **full `node.content`** into the PR `diff` + `title` — a real secret would leak into the `memory_prs` table and any exported proof bundle | PR now stores a truncated **preview** + a **content hash**; sensitive-topic-gated writes are fully **redacted** (`[redacted — sensitive content…]`); the committed `memory_pr.json` was re-captured and contains no secret | tests `gate_redacts_sensitive_content_in_pr_priv`, `content_preview_redacts_sensitive_and_truncates`; **live + bundle scan:** no secret string anywhere |
|
||||
|
||||
One earlier (self-)review claim was **withdrawn**: the `/api/memory-prs/mode`
|
||||
vs `/{id}` route order is *not* a functional bug — axum 0.8 / matchit gives
|
||||
static segments priority. Reordered for clarity only.
|
||||
|
||||
Net after fixes (B1–B7 + C1/C2 + C2-deep + PRIV): **1007 lib tests pass, clippy `-D warnings` clean, dashboard
|
||||
check + build clean.**
|
||||
|
||||
## Reproduce (any reviewer, locally)
|
||||
|
||||
```sh
|
||||
# 1. run a real server with the dashboard on
|
||||
VESTIGE_DATA_DIR=$(mktemp -d) VESTIGE_DASHBOARD_ENABLED=true vestige-mcp # stdio JSON-RPC
|
||||
# 2. initialize, then drive tools/call with a runId arg (smart_ingest, deep_reference)
|
||||
# 3. a sensitive-topic write opens a Memory PR; promote it via the dashboard
|
||||
# 4. export the trace:
|
||||
curl -s http://127.0.0.1:3927/api/traces/<runId>/export
|
||||
# 5. for dream.patch: seed >=5 memories, then call the `dream` tool with the same runId
|
||||
# 6. run the regression: cargo test -p vestige-mcp test_full_spine_one_runid_crosses_every_hop
|
||||
```
|
||||
1
blackbox-proof-2026-06-22/dream-trace.json
Normal file
1
blackbox-proof-2026-06-22/dream-trace.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"events":[{"argsHash":"ec88652d34b9d5b6","at":1782168489775,"runId":"run_dream_proof","tool":"smart_ingest","type":"mcp.call"},{"at":1782168489889,"diff":{"decision":"create"},"id":"b029fe53-5f78-4c49-8100-62e6243a19ae","runId":"run_dream_proof","source":"agent","type":"memory.write"},{"argsHash":"10134f9d053e1139","at":1782168490987,"runId":"run_dream_proof","tool":"smart_ingest","type":"mcp.call"},{"at":1782168491091,"diff":{"decision":"create"},"id":"6167f2c3-c567-4ec4-b63d-d0b6660173fc","runId":"run_dream_proof","source":"agent","type":"memory.write"},{"argsHash":"a687fb99ed887e10","at":1782168492200,"runId":"run_dream_proof","tool":"smart_ingest","type":"mcp.call"},{"at":1782168492297,"diff":{"decision":"create"},"id":"e0782442-0ce0-4a54-94db-4814ae392bbd","runId":"run_dream_proof","source":"agent","type":"memory.write"},{"argsHash":"a672de9e54d138f9","at":1782168493413,"runId":"run_dream_proof","tool":"smart_ingest","type":"mcp.call"},{"at":1782168493496,"diff":{"decision":"create"},"id":"a41aca72-7758-4f07-8da0-2e16469efc81","runId":"run_dream_proof","source":"agent","type":"memory.write"},{"argsHash":"d5878d7adad2cfe1","at":1782168494628,"runId":"run_dream_proof","tool":"smart_ingest","type":"mcp.call"},{"at":1782168494724,"diff":{"decision":"create"},"id":"5d941c7f-c75c-4c89-acc1-af1b95d3a4de","runId":"run_dream_proof","source":"agent","type":"memory.write"},{"argsHash":"14c334181d37393d","at":1782168495841,"runId":"run_dream_proof","tool":"smart_ingest","type":"mcp.call"},{"at":1782168495922,"diff":{"decision":"create"},"id":"1117dd4e-11f7-434d-b0b7-2b5bcbd841d4","runId":"run_dream_proof","source":"agent","type":"memory.write"},{"argsHash":"1071da7bf3583db3","at":1782168511374,"runId":"run_dream_proof","tool":"dream","type":"mcp.call"},{"at":1782168511375,"proposalIds":["dream:RecurringPattern:5d941c7f+a41aca72+b029fe53+6167f2c3+1117dd4e+e0782442"],"runId":"run_dream_proof","type":"dream.patch"}],"exportedAt":"2026-06-22T22:50:09.588334+00:00","format":"vestige-trace","runId":"run_dream_proof","summary":{"eventCount":14,"firstTool":"smart_ingest","lastAt":1782168511375,"retrievedCount":0,"startedAt":1782168489775,"suppressedCount":0,"vetoCount":0,"writeCount":6},"version":1}
|
||||
5
blackbox-proof-2026-06-22/dream-websocket-events.jsonl
Normal file
5
blackbox-proof-2026-06-22/dream-websocket-events.jsonl
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{"data": {"timestamp": "2026-06-22T22:48:29.454773+00:00", "version": "2.1.27"}, "type": "Connected"}
|
||||
{"type": "TraceEvent", "data": {"run_id": "run_dream_proof", "seq": 12, "event": {"type": "mcp.call", "runId": "run_dream_proof", "tool": "dream", "argsHash": "1071da7bf3583db3", "at": 1782168511374}, "timestamp": "2026-06-22T22:48:31.374680Z"}}
|
||||
{"type": "DreamStarted", "data": {"memory_count": 6, "timestamp": "2026-06-22T22:48:31.374852Z"}}
|
||||
{"type": "DreamCompleted", "data": {"memories_replayed": 6, "connections_found": 0, "insights_generated": 1, "duration_ms": 0, "timestamp": "2026-06-22T22:48:31.375855Z"}}
|
||||
{"type": "TraceEvent", "data": {"run_id": "run_dream_proof", "seq": 13, "event": {"type": "dream.patch", "runId": "run_dream_proof", "proposalIds": ["dream:RecurringPattern:5d941c7f+a41aca72+b029fe53+6167f2c3+1117dd4e+e0782442"], "at": 1782168511375}, "timestamp": "2026-06-22T22:48:31.375973Z"}}
|
||||
0
blackbox-proof-2026-06-22/memory_pr.json
Normal file
0
blackbox-proof-2026-06-22/memory_pr.json
Normal file
0
blackbox-proof-2026-06-22/receipt.json
Normal file
0
blackbox-proof-2026-06-22/receipt.json
Normal file
16
blackbox-proof-2026-06-22/screenshots/README.md
Normal file
16
blackbox-proof-2026-06-22/screenshots/README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Proof Pack Screenshots
|
||||
|
||||
Captured with Playwright (`@playwright/test`, headless Chromium, 1440×1700 @2x)
|
||||
from the **live** Vestige dashboard at `http://localhost:5173/dashboard`,
|
||||
proxying to a real `vestige-mcp` server with real trace data.
|
||||
|
||||
| File | Tab | Shows |
|
||||
|------|-----|-------|
|
||||
| `black-box.png` | Black Box | spine header (WebSocket Connected), run picker (`proof`/`proof2`), timeline scrubber + colored ticks, current event detail, memory pulse, **event producers** (with honest `dream.patch`/`sanhedrin.veto` off-by-default states), receipts panel, full event log |
|
||||
| `receipts.png` | Black Box → Receipts | a real `ReceiptCard`: receipt id, retrieved/suppressed/trust-floor, activation path, retrieved ids, "Open receipt in Cinema" |
|
||||
| `memory-prs.png` | Memory PRs | killer line + quarantine-review note, Fast/Risk-Gated/Paranoid modes, status filters, PR rows, cognition diff, "Why this opened" signal (`sensitive_topic`), `Decided: promote` |
|
||||
| `graph.png` | Graph | the live WebGL memory constellation + Memory Cinema button (unchanged) |
|
||||
|
||||
Re-capture: start the dev server (`pnpm --filter @vestige/dashboard dev`),
|
||||
point its `/api` proxy at a running `vestige-mcp` with trace data, then run the
|
||||
capture script (see PROOF.md "Reproduce").
|
||||
BIN
blackbox-proof-2026-06-22/screenshots/black-box-dream.png
Normal file
BIN
blackbox-proof-2026-06-22/screenshots/black-box-dream.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 556 KiB |
BIN
blackbox-proof-2026-06-22/screenshots/black-box.png
Normal file
BIN
blackbox-proof-2026-06-22/screenshots/black-box.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 679 KiB |
BIN
blackbox-proof-2026-06-22/screenshots/dream-producers.png
Normal file
BIN
blackbox-proof-2026-06-22/screenshots/dream-producers.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
BIN
blackbox-proof-2026-06-22/screenshots/graph.png
Normal file
BIN
blackbox-proof-2026-06-22/screenshots/graph.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 263 KiB |
BIN
blackbox-proof-2026-06-22/screenshots/memory-prs.png
Normal file
BIN
blackbox-proof-2026-06-22/screenshots/memory-prs.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 579 KiB |
BIN
blackbox-proof-2026-06-22/screenshots/receipts.png
Normal file
BIN
blackbox-proof-2026-06-22/screenshots/receipts.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
0
blackbox-proof-2026-06-22/status.json
Normal file
0
blackbox-proof-2026-06-22/status.json
Normal file
0
blackbox-proof-2026-06-22/trace.json
Normal file
0
blackbox-proof-2026-06-22/trace.json
Normal file
8
blackbox-proof-2026-06-22/websocket-events.jsonl
Normal file
8
blackbox-proof-2026-06-22/websocket-events.jsonl
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{"data": {"timestamp": "2026-06-23T00:43:28.238141+00:00", "version": "2.1.27"}, "type": "Connected"}
|
||||
{"type": "TraceEvent", "data": {"run_id": "run_proof", "seq": 14, "event": {"type": "mcp.call", "runId": "run_proof", "tool": "deep_reference", "argsHash": "13a31297fe007a2e", "at": 1782175410153}, "timestamp": "2026-06-23T00:43:30.154710Z"}}
|
||||
{"type": "TraceEvent", "data": {"run_id": "run_proof", "seq": 15, "event": {"type": "memory.retrieve", "runId": "run_proof", "ids": ["591c638e-1fc7-4b6d-bcb3-b7fcb6c0c7b3", "6aa12b99-270e-4fb6-b523-9f01b0bee16b", "26a3c976-043b-4915-accf-ae098c8dc66b", "76c13cba-7b88-4ce7-b7de-0a906d372806"], "activation": {"26a3c976-043b-4915-accf-ae098c8dc66b": 0.62, "591c638e-1fc7-4b6d-bcb3-b7fcb6c0c7b3": 0.62, "6aa12b99-270e-4fb6-b523-9f01b0bee16b": 0.53, "76c13cba-7b88-4ce7-b7de-0a906d372806": 0.62}, "at": 1782175410209}, "timestamp": "2026-06-23T00:43:30.209554Z"}}
|
||||
{"type": "TraceEvent", "data": {"run_id": "run_proof", "seq": 16, "event": {"type": "mcp.call", "runId": "run_proof", "tool": "search", "argsHash": "ac19c646baf0673d", "at": 1782175411167}, "timestamp": "2026-06-23T00:43:31.167561Z"}}
|
||||
{"type": "SearchPerformed", "data": {"query": "dashboard", "result_count": 2, "result_ids": ["26a3c976-043b-4915-accf-ae098c8dc66b", "76c13cba-7b88-4ce7-b7de-0a906d372806"], "duration_ms": 0, "timestamp": "2026-06-23T00:43:31.182829Z"}}
|
||||
{"type": "TraceEvent", "data": {"run_id": "run_proof", "seq": 17, "event": {"type": "memory.retrieve", "runId": "run_proof", "ids": ["26a3c976-043b-4915-accf-ae098c8dc66b", "76c13cba-7b88-4ce7-b7de-0a906d372806"], "activation": {}, "at": 1782175411182}, "timestamp": "2026-06-23T00:43:31.182933Z"}}
|
||||
{"type": "MemoryUnsuppressed", "data": {"id": "6aa12b99-270e-4fb6-b523-9f01b0bee16b", "remaining_count": 0, "timestamp": "2026-06-23T00:46:18.338387Z"}}
|
||||
{"type": "MemoryPrDecided", "data": {"id": "pr_31ab4c15f1694504bf33be82715bee03", "decision": "promote", "status": "promoted", "timestamp": "2026-06-23T00:46:18.338407Z"}}
|
||||
|
|
@ -90,6 +90,10 @@ pub mod fts;
|
|||
pub mod memory;
|
||||
pub mod storage;
|
||||
|
||||
/// Agent Black Box, Memory Receipts & Memory PRs — the cognitive flight
|
||||
/// recorder, immune system, and reviewable-diff model for agent memory.
|
||||
pub mod trace;
|
||||
|
||||
#[cfg(feature = "embeddings")]
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "embeddings")))]
|
||||
pub mod embeddings;
|
||||
|
|
@ -160,6 +164,13 @@ pub use fsrs::{
|
|||
// Configuration (vestige.toml output profiles / defaults)
|
||||
pub use config::{CONFIG_FILE, OutputConfig, OutputDefaults, OutputProfile, VestigeConfig};
|
||||
|
||||
// Agent Black Box / Receipts / Memory PRs (the cognitive flight recorder)
|
||||
pub use trace::{
|
||||
classify_write, DecayRisk, MemoryPr, MemoryPrAction, MemoryPrKind, MemoryPrStatus,
|
||||
MemoryTraceEvent, Receipt, ReceiptMutation, ReviewMode, RiskClass, RiskSignal, SuppressReason,
|
||||
SuppressedReceiptEntry, WriteContext, WriteSource, HIGH_TRUST_FLOOR, LOW_CONFIDENCE_FLOOR,
|
||||
};
|
||||
|
||||
// Storage layer
|
||||
pub use storage::{
|
||||
ClassificationResult,
|
||||
|
|
@ -191,6 +202,7 @@ pub use storage::{
|
|||
Result,
|
||||
SchedulingState,
|
||||
SearchQuery,
|
||||
AgentRunSummary,
|
||||
SmartIngestResult,
|
||||
SourceUpsertOutcome,
|
||||
SourceUpsertResult,
|
||||
|
|
|
|||
|
|
@ -89,6 +89,11 @@ pub const MIGRATIONS: &[Migration] = &[
|
|||
description: "#57 Source envelope: provenance columns + connector cursor checkpoints for idempotent external-source sync",
|
||||
up: MIGRATION_V17_UP,
|
||||
},
|
||||
Migration {
|
||||
version: 18,
|
||||
description: "Agent Black Box + Memory Receipts + Memory PRs: replayable run traces, retrieval receipts, risk-gated brain-change review queue",
|
||||
up: MIGRATION_V18_UP,
|
||||
},
|
||||
];
|
||||
|
||||
/// A database migration
|
||||
|
|
@ -1029,6 +1034,105 @@ pub const MIGRATION_V17_ALTER_COLUMNS: &[&str] = &[
|
|||
"ALTER TABLE knowledge_nodes ADD COLUMN source_author TEXT",
|
||||
];
|
||||
|
||||
/// V18: Agent Black Box + Memory Receipts + Memory PRs.
|
||||
///
|
||||
/// Three append-only / review tables that turn Vestige into the *black box,
|
||||
/// immune system, and cinematic debugger for agent memory*:
|
||||
///
|
||||
/// - `agent_traces` — one row per [`crate::trace::MemoryTraceEvent`], ordered by
|
||||
/// `(run_id, seq)`. Append-only so a run replays exactly as the agent
|
||||
/// experienced it. `payload` is the full serialized event; `event_type` and
|
||||
/// `run_id` are denormalized for fast filtering and the `vestige://trace/{id}`
|
||||
/// resource. `args_hash` (for `mcp.call`) is stored, never the raw args, so
|
||||
/// traces can't leak prompt contents or secrets.
|
||||
///
|
||||
/// - `memory_receipts` — one row per retrieval receipt. `payload` holds the full
|
||||
/// [`crate::trace::Receipt`]; the scalar columns (`trust_floor`, `decay_risk`)
|
||||
/// are denormalized for list/sort without parsing JSON.
|
||||
///
|
||||
/// - `memory_prs` — the risk-gated review queue. A risky write (contradiction
|
||||
/// vs high-trust, supersede/forget/merge/protect, sensitive topic, dream
|
||||
/// consolidation, decay resurrection, low-confidence batch, weak-provenance
|
||||
/// connector) lands here as `pending` instead of auto-committing. `diff` is the
|
||||
/// structured before/after, `signals` is the self-explaining risk evidence,
|
||||
/// `run_id` links the PR back to the black-box trace that produced it.
|
||||
///
|
||||
/// `memory_id` / `run_id` references are intentionally *not* foreign keys to
|
||||
/// `knowledge_nodes`: forgetting or superseding a memory must never erase the
|
||||
/// audit trail of the trace, receipt, or PR that touched it (same
|
||||
/// audit-preserving stance as V15's composition tables).
|
||||
const MIGRATION_V18_UP: &str = r#"
|
||||
-- Black-box trace events: append-only, ordered by (run_id, seq).
|
||||
CREATE TABLE IF NOT EXISTS agent_traces (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
event_type TEXT NOT NULL, -- mcp.call | memory.retrieve | ...
|
||||
tool TEXT, -- denormalized for mcp.call rows
|
||||
payload TEXT NOT NULL, -- full serialized MemoryTraceEvent (JSON)
|
||||
at INTEGER NOT NULL, -- wall-clock millis
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_traces_run ON agent_traces(run_id, seq);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_traces_type ON agent_traces(event_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_traces_at ON agent_traces(at);
|
||||
|
||||
-- One row per agent run, for the Black Box run list (denormalized roll-up).
|
||||
CREATE TABLE IF NOT EXISTS agent_runs (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
first_tool TEXT,
|
||||
event_count INTEGER NOT NULL DEFAULT 0,
|
||||
retrieved_count INTEGER NOT NULL DEFAULT 0,
|
||||
suppressed_count INTEGER NOT NULL DEFAULT 0,
|
||||
write_count INTEGER NOT NULL DEFAULT 0,
|
||||
veto_count INTEGER NOT NULL DEFAULT 0,
|
||||
started_at INTEGER NOT NULL, -- millis of first event
|
||||
last_at INTEGER NOT NULL, -- millis of latest event
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_runs_last_at ON agent_runs(last_at DESC);
|
||||
|
||||
-- Retrieval receipts (the "nutrition label" for a piece of agent memory).
|
||||
CREATE TABLE IF NOT EXISTS memory_receipts (
|
||||
receipt_id TEXT PRIMARY KEY,
|
||||
run_id TEXT, -- links to the trace, if any
|
||||
tool TEXT,
|
||||
query TEXT,
|
||||
retrieved_count INTEGER NOT NULL DEFAULT 0,
|
||||
suppressed_count INTEGER NOT NULL DEFAULT 0,
|
||||
trust_floor REAL NOT NULL DEFAULT 0,
|
||||
decay_risk TEXT NOT NULL DEFAULT 'low',
|
||||
payload TEXT NOT NULL, -- full serialized Receipt (JSON)
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_receipts_run ON memory_receipts(run_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_receipts_created_at ON memory_receipts(created_at DESC);
|
||||
|
||||
-- Memory PRs: the risk-gated review queue for brain changes.
|
||||
CREATE TABLE IF NOT EXISTS memory_prs (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL, -- new_fact | contradiction_detected | ...
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
title TEXT NOT NULL,
|
||||
subject_id TEXT, -- the memory this PR concerns, if any
|
||||
run_id TEXT, -- the run that produced it
|
||||
diff TEXT NOT NULL DEFAULT '{}', -- structured before/after (JSON)
|
||||
signals TEXT NOT NULL DEFAULT '[]', -- self-explaining RiskSignal[] (JSON)
|
||||
decision TEXT, -- promote | merge | supersede | ...
|
||||
created_at TEXT NOT NULL,
|
||||
decided_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_prs_status ON memory_prs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_prs_kind ON memory_prs(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_prs_created_at ON memory_prs(created_at DESC);
|
||||
|
||||
UPDATE schema_version SET version = 18, applied_at = datetime('now');
|
||||
"#;
|
||||
|
||||
/// Apply pending migrations
|
||||
pub fn apply_migrations(conn: &rusqlite::Connection) -> rusqlite::Result<u32> {
|
||||
let current_version = get_current_version(conn)?;
|
||||
|
|
@ -1109,9 +1213,10 @@ mod tests {
|
|||
|
||||
// 1. schema_version advanced to the latest migration
|
||||
let version = get_current_version(&conn).expect("read schema_version");
|
||||
let latest = MIGRATIONS.last().unwrap().version;
|
||||
assert_eq!(
|
||||
version, 17,
|
||||
"schema_version must be 17 after all migrations"
|
||||
version, latest,
|
||||
"schema_version must be the latest migration after all migrations"
|
||||
);
|
||||
|
||||
// 2. knowledge_edges is gone (V11 drops it)
|
||||
|
|
@ -1236,7 +1341,11 @@ mod tests {
|
|||
|
||||
// After replaying from V10, the schema advances to the latest version.
|
||||
let version = get_current_version(&conn).expect("read schema_version");
|
||||
assert_eq!(version, 17, "schema_version back at latest after replay");
|
||||
assert_eq!(
|
||||
version,
|
||||
MIGRATIONS.last().unwrap().version,
|
||||
"schema_version back at latest after replay"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1310,7 +1419,11 @@ mod tests {
|
|||
// V16 uses CREATE TABLE IF NOT EXISTS and idempotent ALTER handling.
|
||||
apply_migrations(&conn).expect("V16 replay must be idempotent");
|
||||
let version = get_current_version(&conn).expect("read version");
|
||||
assert_eq!(version, 17, "schema_version must be latest after replay");
|
||||
assert_eq!(
|
||||
version,
|
||||
MIGRATIONS.last().unwrap().version,
|
||||
"schema_version must be latest after replay"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1400,7 +1513,11 @@ mod tests {
|
|||
.expect("rewind to 16");
|
||||
apply_migrations(&conn).expect("V17 replay must be idempotent");
|
||||
let version = get_current_version(&conn).expect("read version");
|
||||
assert_eq!(version, 17, "schema_version must be 17 after replay");
|
||||
assert_eq!(
|
||||
version,
|
||||
MIGRATIONS.last().unwrap().version,
|
||||
"schema_version must be latest after replay"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ mod memory_store;
|
|||
mod migrations;
|
||||
mod portable;
|
||||
mod sqlite;
|
||||
mod trace_store;
|
||||
|
||||
pub use memory_store::{
|
||||
ClassificationResult, Domain, HealthStatus, LocalMemoryStore, MemoryEdge, MemoryRecord,
|
||||
|
|
@ -25,6 +26,7 @@ pub use sqlite::{
|
|||
SmartIngestResult, SourceUpsertOutcome, SourceUpsertResult, SqliteMemoryStore,
|
||||
StateTransitionRecord, StorageError,
|
||||
};
|
||||
pub use trace_store::AgentRunSummary;
|
||||
|
||||
/// Backwards-compatibility alias. Retained until Phase 4 completes so every
|
||||
/// existing `Arc<Storage>` call site keeps compiling. Scheduled for removal
|
||||
|
|
|
|||
|
|
@ -302,8 +302,11 @@ const VESTIGE_DISABLE_VECTOR_SEARCH: &str = "VESTIGE_DISABLE_VECTOR_SEARCH";
|
|||
/// so the MCP layer can use `Arc<Storage>` instead of `Arc<Mutex<Storage>>`.
|
||||
pub struct SqliteMemoryStore {
|
||||
db_path: PathBuf,
|
||||
writer: Mutex<Connection>,
|
||||
reader: Mutex<Connection>,
|
||||
// `pub(crate)` so the sibling `trace_store` module (Black Box / Receipts /
|
||||
// Memory PRs CRUD) can lock the same writer/reader connections and follow
|
||||
// the established store idiom without duplicating connection management.
|
||||
pub(crate) writer: Mutex<Connection>,
|
||||
pub(crate) reader: Mutex<Connection>,
|
||||
scheduler: Mutex<FSRSScheduler>,
|
||||
#[cfg(feature = "embeddings")]
|
||||
embedding_service: EmbeddingService,
|
||||
|
|
@ -1782,6 +1785,61 @@ impl SqliteMemoryStore {
|
|||
.ok_or_else(|| StorageError::NotFound(id.to_string()))
|
||||
}
|
||||
|
||||
/// Release a memory from quarantine **unconditionally** (no labile-window
|
||||
/// limit), used when a Memory PR is approved.
|
||||
///
|
||||
/// Unlike [`Self::reverse_suppression`] (which models a time-bounded "undo"
|
||||
/// of an active-forgetting decision and refuses after the labile window),
|
||||
/// approving a quarantined risky write is an explicit reviewer decision that
|
||||
/// must always restore the memory's retrieval influence — even days later.
|
||||
/// Fully clears the suppression (count → 0, `suppressed_at` → NULL) and
|
||||
/// restores strengths. A no-op (returns the node) if it isn't suppressed.
|
||||
pub fn release_quarantine(&self, id: &str) -> Result<KnowledgeNode> {
|
||||
let node = self
|
||||
.get_node(id)?
|
||||
.ok_or_else(|| StorageError::NotFound(id.to_string()))?;
|
||||
|
||||
if node.suppression_count == 0 && node.suppressed_at.is_none() {
|
||||
// Nothing to release — idempotent.
|
||||
return Ok(node);
|
||||
}
|
||||
|
||||
{
|
||||
let writer = self
|
||||
.writer
|
||||
.lock()
|
||||
.map_err(|_| StorageError::Init("Writer lock poisoned".into()))?;
|
||||
writer.execute(
|
||||
"UPDATE knowledge_nodes SET
|
||||
suppression_count = 0,
|
||||
suppressed_at = NULL,
|
||||
retrieval_strength = MIN(1.0, retrieval_strength + 0.15),
|
||||
retention_strength = MIN(1.0, retention_strength + 0.10),
|
||||
stability = stability * 1.25
|
||||
WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
}
|
||||
|
||||
let _ = self.log_access(id, "release_quarantine");
|
||||
|
||||
self.get_node(id)?
|
||||
.ok_or_else(|| StorageError::NotFound(id.to_string()))
|
||||
}
|
||||
|
||||
/// Test-only: backdate a node's `suppressed_at` to simulate a suppression
|
||||
/// that happened long ago (e.g. to verify release works past the labile
|
||||
/// window). `pub(crate)` so sibling test modules can reach it.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_suppressed_at_for_test(&self, id: &str, when: DateTime<Utc>) {
|
||||
if let Ok(writer) = self.writer.lock() {
|
||||
let _ = writer.execute(
|
||||
"UPDATE knowledge_nodes SET suppressed_at = ?1 WHERE id = ?2",
|
||||
params![when.to_rfc3339(), id],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Count memories currently in a suppressed state (suppression_count > 0).
|
||||
pub fn count_suppressed(&self) -> Result<usize> {
|
||||
let reader = self
|
||||
|
|
|
|||
732
crates/vestige-core/src/storage/trace_store.rs
Normal file
732
crates/vestige-core/src/storage/trace_store.rs
Normal file
|
|
@ -0,0 +1,732 @@
|
|||
//! # Black Box / Receipts / Memory PRs — persistence
|
||||
//!
|
||||
//! CRUD for the three V18 tables (`agent_traces` + `agent_runs`,
|
||||
//! `memory_receipts`, `memory_prs`) on [`SqliteMemoryStore`]. The pure data
|
||||
//! model lives in [`crate::trace`]; this file is the storage half of the
|
||||
//! Black Box, immune system, and cinematic debugger for agent memory.
|
||||
//!
|
||||
//! Every method follows the established store idiom: lock the writer/reader
|
||||
//! `Mutex<Connection>`, `params![]`-bind, store timestamps as RFC3339 (and
|
||||
//! event millis as INTEGER), serialize structured fields with `serde_json`, and
|
||||
//! map rows back through a small closure.
|
||||
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::sqlite::SqliteMemoryStore;
|
||||
use super::{Result, StorageError};
|
||||
use crate::trace::{MemoryPr, MemoryPrAction, MemoryPrStatus, MemoryTraceEvent, Receipt};
|
||||
|
||||
/// A roll-up summary of one agent run, for the Black Box run list.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
|
||||
pub struct AgentRunSummary {
|
||||
/// The run id.
|
||||
pub run_id: String,
|
||||
/// The first tool invoked in the run (the run's "entry point").
|
||||
pub first_tool: Option<String>,
|
||||
/// Total events recorded.
|
||||
pub event_count: i64,
|
||||
/// Memories retrieved across the run.
|
||||
pub retrieved_count: i64,
|
||||
/// Memories suppressed across the run.
|
||||
pub suppressed_count: i64,
|
||||
/// Memory writes across the run.
|
||||
pub write_count: i64,
|
||||
/// Sanhedrin vetoes across the run.
|
||||
pub veto_count: i64,
|
||||
/// Millis of the first event.
|
||||
pub started_at: i64,
|
||||
/// Millis of the most recent event.
|
||||
pub last_at: i64,
|
||||
}
|
||||
|
||||
impl SqliteMemoryStore {
|
||||
// ========================================================================
|
||||
// BLACK BOX — trace events + run roll-up
|
||||
// ========================================================================
|
||||
|
||||
/// Append one trace event to a run (append-only) and update the run
|
||||
/// roll-up. Returns the assigned sequence number within the run.
|
||||
///
|
||||
/// `seq` is `MAX(seq)+1` for the run, computed under the writer lock so a
|
||||
/// run's events stay totally ordered even under concurrent tool calls.
|
||||
pub fn append_trace_event(&self, event: &MemoryTraceEvent) -> Result<i64> {
|
||||
let now = Utc::now();
|
||||
let run_id = event.run_id().to_string();
|
||||
let event_type = event.kind();
|
||||
let at = event.at();
|
||||
let payload = serde_json::to_string(event)
|
||||
.map_err(|e| StorageError::Init(format!("trace event serialize: {e}")))?;
|
||||
let tool = match event {
|
||||
MemoryTraceEvent::McpCall { tool, .. } => Some(tool.clone()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Roll-up deltas this event contributes.
|
||||
let (d_retrieved, d_suppressed, d_write, d_veto) = match event {
|
||||
MemoryTraceEvent::MemoryRetrieve { ids, .. } => (ids.len() as i64, 0, 0, 0),
|
||||
MemoryTraceEvent::MemorySuppress { .. } => (0, 1, 0, 0),
|
||||
MemoryTraceEvent::MemoryWrite { .. } => (0, 0, 1, 0),
|
||||
MemoryTraceEvent::SanhedrinVeto { .. } => (0, 0, 0, 1),
|
||||
_ => (0, 0, 0, 0),
|
||||
};
|
||||
|
||||
let writer = self
|
||||
.writer
|
||||
.lock()
|
||||
.map_err(|_| StorageError::Init("Writer lock poisoned".into()))?;
|
||||
|
||||
let seq: i64 = writer
|
||||
.query_row(
|
||||
"SELECT COALESCE(MAX(seq), -1) + 1 FROM agent_traces WHERE run_id = ?1",
|
||||
params![run_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
|
||||
writer.execute(
|
||||
"INSERT INTO agent_traces (id, run_id, seq, event_type, tool, payload, at, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![
|
||||
Uuid::new_v4().to_string(),
|
||||
run_id,
|
||||
seq,
|
||||
event_type,
|
||||
tool,
|
||||
payload,
|
||||
at,
|
||||
now.to_rfc3339(),
|
||||
],
|
||||
)?;
|
||||
|
||||
// Upsert the run roll-up. On first event the row is created with the
|
||||
// event's tool as the entry point; subsequent events accumulate counts
|
||||
// and advance `last_at`.
|
||||
writer.execute(
|
||||
"INSERT INTO agent_runs (run_id, first_tool, event_count, retrieved_count,
|
||||
suppressed_count, write_count, veto_count, started_at, last_at, created_at)
|
||||
VALUES (?1, ?2, 1, ?3, ?4, ?5, ?6, ?7, ?7, ?8)
|
||||
ON CONFLICT(run_id) DO UPDATE SET
|
||||
first_tool = COALESCE(agent_runs.first_tool, excluded.first_tool),
|
||||
event_count = agent_runs.event_count + 1,
|
||||
retrieved_count = agent_runs.retrieved_count + ?3,
|
||||
suppressed_count = agent_runs.suppressed_count + ?4,
|
||||
write_count = agent_runs.write_count + ?5,
|
||||
veto_count = agent_runs.veto_count + ?6,
|
||||
last_at = MAX(agent_runs.last_at, ?7)",
|
||||
params![
|
||||
run_id,
|
||||
tool,
|
||||
d_retrieved,
|
||||
d_suppressed,
|
||||
d_write,
|
||||
d_veto,
|
||||
at,
|
||||
now.to_rfc3339(),
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(seq)
|
||||
}
|
||||
|
||||
/// Fetch every event of a run, in sequence order. The black-box replay.
|
||||
pub fn get_trace(&self, run_id: &str) -> Result<Vec<MemoryTraceEvent>> {
|
||||
let reader = self
|
||||
.reader
|
||||
.lock()
|
||||
.map_err(|_| StorageError::Init("Reader lock poisoned".into()))?;
|
||||
let mut stmt = reader.prepare(
|
||||
"SELECT payload FROM agent_traces WHERE run_id = ?1 ORDER BY seq ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![run_id], |row| {
|
||||
let payload: String = row.get(0)?;
|
||||
Ok(payload)
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
let payload = r?;
|
||||
if let Ok(ev) = serde_json::from_str::<MemoryTraceEvent>(&payload) {
|
||||
out.push(ev);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// List recent runs, newest activity first.
|
||||
pub fn list_agent_runs(&self, limit: usize) -> Result<Vec<AgentRunSummary>> {
|
||||
let reader = self
|
||||
.reader
|
||||
.lock()
|
||||
.map_err(|_| StorageError::Init("Reader lock poisoned".into()))?;
|
||||
let mut stmt = reader.prepare(
|
||||
"SELECT run_id, first_tool, event_count, retrieved_count, suppressed_count,
|
||||
write_count, veto_count, started_at, last_at
|
||||
FROM agent_runs ORDER BY last_at DESC LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![limit as i64], Self::row_to_run_summary)?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Fetch one run summary.
|
||||
pub fn get_agent_run(&self, run_id: &str) -> Result<Option<AgentRunSummary>> {
|
||||
let reader = self
|
||||
.reader
|
||||
.lock()
|
||||
.map_err(|_| StorageError::Init("Reader lock poisoned".into()))?;
|
||||
reader
|
||||
.query_row(
|
||||
"SELECT run_id, first_tool, event_count, retrieved_count, suppressed_count,
|
||||
write_count, veto_count, started_at, last_at
|
||||
FROM agent_runs WHERE run_id = ?1",
|
||||
params![run_id],
|
||||
Self::row_to_run_summary,
|
||||
)
|
||||
.optional()
|
||||
.map_err(StorageError::from)
|
||||
}
|
||||
|
||||
fn row_to_run_summary(row: &rusqlite::Row) -> rusqlite::Result<AgentRunSummary> {
|
||||
Ok(AgentRunSummary {
|
||||
run_id: row.get("run_id")?,
|
||||
first_tool: row.get("first_tool").ok().flatten(),
|
||||
event_count: row.get("event_count")?,
|
||||
retrieved_count: row.get("retrieved_count")?,
|
||||
suppressed_count: row.get("suppressed_count")?,
|
||||
write_count: row.get("write_count")?,
|
||||
veto_count: row.get("veto_count")?,
|
||||
started_at: row.get("started_at")?,
|
||||
last_at: row.get("last_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// MEMORY RECEIPTS
|
||||
// ========================================================================
|
||||
|
||||
/// Persist a retrieval receipt. `run_id`/`tool`/`query` are denormalized
|
||||
/// context for the dashboard; the full [`Receipt`] is stored as JSON.
|
||||
pub fn save_receipt(
|
||||
&self,
|
||||
receipt: &Receipt,
|
||||
run_id: Option<&str>,
|
||||
tool: Option<&str>,
|
||||
query: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let payload = serde_json::to_string(receipt)
|
||||
.map_err(|e| StorageError::Init(format!("receipt serialize: {e}")))?;
|
||||
let writer = self
|
||||
.writer
|
||||
.lock()
|
||||
.map_err(|_| StorageError::Init("Writer lock poisoned".into()))?;
|
||||
writer.execute(
|
||||
"INSERT OR REPLACE INTO memory_receipts
|
||||
(receipt_id, run_id, tool, query, retrieved_count, suppressed_count,
|
||||
trust_floor, decay_risk, payload, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
params![
|
||||
receipt.receipt_id,
|
||||
run_id,
|
||||
tool,
|
||||
query,
|
||||
receipt.retrieved.len() as i64,
|
||||
receipt.suppressed.len() as i64,
|
||||
receipt.trust_floor,
|
||||
receipt.decay_risk.as_str(),
|
||||
payload,
|
||||
Utc::now().to_rfc3339(),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch one receipt by id.
|
||||
pub fn get_receipt(&self, receipt_id: &str) -> Result<Option<Receipt>> {
|
||||
let reader = self
|
||||
.reader
|
||||
.lock()
|
||||
.map_err(|_| StorageError::Init("Reader lock poisoned".into()))?;
|
||||
let payload: Option<String> = reader
|
||||
.query_row(
|
||||
"SELECT payload FROM memory_receipts WHERE receipt_id = ?1",
|
||||
params![receipt_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(payload.and_then(|p| serde_json::from_str(&p).ok()))
|
||||
}
|
||||
|
||||
/// List recent receipts, newest first.
|
||||
pub fn list_receipts(&self, limit: usize) -> Result<Vec<Receipt>> {
|
||||
let reader = self
|
||||
.reader
|
||||
.lock()
|
||||
.map_err(|_| StorageError::Init("Reader lock poisoned".into()))?;
|
||||
let mut stmt = reader.prepare(
|
||||
"SELECT payload FROM memory_receipts ORDER BY created_at DESC LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![limit as i64], |row| {
|
||||
let p: String = row.get(0)?;
|
||||
Ok(p)
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
if let Ok(rc) = serde_json::from_str::<Receipt>(&r?) {
|
||||
out.push(rc);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// List the receipts belonging to one run, newest first (B5). The Black Box
|
||||
/// receipts panel uses this so the receipts it shows actually belong to the
|
||||
/// selected run, not the global latest.
|
||||
pub fn list_receipts_for_run(&self, run_id: &str, limit: usize) -> Result<Vec<Receipt>> {
|
||||
let reader = self
|
||||
.reader
|
||||
.lock()
|
||||
.map_err(|_| StorageError::Init("Reader lock poisoned".into()))?;
|
||||
let mut stmt = reader.prepare(
|
||||
"SELECT payload FROM memory_receipts WHERE run_id = ?1
|
||||
ORDER BY created_at DESC LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![run_id, limit as i64], |row| {
|
||||
let p: String = row.get(0)?;
|
||||
Ok(p)
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
if let Ok(rc) = serde_json::from_str::<Receipt>(&r?) {
|
||||
out.push(rc);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// MEMORY PRs — the risk-gated review queue
|
||||
// ========================================================================
|
||||
|
||||
/// Open (insert) a Memory PR.
|
||||
pub fn save_memory_pr(&self, pr: &MemoryPr) -> Result<()> {
|
||||
let diff = serde_json::to_string(&pr.diff).unwrap_or_else(|_| "{}".to_string());
|
||||
let signals = serde_json::to_string(&pr.signals).unwrap_or_else(|_| "[]".to_string());
|
||||
let writer = self
|
||||
.writer
|
||||
.lock()
|
||||
.map_err(|_| StorageError::Init("Writer lock poisoned".into()))?;
|
||||
writer.execute(
|
||||
"INSERT OR REPLACE INTO memory_prs
|
||||
(id, kind, status, title, subject_id, run_id, diff, signals,
|
||||
decision, created_at, decided_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
params![
|
||||
pr.id,
|
||||
pr.kind.as_str(),
|
||||
pr.status.as_str(),
|
||||
pr.title,
|
||||
pr.subject_id,
|
||||
pr.run_id,
|
||||
diff,
|
||||
signals,
|
||||
pr.decision
|
||||
.and_then(|d| serde_json::to_value(d).ok())
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string())),
|
||||
pr.created_at,
|
||||
pr.decided_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch one Memory PR by id.
|
||||
pub fn get_memory_pr(&self, id: &str) -> Result<Option<MemoryPr>> {
|
||||
let reader = self
|
||||
.reader
|
||||
.lock()
|
||||
.map_err(|_| StorageError::Init("Reader lock poisoned".into()))?;
|
||||
reader
|
||||
.query_row(
|
||||
"SELECT id, kind, status, title, subject_id, run_id, diff, signals,
|
||||
decision, created_at, decided_at
|
||||
FROM memory_prs WHERE id = ?1",
|
||||
params![id],
|
||||
Self::row_to_memory_pr,
|
||||
)
|
||||
.optional()
|
||||
.map_err(StorageError::from)
|
||||
}
|
||||
|
||||
/// List Memory PRs, optionally filtered by status, newest first.
|
||||
pub fn list_memory_prs(
|
||||
&self,
|
||||
status: Option<MemoryPrStatus>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<MemoryPr>> {
|
||||
let reader = self
|
||||
.reader
|
||||
.lock()
|
||||
.map_err(|_| StorageError::Init("Reader lock poisoned".into()))?;
|
||||
let (sql, with_filter) = match status {
|
||||
Some(_) => (
|
||||
"SELECT id, kind, status, title, subject_id, run_id, diff, signals,
|
||||
decision, created_at, decided_at
|
||||
FROM memory_prs WHERE status = ?1 ORDER BY created_at DESC LIMIT ?2",
|
||||
true,
|
||||
),
|
||||
None => (
|
||||
"SELECT id, kind, status, title, subject_id, run_id, diff, signals,
|
||||
decision, created_at, decided_at
|
||||
FROM memory_prs ORDER BY created_at DESC LIMIT ?1",
|
||||
false,
|
||||
),
|
||||
};
|
||||
let mut stmt = reader.prepare(sql)?;
|
||||
let mut out = Vec::new();
|
||||
if with_filter {
|
||||
let st = status.unwrap();
|
||||
let rows =
|
||||
stmt.query_map(params![st.as_str(), limit as i64], Self::row_to_memory_pr)?;
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
} else {
|
||||
let rows = stmt.query_map(params![limit as i64], Self::row_to_memory_pr)?;
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Count pending Memory PRs (for the nav badge).
|
||||
pub fn count_pending_memory_prs(&self) -> Result<i64> {
|
||||
let reader = self
|
||||
.reader
|
||||
.lock()
|
||||
.map_err(|_| StorageError::Init("Reader lock poisoned".into()))?;
|
||||
let n: i64 = reader
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM memory_prs WHERE status = 'pending'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Record a decision on a Memory PR, moving it out of `pending`. Returns the
|
||||
/// updated PR. `AskAgentWhy` is read-only and never reaches here.
|
||||
pub fn decide_memory_pr(&self, id: &str, action: MemoryPrAction) -> Result<MemoryPr> {
|
||||
let new_status = action.resulting_status().ok_or_else(|| {
|
||||
StorageError::Init("ask_agent_why is read-only and decides nothing".into())
|
||||
})?;
|
||||
let decision = serde_json::to_value(action)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
{
|
||||
let writer = self
|
||||
.writer
|
||||
.lock()
|
||||
.map_err(|_| StorageError::Init("Writer lock poisoned".into()))?;
|
||||
let changed = writer.execute(
|
||||
"UPDATE memory_prs SET status = ?1, decision = ?2, decided_at = ?3 WHERE id = ?4",
|
||||
params![new_status.as_str(), decision, now, id],
|
||||
)?;
|
||||
if changed == 0 {
|
||||
return Err(StorageError::NotFound(id.to_string()));
|
||||
}
|
||||
}
|
||||
self.get_memory_pr(id)?
|
||||
.ok_or_else(|| StorageError::NotFound(id.to_string()))
|
||||
}
|
||||
|
||||
fn row_to_memory_pr(row: &rusqlite::Row) -> rusqlite::Result<MemoryPr> {
|
||||
let kind_s: String = row.get("kind")?;
|
||||
let status_s: String = row.get("status")?;
|
||||
let diff_s: String = row.get("diff")?;
|
||||
let signals_s: String = row.get("signals")?;
|
||||
let decision_s: Option<String> = row.get("decision").ok().flatten();
|
||||
|
||||
let kind = crate::trace::MemoryPrKind::from_label(&kind_s)
|
||||
.unwrap_or(crate::trace::MemoryPrKind::NewFact);
|
||||
let status = serde_json::from_value(serde_json::Value::String(status_s))
|
||||
.unwrap_or(MemoryPrStatus::Pending);
|
||||
let diff: serde_json::Value = serde_json::from_str(&diff_s).unwrap_or(serde_json::json!({}));
|
||||
let signals = serde_json::from_str(&signals_s).unwrap_or_default();
|
||||
let decision = decision_s
|
||||
.and_then(|s| serde_json::from_value(serde_json::Value::String(s)).ok());
|
||||
|
||||
Ok(MemoryPr {
|
||||
id: row.get("id")?,
|
||||
kind,
|
||||
status,
|
||||
title: row.get("title")?,
|
||||
diff,
|
||||
signals,
|
||||
subject_id: row.get("subject_id").ok().flatten(),
|
||||
run_id: row.get("run_id").ok().flatten(),
|
||||
created_at: row.get("created_at")?,
|
||||
decided_at: row.get("decided_at").ok().flatten(),
|
||||
decision,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::trace::{
|
||||
DecayRisk, MemoryPrKind, MemoryTraceEvent, Receipt, RiskSignal, SuppressReason,
|
||||
SuppressedReceiptEntry,
|
||||
};
|
||||
|
||||
fn store() -> SqliteMemoryStore {
|
||||
// Temp-file store for isolated, fast tests (mirrors the existing
|
||||
// sqlite.rs test helpers; there is no in-memory constructor).
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
SqliteMemoryStore::new(Some(dir.path().join("trace_test.db"))).expect("test store")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_append_orders_and_rolls_up() {
|
||||
let s = store();
|
||||
let run = "run_abc";
|
||||
s.append_trace_event(&MemoryTraceEvent::McpCall {
|
||||
run_id: run.into(),
|
||||
tool: "deep_reference".into(),
|
||||
args_hash: "h".into(),
|
||||
at: 100,
|
||||
})
|
||||
.unwrap();
|
||||
let mut activation = std::collections::BTreeMap::new();
|
||||
activation.insert("m1".to_string(), 0.9);
|
||||
s.append_trace_event(&MemoryTraceEvent::MemoryRetrieve {
|
||||
run_id: run.into(),
|
||||
ids: vec!["m1".into(), "m2".into()],
|
||||
activation,
|
||||
at: 110,
|
||||
})
|
||||
.unwrap();
|
||||
s.append_trace_event(&MemoryTraceEvent::MemorySuppress {
|
||||
run_id: run.into(),
|
||||
id: "m3".into(),
|
||||
reason: SuppressReason::Contradicted,
|
||||
at: 120,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let events = s.get_trace(run).unwrap();
|
||||
assert_eq!(events.len(), 3);
|
||||
assert_eq!(events[0].kind(), "mcp.call");
|
||||
assert_eq!(events[2].kind(), "memory.suppress");
|
||||
|
||||
let summary = s.get_agent_run(run).unwrap().unwrap();
|
||||
assert_eq!(summary.first_tool.as_deref(), Some("deep_reference"));
|
||||
assert_eq!(summary.event_count, 3);
|
||||
assert_eq!(summary.retrieved_count, 2);
|
||||
assert_eq!(summary.suppressed_count, 1);
|
||||
assert_eq!(summary.started_at, 100);
|
||||
assert_eq!(summary.last_at, 120);
|
||||
|
||||
let runs = s.list_agent_runs(10).unwrap();
|
||||
assert_eq!(runs.len(), 1);
|
||||
assert_eq!(runs[0].run_id, run);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipt_roundtrips() {
|
||||
let s = store();
|
||||
let receipt = Receipt {
|
||||
receipt_id: "r_2026_06_22_abc".into(),
|
||||
retrieved: vec!["m1".into(), "m2".into()],
|
||||
suppressed: vec![SuppressedReceiptEntry::new("m3", SuppressReason::LowTrust)],
|
||||
activation_path: vec!["a -> b".into()],
|
||||
trust_floor: 0.62,
|
||||
decay_risk: DecayRisk::Medium,
|
||||
mutations: vec![],
|
||||
};
|
||||
s.save_receipt(&receipt, Some("run_abc"), Some("search"), Some("q"))
|
||||
.unwrap();
|
||||
let got = s.get_receipt("r_2026_06_22_abc").unwrap().unwrap();
|
||||
assert_eq!(got, receipt);
|
||||
assert_eq!(s.list_receipts(10).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipts_are_listable_per_run_b5() {
|
||||
let s = store();
|
||||
let mk = |id: &str| Receipt {
|
||||
receipt_id: id.into(),
|
||||
retrieved: vec!["m1".into()],
|
||||
suppressed: vec![],
|
||||
activation_path: vec![],
|
||||
trust_floor: 0.9,
|
||||
decay_risk: DecayRisk::Low,
|
||||
mutations: vec![],
|
||||
};
|
||||
s.save_receipt(&mk("r_a1"), Some("run_a"), Some("search"), None)
|
||||
.unwrap();
|
||||
s.save_receipt(&mk("r_a2"), Some("run_a"), Some("search"), None)
|
||||
.unwrap();
|
||||
s.save_receipt(&mk("r_b1"), Some("run_b"), Some("search"), None)
|
||||
.unwrap();
|
||||
|
||||
let run_a = s.list_receipts_for_run("run_a", 10).unwrap();
|
||||
assert_eq!(run_a.len(), 2, "run_a has exactly its 2 receipts");
|
||||
assert!(run_a.iter().all(|r| r.receipt_id.starts_with("r_a")));
|
||||
|
||||
let run_b = s.list_receipts_for_run("run_b", 10).unwrap();
|
||||
assert_eq!(run_b.len(), 1, "run_b has only its own receipt");
|
||||
assert_eq!(run_b[0].receipt_id, "r_b1");
|
||||
|
||||
// Global list still sees all three.
|
||||
assert_eq!(s.list_receipts(10).unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_pr_lifecycle() {
|
||||
let s = store();
|
||||
let pr = MemoryPr {
|
||||
id: "pr_1".into(),
|
||||
kind: MemoryPrKind::ContradictionDetected,
|
||||
status: MemoryPrStatus::Pending,
|
||||
title: "Agent wants to overwrite a high-trust fact".into(),
|
||||
diff: serde_json::json!({"before": "x", "after": "y"}),
|
||||
signals: vec![RiskSignal {
|
||||
code: "contradicts_high_trust".into(),
|
||||
detail: "Contradicts trust 0.9.".into(),
|
||||
}],
|
||||
subject_id: Some("m_old".into()),
|
||||
run_id: Some("run_abc".into()),
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
decided_at: None,
|
||||
decision: None,
|
||||
};
|
||||
s.save_memory_pr(&pr).unwrap();
|
||||
|
||||
assert_eq!(s.count_pending_memory_prs().unwrap(), 1);
|
||||
let pending = s
|
||||
.list_memory_prs(Some(MemoryPrStatus::Pending), 10)
|
||||
.unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].signals[0].code, "contradicts_high_trust");
|
||||
|
||||
let decided = s.decide_memory_pr("pr_1", MemoryPrAction::Promote).unwrap();
|
||||
assert_eq!(decided.status, MemoryPrStatus::Promoted);
|
||||
assert_eq!(decided.decision, Some(MemoryPrAction::Promote));
|
||||
assert!(decided.decided_at.is_some());
|
||||
assert_eq!(s.count_pending_memory_prs().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn promote_releases_a_quarantined_memory_end_to_end() {
|
||||
// B1 regression: the full quarantine→release cycle at the storage layer.
|
||||
// gate_writes suppresses a risky write; an accept action must reverse it.
|
||||
let s = store();
|
||||
let node = s
|
||||
.ingest(crate::IngestInput {
|
||||
content: "Risky write that got quarantined.".to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("ingest");
|
||||
assert_eq!(node.suppression_count, 0, "fresh node not suppressed");
|
||||
|
||||
// Quarantine it (what gate_writes does for a risky write).
|
||||
let suppressed = s.suppress_memory(&node.id).expect("suppress");
|
||||
assert_eq!(
|
||||
suppressed.suppression_count, 1,
|
||||
"quarantined write is suppressed (held out of retrieval)"
|
||||
);
|
||||
|
||||
// Promote = release. (The action releases_memory() == true; the handler
|
||||
// calls release_quarantine on the subject.)
|
||||
assert!(crate::MemoryPrAction::Promote.releases_memory());
|
||||
let released = s
|
||||
.release_quarantine(&node.id)
|
||||
.expect("release quarantine");
|
||||
assert_eq!(
|
||||
released.suppression_count, 0,
|
||||
"promoting the PR must release the memory — not leave it suppressed"
|
||||
);
|
||||
assert!(
|
||||
released.suppressed_at.is_none(),
|
||||
"release must clear suppressed_at"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_quarantine_works_past_the_labile_window_c1() {
|
||||
// C1: a PR reviewed LATE (past the 24h active-forgetting labile window)
|
||||
// must still release the memory. reverse_suppression refuses after the
|
||||
// window; release_quarantine must not.
|
||||
let s = store();
|
||||
let node = s
|
||||
.ingest(crate::IngestInput {
|
||||
content: "Risky write quarantined and reviewed days later.".to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("ingest");
|
||||
s.suppress_memory(&node.id).expect("suppress");
|
||||
|
||||
// Backdate suppressed_at to 100h ago — well past any labile window.
|
||||
s.set_suppressed_at_for_test(&node.id, chrono::Utc::now() - chrono::Duration::hours(100));
|
||||
|
||||
// reverse_suppression refuses (window expired)...
|
||||
assert!(
|
||||
s.reverse_suppression(&node.id, 24).is_err(),
|
||||
"reverse_suppression must refuse past the labile window"
|
||||
);
|
||||
// ...but release_quarantine still releases it.
|
||||
let released = s.release_quarantine(&node.id).expect("release past window");
|
||||
assert_eq!(released.suppression_count, 0);
|
||||
assert!(released.suppressed_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_quarantine_is_idempotent_on_unsuppressed() {
|
||||
let s = store();
|
||||
let node = s
|
||||
.ingest(crate::IngestInput {
|
||||
content: "Never suppressed.".to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("ingest");
|
||||
// No-op when not suppressed — must not error.
|
||||
let same = s.release_quarantine(&node.id).expect("idempotent release");
|
||||
assert_eq!(same.suppression_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_agent_why_is_not_a_decision() {
|
||||
let s = store();
|
||||
let pr = MemoryPr {
|
||||
id: "pr_2".into(),
|
||||
kind: MemoryPrKind::NewFact,
|
||||
status: MemoryPrStatus::Pending,
|
||||
title: "t".into(),
|
||||
diff: serde_json::json!({}),
|
||||
signals: vec![],
|
||||
subject_id: None,
|
||||
run_id: None,
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
decided_at: None,
|
||||
decision: None,
|
||||
};
|
||||
s.save_memory_pr(&pr).unwrap();
|
||||
assert!(s
|
||||
.decide_memory_pr("pr_2", MemoryPrAction::AskAgentWhy)
|
||||
.is_err());
|
||||
// Still pending.
|
||||
assert_eq!(s.count_pending_memory_prs().unwrap(), 1);
|
||||
}
|
||||
}
|
||||
356
crates/vestige-core/src/trace/mod.rs
Normal file
356
crates/vestige-core/src/trace/mod.rs
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
//! # Agent Black Box, Receipts & Memory PRs — the cognitive flight recorder
|
||||
//!
|
||||
//! This module holds the **pure** data model and classification logic for three
|
||||
//! tightly-related capabilities that together make Vestige *the black box,
|
||||
//! immune system, and cinematic debugger for agent memory*:
|
||||
//!
|
||||
//! 1. **Agent Black Box** — a replayable trace of everything an agent run did to
|
||||
//! memory: prompt → retrieved → suppressed → activated edges → tool calls →
|
||||
//! writes → contradictions → vetoes → dream consolidation → final answer.
|
||||
//! The event model is [`MemoryTraceEvent`].
|
||||
//!
|
||||
//! 2. **Memory Receipts** — every important retrieval returns a structured
|
||||
//! [`Receipt`]: what was retrieved, what was suppressed and why, the
|
||||
//! activation path that surfaced it, the trust floor, the decay risk, and any
|
||||
//! mutations. A receipt is the "nutrition label" for a piece of agent memory.
|
||||
//!
|
||||
//! 3. **Memory PRs** — changes to an agent's *brain* are reviewed like changes
|
||||
//! to code. Ordinary context auto-commits (and always leaves a receipt), but
|
||||
//! risky writes — contradictions against high-trust memory, supersede / forget
|
||||
//! / merge / protect, identity / preference / workflow / positioning facts,
|
||||
//! permission / auth / security / money / legal facts, dream consolidation
|
||||
//! proposals, decay-below-threshold resurrection, low-confidence batch
|
||||
//! imports, and weak-provenance connector writes — open a reviewable
|
||||
//! [`MemoryPr`]. The gating decision is [`classify_write`].
|
||||
//!
|
||||
//! ## Design north star (shared with [`crate::advanced::merge_supersede`])
|
||||
//!
|
||||
//! - **append-only** — trace events are never mutated, only appended, so a run
|
||||
//! replays exactly as the agent experienced it.
|
||||
//! - **self-explaining** — every gated write carries the [`RiskSignal`]s that
|
||||
//! explain *why* it needs review, in plain language.
|
||||
//! - **opt-in friction** — the default [`ReviewMode::RiskGated`] keeps ordinary
|
||||
//! memory frictionless and only opens a PR when the agent tries to rewrite its
|
||||
//! own brain. [`ReviewMode::Fast`] never gates; [`ReviewMode::Paranoid`] gates
|
||||
//! every write.
|
||||
//! - **DB-free** — this module is pure logic so it is unit-testable without a
|
||||
//! database. Persistence (the `agent_traces`, `memory_receipts`, and
|
||||
//! `memory_prs` tables) lives in [`crate::storage`].
|
||||
//!
|
||||
//! The killer line, made literal by [`classify_write`]:
|
||||
//!
|
||||
//! > Vestige auto-remembers ordinary context, but opens a Memory PR when the
|
||||
//! > agent tries to rewrite its own brain.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod receipt;
|
||||
mod review;
|
||||
|
||||
pub use receipt::{DecayRisk, Receipt, ReceiptMutation, SuppressedReceiptEntry};
|
||||
pub use review::{
|
||||
classify_write, MemoryPr, MemoryPrAction, MemoryPrKind, MemoryPrStatus, ReviewMode, RiskClass,
|
||||
RiskSignal, WriteContext, HIGH_TRUST_FLOOR, LOW_CONFIDENCE_FLOOR,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// TRACE EVENTS — the black-box flight recorder
|
||||
// ============================================================================
|
||||
|
||||
/// One append-only event in an agent run's black-box trace.
|
||||
///
|
||||
/// Mirrors the TypeScript `MemoryTraceEvent` union exactly (tagged on `type`,
|
||||
/// camelCase fields) so the dashboard, the `vestige://trace/{runId}` MCP
|
||||
/// resource, and the exported `.vestige-trace.json` all speak one schema.
|
||||
///
|
||||
/// ```ts
|
||||
/// type MemoryTraceEvent =
|
||||
/// | { type: "mcp.call"; runId; tool; argsHash; at }
|
||||
/// | { type: "memory.retrieve"; runId; ids; activation; at }
|
||||
/// | { type: "memory.suppress"; runId; id; reason }
|
||||
/// | { type: "memory.write"; runId; id; diff; source }
|
||||
/// | { type: "sanhedrin.veto"; runId; claim; evidenceIds; confidence }
|
||||
/// | { type: "dream.patch"; runId; proposalIds; at };
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum MemoryTraceEvent {
|
||||
/// An MCP tool was invoked. The args are stored as a hash (not the raw
|
||||
/// payload) so traces never leak prompt contents or secrets.
|
||||
#[serde(rename = "mcp.call")]
|
||||
McpCall {
|
||||
#[serde(rename = "runId")]
|
||||
run_id: String,
|
||||
tool: String,
|
||||
#[serde(rename = "argsHash")]
|
||||
args_hash: String,
|
||||
at: i64,
|
||||
},
|
||||
|
||||
/// Memories were retrieved, with per-id spreading-activation strength so the
|
||||
/// graph replay can pulse exactly the nodes the agent saw, at their weight.
|
||||
#[serde(rename = "memory.retrieve")]
|
||||
MemoryRetrieve {
|
||||
#[serde(rename = "runId")]
|
||||
run_id: String,
|
||||
ids: Vec<String>,
|
||||
activation: std::collections::BTreeMap<String, f64>,
|
||||
at: i64,
|
||||
},
|
||||
|
||||
/// A memory that *would* have surfaced was suppressed, with the reason —
|
||||
/// this is the "what the agent chose NOT to use" channel.
|
||||
#[serde(rename = "memory.suppress")]
|
||||
MemorySuppress {
|
||||
#[serde(rename = "runId")]
|
||||
run_id: String,
|
||||
id: String,
|
||||
reason: SuppressReason,
|
||||
#[serde(default)]
|
||||
at: i64,
|
||||
},
|
||||
|
||||
/// A memory was written / strengthened. `diff` is an opaque JSON description
|
||||
/// of the change; `source` records who caused it.
|
||||
#[serde(rename = "memory.write")]
|
||||
MemoryWrite {
|
||||
#[serde(rename = "runId")]
|
||||
run_id: String,
|
||||
id: String,
|
||||
diff: serde_json::Value,
|
||||
source: WriteSource,
|
||||
#[serde(default)]
|
||||
at: i64,
|
||||
},
|
||||
|
||||
/// A contradiction was detected between memories during a run — its own
|
||||
/// first-class event (not folded into `memory.suppress`), so the Black Box
|
||||
/// can show the exact contradiction decision the agent faced.
|
||||
#[serde(rename = "contradiction.detected")]
|
||||
ContradictionDetected {
|
||||
#[serde(rename = "runId")]
|
||||
run_id: String,
|
||||
/// The two (or more) memory ids in tension.
|
||||
ids: Vec<String>,
|
||||
/// The id the agent trusted (kept), if it resolved the tension.
|
||||
#[serde(rename = "winnerId", skip_serializing_if = "Option::is_none")]
|
||||
winner_id: Option<String>,
|
||||
/// Plain-language description of the contradiction.
|
||||
detail: String,
|
||||
#[serde(default)]
|
||||
at: i64,
|
||||
},
|
||||
|
||||
/// The Sanhedrin verifier vetoed a claim the agent was about to assert,
|
||||
/// citing the evidence it weighed and its confidence.
|
||||
#[serde(rename = "sanhedrin.veto")]
|
||||
SanhedrinVeto {
|
||||
#[serde(rename = "runId")]
|
||||
run_id: String,
|
||||
claim: String,
|
||||
#[serde(rename = "evidenceIds")]
|
||||
evidence_ids: Vec<String>,
|
||||
confidence: f64,
|
||||
#[serde(default)]
|
||||
at: i64,
|
||||
},
|
||||
|
||||
/// Dream consolidation proposed a patch to memory (merge / insight / prune).
|
||||
#[serde(rename = "dream.patch")]
|
||||
DreamPatch {
|
||||
#[serde(rename = "runId")]
|
||||
run_id: String,
|
||||
#[serde(rename = "proposalIds")]
|
||||
proposal_ids: Vec<String>,
|
||||
at: i64,
|
||||
},
|
||||
}
|
||||
|
||||
impl MemoryTraceEvent {
|
||||
/// The run this event belongs to.
|
||||
pub fn run_id(&self) -> &str {
|
||||
match self {
|
||||
MemoryTraceEvent::McpCall { run_id, .. }
|
||||
| MemoryTraceEvent::MemoryRetrieve { run_id, .. }
|
||||
| MemoryTraceEvent::MemorySuppress { run_id, .. }
|
||||
| MemoryTraceEvent::MemoryWrite { run_id, .. }
|
||||
| MemoryTraceEvent::ContradictionDetected { run_id, .. }
|
||||
| MemoryTraceEvent::SanhedrinVeto { run_id, .. }
|
||||
| MemoryTraceEvent::DreamPatch { run_id, .. } => run_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// The wall-clock millisecond timestamp the event was recorded at.
|
||||
pub fn at(&self) -> i64 {
|
||||
match self {
|
||||
MemoryTraceEvent::McpCall { at, .. }
|
||||
| MemoryTraceEvent::MemoryRetrieve { at, .. }
|
||||
| MemoryTraceEvent::MemorySuppress { at, .. }
|
||||
| MemoryTraceEvent::MemoryWrite { at, .. }
|
||||
| MemoryTraceEvent::ContradictionDetected { at, .. }
|
||||
| MemoryTraceEvent::SanhedrinVeto { at, .. }
|
||||
| MemoryTraceEvent::DreamPatch { at, .. } => *at,
|
||||
}
|
||||
}
|
||||
|
||||
/// Short stable kind label used for filtering / the `event_type` column.
|
||||
pub fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
MemoryTraceEvent::McpCall { .. } => "mcp.call",
|
||||
MemoryTraceEvent::MemoryRetrieve { .. } => "memory.retrieve",
|
||||
MemoryTraceEvent::MemorySuppress { .. } => "memory.suppress",
|
||||
MemoryTraceEvent::MemoryWrite { .. } => "memory.write",
|
||||
MemoryTraceEvent::ContradictionDetected { .. } => "contradiction.detected",
|
||||
MemoryTraceEvent::SanhedrinVeto { .. } => "sanhedrin.veto",
|
||||
MemoryTraceEvent::DreamPatch { .. } => "dream.patch",
|
||||
}
|
||||
}
|
||||
|
||||
/// Stamp `at` on events that left it defaulted (the recorder fills this so
|
||||
/// callers don't have to thread a clock through every emit site).
|
||||
pub fn with_at(mut self, now_ms: i64) -> Self {
|
||||
match &mut self {
|
||||
MemoryTraceEvent::McpCall { at, .. }
|
||||
| MemoryTraceEvent::MemoryRetrieve { at, .. }
|
||||
| MemoryTraceEvent::MemorySuppress { at, .. }
|
||||
| MemoryTraceEvent::MemoryWrite { at, .. }
|
||||
| MemoryTraceEvent::ContradictionDetected { at, .. }
|
||||
| MemoryTraceEvent::SanhedrinVeto { at, .. }
|
||||
| MemoryTraceEvent::DreamPatch { at, .. } => {
|
||||
if *at == 0 {
|
||||
*at = now_ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a memory was suppressed during a run. Mirrors the TS union member
|
||||
/// `"low_trust" | "decayed" | "contradicted" | "privacy"`, plus `competition`
|
||||
/// for the existing spreading-activation competition suppression.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SuppressReason {
|
||||
/// Below the trust floor for this retrieval.
|
||||
LowTrust,
|
||||
/// FSRS retrievability decayed below the usable threshold.
|
||||
Decayed,
|
||||
/// Contradicted by a higher-trust memory.
|
||||
Contradicted,
|
||||
/// Withheld for privacy / sensitivity reasons.
|
||||
Privacy,
|
||||
/// Lost spreading-activation competition to a stronger memory.
|
||||
Competition,
|
||||
}
|
||||
|
||||
impl SuppressReason {
|
||||
/// Stable string label.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
SuppressReason::LowTrust => "low_trust",
|
||||
SuppressReason::Decayed => "decayed",
|
||||
SuppressReason::Contradicted => "contradicted",
|
||||
SuppressReason::Privacy => "privacy",
|
||||
SuppressReason::Competition => "competition",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Who caused a `memory.write`. Mirrors the TS `"agent" | "user" | "dream"`,
|
||||
/// plus `connector` for external-source sync writes.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WriteSource {
|
||||
/// The agent wrote it autonomously.
|
||||
Agent,
|
||||
/// The user explicitly asked for it.
|
||||
User,
|
||||
/// Produced by dream consolidation.
|
||||
Dream,
|
||||
/// Ingested by an external connector (GitHub, Redmine, …).
|
||||
Connector,
|
||||
}
|
||||
|
||||
impl WriteSource {
|
||||
/// Stable string label.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
WriteSource::Agent => "agent",
|
||||
WriteSource::User => "user",
|
||||
WriteSource::Dream => "dream",
|
||||
WriteSource::Connector => "connector",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn trace_event_roundtrips_with_ts_shape() {
|
||||
let ev = MemoryTraceEvent::McpCall {
|
||||
run_id: "run_123".into(),
|
||||
tool: "deep_reference".into(),
|
||||
args_hash: "abc".into(),
|
||||
at: 42,
|
||||
};
|
||||
let json = serde_json::to_value(&ev).unwrap();
|
||||
// Tagged on `type`, camelCase runId/argsHash — exactly the TS contract.
|
||||
assert_eq!(json["type"], "mcp.call");
|
||||
assert_eq!(json["runId"], "run_123");
|
||||
assert_eq!(json["argsHash"], "abc");
|
||||
assert_eq!(json["at"], 42);
|
||||
|
||||
let back: MemoryTraceEvent = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(back, ev);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retrieve_event_carries_activation_map() {
|
||||
let mut activation = std::collections::BTreeMap::new();
|
||||
activation.insert("mem_1".to_string(), 0.91);
|
||||
activation.insert("mem_7".to_string(), 0.42);
|
||||
let ev = MemoryTraceEvent::MemoryRetrieve {
|
||||
run_id: "r".into(),
|
||||
ids: vec!["mem_1".into(), "mem_7".into()],
|
||||
activation,
|
||||
at: 1,
|
||||
};
|
||||
let json = serde_json::to_value(&ev).unwrap();
|
||||
assert_eq!(json["type"], "memory.retrieve");
|
||||
assert_eq!(json["activation"]["mem_1"], 0.91);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_at_fills_only_when_unset() {
|
||||
let ev = MemoryTraceEvent::MemorySuppress {
|
||||
run_id: "r".into(),
|
||||
id: "m".into(),
|
||||
reason: SuppressReason::Contradicted,
|
||||
at: 0,
|
||||
}
|
||||
.with_at(999);
|
||||
assert_eq!(ev.at(), 999);
|
||||
|
||||
let ev2 = MemoryTraceEvent::DreamPatch {
|
||||
run_id: "r".into(),
|
||||
proposal_ids: vec!["p".into()],
|
||||
at: 7,
|
||||
}
|
||||
.with_at(999);
|
||||
assert_eq!(ev2.at(), 7, "explicit timestamp must not be overwritten");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suppress_reason_labels_match_ts() {
|
||||
assert_eq!(SuppressReason::LowTrust.as_str(), "low_trust");
|
||||
assert_eq!(SuppressReason::Contradicted.as_str(), "contradicted");
|
||||
// Serde uses the same snake_case form on the wire.
|
||||
assert_eq!(
|
||||
serde_json::to_value(SuppressReason::Privacy).unwrap(),
|
||||
serde_json::json!("privacy")
|
||||
);
|
||||
}
|
||||
}
|
||||
302
crates/vestige-core/src/trace/receipt.rs
Normal file
302
crates/vestige-core/src/trace/receipt.rs
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
//! # Memory Receipts
|
||||
//!
|
||||
//! Every important retrieval returns a [`Receipt`] — a structured record of what
|
||||
//! the agent's memory actually did to answer a query. It is built entirely from
|
||||
//! data the retrieval pipeline *already computes* (scored memories, suppression
|
||||
//! decisions, spreading-activation path, FSRS trust), so attaching one is nearly
|
||||
//! free and never changes the answer.
|
||||
//!
|
||||
//! The canonical shape (matching the product spec):
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "receipt_id": "r_2026_06_22_abc",
|
||||
//! "retrieved": ["mem_1", "mem_7", "mem_9"],
|
||||
//! "suppressed": [{"id": "mem_4", "reason": "contradicted"}],
|
||||
//! "activation_path": ["project_goal -> design_decision -> current_file"],
|
||||
//! "trust_floor": 0.62,
|
||||
//! "decay_risk": "medium",
|
||||
//! "mutations": []
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::SuppressReason;
|
||||
|
||||
/// A structured receipt attached to a retrieval's output.
|
||||
///
|
||||
/// Field names are snake_case to match the published product spec and the
|
||||
/// dashboard receipt card exactly.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Receipt {
|
||||
/// Stable, human-legible id: `r_<yyyy>_<mm>_<dd>_<short>`.
|
||||
pub receipt_id: String,
|
||||
|
||||
/// Ids of the memories that actually informed the answer, best-first.
|
||||
pub retrieved: Vec<String>,
|
||||
|
||||
/// Memories that were withheld, each with the reason — the "what the agent
|
||||
/// chose NOT to use" channel that makes retrieval auditable.
|
||||
pub suppressed: Vec<SuppressedReceiptEntry>,
|
||||
|
||||
/// Human-readable spreading-activation path(s) that surfaced the result,
|
||||
/// e.g. `"project_goal -> design_decision -> current_file"`.
|
||||
pub activation_path: Vec<String>,
|
||||
|
||||
/// The minimum trust score among the retrieved memories — the weakest link
|
||||
/// the answer rests on.
|
||||
pub trust_floor: f64,
|
||||
|
||||
/// Coarse decay risk for the retrieved set (how stale the evidence is).
|
||||
pub decay_risk: DecayRisk,
|
||||
|
||||
/// Any memory mutations this retrieval triggered (testing-effect
|
||||
/// strengthening, reconsolidation, supersession). Empty for a pure read.
|
||||
pub mutations: Vec<ReceiptMutation>,
|
||||
}
|
||||
|
||||
impl Receipt {
|
||||
/// Build a receipt from already-computed retrieval signals.
|
||||
///
|
||||
/// `receipt_id` is `r_<date>_<discriminator8>_<unique6>` — human-legible
|
||||
/// and dated, with a short random suffix so that **multiple retrievals in
|
||||
/// the same run never collide** (B3). The discriminator (usually the runId)
|
||||
/// keeps receipts from one run visually grouped; the suffix guarantees
|
||||
/// uniqueness so `INSERT OR REPLACE` can't overwrite an earlier receipt.
|
||||
/// `trust_scores` is the per-id FSRS retrievability/trust the pipeline
|
||||
/// already produced.
|
||||
pub fn build(
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
discriminator: &str,
|
||||
retrieved: Vec<String>,
|
||||
suppressed: Vec<SuppressedReceiptEntry>,
|
||||
activation_path: Vec<String>,
|
||||
trust_scores: &[f64],
|
||||
mutations: Vec<ReceiptMutation>,
|
||||
) -> Self {
|
||||
Self::build_with_unique(
|
||||
now,
|
||||
discriminator,
|
||||
&uuid::Uuid::new_v4().simple().to_string()[..6],
|
||||
retrieved,
|
||||
suppressed,
|
||||
activation_path,
|
||||
trust_scores,
|
||||
mutations,
|
||||
)
|
||||
}
|
||||
|
||||
/// Like [`Receipt::build`] but with a caller-supplied uniqueness token,
|
||||
/// so the id is fully deterministic for tests. Production uses
|
||||
/// [`Receipt::build`] which mints a random token.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_with_unique(
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
discriminator: &str,
|
||||
unique: &str,
|
||||
retrieved: Vec<String>,
|
||||
suppressed: Vec<SuppressedReceiptEntry>,
|
||||
activation_path: Vec<String>,
|
||||
trust_scores: &[f64],
|
||||
mutations: Vec<ReceiptMutation>,
|
||||
) -> Self {
|
||||
let trust_floor = trust_scores
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
let trust_floor = if trust_floor.is_finite() {
|
||||
(trust_floor * 100.0).round() / 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let decay_risk = DecayRisk::from_trust_floor(trust_floor);
|
||||
|
||||
let short: String = discriminator
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.take(8)
|
||||
.collect();
|
||||
let unique_clean: String = unique
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.take(6)
|
||||
.collect();
|
||||
let receipt_id = format!(
|
||||
"r_{}_{}_{}",
|
||||
now.format("%Y_%m_%d"),
|
||||
short,
|
||||
unique_clean
|
||||
);
|
||||
|
||||
Self {
|
||||
receipt_id,
|
||||
retrieved,
|
||||
suppressed,
|
||||
activation_path,
|
||||
trust_floor,
|
||||
decay_risk,
|
||||
mutations,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One suppressed-memory entry in a [`Receipt`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SuppressedReceiptEntry {
|
||||
/// The id of the suppressed memory.
|
||||
pub id: String,
|
||||
/// Why it was withheld.
|
||||
pub reason: SuppressReason,
|
||||
}
|
||||
|
||||
impl SuppressedReceiptEntry {
|
||||
/// Convenience constructor.
|
||||
pub fn new(id: impl Into<String>, reason: SuppressReason) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Coarse staleness signal for a retrieved set, derived from the trust floor.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DecayRisk {
|
||||
/// Trust floor is healthy; the evidence is fresh.
|
||||
Low,
|
||||
/// Some of the evidence is weakening.
|
||||
Medium,
|
||||
/// The answer rests on memory that is decaying out.
|
||||
High,
|
||||
}
|
||||
|
||||
impl DecayRisk {
|
||||
/// Map the weakest retrieved-trust score to a decay-risk band.
|
||||
///
|
||||
/// Thresholds align with the FSRS "due for review" intuition: above 0.7 the
|
||||
/// memory is comfortably retrievable, 0.4–0.7 is getting weak, below 0.4 is
|
||||
/// at risk of being forgotten.
|
||||
pub fn from_trust_floor(trust_floor: f64) -> Self {
|
||||
if trust_floor >= 0.7 {
|
||||
DecayRisk::Low
|
||||
} else if trust_floor >= 0.4 {
|
||||
DecayRisk::Medium
|
||||
} else {
|
||||
DecayRisk::High
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable string label.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
DecayRisk::Low => "low",
|
||||
DecayRisk::Medium => "medium",
|
||||
DecayRisk::High => "high",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A memory mutation that a retrieval triggered, recorded on the receipt so the
|
||||
/// side effects of "just reading" are never invisible.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ReceiptMutation {
|
||||
/// The mutated memory id.
|
||||
pub id: String,
|
||||
/// What changed: `"strengthened"`, `"reconsolidated"`, `"superseded"`, …
|
||||
pub kind: String,
|
||||
/// Optional human note about the change.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
|
||||
fn fixed_now() -> chrono::DateTime<chrono::Utc> {
|
||||
chrono::Utc.with_ymd_and_hms(2026, 6, 22, 15, 0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipt_id_is_human_legible_and_dated() {
|
||||
let r = Receipt::build_with_unique(
|
||||
fixed_now(),
|
||||
"abc123!!",
|
||||
"u1u2u3",
|
||||
vec!["mem_1".into()],
|
||||
vec![],
|
||||
vec![],
|
||||
&[0.9],
|
||||
vec![],
|
||||
);
|
||||
assert_eq!(r.receipt_id, "r_2026_06_22_abc123_u1u2u3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipt_ids_unique_within_a_run_b3() {
|
||||
// B3: two retrievals in the SAME run (same date + discriminator) must
|
||||
// get DISTINCT ids so INSERT OR REPLACE can't overwrite the first.
|
||||
let a = Receipt::build(fixed_now(), "run_x", vec![], vec![], vec![], &[], vec![]);
|
||||
let b = Receipt::build(fixed_now(), "run_x", vec![], vec![], vec![], &[], vec![]);
|
||||
assert_ne!(
|
||||
a.receipt_id, b.receipt_id,
|
||||
"same-run receipts must not collide"
|
||||
);
|
||||
assert!(a.receipt_id.starts_with("r_2026_06_22_runx_"));
|
||||
assert!(b.receipt_id.starts_with("r_2026_06_22_runx_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_floor_is_the_weakest_link() {
|
||||
let r = Receipt::build(
|
||||
fixed_now(),
|
||||
"x",
|
||||
vec!["a".into(), "b".into(), "c".into()],
|
||||
vec![],
|
||||
vec![],
|
||||
&[0.91, 0.62, 0.78],
|
||||
vec![],
|
||||
);
|
||||
assert_eq!(r.trust_floor, 0.62);
|
||||
assert_eq!(r.decay_risk, DecayRisk::Medium);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_trust_scores_floor_to_zero_high_risk() {
|
||||
let r = Receipt::build(fixed_now(), "x", vec![], vec![], vec![], &[], vec![]);
|
||||
assert_eq!(r.trust_floor, 0.0);
|
||||
assert_eq!(r.decay_risk, DecayRisk::High);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decay_bands() {
|
||||
assert_eq!(DecayRisk::from_trust_floor(0.95), DecayRisk::Low);
|
||||
assert_eq!(DecayRisk::from_trust_floor(0.55), DecayRisk::Medium);
|
||||
assert_eq!(DecayRisk::from_trust_floor(0.20), DecayRisk::High);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_published_spec_shape() {
|
||||
let r = Receipt {
|
||||
receipt_id: "r_2026_06_22_abc".into(),
|
||||
retrieved: vec!["mem_1".into(), "mem_7".into(), "mem_9".into()],
|
||||
suppressed: vec![SuppressedReceiptEntry::new(
|
||||
"mem_4",
|
||||
SuppressReason::Contradicted,
|
||||
)],
|
||||
activation_path: vec!["project_goal -> design_decision -> current_file".into()],
|
||||
trust_floor: 0.62,
|
||||
decay_risk: DecayRisk::Medium,
|
||||
mutations: vec![],
|
||||
};
|
||||
let json = serde_json::to_value(&r).unwrap();
|
||||
assert_eq!(json["receipt_id"], "r_2026_06_22_abc");
|
||||
assert_eq!(json["suppressed"][0]["reason"], "contradicted");
|
||||
assert_eq!(json["decay_risk"], "medium");
|
||||
assert_eq!(json["trust_floor"], 0.62);
|
||||
assert!(json["mutations"].as_array().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
802
crates/vestige-core/src/trace/review.rs
Normal file
802
crates/vestige-core/src/trace/review.rs
Normal file
|
|
@ -0,0 +1,802 @@
|
|||
//! # Memory PRs — review changes to an agent's brain like code
|
||||
//!
|
||||
//! Ordinary context auto-commits and always leaves a receipt. But a *risky*
|
||||
//! write — one where the agent is rewriting its own brain — opens a reviewable
|
||||
//! [`MemoryPr`] instead. [`classify_write`] is the immune system: given a
|
||||
//! [`WriteContext`] and a [`ReviewMode`], it returns the [`RiskClass`] and the
|
||||
//! [`RiskSignal`]s that explain, in plain language, *why* a write needs review.
|
||||
//!
|
||||
//! ## The three modes (one-click in the dashboard)
|
||||
//!
|
||||
//! | Mode | Behaviour |
|
||||
//! |------|-----------|
|
||||
//! | [`ReviewMode::Fast`] | Never gate. Every write auto-commits. (Demos, trusted solo flows.) |
|
||||
//! | [`ReviewMode::RiskGated`] | **Default.** Auto-commit ordinary writes; open a PR for risky ones. |
|
||||
//! | [`ReviewMode::Paranoid`] | Gate *every* write. Nothing enters the brain without approval. |
|
||||
//!
|
||||
//! ## What counts as "risky" (the taxonomy)
|
||||
//!
|
||||
//! A write is risky when any of these hold:
|
||||
//! - it **contradicts a high-trust memory**,
|
||||
//! - it **supersedes / forgets / merges / protects** existing memory,
|
||||
//! - it touches **identity, user preference, workflow, or project positioning**,
|
||||
//! - it asserts a **permission / auth / security / money / bounty / legal-ish** fact,
|
||||
//! - it is a **dream consolidation** proposal,
|
||||
//! - it **resurrects a decayed** memory (below the retention threshold),
|
||||
//! - it is part of a **low-confidence batch import**,
|
||||
//! - it is an **external connector write without strong provenance**.
|
||||
//!
|
||||
//! Each rule maps to a [`RiskSignal`] so the resulting Memory PR is fully
|
||||
//! self-explaining.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::WriteSource;
|
||||
|
||||
/// A memory is "high trust" at or above this FSRS retrievability/trust score.
|
||||
/// Contradicting something this trusted is always worth a review.
|
||||
pub const HIGH_TRUST_FLOOR: f64 = 0.7;
|
||||
|
||||
/// Writes below this confidence are treated as low-confidence (e.g. a bulk
|
||||
/// import where the model wasn't sure).
|
||||
pub const LOW_CONFIDENCE_FLOOR: f64 = 0.5;
|
||||
|
||||
// ============================================================================
|
||||
// REVIEW MODE
|
||||
// ============================================================================
|
||||
|
||||
/// How aggressively the agent's brain gates incoming writes.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReviewMode {
|
||||
/// Never gate — every write auto-commits.
|
||||
Fast,
|
||||
/// Default: auto-commit ordinary writes, open a PR for risky ones.
|
||||
#[default]
|
||||
RiskGated,
|
||||
/// Gate every write — nothing enters the brain without approval.
|
||||
Paranoid,
|
||||
}
|
||||
|
||||
impl ReviewMode {
|
||||
/// Stable string label, also the wire form.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ReviewMode::Fast => "fast",
|
||||
ReviewMode::RiskGated => "risk_gated",
|
||||
ReviewMode::Paranoid => "paranoid",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse from a label (case-insensitive, tolerant of `-`/`_`). Falls back to
|
||||
/// the default [`ReviewMode::RiskGated`] on anything unrecognised.
|
||||
pub fn from_label(s: &str) -> Self {
|
||||
match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
|
||||
"fast" => ReviewMode::Fast,
|
||||
"paranoid" => ReviewMode::Paranoid,
|
||||
_ => ReviewMode::RiskGated,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RISK CLASSIFICATION
|
||||
// ============================================================================
|
||||
|
||||
/// The outcome of [`classify_write`]: does this write auto-commit or open a PR?
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RiskClass {
|
||||
/// Ordinary context — auto-commit (a receipt is still generated).
|
||||
AutoCommit,
|
||||
/// Risky — open a [`MemoryPr`] for review.
|
||||
Review,
|
||||
}
|
||||
|
||||
impl RiskClass {
|
||||
/// Stable string label.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
RiskClass::AutoCommit => "auto_commit",
|
||||
RiskClass::Review => "review",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this write should be held for review.
|
||||
pub fn needs_review(&self) -> bool {
|
||||
matches!(self, RiskClass::Review)
|
||||
}
|
||||
}
|
||||
|
||||
/// A single, self-explaining reason a write was flagged for review. The
|
||||
/// `code` is stable for filtering/telemetry; the `detail` is human prose for
|
||||
/// the PR card.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RiskSignal {
|
||||
/// Stable machine code, e.g. `"contradicts_high_trust"`.
|
||||
pub code: String,
|
||||
/// Plain-language explanation shown on the Memory PR.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl RiskSignal {
|
||||
fn new(code: &str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: code.into(),
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything [`classify_write`] needs to decide whether a write is risky.
|
||||
///
|
||||
/// All fields default to the "ordinary, safe" interpretation so callers only
|
||||
/// set the signals that actually apply to their write.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WriteContext {
|
||||
/// Who is performing the write.
|
||||
pub source: Option<WriteSource>,
|
||||
/// The node type being written, e.g. `"fact"`, `"preference"`, `"identity"`.
|
||||
pub node_type: String,
|
||||
/// The content (or a representative slice) — scanned for sensitive topics.
|
||||
pub content: String,
|
||||
/// Tags attached to the write — also scanned for sensitive topics.
|
||||
pub tags: Vec<String>,
|
||||
/// The write contradicts an existing memory whose trust is this high.
|
||||
/// `None` if there is no contradiction.
|
||||
pub contradicts_trust: Option<f64>,
|
||||
/// This write supersedes / replaces an existing memory.
|
||||
pub supersedes: bool,
|
||||
/// This write forgets / suppresses an existing memory.
|
||||
pub forgets: bool,
|
||||
/// This write merges existing memories.
|
||||
pub merges: bool,
|
||||
/// This write protects / pins a memory.
|
||||
pub protects: bool,
|
||||
/// This write resurrects a memory that had decayed below retention.
|
||||
pub resurrects_decayed: bool,
|
||||
/// Confidence of the write (0..1). `None` means "not a batch / unknown".
|
||||
pub confidence: Option<f64>,
|
||||
/// This write is one of many in a bulk import.
|
||||
pub batch_import: bool,
|
||||
/// For connector writes: whether the source envelope carries strong
|
||||
/// provenance (a verified `source_system` + `source_id` + URL).
|
||||
pub strong_provenance: bool,
|
||||
}
|
||||
|
||||
/// Sensitive topic substrings. A write whose content/tags/type mention any of
|
||||
/// these is treated as touching identity / preference / security / money /
|
||||
/// legal / workflow / positioning and is routed to review.
|
||||
const SENSITIVE_TOPICS: &[(&str, &str)] = &[
|
||||
// identity & preference
|
||||
("identity", "identity fact"),
|
||||
("preference", "user preference"),
|
||||
("workflow", "workflow rule"),
|
||||
("positioning", "project positioning"),
|
||||
("persona", "agent persona"),
|
||||
// permission / auth / security
|
||||
("permission", "tool permission"),
|
||||
("auth", "authentication / authorization"),
|
||||
("token", "credential / token"),
|
||||
("secret", "secret material"),
|
||||
("password", "credential"),
|
||||
("api key", "credential / API key"),
|
||||
("security", "security-relevant fact"),
|
||||
("vuln", "security vulnerability"),
|
||||
("vulnerability", "security vulnerability"),
|
||||
("credential", "credential material"),
|
||||
("credentials", "credential material"),
|
||||
("api key", "credential / API key"),
|
||||
("apikey", "credential / API key"),
|
||||
// money / bounty / legal
|
||||
("money", "financial fact"),
|
||||
("payment", "financial fact"),
|
||||
("invoice", "financial fact"),
|
||||
("bounty", "bounty / payout"),
|
||||
("salary", "financial fact"),
|
||||
("license", "legal / license fact"),
|
||||
("legal", "legal-relevant fact"),
|
||||
("contract", "legal / contract fact"),
|
||||
];
|
||||
|
||||
/// Node types that are intrinsically sensitive regardless of content.
|
||||
const SENSITIVE_NODE_TYPES: &[&str] = &[
|
||||
"identity",
|
||||
"preference",
|
||||
"user_preference",
|
||||
"credential",
|
||||
"permission",
|
||||
"security",
|
||||
"constitution",
|
||||
];
|
||||
|
||||
/// Classify a write into auto-commit vs. review, with the signals explaining the
|
||||
/// decision.
|
||||
///
|
||||
/// This is the immune system. It is pure and deterministic, so the dashboard's
|
||||
/// "explain this PR" view and the agent's `Ask Agent Why` action see exactly the
|
||||
/// same reasoning the gate used.
|
||||
pub fn classify_write(ctx: &WriteContext, mode: ReviewMode) -> (RiskClass, Vec<RiskSignal>) {
|
||||
// Mode shortcuts.
|
||||
match mode {
|
||||
// Fast never gates — but we still collect signals so the receipt/PR
|
||||
// record can note what *would* have been flagged.
|
||||
ReviewMode::Fast => return (RiskClass::AutoCommit, Vec::new()),
|
||||
ReviewMode::Paranoid => {
|
||||
let mut signals = collect_signals(ctx);
|
||||
if signals.is_empty() {
|
||||
signals.push(RiskSignal::new(
|
||||
"paranoid_mode",
|
||||
"Paranoid mode: every write is reviewed before entering memory.",
|
||||
));
|
||||
}
|
||||
return (RiskClass::Review, signals);
|
||||
}
|
||||
ReviewMode::RiskGated => {}
|
||||
}
|
||||
|
||||
let signals = collect_signals(ctx);
|
||||
if signals.is_empty() {
|
||||
(RiskClass::AutoCommit, signals)
|
||||
} else {
|
||||
(RiskClass::Review, signals)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gather every risk signal that applies to a write, independent of mode.
|
||||
fn collect_signals(ctx: &WriteContext) -> Vec<RiskSignal> {
|
||||
let mut signals = Vec::new();
|
||||
|
||||
// 1. Contradiction against a high-trust memory.
|
||||
if let Some(trust) = ctx.contradicts_trust
|
||||
&& trust >= HIGH_TRUST_FLOOR
|
||||
{
|
||||
signals.push(RiskSignal::new(
|
||||
"contradicts_high_trust",
|
||||
format!(
|
||||
"Contradicts an existing high-trust memory (trust {:.2} ≥ {:.2}).",
|
||||
trust, HIGH_TRUST_FLOOR
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
// 2. Structural rewrites of existing memory.
|
||||
if ctx.supersedes {
|
||||
signals.push(RiskSignal::new(
|
||||
"supersedes_memory",
|
||||
"Supersedes / replaces an existing memory.",
|
||||
));
|
||||
}
|
||||
if ctx.forgets {
|
||||
signals.push(RiskSignal::new(
|
||||
"forgets_memory",
|
||||
"Forgets / suppresses an existing memory.",
|
||||
));
|
||||
}
|
||||
if ctx.merges {
|
||||
signals.push(RiskSignal::new(
|
||||
"merges_memory",
|
||||
"Merges existing memories into one.",
|
||||
));
|
||||
}
|
||||
if ctx.protects {
|
||||
signals.push(RiskSignal::new(
|
||||
"protects_memory",
|
||||
"Protects / pins a memory against decay and forgetting.",
|
||||
));
|
||||
}
|
||||
|
||||
// 3. Sensitive node types & topics (identity / preference / workflow /
|
||||
// positioning / permission / auth / security / money / legal).
|
||||
let node_type_lc = ctx.node_type.to_ascii_lowercase();
|
||||
if SENSITIVE_NODE_TYPES.contains(&node_type_lc.as_str()) {
|
||||
signals.push(RiskSignal::new(
|
||||
"sensitive_node_type",
|
||||
format!("Writes a sensitive node type: `{}`.", node_type_lc),
|
||||
));
|
||||
}
|
||||
if let Some(topic) = first_sensitive_topic(&ctx.content, &ctx.tags) {
|
||||
signals.push(RiskSignal::new(
|
||||
"sensitive_topic",
|
||||
format!("Touches a sensitive topic: {topic}."),
|
||||
));
|
||||
}
|
||||
|
||||
// 4. Dream consolidation proposals.
|
||||
if matches!(ctx.source, Some(WriteSource::Dream)) {
|
||||
signals.push(RiskSignal::new(
|
||||
"dream_consolidation",
|
||||
"Proposed by dream consolidation — a machine-generated change to memory.",
|
||||
));
|
||||
}
|
||||
|
||||
// 5. Decay-below-threshold resurrection.
|
||||
if ctx.resurrects_decayed {
|
||||
signals.push(RiskSignal::new(
|
||||
"resurrects_decayed",
|
||||
"Resurrects a memory that had decayed below the retention threshold.",
|
||||
));
|
||||
}
|
||||
|
||||
// 6. Low-confidence batch imports.
|
||||
if ctx.batch_import {
|
||||
if let Some(conf) = ctx.confidence {
|
||||
if conf < LOW_CONFIDENCE_FLOOR {
|
||||
signals.push(RiskSignal::new(
|
||||
"low_confidence_batch",
|
||||
format!(
|
||||
"Low-confidence batch import (confidence {:.2} < {:.2}).",
|
||||
conf, LOW_CONFIDENCE_FLOOR
|
||||
),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
signals.push(RiskSignal::new(
|
||||
"unscored_batch",
|
||||
"Batch import with no confidence score.",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 7. External connector writes without strong provenance.
|
||||
if matches!(ctx.source, Some(WriteSource::Connector)) && !ctx.strong_provenance {
|
||||
signals.push(RiskSignal::new(
|
||||
"weak_provenance_connector",
|
||||
"External connector write without strong provenance (unverified source envelope).",
|
||||
));
|
||||
}
|
||||
|
||||
signals
|
||||
}
|
||||
|
||||
/// Return the human label of the first sensitive topic found in content/tags.
|
||||
///
|
||||
/// B6: matches on WORD BOUNDARIES, not substrings — so "tokenizer" no longer
|
||||
/// trips "token", "author" no longer trips "auth", "secretary" no longer trips
|
||||
/// "secret". Multi-word needles (e.g. "api key") match a consecutive run of
|
||||
/// words. The text is lowercased and split on any non-alphanumeric char.
|
||||
fn first_sensitive_topic(content: &str, tags: &[String]) -> Option<&'static str> {
|
||||
// Tokenize content + tags into lowercased alphanumeric words.
|
||||
let mut words: Vec<String> = Vec::new();
|
||||
let mut push_words = |s: &str| {
|
||||
for w in s
|
||||
.to_ascii_lowercase()
|
||||
.split(|c: char| !c.is_ascii_alphanumeric())
|
||||
{
|
||||
if !w.is_empty() {
|
||||
words.push(w.to_string());
|
||||
}
|
||||
}
|
||||
};
|
||||
push_words(content);
|
||||
for t in tags {
|
||||
push_words(t);
|
||||
}
|
||||
|
||||
SENSITIVE_TOPICS
|
||||
.iter()
|
||||
.find(|(needle, _)| matches_word_sequence(&words, needle))
|
||||
.map(|(_, label)| *label)
|
||||
}
|
||||
|
||||
/// Whether `needle` (one or more space-separated words) appears as a consecutive
|
||||
/// whole-word run in `words`.
|
||||
fn matches_word_sequence(words: &[String], needle: &str) -> bool {
|
||||
let needle_words: Vec<&str> = needle.split_whitespace().collect();
|
||||
if needle_words.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if needle_words.len() == 1 {
|
||||
return words.iter().any(|w| w == needle_words[0]);
|
||||
}
|
||||
words
|
||||
.windows(needle_words.len())
|
||||
.any(|win| win.iter().zip(&needle_words).all(|(w, n)| w == n))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MEMORY PR DATA MODEL
|
||||
// ============================================================================
|
||||
|
||||
/// What kind of change a Memory PR represents.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MemoryPrKind {
|
||||
/// A brand-new fact entering the brain.
|
||||
NewFact,
|
||||
/// An existing fact being strengthened / reinforced.
|
||||
StrengthenedFact,
|
||||
/// A contradiction was detected against existing memory.
|
||||
ContradictionDetected,
|
||||
/// A memory being superseded by a newer one.
|
||||
MemorySuperseded,
|
||||
/// A new edge added to the knowledge graph.
|
||||
EdgeAdded,
|
||||
/// A node decayed below the retention threshold.
|
||||
NodeDecayed,
|
||||
/// Dream consolidation proposed a merge / insight.
|
||||
DreamConsolidation,
|
||||
}
|
||||
|
||||
impl MemoryPrKind {
|
||||
/// Stable string label.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
MemoryPrKind::NewFact => "new_fact",
|
||||
MemoryPrKind::StrengthenedFact => "strengthened_fact",
|
||||
MemoryPrKind::ContradictionDetected => "contradiction_detected",
|
||||
MemoryPrKind::MemorySuperseded => "memory_superseded",
|
||||
MemoryPrKind::EdgeAdded => "edge_added",
|
||||
MemoryPrKind::NodeDecayed => "node_decayed",
|
||||
MemoryPrKind::DreamConsolidation => "dream_consolidation",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse from a label; `None` if unrecognised.
|
||||
pub fn from_label(s: &str) -> Option<Self> {
|
||||
Some(match s {
|
||||
"new_fact" => MemoryPrKind::NewFact,
|
||||
"strengthened_fact" => MemoryPrKind::StrengthenedFact,
|
||||
"contradiction_detected" => MemoryPrKind::ContradictionDetected,
|
||||
"memory_superseded" => MemoryPrKind::MemorySuperseded,
|
||||
"edge_added" => MemoryPrKind::EdgeAdded,
|
||||
"node_decayed" => MemoryPrKind::NodeDecayed,
|
||||
"dream_consolidation" => MemoryPrKind::DreamConsolidation,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The review status of a Memory PR.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MemoryPrStatus {
|
||||
/// Awaiting a decision.
|
||||
#[default]
|
||||
Pending,
|
||||
/// Promoted into long-term memory as-is.
|
||||
Promoted,
|
||||
/// Merged into an existing memory.
|
||||
Merged,
|
||||
/// Superseded an existing memory.
|
||||
Superseded,
|
||||
/// Quarantined — held in the firewall, not used for retrieval.
|
||||
Quarantined,
|
||||
/// Forgotten — rejected and suppressed.
|
||||
Forgotten,
|
||||
}
|
||||
|
||||
impl MemoryPrStatus {
|
||||
/// Stable string label.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
MemoryPrStatus::Pending => "pending",
|
||||
MemoryPrStatus::Promoted => "promoted",
|
||||
MemoryPrStatus::Merged => "merged",
|
||||
MemoryPrStatus::Superseded => "superseded",
|
||||
MemoryPrStatus::Quarantined => "quarantined",
|
||||
MemoryPrStatus::Forgotten => "forgotten",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The actions a reviewer can take on a Memory PR (the buttons in the diff UI).
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MemoryPrAction {
|
||||
/// Accept the change as-is.
|
||||
Promote,
|
||||
/// Fold it into an existing memory.
|
||||
Merge,
|
||||
/// Use it to supersede an existing memory.
|
||||
Supersede,
|
||||
/// Hold it in the firewall.
|
||||
Quarantine,
|
||||
/// Reject and suppress it.
|
||||
Forget,
|
||||
/// Ask the agent to explain the change (returns the risk signals).
|
||||
AskAgentWhy,
|
||||
}
|
||||
|
||||
impl MemoryPrAction {
|
||||
/// Parse from a URL/path label; `None` if unrecognised.
|
||||
pub fn from_label(s: &str) -> Option<Self> {
|
||||
Some(match s {
|
||||
"promote" => MemoryPrAction::Promote,
|
||||
"merge" => MemoryPrAction::Merge,
|
||||
"supersede" => MemoryPrAction::Supersede,
|
||||
"quarantine" => MemoryPrAction::Quarantine,
|
||||
"forget" => MemoryPrAction::Forget,
|
||||
"ask_agent_why" | "ask-agent-why" | "why" => MemoryPrAction::AskAgentWhy,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The status this action moves the PR into (`None` for `AskAgentWhy`, which
|
||||
/// is read-only).
|
||||
pub fn resulting_status(&self) -> Option<MemoryPrStatus> {
|
||||
Some(match self {
|
||||
MemoryPrAction::Promote => MemoryPrStatus::Promoted,
|
||||
MemoryPrAction::Merge => MemoryPrStatus::Merged,
|
||||
MemoryPrAction::Supersede => MemoryPrStatus::Superseded,
|
||||
MemoryPrAction::Quarantine => MemoryPrStatus::Quarantined,
|
||||
MemoryPrAction::Forget => MemoryPrStatus::Forgotten,
|
||||
MemoryPrAction::AskAgentWhy => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether deciding the PR with this action should **release** the subject
|
||||
/// memory from quarantine (reverse the suppression that gate_writes applied).
|
||||
///
|
||||
/// A risky write is committed-then-suppressed; approving it must restore its
|
||||
/// retrieval influence, otherwise the UI says "promoted" while the memory
|
||||
/// stays held out — the bug this guards against. Accept actions release;
|
||||
/// `Quarantine` keeps it held; `Forget` rejects it (stays suppressed);
|
||||
/// `AskAgentWhy` is read-only.
|
||||
pub fn releases_memory(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
MemoryPrAction::Promote | MemoryPrAction::Merge | MemoryPrAction::Supersede
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A reviewable change to the agent's brain — the persisted Memory PR record.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct MemoryPr {
|
||||
/// UUID.
|
||||
pub id: String,
|
||||
/// What kind of change this is.
|
||||
pub kind: MemoryPrKind,
|
||||
/// Current review status.
|
||||
pub status: MemoryPrStatus,
|
||||
/// Short human title for the PR list.
|
||||
pub title: String,
|
||||
/// The proposed change as a structured diff (before/after, ids, payload).
|
||||
pub diff: serde_json::Value,
|
||||
/// The self-explaining risk signals that opened this PR.
|
||||
pub signals: Vec<RiskSignal>,
|
||||
/// The memory id this PR concerns, if any.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subject_id: Option<String>,
|
||||
/// The run that produced this change, linking the PR back to the black box.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub run_id: Option<String>,
|
||||
/// RFC3339 creation time.
|
||||
pub created_at: String,
|
||||
/// RFC3339 decision time, once decided.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub decided_at: Option<String>,
|
||||
/// The action that resolved this PR, once decided.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub decision: Option<MemoryPrAction>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ordinary() -> WriteContext {
|
||||
WriteContext {
|
||||
source: Some(WriteSource::Agent),
|
||||
node_type: "fact".into(),
|
||||
content: "The build uses cargo and pnpm.".into(),
|
||||
tags: vec!["build".into()],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_write_auto_commits_in_risk_gated() {
|
||||
let (class, signals) = classify_write(&ordinary(), ReviewMode::RiskGated);
|
||||
assert_eq!(class, RiskClass::AutoCommit);
|
||||
assert!(signals.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_mode_never_gates_even_risky_writes() {
|
||||
let mut ctx = ordinary();
|
||||
ctx.supersedes = true;
|
||||
ctx.contradicts_trust = Some(0.95);
|
||||
let (class, _) = classify_write(&ctx, ReviewMode::Fast);
|
||||
assert_eq!(class, RiskClass::AutoCommit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paranoid_mode_gates_even_ordinary_writes() {
|
||||
let (class, signals) = classify_write(&ordinary(), ReviewMode::Paranoid);
|
||||
assert_eq!(class, RiskClass::Review);
|
||||
assert_eq!(signals[0].code, "paranoid_mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contradiction_against_high_trust_is_risky() {
|
||||
let mut ctx = ordinary();
|
||||
ctx.contradicts_trust = Some(0.82);
|
||||
let (class, signals) = classify_write(&ctx, ReviewMode::RiskGated);
|
||||
assert_eq!(class, RiskClass::Review);
|
||||
assert!(signals.iter().any(|s| s.code == "contradicts_high_trust"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contradiction_against_low_trust_is_fine() {
|
||||
let mut ctx = ordinary();
|
||||
ctx.contradicts_trust = Some(0.3);
|
||||
let (class, _) = classify_write(&ctx, ReviewMode::RiskGated);
|
||||
assert_eq!(class, RiskClass::AutoCommit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supersede_forget_merge_protect_all_gate() {
|
||||
for set in [
|
||||
|c: &mut WriteContext| c.supersedes = true,
|
||||
|c: &mut WriteContext| c.forgets = true,
|
||||
|c: &mut WriteContext| c.merges = true,
|
||||
|c: &mut WriteContext| c.protects = true,
|
||||
] {
|
||||
let mut ctx = ordinary();
|
||||
set(&mut ctx);
|
||||
let (class, _) = classify_write(&ctx, ReviewMode::RiskGated);
|
||||
assert_eq!(class, RiskClass::Review);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitive_topics_gate() {
|
||||
for topic in [
|
||||
"remember my auth token is xyz",
|
||||
"Sam's salary is confidential",
|
||||
"the bounty payout terms",
|
||||
"user preference: dark mode",
|
||||
"this is a security vulnerability",
|
||||
] {
|
||||
let mut ctx = ordinary();
|
||||
ctx.content = topic.into();
|
||||
let (class, signals) = classify_write(&ctx, ReviewMode::RiskGated);
|
||||
assert_eq!(class, RiskClass::Review, "should gate: {topic}");
|
||||
assert!(signals.iter().any(|s| s.code == "sensitive_topic"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitive_topic_word_boundary_no_false_positives_b6() {
|
||||
// B6: these ordinary technical writes must NOT gate — they only CONTAIN
|
||||
// a sensitive substring, they don't USE the sensitive word.
|
||||
// These each only CONTAIN a sensitive substring; the word-boundary fix
|
||||
// means they no longer gate. (Note: bare "license"/"contract"/"legal"
|
||||
// ARE kept as gating words — a license/contract fact is legitimately
|
||||
// legal-relevant — so they're intentionally not in this benign set.)
|
||||
for benign in [
|
||||
"The tokenizer converts input strings to embeddings.",
|
||||
"The author of this module is documented in the header.",
|
||||
"The secretary pattern coordinates the worker pool.",
|
||||
"Contraction of the array happens during compaction.",
|
||||
"The authority record links to the canonical node.",
|
||||
"The authentication-free endpoint is for health checks.", // "authentication" != "auth"
|
||||
] {
|
||||
let mut ctx = ordinary();
|
||||
ctx.content = benign.into();
|
||||
ctx.node_type = "fact".into();
|
||||
ctx.tags = vec![];
|
||||
let (class, _) = classify_write(&ctx, ReviewMode::RiskGated);
|
||||
assert_eq!(
|
||||
class,
|
||||
RiskClass::AutoCommit,
|
||||
"must NOT gate ordinary write: {benign}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitive_topic_word_boundary_still_catches_real_b6() {
|
||||
// The real sensitive phrasings must still gate.
|
||||
for risky in [
|
||||
"store the auth token for the deploy",
|
||||
"this is a security vulnerability in the parser",
|
||||
"the api key for the service",
|
||||
"remember the user preference for dark mode",
|
||||
"the bounty payout is configured",
|
||||
] {
|
||||
let mut ctx = ordinary();
|
||||
ctx.content = risky.into();
|
||||
ctx.node_type = "fact".into();
|
||||
ctx.tags = vec![];
|
||||
let (class, signals) = classify_write(&ctx, ReviewMode::RiskGated);
|
||||
assert_eq!(class, RiskClass::Review, "must gate: {risky}");
|
||||
assert!(signals.iter().any(|s| s.code == "sensitive_topic"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitive_node_type_gates() {
|
||||
let mut ctx = ordinary();
|
||||
ctx.node_type = "identity".into();
|
||||
let (class, signals) = classify_write(&ctx, ReviewMode::RiskGated);
|
||||
assert_eq!(class, RiskClass::Review);
|
||||
assert!(signals.iter().any(|s| s.code == "sensitive_node_type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dream_consolidation_gates() {
|
||||
let mut ctx = ordinary();
|
||||
ctx.source = Some(WriteSource::Dream);
|
||||
let (class, signals) = classify_write(&ctx, ReviewMode::RiskGated);
|
||||
assert_eq!(class, RiskClass::Review);
|
||||
assert!(signals.iter().any(|s| s.code == "dream_consolidation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decayed_resurrection_gates() {
|
||||
let mut ctx = ordinary();
|
||||
ctx.resurrects_decayed = true;
|
||||
let (class, _) = classify_write(&ctx, ReviewMode::RiskGated);
|
||||
assert_eq!(class, RiskClass::Review);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_confidence_batch_gates_but_confident_batch_does_not() {
|
||||
let mut low = ordinary();
|
||||
low.batch_import = true;
|
||||
low.confidence = Some(0.3);
|
||||
assert_eq!(
|
||||
classify_write(&low, ReviewMode::RiskGated).0,
|
||||
RiskClass::Review
|
||||
);
|
||||
|
||||
let mut high = ordinary();
|
||||
high.batch_import = true;
|
||||
high.confidence = Some(0.9);
|
||||
assert_eq!(
|
||||
classify_write(&high, ReviewMode::RiskGated).0,
|
||||
RiskClass::AutoCommit
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weak_provenance_connector_gates_strong_does_not() {
|
||||
let mut weak = ordinary();
|
||||
weak.source = Some(WriteSource::Connector);
|
||||
weak.strong_provenance = false;
|
||||
assert_eq!(
|
||||
classify_write(&weak, ReviewMode::RiskGated).0,
|
||||
RiskClass::Review
|
||||
);
|
||||
|
||||
let mut strong = ordinary();
|
||||
strong.source = Some(WriteSource::Connector);
|
||||
strong.strong_provenance = true;
|
||||
assert_eq!(
|
||||
classify_write(&strong, ReviewMode::RiskGated).0,
|
||||
RiskClass::AutoCommit
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_label_roundtrip() {
|
||||
assert_eq!(ReviewMode::from_label("FAST"), ReviewMode::Fast);
|
||||
assert_eq!(ReviewMode::from_label("risk-gated"), ReviewMode::RiskGated);
|
||||
assert_eq!(ReviewMode::from_label("paranoid"), ReviewMode::Paranoid);
|
||||
assert_eq!(ReviewMode::from_label("garbage"), ReviewMode::RiskGated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_resulting_status() {
|
||||
assert_eq!(
|
||||
MemoryPrAction::Promote.resulting_status(),
|
||||
Some(MemoryPrStatus::Promoted)
|
||||
);
|
||||
assert_eq!(MemoryPrAction::AskAgentWhy.resulting_status(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_accept_actions_release_the_memory() {
|
||||
// B1: accepting a risky write must release it from quarantine.
|
||||
assert!(MemoryPrAction::Promote.releases_memory());
|
||||
assert!(MemoryPrAction::Merge.releases_memory());
|
||||
assert!(MemoryPrAction::Supersede.releases_memory());
|
||||
// Rejecting / holding keeps it suppressed.
|
||||
assert!(!MemoryPrAction::Forget.releases_memory());
|
||||
assert!(!MemoryPrAction::Quarantine.releases_memory());
|
||||
assert!(!MemoryPrAction::AskAgentWhy.releases_memory());
|
||||
}
|
||||
}
|
||||
|
|
@ -443,7 +443,10 @@ async fn handle_event(
|
|||
| VestigeEvent::ConsolidationCompleted { .. }
|
||||
| VestigeEvent::RetentionDecayed { .. }
|
||||
| VestigeEvent::ConnectionDiscovered { .. }
|
||||
| VestigeEvent::ActivationSpread { .. } => {}
|
||||
| VestigeEvent::ActivationSpread { .. }
|
||||
| VestigeEvent::TraceEvent { .. }
|
||||
| VestigeEvent::MemoryPrOpened { .. }
|
||||
| VestigeEvent::MemoryPrDecided { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -167,6 +167,39 @@ pub enum VestigeEvent {
|
|||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
// -- Agent Black Box (v2.2) --
|
||||
// One replayable trace event from an agent run. The dashboard Black Box tab
|
||||
// appends these to the live timeline and pulses the graph exactly as the
|
||||
// agent experienced it. The inner event is the canonical
|
||||
// `vestige_core::MemoryTraceEvent`, serialized with its own `type` tag, so
|
||||
// the wire shape is `{ "type": "TraceEvent", "data": { "runId": ..., "event": { "type": "mcp.call", ... } } }`.
|
||||
TraceEvent {
|
||||
run_id: String,
|
||||
seq: i64,
|
||||
event: vestige_core::MemoryTraceEvent,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
// -- Memory PRs (v2.2) — the cognitive immune system --
|
||||
// A risky write opened a Memory PR. The dashboard raises the PR-queue badge
|
||||
// and can surface a toast: "Vestige opened a Memory PR — the agent tried to
|
||||
// rewrite its own brain."
|
||||
MemoryPrOpened {
|
||||
id: String,
|
||||
kind: String,
|
||||
title: String,
|
||||
signal_count: usize,
|
||||
run_id: Option<String>,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
// A Memory PR was decided (promote / merge / supersede / quarantine / forget).
|
||||
MemoryPrDecided {
|
||||
id: String,
|
||||
decision: String,
|
||||
status: String,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
|
||||
// -- System --
|
||||
Heartbeat {
|
||||
uptime_secs: u64,
|
||||
|
|
|
|||
|
|
@ -1983,6 +1983,358 @@ pub async fn deep_reference_query(
|
|||
Ok(Json(response))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AGENT BLACK BOX (v2.2) — replayable agent-run traces
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TraceListParams {
|
||||
pub limit: Option<usize>,
|
||||
/// Optional run filter — receipts/traces scoped to one run (B5).
|
||||
pub run: Option<String>,
|
||||
}
|
||||
|
||||
/// List recent agent runs (newest activity first) for the Black Box run picker.
|
||||
pub async fn list_traces(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<TraceListParams>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let limit = params.limit.unwrap_or(50).clamp(1, 500);
|
||||
let runs = state
|
||||
.storage
|
||||
.list_agent_runs(limit)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let runs_json: Vec<Value> = runs
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
serde_json::json!({
|
||||
"runId": r.run_id,
|
||||
"firstTool": r.first_tool,
|
||||
"eventCount": r.event_count,
|
||||
"retrievedCount": r.retrieved_count,
|
||||
"suppressedCount": r.suppressed_count,
|
||||
"writeCount": r.write_count,
|
||||
"vetoCount": r.veto_count,
|
||||
"startedAt": r.started_at,
|
||||
"lastAt": r.last_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(serde_json::json!({
|
||||
"total": runs_json.len(),
|
||||
"runs": runs_json,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Fetch the full event timeline for one run — the black-box replay payload.
|
||||
pub async fn get_trace(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let events = state
|
||||
.storage
|
||||
.get_trace(&run_id)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
if events.is_empty() {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
let summary = state.storage.get_agent_run(&run_id).ok().flatten();
|
||||
Ok(Json(serde_json::json!({
|
||||
"runId": run_id,
|
||||
"summary": summary.map(|s| serde_json::json!({
|
||||
"firstTool": s.first_tool,
|
||||
"eventCount": s.event_count,
|
||||
"retrievedCount": s.retrieved_count,
|
||||
"suppressedCount": s.suppressed_count,
|
||||
"writeCount": s.write_count,
|
||||
"vetoCount": s.veto_count,
|
||||
"startedAt": s.started_at,
|
||||
"lastAt": s.last_at,
|
||||
})),
|
||||
"events": events,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Export a run as a downloadable `.vestige-trace.json` artifact.
|
||||
pub async fn export_trace(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
) -> Result<([(axum::http::HeaderName, String); 2], Json<Value>), StatusCode> {
|
||||
let events = state
|
||||
.storage
|
||||
.get_trace(&run_id)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
if events.is_empty() {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
let summary = state.storage.get_agent_run(&run_id).ok().flatten();
|
||||
let body = serde_json::json!({
|
||||
"format": "vestige-trace",
|
||||
"version": 1,
|
||||
"runId": run_id,
|
||||
"exportedAt": Utc::now().to_rfc3339(),
|
||||
"summary": summary.map(|s| serde_json::json!({
|
||||
"firstTool": s.first_tool,
|
||||
"eventCount": s.event_count,
|
||||
"retrievedCount": s.retrieved_count,
|
||||
"suppressedCount": s.suppressed_count,
|
||||
"writeCount": s.write_count,
|
||||
"vetoCount": s.veto_count,
|
||||
"startedAt": s.started_at,
|
||||
"lastAt": s.last_at,
|
||||
})),
|
||||
"events": events,
|
||||
});
|
||||
// B7: sanitize the run_id before putting it in the download filename so a
|
||||
// crafted run_id (quotes, path separators, control chars) can't break the
|
||||
// Content-Disposition header or the filename. Falls back to "trace".
|
||||
let safe: String = run_id
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' })
|
||||
.collect();
|
||||
let safe = if safe.trim_matches('_').is_empty() {
|
||||
"trace".to_string()
|
||||
} else {
|
||||
safe
|
||||
};
|
||||
let headers = [
|
||||
(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
"application/json".to_string(),
|
||||
),
|
||||
(
|
||||
axum::http::header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{safe}.vestige-trace.json\""),
|
||||
),
|
||||
];
|
||||
Ok((headers, Json(body)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MEMORY RECEIPTS (v2.2)
|
||||
// ============================================================================
|
||||
|
||||
/// List recent retrieval receipts.
|
||||
pub async fn list_receipts(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<TraceListParams>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let limit = params.limit.unwrap_or(50).clamp(1, 500);
|
||||
// B5: when a run is given, scope to that run's receipts so the Black Box
|
||||
// panel shows only receipts that actually belong to the selected run.
|
||||
let receipts = match params.run.as_deref().filter(|r| !r.is_empty()) {
|
||||
Some(run_id) => state.storage.list_receipts_for_run(run_id, limit),
|
||||
None => state.storage.list_receipts(limit),
|
||||
}
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"total": receipts.len(),
|
||||
"receipts": receipts,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Fetch one receipt by id — the payload behind "Open receipt in Cinema".
|
||||
pub async fn get_receipt(
|
||||
State(state): State<AppState>,
|
||||
Path(receipt_id): Path<String>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let receipt = state
|
||||
.storage
|
||||
.get_receipt(&receipt_id)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
Ok(Json(serde_json::to_value(receipt).unwrap_or_default()))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MEMORY PRs (v2.2) — risk-gated brain-change review queue
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MemoryPrListParams {
|
||||
pub status: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// List Memory PRs, optionally filtered by status.
|
||||
pub async fn list_memory_prs(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<MemoryPrListParams>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let limit = params.limit.unwrap_or(100).clamp(1, 500);
|
||||
let status = params.status.as_deref().and_then(|s| {
|
||||
serde_json::from_value::<vestige_core::MemoryPrStatus>(serde_json::Value::String(
|
||||
s.to_string(),
|
||||
))
|
||||
.ok()
|
||||
});
|
||||
let prs = state
|
||||
.storage
|
||||
.list_memory_prs(status, limit)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let pending = state.storage.count_pending_memory_prs().unwrap_or(0);
|
||||
Ok(Json(serde_json::json!({
|
||||
"total": prs.len(),
|
||||
"pendingCount": pending,
|
||||
"mode": read_review_mode(&state).as_str(),
|
||||
"prs": prs,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Fetch one Memory PR by id.
|
||||
pub async fn get_memory_pr(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let pr = state
|
||||
.storage
|
||||
.get_memory_pr(&id)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
Ok(Json(serde_json::to_value(pr).unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// Act on a Memory PR: promote / merge / supersede / quarantine / forget /
|
||||
/// ask_agent_why. `ask_agent_why` is read-only and returns the risk signals.
|
||||
pub async fn act_on_memory_pr(
|
||||
State(state): State<AppState>,
|
||||
Path((id, action)): Path<(String, String)>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let action = vestige_core::MemoryPrAction::from_label(&action)
|
||||
.ok_or(StatusCode::BAD_REQUEST)?;
|
||||
|
||||
// Ask Agent Why is read-only — return the self-explaining signals.
|
||||
if matches!(action, vestige_core::MemoryPrAction::AskAgentWhy) {
|
||||
let pr = state
|
||||
.storage
|
||||
.get_memory_pr(&id)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
return Ok(Json(serde_json::json!({
|
||||
"id": pr.id,
|
||||
"kind": pr.kind.as_str(),
|
||||
"title": pr.title,
|
||||
"why": pr.signals,
|
||||
"explanation": "These are the risk signals that opened this Memory PR.",
|
||||
})));
|
||||
}
|
||||
|
||||
let decided = state
|
||||
.storage
|
||||
.decide_memory_pr(&id, action)
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
// B1: an accept action (promote/merge/supersede) must RELEASE the subject
|
||||
// memory from quarantine — gate_writes suppressed it, so deciding the PR
|
||||
// without un-suppressing would leave it "promoted" yet still held out of
|
||||
// retrieval. Forget/Quarantine intentionally keep it suppressed.
|
||||
let mut released = false;
|
||||
if action.releases_memory()
|
||||
&& let Some(subject_id) = decided.subject_id.as_deref()
|
||||
{
|
||||
// Use the UNCONDITIONAL quarantine release, not reverse_suppression:
|
||||
// approving a PR must restore the memory even if reviewed days later,
|
||||
// past the active-forgetting labile window (the C1 fix).
|
||||
match state.storage.release_quarantine(subject_id) {
|
||||
Ok(node) => {
|
||||
released = true;
|
||||
state.emit(VestigeEvent::MemoryUnsuppressed {
|
||||
id: node.id.clone(),
|
||||
remaining_count: node.suppression_count,
|
||||
timestamp: Utc::now(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
// Best-effort: the PR is decided regardless, but surface the
|
||||
// failure so a stuck-suppressed memory isn't silent.
|
||||
tracing::warn!(
|
||||
"memory PR {} {}d but failed to release subject {}: {}",
|
||||
id,
|
||||
action_label(action),
|
||||
subject_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.emit(VestigeEvent::MemoryPrDecided {
|
||||
id: decided.id.clone(),
|
||||
decision: decided
|
||||
.decision
|
||||
.and_then(|d| serde_json::to_value(d).ok())
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
.unwrap_or_default(),
|
||||
status: decided.status.as_str().to_string(),
|
||||
timestamp: Utc::now(),
|
||||
});
|
||||
|
||||
let mut out = serde_json::to_value(&decided).unwrap_or_default();
|
||||
if let Some(obj) = out.as_object_mut() {
|
||||
obj.insert("subjectReleased".to_string(), serde_json::json!(released));
|
||||
}
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
/// Short label for a Memory PR action, for log lines.
|
||||
fn action_label(action: vestige_core::MemoryPrAction) -> &'static str {
|
||||
use vestige_core::MemoryPrAction::*;
|
||||
match action {
|
||||
Promote => "promote",
|
||||
Merge => "merge",
|
||||
Supersede => "supersede",
|
||||
Quarantine => "quarantine",
|
||||
Forget => "forget",
|
||||
AskAgentWhy => "ask_agent_why",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReviewModeBody {
|
||||
pub mode: String,
|
||||
}
|
||||
|
||||
/// Get the current review mode (fast / risk_gated / paranoid).
|
||||
pub async fn get_review_mode(State(state): State<AppState>) -> Json<Value> {
|
||||
let mode = read_review_mode(&state);
|
||||
Json(serde_json::json!({
|
||||
"mode": mode.as_str(),
|
||||
"pendingCount": state.storage.count_pending_memory_prs().unwrap_or(0),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Set the review mode. Persisted to a small JSON file in the data dir so it
|
||||
/// survives restarts (local-first, no extra config service).
|
||||
pub async fn set_review_mode(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<ReviewModeBody>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let mode = vestige_core::ReviewMode::from_label(&body.mode);
|
||||
let path = review_mode_path(&state);
|
||||
let payload = serde_json::json!({ "mode": mode.as_str() });
|
||||
// B7: atomic write (temp + rename) so a concurrent read can never see a
|
||||
// partially-written / corrupt review_mode.json, reusing the same helper the
|
||||
// Sanhedrin receipt path uses.
|
||||
write_atomic(&path, &serde_json::to_vec_pretty(&payload).unwrap_or_default())?;
|
||||
Ok(Json(serde_json::json!({ "mode": mode.as_str() })))
|
||||
}
|
||||
|
||||
/// Path to the persisted review-mode file.
|
||||
fn review_mode_path(state: &AppState) -> PathBuf {
|
||||
state.storage.data_dir().join("review_mode.json")
|
||||
}
|
||||
|
||||
/// Read the persisted review mode, defaulting to RiskGated.
|
||||
pub fn read_review_mode(state: &AppState) -> vestige_core::ReviewMode {
|
||||
let path = review_mode_path(state);
|
||||
fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
|
||||
.and_then(|v| v.get("mode").and_then(|m| m.as_str()).map(String::from))
|
||||
.map(|s| vestige_core::ReviewMode::from_label(&s))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -183,6 +183,32 @@ fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) {
|
|||
get(handlers::get_sanhedrin_telemetry),
|
||||
)
|
||||
.route("/api/sanhedrin/appeal", post(handlers::appeal_sanhedrin))
|
||||
// ============================================================
|
||||
// AGENT BLACK BOX (v2.2) — replayable agent-run traces
|
||||
// ============================================================
|
||||
.route("/api/traces", get(handlers::list_traces))
|
||||
.route("/api/traces/{run_id}", get(handlers::get_trace))
|
||||
.route("/api/traces/{run_id}/export", get(handlers::export_trace))
|
||||
// ============================================================
|
||||
// MEMORY RECEIPTS (v2.2) — the nutrition label for a retrieval
|
||||
// ============================================================
|
||||
.route("/api/receipts", get(handlers::list_receipts))
|
||||
.route("/api/receipts/{receipt_id}", get(handlers::get_receipt))
|
||||
// ============================================================
|
||||
// MEMORY PRs (v2.2) — risk-gated brain-change review queue
|
||||
// ============================================================
|
||||
.route("/api/memory-prs", get(handlers::list_memory_prs))
|
||||
// Static `/mode` routes declared BEFORE the dynamic `/{id}` route (B7
|
||||
// hygiene). axum 0.8/matchit already prioritizes static segments, but
|
||||
// declaring them first makes the intent unambiguous and guards against
|
||||
// a future router that doesn't.
|
||||
.route("/api/memory-prs/mode", get(handlers::get_review_mode))
|
||||
.route("/api/memory-prs/mode", post(handlers::set_review_mode))
|
||||
.route("/api/memory-prs/{id}", get(handlers::get_memory_pr))
|
||||
.route(
|
||||
"/api/memory-prs/{id}/{action}",
|
||||
post(handlers::act_on_memory_pr),
|
||||
)
|
||||
.layer(
|
||||
ServiceBuilder::new()
|
||||
.concurrency_limit(50)
|
||||
|
|
|
|||
|
|
@ -9,3 +9,4 @@ pub mod protocol;
|
|||
pub mod resources;
|
||||
pub mod server;
|
||||
pub mod tools;
|
||||
pub mod trace_recorder;
|
||||
|
|
|
|||
|
|
@ -4,3 +4,4 @@
|
|||
|
||||
pub mod codebase;
|
||||
pub mod memory;
|
||||
pub mod trace;
|
||||
|
|
|
|||
103
crates/vestige-mcp/src/resources/trace.rs
Normal file
103
crates/vestige-mcp/src/resources/trace.rs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
//! Agent Black Box Resources
|
||||
//!
|
||||
//! `trace://` URI scheme — exposes replayable agent-run traces as MCP resources
|
||||
//! so a coding agent can read its *own* black box back. This closes the trace
|
||||
//! correlation spine on the MCP side: the same `runId` an agent received in a
|
||||
//! tool result's `traceUri` resolves here to the full event timeline.
|
||||
//!
|
||||
//! - `trace://{runId}` — the full ordered event log for a run.
|
||||
//! - `trace://{runId}/summary` — just the roll-up counts.
|
||||
//! - `trace://runs` — recent runs (the run picker).
|
||||
//! - `trace://latest` — the most recently active run's full trace.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vestige_core::Storage;
|
||||
|
||||
/// Read a `trace://` resource.
|
||||
pub async fn read(storage: &Arc<Storage>, uri: &str) -> Result<String, String> {
|
||||
let path = uri.strip_prefix("trace://").unwrap_or("");
|
||||
let (path, _query) = match path.split_once('?') {
|
||||
Some((p, q)) => (p, Some(q)),
|
||||
None => (path, None),
|
||||
};
|
||||
|
||||
match path {
|
||||
"" | "runs" => read_runs(storage).await,
|
||||
"latest" => read_latest(storage).await,
|
||||
other => {
|
||||
if let Some(run_id) = other.strip_suffix("/summary") {
|
||||
read_summary(storage, run_id).await
|
||||
} else {
|
||||
read_run(storage, other).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_runs(storage: &Arc<Storage>) -> Result<String, String> {
|
||||
let runs = storage.list_agent_runs(50).map_err(|e| e.to_string())?;
|
||||
let json: Vec<_> = runs
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
serde_json::json!({
|
||||
"runId": r.run_id,
|
||||
"firstTool": r.first_tool,
|
||||
"eventCount": r.event_count,
|
||||
"retrievedCount": r.retrieved_count,
|
||||
"suppressedCount": r.suppressed_count,
|
||||
"writeCount": r.write_count,
|
||||
"vetoCount": r.veto_count,
|
||||
"startedAt": r.started_at,
|
||||
"lastAt": r.last_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_string_pretty(&serde_json::json!({ "runs": json }))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn read_latest(storage: &Arc<Storage>) -> Result<String, String> {
|
||||
let runs = storage.list_agent_runs(1).map_err(|e| e.to_string())?;
|
||||
let run = runs
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| "No agent runs recorded yet".to_string())?;
|
||||
read_run(storage, &run.run_id).await
|
||||
}
|
||||
|
||||
async fn read_run(storage: &Arc<Storage>, run_id: &str) -> Result<String, String> {
|
||||
let events = storage.get_trace(run_id).map_err(|e| e.to_string())?;
|
||||
if events.is_empty() {
|
||||
return Err(format!("No trace found for run: {run_id}"));
|
||||
}
|
||||
let summary = storage.get_agent_run(run_id).ok().flatten();
|
||||
let body = serde_json::json!({
|
||||
"runId": run_id,
|
||||
"summary": summary.map(summary_json),
|
||||
"events": events,
|
||||
});
|
||||
serde_json::to_string_pretty(&body).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn read_summary(storage: &Arc<Storage>, run_id: &str) -> Result<String, String> {
|
||||
let summary = storage
|
||||
.get_agent_run(run_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("No run: {run_id}"))?;
|
||||
serde_json::to_string_pretty(&summary_json(summary)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn summary_json(s: vestige_core::AgentRunSummary) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"runId": s.run_id,
|
||||
"firstTool": s.first_tool,
|
||||
"eventCount": s.event_count,
|
||||
"retrievedCount": s.retrieved_count,
|
||||
"suppressedCount": s.suppressed_count,
|
||||
"writeCount": s.write_count,
|
||||
"vetoCount": s.veto_count,
|
||||
"startedAt": s.started_at,
|
||||
"lastAt": s.last_at,
|
||||
})
|
||||
}
|
||||
|
|
@ -129,6 +129,23 @@ impl McpServer {
|
|||
}
|
||||
}
|
||||
|
||||
/// Read the active Memory PR review mode from `<data_dir>/review_mode.json`,
|
||||
/// defaulting to `RiskGated`. Shared shape with the dashboard handler so the
|
||||
/// MCP write path and the UI agree on the mode.
|
||||
fn review_mode(&self) -> vestige_core::ReviewMode {
|
||||
let path = self.storage.data_dir().join("review_mode.json");
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
.and_then(|v| {
|
||||
v.get("mode")
|
||||
.and_then(|m| m.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.map(|s| vestige_core::ReviewMode::from_label(&s))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Handle an incoming JSON-RPC request
|
||||
pub async fn handle_request(&mut self, request: JsonRpcRequest) -> Option<JsonRpcResponse> {
|
||||
debug!("Handling request: {}", request.method);
|
||||
|
|
@ -240,8 +257,8 @@ impl McpServer {
|
|||
|
||||
/// Handle tools/list request
|
||||
async fn handle_tools_list(&self) -> Result<serde_json::Value, JsonRpcError> {
|
||||
// v2.1.21: 25 tools (verified by the `tools.len() == 25` assertion in the
|
||||
// handle_tools_list test below — the `suppress` tool landed in v2.0.5).
|
||||
// v2.1.27: 34 tools (verified by the `tools.len() == 34` assertion in the
|
||||
// handle_tools_list test below).
|
||||
// Deprecated tools still work via redirects in handle_tools_call.
|
||||
let mut tools = vec![
|
||||
// ================================================================
|
||||
|
|
@ -503,7 +520,7 @@ impl McpServer {
|
|||
// Per-tool caps below are sized at ~2× observed peak with growth
|
||||
// headroom; max permitted by Anthropic is 500_000. Only the four
|
||||
// empirically-measured high-payload tools carry the annotation today;
|
||||
// the remaining 21 tools deliberately do NOT (cargo-cult prevention —
|
||||
// the remaining 30 tools deliberately do NOT (cargo-cult prevention —
|
||||
// annotating a small-payload tool dilutes the signal).
|
||||
//
|
||||
// Other tools that COULD plausibly grow into the annotated set with
|
||||
|
|
@ -563,6 +580,21 @@ impl McpServer {
|
|||
None
|
||||
};
|
||||
|
||||
// ================================================================
|
||||
// AGENT BLACK BOX (v2.2)
|
||||
// Open/continue a run for this call and record the opening `mcp.call`
|
||||
// event (args are hashed, never stored raw). Downstream memory events
|
||||
// are recorded from the result after dispatch.
|
||||
// ================================================================
|
||||
let run_id = crate::trace_recorder::run_id_for(&request.arguments);
|
||||
crate::trace_recorder::record_call(
|
||||
&self.storage,
|
||||
self.event_tx.as_ref(),
|
||||
&run_id,
|
||||
&request.name,
|
||||
&request.arguments,
|
||||
);
|
||||
|
||||
let result = match request.name.as_str() {
|
||||
// ================================================================
|
||||
// UNIFIED TOOLS (v1.1+) - Preferred API
|
||||
|
|
@ -1083,10 +1115,81 @@ impl McpServer {
|
|||
// ================================================================
|
||||
if let Ok(ref content) = result {
|
||||
self.emit_tool_event(&request.name, &saved_args, content);
|
||||
// Black Box: record the downstream memory events (retrieve /
|
||||
// suppress / write / veto / dream) the agent experienced.
|
||||
crate::trace_recorder::record_result(
|
||||
&self.storage,
|
||||
self.event_tx.as_ref(),
|
||||
&run_id,
|
||||
&request.name,
|
||||
content,
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// RISK-GATED MEMORY PRs (v2.2) — quarantine review, the cognitive
|
||||
// immune system. Normal writes auto-land. Risky writes (contradiction
|
||||
// vs high-trust, supersede/forget/merge, sensitive topics, …) are
|
||||
// *committed then quarantined*: the row is recorded (audit history
|
||||
// preserved) but suppressed out of retrieval until a Memory PR is
|
||||
// decided. This is quarantine review, NOT pre-write blocking — the
|
||||
// write happens inside the tool before the gate sees it; we hold its
|
||||
// influence, not its existence. Centralized here so tools stay
|
||||
// untouched.
|
||||
// ================================================================
|
||||
let opened_prs = if let Ok(ref content) = result {
|
||||
crate::trace_recorder::gate_writes(
|
||||
&self.storage,
|
||||
self.event_tx.as_ref(),
|
||||
&run_id,
|
||||
&request.name,
|
||||
content,
|
||||
self.review_mode(),
|
||||
)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let response = match result {
|
||||
Ok(content) => {
|
||||
Ok(mut content) => {
|
||||
// ============================================================
|
||||
// TRACE SPINE (Phase 0)
|
||||
// Stamp the runId + a pointer to the full trace onto the tool
|
||||
// output itself. This is the first hop of the correlation
|
||||
// chain: the same runId now appears in the tool result, the
|
||||
// SQLite trace rows, the WebSocket events, /api/traces/{runId},
|
||||
// and vestige://trace/{runId}. One id, end to end.
|
||||
// ============================================================
|
||||
// Memory Receipt: for retrieval tools, build + persist a
|
||||
// receipt from what the tool already computed and attach it.
|
||||
// Done before the runId stamp so the receipt's own suppressed
|
||||
// list is part of the same payload the agent reads.
|
||||
let receipt =
|
||||
crate::trace_recorder::build_and_save_receipt(&self.storage, &run_id, &request.name, &content);
|
||||
if let Some(obj) = content.as_object_mut() {
|
||||
obj.insert("runId".to_string(), serde_json::json!(run_id));
|
||||
obj.insert(
|
||||
"traceUri".to_string(),
|
||||
serde_json::json!(format!("vestige://trace/{run_id}")),
|
||||
);
|
||||
if let Some(r) = receipt {
|
||||
obj.insert("receipt".to_string(), r);
|
||||
}
|
||||
// Surface opened Memory PRs so the agent learns its risky
|
||||
// write is held for review, not silently committed.
|
||||
if !opened_prs.is_empty() {
|
||||
obj.insert(
|
||||
"memoryPrsOpened".to_string(),
|
||||
serde_json::json!(opened_prs),
|
||||
);
|
||||
obj.insert(
|
||||
"memoryPrNotice".to_string(),
|
||||
serde_json::json!(
|
||||
"Vestige opened a Memory PR (quarantine review): this write was recorded but is held out of retrieval until reviewed — its audit history is preserved while its influence is suspended. See the Memory PRs queue."
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
let call_result = CallToolResult {
|
||||
content: vec![crate::protocol::messages::ToolResultContent {
|
||||
content_type: "text".to_string(),
|
||||
|
|
@ -1228,6 +1331,27 @@ impl McpServer {
|
|||
description: Some("Intentions that have been triggered or are overdue".to_string()),
|
||||
mime_type: Some("application/json".to_string()),
|
||||
},
|
||||
// Agent Black Box (v2.2) — replayable agent-run traces. Individual
|
||||
// runs are read via the templated `vestige://trace/{runId}` (or
|
||||
// `trace://{runId}`) URI; these concrete entries list the runs and
|
||||
// the latest trace so a client can discover them.
|
||||
ResourceDescription {
|
||||
uri: "trace://runs".to_string(),
|
||||
name: "Agent Runs (Black Box)".to_string(),
|
||||
description: Some(
|
||||
"Recent agent runs. Read vestige://trace/{runId} for a full replayable trace."
|
||||
.to_string(),
|
||||
),
|
||||
mime_type: Some("application/json".to_string()),
|
||||
},
|
||||
ResourceDescription {
|
||||
uri: "trace://latest".to_string(),
|
||||
name: "Latest Agent Trace".to_string(),
|
||||
description: Some(
|
||||
"The most recently active agent run's full black-box trace.".to_string(),
|
||||
),
|
||||
mime_type: Some("application/json".to_string()),
|
||||
},
|
||||
];
|
||||
|
||||
let result = ListResourcesResult { resources };
|
||||
|
|
@ -1250,7 +1374,17 @@ impl McpServer {
|
|||
// OpenCode and other MCP clients may send "vestige/memory://recent"
|
||||
// but we register resources as "memory://recent"
|
||||
let normalized_uri = uri.strip_prefix("vestige/").unwrap_or(uri);
|
||||
let content = if normalized_uri.starts_with("memory://") {
|
||||
// The trace resource is specced as `vestige://trace/{runId}`. Accept
|
||||
// both that form and the bare `trace://{runId}` scheme, normalizing the
|
||||
// former to the latter so the resource module sees one shape.
|
||||
let trace_uri = normalized_uri
|
||||
.strip_prefix("vestige://trace/")
|
||||
.map(|rest| format!("trace://{rest}"));
|
||||
let content = if let Some(ref tu) = trace_uri {
|
||||
resources::trace::read(&self.storage, tu).await
|
||||
} else if normalized_uri.starts_with("trace://") {
|
||||
resources::trace::read(&self.storage, normalized_uri).await
|
||||
} else if normalized_uri.starts_with("memory://") {
|
||||
resources::memory::read(&self.storage, normalized_uri).await
|
||||
} else if normalized_uri.starts_with("codebase://") {
|
||||
resources::codebase::read(&self.storage, normalized_uri).await
|
||||
|
|
@ -1820,9 +1954,9 @@ mod tests {
|
|||
let result = response.result.unwrap();
|
||||
let tools = result["tools"].as_array().unwrap();
|
||||
|
||||
// 34 tools: 25 from v2.1.21 + 7 Phase 3 merge/supersede tools
|
||||
// (merge_candidates, plan_merge, plan_supersede, apply_plan, merge_undo,
|
||||
// protect, merge_policy, composed_graph) + 1 connector tool (source_sync, #57).
|
||||
// 34 tools in v2.1.27: the unified memory surface, Phase 3
|
||||
// merge/supersede controls, ComposedGraph, and the #57 source_sync
|
||||
// connector tool.
|
||||
assert_eq!(tools.len(), 34, "Expected exactly 34 tools");
|
||||
|
||||
let tool_names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||||
|
|
@ -2248,4 +2382,245 @@ mod tests {
|
|||
"search tool has un-renamed `meta` key (regression — serde rename broke)"
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TRACE SPINE (Phase 0) — one runId, end to end
|
||||
// ========================================================================
|
||||
|
||||
/// Every tools/call must stamp a runId + a trace pointer onto its output,
|
||||
/// persist an `mcp.call` trace row under that same runId, and that runId
|
||||
/// must resolve through the `vestige://trace/{runId}` resource. This is the
|
||||
/// load-bearing correlation guarantee.
|
||||
#[tokio::test]
|
||||
async fn test_trace_spine_runid_end_to_end() {
|
||||
let (mut server, _dir) = test_server().await;
|
||||
server
|
||||
.handle_request(make_request("initialize", Some(init_params())))
|
||||
.await;
|
||||
|
||||
// A client-supplied runId must be honoured so a whole session
|
||||
// correlates under one id.
|
||||
let call = make_request(
|
||||
"tools/call",
|
||||
Some(serde_json::json!({
|
||||
"name": "memory_health",
|
||||
"arguments": { "runId": "run_spine_test" }
|
||||
})),
|
||||
);
|
||||
let response = server.handle_request(call).await.unwrap();
|
||||
let result = response.result.expect("tools/call ok");
|
||||
|
||||
// 1. The tool output itself carries the runId + trace pointer.
|
||||
let structured = &result["structuredContent"];
|
||||
assert_eq!(
|
||||
structured["runId"].as_str(),
|
||||
Some("run_spine_test"),
|
||||
"tool output must echo the runId (spine hop 1)"
|
||||
);
|
||||
assert_eq!(
|
||||
structured["traceUri"].as_str(),
|
||||
Some("vestige://trace/run_spine_test"),
|
||||
"tool output must carry the trace resource pointer"
|
||||
);
|
||||
|
||||
// 2. The same runId persisted a trace row (the mcp.call event).
|
||||
let events = server.storage.get_trace("run_spine_test").unwrap();
|
||||
assert!(
|
||||
events.iter().any(|e| e.kind() == "mcp.call"),
|
||||
"an mcp.call event must be persisted under the runId (spine hop 2)"
|
||||
);
|
||||
|
||||
// 3. The run roll-up exists with the right entry tool.
|
||||
let run = server
|
||||
.storage
|
||||
.get_agent_run("run_spine_test")
|
||||
.unwrap()
|
||||
.expect("run summary persisted");
|
||||
assert_eq!(run.first_tool.as_deref(), Some("memory_health"));
|
||||
|
||||
// 4. The MCP resource resolves the same runId (spine hop 3).
|
||||
let read = make_request(
|
||||
"resources/read",
|
||||
Some(serde_json::json!({ "uri": "vestige://trace/run_spine_test" })),
|
||||
);
|
||||
let read_resp = server.handle_request(read).await.unwrap();
|
||||
let read_result = read_resp.result.expect("resource read ok");
|
||||
let text = read_result["contents"][0]["text"]
|
||||
.as_str()
|
||||
.expect("resource text");
|
||||
assert!(
|
||||
text.contains("run_spine_test") && text.contains("mcp.call"),
|
||||
"vestige://trace/{{runId}} must return the run's events"
|
||||
);
|
||||
}
|
||||
|
||||
/// Trace events must be broadcast to a live WebSocket subscriber, not just
|
||||
/// persisted. This guards the spine hop from SQLite → WebSocket → pulse.
|
||||
#[tokio::test]
|
||||
async fn test_trace_event_is_broadcast_to_subscriber() {
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let cognitive = Arc::new(Mutex::new(CognitiveEngine::new()));
|
||||
let (event_tx, mut event_rx) = broadcast::channel(64);
|
||||
let mut server = McpServer::new_with_events(storage, cognitive, event_tx);
|
||||
server
|
||||
.handle_request(make_request("initialize", Some(init_params())))
|
||||
.await;
|
||||
|
||||
let call = make_request(
|
||||
"tools/call",
|
||||
Some(serde_json::json!({
|
||||
"name": "memory_health",
|
||||
"arguments": { "runId": "run_ws" }
|
||||
})),
|
||||
);
|
||||
server.handle_request(call).await.unwrap();
|
||||
|
||||
// Drain the broadcast: at least one TraceEvent for run_ws must arrive.
|
||||
let mut saw_trace = false;
|
||||
while let Ok(ev) = event_rx.try_recv() {
|
||||
if let VestigeEvent::TraceEvent { run_id, .. } = ev {
|
||||
if run_id == "run_ws" {
|
||||
saw_trace = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_trace,
|
||||
"a TraceEvent for the run must be broadcast to subscribers (spine hop: WebSocket)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Risk-gated Memory PRs default: an ordinary tool call opens no PR.
|
||||
#[tokio::test]
|
||||
async fn test_no_memory_pr_for_non_write_tool() {
|
||||
let (mut server, _dir) = test_server().await;
|
||||
server
|
||||
.handle_request(make_request("initialize", Some(init_params())))
|
||||
.await;
|
||||
let call = make_request(
|
||||
"tools/call",
|
||||
Some(serde_json::json!({
|
||||
"name": "memory_health",
|
||||
"arguments": { "runId": "run_no_pr" }
|
||||
})),
|
||||
);
|
||||
server.handle_request(call).await.unwrap();
|
||||
assert_eq!(
|
||||
server.storage.count_pending_memory_prs().unwrap(),
|
||||
0,
|
||||
"a read-only tool must never open a Memory PR"
|
||||
);
|
||||
}
|
||||
|
||||
/// PROOF LOCK: the complete spine in one test. A single runId must cross
|
||||
/// every hop, and the value must be byte-identical at each:
|
||||
/// MCP output → SQLite trace → WebSocket event → API response shape →
|
||||
/// MCP resource.
|
||||
/// If any hop drops or rewrites the runId, this fails. This is the
|
||||
/// "impossible to doubt" guarantee for the receipt chain.
|
||||
#[tokio::test]
|
||||
async fn test_full_spine_one_runid_crosses_every_hop() {
|
||||
const RUN: &str = "run_full_spine";
|
||||
|
||||
let (storage, _dir) = test_storage().await;
|
||||
let cognitive = Arc::new(Mutex::new(CognitiveEngine::new()));
|
||||
let (event_tx, mut event_rx) = broadcast::channel(256);
|
||||
let mut server = McpServer::new_with_events(storage, cognitive, event_tx);
|
||||
server
|
||||
.handle_request(make_request("initialize", Some(init_params())))
|
||||
.await;
|
||||
|
||||
// ---- HOP 1: MCP tool output carries the runId + trace pointer ----
|
||||
let call = make_request(
|
||||
"tools/call",
|
||||
Some(serde_json::json!({
|
||||
"name": "memory_health",
|
||||
"arguments": { "runId": RUN }
|
||||
})),
|
||||
);
|
||||
let response = server.handle_request(call).await.unwrap();
|
||||
let structured = response.result.expect("tools/call ok")["structuredContent"].clone();
|
||||
assert_eq!(structured["runId"].as_str(), Some(RUN), "HOP 1: tool output runId");
|
||||
assert_eq!(
|
||||
structured["traceUri"].as_str(),
|
||||
Some(&format!("vestige://trace/{RUN}")[..]),
|
||||
"HOP 1: tool output traceUri"
|
||||
);
|
||||
|
||||
// ---- HOP 2: SQLite trace rows persisted under the same runId ----
|
||||
let events = server.storage.get_trace(RUN).unwrap();
|
||||
assert!(!events.is_empty(), "HOP 2: trace rows exist");
|
||||
assert!(
|
||||
events.iter().all(|e| e.run_id() == RUN),
|
||||
"HOP 2: every persisted trace row carries the SAME runId"
|
||||
);
|
||||
|
||||
// ---- HOP 3: WebSocket broadcast carries the same runId ----
|
||||
let mut ws_run: Option<String> = None;
|
||||
while let Ok(ev) = event_rx.try_recv() {
|
||||
if let VestigeEvent::TraceEvent { run_id, .. } = ev {
|
||||
ws_run = Some(run_id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
ws_run.as_deref(),
|
||||
Some(RUN),
|
||||
"HOP 3: the broadcast TraceEvent carries the same runId"
|
||||
);
|
||||
|
||||
// ---- HOP 4: API response shape (what the dashboard renders) ----
|
||||
// Exercise the exact handler the dashboard /api/traces/:runId calls by
|
||||
// going through storage the same way, and assert the render-critical
|
||||
// shape: a summary roll-up + an ordered event list, all under runId.
|
||||
let summary = server
|
||||
.storage
|
||||
.get_agent_run(RUN)
|
||||
.unwrap()
|
||||
.expect("HOP 4: run summary the list view renders");
|
||||
assert_eq!(summary.run_id, RUN, "HOP 4: API run summary runId");
|
||||
assert!(summary.event_count >= 1, "HOP 4: event_count rendered in the list");
|
||||
// The detail view renders these events in sequence order.
|
||||
let detail_events = server.storage.get_trace(RUN).unwrap();
|
||||
assert_eq!(
|
||||
detail_events.len() as i64,
|
||||
summary.event_count,
|
||||
"HOP 4: detail event count matches the roll-up the list shows"
|
||||
);
|
||||
|
||||
// ---- HOP 5: MCP resource resolves the same runId ----
|
||||
let read = make_request(
|
||||
"resources/read",
|
||||
Some(serde_json::json!({ "uri": format!("vestige://trace/{RUN}") })),
|
||||
);
|
||||
let read_resp = server.handle_request(read).await.unwrap();
|
||||
let text = read_resp.result.expect("resource read ok")["contents"][0]["text"]
|
||||
.as_str()
|
||||
.expect("resource text")
|
||||
.to_string();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(
|
||||
parsed["runId"].as_str(),
|
||||
Some(RUN),
|
||||
"HOP 5: vestige://trace/{{runId}} resolves the same runId"
|
||||
);
|
||||
assert!(
|
||||
parsed["events"].as_array().map(|a| !a.is_empty()).unwrap_or(false),
|
||||
"HOP 5: the resource returns the run's events"
|
||||
);
|
||||
|
||||
// ---- INVARIANT: one id, every hop, byte-identical ----
|
||||
// Collect the runId as seen at each hop and assert they are all equal.
|
||||
let seen = [
|
||||
structured["runId"].as_str().unwrap().to_string(), // hop 1
|
||||
events[0].run_id().to_string(), // hop 2
|
||||
ws_run.unwrap(), // hop 3
|
||||
summary.run_id, // hop 4
|
||||
parsed["runId"].as_str().unwrap().to_string(), // hop 5
|
||||
];
|
||||
assert!(
|
||||
seen.iter().all(|r| r == RUN),
|
||||
"the SAME runId must appear, unchanged, at every hop: {seen:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1045
crates/vestige-mcp/src/trace_recorder.rs
Normal file
1045
crates/vestige-mcp/src/trace_recorder.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue