openvibers/openvibe.shared Commits
4.205.0: the step view reads AG-UI, compact runs, the one composer to talk to a run, crawlable first HTML (#462)
Changes
@@@ -1,5 +1,24 @@# Changelog++## 4.205.0 — 2026-11-11++++**Agents show their work everywhere: the one step view reads AG-UI, compact like the portal, with the one composer to++talk to the run; and every site's first HTML is crawlable.**++++- **The step view reads AG-UI 1.0 (`agent-session.js`, protocol 1.2, ask 354).** Run, step, text, tool call,++ reasoning, state, activity and custom events, with OpenVibe's words in `metadata.ov` (title, kind, sources): phases++ with a plan row ("1 of 3"), steps under the agent's own titles (rows under one title show their own target), thinking++ folded to one line, sources gathered in one strip (site tiles, https links), text streamed in place.++- **Compact runs.** A group is one line (its state, its words, the step it is on in mono, the chevron), the head says++ what it is doing now, three dots while it works; `mount(el, { mode: 'card', href })` for a chat message, the Dock or++ a feed card.++- **Talk to the run with the one composer.** `mount(el, { onSend(text, { files, uploads }) })`: the shared composer's++ markup ("+" for files, paste and drop, "/" commands, voice, drafts, settings), loaded from beside the view.++- **The composer on any page (`ov-composer.js`).** `clear()` and `uploads()` for boxes sent by script, a form reset++ clears it, and it styles its own tool buttons and row, so it looks the same without thread.css.++- **`openvibe-shared/crawlable` (was #457).** Static navigation, the network's sites by group, JSON-LD and llms.txt for++ the HTML a site sends before its scripts run.++## 4.204.0 — 2026-11-11**Buttons that show their state, windows that resize, the one composer everywhere, players that start sooner, and one@@@ -5,7 +5,7 @@**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt**Install and test:** `npm ci && npm test`--**Status:** alpha, v4.204.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.205.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,13 +4,14 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.204.0",++ "version": "4.205.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)","contracts": "openvibe-contracts v0.399.0 (devDependency: the release manifest contract)","sdk": "openvibe-sdk v0.147.0","features": [++ "the one step view reads AG-UI (phases, titled steps, sources, thinking, compact groups, card mode) with the one composer to talk to a run, and crawlable first HTML for every site (4.205.0)","buttons that show their state (kinds, badge, dot, attention, busy/done/error, hold), resizable panel windows, the one composer everywhere, players that start sooner, and openvibe-shared/csp (4.204.0)","the Dock updates edited DM text and link cards live and lets authors edit their own messages (4.203.0)","the shared field dropdown, one dialog/sheet/panel window, user cards, eight site groups, measured page ends and remade reveal (4.202.0)",@@@ -202,3 +202,82 @@ details[open] > summary .ovas-chev { transform: rotate(90deg); }.ovas-sheet-x, .ovas-sheet-foot a { min-height: 44px; }.ovas-prev-img { max-height: 68vh; }}++++/* ── Protocol 1.2 (4.205.0, ask 354): phases, thinking, sources, card mode ───────────────────────────────────── */++.ovas-phase { display: flex; align-items: center; gap: 9px; margin: 6px 2px 0; font-size: 12.5px; letter-spacing: .02em; color: var(--ovas-mut, var(--text-secondary, #9fb0cf)); }++.ovas-phase b { font-weight: 750; color: var(--text-primary, #e6edf7); }++.ovas-phase-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; background: var(--accent, #3b82f6); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); }++.ovas-phase[data-s="running"] .ovas-phase-dot { animation: ovasBreathe 1.6s ease-in-out infinite; }++.ovas-phase[data-s="done"] .ovas-phase-dot { background: var(--ovas-ok, #22c55e); box-shadow: none; }++.ovas-phase::after { content: ""; flex: 1; height: 1px; background: var(--ovas-b, rgba(255,255,255,.1)); }++@keyframes ovasBreathe { 50% { transform: scale(calc(1 - .25 * var(--ov-move, 1))); opacity: calc(1 - .5 * var(--ov-move, 1)); } }++.ovas-think details { border-radius: 12px; }++.ovas-think summary { display: flex; align-items: center; gap: 8px; cursor: pointer; list-style: none; font-size: 13px; color: var(--ovas-mut, var(--text-secondary, #9fb0cf)); }++.ovas-think summary::-webkit-details-marker { display: none; }++.ovas-think-dots { width: 18px; height: 6px; flex: none; background: radial-gradient(circle, currentColor 2px, transparent 2.5px) 0 50% / 6px 6px repeat-x; animation: ovasDots 1.1s linear infinite; }++.ovas-think.is-done .ovas-think-dots { animation: none; opacity: .5; }++@keyframes ovasDots { 50% { opacity: .35; } }++.ovas-think-body { margin: 6px 0 0 26px; padding: 8px 12px; border-left: 2px solid var(--ovas-b, rgba(255,255,255,.1)); font-size: 13px; line-height: 1.5; white-space: pre-wrap; color: var(--ovas-mut, var(--text-secondary, #9fb0cf)); max-height: 320px; overflow: auto; }++.ovas-sources { border: 1px solid var(--ovas-b, rgba(255,255,255,.1)); border-radius: 14px; padding: 8px 12px; background: var(--ovas-t, rgba(255,255,255,.04)); }++.ovas-sources > summary { display: flex; align-items: center; gap: 10px; cursor: pointer; list-style: none; font-size: 13px; font-weight: 650; }++.ovas-sources > summary::-webkit-details-marker { display: none; }++.ovas-src-tiles { display: inline-flex; }++.ovas-src-tile { display: inline-grid; place-items: center; width: 22px; height: 22px; margin-left: -6px; border-radius: 7px; font-size: 11px; font-weight: 800; color: #fff;++ background: hsl(var(--h, 210) 55% 42%); box-shadow: 0 0 0 2px var(--ovas-s, #121826); }++.ovas-src-tile:first-child { margin-left: 0; }++.ovas-sources ol { display: grid; gap: 6px; margin: 10px 0 2px; padding: 0; list-style: none; max-height: 340px; overflow: auto; }++.ovas-sources li { display: grid; gap: 1px; min-width: 0; }++.ovas-sources li a { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: none; font-size: 13.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovas-sources li a:hover { text-decoration: underline; }++.ovas-sources li small { color: var(--ovas-mut, var(--text-secondary, #9fb0cf)); font-size: 11.5px; }++.ovas--card { gap: 8px; }++.ovas--card .ovas-head { position: static; padding: 10px 12px; }++.ovas--card :is(.ovas-bar, .ovas-files) { display: none !important; }++.ovas--card .ovas-feed > li:not(:nth-last-child(-n+3)) { display: none; }++.ovas--card .ovas-feed > li .ovas-out { max-height: 120px; }++.ovas-open { justify-self: start; display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 12px; border-radius: 10px; font-size: 13px; font-weight: 700; text-decoration: none;++ color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); }++html[data-ov-motion="off"] :is(.ovas-phase-dot, .ovas-think-dots) { animation: none !important; }++/* 3. A card keeps its groups folded: the head says what they did. */++.ovas--card .ovas-group:not(.is-one) > :not(summary) { display: none; }++.ovas--card .ovas-group:not(.is-one) > summary .ovas-chev { display: none; }++++/* ── Compact runs (2026-11-11, the owner with the portal's run view open: "consider stuff like this … parse and expandable++ and compact mode … smart"): a group is one line, its state first, its words, the step it is on in mono, the chevron++ last; the steps inside are light rows; the head says what it is doing now; three dots while it works; a box to talk++ to it. Every motion follows --ov-move. */++.ovas-feed { gap: 6px; }++.ovas-group > summary { min-height: 42px; padding: 4px 10px; gap: 9px; }++.ovas-group:not(.is-one) > summary .ovas-state { order: -1; }++.ovas-group:not(.is-one) > summary .ovas-icons { display: none; }++.ovas-group:not(.is-one) > summary .ovas-chev { order: 99; transition: transform var(--ov-dur-2, .2s) var(--ov-ease-out, ease); }++.ovas-group[open]:not(.is-one) > summary .ovas-chev { transform: rotate(90deg); }++.ovas-group:not(.is-one) > summary .ovas-sum { display: flex; align-items: baseline; gap: 9px; }++.ovas-group:not(.is-one) > summary .ovas-sum b { flex: none; max-width: 60%; }++.ovas-group:not(.is-one) > summary .ovas-sum small { flex: 1 1 auto; min-width: 0; }++.ovas-group > ol .ovas-step { border: 0; background: transparent; border-radius: 10px; }++.ovas-group > ol .ovas-step > summary { min-height: 38px; padding: 3px 8px; }++.ovas-group > ol .ovas-step > summary:hover { background: color-mix(in srgb, var(--text-primary, #fff) 5%, transparent); }++.ovas-group > ol .ovas-kind { width: 24px; height: 24px; border-radius: 7px; }++.ovas-meta [data-m="now"] { flex: 1 1 100%; min-width: 0; order: -1; }++.ovas-now { display: flex; align-items: center; gap: 7px; min-width: 0; font: 12.5px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text-primary, #e6edf7); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }++.ovas-now::before { content: ""; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #3b82f6); animation: ovasBreathe 1.6s ease-in-out infinite; }++.ovas-working { display: flex; gap: 5px; padding: 4px 6px; }++.ovas-working[hidden] { display: none; }++.ovas-working i { width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #3b82f6); opacity: .35; animation: ovasWork 1.2s ease-in-out infinite; }++.ovas-working i:nth-child(2) { animation-delay: .16s; } .ovas-working i:nth-child(3) { animation-delay: .32s; }++@keyframes ovasWork { 40% { opacity: 1; transform: translateY(calc(-3px * var(--ov-move, 1))); } }++.ovas-you { justify-self: end; max-width: 85%; padding: 8px 12px; border-radius: 14px 14px 4px 14px; background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); font-size: 14px; white-space: pre-wrap; overflow-wrap: anywhere; }++.ovas-you:not(.is-sent):not(.is-failed) { opacity: .6; }++.ovas-you.is-failed { outline: 1px solid var(--ovas-bad, #ef4444); }++.ovas-say { position: sticky; bottom: 0; display: grid; gap: 4px; padding: 8px; border-radius: 16px; border: 1px solid var(--ovas-b, rgba(255,255,255,.1)); background: var(--ovas-s, #121826); }++.ovas-say textarea { flex: 1; min-height: 40px; max-height: 160px; resize: none; padding: 9px 12px; border: 0; border-radius: 12px; background: var(--ovas-t, rgba(255,255,255,.05)); color: inherit; font: inherit; font-size: 15px; }++.ovas-say textarea:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 0; }++.ovas-say-send { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border: 0; border-radius: 12px; background: var(--accent-strong, var(--accent, #2563eb)); color: #fff; cursor: pointer; }++.ovas-say.is-busy .ovas-say-send { opacity: .5; }++html[data-ov-motion="off"] :is(.ovas-now, .ovas-working i)::before, html[data-ov-motion="off"] .ovas-working i { animation: none !important; }++.ovas-you-files { display: block; margin-top: 4px; font-size: 12px; opacity: .8; }++.ovas-say .ovt-c-row { display: flex; align-items: center; gap: 6px; }++.ovas-say .ovt-c-tools { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }++.ovas-say .ovt-c-note { margin: 0; font-size: 12px; color: var(--ovas-mut, var(--text-secondary, #9fb0cf)); }++.ovas-say .ovt-c-note:empty { display: none; }@@@ -128,9 +128,48 @@if (e.step === 'tool_result') return [{ t: 'tool_result', name: String(e.tool || 'tool'), ok: !e.is_error, output: cut(e.text, 8000), ...att, ...agent }];return text ? [{ t: 'text', text, ...agent }] : [];}++++ // ── AG-UI 1.0 (protocol 1.2, ask 354, 2026-11-11): every OpenVibe agent run streams AG-UI events (docs.ag-ui.com,++ // spec 1.0 of 2026-11-07) with OpenVibe's own words in metadata.ov (title, kind, sources, …), so another tool's agent++ // renders here and ours in theirs. Deltas (text, tool arguments, thinking) carry their ids; the view joins them.++ const pick = (e, ...k) => { for (const n of k) if (e[n] != null) return e[n]; return null; };++ function fromAgui(e) {++ const ov = (e.metadata && typeof e.metadata === 'object' && e.metadata.ov && typeof e.metadata.ov === 'object') ? e.metadata.ov : {};++ const src = Array.isArray(ov.sources) && ov.sources.length ? [{ t: 'sources', items: ov.sources }] : [];++ const id = (...k) => String(pick(e, ...k) || '');++ switch (e.type) {++ case 'RUN_STARTED': return [{ t: 'state', state: 'running' }];++ case 'RUN_FINISHED': return [...src, { t: 'done', ok: true, text: cut(typeof e.result === 'string' ? e.result : '', MAX_TEXT) }];++ case 'RUN_ERROR': return [{ t: 'done', ok: false, text: cut(String(e.message || 'The run failed.'), 2000) }];++ case 'STEP_STARTED': return [{ t: 'phase', id: id('stepName', 'step_name'), title: String(ov.title || pick(e, 'stepName', 'step_name') || 'Step'), status: 'running' }];++ case 'STEP_FINISHED': return [{ t: 'phase', id: id('stepName', 'step_name'), status: 'done' }];++ case 'TEXT_MESSAGE_START': return [{ t: 'text_start', id: id('messageId', 'message_id') }];++ case 'TEXT_MESSAGE_CONTENT': case 'TEXT_MESSAGE_CHUNK': return [{ t: 'text_delta', id: id('messageId', 'message_id'), delta: String(e.delta || '') }];++ case 'TOOL_CALL_START': return [{ t: 'tool', id: id('toolCallId', 'tool_call_id'), name: String(pick(e, 'toolCallName', 'tool_call_name') || 'tool'), input: {}, title: ov.title ? cut(ov.title, 160) : '', kind: ov.kind || null }];++ case 'TOOL_CALL_ARGS': case 'TOOL_CALL_CHUNK': return [{ t: 'tool_args', id: id('toolCallId', 'tool_call_id'), delta: String(e.delta || '') }];++ case 'TOOL_CALL_RESULT': return [...src, { t: 'tool_result', id: id('toolCallId', 'tool_call_id'), ok: !ov.error, output: cut(typeof e.content === 'string' ? e.content : JSON.stringify(e.content || ''), 8000), duration_ms: Number(ov.duration_ms) || 0, cost_usd: Number(ov.cost_usd) || 0 }];++ case 'REASONING_START': case 'REASONING_MESSAGE_START': return [{ t: 'thinking', id: id('messageId', 'message_id') || 'r' }];++ case 'REASONING_MESSAGE_CONTENT': case 'REASONING_MESSAGE_CHUNK': return [{ t: 'thinking_delta', id: id('messageId', 'message_id') || 'r', delta: String(e.delta || '') }];++ case 'REASONING_END': case 'REASONING_MESSAGE_END': return [{ t: 'thinking', id: id('messageId', 'message_id') || 'r', done: true }];++ case 'REASONING_ENCRYPTED_VALUE': return [{ t: 'thinking', id: id('messageId', 'message_id') || 'r', hidden: true }];++ case 'STATE_SNAPSHOT': {++ const plan = e.snapshot && Array.isArray(e.snapshot.plan) ? e.snapshot.plan : null;++ return plan ? [{ t: 'phases', items: plan.slice(0, 30).map((p) => ({ title: String(p.title || p.content || ''), status: String(p.status || 'pending') })) }] : [];++ }++ case 'ACTIVITY_SNAPSHOT': return e.content && e.content.title ? [{ t: 'activity', text: cut(String(e.content.title), 200) }] : [];++ case 'CUSTOM': {++ if (e.name === 'ov.sources' && Array.isArray(e.value)) return [{ t: 'sources', items: e.value }];++ if (e.name === 'ov.handoff' && e.value) return [{ t: 'handoff', from: e.value.from, to: e.value.to || null, reason: cut(e.value.reason, 600) }];++ if (e.name === 'ov.approval' && e.value) return [{ t: 'note', level: 'warning', text: cut(e.value.question || 'Waiting for your approval.', 600) }];++ return [];++ }++ default: return src;++ }++ }function normalize(e) {if (!e || typeof e !== 'object') return [];if (e.t) return [e];++ if (typeof e.type === 'string' && /^[A-Z][A-Z_]+$/.test(e.type)) return fromAgui(e);if (e.kind) return fromActor(e);const parent = e.parent_tool_use_id || null;if (e.type === 'system' && e.subtype === 'init') return [{ t: 'agent', agent: e.agent || 'claude', model: e.model || null }];@@@ -422,6 +461,7 @@else if (k === 'plan') { out.title = 'Updated the plan'; }else { out.title = String(step.name || 'Tool'); out.sub = String(step.what || ''); }if (!out.title) out.title = String(step.what || step.name || 'Step');++ if (step.title) out.title = String(step.title); // the agent's own words for it (AG-UI metadata.ov.title)return out;}@@@ -449,14 +489,23 @@// The clock runs from startedAt, or from now for a run that is live as it mounts; a finished run replayed without// a start time shows the time only once its done line says it.const clock = Boolean(opts.startedAt) || LIVE.includes(opts.status || 'running');-- const S = { status: opts.status || 'running', label: opts.label || '', startedAt: opts.startedAt || Date.now(), endedAt: null, agents: [], steps: new Map(), plan: null, tokens: { in: 0, out: 0 }, cost: 0, agentCost: new Map(), durationMs: 0, problems: 0, filter: 'all', follow: opts.follow !== false, lastGroup: null };++ const S = { phases: [], thinks: new Map(), msgs: new Map(), sources: new Map(), activity: '', ownPlan: false, status: opts.status || 'running', label: opts.label || '', startedAt: opts.startedAt || Date.now(), endedAt: null, agents: [], steps: new Map(), plan: null, tokens: { in: 0, out: 0 }, cost: 0, agentCost: new Map(), durationMs: 0, problems: 0, filter: 'all', follow: opts.follow !== false, lastGroup: null };host.textContent = '';const box = el('div', 'ovas');-- box.innerHTML = `<div class="ovas-head"><div class="ovas-top"><span class="ovas-pill" data-s="${esc(S.status)}"></span><span class="ovas-title"></span></div><div class="ovas-meta"><span class="ovas-agents"></span><span data-m="time"></span><span data-m="steps"></span><span data-m="files"></span><span data-m="cost"></span></div><details class="ovas-plan" hidden><summary><span class="ovas-plan-now"></span><span class="ovas-plan-n"></span></summary><div class="ovas-plan-bar"><i style="width:0"></i></div><ol></ol></details></div>`++ box.innerHTML = `<div class="ovas-head"><div class="ovas-top"><span class="ovas-pill" data-s="${esc(S.status)}"></span><span class="ovas-title"></span></div><div class="ovas-meta"><span class="ovas-agents"></span><span data-m="now" hidden></span><span data-m="time"></span><span data-m="steps"></span><span data-m="files"></span><span data-m="cost"></span></div><details class="ovas-plan" hidden><summary><span class="ovas-plan-now"></span><span class="ovas-plan-n"></span></summary><div class="ovas-plan-bar"><i style="width:0"></i></div><ol></ol></details></div>`+ '<div class="ovas-bar" role="toolbar" aria-label="Show"></div><ol class="ovas-files" hidden></ol><ol class="ovas-feed" aria-live="polite"></ol><div class="ovas-actions" hidden></div>';host.appendChild(box);const head = box.querySelector('.ovas-head'), feed = box.querySelector('.ovas-feed'), bar = box.querySelector('.ovas-bar');const filesEl = box.querySelector('.ovas-files'), actBar = box.querySelector('.ovas-actions');++ // The sources strip (protocol 1.2) under the feed; card mode (opts.mode 'card': a chat message, the Dock, a feed card)++ // keeps the head, the plan row, the newest three lines and the sources, with a way to the whole run (opts.href).++ const srcBox = el('details', 'ovas-sources'); srcBox.hidden = true;++ srcBox.innerHTML = '<summary><span class="ovas-src-tiles"></span><span class="ovas-src-n"></span></summary><ol></ol>';++ box.insertBefore(srcBox, actBar);++ if (opts.mode === 'card') {++ box.classList.add('ovas--card');++ if (typeof opts.href === 'string' && /^(https:\/\/|\/)/.test(opts.href)) { const more = el('a', 'ovas-open', 'Open the whole run'); more.href = opts.href; box.insertBefore(more, actBar); }++ }head.querySelector('.ovas-title').textContent = opts.title || 'Agent session';const FILTERS = [['all', 'Everything'], ['said', 'What it said'], ['steps', 'Steps'], ['files', 'Files'], ['problems', 'Problems']];FILTERS.forEach(([id, label]) => { const b = el('button', 'ovas-chip', label); b.type = 'button'; b.dataset.f = id; b.setAttribute('aria-pressed', String(id === 'all')); bar.appendChild(b); });@@@ -474,6 +523,9 @@try { navigator.clipboard.writeText(text); c.lastChild.textContent = 'Copied'; setTimeout(() => { c.lastChild.textContent = 'Copy'; }, 1400); } catch { /* no clipboard */ }});const empty = el('li', 'ovas-empty', opts.empty || 'Waiting for the agent to start…'); feed.appendChild(empty);++ // The run is still working: three dots after the newest line (gone when it ends).++ const working = el('div', 'ovas-working'); working.setAttribute('aria-hidden', 'true'); working.innerHTML = '<i></i><i></i><i></i>';++ working.hidden = !LIVE.includes(S.status); box.insertBefore(working, box.querySelector('.ovas-actions'));// ── The preview sheet: one attachment, opened by a tap ──────────────────────────const sheet = el('div', 'ovas-sheet');@@@ -654,7 +706,21 @@const kinds = order.filter((k) => count[k]);g.querySelector('.ovas-icons').innerHTML = kinds.slice(0, 3).map((k) => `<span class="ovas-kind" data-k="${k}">${svg(k)}</span>`).join('');// A few steps read as what was done ("Ran 2 commands"); more as counts ("2 commands · 1 edit · 3 reads").-- g.querySelector('.ovas-sum b').textContent = kinds.length === 1 ? plural(kinds[0], count[kinds[0]]) : kinds.map((k) => `${count[k]} ${NOUN[k][count[k] === 1 ? 0 : 1]}`).join(' · ');++ // Steps the agent named itself (AG-UI metadata.ov.title) read as its words: the newest title and how many there are.++ const titled = steps.filter((s2) => s2.title);++ // Rows under one shared title say what each one did instead of the title again: its address, query or file.++ const sharedTitle = titled.length > 1 && titled.every((s2) => s2.title === titled[0].title);++ for (const s2 of steps) {++ const b2 = s2.el && s2.el.querySelector(':scope>summary .ovas-sum b');++ if (!b2 || !s2.info) continue;++ // An address reads as its page and its site ("OpenVibe.Codes · github.com"), anything else as it is.++ let own = String(s2.info.sub || '');++ try { if (/^https?:\/\//.test(own)) { const u = new URL(own); const leaf = decodeURIComponent(u.pathname.split('/').filter(Boolean).pop() || ''); own = leaf ? `${leaf} · ${u.hostname.replace(/^www\./, '')}` : u.hostname.replace(/^www\./, ''); } } catch { /* as it is */ }++ b2.textContent = sharedTitle && own ? own : s2.info.title;++ }++ g.querySelector('.ovas-sum b').textContent = titled.length++ ? `${titled[titled.length - 1].title}${steps.length > 1 ? ` · ${steps.length}` : ''}`++ : kinds.length === 1 ? plural(kinds[0], count[kinds[0]]) : kinds.map((k) => `${count[k]} ${NOUN[k][count[k] === 1 ? 0 : 1]}`).join(' · ');const running = steps.find((s) => !s.done) || steps[steps.length - 1];const small = g.querySelector('.ovas-sum small');small.textContent = running ? (running.info.sub || running.info.title) : '';@@@ -688,7 +754,8 @@const pending = new Map(); // tool name → ids of its steps still waiting for a result (events without ids)// Any line may carry attachments, and a line of a type this component does not know still shows: a minimal// row with its label or summary, then its attachments (Codes' proof lines, for one).-- const KNOWN = new Set(['agent', 'text', 'tool', 'tool_progress', 'tool_result', 'handoff', 'note', 'step', 'usage', 'done', 'state']);++ const KNOWN = new Set(['agent', 'text', 'tool', 'tool_progress', 'tool_result', 'handoff', 'note', 'step', 'usage', 'done', 'state',++ 'phase', 'phases', 'activity', 'thinking', 'thinking_delta', 'text_start', 'text_delta', 'tool_args', 'sources']);function extraLine(x, target) {const li = el('li', 'ovas-extra');li.dataset.k = 'steps'; // it is part of the work, so the Steps filter keeps it@@@ -721,9 +788,9 @@appendLi(target, li); return;}if (x.t === 'tool') {-- const kind = kindOf(x.name);-- const step = { id: x.id || `s${S.steps.size}`, name: x.name, kind, input: x.input || {}, what: x.what || '', started: Date.now(), done: false, ok: null, out: '', attachments: safeAtts(x.attachments) };-- if (kind === 'plan') { if (Array.isArray(step.input.todos)) { S.plan = step.input.todos; paintPlan(); } step.hidden = true; S.steps.set(step.id, step); return; }++ const kind = x.kind && VERB[x.kind] ? x.kind : kindOf(x.name);++ const step = { id: x.id || `s${S.steps.size}`, name: x.name, kind, input: x.input || {}, what: x.what || '', title: x.title || '', started: Date.now(), done: false, ok: null, out: '', attachments: safeAtts(x.attachments) };++ if (kind === 'plan') { if (Array.isArray(step.input.todos)) { S.ownPlan = true; S.plan = step.input.todos; paintPlan(); } step.hidden = true; S.steps.set(step.id, step); return; }S.steps.set(step.id, step);const g = groupFor(target === feed ? feed : target);if (target === feed && empty.parentNode) empty.remove();@@@ -731,6 +798,21 @@g.firstChild.querySelector('ol').appendChild(stepEl(step));paintGroup(g); step.group = g; paintHead(); return;}++ if (x.t === 'phase' || x.t === 'phases' || x.t === 'activity') { onPhase(x, target); return; }++ if (x.t === 'thinking' || x.t === 'thinking_delta') { onThinking(x, target); return; }++ if (x.t === 'text_start' || x.t === 'text_delta') {++ let m = S.msgs.get(x.id);++ if (!m) { const li = el('li', 'ovas-msg'); li.dataset.k = 'msg'; m = { li, text: '' }; S.msgs.set(x.id, m); appendLi(target, li); }++ if (x.t === 'text_delta') { m.text = (m.text + x.delta).slice(0, MAX_TEXT); m.li.textContent = ''; m.li.appendChild(markdown(m.text, linkOk)); }++ return;++ }++ if (x.t === 'tool_args') {++ const step = S.steps.get(String(x.id)); if (!step) return;++ step.argText = (step.argText || '') + x.delta;++ try { step.input = JSON.parse(step.argText); step.info = describe(step); if (step.group) paintGroup(step.group); } catch { /* not whole yet */ }++ return;++ }++ if (x.t === 'sources') { addSources(x.items); return; }if (x.t === 'tool_progress') {const step = S.steps.get(String(x.id)); if (!step || !step.outEl) return;step.out = (step.out + x.chunk).slice(-20000); step.outEl.textContent = step.out; step.outEl.hidden = false; step.waitEl.hidden = true;@@@ -797,10 +879,69 @@appendLi(feed, li); paintHead();}}++ // Phases: named parts of the run (AG-UI steps). The plan row in the head shows where the run is ("2 of 5"), and a++ // phase that starts is a divider in the feed. A plan the agent keeps itself (TodoWrite) wins over phases.++ function onPhase(x, target) {++ if (x.t === 'activity') { S.activity = x.text; paintPlan(); return; }++ if (x.t === 'phases') { S.phases = x.items.map((p, i) => ({ id: `p${i}`, title: p.title, status: p.status })); }++ else {++ const same = (a, b) => String(a || '').trim().toLowerCase() === String(b || '').trim().toLowerCase();++ let p = S.phases.find((q) => q.id === x.id) || S.phases.find((q) => same(q.title, x.title || x.id));++ if (!p) { p = { id: x.id, title: x.title || x.id, status: 'running' }; S.phases.push(p); }++ if (x.title) p.title = x.title;++ p.status = x.status;++ if (x.status === 'running' && !p.li) {++ const li = el('li', 'ovas-phase'); li.dataset.k = 'phase';++ li.appendChild(el('span', 'ovas-phase-dot')); li.appendChild(el('b', '', p.title));++ p.li = li; appendLi(target === feed ? feed : target, li);++ }++ if (p.li) { p.li.dataset.s = p.status; p.li.querySelector('b').textContent = p.title; }++ }++ if (!S.ownPlan) {++ S.plan = S.phases.map((p) => ({ content: p.title, status: /done|complete/.test(p.status) ? 'completed' : /run|progress/.test(p.status) ? 'in_progress' : 'pending' }));++ paintPlan();++ }++ }++ // Thinking: folded to one line, "Thinking…" while it streams and "Thought for 1m 20s" after; its words open on a tap.++ function onThinking(x, target) {++ let th = S.thinks.get(x.id);++ if (!th) {++ const li = el('li', 'ovas-think'); li.dataset.k = 'msg';++ const d = el('details'); const sm = el('summary'); sm.appendChild(el('span', 'ovas-think-dots'));++ const label = el('span', '', 'Thinking…'); sm.appendChild(label); d.appendChild(sm);++ const body = el('div', 'ovas-think-body'); d.appendChild(body); li.appendChild(d);++ th = { li, label, body, text: '', started: Date.now() }; S.thinks.set(x.id, th); appendLi(target, li);++ }++ if (x.t === 'thinking_delta') { th.text = (th.text + x.delta).slice(-20000); th.body.textContent = th.text; }++ if (x.hidden) th.body.textContent = 'This model keeps its thinking to itself.';++ if (x.done || x.hidden) { th.li.classList.add('is-done'); th.label.textContent = `Thought for ${dur(x.duration_ms || Date.now() - th.started)}`; }++ }++ // Sources: every page the run read, once each, in one strip under the feed: the sites as letter tiles and a count;++ // opened, the titles as links (https only, a new tab).++ function addSources(items) {++ for (const it of (items || []).slice(0, 200)) {++ const url = it && String(it.url || '');++ if (!/^https:\/\//.test(url) || S.sources.has(url)) continue;++ let site = String(it.site || ''); try { if (!site) site = new URL(url).hostname.replace(/^www\./, ''); } catch { continue; }++ S.sources.set(url, { url, site, title: cut(String(it.title || site), 160) });++ }++ paintSources();++ }++ function paintSources() {++ const list = [...S.sources.values()];++ srcBox.hidden = !list.length; if (!list.length) return;++ const tiles = srcBox.querySelector('.ovas-src-tiles'); tiles.textContent = '';++ const sites = [...new Set(list.map((s) => s.site))];++ sites.slice(0, 5).forEach((site) => { const t = el('span', 'ovas-src-tile', site.charAt(0).toUpperCase()); t.title = site; let h = 0; for (const c of site) h = (h * 31 + c.charCodeAt(0)) % 360; t.style.setProperty('--h', String(h)); tiles.appendChild(t); });++ srcBox.querySelector('.ovas-src-n').textContent = `${list.length} source${list.length === 1 ? '' : 's'}${sites.length > 1 ? ` from ${sites.length} sites` : ''}`;++ const ol = srcBox.querySelector('ol'); ol.textContent = '';++ list.forEach((s) => { const li = el('li'); const a = el('a', '', s.title); a.href = s.url; a.target = '_blank'; a.rel = 'noopener noreferrer'; li.appendChild(a); li.appendChild(el('small', '', s.site)); ol.appendChild(li); });++ }function push(event) { normalize(event).forEach((x) => { try { onLine(x); } catch { /* one odd line never stops the view */ } }); applyFilter(); keep(); }function paintPlan() {const box2 = head.querySelector('.ovas-plan'); const todos = (S.plan || []).filter((t) => t && (t.content || t.activeForm));++ if (!todos.length && S.activity) { box2.hidden = false; box2.querySelector('.ovas-plan-now').textContent = S.activity; box2.querySelector('.ovas-plan-n').textContent = ''; box2.querySelector('i').style.width = '0%'; return; }box2.hidden = !todos.length; if (!todos.length) return;const doneN = todos.filter((t) => t.status === 'completed').length;box2.querySelector('i').style.width = `${Math.round((doneN / todos.length) * 100)}%`;@@@ -838,6 +979,9 @@});const fc = bar.querySelector('[data-f="files"]'); if (fc) fc.textContent = sm.files ? `Files ${sm.files}` : 'Files';const m = (k, html) => { const n = head.querySelector(`[data-m="${k}"]`); n.innerHTML = html; n.hidden = !html; };++ // What it is doing now (the portal's sticky line, 2026-11-11): the newest step that has not ended.++ const nowStep = LIVE.includes(S.status) ? [...S.steps.values()].reverse().find((st) => !st.done && !st.hidden) : null;++ m('now', nowStep && nowStep.info ? `<span class="ovas-now">${esc(String(nowStep.info.command || nowStep.info.title || '').split('\n')[0].slice(0, 90))}</span>` : '');m('time', sm.durationMs ? `<b>${esc(dur(sm.durationMs))}</b>` : '');m('steps', sm.steps ? `<b>${sm.steps}</b> step${sm.steps === 1 ? '' : 's'}${sm.commands ? ` · <b>${sm.commands}</b> command${sm.commands === 1 ? '' : 's'}` : ''}` : '');m('files', sm.files ? `<b>${sm.files}</b> file${sm.files === 1 ? '' : 's'} <span style="color:var(--ovas-ok)">+${sm.added}</span> <span style="color:var(--ovas-bad)">−${sm.removed}</span>` : '');@@@ -845,9 +989,62 @@}function status(s, label) {S.status = s; S.label = label || '';++ working.hidden = !LIVE.includes(s);if (!LIVE.includes(s)) { S.endedAt = S.endedAt || Date.now(); empty.textContent = 'Nothing was recorded for this run.'; }paintHead();}++ // Talking to the run while it works (opts.onSend(text, { files, uploads }) → a promise; the product sends it to its++ // agent: Codes' job message, Actor's task input, the portal's session). The box is the one composer on every site++ // (owner, 2026-11-11: "uses our custom modular composer across all sites"): files by picker, paste and drop, "/"++ // commands and the person's own, drafts, voice and their settings; loaded from beside this file when the page has++ // not loaded it. opts.composer: false keeps a plain box.++ if (typeof opts.onSend === 'function') {++ // The one composer's markup (thread.js composer()): the box, then a row with its tools and Send, then its note,++ // so ov-composer.js puts its + menu (files), "/" commands, voice and settings where every site has them.++ const form = el('form', 'ovas-say ovt-composer'); form.setAttribute('data-ovt-composer', '');++ const ta = el('textarea'); ta.name = 'text'; ta.rows = 1; ta.maxLength = 4000; ta.placeholder = opts.sayPlaceholder || 'Message the agent'; ta.setAttribute('aria-label', 'Message the agent'); ta.setAttribute('data-ovt-text', '');++ const row = el('div', 'ovt-c-row'); const toolsEl = el('div', 'ovt-c-tools');++ const send = el('button', 'ovas-say-send ovt-send'); send.type = 'submit'; send.setAttribute('aria-label', 'Send'); send.setAttribute('data-ovt-send', ''); send.innerHTML = svg('handoff');++ const noteEl = el('p', 'ovt-c-note'); noteEl.setAttribute('data-ovt-note', ''); noteEl.setAttribute('role', 'status');++ row.appendChild(toolsEl); row.appendChild(send);++ // A file input makes the box one that takes files (the composer's + menu, paste and drop); the files go to onSend.++ if (opts.files !== false) {++ // The shared "+" (thread.js attachHtml): a label holding the file input, first in the tools row.++ const plus = el('label', 'ovt-btn ovt-attach'); plus.title = 'Attach files';++ plus.innerHTML = '<svg class="ovt-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg><span class="ovt-sr">Attach files</span>';++ const fi = el('input'); fi.type = 'file'; fi.name = 'files'; fi.multiple = true; fi.hidden = true; fi.setAttribute('data-ovt-files', ''); if (opts.accept) fi.accept = String(opts.accept);++ plus.appendChild(fi); toolsEl.insertBefore(plus, toolsEl.firstChild);++ }++ form.appendChild(ta); form.appendChild(row); form.appendChild(noteEl);++ box.appendChild(form);++ ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit ? form.requestSubmit() : form.dispatchEvent(new Event('submit', { cancelable: true })); } });++ form.addEventListener('submit', async (e) => {++ e.preventDefault();++ const ovc = form.__ovc;++ const files = ovc ? ovc.files() : [], uploads = ovc && ovc.uploads ? ovc.uploads() : [];++ const text = ta.value.trim(); if ((!text && !files.length) || form.classList.contains('is-busy')) return;++ form.classList.add('is-busy');++ const li = el('li', 'ovas-you'); li.dataset.k = 'msg'; li.appendChild(el('span', '', text));++ if (files.length) li.appendChild(el('small', 'ovas-you-files', files.map((f) => f.name || 'a file').join(' · ')));++ appendLi(feed, li); keep();++ try { await opts.onSend(text, { files, uploads }); if (ovc && ovc.clear) ovc.clear(); else ta.value = ''; li.classList.add('is-sent'); }++ catch (err) { li.classList.add('is-failed'); li.title = (err && err.message) || 'Not sent'; }++ finally { form.classList.remove('is-busy'); }++ });++ if (opts.composer !== false) {++ form.id = form.id || `ovas-say-${Math.random().toString(36).slice(2, 8)}`;++ const join = () => { try { if (root.OpenVibeComposer && form.isConnected) root.OpenVibeComposer.enhance(form); } catch { /* a plain box still sends */ } };++ if (root.OpenVibeComposer) join();++ else {++ const m = /^(https?:\/\/[^?#]*\/)agent-session(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(SELF_SRC);++ if (m) {++ let sc = document.getElementById('ov-composer-script');++ if (!sc) { sc = document.createElement('script'); sc.id = 'ov-composer-script'; sc.async = true; sc.src = m[1] + 'ov-composer.js'; document.head.appendChild(sc); }++ sc.addEventListener('load', join, { once: true });++ }++ }++ }++ }const tick = setInterval(() => { if (S.status === 'running' || S.status === 'placed') { S.steps.forEach((st) => { if (!st.done) setStepState(st); }); paintHead(); } }, 1000);paintHead();return { push, pushAll: (list) => (list || []).forEach(push), status, summary, actions: (list, note) => paintActions(list, note), files: () => changedFiles().map((f) => ({ path: f.path, added: f.added, removed: f.removed, changes: f.n })), destroy() { clearInterval(tick); host.textContent = ''; } };nnew file mode 100644@@@ -0,0 +1,423 @@++{++ "groups": [++ {++ "id": "watch",++ "name": "Watch & stream"++ },++ {++ "id": "talk",++ "name": "Talk & belong"++ },++ {++ "id": "play",++ "name": "Play"++ },++ {++ "id": "read",++ "name": "Read & ask"++ },++ {++ "id": "shop",++ "name": "Shop & money"++ },++ {++ "id": "tools",++ "name": "Free tools"++ },++ {++ "id": "make",++ "name": "Make with AI"++ },++ {++ "id": "build",++ "name": "For developers"++ }++ ],++ "sites": [++ {++ "id": "live",++ "name": "Live",++ "url": "https://openvibe.live/",++ "description": "Go live, watch streams",++ "group": "watch"++ },++ {++ "id": "community",++ "name": "Community",++ "url": "https://openvibe.community/",++ "description": "Posts and pastes",++ "group": "talk"++ },++ {++ "id": "chat",++ "name": "Chat",++ "url": "https://openvibe.chat/",++ "description": "Chat with anyone",++ "group": "talk"++ },++ {++ "id": "media-hub",++ "name": "Video",++ "url": "https://openvibe.video/",++ "description": "Clips and past streams",++ "group": "watch"++ },++ {++ "id": "pics",++ "name": "Pics",++ "url": "https://openvibe.pics/",++ "description": "Share pictures",++ "group": "watch"++ },++ {++ "id": "download",++ "name": "Download",++ "url": "https://openvibe.download/",++ "description": "Your files",++ "group": "watch"++ },++ {++ "id": "tools",++ "name": "Tools",++ "url": "https://openvibe.tools/",++ "description": "Every online tool",++ "group": "tools"++ },++ {++ "id": "bot",++ "name": "Bot",++ "url": "https://openvibe.bot/",++ "description": "Drive a robot",++ "group": "make"++ },++ {++ "id": "space",++ "name": "Space",++ "url": "https://openvibe.space/",++ "description": "Host a site or app",++ "group": "make"++ },++ {++ "id": "tips",++ "name": "Tips",++ "url": "https://openvibe.tips/",++ "description": "Support streamers",++ "group": "talk"++ },++ {++ "id": "news",++ "name": "News",++ "url": "https://openvibe.news/",++ "description": "Sourced, not spun",++ "group": "read"++ },++ {++ "id": "games",++ "name": "Games",++ "url": "https://openvibe.games/",++ "description": "Play in your browser",++ "group": "play"++ },++ {++ "id": "vip",++ "name": "VIP",++ "url": "https://openvibe.vip/",++ "description": "Back your creators",++ "group": "talk"++ },++ {++ "id": "report",++ "name": "Report",++ "url": "https://openvibe.report/",++ "description": "Your numbers, honest",++ "group": "read"++ },++ {++ "id": "blog",++ "name": "Blog",++ "url": "https://openvibe.blog/",++ "description": "Write long-form",++ "group": "read"++ },++ {++ "id": "coupons",++ "name": "Coupons",++ "url": "https://openvibe.coupons/",++ "description": "Codes that work",++ "group": "shop"++ },++ {++ "id": "deals",++ "name": "Deals",++ "url": "https://openvibe.deals/",++ "description": "Deals people share",++ "group": "shop"++ },++ {++ "id": "actor",++ "name": "Actor",++ "url": "https://openvibe.actor/",++ "description": "The agent engine",++ "group": "make"++ },++ {++ "id": "design",++ "name": "Design",++ "url": "https://openvibe.design/",++ "description": "Try themes live",++ "group": "tools"++ },++ {++ "id": "codes",++ "name": "Codes",++ "url": "https://openvibe.codes/",++ "description": "Code with any agent",++ "group": "make"++ },++ {++ "id": "wiki",++ "name": "Wiki",++ "url": "https://openvibe.wiki/",++ "description": "Facts with sources",++ "group": "read"++ },++ {++ "id": "exchange",++ "name": "Exchange",++ "url": "https://openvibe.exchange/",++ "description": "AI models, one account",++ "group": "build"++ },++ {++ "id": "reviews",++ "name": "Reviews",++ "url": "https://openvibe.reviews/",++ "description": "Honest reviews",++ "group": "shop"++ },++ {++ "id": "trade",++ "name": "Trade",++ "url": "https://openvibe.trade/",++ "description": "Watch the markets",++ "group": "shop"++ },++ {++ "id": "host",++ "name": "Host",++ "url": "https://openvibe.host/",++ "description": "Compare hosting",++ "group": "tools"++ },++ {++ "id": "fun",++ "name": "Fun",++ "url": "https://openvibe.fun/",++ "description": "Something to do",++ "group": "play"++ },++ {++ "id": "network",++ "name": "Network",++ "url": "https://openvibe.network/",++ "description": "Your account",++ "group": "talk"++ },++ {++ "id": "rent",++ "name": "Rent",++ "url": "https://openvibe.rent/",++ "description": "Places to rent",++ "group": "shop"++ },++ {++ "id": "computer",++ "name": "Computer",++ "url": "https://openvibe.computer/",++ "description": "Your computer, your way",++ "group": "make"++ },++ {++ "id": "work",++ "name": "Work",++ "url": "https://openvibe.work/",++ "description": "Find a job",++ "group": "shop"++ },++ {++ "id": "inventory",++ "name": "Inventory",++ "url": "https://openvibe.my/inventory",++ "description": "Your items",++ "group": "play"++ },++ {++ "id": "services",++ "name": "Services",++ "url": "https://openvibe.services/",++ "description": "APIs, SDK and keys",++ "group": "build"++ },++ {++ "id": "tours",++ "name": "Tours",++ "url": "https://openvibe.tours/",++ "description": "Cities, routes, stays",++ "group": "watch"++ },++ {++ "id": "vision",++ "name": "Vision",++ "url": "https://openvibe.vision/",++ "description": "Ask a picture",++ "group": "read"++ },++ {++ "id": "casino",++ "name": "Casino",++ "url": "https://openvibe.casino/",++ "description": "Odds shown, chips free",++ "group": "play"++ },++ {++ "id": "food",++ "name": "Food",++ "url": "https://openvibe.food/",++ "description": "Food near you",++ "group": "tools"++ },++ {++ "id": "surf",++ "name": "Surf",++ "url": "https://openvibe.surf/",++ "description": "Surf maps and the web",++ "group": "play"++ },++ {++ "id": "today",++ "name": "Today",++ "url": "https://openvibe.today/",++ "description": "Your schedule",++ "group": "read"++ },++ {++ "id": "domains",++ "name": "Domains",++ "url": "https://openvibe.domains/",++ "description": "Names and prices",++ "group": "tools"++ },++ {++ "id": "quest",++ "name": "Quest",++ "url": "https://openvibe.quest/",++ "description": "Earn badges",++ "group": "play"++ },++ {++ "id": "events",++ "name": "Events",++ "url": "https://openvibe.events/",++ "description": "Event streams",++ "group": "build"++ },++ {++ "id": "cam",++ "name": "Cam",++ "url": "https://openvibe.cam/",++ "description": "Your camera, in private",++ "group": "watch"++ },++ {++ "id": "run",++ "name": "Run",++ "url": "https://openvibe.run/",++ "description": "Run jobs anywhere",++ "group": "build"++ },++ {++ "id": "zone",++ "name": "Zone",++ "url": "https://openvibe.zone/",++ "description": "S3 storage",++ "group": "build"++ },++ {++ "id": "help",++ "name": "Help",++ "url": "https://openvibe.help/",++ "description": "Help nearby",++ "group": "talk"++ },++ {++ "id": "meme",++ "name": "Meme",++ "url": "https://openvibe.meme/",++ "description": "Memes with receipts",++ "group": "play"++ },++ {++ "id": "mail",++ "name": "Email",++ "url": "https://openvibe.email/",++ "description": "Masks and inboxes",++ "group": "talk"++ },++ {++ "id": "stream",++ "name": "Restream",++ "url": "https://openre.stream/",++ "description": "Stream everywhere",++ "group": "watch"++ },++ {++ "id": "shoes",++ "name": "Shoes",++ "url": "https://open.shoes/",++ "description": "Every shoe, every price",++ "group": "shop"++ },++ {++ "id": "connect",++ "name": "Connect",++ "url": "https://connect.openvibe.link/",++ "description": "Share a port with Link",++ "group": "make"++ },++ {++ "id": "watch",++ "name": "Watch",++ "url": "https://openvibe.watch/",++ "description": "Know when it changes",++ "group": "build"++ },++ {++ "id": "image",++ "name": "Studio",++ "url": "https://openvibe.studio/",++ "description": "Edit photos in your browser",++ "group": "tools"++ },++ {++ "id": "search",++ "name": "Search",++ "url": "https://search.openvibe.network/",++ "description": "Search everything",++ "group": "read"++ },++ {++ "id": "lock",++ "name": "Locker",++ "url": "https://openvibe.locker/",++ "description": "Files only you can open",++ "group": "tools"++ },++ {++ "id": "git",++ "name": "Git",++ "url": "https://git.openvibe.codes/",++ "description": "Host and browse code",++ "group": "build"++ }++ ]++}nnew file mode 100644@@@ -0,0 +1,32 @@++'use strict';++++const seo = require('./seo');++const catalog = require('./crawl-sites.json');++const esc = (value) => String(value == null ? '' : value).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++const absolute = (url, base) => new URL(url, base).href;++++/** Static navigation, network links and discovery metadata for HTML sent before JavaScript runs. */++function render({ name, service, url, description = '', links, pages = [], searchUrl } = {}) {++ if (!name || !url) throw new TypeError('crawlable.render requires name and url');++ const base = new URL(url).origin;++ const ownLinks = Array.isArray(links) ? links : [];++ const ownItems = ownLinks.flatMap((item) => Array.isArray(item.items) ? item.items : [item])++ .filter((item) => item && (item.href || item.url) && (item.label || item.name || item.title))++ .map((item) => ({ label: item.label || item.name || item.title, href: item.href || item.url }));++ const nav = `<nav aria-label="${esc(name)}"><ul><li><a href="/">Home</a></li>${ownItems.filter((item) => item.href !== '/').map((item) => `<li><a href="${esc(item.href)}">${esc(item.label)}</a></li>`).join('')}</ul></nav>`;++ const network = catalog.sites.filter((site) => site.id !== service);++ const footer = `<nav aria-label="OpenVibe">${catalog.groups.map((group) => {++ const sites = network.filter((site) => site.group === group.id);++ return sites.length ? `<section><h2>${esc(group.name)}</h2><ul>${sites.map((site) => `<li><a href="${esc(site.url)}">${esc(site.name)}</a> — ${esc(site.description)}</li>`).join('')}</ul></section>` : '';++ }).join('')}</nav>`;++ const sameAs = [...new Set(catalog.sites.map((site) => new URL(site.url).origin))];++ const jsonLd = [seo.jsonLd.organization({ sameAs }), seo.jsonLd.website({ name, url: base + '/', description, searchUrl })];++ const head = jsonLd.map((item) => `<script type="application/ld+json">${JSON.stringify(item).replace(/</g, '\\u003c')}</script>`).join('\n');++ const llmsTxt = seo.llmsTxt({ name, summary: description || name, sections: [++ { title: 'Main pages', links: [{ title: 'Home', url: base + '/' }, ...pages.map((page) => ({ title: page.title || page.name, url: absolute(page.url || page.href, base) }))] },++ ...catalog.groups.map((group) => ({ title: group.name, links: network.filter((site) => site.group === group.id).map((site) => ({ title: site.name, url: site.url, note: site.description })) })).filter((section) => section.links.length),++ ] });++ return { nav, footer, head, jsonLd, llmsTxt };++}++++module.exports = { render, catalog };@@@ -58,7 +58,7 @@// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';-- const VIBE_REV = { 'ov-composer.js': 'f52e2323e6e9' };++ const VIBE_REV = { 'ov-composer.js': 'fbe0a5b0d179' };function joinComposer(form) {if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain boxform.setAttribute('data-ovc-no-files', '');@@@ -89,7 +89,7 @@}// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.-- const DOCK_REV = { 'dock-vibe.js': '81365d0289bb', 'dock-code.js': '3dcbaca10bfb', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };++ const DOCK_REV = { 'dock-vibe.js': '7f3c25bc076d', 'dock-code.js': '3dcbaca10bfb', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };function load(src, test) {return new Promise((ok) => {if (test()) return ok(true);@@@ -607,7 +607,7 @@* serves its own pinned copy at /shared/ gets its own), else the network's copy.*/// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '771efd0e3963', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '8eec18660ff4', 'dock.js': '31d1cac08de5', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'bottom.js': 'b9f708d04350', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': 'ea69b8a08868', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'e84894600223', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };++ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '771efd0e3963', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '8eec18660ff4', 'dock.js': '0748f4087385', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'bottom.js': 'b9f708d04350', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': 'ea69b8a08868', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'e84894600223', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;// ov-tip.js loads at the first hover over a tooltip's owner.@@@ -19,6 +19,8 @@* upload(file, onProgress) → Promise<{ id }>, files go to the site's store before the send* beforeSend(ctx) → false to hold the send })* ctx: { form, text, value, set(v), insert(s), files(), add(files), note(s), send() }.++ * A box its page sends by script (a chat, an agent's run): form.__ovc.files() → the files (File objects), uploads(): the++ * ids a mod's upload gave, clear() after the send (the text, the chips, the draft); a form 'reset' clears the same.* A site without script says the same in data: data-ovt-commands='[{"name","hint","insert"}]', data-ovc-max-files,* data-ovc-max-mb, data-ovc-no-draft.*/@@@ -86,7 +88,16 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data.ovc-set-add { display: grid; grid-template-columns: 110px 1fr auto; gap: 8px; align-items: start; }.ovc-set-add input, .ovc-set-add textarea { box-sizing: border-box; width: 100%; padding: 9px 10px; border-radius: 10px; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .25)); background: var(--bg-input, rgba(2, 6, 14, .35)); color: inherit; font: inherit; font-size: 14px; resize: vertical; }.ovc-set-btn { height: 40px; padding: 0 14px; border: 0; border-radius: 12px; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font: inherit; font-weight: 650; cursor: pointer; }--@media (max-width: 480px) { .ovc-set-add { grid-template-columns: 1fr; } .ovc-file { max-width: 200px; } }`;++@media (max-width: 480px) { .ovc-set-add { grid-template-columns: 1fr; } .ovc-file { max-width: 200px; } }++/* 4.205.0: the composer's own tool buttons and row on a page without thread.css (an agent's run, a card, any box), so it++ is the same composer everywhere. Where thread.css is loaded its rules are the same. */++.ovc .ovt-btn { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 12px; border: 0; background: none; color: var(--text-secondary, #b4c0d4); cursor: pointer; text-decoration: none; }++.ovc .ovt-btn:hover, .ovc .ovt-btn:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 6%, transparent)); color: var(--text-primary, #e6edf7); }++.ovc .ovt-btn .ovt-ic, .ovc .ovt-btn svg { width: 22px; height: 22px; }++.ovc .ovt-c-row { display: flex; align-items: center; gap: 6px; }++.ovc .ovt-c-tools { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }++.ovc .ovt-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }++`;function styles() { if (document.getElementById('ovc-styles')) return; const st = document.createElement('style'); st.id = 'ovc-styles'; st.textContent = CSS; document.head.appendChild(st); }const mods = [];// 4.202.0: your settings open in the one window (ov-window.js: a sheet on a phone, swiped down to close), loaded@@@ -495,9 +506,12 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[dataform, ctx,refresh() { form.classList.toggle('ovc-compact', prefs.density === 'compact'); micButton(); paintTools(); syncSend(); },add, files: () => files.map((f) => f.file),++ uploads: () => files.map((f) => f.id).filter(Boolean),++ clear() { files.slice().forEach(remove); text.value = ''; if (draftKey) store.set(draftKey, null); fire(); },destroy() { live.delete(api); pop.remove(); strip.remove(); if (mic) mic.remove(); modTools.forEach((b) => b.remove()); form.__ovc = null; },};form.__ovc = api;++ form.addEventListener('reset', () => setTimeout(() => api.clear(), 0));live.add(api);form.classList.add('ovc');api.refresh();@@@ -24,7 +24,7 @@// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);-- const THREAD_REV = { 'ov-composer.js': 'f52e2323e6e9', 'ov-combo.js': '3e426e56fc75' };++ const THREAD_REV = { 'ov-composer.js': 'fbe0a5b0d179', 'ov-combo.js': '3e426e56fc75' };var selfSrc = (document.currentScript && document.currentScript.src) || '';// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.function loadCombo() {@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.204.0",++ "version": "4.205.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.204.0",++ "version": "4.205.0","license": "MIT","dependencies": {"clean-css": "^5.3.3",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.204.0",++ "version": "4.205.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -128,6 +128,7 @@"./vibes-pay": "./vibes-pay.js","./trace": "./trace.js","./csp": "./csp.js",++ "./crawlable": "./crawlable.js","./ui": "./ui.js","./url-resolver": "./url-resolver.js","./ov-user": "./ov-user.js",nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++// Protocol 1.2 (ask 354, 2026-11-11): agent-session.js reads AG-UI 1.0 events (docs.ag-ui.com) with OpenVibe's words in++// metadata.ov, so any agent that speaks AG-UI renders in the one step view: phases, steps with their own titles and++// sources, thinking, streamed text, the plan, the activity line and the CUSTOM ov.* events.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const A = require('../agent-session.js');++const n = (e) => A.normalize(e);++++assert.deepStrictEqual(n({ type: 'RUN_STARTED' }), [{ t: 'state', state: 'running' }]);++assert.deepStrictEqual(n({ type: 'STEP_STARTED', stepName: 'Read the repos' }), [{ t: 'phase', id: 'Read the repos', title: 'Read the repos', status: 'running' }]);++assert.deepStrictEqual(n({ type: 'STEP_FINISHED', step_name: 'Read the repos' }), [{ t: 'phase', id: 'Read the repos', status: 'done' }], 'snake_case fields too');++const tool = n({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'web_fetch', metadata: { ov: { title: 'Fetching README files', kind: 'web' } } })[0];++assert.strictEqual(tool.t, 'tool');++assert.strictEqual(tool.title, 'Fetching README files');++assert.strictEqual(tool.kind, 'web');++assert.deepStrictEqual(n({ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: '{"u' }), [{ t: 'tool_args', id: 't1', delta: '{"u' }]);++const res = n({ type: 'TOOL_CALL_RESULT', toolCallId: 't1', content: 'ok', metadata: { ov: { sources: [{ url: 'https://a.example/x' }] } } });++assert.deepStrictEqual(res.map((x) => x.t), ['sources', 'tool_result'], 'a result\'s sources come first');++assert.deepStrictEqual(n({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm1', delta: 'Hi' }), [{ t: 'text_delta', id: 'm1', delta: 'Hi' }]);++assert.deepStrictEqual(n({ type: 'REASONING_END', messageId: 'r1' }), [{ t: 'thinking', id: 'r1', done: true }]);++assert.deepStrictEqual(n({ type: 'REASONING_ENCRYPTED_VALUE', messageId: 'r1' }), [{ t: 'thinking', id: 'r1', hidden: true }], 'an encrypted thought shows only that it happened');++assert.deepStrictEqual(n({ type: 'STATE_SNAPSHOT', snapshot: { plan: [{ title: 'A', status: 'completed' }] } }), [{ t: 'phases', items: [{ title: 'A', status: 'completed' }] }]);++assert.deepStrictEqual(n({ type: 'ACTIVITY_SNAPSHOT', content: { title: 'Comparing' } }), [{ t: 'activity', text: 'Comparing' }]);++assert.deepStrictEqual(n({ type: 'CUSTOM', name: 'ov.sources', value: [{ url: 'https://b.example' }] }), [{ t: 'sources', items: [{ url: 'https://b.example' }] }]);++assert.strictEqual(n({ type: 'RUN_ERROR', message: 'boom' })[0].ok, false);++assert.deepStrictEqual(n({ type: 'SOMETHING_NEW' }), [], 'an unknown family is ignored, never an error');++assert.deepStrictEqual(n({ type: 'assistant', message: { content: [{ type: 'text', text: 'x' }] } }).map((x) => x.t), ['text'], 'Claude stream-json still reads as before');++++const js = fs.readFileSync(path.join(__dirname, '..', 'agent-session.js'), 'utf8');++assert.ok(js.includes("if (!/^https:\\/\\//.test(url) || S.sources.has(url)) continue;"), 'sources are https only, once each');++assert.ok(js.includes("a.rel = 'noopener noreferrer'") && js.includes("a.target = '_blank'"), 'a source opens in a new tab, without the opener');++const css = fs.readFileSync(path.join(__dirname, '..', 'agent-session.css'), 'utf8');++['.ovas-phase', '.ovas-think', '.ovas-sources', '.ovas--card', '.ovas-open'].forEach((sel) => assert.ok(css.includes(sel), sel));++console.log('agent-session-agui: AG-UI events read as phases, titled steps, sources, thinking and text');nnew file mode 100644@@@ -0,0 +1,20 @@++'use strict';++const assert = require('assert');++const crawlable = require('../crawlable');++++const page = crawlable.render({++ name: 'OpenVibe.Test', service: 'live', url: 'https://openvibe.test/', description: 'Test site',++ links: [{ label: 'Latest', href: '/latest' }, { label: 'About <us>', href: '/about' }],++ pages: [{ title: 'Latest', url: '/latest' }], searchUrl: 'https://openvibe.test/search?q={q}',++});++assert.match(page.nav, /<nav aria-label="OpenVibe\.Test"><ul><li><a href="\/">Home<\/a>/);++assert.match(page.nav, /href="\/about">About <us>/);++assert.match(page.footer, /<nav aria-label="OpenVibe">/);++assert.ok(page.catalog === undefined);++assert.ok(page.jsonLd.some((item) => item['@type'] === 'Organization' && item.sameAs.length === crawlable.catalog.sites.length));++assert.ok(page.jsonLd.some((item) => item['@type'] === 'WebSite' && item.potentialAction['@type'] === 'SearchAction'));++page.jsonLd.forEach((item) => JSON.parse(JSON.stringify(item)));++assert.match(page.llmsTxt, /Main pages[\s\S]*Latest[\s\S]*Watch & stream/);++assert.ok(page.catalog === undefined, 'render output contains only page artifacts');++assert.throws(() => crawlable.render({}), /requires name and url/);++console.log('crawlable: all checks passed');
@@ -1,5 +1,24 @@
@@ -1,5 +1,24 @@
# Changelog
# Changelog
+## 4.205.0 — 2026-11-11
+
+**Agents show their work everywhere: the one step view reads AG-UI, compact like the portal, with the one composer to
+talk to the run; and every site's first HTML is crawlable.**
+
+- **The step view reads AG-UI 1.0 (`agent-session.js`, protocol 1.2, ask 354).** Run, step, text, tool call,
+ reasoning, state, activity and custom events, with OpenVibe's words in `metadata.ov` (title, kind, sources): phases
+ with a plan row ("1 of 3"), steps under the agent's own titles (rows under one title show their own target), thinking
+ folded to one line, sources gathered in one strip (site tiles, https links), text streamed in place.
+- **Compact runs.** A group is one line (its state, its words, the step it is on in mono, the chevron), the head says
+ what it is doing now, three dots while it works; `mount(el, { mode: 'card', href })` for a chat message, the Dock or
+ a feed card.
+- **Talk to the run with the one composer.** `mount(el, { onSend(text, { files, uploads }) })`: the shared composer's
+ markup ("+" for files, paste and drop, "/" commands, voice, drafts, settings), loaded from beside the view.
+- **The composer on any page (`ov-composer.js`).** `clear()` and `uploads()` for boxes sent by script, a form reset
+ clears it, and it styles its own tool buttons and row, so it looks the same without thread.css.
+- **`openvibe-shared/crawlable` (was #457).** Static navigation, the network's sites by group, JSON-LD and llms.txt for
+ the HTML a site sends before its scripts run.
+
## 4.204.0 — 2026-11-11
## 4.204.0 — 2026-11-11
**Buttons that show their state, windows that resize, the one composer everywhere, players that start sooner, and one
**Buttons that show their state, windows that resize, the one composer everywhere, players that start sooner, and one
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**Install and test:** `npm ci && npm test`
-**Status:** alpha, v4.204.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.205.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,13 +4,14 @@
@@ -4,13 +4,14 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.204.0",
+ "version": "4.205.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"contracts": "openvibe-contracts v0.399.0 (devDependency: the release manifest contract)",
"contracts": "openvibe-contracts v0.399.0 (devDependency: the release manifest contract)",
"sdk": "openvibe-sdk v0.147.0",
"sdk": "openvibe-sdk v0.147.0",
"features": [
"features": [
+ "the one step view reads AG-UI (phases, titled steps, sources, thinking, compact groups, card mode) with the one composer to talk to a run, and crawlable first HTML for every site (4.205.0)",
"buttons that show their state (kinds, badge, dot, attention, busy/done/error, hold), resizable panel windows, the one composer everywhere, players that start sooner, and openvibe-shared/csp (4.204.0)",
"buttons that show their state (kinds, badge, dot, attention, busy/done/error, hold), resizable panel windows, the one composer everywhere, players that start sooner, and openvibe-shared/csp (4.204.0)",
"the Dock updates edited DM text and link cards live and lets authors edit their own messages (4.203.0)",
"the Dock updates edited DM text and link cards live and lets authors edit their own messages (4.203.0)",
"the shared field dropdown, one dialog/sheet/panel window, user cards, eight site groups, measured page ends and remade reveal (4.202.0)",
"the shared field dropdown, one dialog/sheet/panel window, user cards, eight site groups, measured page ends and remade reveal (4.202.0)",
@@ -202,3 +202,82 @@ details[open] > summary .ovas-chev { transform: rotate(90deg); }
@@ -202,3 +202,82 @@ details[open] > summary .ovas-chev { transform: rotate(90deg); }
.ovas-sheet-x, .ovas-sheet-foot a { min-height: 44px; }
.ovas-sheet-x, .ovas-sheet-foot a { min-height: 44px; }
.ovas-prev-img { max-height: 68vh; }
.ovas-prev-img { max-height: 68vh; }
}
}
+
+/* ── Protocol 1.2 (4.205.0, ask 354): phases, thinking, sources, card mode ───────────────────────────────────── */
+.ovas-phase { display: flex; align-items: center; gap: 9px; margin: 6px 2px 0; font-size: 12.5px; letter-spacing: .02em; color: var(--ovas-mut, var(--text-secondary, #9fb0cf)); }
+.ovas-phase b { font-weight: 750; color: var(--text-primary, #e6edf7); }
+.ovas-phase-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; background: var(--accent, #3b82f6); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); }
+.ovas-phase[data-s="running"] .ovas-phase-dot { animation: ovasBreathe 1.6s ease-in-out infinite; }
+.ovas-phase[data-s="done"] .ovas-phase-dot { background: var(--ovas-ok, #22c55e); box-shadow: none; }
+.ovas-phase::after { content: ""; flex: 1; height: 1px; background: var(--ovas-b, rgba(255,255,255,.1)); }
+@keyframes ovasBreathe { 50% { transform: scale(calc(1 - .25 * var(--ov-move, 1))); opacity: calc(1 - .5 * var(--ov-move, 1)); } }
+.ovas-think details { border-radius: 12px; }
+.ovas-think summary { display: flex; align-items: center; gap: 8px; cursor: pointer; list-style: none; font-size: 13px; color: var(--ovas-mut, var(--text-secondary, #9fb0cf)); }
+.ovas-think summary::-webkit-details-marker { display: none; }
+.ovas-think-dots { width: 18px; height: 6px; flex: none; background: radial-gradient(circle, currentColor 2px, transparent 2.5px) 0 50% / 6px 6px repeat-x; animation: ovasDots 1.1s linear infinite; }
+.ovas-think.is-done .ovas-think-dots { animation: none; opacity: .5; }
+@keyframes ovasDots { 50% { opacity: .35; } }
+.ovas-think-body { margin: 6px 0 0 26px; padding: 8px 12px; border-left: 2px solid var(--ovas-b, rgba(255,255,255,.1)); font-size: 13px; line-height: 1.5; white-space: pre-wrap; color: var(--ovas-mut, var(--text-secondary, #9fb0cf)); max-height: 320px; overflow: auto; }
+.ovas-sources { border: 1px solid var(--ovas-b, rgba(255,255,255,.1)); border-radius: 14px; padding: 8px 12px; background: var(--ovas-t, rgba(255,255,255,.04)); }
+.ovas-sources > summary { display: flex; align-items: center; gap: 10px; cursor: pointer; list-style: none; font-size: 13px; font-weight: 650; }
+.ovas-sources > summary::-webkit-details-marker { display: none; }
+.ovas-src-tiles { display: inline-flex; }
+.ovas-src-tile { display: inline-grid; place-items: center; width: 22px; height: 22px; margin-left: -6px; border-radius: 7px; font-size: 11px; font-weight: 800; color: #fff;
+ background: hsl(var(--h, 210) 55% 42%); box-shadow: 0 0 0 2px var(--ovas-s, #121826); }
+.ovas-src-tile:first-child { margin-left: 0; }
+.ovas-sources ol { display: grid; gap: 6px; margin: 10px 0 2px; padding: 0; list-style: none; max-height: 340px; overflow: auto; }
+.ovas-sources li { display: grid; gap: 1px; min-width: 0; }
+.ovas-sources li a { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: none; font-size: 13.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovas-sources li a:hover { text-decoration: underline; }
+.ovas-sources li small { color: var(--ovas-mut, var(--text-secondary, #9fb0cf)); font-size: 11.5px; }
+.ovas--card { gap: 8px; }
+.ovas--card .ovas-head { position: static; padding: 10px 12px; }
+.ovas--card :is(.ovas-bar, .ovas-files) { display: none !important; }
+.ovas--card .ovas-feed > li:not(:nth-last-child(-n+3)) { display: none; }
+.ovas--card .ovas-feed > li .ovas-out { max-height: 120px; }
+.ovas-open { justify-self: start; display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 12px; border-radius: 10px; font-size: 13px; font-weight: 700; text-decoration: none;
+ color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); }
+html[data-ov-motion="off"] :is(.ovas-phase-dot, .ovas-think-dots) { animation: none !important; }
+/* 3. A card keeps its groups folded: the head says what they did. */
+.ovas--card .ovas-group:not(.is-one) > :not(summary) { display: none; }
+.ovas--card .ovas-group:not(.is-one) > summary .ovas-chev { display: none; }
+
+/* ── Compact runs (2026-11-11, the owner with the portal's run view open: "consider stuff like this … parse and expandable
+ and compact mode … smart"): a group is one line, its state first, its words, the step it is on in mono, the chevron
+ last; the steps inside are light rows; the head says what it is doing now; three dots while it works; a box to talk
+ to it. Every motion follows --ov-move. */
+.ovas-feed { gap: 6px; }
+.ovas-group > summary { min-height: 42px; padding: 4px 10px; gap: 9px; }
+.ovas-group:not(.is-one) > summary .ovas-state { order: -1; }
+.ovas-group:not(.is-one) > summary .ovas-icons { display: none; }
+.ovas-group:not(.is-one) > summary .ovas-chev { order: 99; transition: transform var(--ov-dur-2, .2s) var(--ov-ease-out, ease); }
+.ovas-group[open]:not(.is-one) > summary .ovas-chev { transform: rotate(90deg); }
+.ovas-group:not(.is-one) > summary .ovas-sum { display: flex; align-items: baseline; gap: 9px; }
+.ovas-group:not(.is-one) > summary .ovas-sum b { flex: none; max-width: 60%; }
+.ovas-group:not(.is-one) > summary .ovas-sum small { flex: 1 1 auto; min-width: 0; }
+.ovas-group > ol .ovas-step { border: 0; background: transparent; border-radius: 10px; }
+.ovas-group > ol .ovas-step > summary { min-height: 38px; padding: 3px 8px; }
+.ovas-group > ol .ovas-step > summary:hover { background: color-mix(in srgb, var(--text-primary, #fff) 5%, transparent); }
+.ovas-group > ol .ovas-kind { width: 24px; height: 24px; border-radius: 7px; }
+.ovas-meta [data-m="now"] { flex: 1 1 100%; min-width: 0; order: -1; }
+.ovas-now { display: flex; align-items: center; gap: 7px; min-width: 0; font: 12.5px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text-primary, #e6edf7); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
+.ovas-now::before { content: ""; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #3b82f6); animation: ovasBreathe 1.6s ease-in-out infinite; }
+.ovas-working { display: flex; gap: 5px; padding: 4px 6px; }
+.ovas-working[hidden] { display: none; }
+.ovas-working i { width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #3b82f6); opacity: .35; animation: ovasWork 1.2s ease-in-out infinite; }
+.ovas-working i:nth-child(2) { animation-delay: .16s; } .ovas-working i:nth-child(3) { animation-delay: .32s; }
+@keyframes ovasWork { 40% { opacity: 1; transform: translateY(calc(-3px * var(--ov-move, 1))); } }
+.ovas-you { justify-self: end; max-width: 85%; padding: 8px 12px; border-radius: 14px 14px 4px 14px; background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); font-size: 14px; white-space: pre-wrap; overflow-wrap: anywhere; }
+.ovas-you:not(.is-sent):not(.is-failed) { opacity: .6; }
+.ovas-you.is-failed { outline: 1px solid var(--ovas-bad, #ef4444); }
+.ovas-say { position: sticky; bottom: 0; display: grid; gap: 4px; padding: 8px; border-radius: 16px; border: 1px solid var(--ovas-b, rgba(255,255,255,.1)); background: var(--ovas-s, #121826); }
+.ovas-say textarea { flex: 1; min-height: 40px; max-height: 160px; resize: none; padding: 9px 12px; border: 0; border-radius: 12px; background: var(--ovas-t, rgba(255,255,255,.05)); color: inherit; font: inherit; font-size: 15px; }
+.ovas-say textarea:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 0; }
+.ovas-say-send { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border: 0; border-radius: 12px; background: var(--accent-strong, var(--accent, #2563eb)); color: #fff; cursor: pointer; }
+.ovas-say.is-busy .ovas-say-send { opacity: .5; }
+html[data-ov-motion="off"] :is(.ovas-now, .ovas-working i)::before, html[data-ov-motion="off"] .ovas-working i { animation: none !important; }
+.ovas-you-files { display: block; margin-top: 4px; font-size: 12px; opacity: .8; }
+.ovas-say .ovt-c-row { display: flex; align-items: center; gap: 6px; }
+.ovas-say .ovt-c-tools { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
+.ovas-say .ovt-c-note { margin: 0; font-size: 12px; color: var(--ovas-mut, var(--text-secondary, #9fb0cf)); }
+.ovas-say .ovt-c-note:empty { display: none; }
@@ -128,9 +128,48 @@
@@ -128,9 +128,48 @@
if (e.step === 'tool_result') return [{ t: 'tool_result', name: String(e.tool || 'tool'), ok: !e.is_error, output: cut(e.text, 8000), ...att, ...agent }];
if (e.step === 'tool_result') return [{ t: 'tool_result', name: String(e.tool || 'tool'), ok: !e.is_error, output: cut(e.text, 8000), ...att, ...agent }];
return text ? [{ t: 'text', text, ...agent }] : [];
return text ? [{ t: 'text', text, ...agent }] : [];
}
}
+
+ // ── AG-UI 1.0 (protocol 1.2, ask 354, 2026-11-11): every OpenVibe agent run streams AG-UI events (docs.ag-ui.com,
+ // spec 1.0 of 2026-11-07) with OpenVibe's own words in metadata.ov (title, kind, sources, …), so another tool's agent
+ // renders here and ours in theirs. Deltas (text, tool arguments, thinking) carry their ids; the view joins them.
+ const pick = (e, ...k) => { for (const n of k) if (e[n] != null) return e[n]; return null; };
+ function fromAgui(e) {
+ const ov = (e.metadata && typeof e.metadata === 'object' && e.metadata.ov && typeof e.metadata.ov === 'object') ? e.metadata.ov : {};
+ const src = Array.isArray(ov.sources) && ov.sources.length ? [{ t: 'sources', items: ov.sources }] : [];
+ const id = (...k) => String(pick(e, ...k) || '');
+ switch (e.type) {
+ case 'RUN_STARTED': return [{ t: 'state', state: 'running' }];
+ case 'RUN_FINISHED': return [...src, { t: 'done', ok: true, text: cut(typeof e.result === 'string' ? e.result : '', MAX_TEXT) }];
+ case 'RUN_ERROR': return [{ t: 'done', ok: false, text: cut(String(e.message || 'The run failed.'), 2000) }];
+ case 'STEP_STARTED': return [{ t: 'phase', id: id('stepName', 'step_name'), title: String(ov.title || pick(e, 'stepName', 'step_name') || 'Step'), status: 'running' }];
+ case 'STEP_FINISHED': return [{ t: 'phase', id: id('stepName', 'step_name'), status: 'done' }];
+ case 'TEXT_MESSAGE_START': return [{ t: 'text_start', id: id('messageId', 'message_id') }];
+ case 'TEXT_MESSAGE_CONTENT': case 'TEXT_MESSAGE_CHUNK': return [{ t: 'text_delta', id: id('messageId', 'message_id'), delta: String(e.delta || '') }];
+ case 'TOOL_CALL_START': return [{ t: 'tool', id: id('toolCallId', 'tool_call_id'), name: String(pick(e, 'toolCallName', 'tool_call_name') || 'tool'), input: {}, title: ov.title ? cut(ov.title, 160) : '', kind: ov.kind || null }];
+ case 'TOOL_CALL_ARGS': case 'TOOL_CALL_CHUNK': return [{ t: 'tool_args', id: id('toolCallId', 'tool_call_id'), delta: String(e.delta || '') }];
+ case 'TOOL_CALL_RESULT': return [...src, { t: 'tool_result', id: id('toolCallId', 'tool_call_id'), ok: !ov.error, output: cut(typeof e.content === 'string' ? e.content : JSON.stringify(e.content || ''), 8000), duration_ms: Number(ov.duration_ms) || 0, cost_usd: Number(ov.cost_usd) || 0 }];
+ case 'REASONING_START': case 'REASONING_MESSAGE_START': return [{ t: 'thinking', id: id('messageId', 'message_id') || 'r' }];
+ case 'REASONING_MESSAGE_CONTENT': case 'REASONING_MESSAGE_CHUNK': return [{ t: 'thinking_delta', id: id('messageId', 'message_id') || 'r', delta: String(e.delta || '') }];
+ case 'REASONING_END': case 'REASONING_MESSAGE_END': return [{ t: 'thinking', id: id('messageId', 'message_id') || 'r', done: true }];
+ case 'REASONING_ENCRYPTED_VALUE': return [{ t: 'thinking', id: id('messageId', 'message_id') || 'r', hidden: true }];
+ case 'STATE_SNAPSHOT': {
+ const plan = e.snapshot && Array.isArray(e.snapshot.plan) ? e.snapshot.plan : null;
+ return plan ? [{ t: 'phases', items: plan.slice(0, 30).map((p) => ({ title: String(p.title || p.content || ''), status: String(p.status || 'pending') })) }] : [];
+ }
+ case 'ACTIVITY_SNAPSHOT': return e.content && e.content.title ? [{ t: 'activity', text: cut(String(e.content.title), 200) }] : [];
+ case 'CUSTOM': {
+ if (e.name === 'ov.sources' && Array.isArray(e.value)) return [{ t: 'sources', items: e.value }];
+ if (e.name === 'ov.handoff' && e.value) return [{ t: 'handoff', from: e.value.from, to: e.value.to || null, reason: cut(e.value.reason, 600) }];
+ if (e.name === 'ov.approval' && e.value) return [{ t: 'note', level: 'warning', text: cut(e.value.question || 'Waiting for your approval.', 600) }];
+ return [];
+ }
+ default: return src;
+ }
+ }
function normalize(e) {
function normalize(e) {
if (!e || typeof e !== 'object') return [];
if (!e || typeof e !== 'object') return [];
if (e.t) return [e];
if (e.t) return [e];
+ if (typeof e.type === 'string' && /^[A-Z][A-Z_]+$/.test(e.type)) return fromAgui(e);
if (e.kind) return fromActor(e);
if (e.kind) return fromActor(e);
const parent = e.parent_tool_use_id || null;
const parent = e.parent_tool_use_id || null;
if (e.type === 'system' && e.subtype === 'init') return [{ t: 'agent', agent: e.agent || 'claude', model: e.model || null }];
if (e.type === 'system' && e.subtype === 'init') return [{ t: 'agent', agent: e.agent || 'claude', model: e.model || null }];
@@ -422,6 +461,7 @@
@@ -422,6 +461,7 @@
else if (k === 'plan') { out.title = 'Updated the plan'; }
else if (k === 'plan') { out.title = 'Updated the plan'; }
else { out.title = String(step.name || 'Tool'); out.sub = String(step.what || ''); }
else { out.title = String(step.name || 'Tool'); out.sub = String(step.what || ''); }
if (!out.title) out.title = String(step.what || step.name || 'Step');
if (!out.title) out.title = String(step.what || step.name || 'Step');
+ if (step.title) out.title = String(step.title); // the agent's own words for it (AG-UI metadata.ov.title)
return out;
return out;
}
}
@@ -449,14 +489,23 @@
@@ -449,14 +489,23 @@
// The clock runs from startedAt, or from now for a run that is live as it mounts; a finished run replayed without
// The clock runs from startedAt, or from now for a run that is live as it mounts; a finished run replayed without
// a start time shows the time only once its done line says it.
// a start time shows the time only once its done line says it.
const clock = Boolean(opts.startedAt) || LIVE.includes(opts.status || 'running');
const clock = Boolean(opts.startedAt) || LIVE.includes(opts.status || 'running');
- const S = { status: opts.status || 'running', label: opts.label || '', startedAt: opts.startedAt || Date.now(), endedAt: null, agents: [], steps: new Map(), plan: null, tokens: { in: 0, out: 0 }, cost: 0, agentCost: new Map(), durationMs: 0, problems: 0, filter: 'all', follow: opts.follow !== false, lastGroup: null };
+ const S = { phases: [], thinks: new Map(), msgs: new Map(), sources: new Map(), activity: '', ownPlan: false, status: opts.status || 'running', label: opts.label || '', startedAt: opts.startedAt || Date.now(), endedAt: null, agents: [], steps: new Map(), plan: null, tokens: { in: 0, out: 0 }, cost: 0, agentCost: new Map(), durationMs: 0, problems: 0, filter: 'all', follow: opts.follow !== false, lastGroup: null };
host.textContent = '';
host.textContent = '';
const box = el('div', 'ovas');
const box = el('div', 'ovas');
- box.innerHTML = `<div class="ovas-head"><div class="ovas-top"><span class="ovas-pill" data-s="${esc(S.status)}"></span><span class="ovas-title"></span></div><div class="ovas-meta"><span class="ovas-agents"></span><span data-m="time"></span><span data-m="steps"></span><span data-m="files"></span><span data-m="cost"></span></div><details class="ovas-plan" hidden><summary><span class="ovas-plan-now"></span><span class="ovas-plan-n"></span></summary><div class="ovas-plan-bar"><i style="width:0"></i></div><ol></ol></details></div>`
+ box.innerHTML = `<div class="ovas-head"><div class="ovas-top"><span class="ovas-pill" data-s="${esc(S.status)}"></span><span class="ovas-title"></span></div><div class="ovas-meta"><span class="ovas-agents"></span><span data-m="now" hidden></span><span data-m="time"></span><span data-m="steps"></span><span data-m="files"></span><span data-m="cost"></span></div><details class="ovas-plan" hidden><summary><span class="ovas-plan-now"></span><span class="ovas-plan-n"></span></summary><div class="ovas-plan-bar"><i style="width:0"></i></div><ol></ol></details></div>`
+ '<div class="ovas-bar" role="toolbar" aria-label="Show"></div><ol class="ovas-files" hidden></ol><ol class="ovas-feed" aria-live="polite"></ol><div class="ovas-actions" hidden></div>';
+ '<div class="ovas-bar" role="toolbar" aria-label="Show"></div><ol class="ovas-files" hidden></ol><ol class="ovas-feed" aria-live="polite"></ol><div class="ovas-actions" hidden></div>';
host.appendChild(box);
host.appendChild(box);
const head = box.querySelector('.ovas-head'), feed = box.querySelector('.ovas-feed'), bar = box.querySelector('.ovas-bar');
const head = box.querySelector('.ovas-head'), feed = box.querySelector('.ovas-feed'), bar = box.querySelector('.ovas-bar');
const filesEl = box.querySelector('.ovas-files'), actBar = box.querySelector('.ovas-actions');
const filesEl = box.querySelector('.ovas-files'), actBar = box.querySelector('.ovas-actions');
+ // The sources strip (protocol 1.2) under the feed; card mode (opts.mode 'card': a chat message, the Dock, a feed card)
+ // keeps the head, the plan row, the newest three lines and the sources, with a way to the whole run (opts.href).
+ const srcBox = el('details', 'ovas-sources'); srcBox.hidden = true;
+ srcBox.innerHTML = '<summary><span class="ovas-src-tiles"></span><span class="ovas-src-n"></span></summary><ol></ol>';
+ box.insertBefore(srcBox, actBar);
+ if (opts.mode === 'card') {
+ box.classList.add('ovas--card');
+ if (typeof opts.href === 'string' && /^(https:\/\/|\/)/.test(opts.href)) { const more = el('a', 'ovas-open', 'Open the whole run'); more.href = opts.href; box.insertBefore(more, actBar); }
+ }
head.querySelector('.ovas-title').textContent = opts.title || 'Agent session';
head.querySelector('.ovas-title').textContent = opts.title || 'Agent session';
const FILTERS = [['all', 'Everything'], ['said', 'What it said'], ['steps', 'Steps'], ['files', 'Files'], ['problems', 'Problems']];
const FILTERS = [['all', 'Everything'], ['said', 'What it said'], ['steps', 'Steps'], ['files', 'Files'], ['problems', 'Problems']];
FILTERS.forEach(([id, label]) => { const b = el('button', 'ovas-chip', label); b.type = 'button'; b.dataset.f = id; b.setAttribute('aria-pressed', String(id === 'all')); bar.appendChild(b); });
FILTERS.forEach(([id, label]) => { const b = el('button', 'ovas-chip', label); b.type = 'button'; b.dataset.f = id; b.setAttribute('aria-pressed', String(id === 'all')); bar.appendChild(b); });
@@ -474,6 +523,9 @@
@@ -474,6 +523,9 @@
try { navigator.clipboard.writeText(text); c.lastChild.textContent = 'Copied'; setTimeout(() => { c.lastChild.textContent = 'Copy'; }, 1400); } catch { /* no clipboard */ }
try { navigator.clipboard.writeText(text); c.lastChild.textContent = 'Copied'; setTimeout(() => { c.lastChild.textContent = 'Copy'; }, 1400); } catch { /* no clipboard */ }
});
});
const empty = el('li', 'ovas-empty', opts.empty || 'Waiting for the agent to start…'); feed.appendChild(empty);
const empty = el('li', 'ovas-empty', opts.empty || 'Waiting for the agent to start…'); feed.appendChild(empty);
+ // The run is still working: three dots after the newest line (gone when it ends).
+ const working = el('div', 'ovas-working'); working.setAttribute('aria-hidden', 'true'); working.innerHTML = '<i></i><i></i><i></i>';
+ working.hidden = !LIVE.includes(S.status); box.insertBefore(working, box.querySelector('.ovas-actions'));
// ── The preview sheet: one attachment, opened by a tap ──────────────────────────
// ── The preview sheet: one attachment, opened by a tap ──────────────────────────
const sheet = el('div', 'ovas-sheet');
const sheet = el('div', 'ovas-sheet');
@@ -654,7 +706,21 @@
@@ -654,7 +706,21 @@
const kinds = order.filter((k) => count[k]);
const kinds = order.filter((k) => count[k]);
g.querySelector('.ovas-icons').innerHTML = kinds.slice(0, 3).map((k) => `<span class="ovas-kind" data-k="${k}">${svg(k)}</span>`).join('');
g.querySelector('.ovas-icons').innerHTML = kinds.slice(0, 3).map((k) => `<span class="ovas-kind" data-k="${k}">${svg(k)}</span>`).join('');
// A few steps read as what was done ("Ran 2 commands"); more as counts ("2 commands · 1 edit · 3 reads").
// A few steps read as what was done ("Ran 2 commands"); more as counts ("2 commands · 1 edit · 3 reads").
- g.querySelector('.ovas-sum b').textContent = kinds.length === 1 ? plural(kinds[0], count[kinds[0]]) : kinds.map((k) => `${count[k]} ${NOUN[k][count[k] === 1 ? 0 : 1]}`).join(' · ');
+ // Steps the agent named itself (AG-UI metadata.ov.title) read as its words: the newest title and how many there are.
+ const titled = steps.filter((s2) => s2.title);
+ // Rows under one shared title say what each one did instead of the title again: its address, query or file.
+ const sharedTitle = titled.length > 1 && titled.every((s2) => s2.title === titled[0].title);
+ for (const s2 of steps) {
+ const b2 = s2.el && s2.el.querySelector(':scope>summary .ovas-sum b');
+ if (!b2 || !s2.info) continue;
+ // An address reads as its page and its site ("OpenVibe.Codes · github.com"), anything else as it is.
+ let own = String(s2.info.sub || '');
+ try { if (/^https?:\/\//.test(own)) { const u = new URL(own); const leaf = decodeURIComponent(u.pathname.split('/').filter(Boolean).pop() || ''); own = leaf ? `${leaf} · ${u.hostname.replace(/^www\./, '')}` : u.hostname.replace(/^www\./, ''); } } catch { /* as it is */ }
+ b2.textContent = sharedTitle && own ? own : s2.info.title;
+ }
+ g.querySelector('.ovas-sum b').textContent = titled.length
+ ? `${titled[titled.length - 1].title}${steps.length > 1 ? ` · ${steps.length}` : ''}`
+ : kinds.length === 1 ? plural(kinds[0], count[kinds[0]]) : kinds.map((k) => `${count[k]} ${NOUN[k][count[k] === 1 ? 0 : 1]}`).join(' · ');
const running = steps.find((s) => !s.done) || steps[steps.length - 1];
const running = steps.find((s) => !s.done) || steps[steps.length - 1];
const small = g.querySelector('.ovas-sum small');
const small = g.querySelector('.ovas-sum small');
small.textContent = running ? (running.info.sub || running.info.title) : '';
small.textContent = running ? (running.info.sub || running.info.title) : '';
@@ -688,7 +754,8 @@
@@ -688,7 +754,8 @@
const pending = new Map(); // tool name → ids of its steps still waiting for a result (events without ids)
const pending = new Map(); // tool name → ids of its steps still waiting for a result (events without ids)
// Any line may carry attachments, and a line of a type this component does not know still shows: a minimal
// Any line may carry attachments, and a line of a type this component does not know still shows: a minimal
// row with its label or summary, then its attachments (Codes' proof lines, for one).
// row with its label or summary, then its attachments (Codes' proof lines, for one).
- const KNOWN = new Set(['agent', 'text', 'tool', 'tool_progress', 'tool_result', 'handoff', 'note', 'step', 'usage', 'done', 'state']);
+ const KNOWN = new Set(['agent', 'text', 'tool', 'tool_progress', 'tool_result', 'handoff', 'note', 'step', 'usage', 'done', 'state',
+ 'phase', 'phases', 'activity', 'thinking', 'thinking_delta', 'text_start', 'text_delta', 'tool_args', 'sources']);
function extraLine(x, target) {
function extraLine(x, target) {
const li = el('li', 'ovas-extra');
const li = el('li', 'ovas-extra');
li.dataset.k = 'steps'; // it is part of the work, so the Steps filter keeps it
li.dataset.k = 'steps'; // it is part of the work, so the Steps filter keeps it
@@ -721,9 +788,9 @@
@@ -721,9 +788,9 @@
appendLi(target, li); return;
appendLi(target, li); return;
}
}
if (x.t === 'tool') {
if (x.t === 'tool') {
- const kind = kindOf(x.name);
- const step = { id: x.id || `s${S.steps.size}`, name: x.name, kind, input: x.input || {}, what: x.what || '', started: Date.now(), done: false, ok: null, out: '', attachments: safeAtts(x.attachments) };
- if (kind === 'plan') { if (Array.isArray(step.input.todos)) { S.plan = step.input.todos; paintPlan(); } step.hidden = true; S.steps.set(step.id, step); return; }
+ const kind = x.kind && VERB[x.kind] ? x.kind : kindOf(x.name);
+ const step = { id: x.id || `s${S.steps.size}`, name: x.name, kind, input: x.input || {}, what: x.what || '', title: x.title || '', started: Date.now(), done: false, ok: null, out: '', attachments: safeAtts(x.attachments) };
+ if (kind === 'plan') { if (Array.isArray(step.input.todos)) { S.ownPlan = true; S.plan = step.input.todos; paintPlan(); } step.hidden = true; S.steps.set(step.id, step); return; }
S.steps.set(step.id, step);
S.steps.set(step.id, step);
const g = groupFor(target === feed ? feed : target);
const g = groupFor(target === feed ? feed : target);
if (target === feed && empty.parentNode) empty.remove();
if (target === feed && empty.parentNode) empty.remove();
@@ -731,6 +798,21 @@
@@ -731,6 +798,21 @@
g.firstChild.querySelector('ol').appendChild(stepEl(step));
g.firstChild.querySelector('ol').appendChild(stepEl(step));
paintGroup(g); step.group = g; paintHead(); return;
paintGroup(g); step.group = g; paintHead(); return;
}
}
+ if (x.t === 'phase' || x.t === 'phases' || x.t === 'activity') { onPhase(x, target); return; }
+ if (x.t === 'thinking' || x.t === 'thinking_delta') { onThinking(x, target); return; }
+ if (x.t === 'text_start' || x.t === 'text_delta') {
+ let m = S.msgs.get(x.id);
+ if (!m) { const li = el('li', 'ovas-msg'); li.dataset.k = 'msg'; m = { li, text: '' }; S.msgs.set(x.id, m); appendLi(target, li); }
+ if (x.t === 'text_delta') { m.text = (m.text + x.delta).slice(0, MAX_TEXT); m.li.textContent = ''; m.li.appendChild(markdown(m.text, linkOk)); }
+ return;
+ }
+ if (x.t === 'tool_args') {
+ const step = S.steps.get(String(x.id)); if (!step) return;
+ step.argText = (step.argText || '') + x.delta;
+ try { step.input = JSON.parse(step.argText); step.info = describe(step); if (step.group) paintGroup(step.group); } catch { /* not whole yet */ }
+ return;
+ }
+ if (x.t === 'sources') { addSources(x.items); return; }
if (x.t === 'tool_progress') {
if (x.t === 'tool_progress') {
const step = S.steps.get(String(x.id)); if (!step || !step.outEl) return;
const step = S.steps.get(String(x.id)); if (!step || !step.outEl) return;
step.out = (step.out + x.chunk).slice(-20000); step.outEl.textContent = step.out; step.outEl.hidden = false; step.waitEl.hidden = true;
step.out = (step.out + x.chunk).slice(-20000); step.outEl.textContent = step.out; step.outEl.hidden = false; step.waitEl.hidden = true;
@@ -797,10 +879,69 @@
@@ -797,10 +879,69 @@
appendLi(feed, li); paintHead();
appendLi(feed, li); paintHead();
}
}
}
}
+ // Phases: named parts of the run (AG-UI steps). The plan row in the head shows where the run is ("2 of 5"), and a
+ // phase that starts is a divider in the feed. A plan the agent keeps itself (TodoWrite) wins over phases.
+ function onPhase(x, target) {
+ if (x.t === 'activity') { S.activity = x.text; paintPlan(); return; }
+ if (x.t === 'phases') { S.phases = x.items.map((p, i) => ({ id: `p${i}`, title: p.title, status: p.status })); }
+ else {
+ const same = (a, b) => String(a || '').trim().toLowerCase() === String(b || '').trim().toLowerCase();
+ let p = S.phases.find((q) => q.id === x.id) || S.phases.find((q) => same(q.title, x.title || x.id));
+ if (!p) { p = { id: x.id, title: x.title || x.id, status: 'running' }; S.phases.push(p); }
+ if (x.title) p.title = x.title;
+ p.status = x.status;
+ if (x.status === 'running' && !p.li) {
+ const li = el('li', 'ovas-phase'); li.dataset.k = 'phase';
+ li.appendChild(el('span', 'ovas-phase-dot')); li.appendChild(el('b', '', p.title));
+ p.li = li; appendLi(target === feed ? feed : target, li);
+ }
+ if (p.li) { p.li.dataset.s = p.status; p.li.querySelector('b').textContent = p.title; }
+ }
+ if (!S.ownPlan) {
+ S.plan = S.phases.map((p) => ({ content: p.title, status: /done|complete/.test(p.status) ? 'completed' : /run|progress/.test(p.status) ? 'in_progress' : 'pending' }));
+ paintPlan();
+ }
+ }
+ // Thinking: folded to one line, "Thinking…" while it streams and "Thought for 1m 20s" after; its words open on a tap.
+ function onThinking(x, target) {
+ let th = S.thinks.get(x.id);
+ if (!th) {
+ const li = el('li', 'ovas-think'); li.dataset.k = 'msg';
+ const d = el('details'); const sm = el('summary'); sm.appendChild(el('span', 'ovas-think-dots'));
+ const label = el('span', '', 'Thinking…'); sm.appendChild(label); d.appendChild(sm);
+ const body = el('div', 'ovas-think-body'); d.appendChild(body); li.appendChild(d);
+ th = { li, label, body, text: '', started: Date.now() }; S.thinks.set(x.id, th); appendLi(target, li);
+ }
+ if (x.t === 'thinking_delta') { th.text = (th.text + x.delta).slice(-20000); th.body.textContent = th.text; }
+ if (x.hidden) th.body.textContent = 'This model keeps its thinking to itself.';
+ if (x.done || x.hidden) { th.li.classList.add('is-done'); th.label.textContent = `Thought for ${dur(x.duration_ms || Date.now() - th.started)}`; }
+ }
+ // Sources: every page the run read, once each, in one strip under the feed: the sites as letter tiles and a count;
+ // opened, the titles as links (https only, a new tab).
+ function addSources(items) {
+ for (const it of (items || []).slice(0, 200)) {
+ const url = it && String(it.url || '');
+ if (!/^https:\/\//.test(url) || S.sources.has(url)) continue;
+ let site = String(it.site || ''); try { if (!site) site = new URL(url).hostname.replace(/^www\./, ''); } catch { continue; }
+ S.sources.set(url, { url, site, title: cut(String(it.title || site), 160) });
+ }
+ paintSources();
+ }
+ function paintSources() {
+ const list = [...S.sources.values()];
+ srcBox.hidden = !list.length; if (!list.length) return;
+ const tiles = srcBox.querySelector('.ovas-src-tiles'); tiles.textContent = '';
+ const sites = [...new Set(list.map((s) => s.site))];
+ sites.slice(0, 5).forEach((site) => { const t = el('span', 'ovas-src-tile', site.charAt(0).toUpperCase()); t.title = site; let h = 0; for (const c of site) h = (h * 31 + c.charCodeAt(0)) % 360; t.style.setProperty('--h', String(h)); tiles.appendChild(t); });
+ srcBox.querySelector('.ovas-src-n').textContent = `${list.length} source${list.length === 1 ? '' : 's'}${sites.length > 1 ? ` from ${sites.length} sites` : ''}`;
+ const ol = srcBox.querySelector('ol'); ol.textContent = '';
+ list.forEach((s) => { const li = el('li'); const a = el('a', '', s.title); a.href = s.url; a.target = '_blank'; a.rel = 'noopener noreferrer'; li.appendChild(a); li.appendChild(el('small', '', s.site)); ol.appendChild(li); });
+ }
function push(event) { normalize(event).forEach((x) => { try { onLine(x); } catch { /* one odd line never stops the view */ } }); applyFilter(); keep(); }
function push(event) { normalize(event).forEach((x) => { try { onLine(x); } catch { /* one odd line never stops the view */ } }); applyFilter(); keep(); }
function paintPlan() {
function paintPlan() {
const box2 = head.querySelector('.ovas-plan'); const todos = (S.plan || []).filter((t) => t && (t.content || t.activeForm));
const box2 = head.querySelector('.ovas-plan'); const todos = (S.plan || []).filter((t) => t && (t.content || t.activeForm));
+ if (!todos.length && S.activity) { box2.hidden = false; box2.querySelector('.ovas-plan-now').textContent = S.activity; box2.querySelector('.ovas-plan-n').textContent = ''; box2.querySelector('i').style.width = '0%'; return; }
box2.hidden = !todos.length; if (!todos.length) return;
box2.hidden = !todos.length; if (!todos.length) return;
const doneN = todos.filter((t) => t.status === 'completed').length;
const doneN = todos.filter((t) => t.status === 'completed').length;
box2.querySelector('i').style.width = `${Math.round((doneN / todos.length) * 100)}%`;
box2.querySelector('i').style.width = `${Math.round((doneN / todos.length) * 100)}%`;
@@ -838,6 +979,9 @@
@@ -838,6 +979,9 @@
});
});
const fc = bar.querySelector('[data-f="files"]'); if (fc) fc.textContent = sm.files ? `Files ${sm.files}` : 'Files';
const fc = bar.querySelector('[data-f="files"]'); if (fc) fc.textContent = sm.files ? `Files ${sm.files}` : 'Files';
const m = (k, html) => { const n = head.querySelector(`[data-m="${k}"]`); n.innerHTML = html; n.hidden = !html; };
const m = (k, html) => { const n = head.querySelector(`[data-m="${k}"]`); n.innerHTML = html; n.hidden = !html; };
+ // What it is doing now (the portal's sticky line, 2026-11-11): the newest step that has not ended.
+ const nowStep = LIVE.includes(S.status) ? [...S.steps.values()].reverse().find((st) => !st.done && !st.hidden) : null;
+ m('now', nowStep && nowStep.info ? `<span class="ovas-now">${esc(String(nowStep.info.command || nowStep.info.title || '').split('\n')[0].slice(0, 90))}</span>` : '');
m('time', sm.durationMs ? `<b>${esc(dur(sm.durationMs))}</b>` : '');
m('time', sm.durationMs ? `<b>${esc(dur(sm.durationMs))}</b>` : '');
m('steps', sm.steps ? `<b>${sm.steps}</b> step${sm.steps === 1 ? '' : 's'}${sm.commands ? ` · <b>${sm.commands}</b> command${sm.commands === 1 ? '' : 's'}` : ''}` : '');
m('steps', sm.steps ? `<b>${sm.steps}</b> step${sm.steps === 1 ? '' : 's'}${sm.commands ? ` · <b>${sm.commands}</b> command${sm.commands === 1 ? '' : 's'}` : ''}` : '');
m('files', sm.files ? `<b>${sm.files}</b> file${sm.files === 1 ? '' : 's'} <span style="color:var(--ovas-ok)">+${sm.added}</span> <span style="color:var(--ovas-bad)">−${sm.removed}</span>` : '');
m('files', sm.files ? `<b>${sm.files}</b> file${sm.files === 1 ? '' : 's'} <span style="color:var(--ovas-ok)">+${sm.added}</span> <span style="color:var(--ovas-bad)">−${sm.removed}</span>` : '');
@@ -845,9 +989,62 @@
@@ -845,9 +989,62 @@
}
}
function status(s, label) {
function status(s, label) {
S.status = s; S.label = label || '';
S.status = s; S.label = label || '';
+ working.hidden = !LIVE.includes(s);
if (!LIVE.includes(s)) { S.endedAt = S.endedAt || Date.now(); empty.textContent = 'Nothing was recorded for this run.'; }
if (!LIVE.includes(s)) { S.endedAt = S.endedAt || Date.now(); empty.textContent = 'Nothing was recorded for this run.'; }
paintHead();
paintHead();
}
}
+ // Talking to the run while it works (opts.onSend(text, { files, uploads }) → a promise; the product sends it to its
+ // agent: Codes' job message, Actor's task input, the portal's session). The box is the one composer on every site
+ // (owner, 2026-11-11: "uses our custom modular composer across all sites"): files by picker, paste and drop, "/"
+ // commands and the person's own, drafts, voice and their settings; loaded from beside this file when the page has
+ // not loaded it. opts.composer: false keeps a plain box.
+ if (typeof opts.onSend === 'function') {
+ // The one composer's markup (thread.js composer()): the box, then a row with its tools and Send, then its note,
+ // so ov-composer.js puts its + menu (files), "/" commands, voice and settings where every site has them.
+ const form = el('form', 'ovas-say ovt-composer'); form.setAttribute('data-ovt-composer', '');
+ const ta = el('textarea'); ta.name = 'text'; ta.rows = 1; ta.maxLength = 4000; ta.placeholder = opts.sayPlaceholder || 'Message the agent'; ta.setAttribute('aria-label', 'Message the agent'); ta.setAttribute('data-ovt-text', '');
+ const row = el('div', 'ovt-c-row'); const toolsEl = el('div', 'ovt-c-tools');
+ const send = el('button', 'ovas-say-send ovt-send'); send.type = 'submit'; send.setAttribute('aria-label', 'Send'); send.setAttribute('data-ovt-send', ''); send.innerHTML = svg('handoff');
+ const noteEl = el('p', 'ovt-c-note'); noteEl.setAttribute('data-ovt-note', ''); noteEl.setAttribute('role', 'status');
+ row.appendChild(toolsEl); row.appendChild(send);
+ // A file input makes the box one that takes files (the composer's + menu, paste and drop); the files go to onSend.
+ if (opts.files !== false) {
+ // The shared "+" (thread.js attachHtml): a label holding the file input, first in the tools row.
+ const plus = el('label', 'ovt-btn ovt-attach'); plus.title = 'Attach files';
+ plus.innerHTML = '<svg class="ovt-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg><span class="ovt-sr">Attach files</span>';
+ const fi = el('input'); fi.type = 'file'; fi.name = 'files'; fi.multiple = true; fi.hidden = true; fi.setAttribute('data-ovt-files', ''); if (opts.accept) fi.accept = String(opts.accept);
+ plus.appendChild(fi); toolsEl.insertBefore(plus, toolsEl.firstChild);
+ }
+ form.appendChild(ta); form.appendChild(row); form.appendChild(noteEl);
+ box.appendChild(form);
+ ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit ? form.requestSubmit() : form.dispatchEvent(new Event('submit', { cancelable: true })); } });
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const ovc = form.__ovc;
+ const files = ovc ? ovc.files() : [], uploads = ovc && ovc.uploads ? ovc.uploads() : [];
+ const text = ta.value.trim(); if ((!text && !files.length) || form.classList.contains('is-busy')) return;
+ form.classList.add('is-busy');
+ const li = el('li', 'ovas-you'); li.dataset.k = 'msg'; li.appendChild(el('span', '', text));
+ if (files.length) li.appendChild(el('small', 'ovas-you-files', files.map((f) => f.name || 'a file').join(' · ')));
+ appendLi(feed, li); keep();
+ try { await opts.onSend(text, { files, uploads }); if (ovc && ovc.clear) ovc.clear(); else ta.value = ''; li.classList.add('is-sent'); }
+ catch (err) { li.classList.add('is-failed'); li.title = (err && err.message) || 'Not sent'; }
+ finally { form.classList.remove('is-busy'); }
+ });
+ if (opts.composer !== false) {
+ form.id = form.id || `ovas-say-${Math.random().toString(36).slice(2, 8)}`;
+ const join = () => { try { if (root.OpenVibeComposer && form.isConnected) root.OpenVibeComposer.enhance(form); } catch { /* a plain box still sends */ } };
+ if (root.OpenVibeComposer) join();
+ else {
+ const m = /^(https?:\/\/[^?#]*\/)agent-session(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(SELF_SRC);
+ if (m) {
+ let sc = document.getElementById('ov-composer-script');
+ if (!sc) { sc = document.createElement('script'); sc.id = 'ov-composer-script'; sc.async = true; sc.src = m[1] + 'ov-composer.js'; document.head.appendChild(sc); }
+ sc.addEventListener('load', join, { once: true });
+ }
+ }
+ }
+ }
const tick = setInterval(() => { if (S.status === 'running' || S.status === 'placed') { S.steps.forEach((st) => { if (!st.done) setStepState(st); }); paintHead(); } }, 1000);
const tick = setInterval(() => { if (S.status === 'running' || S.status === 'placed') { S.steps.forEach((st) => { if (!st.done) setStepState(st); }); paintHead(); } }, 1000);
paintHead();
paintHead();
return { push, pushAll: (list) => (list || []).forEach(push), status, summary, actions: (list, note) => paintActions(list, note), files: () => changedFiles().map((f) => ({ path: f.path, added: f.added, removed: f.removed, changes: f.n })), destroy() { clearInterval(tick); host.textContent = ''; } };
return { push, pushAll: (list) => (list || []).forEach(push), status, summary, actions: (list, note) => paintActions(list, note), files: () => changedFiles().map((f) => ({ path: f.path, added: f.added, removed: f.removed, changes: f.n })), destroy() { clearInterval(tick); host.textContent = ''; } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,423 @@
@@ -0,0 +1,423 @@
+{
+ "groups": [
+ {
+ "id": "watch",
+ "name": "Watch & stream"
+ },
+ {
+ "id": "talk",
+ "name": "Talk & belong"
+ },
+ {
+ "id": "play",
+ "name": "Play"
+ },
+ {
+ "id": "read",
+ "name": "Read & ask"
+ },
+ {
+ "id": "shop",
+ "name": "Shop & money"
+ },
+ {
+ "id": "tools",
+ "name": "Free tools"
+ },
+ {
+ "id": "make",
+ "name": "Make with AI"
+ },
+ {
+ "id": "build",
+ "name": "For developers"
+ }
+ ],
+ "sites": [
+ {
+ "id": "live",
+ "name": "Live",
+ "url": "https://openvibe.live/",
+ "description": "Go live, watch streams",
+ "group": "watch"
+ },
+ {
+ "id": "community",
+ "name": "Community",
+ "url": "https://openvibe.community/",
+ "description": "Posts and pastes",
+ "group": "talk"
+ },
+ {
+ "id": "chat",
+ "name": "Chat",
+ "url": "https://openvibe.chat/",
+ "description": "Chat with anyone",
+ "group": "talk"
+ },
+ {
+ "id": "media-hub",
+ "name": "Video",
+ "url": "https://openvibe.video/",
+ "description": "Clips and past streams",
+ "group": "watch"
+ },
+ {
+ "id": "pics",
+ "name": "Pics",
+ "url": "https://openvibe.pics/",
+ "description": "Share pictures",
+ "group": "watch"
+ },
+ {
+ "id": "download",
+ "name": "Download",
+ "url": "https://openvibe.download/",
+ "description": "Your files",
+ "group": "watch"
+ },
+ {
+ "id": "tools",
+ "name": "Tools",
+ "url": "https://openvibe.tools/",
+ "description": "Every online tool",
+ "group": "tools"
+ },
+ {
+ "id": "bot",
+ "name": "Bot",
+ "url": "https://openvibe.bot/",
+ "description": "Drive a robot",
+ "group": "make"
+ },
+ {
+ "id": "space",
+ "name": "Space",
+ "url": "https://openvibe.space/",
+ "description": "Host a site or app",
+ "group": "make"
+ },
+ {
+ "id": "tips",
+ "name": "Tips",
+ "url": "https://openvibe.tips/",
+ "description": "Support streamers",
+ "group": "talk"
+ },
+ {
+ "id": "news",
+ "name": "News",
+ "url": "https://openvibe.news/",
+ "description": "Sourced, not spun",
+ "group": "read"
+ },
+ {
+ "id": "games",
+ "name": "Games",
+ "url": "https://openvibe.games/",
+ "description": "Play in your browser",
+ "group": "play"
+ },
+ {
+ "id": "vip",
+ "name": "VIP",
+ "url": "https://openvibe.vip/",
+ "description": "Back your creators",
+ "group": "talk"
+ },
+ {
+ "id": "report",
+ "name": "Report",
+ "url": "https://openvibe.report/",
+ "description": "Your numbers, honest",
+ "group": "read"
+ },
+ {
+ "id": "blog",
+ "name": "Blog",
+ "url": "https://openvibe.blog/",
+ "description": "Write long-form",
+ "group": "read"
+ },
+ {
+ "id": "coupons",
+ "name": "Coupons",
+ "url": "https://openvibe.coupons/",
+ "description": "Codes that work",
+ "group": "shop"
+ },
+ {
+ "id": "deals",
+ "name": "Deals",
+ "url": "https://openvibe.deals/",
+ "description": "Deals people share",
+ "group": "shop"
+ },
+ {
+ "id": "actor",
+ "name": "Actor",
+ "url": "https://openvibe.actor/",
+ "description": "The agent engine",
+ "group": "make"
+ },
+ {
+ "id": "design",
+ "name": "Design",
+ "url": "https://openvibe.design/",
+ "description": "Try themes live",
+ "group": "tools"
+ },
+ {
+ "id": "codes",
+ "name": "Codes",
+ "url": "https://openvibe.codes/",
+ "description": "Code with any agent",
+ "group": "make"
+ },
+ {
+ "id": "wiki",
+ "name": "Wiki",
+ "url": "https://openvibe.wiki/",
+ "description": "Facts with sources",
+ "group": "read"
+ },
+ {
+ "id": "exchange",
+ "name": "Exchange",
+ "url": "https://openvibe.exchange/",
+ "description": "AI models, one account",
+ "group": "build"
+ },
+ {
+ "id": "reviews",
+ "name": "Reviews",
+ "url": "https://openvibe.reviews/",
+ "description": "Honest reviews",
+ "group": "shop"
+ },
+ {
+ "id": "trade",
+ "name": "Trade",
+ "url": "https://openvibe.trade/",
+ "description": "Watch the markets",
+ "group": "shop"
+ },
+ {
+ "id": "host",
+ "name": "Host",
+ "url": "https://openvibe.host/",
+ "description": "Compare hosting",
+ "group": "tools"
+ },
+ {
+ "id": "fun",
+ "name": "Fun",
+ "url": "https://openvibe.fun/",
+ "description": "Something to do",
+ "group": "play"
+ },
+ {
+ "id": "network",
+ "name": "Network",
+ "url": "https://openvibe.network/",
+ "description": "Your account",
+ "group": "talk"
+ },
+ {
+ "id": "rent",
+ "name": "Rent",
+ "url": "https://openvibe.rent/",
+ "description": "Places to rent",
+ "group": "shop"
+ },
+ {
+ "id": "computer",
+ "name": "Computer",
+ "url": "https://openvibe.computer/",
+ "description": "Your computer, your way",
+ "group": "make"
+ },
+ {
+ "id": "work",
+ "name": "Work",
+ "url": "https://openvibe.work/",
+ "description": "Find a job",
+ "group": "shop"
+ },
+ {
+ "id": "inventory",
+ "name": "Inventory",
+ "url": "https://openvibe.my/inventory",
+ "description": "Your items",
+ "group": "play"
+ },
+ {
+ "id": "services",
+ "name": "Services",
+ "url": "https://openvibe.services/",
+ "description": "APIs, SDK and keys",
+ "group": "build"
+ },
+ {
+ "id": "tours",
+ "name": "Tours",
+ "url": "https://openvibe.tours/",
+ "description": "Cities, routes, stays",
+ "group": "watch"
+ },
+ {
+ "id": "vision",
+ "name": "Vision",
+ "url": "https://openvibe.vision/",
+ "description": "Ask a picture",
+ "group": "read"
+ },
+ {
+ "id": "casino",
+ "name": "Casino",
+ "url": "https://openvibe.casino/",
+ "description": "Odds shown, chips free",
+ "group": "play"
+ },
+ {
+ "id": "food",
+ "name": "Food",
+ "url": "https://openvibe.food/",
+ "description": "Food near you",
+ "group": "tools"
+ },
+ {
+ "id": "surf",
+ "name": "Surf",
+ "url": "https://openvibe.surf/",
+ "description": "Surf maps and the web",
+ "group": "play"
+ },
+ {
+ "id": "today",
+ "name": "Today",
+ "url": "https://openvibe.today/",
+ "description": "Your schedule",
+ "group": "read"
+ },
+ {
+ "id": "domains",
+ "name": "Domains",
+ "url": "https://openvibe.domains/",
+ "description": "Names and prices",
+ "group": "tools"
+ },
+ {
+ "id": "quest",
+ "name": "Quest",
+ "url": "https://openvibe.quest/",
+ "description": "Earn badges",
+ "group": "play"
+ },
+ {
+ "id": "events",
+ "name": "Events",
+ "url": "https://openvibe.events/",
+ "description": "Event streams",
+ "group": "build"
+ },
+ {
+ "id": "cam",
+ "name": "Cam",
+ "url": "https://openvibe.cam/",
+ "description": "Your camera, in private",
+ "group": "watch"
+ },
+ {
+ "id": "run",
+ "name": "Run",
+ "url": "https://openvibe.run/",
+ "description": "Run jobs anywhere",
+ "group": "build"
+ },
+ {
+ "id": "zone",
+ "name": "Zone",
+ "url": "https://openvibe.zone/",
+ "description": "S3 storage",
+ "group": "build"
+ },
+ {
+ "id": "help",
+ "name": "Help",
+ "url": "https://openvibe.help/",
+ "description": "Help nearby",
+ "group": "talk"
+ },
+ {
+ "id": "meme",
+ "name": "Meme",
+ "url": "https://openvibe.meme/",
+ "description": "Memes with receipts",
+ "group": "play"
+ },
+ {
+ "id": "mail",
+ "name": "Email",
+ "url": "https://openvibe.email/",
+ "description": "Masks and inboxes",
+ "group": "talk"
+ },
+ {
+ "id": "stream",
+ "name": "Restream",
+ "url": "https://openre.stream/",
+ "description": "Stream everywhere",
+ "group": "watch"
+ },
+ {
+ "id": "shoes",
+ "name": "Shoes",
+ "url": "https://open.shoes/",
+ "description": "Every shoe, every price",
+ "group": "shop"
+ },
+ {
+ "id": "connect",
+ "name": "Connect",
+ "url": "https://connect.openvibe.link/",
+ "description": "Share a port with Link",
+ "group": "make"
+ },
+ {
+ "id": "watch",
+ "name": "Watch",
+ "url": "https://openvibe.watch/",
+ "description": "Know when it changes",
+ "group": "build"
+ },
+ {
+ "id": "image",
+ "name": "Studio",
+ "url": "https://openvibe.studio/",
+ "description": "Edit photos in your browser",
+ "group": "tools"
+ },
+ {
+ "id": "search",
+ "name": "Search",
+ "url": "https://search.openvibe.network/",
+ "description": "Search everything",
+ "group": "read"
+ },
+ {
+ "id": "lock",
+ "name": "Locker",
+ "url": "https://openvibe.locker/",
+ "description": "Files only you can open",
+ "group": "tools"
+ },
+ {
+ "id": "git",
+ "name": "Git",
+ "url": "https://git.openvibe.codes/",
+ "description": "Host and browse code",
+ "group": "build"
+ }
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+
+const seo = require('./seo');
+const catalog = require('./crawl-sites.json');
+const esc = (value) => String(value == null ? '' : value).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+const absolute = (url, base) => new URL(url, base).href;
+
+/** Static navigation, network links and discovery metadata for HTML sent before JavaScript runs. */
+function render({ name, service, url, description = '', links, pages = [], searchUrl } = {}) {
+ if (!name || !url) throw new TypeError('crawlable.render requires name and url');
+ const base = new URL(url).origin;
+ const ownLinks = Array.isArray(links) ? links : [];
+ const ownItems = ownLinks.flatMap((item) => Array.isArray(item.items) ? item.items : [item])
+ .filter((item) => item && (item.href || item.url) && (item.label || item.name || item.title))
+ .map((item) => ({ label: item.label || item.name || item.title, href: item.href || item.url }));
+ const nav = `<nav aria-label="${esc(name)}"><ul><li><a href="/">Home</a></li>${ownItems.filter((item) => item.href !== '/').map((item) => `<li><a href="${esc(item.href)}">${esc(item.label)}</a></li>`).join('')}</ul></nav>`;
+ const network = catalog.sites.filter((site) => site.id !== service);
+ const footer = `<nav aria-label="OpenVibe">${catalog.groups.map((group) => {
+ const sites = network.filter((site) => site.group === group.id);
+ return sites.length ? `<section><h2>${esc(group.name)}</h2><ul>${sites.map((site) => `<li><a href="${esc(site.url)}">${esc(site.name)}</a> — ${esc(site.description)}</li>`).join('')}</ul></section>` : '';
+ }).join('')}</nav>`;
+ const sameAs = [...new Set(catalog.sites.map((site) => new URL(site.url).origin))];
+ const jsonLd = [seo.jsonLd.organization({ sameAs }), seo.jsonLd.website({ name, url: base + '/', description, searchUrl })];
+ const head = jsonLd.map((item) => `<script type="application/ld+json">${JSON.stringify(item).replace(/</g, '\\u003c')}</script>`).join('\n');
+ const llmsTxt = seo.llmsTxt({ name, summary: description || name, sections: [
+ { title: 'Main pages', links: [{ title: 'Home', url: base + '/' }, ...pages.map((page) => ({ title: page.title || page.name, url: absolute(page.url || page.href, base) }))] },
+ ...catalog.groups.map((group) => ({ title: group.name, links: network.filter((site) => site.group === group.id).map((site) => ({ title: site.name, url: site.url, note: site.description })) })).filter((section) => section.links.length),
+ ] });
+ return { nav, footer, head, jsonLd, llmsTxt };
+}
+
+module.exports = { render, catalog };
@@ -58,7 +58,7 @@
@@ -58,7 +58,7 @@
// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
- const VIBE_REV = { 'ov-composer.js': 'f52e2323e6e9' };
+ const VIBE_REV = { 'ov-composer.js': 'fbe0a5b0d179' };
function joinComposer(form) {
function joinComposer(form) {
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
form.setAttribute('data-ovc-no-files', '');
form.setAttribute('data-ovc-no-files', '');
@@ -89,7 +89,7 @@
@@ -89,7 +89,7 @@
}
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
- const DOCK_REV = { 'dock-vibe.js': '81365d0289bb', 'dock-code.js': '3dcbaca10bfb', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
+ const DOCK_REV = { 'dock-vibe.js': '7f3c25bc076d', 'dock-code.js': '3dcbaca10bfb', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
function load(src, test) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -607,7 +607,7 @@
@@ -607,7 +607,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
*/
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '771efd0e3963', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '8eec18660ff4', 'dock.js': '31d1cac08de5', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'bottom.js': 'b9f708d04350', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': 'ea69b8a08868', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'e84894600223', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };
+ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '771efd0e3963', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '8eec18660ff4', 'dock.js': '0748f4087385', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'bottom.js': 'b9f708d04350', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': 'ea69b8a08868', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'e84894600223', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
// ov-tip.js loads at the first hover over a tooltip's owner.
@@ -19,6 +19,8 @@
@@ -19,6 +19,8 @@
* upload(file, onProgress) → Promise<{ id }>, files go to the site's store before the send
* upload(file, onProgress) → Promise<{ id }>, files go to the site's store before the send
* beforeSend(ctx) → false to hold the send })
* beforeSend(ctx) → false to hold the send })
* ctx: { form, text, value, set(v), insert(s), files(), add(files), note(s), send() }.
* ctx: { form, text, value, set(v), insert(s), files(), add(files), note(s), send() }.
+ * A box its page sends by script (a chat, an agent's run): form.__ovc.files() → the files (File objects), uploads(): the
+ * ids a mod's upload gave, clear() after the send (the text, the chips, the draft); a form 'reset' clears the same.
* A site without script says the same in data: data-ovt-commands='[{"name","hint","insert"}]', data-ovc-max-files,
* A site without script says the same in data: data-ovt-commands='[{"name","hint","insert"}]', data-ovc-max-files,
* data-ovc-max-mb, data-ovc-no-draft.
* data-ovc-max-mb, data-ovc-no-draft.
*/
*/
@@ -86,7 +88,16 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
@@ -86,7 +88,16 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
.ovc-set-add { display: grid; grid-template-columns: 110px 1fr auto; gap: 8px; align-items: start; }
.ovc-set-add { display: grid; grid-template-columns: 110px 1fr auto; gap: 8px; align-items: start; }
.ovc-set-add input, .ovc-set-add textarea { box-sizing: border-box; width: 100%; padding: 9px 10px; border-radius: 10px; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .25)); background: var(--bg-input, rgba(2, 6, 14, .35)); color: inherit; font: inherit; font-size: 14px; resize: vertical; }
.ovc-set-add input, .ovc-set-add textarea { box-sizing: border-box; width: 100%; padding: 9px 10px; border-radius: 10px; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .25)); background: var(--bg-input, rgba(2, 6, 14, .35)); color: inherit; font: inherit; font-size: 14px; resize: vertical; }
.ovc-set-btn { height: 40px; padding: 0 14px; border: 0; border-radius: 12px; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font: inherit; font-weight: 650; cursor: pointer; }
.ovc-set-btn { height: 40px; padding: 0 14px; border: 0; border-radius: 12px; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font: inherit; font-weight: 650; cursor: pointer; }
-@media (max-width: 480px) { .ovc-set-add { grid-template-columns: 1fr; } .ovc-file { max-width: 200px; } }`;
+@media (max-width: 480px) { .ovc-set-add { grid-template-columns: 1fr; } .ovc-file { max-width: 200px; } }
+/* 4.205.0: the composer's own tool buttons and row on a page without thread.css (an agent's run, a card, any box), so it
+ is the same composer everywhere. Where thread.css is loaded its rules are the same. */
+.ovc .ovt-btn { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 12px; border: 0; background: none; color: var(--text-secondary, #b4c0d4); cursor: pointer; text-decoration: none; }
+.ovc .ovt-btn:hover, .ovc .ovt-btn:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 6%, transparent)); color: var(--text-primary, #e6edf7); }
+.ovc .ovt-btn .ovt-ic, .ovc .ovt-btn svg { width: 22px; height: 22px; }
+.ovc .ovt-c-row { display: flex; align-items: center; gap: 6px; }
+.ovc .ovt-c-tools { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
+.ovc .ovt-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
+`;
function styles() { if (document.getElementById('ovc-styles')) return; const st = document.createElement('style'); st.id = 'ovc-styles'; st.textContent = CSS; document.head.appendChild(st); }
function styles() { if (document.getElementById('ovc-styles')) return; const st = document.createElement('style'); st.id = 'ovc-styles'; st.textContent = CSS; document.head.appendChild(st); }
const mods = [];
const mods = [];
// 4.202.0: your settings open in the one window (ov-window.js: a sheet on a phone, swiped down to close), loaded
// 4.202.0: your settings open in the one window (ov-window.js: a sheet on a phone, swiped down to close), loaded
@@ -495,9 +506,12 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
@@ -495,9 +506,12 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
form, ctx,
form, ctx,
refresh() { form.classList.toggle('ovc-compact', prefs.density === 'compact'); micButton(); paintTools(); syncSend(); },
refresh() { form.classList.toggle('ovc-compact', prefs.density === 'compact'); micButton(); paintTools(); syncSend(); },
add, files: () => files.map((f) => f.file),
add, files: () => files.map((f) => f.file),
+ uploads: () => files.map((f) => f.id).filter(Boolean),
+ clear() { files.slice().forEach(remove); text.value = ''; if (draftKey) store.set(draftKey, null); fire(); },
destroy() { live.delete(api); pop.remove(); strip.remove(); if (mic) mic.remove(); modTools.forEach((b) => b.remove()); form.__ovc = null; },
destroy() { live.delete(api); pop.remove(); strip.remove(); if (mic) mic.remove(); modTools.forEach((b) => b.remove()); form.__ovc = null; },
};
};
form.__ovc = api;
form.__ovc = api;
+ form.addEventListener('reset', () => setTimeout(() => api.clear(), 0));
live.add(api);
live.add(api);
form.classList.add('ovc');
form.classList.add('ovc');
api.refresh();
api.refresh();
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
- const THREAD_REV = { 'ov-composer.js': 'f52e2323e6e9', 'ov-combo.js': '3e426e56fc75' };
+ const THREAD_REV = { 'ov-composer.js': 'fbe0a5b0d179', 'ov-combo.js': '3e426e56fc75' };
var selfSrc = (document.currentScript && document.currentScript.src) || '';
var selfSrc = (document.currentScript && document.currentScript.src) || '';
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
function loadCombo() {
function loadCombo() {
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.204.0",
+ "version": "4.205.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.204.0",
+ "version": "4.205.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.204.0",
+ "version": "4.205.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -128,6 +128,7 @@
@@ -128,6 +128,7 @@
"./vibes-pay": "./vibes-pay.js",
"./vibes-pay": "./vibes-pay.js",
"./trace": "./trace.js",
"./trace": "./trace.js",
"./csp": "./csp.js",
"./csp": "./csp.js",
+ "./crawlable": "./crawlable.js",
"./ui": "./ui.js",
"./ui": "./ui.js",
"./url-resolver": "./url-resolver.js",
"./url-resolver": "./url-resolver.js",
"./ov-user": "./ov-user.js",
"./ov-user": "./ov-user.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+// Protocol 1.2 (ask 354, 2026-11-11): agent-session.js reads AG-UI 1.0 events (docs.ag-ui.com) with OpenVibe's words in
+// metadata.ov, so any agent that speaks AG-UI renders in the one step view: phases, steps with their own titles and
+// sources, thinking, streamed text, the plan, the activity line and the CUSTOM ov.* events.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const A = require('../agent-session.js');
+const n = (e) => A.normalize(e);
+
+assert.deepStrictEqual(n({ type: 'RUN_STARTED' }), [{ t: 'state', state: 'running' }]);
+assert.deepStrictEqual(n({ type: 'STEP_STARTED', stepName: 'Read the repos' }), [{ t: 'phase', id: 'Read the repos', title: 'Read the repos', status: 'running' }]);
+assert.deepStrictEqual(n({ type: 'STEP_FINISHED', step_name: 'Read the repos' }), [{ t: 'phase', id: 'Read the repos', status: 'done' }], 'snake_case fields too');
+const tool = n({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'web_fetch', metadata: { ov: { title: 'Fetching README files', kind: 'web' } } })[0];
+assert.strictEqual(tool.t, 'tool');
+assert.strictEqual(tool.title, 'Fetching README files');
+assert.strictEqual(tool.kind, 'web');
+assert.deepStrictEqual(n({ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: '{"u' }), [{ t: 'tool_args', id: 't1', delta: '{"u' }]);
+const res = n({ type: 'TOOL_CALL_RESULT', toolCallId: 't1', content: 'ok', metadata: { ov: { sources: [{ url: 'https://a.example/x' }] } } });
+assert.deepStrictEqual(res.map((x) => x.t), ['sources', 'tool_result'], 'a result\'s sources come first');
+assert.deepStrictEqual(n({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm1', delta: 'Hi' }), [{ t: 'text_delta', id: 'm1', delta: 'Hi' }]);
+assert.deepStrictEqual(n({ type: 'REASONING_END', messageId: 'r1' }), [{ t: 'thinking', id: 'r1', done: true }]);
+assert.deepStrictEqual(n({ type: 'REASONING_ENCRYPTED_VALUE', messageId: 'r1' }), [{ t: 'thinking', id: 'r1', hidden: true }], 'an encrypted thought shows only that it happened');
+assert.deepStrictEqual(n({ type: 'STATE_SNAPSHOT', snapshot: { plan: [{ title: 'A', status: 'completed' }] } }), [{ t: 'phases', items: [{ title: 'A', status: 'completed' }] }]);
+assert.deepStrictEqual(n({ type: 'ACTIVITY_SNAPSHOT', content: { title: 'Comparing' } }), [{ t: 'activity', text: 'Comparing' }]);
+assert.deepStrictEqual(n({ type: 'CUSTOM', name: 'ov.sources', value: [{ url: 'https://b.example' }] }), [{ t: 'sources', items: [{ url: 'https://b.example' }] }]);
+assert.strictEqual(n({ type: 'RUN_ERROR', message: 'boom' })[0].ok, false);
+assert.deepStrictEqual(n({ type: 'SOMETHING_NEW' }), [], 'an unknown family is ignored, never an error');
+assert.deepStrictEqual(n({ type: 'assistant', message: { content: [{ type: 'text', text: 'x' }] } }).map((x) => x.t), ['text'], 'Claude stream-json still reads as before');
+
+const js = fs.readFileSync(path.join(__dirname, '..', 'agent-session.js'), 'utf8');
+assert.ok(js.includes("if (!/^https:\\/\\//.test(url) || S.sources.has(url)) continue;"), 'sources are https only, once each');
+assert.ok(js.includes("a.rel = 'noopener noreferrer'") && js.includes("a.target = '_blank'"), 'a source opens in a new tab, without the opener');
+const css = fs.readFileSync(path.join(__dirname, '..', 'agent-session.css'), 'utf8');
+['.ovas-phase', '.ovas-think', '.ovas-sources', '.ovas--card', '.ovas-open'].forEach((sel) => assert.ok(css.includes(sel), sel));
+console.log('agent-session-agui: AG-UI events read as phases, titled steps, sources, thinking and text');
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+'use strict';
+const assert = require('assert');
+const crawlable = require('../crawlable');
+
+const page = crawlable.render({
+ name: 'OpenVibe.Test', service: 'live', url: 'https://openvibe.test/', description: 'Test site',
+ links: [{ label: 'Latest', href: '/latest' }, { label: 'About <us>', href: '/about' }],
+ pages: [{ title: 'Latest', url: '/latest' }], searchUrl: 'https://openvibe.test/search?q={q}',
+});
+assert.match(page.nav, /<nav aria-label="OpenVibe\.Test"><ul><li><a href="\/">Home<\/a>/);
+assert.match(page.nav, /href="\/about">About <us>/);
+assert.match(page.footer, /<nav aria-label="OpenVibe">/);
+assert.ok(page.catalog === undefined);
+assert.ok(page.jsonLd.some((item) => item['@type'] === 'Organization' && item.sameAs.length === crawlable.catalog.sites.length));
+assert.ok(page.jsonLd.some((item) => item['@type'] === 'WebSite' && item.potentialAction['@type'] === 'SearchAction'));
+page.jsonLd.forEach((item) => JSON.parse(JSON.stringify(item)));
+assert.match(page.llmsTxt, /Main pages[\s\S]*Latest[\s\S]*Watch & stream/);
+assert.ok(page.catalog === undefined, 'render output contains only page artifacts');
+assert.throws(() => crawlable.render({}), /requires name and url/);
+console.log('crawlable: all checks passed');