/** Fail-closed normalization for the append-only HUX-02 memory ledger. */ import { MemoryContractError, isOpaqueMemoryId, isUtcTimestamp, normalizeIdentity, safeMemoryText, sameIdentity, } from "./security.ts"; import type { HuxIdentity, MemoryItem, MemoryKind, MemoryPage, MemoryStatus, MemoryTopic, RawMemoryEntry, RawMemoryPage, Sensitivity, } from "./types.ts"; export const FOUNDATION_FLAG = "hux.foundation"; export const PRIVACY_FLAG = "hux.privacy"; export const MEMORY_CONTROL_FLAG = "hux.memory_control"; const KINDS = new Set([ "preference", "fact", "instruction", "context", ]); const STATUSES = new Set([ "proposed", "active", "rejected", "expired", "forgotten", ]); const SENSITIVITIES = new Set([ "public", "personal", "sensitive", "restricted", ]); export const MEMORY_TOPICS = [ "general", "health", "finance", "legal", "relationships", "credentials", "minors", "location", "biometric", ] as const satisfies readonly MemoryTopic[]; const TOPICS = new Set(MEMORY_TOPICS); const SOURCE_LABELS: Readonly> = { message: "Source message", tool_call: "Tool request", tool_result: "Tool evidence", artifact_version: "Artifact version", source: "Research source", passage: "Supporting passage", memory: "Earlier memory entry", approval: "Approval decision", run: "Agent run", url: "Website", file: "Workspace file", build: "Build evidence", flux: "Flux reconciliation", pod: "Workload evidence", }; const MAX_ENTRIES = 200; export function memoryControlEnabled(flags?: Iterable): boolean { if (!flags) return false; const active = new Set(flags); return ( active.has(FOUNDATION_FLAG) && active.has(PRIVACY_FLAG) && active.has(MEMORY_CONTROL_FLAG) ); } function ttlLabel(raw: unknown): string | null { const ttl = raw as Record | null; if (!ttl || Array.isArray(ttl)) return null; if (ttl.policy === "never") return "Kept until you forget it"; if (ttl.policy === "expires_at" && isUtcTimestamp(ttl.expires_at)) { return `Expires ${ttl.expires_at}`; } if ( ttl.policy === "decay" && Number.isInteger(ttl.decay_days) && (ttl.decay_days as number) >= 1 && (ttl.decay_days as number) <= 3650 ) { return `Reviewed after ${ttl.decay_days} days`; } return null; } function scopeLabel(raw: unknown): string | null { const scope = raw as Record | null; if (!scope || Array.isArray(scope)) return null; if (scope.level === "global" && scope.scope_id === undefined) return "All conversations"; if (scope.level === "project" && typeof scope.scope_id === "string") return "This project"; if (scope.level === "conversation" && typeof scope.scope_id === "string") return "This conversation"; return null; } function reasonFor(raw: RawMemoryEntry, hidden: boolean): string { if (hidden) return "Reason hidden by privacy controls"; const audit = Array.isArray(raw.audit) ? raw.audit : []; for (let index = audit.length - 1; index >= 0; index -= 1) { const record = audit[index] as Record | null; if (record?.note) return safeMemoryText(record.note, "Saved for future help", 200); } return raw.status === "proposed" ? "Hermes suggested this for your approval" : "Saved for future help"; } function normalizeEntry( raw: RawMemoryEntry, expected: HuxIdentity, ): MemoryItem | null { if ( raw.schema !== "hux.memory.v1" || !isOpaqueMemoryId(raw.id) || raw.owner !== expected.userRef || !KINDS.has(raw.kind as MemoryKind) || !STATUSES.has(raw.status as MemoryStatus) || !SENSITIVITIES.has(raw.sensitivity as Sensitivity) || !TOPICS.has((raw.topic ?? "general") as MemoryTopic) || !["automatic", "ask"].includes(String(raw.approval_mode)) || typeof raw.content !== "string" || raw.content.length > 2000 || !Array.isArray(raw.audit) || raw.audit.length < 1 || !isUtcTimestamp(raw.created_at) || !isUtcTimestamp(raw.updated_at) ) { return null; } const provenance = raw.provenance as Record | null; const source = raw.source as Record | null; const ttl = ttlLabel(raw.ttl); const scope = scopeLabel(raw.scope); if ( !provenance || provenance.surface !== expected.surface || !isUtcTimestamp(provenance.recorded_at) || !source || typeof source.id !== "string" || source.id.length < 1 || source.id.length > 200 || typeof source.kind !== "string" || !(source.kind in SOURCE_LABELS) || !ttl || !scope ) { return null; } const actor = provenance.actor as Record | null; if ( !actor || !["user", "assistant", "tool", "system", "operator"].includes(String(actor.type)) || typeof actor.id !== "string" || actor.id.length < 1 || actor.id.length > 120 ) return null; const status = raw.status as MemoryStatus; const sensitivity = raw.sensitivity as Sensitivity; const hidden = status === "forgotten" || sensitivity === "sensitive" || sensitivity === "restricted"; return { id: raw.id, kind: raw.kind as MemoryKind, content: hidden ? null : safeMemoryText(raw.content, "Memory content unavailable", 2000), contentNotice: status === "forgotten" ? "Content permanently removed" : hidden ? "Sensitive value hidden by privacy controls" : null, status, sensitivity, topic: (raw.topic ?? "general") as MemoryTopic, approvalMode: raw.approval_mode as "automatic" | "ask", scopeLabel: scope, sourceLabel: SOURCE_LABELS[source.kind], sourceIsMessage: source.kind === "message", provenanceLabel: `${String(actor.type)} via ${expected.surface}`, reason: reasonFor(raw, hidden), savedAt: raw.created_at, updatedAt: raw.updated_at, ttlLabel: ttl, }; } export function normalizeMemoryPage( raw: RawMemoryPage, expectedIdentity: HuxIdentity, ): MemoryPage { const expected = normalizeIdentity(expectedIdentity); if (!raw || typeof raw !== "object" || raw.schema !== "hux.memory_page.v1") { throw new MemoryContractError("Memory response version is unsupported"); } const actual = normalizeIdentity(raw.identity); if (!sameIdentity(actual, expected)) { throw new MemoryContractError("Memory response crossed a scope boundary"); } if (!Array.isArray(raw.entries)) { throw new MemoryContractError("Memory ledger entries are missing"); } const records = raw.entries; const normalized = records .map((entry) => normalizeEntry(entry as RawMemoryEntry, expected)) .filter((entry): entry is MemoryItem => entry !== null) .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); const truncated = Math.max(0, normalized.length - MAX_ENTRIES); return { entries: truncated ? normalized.slice(0, MAX_ENTRIES) : normalized, invalid: records.length - normalized.length, truncated, nextCursor: typeof raw.next_cursor === "string" && raw.next_cursor.length <= 200 ? raw.next_cursor : null, }; }