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
347 lines
9.6 KiB
TypeScript
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 & 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>
|
|
);
|
|
}
|