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)

OP OpenVibers committed 2 days ago · 3cb0afdf1c · 16 files, +839 −17

* The one step view reads AG-UI: phases, titled steps, sources, thinking, a card for chat

Ask 354: "improve our agent system ... a multi-step system, and parses better, and looks more rich
design, embedded in the chat and everything, across all products ... OpenVibe.design oriented".

- Protocol 1.2: OpenVibeAgentSession.normalize() reads AG-UI 1.0 events (run, step, text, tool
  call, reasoning, state, activity, custom) with OpenVibe's words in metadata.ov (title, kind,
  sources), so any agent that speaks AG-UI renders here; the 1.1 lines and Claude stream-json are
  unchanged.
- Phases: the plan row shows the current one and "1 of 3"; each phase is a divider in the feed.
- Steps keep the agent's own titles ("Fetching README files across key repositories · 6"); rows
  under a shared title show their own target ("OpenVibe.Codes · github.com").
- Thinking folds to one line ("Thinking..." while it streams, "Thought for 1m 20s" after).
- Sources: every page read, once each, in one strip with site tiles and a count, opened to links
  (https only, a new tab, no opener).
- Text streams in place; the activity line shows when there is no plan.
- mount(el, { mode: 'card', href }): for a chat message, the Dock or a feed card: the head, the
  plan row, the newest three lines folded, the sources, "Open the whole run".

* Compact runs: one-line groups, the step it is on in the head, dots while it works, a box to talk to it

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's meta line says what it is doing now; three dots follow the newest line while it works; mount(el, { onSend(text) }) adds a box to talk to the run (the one composer when the page has it) and shows what was sent in the feed.

* The run's message box is the one composer: its markup, files, a clear() after a script send

* The run's box is the whole composer on any page: the shared + for files, its tools styled by the composer itself

The owner: "make sure that system uses our custom modular composer across all sites". agent-session's box uses the composer's own markup (the + label with its file input, the tools row, Send, the note) and loads ov-composer.js from beside itself; onSend(text, { files, uploads }); the composer gains clear() and uploads() for boxes sent by script and clears on a form reset; ov-composer.js now styles its own tool buttons, row and screen-reader text, so it looks the same on a page without thread.css.

* 4.205.0: the step view reads AG-UI, compact runs, the one composer to talk to a run, crawlable first HTML

Carries #457 (openvibe-shared/crawlable).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 6f0c8e9..d9d2185 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex 586ef29..3cf72e1 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
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
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 8da812f..07842ed 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/agent-session.css b/agent-session.css
iindex 4e0b4e6..5b48497 100644
---- a/agent-session.css
++++ b/agent-session.css
@@@ -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; }
ddiff --git a/agent-session.js b/agent-session.js
iindex 4c170d1..3f5ff4c 100644
---- a/agent-session.js
++++ b/agent-session.js
@@@ -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 = ''; } };
ddiff --git a/crawl-sites.json b/crawl-sites.json
nnew file mode 100644
iindex 0000000..9a6d55f
---- /dev/null
++++ b/crawl-sites.json
@@@ -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"
++ }
++ ]
++}
ddiff --git a/crawlable.js b/crawlable.js
nnew file mode 100644
iindex 0000000..a8a8fec
---- /dev/null
++++ b/crawlable.js
@@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 };
ddiff --git a/dock-vibe.js b/dock-vibe.js
iindex 4ed0e43..340473c 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -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 box
form.setAttribute('data-ovc-no-files', '');
ddiff --git a/dock.js b/dock.js
iindex d19e4ef..e5b3d57 100644
---- a/dock.js
++++ b/dock.js
@@@ -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);
ddiff --git a/navbar.js b/navbar.js
iindex 739c547..2c1d4b0 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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.
ddiff --git a/ov-composer.js b/ov-composer.js
iindex 61f4f0e..f2621ba 100644
---- a/ov-composer.js
++++ b/ov-composer.js
@@@ -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[data
form, 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();
ddiff --git a/ov-thread.js b/ov-thread.js
iindex 56661fb..a0ad44b 100644
---- a/ov-thread.js
++++ b/ov-thread.js
@@@ -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() {
ddiff --git a/package-lock.json b/package-lock.json
iindex 9231a02..8d28e15 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex a5ba70e..9797c3c 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/test/agent-session-agui.test.js b/test/agent-session-agui.test.js
nnew file mode 100644
iindex 0000000..b67b410
---- /dev/null
++++ b/test/agent-session-agui.test.js
@@@ -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');
ddiff --git a/test/crawlable.test.js b/test/crawlable.test.js
nnew file mode 100644
iindex 0000000..f622c1b
---- /dev/null
++++ b/test/crawlable.test.js
@@@ -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 &lt;us&gt;/);
++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');