485 lines
34 KiB
JavaScript
Raw Normal View History

/* node:coverage disable */
(function (root, factory) {
'use strict';
const contract = typeof module === 'object' && module.exports ? require('./wave_a_contract.js') : root.HermesHuxWaveAContract;
const shellApi = typeof module === 'object' && module.exports ? require('../shell.js') : root.HermesHuxShell;
const api = factory(contract, shellApi);
if (typeof module === 'object' && module.exports) module.exports = api;
else root.HermesHuxAutonomyPrivacy = api;
}(typeof globalThis === 'object' ? globalThis : this, function (contract, shellApi) {
/* node:coverage enable */
'use strict';
const ACCEPT = 'application/vnd.hermes.hux+json; version=1';
const ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
const SLOT = /^slot-[0-9]{1,3}$/;
const USER = /^usr_[0-9a-f]{16,64}$/;
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
const CONTROL = /[\u0000-\u001f\u007f]/;
const AUTONOMY = new Set(['ask_first', 'safe', 'autonomous']);
const DECISIONS = new Set(['allow', 'ask', 'deny']);
const CHOICES = new Set(['once', 'session', 'always', 'deny']);
const OUTCOMES = new Set(['cancelled', 'already_complete', 'failed_to_cancel']);
const TOPICS = new Set(['health', 'finance', 'legal', 'relationships', 'credentials', 'minors', 'location', 'biometric']);
const NOTICE_CONTROLS = new Set(['forget_this_conversation', 'switch_to_private', 'disable_memory_here', 'dismiss']);
const CAPABILITIES = new Set(['read_files', 'write_files', 'shell', 'network', 'web_search', 'send_message', 'memory_write', 'artifact_write', 'spend_tokens', 'delegate', 'deploy', 'external_side_effect']);
const BUDGETS = ['tokens_per_run', 'tool_calls_per_run', 'wall_clock_seconds', 'delegations_per_run', 'spend_units', 'subagents_per_run'];
const GENERIC_NOTICE = 'Sensitive-topic protections are active. Details stay scoped to this conversation and are not remembered without approval.';
const UNVERIFIED_STOP = 'Model response stopped; tool/side-effect cancellation unverified.';
function record(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : null; }
function exact(value, required, optional) {
const allowed = new Set(required.concat(optional || []));
return required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) &&
Object.keys(value).every((key) => allowed.has(key));
}
function id(value, prefix) { return typeof value === 'string' && ID.test(value) && (!prefix || value.startsWith(`${prefix}_`)); }
function timestamp(value) { return typeof value === 'string' && UTC.test(value) && !Number.isNaN(Date.parse(value)); }
function text(value, max) { return typeof value === 'string' && value.length > 0 && value.length <= max && !CONTROL.test(value); }
function integer(value, min, max) { return Number.isSafeInteger(value) && value >= min && value <= max; }
function sameIdentity(owner, identity) { return owner === identity.userRef; }
function safeRun(value) { return id(value, 'run'); }
function normalizeBudgets(raw) {
const value = record(raw);
if (!value || !exact(value, [], BUDGETS.concat(['scope']))) return null;
const result = {};
for (const key of BUDGETS) {
if (value[key] !== undefined && !integer(value[key], 0, Number.MAX_SAFE_INTEGER)) return null;
if (value[key] !== undefined) result[key] = value[key];
}
if (value.scope !== undefined) {
const scope = record(value.scope);
if (!scope || !exact(scope, [], ['conversations', 'paths'])) return null;
const conversations = scope.conversations || [];
const paths = scope.paths || [];
if (!Array.isArray(conversations) || conversations.length > 64 || conversations.some((item) => !id(item, 'conv')) ||
!Array.isArray(paths) || paths.length > 64 || paths.some((item) => !text(item, 300))) return null;
result.scope = {conversations: conversations.slice(), paths: paths.slice()};
}
return result;
}
function normalizePolicy(raw, identity, conversationId, etag) {
const value = record(raw);
if (!value || !exact(value, ['schema', 'id', 'owner', 'scope', 'autonomy', 'grants', 'budgets', 'provenance', 'updated_at', 'revision']) ||
value.schema !== 'hux.policy.v1' || !id(value.id, 'pol') || !sameIdentity(value.owner, identity) ||
!AUTONOMY.has(value.autonomy) || !timestamp(value.updated_at) || !integer(value.revision, 1, Number.MAX_SAFE_INTEGER) ||
String(etag || '').replaceAll('"', '') !== String(value.revision)) return null;
const scope = record(value.scope);
if (!scope || !exact(scope, ['level'], ['scope_id']) || !['global', 'project', 'conversation'].includes(scope.level) ||
(scope.level === 'global' ? scope.scope_id !== undefined : !id(scope.scope_id, scope.level === 'project' ? 'prj' : 'conv')) ||
(scope.level === 'global' ? value.id !== 'pol_global' : !value.id.startsWith(`pol_${scope.level}.`)) ||
(scope.level === 'conversation' && scope.scope_id !== conversationId) ||
!record(value.provenance) || !Array.isArray(value.grants) || value.grants.length > 64) return null;
const grants = [];
for (const rawGrant of value.grants) {
const grant = record(rawGrant);
if (!grant || !exact(grant, ['capability', 'decision'], ['expires_at', 'granted_by']) ||
!CAPABILITIES.has(grant.capability) || !DECISIONS.has(grant.decision) ||
(grant.expires_at !== undefined && !timestamp(grant.expires_at))) return null;
grants.push({capability: grant.capability, decision: grant.decision, ...(grant.expires_at ? {expires_at: grant.expires_at} : {})});
}
const budgets = normalizeBudgets(value.budgets);
return budgets ? {id: value.id, owner: value.owner, effectiveScope: {...scope},
scope: {level: 'conversation', scope_id: conversationId}, autonomy: value.autonomy, grants, budgets,
revision: value.revision, ifMatch: scope.level === 'conversation' ? value.revision : 0, updatedAt: value.updated_at} : null;
}
function normalizeApproval(raw, conversationId, statuses, expectedChoice, identity) {
const value = record(raw);
if (!value || !exact(value, ['schema', 'id', 'run_id', 'conversation_id', 'capability', 'request', 'status', 'requested_at', 'expires_at'], ['decision', 'idempotency_key']) ||
value.schema !== 'hux.approval.v1' || !id(value.id, 'apr') || !safeRun(value.run_id) ||
value.conversation_id !== conversationId || !CAPABILITIES.has(value.capability) ||
!statuses.has(value.status) || !timestamp(value.requested_at) || !timestamp(value.expires_at)) return null;
const request = record(value.request);
if (!request || !exact(request, ['summary', 'risk', 'external'], ['detail', 'evidence']) || !text(request.summary, 280) ||
!['low', 'medium', 'high'].includes(request.risk) || typeof request.external !== 'boolean') return null;
if (expectedChoice !== undefined) {
const decision = record(value.decision); const actor = decision && record(decision.by);
if (!decision || !exact(decision, ['choice', 'by', 'at']) || decision.choice !== expectedChoice || !timestamp(decision.at) ||
!identity || !actor || !exact(actor, ['type', 'id'], ['display']) || actor.type !== 'user' || actor.id !== identity.userRef) return null;
} else if (value.decision !== undefined) return null;
return {id: value.id, runId: value.run_id, capability: value.capability, summary: request.summary,
risk: request.risk, external: request.external, status: value.status,
requestedAt: value.requested_at, expiresAt: value.expires_at};
}
function normalizeApprovalPage(raw, conversationId) {
const value = record(raw);
if (!value || !exact(value, ['items', 'next']) || !Array.isArray(value.items) || value.items.length > 200 || value.next !== null) return null;
const items = value.items.map((item) => normalizeApproval(item, conversationId, new Set(['pending'])));
return items.some((item) => item === null) || new Set(items.map((item) => item.id)).size !== items.length ? null : items;
}
function normalizeReceipt(raw, runId, conversationId, identity) {
const value = record(raw);
const actor = value && record(value.requested_by);
const human = identity && ['chat', 'telegram', 'voice'].includes(identity.surface);
if (!value || !exact(value, ['schema', 'id', 'run_id', 'requested_by', 'requested_at', 'outcome', 'side_effects'], ['acknowledged_at', 'completed_at', 'conversation_id']) ||
value.schema !== 'hux.cancel_receipt.v1' || !id(value.id, 'rcpt') || value.run_id !== runId || !actor ||
!exact(actor, ['type', 'id'], ['display']) || !identity ||
(human ? actor.type !== 'user' || actor.id !== identity.userRef : actor.type !== 'system' || actor.id !== identity.surface) ||
!timestamp(value.requested_at) || !OUTCOMES.has(value.outcome) ||
(value.conversation_id !== undefined && value.conversation_id !== conversationId) || !Array.isArray(value.side_effects) || value.side_effects.length > 64) return null;
const effects = value.side_effects.map((rawEffect) => {
const effect = record(rawEffect);
return effect && exact(effect, ['description', 'reverted'], ['evidence']) && text(effect.description, 280) && typeof effect.reverted === 'boolean' ?
{description: effect.description, reverted: effect.reverted} : null;
});
return effects.some((item) => item === null) ? null : {id: value.id, runId, outcome: value.outcome, sideEffects: effects};
}
function normalizePrivacyPolicy(raw, auditStale) {
const value = record(raw);
if (!value || !exact(value, ['schema', 'version', 'topics', 'topic_scoping', 'retention_audit'], ['audit_stale']) ||
value.schema !== 'hux.privacy_policy.v1' || !integer(value.version, 1, Number.MAX_SAFE_INTEGER) ||
!Array.isArray(value.topics) || value.topics.length !== TOPICS.size) return null;
const names = new Set(); let shortest = 366; let longest = 0;
for (const rawTopic of value.topics) {
const topic = record(rawTopic);
if (!topic || !exact(topic, ['topic', 'sensitivity', 'memory_write', 'decay_days', 'notice']) || !TOPICS.has(topic.topic) ||
names.has(topic.topic) || !['sensitive', 'restricted'].includes(topic.sensitivity) || !['ask', 'deny'].includes(topic.memory_write) ||
!integer(topic.decay_days, 1, 365) || !text(topic.notice, 280)) return null;
names.add(topic.topic); shortest = Math.min(shortest, topic.decay_days); longest = Math.max(longest, topic.decay_days);
}
const scoping = record(value.topic_scoping); const audit = record(value.retention_audit);
if (!scoping || !exact(scoping, ['scope_to_conversation', 'cross_surface_sharing']) || scoping.scope_to_conversation !== true ||
!['never', 'same_owner_only'].includes(scoping.cross_surface_sharing) || !audit || !exact(audit, ['interval_days', 'actions']) ||
!integer(audit.interval_days, 1, 30) || !Array.isArray(audit.actions) || audit.actions.length < 1) return null;
return {version: value.version, protectedCount: names.size, shortestDecay: shortest, longestDecay: longest,
crossSurfaceSharing: scoping.cross_surface_sharing, auditIntervalDays: audit.interval_days, auditStale: auditStale === 'true'};
}
function normalizeAuditPage(raw) {
const value = record(raw);
if (!value || !exact(value, ['items', 'next']) || !Array.isArray(value.items) || value.items.length > 200) return null;
const rows = value.items.map((rawItem) => {
const item = record(rawItem);
if (!item || !exact(item, ['schema', 'id', 'ran_at', 'policy_version', 'results']) || item.schema !== 'hux.retention_audit.v1' ||
!id(item.id, 'aud') || !timestamp(item.ran_at) || !integer(item.policy_version, 1, Number.MAX_SAFE_INTEGER) || !Array.isArray(item.results)) return null;
let actions = 0; let affected = 0;
for (const rawResult of item.results) {
const result = record(rawResult);
if (!result || !exact(result, ['action', 'count']) || !['expire_memory', 'decay_topic_context', 'purge_forgotten_content', 'report'].includes(result.action) ||
!integer(result.count, 0, Number.MAX_SAFE_INTEGER)) return null;
actions += 1; affected += result.action === 'report' ? 0 : result.count;
}
return {id: item.id, ranAt: item.ran_at, policyVersion: item.policy_version, actions, affected};
});
return rows.some((item) => item === null) ? null : rows;
}
function normalizeNotice(raw, conversationId) {
const value = record(raw);
if (!value || !exact(value, ['schema', 'topic', 'conversation_id', 'text', 'controls', 'shown_at']) || value.schema !== 'hux.privacy_notice.v1' ||
!TOPICS.has(value.topic) || value.conversation_id !== conversationId || !text(value.text, 280) || !timestamp(value.shown_at) ||
!Array.isArray(value.controls) || value.controls.length < 1 || value.controls.some((item) => !NOTICE_CONTROLS.has(item))) return null;
return {text: GENERIC_NOTICE, controls: [...new Set(value.controls)], shownAt: value.shown_at};
}
function normalizeForget(raw, conversationId) {
const value = record(raw);
return value && exact(value, ['conversation_id', 'forgotten', 'memory_forgotten', 'events_redacted']) && value.conversation_id === conversationId &&
value.forgotten === true && integer(value.memory_forgotten, 0, Number.MAX_SAFE_INTEGER) && integer(value.events_redacted, 0, Number.MAX_SAFE_INTEGER) ?
{forgotten: true, memoryForgotten: value.memory_forgotten, eventsRedacted: value.events_redacted} : null;
}
function el(doc, tag, attrs, content) {
const node = doc.createElement(tag);
Object.entries(attrs || {}).forEach(([key, value]) => node.setAttribute(key, value));
if (content !== undefined) node.textContent = String(content);
return node;
}
function status(doc, message, role) { return el(doc, 'p', {class: 'hux-runtime-status', role: role || 'status', 'aria-live': 'polite'}, message); }
function safeError(doc) { return status(doc, 'This workspace control is temporarily unavailable.', 'alert'); }
function responseHeader(response, name) { return response.headers && typeof response.headers.get === 'function' ? response.headers.get(name) : ''; }
function createRuntime(options) {
const settings = options || {}; const doc = settings.document || (typeof document === 'object' ? document : null);
if (!doc || !id(settings.conversationId, 'conv')) throw new TypeError('Runtime needs a document and conversation id');
if (settings.runId !== undefined && !safeRun(settings.runId)) throw new TypeError('Runtime run id is invalid');
const fetcher = settings.fetcher || (typeof fetch === 'function' ? fetch.bind(globalThis) : null);
if (!fetcher) throw new TypeError('Runtime needs fetch');
let client = null; let autonomyRoot = null; let privacyRoot = null; let policy = null; let approvals = [];
let receipt = null; let privacyPolicy = null; let audits = []; let activeTopic = null; let privacyNotice = null;
let noticeShown = false; let noticePending = false; let privacyMessage = ''; let stopMessage = '';
let inlineDock = null; let pollTimer = null;
const issuedApprovals = new Set();
const pollMs = Number.isSafeInteger(settings.approvalPollMs) && settings.approvalPollMs >= 1 ? settings.approvalPollMs : 4000;
function timers() { return settings.timers || globalThis; }
function assertClient(candidate, flag) {
if (!candidate || candidate.apiVersion !== 'hux.v1' || !candidate.identity ||
!SLOT.test(candidate.identity.tenantSlot || '') || !USER.test(candidate.identity.userRef || '') ||
!['chat', 'worker', 'telegram', 'voice', 'api'].includes(candidate.identity.surface) ||
!['router', 'relay', 'worker'].includes(candidate.identity.trust) ||
!candidate.enabled('hux.foundation') || !candidate.enabled(flag)) throw new Error('HUX capability unavailable');
return candidate;
}
async function request(path, init) {
const customHeaders = (init && init.headers) || {};
const response = await fetcher(client.endpoint(path), {...(init || {}), cache: 'no-store', credentials: 'same-origin',
headers: {Accept: ACCEPT, ...((init && init.body) ? {'Content-Type': 'application/json'} : {}), ...customHeaders}});
if (!response.ok) throw new Error(`HUX request failed (${response.status})`);
return response;
}
function key(action, target) { return `hux:${action}:${target}`.slice(0, 120); }
async function loadAutonomy() {
const scope = encodeURIComponent(settings.conversationId);
const [policyResponse, approvalResponse] = await Promise.all([
request(`/policy?scope=conversation&scope_id=${scope}`, {method: 'GET'}),
request('/approvals?status=pending', {method: 'GET'}),
]);
policy = normalizePolicy(await policyResponse.json(), client.identity, settings.conversationId, responseHeader(policyResponse, 'ETag'));
approvals = normalizeApprovalPage(await approvalResponse.json(), settings.conversationId);
if (!policy || !approvals) throw new Error('Invalid autonomy response');
approvals.forEach((item) => issuedApprovals.add(item.id)); renderAutonomy();
}
async function savePolicy(level) {
if (!policy || !AUTONOMY.has(level)) throw new TypeError('Policy choice is invalid');
const body = {scope: policy.scope, autonomy: level, grants: policy.grants, budgets: policy.budgets};
const response = await request('/policy', {method: 'PUT', headers: {'If-Match': String(policy.ifMatch)}, body: JSON.stringify(body)});
const updated = normalizePolicy(await response.json(), client.identity, settings.conversationId, responseHeader(response, 'ETag'));
if (!updated || updated.effectiveScope.level !== 'conversation' || updated.revision <= policy.ifMatch) throw new Error('Policy revision did not advance');
policy = updated; renderAutonomy(); return policy;
}
async function decideApproval(approvalId, choice) {
if (!issuedApprovals.has(approvalId) || !CHOICES.has(choice)) throw new TypeError('Approval decision was not presented here');
const response = await request(`/approvals/${encodeURIComponent(approvalId)}`, {method: 'POST',
headers: {'Idempotency-Key': key(`approval-${choice}`, approvalId)}, body: JSON.stringify({choice})});
const resolved = normalizeApproval(await response.json(), settings.conversationId, new Set(['approved', 'denied']), choice, client.identity);
if (!resolved || resolved.id !== approvalId) throw new Error('Approval result is invalid');
issuedApprovals.delete(approvalId); approvals = approvals.filter((item) => item.id !== approvalId); renderAutonomy(); return resolved;
}
async function refreshApprovals() {
if (!client) return null;
try {
const response = await request('/approvals?status=pending', {method: 'GET'});
const page = normalizeApprovalPage(await response.json(), settings.conversationId);
if (!page) return null;
approvals = page; page.forEach((item) => issuedApprovals.add(item.id));
renderAutonomy(); return page;
} catch (_) { return null; }
}
function startApprovalPolling() {
if (pollTimer !== null) return;
pollTimer = timers().setInterval(() => {
if (typeof settings.canStopModelResponse === 'function' && !settings.canStopModelResponse()) return;
void refreshApprovals();
}, pollMs);
if (pollTimer && typeof pollTimer.unref === 'function') pollTimer.unref();
}
function stopApprovalPolling() {
if (pollTimer === null) return;
timers().clearInterval(pollTimer); pollTimer = null;
}
function mountInlineDock() {
const host = settings.inlineHost || doc.body || null;
if (inlineDock || !host) return;
inlineDock = el(doc, 'section', {class: 'hux-runtime-inline-dock', role: 'region',
'aria-label': 'Approvals needed', 'aria-live': 'polite'});
inlineDock.hidden = true; host.appendChild(inlineDock);
}
function approvalCard(item, choices, extraClass) {
const card = el(doc, 'article', {class: `hux-runtime-approval${extraClass} risk-${item.risk}`});
card.appendChild(el(doc, 'strong', {}, item.summary));
card.appendChild(el(doc, 'p', {}, `${item.capability.replaceAll('_', ' ')} · ${item.risk} risk${item.external ? ' · external' : ''}`));
const actions = el(doc, 'div', {class: 'hux-runtime-actions'});
choices.forEach(([choice, label]) => {
const button = el(doc, 'button', {type: 'button'}, label);
button.addEventListener('click', () => { void decideApproval(item.id, choice).catch(() => card.appendChild(safeError(doc))); });
actions.appendChild(button);
});
card.appendChild(actions); return card;
}
function renderInline() {
if (!inlineDock) return;
inlineDock.replaceChildren();
const pending = (approvals || []).slice().sort((a, b) => b.requestedAt.localeCompare(a.requestedAt));
inlineDock.hidden = pending.length === 0;
if (!pending.length) return;
inlineDock.appendChild(el(doc, 'h2', {class: 'hux-runtime-inline-title'}, 'Approval needed'));
pending.slice(0, 3).forEach((item) => inlineDock.appendChild(approvalCard(item,
[['once', 'Allow once'], ['always', 'Always allow'], ['deny', 'Deny']], ' hux-runtime-inline-card')));
if (pending.length > 3) inlineDock.appendChild(status(doc, `${pending.length - 3} more waiting in the workspace drawer.`));
}
async function stopRun() {
if (typeof settings.stopModelResponse !== 'function') throw new TypeError('No owned model response is bound to this view');
const model = await settings.stopModelResponse();
if (!record(model) || model.accepted !== true) {
stopMessage = 'Model response stop could not be verified; tool/side-effect cancellation unverified.';
renderAutonomy(); return {modelStopped: false, verified: false};
}
stopMessage = UNVERIFIED_STOP; receipt = null; renderAutonomy();
if (!settings.runId) return {modelStopped: true, verified: false};
try {
const body = {conversation_id: settings.conversationId, process_registry_empty: false};
const response = await request(`/runs/${encodeURIComponent(settings.runId)}/stop`, {method: 'POST',
headers: {'Idempotency-Key': key('stop', settings.runId)}, body: JSON.stringify(body)});
receipt = normalizeReceipt(await response.json(), settings.runId, settings.conversationId, client.identity);
const verified = Boolean(receipt && receipt.outcome === 'cancelled' &&
receipt.sideEffects.every((effect) => effect.reverted));
stopMessage = verified ? 'Model response and recorded side effects stopped.' : UNVERIFIED_STOP;
renderAutonomy(); return {modelStopped: true, verified, receipt};
} catch (_) {
stopMessage = UNVERIFIED_STOP; renderAutonomy();
return {modelStopped: true, verified: false};
}
}
function renderAutonomy() {
renderInline();
if (!autonomyRoot) return; autonomyRoot.replaceChildren();
if (!policy || !approvals) { autonomyRoot.appendChild(status(doc, 'Loading autonomy controls…')); return; }
const title = el(doc, 'h2', {class: 'hux-runtime-title'}, 'Autonomy and approvals'); autonomyRoot.appendChild(title);
const picker = el(doc, 'label', {class: 'hux-runtime-field'}, 'Autonomy level');
const select = el(doc, 'select', {'aria-label': 'Autonomy level'});
[['ask_first', 'Ask first'], ['safe', 'Safe actions'], ['autonomous', 'Autonomous']].forEach(([value, label]) => {
const option = el(doc, 'option', {value}, label); option.selected = value === policy.autonomy; select.appendChild(option);
});
picker.appendChild(select); const save = el(doc, 'button', {type: 'button'}, 'Save autonomy');
save.addEventListener('click', () => { void savePolicy(select.value).catch(() => autonomyRoot.appendChild(safeError(doc))); });
picker.appendChild(save); autonomyRoot.appendChild(picker);
const queue = el(doc, 'section', {'aria-labelledby': 'hux-runtime-approvals'});
queue.appendChild(el(doc, 'h3', {id: 'hux-runtime-approvals'}, `Pending approvals (${approvals.length})`));
approvals.forEach((item) => queue.appendChild(approvalCard(item,
[['once', 'Allow once'], ['session', 'Allow this session'], ['always', 'Always allow'], ['deny', 'Deny']], '')));
autonomyRoot.appendChild(queue);
const canStop = typeof settings.canStopModelResponse === 'function' && settings.canStopModelResponse();
if (canStop) { const stop = el(doc, 'button', {type: 'button', class: 'hux-runtime-stop'}, 'Stop model response');
stop.addEventListener('click', () => { void stopRun(); }); autonomyRoot.appendChild(stop); }
if (stopMessage) autonomyRoot.appendChild(status(doc, stopMessage,
stopMessage === 'Model response and recorded side effects stopped.' ? 'status' : 'alert'));
if (receipt) { const box = el(doc, 'section', {class: 'hux-runtime-receipt', role: 'status', 'aria-live': 'polite'});
box.appendChild(el(doc, 'h3', {}, 'Stop receipt')); box.appendChild(el(doc, 'p', {}, `Outcome: ${receipt.outcome.replaceAll('_', ' ')}`));
const list = el(doc, 'ul'); receipt.sideEffects.forEach((effect) => list.appendChild(el(doc, 'li', {}, `${effect.reverted ? 'Reverted' : 'Not reverted'}: ${effect.description}`)));
box.appendChild(list); autonomyRoot.appendChild(box); }
}
async function loadPrivacy() {
const [policyResponse, auditResponse] = await Promise.all([
request('/privacy/policy', {method: 'GET'}), request('/privacy/audit', {method: 'GET'}),
]);
privacyPolicy = normalizePrivacyPolicy(await policyResponse.json(), responseHeader(policyResponse, 'HUX-Audit-Stale'));
audits = normalizeAuditPage(await auditResponse.json());
if (!privacyPolicy || !audits) throw new Error('Invalid privacy response'); renderPrivacy();
}
async function showPrivacyNotice(topic) {
if (noticeShown || noticePending || !TOPICS.has(topic) || !client) return null;
noticePending = true; activeTopic = topic;
try {
const response = await request('/privacy/notices', {method: 'POST', headers: {'Idempotency-Key': key('privacy-notice', settings.conversationId)},
body: JSON.stringify({topic, conversation_id: settings.conversationId})});
privacyNotice = normalizeNotice(await response.json(), settings.conversationId);
if (!privacyNotice) throw new Error('Invalid privacy notice'); noticeShown = true; renderPrivacy(); return privacyNotice;
} catch (error) { activeTopic = null; privacyNotice = null; renderPrivacy(); throw error; }
finally { noticePending = false; }
}
async function privacyChoice(choice) {
if (!privacyNotice || !privacyNotice.controls.includes(choice)) throw new TypeError('Privacy control was not presented here');
if (choice === 'dismiss') { activeTopic = null; privacyNotice = null; privacyMessage = 'Privacy notice dismissed.'; renderPrivacy(); return null; }
if (choice === 'forget_this_conversation') {
const response = await request(`/conversations/${encodeURIComponent(settings.conversationId)}/forget`, {method: 'POST',
headers: {'Idempotency-Key': key('forget', settings.conversationId)}, body: JSON.stringify({})});
const result = normalizeForget(await response.json(), settings.conversationId);
if (!result) throw new Error('Forget result is invalid'); privacyMessage = `Conversation forgotten; ${result.memoryForgotten} memory entries removed.`;
} else {
const response = await request('/privacy/notices', {method: 'POST', headers: {'Idempotency-Key': key(`privacy-${choice}`, settings.conversationId)},
body: JSON.stringify({topic: activeTopic, conversation_id: settings.conversationId, chosen: choice})});
if (!normalizeNotice(await response.json(), settings.conversationId)) throw new Error('Privacy choice is invalid');
if (choice === 'switch_to_private' && typeof settings.onPrivateMode === 'function') await settings.onPrivateMode();
privacyMessage = choice === 'disable_memory_here' ? 'Memory is disabled for this conversation.' : 'Private mode was requested.';
}
activeTopic = null; privacyNotice = null; renderPrivacy(); return true;
}
function renderPrivacy() {
if (!privacyRoot) return; privacyRoot.replaceChildren();
if (!privacyPolicy || !audits) { privacyRoot.appendChild(status(doc, 'Loading privacy controls…')); return; }
privacyRoot.appendChild(el(doc, 'h2', {class: 'hux-runtime-title'}, 'Privacy and retention'));
const summary = el(doc, 'dl', {class: 'hux-runtime-summary', 'aria-label': 'Privacy policy summary'});
[['Sensitive categories', `${privacyPolicy.protectedCount} protected`], ['Context minimization', `${privacyPolicy.shortestDecay}–${privacyPolicy.longestDecay} days`],
['Cross-surface sharing', privacyPolicy.crossSurfaceSharing === 'never' ? 'Never' : 'Same owner only'], ['Retention audit', privacyPolicy.auditStale ? 'Overdue' : `Every ${privacyPolicy.auditIntervalDays} days`]].forEach(([term, value]) => {
const row = el(doc, 'div'); row.appendChild(el(doc, 'dt', {}, term)); row.appendChild(el(doc, 'dd', {}, value)); summary.appendChild(row);
});
privacyRoot.appendChild(summary);
privacyRoot.appendChild(el(doc, 'p', {class: 'hux-runtime-minimize'}, 'When the topic changes, prior sensitive details are not repeated.'));
if (privacyNotice) { const notice = el(doc, 'aside', {class: 'hux-runtime-notice', 'aria-labelledby': 'hux-runtime-privacy-notice'});
notice.appendChild(el(doc, 'h3', {id: 'hux-runtime-privacy-notice'}, 'Sensitive-topic protection')); notice.appendChild(el(doc, 'p', {}, privacyNotice.text));
const actions = el(doc, 'div', {class: 'hux-runtime-actions'}); privacyNotice.controls.forEach((choice) => { const button = el(doc, 'button', {type: 'button'}, choice.replaceAll('_', ' '));
button.addEventListener('click', () => { void privacyChoice(choice).catch(() => notice.appendChild(safeError(doc))); }); actions.appendChild(button); }); notice.appendChild(actions); privacyRoot.appendChild(notice); }
const latest = audits[0]; privacyRoot.appendChild(status(doc, latest ? `Last retention audit: ${latest.ranAt}; ${latest.affected} records affected.` : 'No retention audit has been recorded.'));
if (privacyMessage) privacyRoot.appendChild(status(doc, privacyMessage));
}
const autonomyExtension = {id: 'autonomy-controls', flag: 'hux.autonomy', label: 'Autonomy', order: 30, render(context) {
client = assertClient(context.client, 'hux.autonomy'); autonomyRoot = context.container;
mountInlineDock(); renderAutonomy();
const target = autonomyRoot;
void loadAutonomy().catch(() => { if (autonomyRoot === target) target.replaceChildren(safeError(doc)); });
startApprovalPolling();
}};
const privacyExtension = {id: 'privacy-controls', flag: 'hux.privacy', label: 'Privacy', order: 40, render(context) {
client = assertClient(context.client, 'hux.privacy'); privacyRoot = context.container; renderPrivacy();
const target = privacyRoot;
void loadPrivacy().catch(() => { if (privacyRoot === target) target.replaceChildren(safeError(doc)); });
}};
function register(shell) { if (!shell || typeof shell.register !== 'function') throw new TypeError('HUX shell is required');
const removeAutonomy = shell.register(autonomyExtension); const removePrivacy = shell.register(privacyExtension);
return () => { removePrivacy(); removeAutonomy(); }; }
function destroy() {
stopApprovalPolling();
if (inlineDock && inlineDock.parentNode) inlineDock.parentNode.removeChild(inlineDock);
inlineDock = null;
client = null; autonomyRoot = null; privacyRoot = null; policy = null; approvals = [];
receipt = null; privacyPolicy = null; audits = []; activeTopic = null; privacyNotice = null;
stopMessage = ''; issuedApprovals.clear();
}
return Object.freeze({extensions: Object.freeze([autonomyExtension, privacyExtension]), register, destroy,
showPrivacyNotice, clearPrivacyNotice() { activeTopic = null; privacyNotice = null; if (privacyRoot) renderPrivacy(); },
savePolicy, decideApproval, refreshApprovals, stopRun, privacyChoice});
}
function createAutonomyPrivacyRuntime(options) {
const settings = options || {};
const doc = settings.document || (typeof document === 'object' ? document : null);
if (!doc) throw new TypeError('Autonomy/privacy runtime requires a document');
const client = contract.createCanonicalClient(settings);
const shell = shellApi.createShell({client, document: doc,
instanceId: settings.instanceId || 'governance'});
const runtime = createRuntime(settings);
const removers = runtime.extensions.map((extension) => shell.register(extension));
let mounted = false; let destroyed = false;
return Object.freeze({client,
async mount(target) {
if (destroyed) throw new Error('Autonomy/privacy runtime was destroyed');
if (mounted) throw new Error('Autonomy/privacy runtime is already mounted');
mounted = true; shell.mount(target); await client.negotiate();
},
destroy() {
if (destroyed) return;
destroyed = true; shell.destroy();
removers.slice().reverse().forEach((remove) => remove());
runtime.destroy(); mounted = false;
},
});
}
return Object.freeze({GENERIC_NOTICE, UNVERIFIED_STOP, createAutonomyPrivacyRuntime, createRuntime,
normalizeApproval, normalizeApprovalPage, normalizeAuditPage,
normalizeForget, normalizeNotice, normalizePolicy, normalizePrivacyPolicy, normalizeReceipt});
}));