/** Schema-aligned normalization and lineage invariants for HUX-03. */ import { isOpaqueId, isUtc, safeTags, safeText } from "./security.ts"; import type { ArtifactSummary, AttachmentView, Conversation, LineageNode, OrganizationSnapshot, Project, RawConversation, RawProject, } from "./types.ts"; export const FOUNDATION_FLAG = "hux.foundation"; export const PROJECTS_FLAG = "hux.projects"; const MODES = new Set(["fast", "thoughtful", "research", "create", "private"]); export function projectsEnabled(flags?: Iterable): boolean { const enabled = new Set(flags || []); return enabled.has(FOUNDATION_FLAG) && enabled.has(PROJECTS_FLAG); } export function normalizeProject(raw: RawProject, owner: string): Project | null { const tags = safeTags(raw.tags); if (raw.schema !== "hux.project.v1" || !isOpaqueId(raw.id, "prj") || raw.owner !== owner || tags === null || typeof raw.pinned !== "boolean" || typeof raw.archived !== "boolean" || !isUtc(raw.created_at) || !isUtc(raw.updated_at) || (raw.default_mode !== undefined && (typeof raw.default_mode !== "string" || !MODES.has(raw.default_mode)))) return null; const name = safeText(raw.name, "", 120); if (!name) return null; return { id: raw.id, owner, name, description: safeText(raw.description, "", 2000), tags, pinned: raw.pinned, archived: raw.archived, updatedAt: raw.updated_at, }; } function normalizeBranch(raw: unknown): Conversation["branch"] | undefined { if (raw === undefined) return null; if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; const value = raw as Record; if (!isOpaqueId(value.parent_conversation_id, "conv") || typeof value.branch_point_message_id !== "string") return undefined; const messageId = safeText(value.branch_point_message_id, "", 120); return messageId ? { parentConversationId: value.parent_conversation_id, branchPointMessageId: messageId, } : undefined; } export function normalizeConversation( raw: RawConversation, owner: string, projectIds: ReadonlySet, ): Conversation | null { const tags = safeTags(raw.tags); const branch = normalizeBranch(raw.branch); const artifacts = Array.isArray(raw.artifact_ids) ? raw.artifact_ids : null; const projectId = raw.project_id === undefined ? null : raw.project_id; if (raw.schema !== "hux.conversation.v1" || !isOpaqueId(raw.id, "conv") || raw.owner !== owner || tags === null || branch === undefined || typeof raw.pinned !== "boolean" || typeof raw.archived !== "boolean" || !artifacts || artifacts.length > 500 || new Set(artifacts).size !== artifacts.length || artifacts.some((id) => !isOpaqueId(id, "art")) || !isUtc(raw.created_at) || !isUtc(raw.updated_at) || (raw.last_message_at !== undefined && !isUtc(raw.last_message_at)) || (raw.mode !== undefined && (typeof raw.mode !== "string" || !MODES.has(raw.mode))) || (projectId !== null && (!isOpaqueId(projectId, "prj") || !projectIds.has(projectId)))) return null; const title = safeText(raw.title, "", 200); if (!title || branch?.parentConversationId === raw.id) return null; return { id: raw.id, owner, projectId, title, tags, pinned: raw.pinned, archived: raw.archived, artifactIds: [...artifacts] as string[], branch, lastMessageAt: raw.last_message_at as string | undefined || null, updatedAt: raw.updated_at, }; } /** Reject cross-owner/project records instead of silently re-homing them. */ export function normalizeSnapshot( rawProjects: readonly RawProject[], rawConversations: readonly RawConversation[], owner: string, ): OrganizationSnapshot { const seenProjects = new Set(); const projects = rawProjects.slice(0, 100).map((item) => normalizeProject(item, owner)) .filter((item): item is Project => { if (!item || seenProjects.has(item.id)) return false; seenProjects.add(item.id); return true; }); const projectIds = new Set(projects.map((item) => item.id)); const seenConversations = new Set(); const conversations = rawConversations.slice(0, 500) .map((item) => normalizeConversation(item, owner, projectIds)) .filter((item): item is Conversation => { if (!item || seenConversations.has(item.id)) return false; seenConversations.add(item.id); return true; }); return { projects, conversations, rejected: Math.max(0, rawProjects.length - projects.length) + Math.max(0, rawConversations.length - conversations.length), }; } /** Missing metadata stays visible as an attachment placeholder. */ export function attachmentViews( conversation: Conversation, summaries: readonly ArtifactSummary[], ): AttachmentView[] { const byId = new Map(summaries.filter((item) => isOpaqueId(item.id, "art")).slice(0, 500).map((item) => [item.id, item])); return conversation.artifactIds.map((id) => { const found = byId.get(id); return found ? { id, title: safeText(found.title, "Untitled artifact", 200), kind: safeText(found.kind, "artifact", 40), missing: false, } : {id, title: "Attachment details unavailable", kind: "artifact", missing: true}; }); } /** Build bounded ancestry with explicit missing/cycle nodes, never a fake root. */ export function branchLineage( selectedId: string, conversations: readonly Conversation[], ): LineageNode[] { if (!isOpaqueId(selectedId, "conv")) return []; const byId = new Map(conversations.slice(0, 500).map((item) => [item.id, item])); const result: LineageNode[] = []; const visited = new Set(); let currentId: string | null = selectedId; while (currentId && result.length < 64) { if (visited.has(currentId)) { result.push({conversationId: currentId, title: "Circular branch reference", parentConversationId: null, branchPointMessageId: null, depth: result.length, missing: false, cycle: true}); break; } visited.add(currentId); const item = byId.get(currentId); if (!item) { result.push({conversationId: currentId, title: "Earlier conversation unavailable", parentConversationId: null, branchPointMessageId: null, depth: result.length, missing: true, cycle: false}); break; } result.push({conversationId: item.id, title: item.title, parentConversationId: item.branch?.parentConversationId || null, branchPointMessageId: item.branch?.branchPointMessageId || null, depth: result.length, missing: false, cycle: false}); currentId = item.branch?.parentConversationId || null; } return result.reverse().map((item, depth) => ({...item, depth})); } /** A move/rename may not change stable lineage or orphan any attachment. */ export function preservesConversation(before: Conversation, after: Conversation): boolean { return before.id === after.id && before.owner === after.owner && JSON.stringify(before.branch) === JSON.stringify(after.branch) && before.artifactIds.length === after.artifactIds.length && before.artifactIds.every((id, index) => after.artifactIds[index] === id); }