272 lines
11 KiB
JavaScript

(function (root, factory) {
'use strict';
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.HermesHuxFoundation = api;
}(typeof globalThis === 'object' ? globalThis : this, function () {
'use strict';
const API_VERSION = 'hux.v1';
const CAPABILITY_SCHEMA = 'hux.capabilities.v1';
const FLAG_DEPENDENCIES = Object.freeze({
'hux.activity_timeline': ['hux.foundation'],
'hux.memory_control': ['hux.foundation', 'hux.privacy'],
'hux.projects': ['hux.foundation'],
'hux.artifacts': ['hux.foundation', 'hux.projects'],
'hux.autonomy': ['hux.foundation', 'hux.activity_timeline'],
'hux.friendly_modes': ['hux.foundation'],
'hux.multimodal': ['hux.foundation', 'hux.artifacts', 'hux.autonomy'],
'hux.research': ['hux.foundation', 'hux.friendly_modes'],
'hux.onboarding': ['hux.foundation', 'hux.projects', 'hux.friendly_modes'],
'hux.privacy': ['hux.foundation'],
'hux.foundation': [],
'hux.release_followthrough': [],
});
const FLAGS = Object.freeze(Object.keys(FLAG_DEPENDENCIES));
const SURFACES = new Set(['chat', 'worker', 'telegram', 'voice', 'api']);
const ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
const TENANT_REF = /^tnt_[0-9a-f]{16,64}$/;
const USER_REF = /^usr_[0-9a-f]{16,64}$/;
const TIMESTAMP = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,6})?Z$/;
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',
]);
const EVIDENCE_KINDS = new Set([
'message', 'tool_call', 'tool_result', 'artifact_version', 'source', 'passage',
'memory', 'approval', 'run', 'url', 'file', 'build', 'flux', 'pod',
]);
const SENSITIVITY = new Set(['public', 'personal', 'sensitive', 'restricted']);
class HuxContractError extends Error {
constructor(message) {
super(message);
this.name = 'HuxContractError';
}
}
function immutable(value) {
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
Object.values(value).forEach(immutable);
return Object.freeze(value);
}
function requiredString(value, pattern, field) {
if (typeof value !== 'string' || !pattern.test(value)) {
throw new HuxContractError(`Invalid ${field}`);
}
return value;
}
function normalizeIdentity(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new HuxContractError('Missing scoped identity');
}
if (!SURFACES.has(value.surface)) throw new HuxContractError('Invalid identity surface');
return immutable({
tenantRef: requiredString(value.tenant_ref || value.tenantRef, TENANT_REF, 'tenant reference'),
userRef: requiredString(value.user_ref || value.userRef, USER_REF, 'user reference'),
surface: value.surface,
});
}
function sameIdentity(left, right) {
return left.tenantRef === right.tenantRef &&
left.userRef === right.userRef && left.surface === right.surface;
}
function dependenciesEnabled(flag, declared, seen) {
if (!declared.has(flag) || seen.has(flag)) return declared.has(flag);
const next = new Set(seen).add(flag);
return FLAG_DEPENDENCIES[flag].every((dependency) =>
dependenciesEnabled(dependency, declared, next));
}
function normalizeCapabilities(payload, expectedIdentity) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new HuxContractError('Capability response is not an object');
}
if (payload.schema !== CAPABILITY_SCHEMA || payload.api_version !== API_VERSION) {
throw new HuxContractError('Unsupported HUX capability version');
}
const actualIdentity = normalizeIdentity(payload.identity);
if (!sameIdentity(actualIdentity, expectedIdentity)) {
throw new HuxContractError('Capability identity does not match this surface');
}
if (!Array.isArray(payload.flags)) throw new HuxContractError('Capability flags are missing');
if (payload.flags.length > FLAGS.length * 2) throw new HuxContractError('Too many capability flags');
const declared = new Set(payload.flags.filter((flag) => FLAGS.includes(flag)));
const enabled = FLAGS.filter((flag) => dependenciesEnabled(flag, declared, new Set()));
return immutable({
schema: CAPABILITY_SCHEMA,
apiVersion: API_VERSION,
identity: actualIdentity,
flags: enabled,
});
}
function createClient(options) {
const settings = options || {};
const fetcher = Object.prototype.hasOwnProperty.call(settings, 'fetcher') ?
settings.fetcher : (typeof fetch === 'function' ? fetch.bind(globalThis) : null);
if (!fetcher) throw new TypeError('A fetch implementation is required');
const expectedIdentity = normalizeIdentity(settings.expectedIdentity);
const baseUrl = String(settings.baseUrl || '/hux/v1').replace(/\/$/, '');
if (!baseUrl.startsWith('/') || baseUrl.startsWith('//') || /[?#\\]/.test(baseUrl) || baseUrl.includes('..')) {
throw new TypeError('HUX baseUrl must be same-origin');
}
let state = immutable({phase: 'disabled', capabilities: null, error: null});
let generation = 0;
const listeners = new Set();
function publish(next) {
state = immutable(next);
listeners.forEach((listener) => listener(state));
return state;
}
async function negotiate() {
const current = ++generation;
publish({phase: 'loading', capabilities: null, error: null});
try {
const response = await fetcher(`${baseUrl}/capabilities`, {
cache: 'no-store',
credentials: 'same-origin',
headers: {Accept: 'application/vnd.hermes.hux+json; version=1'},
});
if (current !== generation) return state;
if (response.status === 404) {
return publish({phase: 'disabled', capabilities: null, error: null});
}
if (!response.ok) throw new Error(`Capability request failed (${response.status})`);
const capabilities = normalizeCapabilities(await response.json(), expectedIdentity);
if (!capabilities.flags.includes('hux.foundation')) {
return publish({phase: 'disabled', capabilities: null, error: null});
}
return publish({phase: 'ready', capabilities, error: null});
} catch (error) {
if (current !== generation) return state;
const message = error instanceof HuxContractError ?
'Hermes workspace capabilities could not be verified.' :
'Hermes workspace features are temporarily unavailable.';
return publish({phase: 'error', capabilities: null, error: message});
}
}
function subscribe(listener) {
if (typeof listener !== 'function') throw new TypeError('Listener must be a function');
listeners.add(listener);
listener(state);
return function unsubscribe() { listeners.delete(listener); };
}
return Object.freeze({
apiVersion: API_VERSION,
identity: expectedIdentity,
getState: () => state,
negotiate,
subscribe,
enabled: (flag) => state.phase === 'ready' &&
FLAGS.includes(flag) && state.capabilities.flags.includes(flag),
endpoint: (path) => {
const safe = String(path || '');
if (!safe.startsWith('/') || safe.startsWith('//') || safe.includes('..') ||
/\\|%(?:2e|2f|5c)/i.test(safe)) {
throw new TypeError('HUX endpoint must be a scoped relative path');
}
return baseUrl + safe;
},
});
}
function createAdapters(scope) {
const identity = normalizeIdentity(scope && scope.identity);
const conversationId = requiredString(scope && scope.conversationId, ID, 'conversation id');
function assertRecord(record) {
if (!record || typeof record !== 'object' || Array.isArray(record)) {
throw new HuxContractError('HUX record is not an object');
}
if (record.owner && record.owner !== identity.userRef) {
throw new HuxContractError('HUX record owner crossed the user boundary');
}
if (record.conversation_id && record.conversation_id !== conversationId) {
throw new HuxContractError('HUX record crossed the conversation boundary');
}
}
function adaptEvent(record) {
assertRecord(record);
if (record.schema !== 'hux.event.v1' || !Number.isSafeInteger(record.seq) || record.seq < 0) {
throw new HuxContractError('Invalid activity event');
}
requiredString(record.id, ID, 'event id');
requiredString(record.ts, TIMESTAMP, 'event timestamp');
if (!EVENT_KINDS.has(record.kind) || !SENSITIVITY.has(record.sensitivity)) {
throw new HuxContractError('Invalid activity event classification');
}
if (typeof record.summary !== 'string' || !record.summary.length || record.summary.length > 280) {
throw new HuxContractError('Invalid activity event summary');
}
if (!record.redaction || !['none', 'partial', 'full'].includes(record.redaction.level)) {
throw new HuxContractError('Invalid activity event redaction');
}
if (record.conversation_id !== conversationId) {
throw new HuxContractError('Activity event has no matching conversation');
}
const hidden = record.redaction && record.redaction.level === 'full';
const evidence = Array.isArray(record.evidence) ? record.evidence.slice(0, 64).map((item) => ({
kind: String(item && item.kind || ''),
id: String(item && item.id || ''),
})).filter((item) => EVIDENCE_KINDS.has(item.kind) && item.id.length <= 200) : [];
return immutable({
schema: record.schema,
id: record.id,
seq: record.seq,
timestamp: String(record.ts || ''),
kind: String(record.kind || ''),
summary: hidden ? 'Details hidden by privacy controls.' : String(record.summary || '').slice(0, 280),
sensitivity: String(record.sensitivity || 'personal'),
redacted: hidden,
evidence,
});
}
function adaptObject(record) {
assertRecord(record);
if (typeof record.schema !== 'string' || !/^hux\.[a-z_]+\.v1$/.test(record.schema)) {
throw new HuxContractError('Unsupported HUX object version');
}
requiredString(record.id, ID, 'object id');
return immutable({
schema: record.schema,
id: record.id,
title: String(record.title || record.question || record.kind || 'Hermes item').slice(0, 300),
kind: String(record.kind || record.type || ''),
status: String(record.status || ''),
sensitivity: String(record.sensitivity || 'personal'),
conversationId: record.conversation_id || null,
projectId: record.project_id || null,
updatedAt: record.updated_at || record.created_at || null,
});
}
return Object.freeze({identity, conversationId, adaptEvent, adaptObject});
}
return Object.freeze({
API_VERSION,
CAPABILITY_SCHEMA,
FLAGS,
HuxContractError,
createAdapters,
createClient,
normalizeCapabilities,
normalizeIdentity,
});
}));