277 lines
15 KiB
JavaScript
277 lines
15 KiB
JavaScript
/* node:coverage disable */
|
|
(function (root, factory) {
|
|
'use strict';
|
|
const common = typeof module === 'object' && module.exports;
|
|
const foundation = common ? require('../foundation.js') : root.HermesHuxFoundation;
|
|
const shellApi = common ? require('../shell.js') : root.HermesHuxShell;
|
|
const contract = common ? require('./wave_a_contract.js') : root.HermesHuxWaveAContract;
|
|
const api = factory(foundation, shellApi, contract);
|
|
if (common) module.exports = api;
|
|
else root.HermesHuxWaveA = api;
|
|
}(typeof globalThis === 'object' ? globalThis : this, function (foundation, shellApi, contract) {
|
|
/* node:coverage enable */
|
|
'use strict';
|
|
|
|
const KIND_LABELS = Object.freeze({
|
|
'message.user': 'You replied', 'message.assistant': 'Hermes replied',
|
|
'decision.route': 'Route selected', 'decision.plan': 'Plan updated',
|
|
'tool.call': 'Tool started', 'tool.result': 'Tool finished',
|
|
'approval.requested': 'Approval requested', 'approval.resolved': 'Approval resolved',
|
|
'memory.proposed': 'Memory suggested', 'memory.committed': 'Memory saved',
|
|
'memory.forgotten': 'Memory forgotten', 'memory.suppressed': 'Memory not saved',
|
|
'memory.retrieval_removed': 'Memory retrieval changed', 'artifact.created': 'Artifact created',
|
|
'artifact.version': 'Artifact updated', 'artifact.promoted': 'Artifact promoted',
|
|
'citation.attached': 'Citation attached', 'mode.changed': 'Mode changed',
|
|
'run.started': 'Run started', 'run.cancelled': 'Run cancelled',
|
|
'run.completed': 'Run completed', 'run.failed': 'Run failed',
|
|
'privacy.notice': 'Privacy notice', 'suggestion.shown': 'Suggestion shown',
|
|
'suggestion.dismissed': 'Suggestion dismissed', 'release.transition': 'Release advanced',
|
|
'delegation.started': 'Delegation started', 'delegation.completed': 'Delegation completed',
|
|
'delegation.failed': 'Delegation failed', 'budget.exhausted': 'Budget exhausted',
|
|
'side_effect.blocked': 'Action blocked', 'side_effect.released': 'Action approved',
|
|
});
|
|
|
|
function element(doc, tag, attributes, text) {
|
|
const node = doc.createElement(tag);
|
|
Object.entries(attributes || {}).forEach(([name, value]) => node.setAttribute(name, value));
|
|
if (text !== undefined && text !== null) node.textContent = String(text);
|
|
return node;
|
|
}
|
|
|
|
function announce(doc, container, message, alert) {
|
|
const box = element(doc, 'p', {'class': alert ? 'hux-wave-a__error' : 'hux-wave-a__status',
|
|
'role': alert ? 'alert' : 'status', 'aria-live': alert ? 'assertive' : 'polite'}, message);
|
|
container.replaceChildren(box);
|
|
return box;
|
|
}
|
|
|
|
function friendlyKind(kind) {
|
|
return KIND_LABELS[kind] || String(kind).replace(/[._]/g, ' ');
|
|
}
|
|
|
|
function activityExtension(options) {
|
|
const doc = options.document;
|
|
const conversationId = options.conversationId;
|
|
if (!contract.isId(conversationId, 'conv')) throw new TypeError('Activity requires an opaque conversation id');
|
|
return Object.freeze({id: 'wave-a-activity', flag: 'hux.activity_timeline', label: 'Activity', order: 10,
|
|
render(context) {
|
|
const panel = context.container;
|
|
let generation = 0;
|
|
|
|
function show(page) {
|
|
const section = element(doc, 'section', {'class': 'hux-wave-a hux-wave-a--activity',
|
|
'aria-labelledby': 'hux-wave-a-activity-title'});
|
|
const heading = element(doc, 'header', {'class': 'hux-wave-a__heading'});
|
|
heading.appendChild(element(doc, 'div', {}, null));
|
|
heading.children[0].appendChild(element(doc, 'h3', {'id': 'hux-wave-a-activity-title'}, 'What is happening'));
|
|
heading.children[0].appendChild(element(doc, 'p', {}, 'A compact, privacy-filtered timeline for this conversation.'));
|
|
const refresh = element(doc, 'button', {'type': 'button'}, 'Refresh activity');
|
|
refresh.addEventListener('click', load);
|
|
heading.appendChild(refresh);
|
|
section.appendChild(heading);
|
|
const list = element(doc, 'ol', {'class': 'hux-wave-a__timeline'});
|
|
page.items.forEach((item) => {
|
|
const row = element(doc, 'li', {'class': 'hux-wave-a__event', 'data-kind': item.kind});
|
|
const stamp = element(doc, 'time', {'datetime': item.timestamp}, new Date(item.timestamp).toLocaleString());
|
|
const title = element(doc, 'strong', {}, friendlyKind(item.kind));
|
|
const summary = element(doc, 'p', {}, item.summary);
|
|
row.appendChild(stamp); row.appendChild(title); row.appendChild(summary);
|
|
if (item.evidence.length) {
|
|
const details = element(doc, 'details', {'class': 'hux-wave-a__evidence'});
|
|
details.appendChild(element(doc, 'summary', {}, `${item.evidence.length} evidence reference${item.evidence.length === 1 ? '' : 's'}`));
|
|
const refs = element(doc, 'ul');
|
|
item.evidence.forEach((ref) => refs.appendChild(element(doc, 'li', {}, `${ref.kind}: ${ref.id}`)));
|
|
details.appendChild(refs); row.appendChild(details);
|
|
}
|
|
list.appendChild(row);
|
|
});
|
|
section.appendChild(list);
|
|
if (!page.items.length) section.appendChild(element(doc, 'p', {'class': 'hux-wave-a__empty'}, 'No activity has been recorded yet.'));
|
|
section.appendChild(element(doc, 'p', {'class': 'hux-wave-a__sr', 'role': 'status',
|
|
'aria-live': 'polite'}, `${page.items.length} activity events available`));
|
|
panel.replaceChildren(section);
|
|
}
|
|
|
|
async function load() {
|
|
const current = ++generation;
|
|
announce(doc, panel, 'Loading activity…', false);
|
|
try {
|
|
const response = await context.client.request(`/conversations/${conversationId}/events?after_seq=0&limit=200`);
|
|
if (current !== generation) return;
|
|
show(contract.normalizeEventPage(response.body, context.client.identity, conversationId));
|
|
} catch (_) {
|
|
if (current === generation) announce(doc, panel,
|
|
'Activity could not be verified. No event details were shown.', true);
|
|
}
|
|
}
|
|
load();
|
|
}});
|
|
}
|
|
|
|
function memoryActions(item) {
|
|
if (item.status === 'proposed') return [['approve', 'Approve'], ['reject', 'Reject']];
|
|
if (item.status === 'active') return [
|
|
[item.retrievable ? 'remove_retrieval' : 'restore_retrieval',
|
|
item.retrievable ? 'Stop using in replies' : 'Use in replies'], ['forget', 'Forget'],
|
|
];
|
|
if (item.status === 'expired') return [['forget', 'Forget']];
|
|
return [];
|
|
}
|
|
|
|
function memoryExtension(options) {
|
|
const doc = options.document;
|
|
const conversationId = options.conversationId;
|
|
const keyFactory = options.idempotencyKey;
|
|
if (!contract.isId(conversationId, 'conv')) throw new TypeError('Memory requires an opaque conversation id');
|
|
if (typeof keyFactory !== 'function') throw new TypeError('Memory requires an idempotency key factory');
|
|
return Object.freeze({id: 'wave-a-memory', flag: 'hux.memory_control', label: 'Memory', order: 20,
|
|
render(context) {
|
|
const panel = context.container;
|
|
let generation = 0;
|
|
|
|
async function mutate(item, action, body) {
|
|
announce(doc, panel, `${friendlyKind(`memory.${action}`)}…`, false);
|
|
try {
|
|
await context.client.request(`/memory/${encodeURIComponent(item.id)}/${action}`, {
|
|
method: 'POST', headers: {'If-Match': String(item.revision)}, ...(body ? {body} : {}),
|
|
});
|
|
await load();
|
|
} catch (error) {
|
|
announce(doc, panel, error && error.status === 409 ?
|
|
'This memory changed. Refresh before trying again.' : 'The memory change could not be completed.', true);
|
|
}
|
|
}
|
|
|
|
function card(item) {
|
|
const article = element(doc, 'article', {'class': 'hux-wave-a__memory', 'data-status': item.status});
|
|
const header = element(doc, 'header');
|
|
header.appendChild(element(doc, 'span', {'class': 'hux-wave-a__pill'}, item.kind));
|
|
header.appendChild(element(doc, 'strong', {}, item.status));
|
|
article.appendChild(header);
|
|
article.appendChild(element(doc, 'p', {'class': 'hux-wave-a__memory-content'}, item.content));
|
|
const why = element(doc, 'dl', {'class': 'hux-wave-a__meta'});
|
|
[['Why saved', item.reason], ['Scope', item.scope.scopeId ? `${item.scope.level}: ${item.scope.scopeId}` : item.scope.level],
|
|
['Privacy', item.sensitivity], ['Retention', item.retention]].forEach(([label, value]) => {
|
|
const row = element(doc, 'div'); row.appendChild(element(doc, 'dt', {}, label));
|
|
row.appendChild(element(doc, 'dd', {}, value)); why.appendChild(row);
|
|
});
|
|
article.appendChild(why);
|
|
const actions = element(doc, 'div', {'class': 'hux-wave-a__actions'});
|
|
memoryActions(item).forEach(([action, label]) => {
|
|
const button = element(doc, 'button', {'type': 'button'}, label);
|
|
button.addEventListener('click', () => mutate(item, action)); actions.appendChild(button);
|
|
});
|
|
article.appendChild(actions);
|
|
if (item.status === 'active') {
|
|
const edit = element(doc, 'form', {'class': 'hux-wave-a__edit'});
|
|
const fieldId = `hux-memory-edit-${item.id}`;
|
|
edit.appendChild(element(doc, 'label', {'for': fieldId}, 'Correct this memory'));
|
|
const input = element(doc, 'textarea', {'id': fieldId, 'maxlength': '2000', 'required': 'required'});
|
|
edit.appendChild(input);
|
|
const submit = element(doc, 'button', {'type': 'submit'}, 'Save as a corrected memory');
|
|
edit.appendChild(submit);
|
|
edit.addEventListener('submit', (event) => {
|
|
event.preventDefault();
|
|
const content = contract.safeText(input.value, '', 2000);
|
|
if (content) mutate(item, 'edit', {content});
|
|
});
|
|
article.appendChild(edit);
|
|
}
|
|
return article;
|
|
}
|
|
|
|
function show(page) {
|
|
const section = element(doc, 'section', {'class': 'hux-wave-a hux-wave-a--memory',
|
|
'aria-labelledby': 'hux-wave-a-memory-title'});
|
|
const heading = element(doc, 'header', {'class': 'hux-wave-a__heading'});
|
|
const title = element(doc, 'div');
|
|
title.appendChild(element(doc, 'h3', {'id': 'hux-wave-a-memory-title'}, 'Memory control'));
|
|
title.appendChild(element(doc, 'p', {}, 'See what Hermes remembers, why, and how it is used.'));
|
|
heading.appendChild(title);
|
|
const refresh = element(doc, 'button', {'type': 'button'}, 'Refresh memory');
|
|
refresh.addEventListener('click', load); heading.appendChild(refresh); section.appendChild(heading);
|
|
|
|
const propose = element(doc, 'form', {'class': 'hux-wave-a__propose'});
|
|
propose.appendChild(element(doc, 'h4', {}, 'Suggest a memory'));
|
|
const content = element(doc, 'textarea', {'maxlength': '2000', 'required': 'required',
|
|
'aria-label': 'What should Hermes remember?'});
|
|
const reason = element(doc, 'input', {'maxlength': '280', 'required': 'required',
|
|
'aria-label': 'Why should Hermes remember this?'});
|
|
const kind = element(doc, 'select', {'aria-label': 'Memory kind'});
|
|
['preference', 'fact', 'instruction', 'context'].forEach((value) =>
|
|
kind.appendChild(element(doc, 'option', {'value': value}, value)));
|
|
propose.appendChild(content); propose.appendChild(reason); propose.appendChild(kind);
|
|
propose.appendChild(element(doc, 'button', {'type': 'submit'}, 'Propose for review'));
|
|
propose.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
const clean = contract.safeText(content.value, '', 2000);
|
|
const why = contract.safeText(reason.value, '', 280);
|
|
if (!clean || !why) return;
|
|
announce(doc, panel, 'Submitting memory proposal…', false);
|
|
try {
|
|
const idempotencyKey = contract.safeIdempotencyKey(keyFactory());
|
|
await context.client.request('/memory', {method: 'POST',
|
|
headers: {'Idempotency-Key': idempotencyKey}, body: {kind: kind.value || 'fact', content: clean,
|
|
reason: why, approval_mode: 'ask', conversation_id: conversationId,
|
|
scope: {level: 'conversation', scope_id: conversationId}}});
|
|
await load();
|
|
} catch (_) {
|
|
announce(doc, panel, 'The memory proposal could not be completed.', true);
|
|
}
|
|
});
|
|
section.appendChild(propose);
|
|
const list = element(doc, 'div', {'class': 'hux-wave-a__memory-list'});
|
|
page.items.forEach((item) => list.appendChild(card(item)));
|
|
section.appendChild(list);
|
|
if (!page.items.length) section.appendChild(element(doc, 'p', {'class': 'hux-wave-a__empty'}, 'No memory entries are available.'));
|
|
section.appendChild(element(doc, 'p', {'class': 'hux-wave-a__sr', 'role': 'status',
|
|
'aria-live': 'polite'}, `${page.items.length} memory entries available`));
|
|
panel.replaceChildren(section);
|
|
}
|
|
|
|
async function load() {
|
|
const current = ++generation;
|
|
announce(doc, panel, 'Loading memory…', false);
|
|
try {
|
|
const response = await context.client.request('/memory');
|
|
if (current !== generation) return;
|
|
show(contract.normalizeMemoryPage(response.body, context.client.identity, conversationId));
|
|
} catch (_) {
|
|
if (current === generation) announce(doc, panel,
|
|
'Memory could not be verified. No saved details were shown.', true);
|
|
}
|
|
}
|
|
load();
|
|
}});
|
|
}
|
|
|
|
function defaultKeyFactory() {
|
|
let sequence = 0;
|
|
return () => `webui:memory:${Date.now().toString(36)}:${(++sequence).toString(36)}`;
|
|
}
|
|
|
|
function createWaveARuntime(options) {
|
|
const settings = options || {};
|
|
const doc = settings.document || (typeof document === 'object' ? document : null);
|
|
if (!doc) throw new TypeError('Wave A runtime requires a document');
|
|
const client = contract.createCanonicalClient(settings);
|
|
const shell = shellApi.createShell({client, document: doc, instanceId: settings.instanceId || 'wave-a'});
|
|
const shared = {document: doc, conversationId: settings.conversationId,
|
|
idempotencyKey: settings.idempotencyKey || defaultKeyFactory()};
|
|
const removeActivity = shell.register(activityExtension(shared));
|
|
const removeMemory = shell.register(memoryExtension(shared));
|
|
let mounted = false;
|
|
return Object.freeze({client,
|
|
async mount(target) {
|
|
if (mounted) throw new Error('Wave A runtime is already mounted');
|
|
mounted = true; shell.mount(target); await client.negotiate();
|
|
},
|
|
destroy() {
|
|
removeActivity(); removeMemory(); shell.destroy(); mounted = false;
|
|
},
|
|
});
|
|
}
|
|
|
|
return Object.freeze({activityExtension, createWaveARuntime, friendlyKind, memoryActions, memoryExtension});
|
|
}));
|