264 lines
18 KiB
JavaScript
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});
|
|
}));
|