/** 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> = { 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): 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 => 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), }; }