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
307 lines
8.5 KiB
TypeScript
307 lines
8.5 KiB
TypeScript
/** Normalization, replay safety, and surface policy for HUX-01. */
|
|
|
|
import {
|
|
isOpaqueId,
|
|
isRfc3339Utc,
|
|
safeEvidenceLabel,
|
|
safeText,
|
|
} from "./security.ts";
|
|
import type {
|
|
ActivityItem,
|
|
ActivityPhase,
|
|
ActivityTone,
|
|
CancellationReceipt,
|
|
HuxSurface,
|
|
MergeResult,
|
|
RawActivityEvent,
|
|
RawCancellationReceipt,
|
|
SurfacePolicy,
|
|
} from "./types.ts";
|
|
|
|
export const ACTIVITY_TIMELINE_FLAG = "hux.activity_timeline";
|
|
export const FOUNDATION_FLAG = "hux.foundation";
|
|
|
|
const EVENT_KINDS = new Set([
|
|
"message.user",
|
|
"message.assistant",
|
|
"decision.route",
|
|
"decision.plan",
|
|
"tool.call",
|
|
"tool.result",
|
|
"approval.requested",
|
|
"approval.resolved",
|
|
"memory.proposed",
|
|
"memory.committed",
|
|
"memory.forgotten",
|
|
"artifact.created",
|
|
"artifact.version",
|
|
"artifact.promoted",
|
|
"citation.attached",
|
|
"mode.changed",
|
|
"run.started",
|
|
"run.cancelled",
|
|
"run.completed",
|
|
"run.failed",
|
|
"privacy.notice",
|
|
"suggestion.shown",
|
|
"suggestion.dismissed",
|
|
"release.transition",
|
|
"delegation.started",
|
|
"delegation.completed",
|
|
"delegation.failed",
|
|
"memory.suppressed",
|
|
"memory.retrieval_removed",
|
|
"budget.exhausted",
|
|
"side_effect.blocked",
|
|
"side_effect.released",
|
|
]);
|
|
|
|
const SURFACE_POLICIES: Readonly<Record<HuxSurface, SurfacePolicy>> = {
|
|
chat: {
|
|
maxItems: 80,
|
|
initialItems: 12,
|
|
evidenceLimit: 3,
|
|
cancellationEffectLimit: 4,
|
|
expandRoutineEvents: false,
|
|
},
|
|
worker: {
|
|
maxItems: 250,
|
|
initialItems: 40,
|
|
evidenceLimit: 8,
|
|
cancellationEffectLimit: 12,
|
|
expandRoutineEvents: true,
|
|
},
|
|
telegram: {
|
|
maxItems: 40,
|
|
initialItems: 8,
|
|
evidenceLimit: 2,
|
|
cancellationEffectLimit: 3,
|
|
expandRoutineEvents: false,
|
|
},
|
|
voice: {
|
|
maxItems: 24,
|
|
initialItems: 6,
|
|
evidenceLimit: 1,
|
|
cancellationEffectLimit: 2,
|
|
expandRoutineEvents: false,
|
|
},
|
|
api: {
|
|
maxItems: 250,
|
|
initialItems: 40,
|
|
evidenceLimit: 8,
|
|
cancellationEffectLimit: 12,
|
|
expandRoutineEvents: true,
|
|
},
|
|
};
|
|
|
|
export function surfacePolicy(surface: HuxSurface): SurfacePolicy {
|
|
return { ...SURFACE_POLICIES[surface] };
|
|
}
|
|
|
|
export function activityTimelineEnabled(flags?: Iterable<string>): boolean {
|
|
if (!flags) return false;
|
|
const enabled = new Set(flags);
|
|
return enabled.has(FOUNDATION_FLAG) && enabled.has(ACTIVITY_TIMELINE_FLAG);
|
|
}
|
|
|
|
function phaseFor(kind: string, hasParent: boolean): ActivityPhase {
|
|
if (kind === "message.user") return "intent";
|
|
if (
|
|
kind === "run.failed" ||
|
|
kind === "delegation.failed" ||
|
|
kind === "budget.exhausted"
|
|
) {
|
|
return "failure";
|
|
}
|
|
if (
|
|
kind === "run.completed" ||
|
|
kind === "run.cancelled" ||
|
|
kind === "message.assistant" ||
|
|
kind === "delegation.completed"
|
|
) {
|
|
return "completion";
|
|
}
|
|
if (kind === "delegation.started") return "delegation";
|
|
if (
|
|
kind.startsWith("decision.") ||
|
|
kind.startsWith("approval.") ||
|
|
kind === "mode.changed" ||
|
|
kind === "privacy.notice" ||
|
|
kind === "memory.suppressed" ||
|
|
kind === "memory.retrieval_removed" ||
|
|
kind === "side_effect.blocked"
|
|
)
|
|
return "decision";
|
|
if (
|
|
kind === "tool.result" ||
|
|
kind === "citation.attached" ||
|
|
kind === "artifact.version"
|
|
) {
|
|
return "evidence";
|
|
}
|
|
if (kind === "run.started" && hasParent) return "delegation";
|
|
return "action";
|
|
}
|
|
|
|
function toneFor(phase: ActivityPhase, kind: string): ActivityTone {
|
|
if (phase === "failure") return "warning";
|
|
if (phase === "completion") return "success";
|
|
if (kind === "approval.requested") return "warning";
|
|
if (phase === "action" || phase === "delegation") return "active";
|
|
return "neutral";
|
|
}
|
|
|
|
function hiddenSummary(kind: string): string {
|
|
if (kind === "tool.call") return "Hermes used a tool";
|
|
if (kind === "tool.result") return "Tool evidence was recorded";
|
|
if (kind === "run.failed")
|
|
return "The run failed; sensitive detail is hidden";
|
|
if (kind === "run.completed") return "The run completed";
|
|
return "Activity recorded; sensitive detail is hidden";
|
|
}
|
|
|
|
function normalizeEvent(
|
|
raw: RawActivityEvent,
|
|
conversationId: string,
|
|
policy: SurfacePolicy,
|
|
): ActivityItem | null {
|
|
if (raw.schema !== "hux.event.v1" || !isOpaqueId(raw.id, "evt")) return null;
|
|
if (!Number.isInteger(raw.seq) || (raw.seq as number) < 0) return null;
|
|
if (!isRfc3339Utc(raw.ts) || raw.conversation_id !== conversationId)
|
|
return null;
|
|
if (typeof raw.kind !== "string" || !EVENT_KINDS.has(raw.kind)) return null;
|
|
const redaction = raw.redaction as { level?: unknown } | null;
|
|
const fullyHidden =
|
|
raw.sensitivity === "restricted" || redaction?.level === "full";
|
|
const phase = phaseFor(raw.kind, typeof raw.parent_event_id === "string");
|
|
const references = Array.isArray(raw.evidence) ? raw.evidence : [];
|
|
const evidence = references
|
|
.map((item) => safeEvidenceLabel((item as { kind?: unknown } | null)?.kind))
|
|
.filter((item): item is NonNullable<typeof item> => item !== null)
|
|
.slice(0, policy.evidenceLimit);
|
|
return {
|
|
id: raw.id,
|
|
seq: raw.seq as number,
|
|
timestamp: raw.ts,
|
|
conversationId,
|
|
runId:
|
|
typeof raw.run_id === "string"
|
|
? safeText(raw.run_id, "", 120) || undefined
|
|
: undefined,
|
|
kind: raw.kind,
|
|
phase,
|
|
tone: toneFor(phase, raw.kind),
|
|
summary: fullyHidden
|
|
? hiddenSummary(raw.kind)
|
|
: safeText(raw.summary, hiddenSummary(raw.kind)),
|
|
evidence,
|
|
routine: phase === "action" || phase === "evidence",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Merge reconnect pages without duplicates. Existing sequence owners win;
|
|
* conflicting replay entries are rejected instead of rewriting visible history.
|
|
*/
|
|
export function mergeActivityEvents(
|
|
existing: readonly ActivityItem[],
|
|
incoming: readonly RawActivityEvent[],
|
|
conversationId: string,
|
|
surface: HuxSurface,
|
|
): MergeResult {
|
|
const policy = surfacePolicy(surface);
|
|
const byId = new Map(existing.map((item) => [item.id, item]));
|
|
const bySeq = new Map(existing.map((item) => [item.seq, item]));
|
|
let invalid = 0;
|
|
let replays = 0;
|
|
let conflicts = 0;
|
|
const normalized: ActivityItem[] = [];
|
|
for (const raw of incoming) {
|
|
const item = normalizeEvent(raw, conversationId, policy);
|
|
if (!item) {
|
|
invalid += 1;
|
|
continue;
|
|
}
|
|
normalized.push(item);
|
|
}
|
|
// A reconnect page is not required to preserve transport order. Sorting
|
|
// before conflict resolution makes an initial replay deterministic.
|
|
normalized.sort(
|
|
(left, right) => left.seq - right.seq || left.id.localeCompare(right.id),
|
|
);
|
|
for (const item of normalized) {
|
|
const priorId = byId.get(item.id);
|
|
if (priorId) {
|
|
if (priorId.seq === item.seq) replays += 1;
|
|
else conflicts += 1;
|
|
continue;
|
|
}
|
|
if (bySeq.has(item.seq)) {
|
|
conflicts += 1;
|
|
continue;
|
|
}
|
|
byId.set(item.id, item);
|
|
bySeq.set(item.seq, item);
|
|
}
|
|
const ordered = [...byId.values()].sort(
|
|
(left, right) => left.seq - right.seq || left.id.localeCompare(right.id),
|
|
);
|
|
const afterSeq = ordered.length ? ordered[ordered.length - 1].seq : -1;
|
|
const truncated = Math.max(0, ordered.length - policy.maxItems);
|
|
return {
|
|
items: truncated ? ordered.slice(-policy.maxItems) : ordered,
|
|
afterSeq,
|
|
invalid,
|
|
replays,
|
|
conflicts,
|
|
truncated,
|
|
};
|
|
}
|
|
|
|
export function normalizeCancellationReceipt(
|
|
raw: RawCancellationReceipt,
|
|
surface: HuxSurface,
|
|
): CancellationReceipt | null {
|
|
const outcomes = new Set([
|
|
"cancelled",
|
|
"already_complete",
|
|
"failed_to_cancel",
|
|
]);
|
|
if (raw.schema !== "hux.cancel_receipt.v1" || !isOpaqueId(raw.id, "rcpt"))
|
|
return null;
|
|
if (typeof raw.run_id !== "string" || !isRfc3339Utc(raw.requested_at))
|
|
return null;
|
|
if (typeof raw.outcome !== "string" || !outcomes.has(raw.outcome))
|
|
return null;
|
|
const policy = surfacePolicy(surface);
|
|
const effects = Array.isArray(raw.side_effects) ? raw.side_effects : [];
|
|
const sideEffects = effects
|
|
.slice(0, policy.cancellationEffectLimit)
|
|
.map((value) => {
|
|
const effect = value as {
|
|
description?: unknown;
|
|
reverted?: unknown;
|
|
} | null;
|
|
return {
|
|
description: safeText(
|
|
effect?.description,
|
|
"A side effect was recorded",
|
|
280,
|
|
),
|
|
reverted: effect?.reverted === true,
|
|
};
|
|
});
|
|
return {
|
|
id: raw.id,
|
|
runId: safeText(raw.run_id, "unknown run", 120),
|
|
requestedAt: raw.requested_at,
|
|
acknowledgedAt: isRfc3339Utc(raw.acknowledged_at)
|
|
? raw.acknowledged_at
|
|
: undefined,
|
|
completedAt: isRfc3339Utc(raw.completed_at) ? raw.completed_at : undefined,
|
|
outcome: raw.outcome as CancellationReceipt["outcome"],
|
|
sideEffects,
|
|
omittedEffects: Math.max(0, effects.length - sideEffects.length),
|
|
};
|
|
}
|