272 lines
11 KiB
JavaScript
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,
|
|
});
|
|
}));
|