mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-24 23:41:01 +02:00
Completes the launch-polish lane Codex started (rate-limited mid-wiring): Trust boundary (was: dashboard API/WS unauthenticated): - Token middleware on all /api/* and /ws, reusing the persisted Vestige auth token with constant-time compare (subtle); Sec-Fetch-Site + exact-Origin checks - Token delivered via URL fragment (never sent to server / logged), installed into localStorage on app load — WIRES installDashboardTokenFromLocation() into the root +layout onMount BEFORE websocket.connect()/any fetch (the missing piece that otherwise 401s the dashboard against its own API) Fail-closed memory governance: - Dashboard delete/suppress pre-gate through Memory PRs in Risk-Gated/Paranoid (Fast still mutates directly); decision-before-mutation; replay -> 409 - Duplicate pending-PR reuse; PR content redacted to preview+hash at the API layer Black Box / receipts: - Live refresh of run list + receipts on real TraceEvents (trace-before-receipt race handled via retry); synthetic events excluded from proof counters Fixes on top of Codex's work: - websocket.ts: source 'synthetic' -> `as const` (TS error, blocked pnpm check) - memories: scoped a11y-ignore on the non-interactive review-notice - collapsed sanitize_memory_pr_json (clippy collapsible_if); rustfmt the 7 touched Rust files Gates: pnpm check 0/0 (905 files); vestige-mcp 453 tests; core trace/review/ receipt 67 tests; clippy -D warnings clean. All green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
import type {
|
|
MemoryListResponse,
|
|
Memory,
|
|
SearchResult,
|
|
SystemStats,
|
|
HealthCheck,
|
|
TimelineResponse,
|
|
GraphResponse,
|
|
DreamResult,
|
|
ImportanceScore,
|
|
RetentionDistribution,
|
|
ConsolidationResult,
|
|
IntentionItem,
|
|
DeleteMemoryResult,
|
|
SuppressMemoryResult,
|
|
UnsuppressResult,
|
|
SanhedrinAppealReason,
|
|
SanhedrinAppealResponse,
|
|
SanhedrinLatestResponse,
|
|
SanhedrinTelemetryResponse
|
|
} from '$types';
|
|
|
|
const BASE = '/api';
|
|
const DASHBOARD_TOKEN_STORAGE_KEY = 'vestige.dashboard.token';
|
|
const DASHBOARD_TOKEN_HEADER = 'X-Vestige-Dashboard-Token';
|
|
|
|
async function fetcher<T>(path: string, options?: RequestInit): Promise<T> {
|
|
const headers = withDashboardAuthHeaders(options?.headers);
|
|
const res = await fetch(`${BASE}${path}`, {
|
|
...options,
|
|
headers
|
|
});
|
|
if (!res.ok) {
|
|
if (res.status === 401 || res.status === 403) {
|
|
throw new Error(
|
|
`API ${res.status}: dashboard auth failed. Open Vestige from the CLI or set VESTIGE_AUTH_TOKEN.`
|
|
);
|
|
}
|
|
throw new Error(`API ${res.status}: ${res.statusText}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
function withDashboardAuthHeaders(headers?: HeadersInit): Headers {
|
|
const next = new Headers(headers);
|
|
if (!next.has('Content-Type')) {
|
|
next.set('Content-Type', 'application/json');
|
|
}
|
|
const token = getDashboardAuthToken();
|
|
if (token) {
|
|
next.set(DASHBOARD_TOKEN_HEADER, token);
|
|
next.set('Authorization', `Bearer ${token}`);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
export function getDashboardAuthToken(): string | null {
|
|
if (typeof window === 'undefined') return null;
|
|
return window.localStorage.getItem(DASHBOARD_TOKEN_STORAGE_KEY);
|
|
}
|
|
|
|
export function installDashboardTokenFromLocation(): boolean {
|
|
if (typeof window === 'undefined') return false;
|
|
const hash = window.location.hash.startsWith('#')
|
|
? window.location.hash.slice(1)
|
|
: window.location.hash;
|
|
if (!hash) return false;
|
|
|
|
const params = new URLSearchParams(hash);
|
|
const token = params.get('vestige_token') ?? params.get('token');
|
|
if (!token) return false;
|
|
|
|
window.localStorage.setItem(DASHBOARD_TOKEN_STORAGE_KEY, token);
|
|
params.delete('vestige_token');
|
|
params.delete('token');
|
|
|
|
const remainingHash = params.toString();
|
|
const nextUrl = `${window.location.pathname}${window.location.search}${
|
|
remainingHash ? `#${remainingHash}` : ''
|
|
}`;
|
|
window.history.replaceState(null, document.title, nextUrl);
|
|
return true;
|
|
}
|
|
|
|
async function downloadJson(path: string, filename: string): Promise<void> {
|
|
const res = await fetch(`${BASE}${path}`, {
|
|
headers: withDashboardAuthHeaders()
|
|
});
|
|
if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`);
|
|
const blob = await res.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const anchor = document.createElement('a');
|
|
anchor.href = url;
|
|
anchor.download = filename;
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
export const api = {
|
|
// Memories
|
|
memories: {
|
|
list: (params?: Record<string, string>) => {
|
|
const qs = params ? '?' + new URLSearchParams(params).toString() : '';
|
|
return fetcher<MemoryListResponse>(`/memories${qs}`);
|
|
},
|
|
get: (id: string) => fetcher<Memory>(`/memories/${id}`),
|
|
delete: (id: string) => fetcher<DeleteMemoryResult>(`/memories/${id}`, { method: 'DELETE' }),
|
|
promote: (id: string) => fetcher<Memory>(`/memories/${id}/promote`, { method: 'POST' }),
|
|
demote: (id: string) => fetcher<Memory>(`/memories/${id}/demote`, { method: 'POST' }),
|
|
// v2.0.7: suppress + unsuppress. Anderson 2025 top-down inhibitory
|
|
// control. Each suppress call compounds; reversible within 24h. The
|
|
// backend emits MemorySuppressed / MemoryUnsuppressed so the 3D graph
|
|
// plays the violet implosion / rainbow reversal.
|
|
suppress: (id: string, reason?: string) =>
|
|
fetcher<SuppressMemoryResult>(`/memories/${id}/suppress`, {
|
|
method: 'POST',
|
|
body: reason ? JSON.stringify({ reason }) : undefined
|
|
}),
|
|
unsuppress: (id: string) =>
|
|
fetcher<UnsuppressResult>(`/memories/${id}/unsuppress`, { method: 'POST' })
|
|
},
|
|
|
|
// Search
|
|
search: (q: string, limit = 20) =>
|
|
fetcher<SearchResult>(`/search?q=${encodeURIComponent(q)}&limit=${limit}`),
|
|
|
|
// Stats & Health
|
|
stats: () => fetcher<SystemStats>('/stats'),
|
|
health: () => fetcher<HealthCheck>('/health'),
|
|
|
|
// Timeline
|
|
timeline: (days = 7, limit = 200) =>
|
|
fetcher<TimelineResponse>(`/timeline?days=${days}&limit=${limit}`),
|
|
|
|
// Graph
|
|
//
|
|
// `sort` controls the default center when no query/center_id is given:
|
|
// - "recent" (default) — newest memory; matches user expectation of
|
|
// "show me what I just added". Previously the backend defaulted to
|
|
// "connected" which clustered on historical hotspots and hid
|
|
// fresh memories that hadn't accumulated edges yet.
|
|
// - "connected" — densest node; richer initial subgraph for a
|
|
// well-aged corpus. Exposed for a future UI toggle.
|
|
graph: (params?: {
|
|
query?: string;
|
|
center_id?: string;
|
|
depth?: number;
|
|
max_nodes?: number;
|
|
sort?: 'recent' | 'connected';
|
|
}) => {
|
|
const qs = params ? '?' + new URLSearchParams(
|
|
Object.entries(params)
|
|
.filter(([, v]) => v !== undefined)
|
|
.map(([k, v]) => [k, String(v)])
|
|
).toString() : '';
|
|
return fetcher<GraphResponse>(`/graph${qs}`);
|
|
},
|
|
|
|
// Cognitive operations
|
|
dream: () => fetcher<DreamResult>('/dream', { method: 'POST' }),
|
|
|
|
explore: (fromId: string, action = 'associations', toId?: string, limit = 10) =>
|
|
fetcher<Record<string, unknown>>('/explore', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ from_id: fromId, action, to_id: toId, limit })
|
|
}),
|
|
|
|
predict: () => fetcher<Record<string, unknown>>('/predict', { method: 'POST' }),
|
|
|
|
importance: (content: string) =>
|
|
fetcher<ImportanceScore>('/importance', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ content })
|
|
}),
|
|
|
|
consolidate: () => fetcher<ConsolidationResult>('/consolidate', { method: 'POST' }),
|
|
|
|
retentionDistribution: () => fetcher<RetentionDistribution>('/retention-distribution'),
|
|
|
|
// Intentions
|
|
intentions: (status = 'active') =>
|
|
fetcher<{ intentions: IntentionItem[]; total: number; filter: string }>(`/intentions?status=${status}`),
|
|
|
|
// Reasoning Theater (v2.0.8): the 8-stage deep_reference cognitive pipeline.
|
|
// Returns a reasoning chain + evidence + contradictions + supersession +
|
|
// evolution + confidence. Emits DeepReferenceCompleted on the WebSocket so
|
|
// the 3D graph can camera-glide + pulse + arc.
|
|
deepReference: (query: string, depth = 20) =>
|
|
fetcher<Record<string, unknown>>('/deep_reference', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ query, depth })
|
|
}),
|
|
|
|
sanhedrin: {
|
|
latest: () => fetcher<SanhedrinLatestResponse>('/sanhedrin/latest'),
|
|
telemetry: (days = 7) => fetcher<SanhedrinTelemetryResponse>(`/sanhedrin/telemetry?days=${days}`),
|
|
appeal: (reason: SanhedrinAppealReason, note?: string, claimId?: string, receiptId?: string) =>
|
|
fetcher<SanhedrinAppealResponse>('/sanhedrin/appeal', {
|
|
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, runId?: string) => {
|
|
const qs = new URLSearchParams();
|
|
qs.set('limit', String(limit));
|
|
if (runId) qs.set('run', runId);
|
|
return fetcher<TraceRunListResponse>(`/traces?${qs.toString()}`);
|
|
},
|
|
get: (runId: string) => fetcher<TraceDetail>(`/traces/${encodeURIComponent(runId)}`),
|
|
exportUrl: (runId: string) => `${BASE}/traces/${encodeURIComponent(runId)}/export`,
|
|
download: (runId: string) =>
|
|
downloadJson(
|
|
`/traces/${encodeURIComponent(runId)}/export`,
|
|
`${runId}.vestige-trace.json`
|
|
)
|
|
},
|
|
|
|
// 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[];
|
|
};
|