atlas-iac/dockerfiles/hermes-webui-hux/memory/MemoryControlCenter.tsx
jenkins b66c762f5d hermes(webui): add HUX card UI models with contract-locked suites
Standalone per-card browser model/security/view modules for HUX-01..10
plus node+pytest suites that read the hux.v1 contract schemas directly.
Reconciled drift found on integration: the activity model now accepts
all 32 hux.event.v1 kinds (delegation.*, memory.suppressed,
memory.retrieval_removed, budget.exhausted, side_effect.*), the
autonomy model carries the external_side_effect capability, and the
foundation boundary test now asserts the shipped static HUX surface
exists on disk and that images never bake activated HUX_FLAGS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:12:03 -03:00

347 lines
9.6 KiB
TypeScript

/** Accessible HUX-02 ledger UI. No network request is made by this module. */
import { EyeOff, History, Pencil, ShieldCheck, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import {
MEMORY_TOPICS,
memoryControlEnabled,
normalizeMemoryPage,
} from "./model.ts";
import { isControlReference } from "./security.ts";
import type {
DoNotRememberTarget,
HuxIdentity,
MemoryItem,
MemoryTopic,
RawMemoryPage,
} from "./types.ts";
interface MemoryControlCenterProps {
flags?: Iterable<string>;
identity: HuxIdentity;
page: RawMemoryPage;
conversationId?: string;
messageId?: string;
busyId?: string;
onApprove?: (memoryId: string) => void;
onReject?: (memoryId: string) => void;
onEditAsNew?: (memoryId: string, content: string) => void;
onForget?: (memoryId: string) => void;
onOpenSourceMessage?: (memoryId: string) => void;
onDoNotRemember?: (target: DoNotRememberTarget) => void;
onRequireSuggestOnly?: () => void;
}
function LedgerActions({
item,
busy,
onApprove,
onReject,
onEdit,
onForget,
onOpenSource,
}: {
item: MemoryItem;
busy: boolean;
onApprove?: () => void;
onReject?: () => void;
onEdit?: () => void;
onForget?: () => void;
onOpenSource?: () => void;
}) {
if (item.status === "forgotten" || item.status === "expired") return null;
if (item.status === "proposed") {
return (
<div className="hux-memory-actions" aria-label="Approval choices">
{item.sourceIsMessage && onOpenSource && (
<button type="button" disabled={busy} onClick={onOpenSource}>
View source message
</button>
)}
<button type="button" disabled={busy} onClick={onApprove}>
Remember
</button>
<button type="button" disabled={busy} onClick={onReject}>
Reject
</button>
</div>
);
}
return (
<div className="hux-memory-actions">
{item.sourceIsMessage && onOpenSource && (
<button type="button" disabled={busy} onClick={onOpenSource}>
View source message
</button>
)}
{item.content !== null && (
<button type="button" disabled={busy} onClick={onEdit}>
<Pencil aria-hidden /> Edit as new entry
</button>
)}
<button type="button" disabled={busy} onClick={onForget}>
<Trash2 aria-hidden /> Delete &amp; forget
</button>
</div>
);
}
function MemoryCard({
item,
busy,
editing,
draft,
setDraft,
beginEdit,
cancelEdit,
saveEdit,
approve,
reject,
forget,
openSource,
}: {
item: MemoryItem;
busy: boolean;
editing: boolean;
draft: string;
setDraft: (value: string) => void;
beginEdit: () => void;
cancelEdit: () => void;
saveEdit: () => void;
approve: () => void;
reject: () => void;
forget: () => void;
openSource?: () => void;
}) {
return (
<li className="hux-memory-card" data-status={item.status}>
<header>
<span className="hux-memory-kind">{item.kind}</span>
<span className="hux-memory-status">{item.status}</span>
</header>
{item.content === null ? (
<p className="hux-memory-hidden">
<EyeOff aria-hidden /> {item.contentNotice}
</p>
) : (
<p className="hux-memory-content">{item.content}</p>
)}
<dl className="hux-memory-meta">
<div>
<dt>Why</dt><dd>{item.reason}</dd>
</div>
<div>
<dt>Source</dt><dd>{item.sourceLabel}</dd>
</div>
<div>
<dt>Recorded by</dt><dd>{item.provenanceLabel}</dd>
</div>
<div>
<dt>Scope</dt><dd>{item.scopeLabel}</dd>
</div>
<div>
<dt>Sensitivity</dt><dd>{item.sensitivity}</dd>
</div>
<div>
<dt>Approval</dt>
<dd>{item.approvalMode === "ask" ? "Ask first" : "Automatic (historical)"}</dd>
</div>
<div>
<dt>Retention</dt><dd>{item.ttlLabel}</dd>
</div>
<div>
<dt>Saved</dt>
<dd>
<time dateTime={item.savedAt}>
{new Date(item.savedAt).toLocaleString()}
</time>
</dd>
</div>
</dl>
{editing && (
<form
className="hux-memory-edit"
onSubmit={(event) => {
event.preventDefault();
saveEdit();
}}
>
<label htmlFor={`hux-memory-edit-${item.id}`}>Proposed replacement</label>
<textarea
id={`hux-memory-edit-${item.id}`}
value={draft}
maxLength={2000}
required
autoFocus
onChange={(event) => setDraft(event.target.value)}
/>
<p>
The original stays in the audit ledger. This replacement needs
approval.
</p>
<div className="hux-memory-actions">
<button type="submit" disabled={busy || !draft.trim()}>
Propose change
</button>
<button type="button" onClick={cancelEdit}>Cancel</button>
</div>
</form>
)}
{!editing && (
<LedgerActions
item={item}
busy={busy}
onApprove={approve}
onReject={reject}
onEdit={beginEdit}
onForget={forget}
onOpenSource={openSource}
/>
)}
</li>
);
}
/** Render nothing unless foundation, privacy, and memory-control are enabled. */
export function MemoryControlCenter(props: MemoryControlCenterProps) {
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [topic, setTopic] = useState<MemoryTopic>("general");
const enabled = memoryControlEnabled(props.flags);
const page = useMemo(
() => {
if (!enabled) return null;
try {
return normalizeMemoryPage(props.page, props.identity);
} catch {
return null;
}
},
[enabled, props.identity, props.page],
);
if (!enabled) return null;
if (!page) {
return (
<section className="hux-memory" role="alert">
Memory is unavailable because its workspace scope could not be verified.
</section>
);
}
function beginEdit(item: MemoryItem) {
setEditingId(item.id);
setDraft(item.content || "");
}
function saveEdit(item: MemoryItem) {
const value = draft.trim();
if (value) props.onEditAsNew?.(item.id, value);
setEditingId(null);
setDraft("");
}
return (
<section className="hux-memory" aria-labelledby="hux-memory-title">
<header className="hux-memory-heading">
<div>
<h2 id="hux-memory-title">
<History aria-hidden /> Memory
</h2>
<p>
An append-only record of what Hermes may use in future
conversations.
</p>
</div>
<div className="hux-memory-approval" role="status">
<ShieldCheck aria-hidden />
<span>Suggest only nothing new is saved without your approval.</span>
<button type="button" onClick={props.onRequireSuggestOnly}>Enforce</button>
</div>
</header>
<fieldset className="hux-memory-blocks">
<legend>Do not remember</legend>
{isControlReference(props.messageId) && (
<button
type="button"
onClick={() =>
props.onDoNotRemember?.({
kind: "message",
messageId: props.messageId as string,
})
}
>
This message
</button>
)}
{isControlReference(props.conversationId, "conv") && (
<button
type="button"
onClick={() =>
props.onDoNotRemember?.({
kind: "conversation",
conversationId: props.conversationId as string,
})
}
>
This conversation
</button>
)}
<label htmlFor="hux-memory-topic">Topic</label>
<select
id="hux-memory-topic"
value={topic}
onChange={(event) => setTopic(event.target.value as MemoryTopic)}
>
{MEMORY_TOPICS.map((value) => (
<option key={value} value={value}>{value}</option>
))}
</select>
<button
type="button"
onClick={() => props.onDoNotRemember?.({ kind: "topic", topic })}
>
Block topic
</button>
</fieldset>
{(page.invalid > 0 || page.truncated > 0) && (
<p className="hux-memory-warning" role="status">
Some entries were withheld because they were unsafe, out of scope, or
beyond this page.
</p>
)}
<ol className="hux-memory-ledger" aria-label="Memory ledger">
{page.entries.map((item) => (
<MemoryCard
key={item.id}
item={item}
busy={props.busyId === item.id}
editing={editingId === item.id}
draft={draft}
setDraft={setDraft}
beginEdit={() => beginEdit(item)}
cancelEdit={() => {
setEditingId(null);
setDraft("");
}}
saveEdit={() => saveEdit(item)}
approve={() => props.onApprove?.(item.id)}
reject={() => props.onReject?.(item.id)}
forget={() => props.onForget?.(item.id)}
openSource={
props.onOpenSourceMessage
? () => props.onOpenSourceMessage?.(item.id)
: undefined
}
/>
))}
</ol>
{page.entries.length === 0 && (
<p className="hux-memory-empty">Hermes has no memory entries to show.</p>
)}
</section>
);
}