blob: 51d0f9b57e4d48b3e425406329d5fa2dc0e8307e [file]
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Session · amem</title>
<script>
// the legacy address, /s/<id>, becomes this page's own, so every relative path below holds
{ const m = /\/s\/([^/]+)$/.exec(location.pathname); if (m) location.replace(`../session.html?id=${encodeURIComponent(decodeURIComponent(m[1]))}${location.hash}`); }
// the reader's theme and contrast, set before the first paint; storage is a convenience
try { const s = localStorage, r = document.documentElement; if (s.getItem('amem-theme')) r.dataset.theme = s.getItem('amem-theme'); if (s.getItem('amem-contrast')) r.dataset.contrast = s.getItem('amem-contrast'); } catch (_) { /* private window */ }
</script>
<link rel="stylesheet" href="vendor/xterm.min.css">
<link rel="stylesheet" href="tokens.css">
<style>
html, body { height: 100%; }
body.am-body { display: flex; flex-direction: column; padding: var(--am-space-4); height: 100dvh; box-sizing: border-box; }
.am-frame { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.am-bar-title { margin: 0; }
.am-split { flex: 1; min-height: 0; }
.well { background: var(--am-term-bg); padding: var(--am-space-2) 0 var(--am-space-2) var(--am-space-3); min-width: 0; min-height: 0; display: flex; }
#term { flex: 1; min-width: 0; min-height: 0; }
.am-pane { min-height: 0; }
.am-pane-body { min-height: 0; }
.am-pane-head { min-height: 2.25rem; }
.am-recent { max-height: 9.5rem; overflow: auto; }
.am-recent b { font-weight: var(--am-weight-strong); color: var(--am-muted); }
.am-prose { overflow-wrap: anywhere; }
.am-prose img { max-width: 100%; }
.am-prose .mermaid { background: var(--am-surface); border: var(--am-line) solid var(--am-rule-soft); border-radius: var(--am-radius-sm); padding: var(--am-space-2); overflow-x: auto; }
.am-prose .mermaid svg { max-width: 100%; height: auto; }
.notice { padding: var(--am-space-3) var(--am-space-4); border-bottom: var(--am-line) solid var(--am-rule); background: var(--am-surface); }
.am-tabs { display: none; }
.am-strip-v .na { font-weight: var(--am-weight-regular); color: var(--am-muted); }
@media (max-width: 720px) {
body.am-body { padding: 0; }
.am-frame { border-radius: 0; border-width: 0; }
.am-tabs { display: flex; }
.am-split { display: flex; flex-direction: column; }
.am-split > * { flex: 1; }
.am-pane { border-top: 0; }
.am-frame[data-tab="terminal"] .am-pane, .am-frame[data-tab="record"] .well { display: none; }
.am-bar-facts { order: 3; flex-basis: 100%; }
}
</style>
</head>
<body class="am-body">
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><defs>
<symbol id="i-mark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.5 3.5H3.5v17h4M16.5 3.5h4v17h-4"/><path d="M8 8.5h8M8 12h8M8 15.5h5"/></symbol>
<symbol id="i-running" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.2"/><circle cx="8" cy="8" r="3" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-connecting" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.2" stroke-dasharray="2.7 2.2"/><circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-done" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.2"/><path d="M5.3 8.2l1.9 1.9 3.6-3.9"/></symbol>
<symbol id="i-ok" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2.8 8.6l3.3 3.2L13.2 4.6"/></symbol>
<symbol id="i-fail" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="6.2"/><path d="M5.8 5.8l4.4 4.4M10.2 5.8l-4.4 4.4"/></symbol>
<symbol id="i-warn" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.2l6.3 11.2H1.7Z"/><path d="M8 6.6v3M8 11.6v.05"/></symbol>
<symbol id="i-absent" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="6.2" stroke-dasharray="0.1 2.6"/></symbol>
<symbol id="i-refused" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="6.2"/><path d="M3.7 12.3l8.6-8.6"/></symbol>
<symbol id="i-ceiling" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 2.8h11"/><path d="M8 13.6V5.8M5.2 8.5L8 5.7l2.8 2.8"/></symbol>
<symbol id="i-admit" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 13.8V2.4h7.2v11.4"/><path d="M1.6 8.1h6.6M6 5.9l2.2 2.2L6 10.3"/></symbol>
<symbol id="i-handoff" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 9.6h3.2l1 1.7h3.6l1-1.7H14v3a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1Z"/><path d="M8 2v5.6M5.7 5.4L8 7.7l2.3-2.3"/></symbol>
<symbol id="i-lease" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.2"/><path d="M8 4.6V8l2.4 1.5"/></symbol>
<symbol id="i-queued" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 4.6h10M3 8h10M3 11.4h6"/></symbol>
<symbol id="i-invalid" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="6.2"/><path d="M5 8h6"/></symbol>
<symbol id="i-superseded" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.4 8h8.4M8 5l3 3-3 3"/><path d="M13.6 3.2v9.6"/></symbol>
<symbol id="i-demoted" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.2v8.2M4.9 7.4L8 10.5l3.1-3.1"/><path d="M3 13.6h10"/></symbol>
<symbol id="i-erased" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="2.4" y="2.4" width="11.2" height="11.2" rx="1.6" stroke-dasharray="2.2 2.2"/></symbol>
<symbol id="i-copy" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><rect x="5.6" y="5.6" width="8" height="8" rx="1.4"/><path d="M10.4 5.6V3.8a1.3 1.3 0 0 0-1.3-1.3H3.8a1.3 1.3 0 0 0-1.3 1.3v5.3a1.3 1.3 0 0 0 1.3 1.3h1.8"/></symbol>
<symbol id="i-terminal" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1.8" y="2.6" width="12.4" height="10.8" rx="1.6"/><path d="M4.6 6.2l2 1.8-2 1.8M8.4 10h3"/></symbol>
<symbol id="i-search" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="7" cy="7" r="4.4"/><path d="M10.3 10.3l3.5 3.5"/></symbol>
<symbol id="i-entry" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2.8" y="1.8" width="10.4" height="12.4" rx="1.4"/><path d="M5.4 5.4h5.2M5.4 8h5.2M5.4 10.6h3"/></symbol>
<symbol id="i-start" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M4.6 2.9v10.2L12.8 8Z"/></symbol>
<symbol id="i-label-0" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.2"><rect x="1.6" y="9.6" width="3" height="4.6" rx=".5" fill="currentColor"/><rect x="6.5" y="6.4" width="3" height="7.8" rx=".5" fill="none"/><rect x="11.4" y="2.6" width="3" height="11.6" rx=".5" fill="none"/></symbol>
<symbol id="i-label-1" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.2"><rect x="1.6" y="9.6" width="3" height="4.6" rx=".5" fill="currentColor"/><rect x="6.5" y="6.4" width="3" height="7.8" rx=".5" fill="currentColor"/><rect x="11.4" y="2.6" width="3" height="11.6" rx=".5" fill="none"/></symbol>
<symbol id="i-label-2" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.2"><rect x="1.6" y="9.6" width="3" height="4.6" rx=".5" fill="currentColor"/><rect x="6.5" y="6.4" width="3" height="7.8" rx=".5" fill="currentColor"/><rect x="11.4" y="2.6" width="3" height="11.6" rx=".5" fill="currentColor"/></symbol>
<symbol id="i-trust-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="8" cy="8" r="5.6"/></symbol>
<symbol id="i-trust-1" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="8" cy="8" r="5.6"/><path d="M8 2.4a5.6 5.6 0 0 0 0 11.2Z" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-trust-2" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="8" cy="8" r="5.6" fill="currentColor"/></symbol>
<symbol id="i-pair" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="4.8" cy="8" r="2.8"/><path d="M7.6 8h6.6M11.2 8v2.4M13.6 8v1.8"/></symbol>
<symbol id="i-busy" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M8 1.8a6.2 6.2 0 1 0 6.2 6.2"/></symbol>
</defs></svg>
<a class="am-skip" href="#pane">Skip to the reading pane</a>
<div class="am-frame" id="frame" data-tab="terminal">
<header class="am-bar" id="bar" tabindex="-1">
<a class="am-bar-home" href="index.html" title="All sessions">amem</a>
<h1 class="am-bar-title" id="title">Session</h1>
<dl class="am-bar-facts" id="facts" aria-label="Started with"></dl>
<span id="status" role="status"></span>
</header>
<div class="am-strip" id="strip" role="group" aria-label="What crosses the line">
<div class="am-strip-cell"><span class="am-strip-k">Destination</span><span class="am-strip-v" id="s-dest"></span></div>
<div class="am-strip-cell am-strip-cell--ceiling"><span class="am-strip-k">Label and ceiling</span><span class="am-strip-v" id="s-label"></span></div>
<div class="am-strip-cell"><span class="am-strip-k">Usage so far</span><span class="am-strip-v" id="s-usage"></span></div>
<div class="am-strip-cell"><span class="am-strip-k">Entries written</span><span class="am-strip-v" id="s-written"></span></div>
<div class="am-strip-cell"><span class="am-strip-k">Handoffs waiting</span><span class="am-strip-v" id="s-handoffs"></span></div>
</div>
<div class="notice" id="notice" hidden></div>
<div class="am-tabs" role="tablist" aria-label="Session">
<button role="tab" type="button" id="tab-terminal" aria-controls="well" aria-selected="true">Terminal</button>
<button role="tab" type="button" id="tab-record" aria-controls="pane" aria-selected="false" tabindex="-1">Record</button>
</div>
<div class="am-split">
<div class="well" id="well" role="tabpanel" aria-labelledby="tab-terminal"><div id="term"></div></div>
<aside class="am-pane" id="pane" tabindex="-1" role="tabpanel" aria-labelledby="tab-record">
<div class="am-pane-head"><svg class="am-glyph" aria-hidden="true"><use href="#i-entry"/></svg><span id="why">The record</span></div>
<article class="am-pane-body am-entry" id="entry">
<div class="am-entry-type" id="kind"></div>
<h2 class="am-entry-title" id="ptitle">Nothing open yet</h2>
<div class="am-entry-meta" id="emeta" hidden></div>
<div class="am-prose" id="body"><p>An entry the session writes opens here as it lands. A <code>mem-</code> id in the terminal opens here when clicked.</p></div>
</article>
<nav class="am-recent" id="recent" aria-label="Recent entries"></nav>
</aside>
</div>
</div>
<div class="am-vh" id="live" aria-live="polite"></div>
<script src="vendor/xterm.min.js"></script>
<script src="vendor/addon-fit.min.js"></script>
<script src="vendor/marked.min.js"></script>
<script src="vendor/mermaid.min.js"></script>
<script src="vendor/purify.min.js"></script>
<script src="amem.js"></script>
<script>
// an entry's body is anyone's text, an agent's or a bundle's: Markdown, with no styles,
// no forms and no remote images, so it can neither restyle the page around it nor call
// out from it
const SANITIZE = { FORBID_TAGS: ['style', 'form', 'input', 'button', 'textarea', 'select', 'option'], FORBID_ATTR: ['style'] };
DOMPurify.addHook('afterSanitizeAttributes', (n) => {
if (n.tagName === 'IMG' && !/^data:image\//i.test(n.getAttribute('src') || '')) n.removeAttribute('src');
});
// ---- small DOM helpers: what the API says is only ever set as text ----
const $ = (id) => document.getElementById(id);
const NS = 'http://www.w3.org/2000/svg';
function el(tag, cls, ...kids) {
const e = document.createElement(tag);
if (cls) e.className = cls;
for (const k of kids) if (k !== null && k !== undefined && k !== false) e.append(k);
return e;
}
function glyph(name, label) {
const s = document.createElementNS(NS, 'svg');
s.setAttribute('class', 'am-glyph');
if (label) { s.setAttribute('role', 'img'); s.setAttribute('aria-label', label); } else s.setAttribute('aria-hidden', 'true');
const u = document.createElementNS(NS, 'use');
u.setAttribute('href', `#i-${name}`);
s.append(u);
return s;
}
const state = (tone, name, label) => el('span', `am-state am-state--${tone}`, glyph(name, label));
function chip(tone, name, word, count, extra) {
const c = el('span', `am-chip am-chip--${tone}${extra ? ` ${extra}` : ''}`, glyph(name));
if (count !== undefined) c.append(el('span', 'am-chip-count', String(count)));
c.append(el('span', 'am-chip-word', word));
c.title = count !== undefined ? `${count} ${word}` : word;
return c;
}
const sub = (t) => el('span', 'am-sub', t);
const na = (t = 'not reported') => el('span', 'na', t);
const LABELS = ['public', 'internal', 'restricted'];
const TIERS = ['raw', 'curated', 'verified'];
const fact = (kind, order, word) => el('span', 'am-fact', glyph(`${kind}-${Math.max(0, order.indexOf(word))}`), word);
const abbrev = (n) => (n >= 10000 ? `${(n / 1000).toFixed(1)}k` : String(n));
// the session is ?id= in the address; the legacy /s/<id> was turned into it above
const id = new URLSearchParams(location.search).get('id') || '';
// ---- the terminal: its theme is the tokens, read again whenever the theme changes ----
const root = document.documentElement;
const css = () => getComputedStyle(root);
function termTheme() {
const c = css();
const t = (n) => c.getPropertyValue(`--am-term-${n}`).trim();
const names = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'];
return {
background: t('bg'), foreground: t('fg'), cursor: t('cursor'), cursorAccent: t('cursor-accent'), selectionBackground: t('selection'),
...Object.fromEntries(names.map((n) => [n, t(n)])),
...Object.fromEntries(names.map((n) => [`bright${n[0].toUpperCase()}${n.slice(1)}`, t(`bright-${n}`)])),
};
}
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
const rem = parseFloat(css().fontSize) || 16;
const term = new Terminal({
scrollback: 50000,
fontSize: Math.round(rem * (parseFloat(css().getPropertyValue('--am-text-term')) || 0.875)),
fontFamily: css().getPropertyValue('--am-font-mono').trim(),
lineHeight: Number(css().getPropertyValue('--am-leading-term')) || 1.2,
theme: termTheme(),
minimumContrastRatio: 4.5,
cursorBlink: !still,
cursorStyle: 'block',
screenReaderMode: false,
allowProposedApi: true,
});
const fit = new FitAddon.FitAddon();
term.loadAddon(fit);
term.open($('term'));
fit.fit();
term.focus();
function mermaidInit() {
const c = css();
const v = (n) => c.getPropertyValue(`--am-${n}`).trim();
mermaid.initialize({
startOnLoad: false, theme: 'base', securityLevel: 'strict',
themeVariables: {
primaryColor: v('surface'), primaryBorderColor: v('edge'), primaryTextColor: v('ink'), lineColor: v('muted'),
secondaryColor: v('plate'), tertiaryColor: v('paper'), textColor: v('ink'), fontFamily: v('font-sans'),
},
});
}
mermaidInit();
function retheme() { term.options.theme = termTheme(); mermaidInit(); }
for (const q of ['(prefers-color-scheme: dark)', '(prefers-contrast: more)']) matchMedia(q).addEventListener('change', retheme);
new MutationObserver(retheme).observe(root, { attributes: true, attributeFilter: ['data-theme', 'data-contrast'] });
// ---- the bar: what the session was started with, then what the launcher says ----
let info = null;
let status = {};
function renderFacts() {
const l = (info && info.launch) || {};
const facts = [
['Harness', status.harness || l.harness],
['Model', status.model || l.model],
['Profile', l.profile],
].filter(([, v]) => v);
$('facts').replaceChildren(...facts.map(([k, v]) => el('div', null, el('dt', null, k), el('dd', null, v))));
}
function setState(kind, code) {
const s = $('status');
if (kind === 'connecting' || kind === 'reconnecting') s.replaceChildren(chip('live', 'connecting', kind, undefined, 'am-chip--provisional'));
else if (kind === 'running') s.replaceChildren(chip('live', 'running', 'running'));
else if (kind === 'exited' && code === 0) s.replaceChildren(el('span', 'am-bar-state', state('ok', 'done'), 'exited 0'));
else if (kind === 'exited') s.replaceChildren(chip('fail', 'fail', code === null || code === undefined ? 'exited' : `exited ${code}`));
else if (kind === 'unpaired') s.replaceChildren(el('span', 'am-bar-state', state('await', 'pair'), 'not paired'));
else s.replaceChildren(el('span', 'am-bar-state', state('fail', 'fail'), kind));
}
function notice(tone, glyphName, title, text, action) {
const n = $('notice');
const p = el('p', null, el('b', null, title), ' ', text);
const box = el('div', `am-alert am-alert--${tone}`, glyph(glyphName), p);
box.setAttribute('role', tone === 'fail' ? 'alert' : 'status');
if (action) box.append(el('div', 'am-alert-act', action));
n.replaceChildren(box);
n.hidden = false;
}
// ---- the strip: what crosses the line, from the file the launcher writes ----
function renderStrip() {
const s = status;
const l = (info && info.launch) || {};
const known = !!s.destination;
const dest = $('s-dest');
if (known) {
dest.replaceChildren(s.destination, sub(s.auth || ''));
if (s.metered === false) dest.append(sub('pinned, not metered'));
} else dest.replaceChildren(l.provider || l.auth || na());
const label = $('s-label');
if (known && s.label && s.ceiling) {
label.replaceChildren(fact('label', LABELS, s.label), sub(`of ${s.ceiling}`));
label.title = s.ceiling_of ? `the ceiling grants.toml gives ${s.ceiling_of}` : '';
if (LABELS.indexOf(s.label) >= LABELS.indexOf(s.ceiling)) label.append(chip('warn', 'ceiling', 'at ceiling'));
} else label.replaceChildren(na());
const usage = $('s-usage');
const u = s.usage;
if (known && s.metered !== false && u) {
usage.replaceChildren(`${abbrev(u.input)} in, ${abbrev(u.output)} out`, sub(`${u.calls} call${u.calls === 1 ? '' : 's'}`));
usage.title = `${u.input} tokens in, ${u.output} out, ${u.calls} calls`;
} else usage.replaceChildren(na(known ? 'not reported: not metered' : 'not reported'));
$('s-written').replaceChildren(known ? String(s.written ?? 0) : na());
const h = $('s-handoffs');
if (known && s.handoffs > 0) h.replaceChildren(chip('await', 'handoff', 'to read', s.handoffs));
else h.replaceChildren(known ? '0' : na());
}
let statusTimer = null;
async function pollStatus() {
const r = await amem.fetch(`/api/sessions/${encodeURIComponent(id)}/status`).catch(() => null);
if (r && r.ok) {
status = await r.json();
renderStrip();
renderFacts();
}
}
// ---- the socket ----
let ws = null;
let exited = false;
let tries = 0;
function connect() {
setState(tries ? 'reconnecting' : 'connecting');
ws = amem.socket(`/ws/${encodeURIComponent(id)}`);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
tries = 0;
setState('running');
$('notice').hidden = true;
term.reset();
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
};
ws.onmessage = (e) => {
if (typeof e.data === 'string') {
const m = JSON.parse(e.data);
if (m.type === 'exit') {
exited = true;
setState('exited', m.code);
clearInterval(statusTimer);
pollStatus();
}
return;
}
term.write(new Uint8Array(e.data));
};
ws.onclose = async () => {
if (exited) return;
// a refused upgrade looks like any closed socket; ask whether the page has a key
const r = await amem.fetch(`/api/sessions/${encodeURIComponent(id)}`).catch(() => null);
if (r && r.status === 401) { unpaired(); return; }
if (r && r.status === 404) { missing(); return; }
tries += 1;
if (tries > 30) {
setState('offline');
const again = el('button', 'am-btn am-btn--secondary', 'Try again');
again.type = 'button';
again.addEventListener('click', () => { tries = 0; connect(); });
notice('fail', 'fail', 'Cannot reach amem on this host.', 'The page tried for 30 seconds. The terminal holds what the session printed before the connection went; nothing has been lost from the session itself.', again);
return;
}
setState('reconnecting');
setTimeout(connect, 1000);
};
}
term.onData((d) => { if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data: d })); });
term.onResize(({ cols, rows }) => { if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'resize', cols, rows })); });
window.addEventListener('resize', () => fit.fit());
// what a page says when it has no key; a hosted copy also says where amem has to run
const unpairedWhy = () => { const w = amem.unpaired(); return amem.hosted && !/own machine/.test(w) ? `${w} This copy of the pages runs in your browser and talks to amem on your own machine: run amem there, and it opens this page paired.` : w; };
function unpaired() {
setState('unpaired');
const cmd = el('div', 'am-cmd', el('code', null, el('span', 'p', '$ '), 'amem'));
notice('await', 'pair', 'Not paired.', unpairedWhy(), cmd);
clearInterval(statusTimer);
}
function missing() {
setState('no such session');
const back = el('a', 'am-btn am-btn--secondary', 'All sessions');
back.href = 'index.html';
notice('fail', 'fail', 'No such session.', id ? 'This server has no session with that id. Sessions end with the server that started them.' : 'This address names no session.', back);
clearInterval(statusTimer);
}
// ---- mem- ids in the terminal open in the pane ----
const ID = /mem-[a-z0-9]+-[a-z0-9]+/g;
term.registerLinkProvider({
provideLinks(y, cb) {
const line = term.buffer.active.getLine(y - 1);
if (!line) return cb(undefined);
const text = line.translateToString(true);
const links = [];
for (const m of text.matchAll(ID)) {
links.push({ range: { start: { x: m.index + 1, y }, end: { x: m.index + m[0].length, y } }, text: m[0], activate: () => open(m[0], 'Opened from the terminal') });
}
cb(links.length ? links : undefined);
},
});
// ---- the reading pane ----
let openId = null;
let unseen = 0;
async function open(entryId, why) {
const r = await amem.fetch(`/api/entry/${encodeURIComponent(entryId)}`);
if (!r.ok) return;
const e = await r.json();
openId = e.id;
$('why').textContent = why;
$('kind').textContent = e.type;
$('ptitle').textContent = e.title;
const meta = $('emeta');
meta.replaceChildren(fact('label', LABELS, e.classification), fact('trust', TIERS, e.trust_tier), el('span', 'am-id', e.id), e.author ? el('span', null, e.author) : null);
meta.hidden = false;
const body = $('body');
// an entry is somebody's Markdown: rendered, then stripped of scripts and handlers
body.innerHTML = DOMPurify.sanitize(marked.parse(e.body), SANITIZE);
const blocks = body.querySelectorAll('pre code.language-mermaid');
for (const b of blocks) { const d = el('div', 'mermaid'); d.textContent = b.textContent; b.parentElement.replaceWith(d); }
if (blocks.length) { try { await mermaid.run({ nodes: body.querySelectorAll('.mermaid') }); } catch (err) { console.warn(err); } }
for (const a of $('recent').querySelectorAll('a')) {
const on = a.dataset.id === openId;
a.classList.toggle('is-open', on);
if (on) a.setAttribute('aria-current', 'true'); else a.removeAttribute('aria-current');
}
if (why !== 'Opened from the recent list') $('live').textContent = `${why}: ${e.title}`;
if ($('frame').dataset.tab === 'terminal' && narrow.matches) { unseen += 1; recordTab(); }
}
const seen = new Set();
let first = true;
async function poll() {
try {
const r = await amem.fetch('/api/entries');
if (!r.ok) return;
const list = await r.json();
// titles are anyone's text, an agent's or a bundle's: set as text, never as markup
$('recent').replaceChildren(...list.slice(0, 8).map((e) => {
const a = el('a', e.id === openId ? 'is-open' : null, el('b', null, `${e.type}: `), e.title);
a.href = `#${e.id}`;
a.dataset.id = e.id;
if (e.id === openId) a.setAttribute('aria-current', 'true');
a.addEventListener('click', (ev) => { ev.preventDefault(); open(e.id, 'Opened from the recent list'); });
return a;
}));
const fresh = list.filter((e) => !seen.has(e.id));
list.forEach((e) => seen.add(e.id));
if (!first && fresh.length) open(fresh[0].id, 'Landed during this session');
first = false;
} catch (e) { /* the server may be busy; try again */ }
}
// ---- tabs, below 720px: the terminal and the record ----
const narrow = matchMedia('(max-width: 720px)');
function recordTab() {
const t = $('tab-record');
t.replaceChildren('Record');
if (unseen) t.append(chip('ink', 'entry', 'new', unseen));
}
function selectTab(which) {
$('frame').dataset.tab = which;
for (const [name, b] of [['terminal', $('tab-terminal')], ['record', $('tab-record')]]) {
b.setAttribute('aria-selected', String(name === which));
b.tabIndex = name === which ? 0 : -1;
}
if (which === 'record') { unseen = 0; recordTab(); } else requestAnimationFrame(() => { fit.fit(); term.focus(); });
}
$('tab-terminal').addEventListener('click', () => selectTab('terminal'));
$('tab-record').addEventListener('click', () => selectTab('record'));
for (const b of document.querySelectorAll('[role=tab]')) {
b.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
const next = $('frame').dataset.tab === 'terminal' ? 'record' : 'terminal';
selectTab(next);
$(`tab-${next}`).focus();
});
}
narrow.addEventListener('change', () => requestAnimationFrame(() => fit.fit()));
// ---- F6 moves between the bar, the terminal and the pane: the terminal takes Tab ----
term.attachCustomKeyEventHandler((e) => e.key !== 'F6');
window.addEventListener('keydown', (e) => {
if (e.key !== 'F6') return;
e.preventDefault();
const stops = [$('bar'), $('term').querySelector('textarea'), $('pane')];
const at = stops.findIndex((s) => s && s.contains(document.activeElement));
const next = stops[(at + (e.shiftKey ? stops.length - 1 : 1)) % stops.length];
if (next === $('pane') && narrow.matches) selectTab('record');
if (next !== $('pane') && narrow.matches && $('frame').dataset.tab === 'record') selectTab('terminal');
next.focus();
});
// ---- start ----
async function start() {
await amem.ready;
renderStrip();
if (!id) { missing(); return; }
if (!amem.paired()) { unpaired(); return; }
const r = await amem.fetch(`/api/sessions/${encodeURIComponent(id)}`).catch(() => null);
if (r && r.status === 401) { unpaired(); return; }
if (!r || r.status === 404) { missing(); return; }
if (r.ok) {
info = await r.json();
$('title').textContent = info.title;
document.title = `${info.title} · amem`;
renderFacts();
renderStrip();
}
connect();
pollStatus();
statusTimer = setInterval(pollStatus, 2000);
poll();
setInterval(poll, 2000);
}
start();
</script>
</body>
</html>