264 lines
18 KiB
JavaScript

/* node:coverage disable */
(function (root, factory) {
'use strict';
const waveA = typeof module === 'object' && module.exports ? require('./wave_a_contract.js') : root.HermesHuxWaveAContract;
const api = factory(waveA);
if (typeof module === 'object' && module.exports) module.exports = api;
else root.HermesHuxWaveBContract = api;
}(typeof globalThis === 'object' ? globalThis : this, function (waveA) {
/* node:coverage enable */
'use strict';
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
const MESSAGE = /^[A-Za-z0-9._:-]{1,120}$/;
const SHA = /^sha256:[0-9a-f]{64}$/;
const TAG = /^[a-z0-9][a-z0-9-]{0,39}$/;
const TYPES = new Set(['markdown', 'code', 'html', 'svg', 'image', 'json', 'csv', 'document', 'audio']);
const TEXT_TYPES = new Set(['markdown', 'code', 'html', 'svg', 'json', 'csv']);
const MODES = Object.freeze([
Object.freeze({id: 'fast', label: 'Fast', description: 'Prioritize a quick, direct response.'}),
Object.freeze({id: 'thoughtful', label: 'Thoughtful', description: 'Allow more analysis before answering.'}),
Object.freeze({id: 'research', label: 'Research', description: 'Use evidence and require citations.'}),
Object.freeze({id: 'create', label: 'Create', description: 'Prioritize durable creative output.'}),
Object.freeze({id: 'private', label: 'Private', description: 'Use ephemeral retention and no memory writes.'}),
]);
const MODE_IDS = new Set(MODES.map((mode) => mode.id));
class WaveBContractError extends Error {
constructor(message) { super(message); this.name = 'WaveBContractError'; }
}
function object(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : null; }
function need(condition, message) { if (!condition) throw new WaveBContractError(message); }
function integer(value, min, max) { return Number.isSafeInteger(value) && value >= min && value <= max; }
function utc(value) { return typeof value === 'string' && UTC.test(value) && !Number.isNaN(Date.parse(value)); }
function id(value, prefix) { return waveA.isId(value, prefix); }
function text(value, fallback, limit) { return waveA.safeText(value, fallback, limit); }
function unique(values, max, validator, label) {
need(Array.isArray(values) && values.length <= max && values.every(validator) &&
new Set(values).size === values.length, `Invalid ${label}`);
return values.slice();
}
function page(raw, limit, label) {
need(object(raw) && Array.isArray(raw.items) && raw.items.length <= limit &&
(raw.next === null || typeof raw.next === 'string'), `Invalid ${label} page`);
return raw.items;
}
function revision(raw) { need(integer(raw.revision, 1, Number.MAX_SAFE_INTEGER), 'Invalid revision'); return raw.revision; }
function timestamps(raw) { need(utc(raw.created_at) && utc(raw.updated_at) && raw.updated_at >= raw.created_at, 'Invalid timestamps'); }
function owner(raw, expected) { need(raw.owner === expected.userRef, 'Record crossed the user boundary'); }
function tags(raw) { return unique(raw, 32, (item) => typeof item === 'string' && TAG.test(item), 'tags'); }
function normalizeProject(raw, identity, expectedId) {
need(object(raw) && raw.schema === 'hux.project.v1' && id(raw.id, 'prj') &&
(!expectedId || raw.id === expectedId), 'Invalid project');
owner(raw, identity); timestamps(raw); revision(raw);
need(typeof raw.name === 'string' && raw.name.trim() && raw.name.length <= 120 &&
typeof raw.pinned === 'boolean' && typeof raw.archived === 'boolean' &&
(raw.default_mode === undefined || MODE_IDS.has(raw.default_mode)), 'Invalid project fields');
return Object.freeze({id: raw.id, name: text(raw.name, 'Untitled project', 120),
description: text(raw.description, '', 500), tags: tags(raw.tags), pinned: raw.pinned,
archived: raw.archived, defaultMode: raw.default_mode || null, revision: raw.revision,
updatedAt: raw.updated_at});
}
function normalizeConversation(raw, identity, projectId) {
need(object(raw) && raw.schema === 'hux.conversation.v1' && id(raw.id, 'conv') &&
raw.project_id === projectId, 'Conversation crossed the project boundary');
owner(raw, identity); timestamps(raw); revision(raw);
need(typeof raw.title === 'string' && raw.title.trim() && raw.title.length <= 200 &&
typeof raw.pinned === 'boolean' && typeof raw.archived === 'boolean' &&
(raw.mode === undefined || MODE_IDS.has(raw.mode)), 'Invalid conversation fields');
const artifactIds = unique(raw.artifact_ids, 500, (item) => id(item, 'art'), 'artifact references');
let branch = null;
if (raw.branch !== undefined) {
need(object(raw.branch) && id(raw.branch.parent_conversation_id, 'conv') &&
MESSAGE.test(raw.branch.branch_point_message_id), 'Invalid conversation branch');
branch = Object.freeze({parentId: raw.branch.parent_conversation_id,
pointMessageId: raw.branch.branch_point_message_id});
}
return Object.freeze({id: raw.id, projectId, title: text(raw.title, 'Untitled conversation', 200),
tags: tags(raw.tags), pinned: raw.pinned, archived: raw.archived, mode: raw.mode || null,
branch, artifactIds, revision: raw.revision, updatedAt: raw.updated_at});
}
function normalizeConversationPage(raw, identity, projectId) {
const items = page(raw, 2000, 'conversation').map((item) => normalizeConversation(item, identity, projectId));
need(new Set(items.map((item) => item.id)).size === items.length, 'Duplicate conversations');
return Object.freeze(items);
}
function normalizeLineage(raw, identity, projectId, conversationId) {
need(object(raw) && Array.isArray(raw.ancestors) && raw.ancestors.length <= 64 &&
Array.isArray(raw.children) && raw.children.length <= 200, 'Invalid lineage');
const current = normalizeConversation(raw.conversation, identity, projectId);
need(current.id === conversationId, 'Lineage crossed the conversation boundary');
const ancestors = raw.ancestors.map((item) => normalizeConversation(item, identity, projectId));
const children = raw.children.map((item) => normalizeConversation(item, identity, projectId));
need(new Set([...ancestors, current, ...children].map((item) => item.id)).size ===
ancestors.length + children.length + 1, 'Lineage contains duplicate records');
return Object.freeze({conversation: current, ancestors: Object.freeze(ancestors), children: Object.freeze(children)});
}
function normalizeSearch(raw, identity, projectId) {
const items = normalizeConversationPage(raw, identity, projectId);
need(Array.isArray(raw.indexed) && Array.isArray(raw.not_indexed) && raw.indexed.every((item) =>
['title', 'tags', 'project_name', 'artifact_titles'].includes(item)) &&
raw.not_indexed.includes('message_text'), 'Search scope is not explicit');
return Object.freeze({items, indexed: Object.freeze(raw.indexed.slice()), messageTextIndexed: false});
}
function normalizeVersion(raw) {
need(object(raw) && integer(raw.version, 1, 200) && utc(raw.created_at) && object(raw.created_by) &&
['user', 'assistant', 'tool', 'system', 'operator'].includes(raw.created_by.type) &&
typeof raw.created_by.id === 'string' && raw.created_by.id.length <= 120 && object(raw.content_ref) &&
SHA.test(raw.content_ref.hash) && integer(raw.content_ref.bytes, 0, 25 * 1024 * 1024) &&
typeof raw.content_ref.mime === 'string' && raw.content_ref.mime.length <= 120, 'Invalid artifact version');
let lineage = null;
if (raw.lineage !== undefined) {
need(object(raw.lineage) && id(raw.lineage.artifact_id, 'art') &&
integer(raw.lineage.version, 1, 200), 'Invalid artifact lineage');
lineage = Object.freeze({artifactId: raw.lineage.artifact_id, version: raw.lineage.version});
}
return Object.freeze({version: raw.version, createdAt: raw.created_at, creator: raw.created_by.type,
bytes: raw.content_ref.bytes, mime: text(raw.content_ref.mime, 'application/octet-stream', 120),
note: text(raw.note, '', 200), diffFrom: integer(raw.diff_from, 1, 200) ? raw.diff_from : null, lineage});
}
function normalizeArtifact(raw, identity, conversationId, projectId) {
need(object(raw) && raw.schema === 'hux.artifact.v1' && id(raw.id, 'art') &&
raw.conversation_id === conversationId && (raw.project_id === undefined || raw.project_id === projectId) &&
TYPES.has(raw.type) && typeof raw.title === 'string' && raw.title.trim() && raw.title.length <= 200 &&
['public', 'personal', 'sensitive', 'restricted'].includes(raw.sensitivity) &&
object(raw.access) && raw.access.mode === 'owner', 'Artifact crossed its workspace boundary');
owner(raw, identity); timestamps(raw); revision(raw);
need(Array.isArray(raw.versions) && raw.versions.length >= 1 && raw.versions.length <= 200, 'Invalid artifact history');
const versions = raw.versions.map(normalizeVersion);
need(versions.every((entry, index) => entry.version === index + 1) &&
raw.current_version === versions.length, 'Artifact history is not immutable and sequential');
return Object.freeze({id: raw.id, conversationId, projectId: raw.project_id || null, type: raw.type,
title: text(raw.title, 'Untitled artifact', 200), sensitivity: raw.sensitivity,
currentVersion: raw.current_version, versions: Object.freeze(versions), revision: raw.revision,
promoted: Boolean(object(raw.promotion) && raw.promotion.project_id === projectId &&
integer(raw.promotion.version, 1, raw.current_version)), updatedAt: raw.updated_at});
}
function normalizeArtifactPage(raw, identity, conversationId, projectId) {
const items = page(raw, 50, 'artifact').map((item) => normalizeArtifact(item, identity, conversationId, projectId));
need(new Set(items.map((item) => item.id)).size === items.length, 'Duplicate artifacts');
return Object.freeze({items: Object.freeze(items), next: raw.next});
}
function normalizeArtifactContent(raw, artifact, number) {
need(object(raw) && raw.artifact_id === artifact.id && raw.type === artifact.type &&
object(raw.version) && raw.version.version === number, 'Artifact content crossed its version boundary');
const version = normalizeVersion(raw.version);
need((typeof raw.content === 'string') !== (typeof raw.content_base64 === 'string') &&
(raw.content === undefined || TEXT_TYPES.has(artifact.type)), 'Unsafe artifact content envelope');
if (raw.content === undefined || artifact.sensitivity === 'restricted') {
return Object.freeze({version, preview: null, reason: artifact.sensitivity === 'restricted' ?
'Preview hidden because this artifact is restricted.' : 'Binary content stays attachment-only.'});
}
return Object.freeze({version, preview: text(raw.content, '', 20000),
reason: raw.content.length > 20000 ? 'Preview truncated at 20,000 characters.' : ''});
}
function normalizeDiff(raw, from, to) {
need(object(raw) && raw.from === from && raw.to === to &&
(typeof raw.unified === 'string') !== Boolean(object(raw.binary)), 'Invalid artifact diff');
if (typeof raw.unified === 'string') {
const lines = raw.unified.slice(0, 20000).split('\n').map((line) => text(line, '', 1000));
return Object.freeze({text: lines.join('\n'), binary: false});
}
need(integer(raw.binary.from_bytes, 0, 25 * 1024 * 1024) &&
integer(raw.binary.to_bytes, 0, 25 * 1024 * 1024), 'Invalid binary diff');
return Object.freeze({text: `${raw.binary.from_bytes} bytes → ${raw.binary.to_bytes} bytes`, binary: true});
}
function safeSourceUrl(value) {
if (typeof value !== 'string' || value.length > 2000) return null;
let parsed;
try { parsed = new URL(value); } catch (_) { return null; }
if (parsed.protocol !== 'https:' || !parsed.hostname || parsed.username || parsed.password) return null;
const sensitive = /^(?:access_?token|api_?key|auth|authorization|code|credential|key|password|secret|sig|signature|token)$/i;
if ([...parsed.searchParams.keys()].some((key) => sensitive.test(key))) return null;
parsed.hash = '';
return parsed.toString();
}
function isTextType(value) { return TEXT_TYPES.has(value); }
function normalizeSource(raw, conversationId) {
need(object(raw) && raw.schema === 'hux.source.v1' && id(raw.id, 'src') &&
['web', 'document', 'artifact', 'memory', 'tool_output', 'dataset'].includes(raw.kind) &&
typeof raw.title === 'string' && raw.title.trim() && raw.title.length <= 300 &&
['primary', 'secondary', 'unknown'].includes(raw.classification) && utc(raw.retrieved_at), 'Invalid source');
if (object(raw.provenance) && raw.provenance.conversation_id !== undefined) {
need(raw.provenance.conversation_id === conversationId, 'Source crossed the conversation boundary');
}
return Object.freeze({id: raw.id, title: text(raw.title, 'Untitled source', 300), kind: raw.kind,
classification: raw.classification, publisher: text(raw.publisher, '', 200),
publishedAt: utc(raw.published_at) ? raw.published_at : null, url: safeSourceUrl(raw.uri)});
}
function normalizeCitationPage(raw, messageId, conversationId) {
const items = page(raw, 200, 'citation').map((bundle) => {
need(object(bundle) && object(bundle.citation) && Array.isArray(bundle.passages) &&
Array.isArray(bundle.sources), 'Invalid citation bundle');
const citation = bundle.citation;
need(citation.schema === 'hux.citation.v1' && id(citation.id, 'cit') && citation.message_id === messageId &&
typeof citation.claim === 'string' && citation.claim.trim() && citation.claim.length <= 1000 &&
['supports', 'partially_supports', 'contradicts', 'unverified'].includes(citation.support), 'Citation crossed the message boundary');
const passageIds = unique(citation.passage_ids, 32, (item) => id(item, 'psg'), 'citation passages');
need(passageIds.length > 0, 'Citation needs evidence');
const sources = bundle.sources.map((source) => normalizeSource(source, conversationId));
const sourceMap = new Map(sources.map((source) => [source.id, source]));
const passages = bundle.passages.map((passage) => {
need(object(passage) && passage.schema === 'hux.passage.v1' && id(passage.id, 'psg') &&
passageIds.includes(passage.id) && id(passage.source_id, 'src') && sourceMap.has(passage.source_id) &&
typeof passage.text === 'string' && passage.text.trim() && passage.text.length <= 4000,
'Invalid supporting passage');
return Object.freeze({id: passage.id, sourceId: passage.source_id,
text: text(passage.text, 'Passage unavailable.', 4000)});
});
need(passages.length === passageIds.length && new Set(passages.map((item) => item.id)).size === passages.length,
'Citation evidence is incomplete');
return Object.freeze({id: citation.id, messageId, claim: text(citation.claim, 'Claim unavailable.', 1000),
support: citation.support, note: text(citation.note, '', 500),
passages: Object.freeze(passages), sources: Object.freeze(sources)});
});
need(new Set(items.map((item) => item.id)).size === items.length, 'Duplicate citations');
return Object.freeze(items);
}
function normalizeNotebook(raw, conversationId, etag) {
need(object(raw) && raw.schema === 'hux.research_notebook.v1' && id(raw.id, 'nb') &&
raw.conversation_id === conversationId && typeof raw.question === 'string' && raw.question.trim() &&
raw.question.length <= 1000 && ['open', 'answered', 'abandoned'].includes(raw.status) &&
utc(raw.updated_at), 'Notebook crossed the conversation boundary');
const rev = revision(raw);
need(String(etag || '').replaceAll('"', '') === String(rev), 'Notebook ETag does not match its revision');
const sourceIds = unique(raw.source_ids, 500, (item) => id(item, 'src'), 'notebook sources');
const passageIds = unique(raw.passage_ids, 500, (item) => id(item, 'psg'), 'notebook passages');
const citationIds = unique(raw.citation_ids, 500, (item) => id(item, 'cit'), 'notebook citations');
const assumptions = unique(raw.assumptions, 64, (item) => typeof item === 'string' && item.trim() && item.length <= 500, 'assumptions');
const questions = unique(raw.unresolved_questions, 64, (item) => typeof item === 'string' && item.trim() && item.length <= 500, 'questions');
need(Array.isArray(raw.notes) && raw.notes.length <= 128, 'Invalid notebook notes');
const notes = raw.notes.map((note) => {
need(object(note) && utc(note.at) && typeof note.text === 'string' && note.text.trim() &&
note.text.length <= 2000 && (note.source_id === undefined || id(note.source_id, 'src')),
'Invalid notebook note');
return Object.freeze({at: note.at, text: text(note.text, 'Note unavailable.', 2000), sourceId: note.source_id || null});
});
return Object.freeze({id: raw.id, conversationId, question: text(raw.question, 'Research question unavailable.', 1000),
status: raw.status, sourceIds: Object.freeze(sourceIds), passageIds: Object.freeze(passageIds),
citationIds: Object.freeze(citationIds), assumptions: Object.freeze(assumptions.map((item) => text(item, '', 500))),
unresolvedQuestions: Object.freeze(questions.map((item) => text(item, '', 500))),
notes: Object.freeze(notes), revision: rev, updatedAt: raw.updated_at});
}
return Object.freeze({MESSAGE, MODES, MODE_IDS, WaveBContractError, isId: id, isTextType, normalizeArtifact,
normalizeArtifactContent, normalizeArtifactPage, normalizeCitationPage, normalizeConversation,
normalizeConversationPage, normalizeDiff, normalizeLineage, normalizeNotebook, normalizeProject,
normalizeSearch, normalizeSource, safeSourceUrl});
}));