openvibers/openvibe.chat Commits
Feeds: vibe coding moves from Live into Chat and gets better: any room, any site, Live slots; Claude Code's own http hooks; secrets scrubbed on the way in; the delay kept on the server (#144)
Changes
@@@ -462,6 +462,43 @@ for people and for companies, with "direct live support with an agent", "fully m- Export and account deletion cover inboxes: the owner's inboxes go with their conversations; an agent's replies inother inboxes stay, signed "A former agent".++### Feeds: vibe coding, live (openvibe.chat/feeds)++++The owner, 2026-10-24: "improve the vibe coding feed feature on live and migrate that whole entire feature so it's built++into the openvibe.chat system". A **feed** (`server/feeds/`, `migrations/0017_feeds.sql`) is live activity a person++publishes into Chat; vibe coding, moved from OpenVibe.Live (its `server/vibe-coding/`, same canonical events), is the++first kind. Viewers see a card (`public/web/feed-view.js`): turns of a prompt, the steps taken (tools used, files++changed) and the answer, the newest open, a live dot while steps arrive.++++- **Where it shows:** in a room its owner owns (above the messages), on the owner's Live channel slot while it is live++ (as Live's `vibe-coding` frame on the stream's chat, which Live's channel widget reads), and on any site:++ `<iframe src="https://openvibe.chat/embed/feed/fed_…">` (`?theme=light|dark`; it tells the parent its height,++ `{ ov: 'chat', type: 'feed-height' }`).++- **Publishing** with the feed's key (`ovf_…`, shown once, stored as its SHA-256; a new one on request):++ - **Claude Code**: the feed page gives a `.claude/settings.json` block of `http` hooks (SessionStart,++ UserPromptSubmit, PostToolUse, Stop, SessionEnd) that post each hook's JSON as it is to++ `POST /api/chat/feeds/:id/hooks/claude-code` with `Authorization: Bearer $OPENVIBE_FEED_KEY`. A prompt, a tool++ used (an edit is a file event), the turn's answer (`last_assistant_message`); paths become relative to the++ session's folder; nothing is read from the transcript; the answer is always `{}`, so it never blocks Claude Code.++ - **Anything else**: `POST /api/chat/feeds/:id/events` with one event, `{ events: [...] }` (at most 50) or Live's++ `vibe-coding.event` envelopes; `POST /:id/hello { sessionKey, publisher }`. `eventType` is `session.status`,++ `prompt`, `response`, `thinking`, `tool.call`, `file.change` or `file.save`; a short `{ eventType, text }` works.++ 600 events a minute a feed.++- **Safety:** every event is scrubbed on the way in: API keys and tokens (OpenAI/Anthropic, GitHub, Slack, AWS,++ Google, JWTs, OpenVibe's own), `Bearer …`, `PASSWORD=`-style values, credentials in URLs, private keys, and home++ folders (`/home/<name>` → `~`). What a viewer sees is decided when they see it by the feed's settings (Live's++ projection): prompts, answers, tools, what the tools were given (off by default), files (names only by default),++ thinking (off by default), detail, paused. The **delay** is the server's: neither the socket nor the history shows++ a step before it (Live sent the delay to the browser).++- **API:** anyone: `GET /api/chat/feeds/:id` (what a viewer sees now), `GET /api/chat/feeds?channel=<username>` or++ `?slot=<id,…>` (the feeds on Live channel slots, what Live's chat asks); the owner: `GET /api/chat/feeds/mine`,++ `POST /api/chat/feeds { title, room?, slot?, settings? }` → `{ feed, key }`, `PATCH /:id`, `POST /:id/key`,++ `DELETE /:id`. The public and publishing routes answer any site without cookies. Socket: `{ type: 'feed_watch',++ feed }`, then `feed_event { feed, event }`. A feed keeps its newest 5,000 events; at most 10 feeds a person.++- **From Live:** `scripts/import-live-feeds.js --file <export.jsonl> [--apply]` copies each slot's feed (settings,++ sessions, its newest 5,000 events, scrubbed) and verifies the copy; Live then drops `vibe_coding_*`.++- Export and account deletion cover feeds (never the key's hash).++### VIP member badgesA member's messages in a creator's room (stream or offline channel chat) carry that creator's VIP@@@ -49,10 +49,12 @@ center (MagicBrowse privacy), getstream.io (Stream vs Sendbird), rumbletalk.com,## Order of work--1. **Support inbox + widget (first)**: inboxes, agents, visitor conversations, Vibe as first responder with handoff,++1. **Support inbox + widget (first; Chat#143)**: inboxes, agents, visitor conversations, Vibe as first responder with handoff,the agent inbox page, `widget.js` + `/embed/inbox/:id`, allowed sites (frame-ancestors), the API.--2. **Feeds**: Live's vibe coding feed moves into Chat as the first feed type (publisher socket, settings, viewer cards in-- any room and in Live's channel chat); Live's tables are copied, verified and dropped (convert-verify-delete).++2. **Feeds** (built): Live's vibe coding feed moves into Chat as the first feed type, and gets better on the way: any++ room, any site (an iframe card), Claude Code's own `http` hooks with no extension to install, publishing over plain++ HTTP, secrets and home folders scrubbed on the way in, the delay kept on the server. Live's channel chat shows++ Chat's card; Live's tables are copied (`scripts/import-live-feeds.js`), verified and dropped (convert-verify-delete).3. **Community room and site assistant modes** of the widget; pictures, video and files in the embed (signed-invisitors first, then anonymous once Pics/Video take anonymous uploads); call escalation from a support conversation.4. **Developer platform**: capabilities in OpenVibe.Contracts (`chat.inbox.*`, `chat.conversation.*`, `chat.feed.*`),nnew file mode 100644@@@ -0,0 +1,56 @@++-- phase: expand++-- Feeds (docs/platform.md step 2, the owner 2026-10-24: "migrate that whole entire feature so it's built into the++-- openvibe.chat system"): live activity a person publishes into Chat with a feed's key; vibe coding, moved from++-- OpenVibe.Live, is the first kind. Viewers see a card in the feed's room, on its Live channel slot while live, or on any++-- site (openvibe.chat/embed/feed/:id). What they see is projected from the stored events by the feed's settings when++-- they see it (server/feeds/feeds.js).++CREATE TABLE IF NOT EXISTS feeds (++ id text COLLATE "C" PRIMARY KEY, -- fed_…++ owner_id bigint NOT NULL, -- Live user id, like rooms.owner_id++ owner_subject text COLLATE "C",++ kind text COLLATE "C" NOT NULL DEFAULT 'coding' CHECK (kind IN ('coding')),++ title text NOT NULL,++ room_id bigint, -- a Chat room it shows in (rooms.id)++ managed_stream_id bigint, -- a Live channel slot it shows on while live++ settings jsonb NOT NULL DEFAULT '{}',++ key_hash text COLLATE "C", -- sha256 of the publish key (ovf_…), shown once++ key_hint text COLLATE "C", -- its last four characters++ legacy_source text COLLATE "C", -- 'live:managed_stream:<id>' when copied from Live++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ last_event_at text COLLATE "C",++ archived_at text COLLATE "C"++);++CREATE INDEX IF NOT EXISTS feeds_owner ON feeds (owner_id) WHERE archived_at IS NULL;++CREATE UNIQUE INDEX IF NOT EXISTS feeds_key ON feeds (key_hash) WHERE key_hash IS NOT NULL;++CREATE INDEX IF NOT EXISTS feeds_room ON feeds (room_id) WHERE room_id IS NOT NULL AND archived_at IS NULL;++CREATE INDEX IF NOT EXISTS feeds_slot ON feeds (managed_stream_id) WHERE managed_stream_id IS NOT NULL AND archived_at IS NULL;++CREATE UNIQUE INDEX IF NOT EXISTS feeds_legacy ON feeds (legacy_source) WHERE legacy_source IS NOT NULL;++++-- One publisher's run (an editor window, an agent session): who published (integration, client, workspace) and when.++CREATE TABLE IF NOT EXISTS feed_sessions (++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,++ feed_id text COLLATE "C" NOT NULL,++ session_key text COLLATE "C" NOT NULL,++ publisher jsonb NOT NULL DEFAULT '{}',++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'ended')),++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ last_event_at text COLLATE "C",++ ended_at text COLLATE "C",++ UNIQUE (feed_id, session_key)++);++++-- The events as published (scrubbed of secrets and home folders on the way in). received_ms orders the feed's delay.++CREATE TABLE IF NOT EXISTS feed_events (++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,++ feed_id text COLLATE "C" NOT NULL,++ session_key text COLLATE "C",++ event_id text COLLATE "C" NOT NULL,++ seq bigint NOT NULL DEFAULT 0,++ type text COLLATE "C" NOT NULL,++ summary text NOT NULL DEFAULT '',++ payload jsonb NOT NULL,++ received_ms bigint NOT NULL,++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ UNIQUE (feed_id, event_id)++);++CREATE INDEX IF NOT EXISTS feed_events_feed ON feed_events (feed_id, id DESC);@@@ -302,3 +302,10 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }.ib-dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; flex: none; }.ib-snippet { padding: 12px 14px; border-radius: 12px; background: var(--oc-panel); border: 1px solid var(--oc-line); overflow-x: auto; }.ib-snippet code { font: .85rem/1.5 ui-monospace, Menlo, Consolas, monospace; white-space: pre; }++++/* Feeds (server/web/feed-pages.js; the card itself is public/web/feed-view.css) */++.oc-room-fv { margin: 0 0 12px; }++.fd-preview { margin: 12px 0 18px; }++.fd-dot { background: var(--oc-muted); }++.fd-dot.fd-on { background: var(--oc-ok); }++.fd-key code { font-size: 1rem; user-select: all; }nnew file mode 100644@@@ -0,0 +1,34 @@++/* A feed's card (public/web/feed-view.js): in a room on openvibe.chat (Chat's --oc-* colours) and in the embed++ /embed/feed/:id, which sets the same names for light and dark (server/web/feed-pages.js). */++.fv { --fv-line: var(--oc-line, #223049); --fv-panel: var(--oc-panel, #121a2c); --fv-text: var(--oc-text, #e8edf6); --fv-muted: var(--oc-muted, #98a6bd); --fv-accent: var(--oc-accent, #3b82f6); --fv-ok: var(--oc-ok, #22c55e); }++.fv.fv-on { border: 1px solid var(--fv-line); border-radius: 14px; background: var(--fv-panel); color: var(--fv-text); overflow: hidden; font-size: .9rem; }++.fv.fv-gone { display: none; }++.fv-head { all: unset; box-sizing: border-box; cursor: pointer; display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 12px; }++.fv-head:focus-visible { outline: 2px solid var(--fv-accent); outline-offset: -2px; }++.fv-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--fv-muted); flex: none; }++.fv-live .fv-dot { background: var(--fv-ok); box-shadow: 0 0 0 0 color-mix(in srgb, var(--fv-ok) 60%, transparent); animation: fvPulse 2s infinite; }++@keyframes fvPulse { 70% { box-shadow: 0 0 0 7px transparent; } }++.fv-title { font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }++.fv-who { color: var(--fv-muted); font-size: .8rem; white-space: nowrap; }++.fv-who:not(:empty)::before { content: '· '; }++.fv-when { margin-left: auto; color: var(--fv-muted); font-size: .78rem; }++.fv-chev { color: var(--fv-muted); transition: transform .15s; }++.fv-folded .fv-chev { transform: rotate(-90deg); }++.fv-folded .fv-body { display: none; }++.fv-body { border-top: 1px solid var(--fv-line); max-height: 260px; overflow-y: auto; padding: 8px 12px 10px; overscroll-behavior: contain; }++.fv-turns, .fv-steps { list-style: none; margin: 0; padding: 0; }++.fv-turn { padding: 4px 0; }++.fv-turn + .fv-turn { border-top: 1px dashed color-mix(in srgb, var(--fv-line) 70%, transparent); }++.fv-turn:not(.fv-cur) { opacity: .72; }++.fv-prompt, .fv-answer, .fv-step, .fv-count, .fv-more, .fv-working { display: flex; gap: 7px; align-items: baseline; margin: 3px 0; min-width: 0; }++.fv-prompt { font-weight: 600; }++.fv-ic { flex: none; width: 1.1em; text-align: center; color: var(--fv-muted); }++.fv-prompt .fv-ic { color: var(--fv-accent); }++.fv-answer .fv-ic { color: var(--fv-ok); }++.fv-txt { overflow-wrap: anywhere; }++.fv-step { color: var(--fv-muted); font-size: .84rem; flex-wrap: wrap; }++.fv-arg { font: .78rem ui-monospace, Menlo, Consolas, monospace; background: color-mix(in srgb, var(--fv-muted) 14%, transparent); padding: 1px 6px; border-radius: 6px; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-left: 1.8em; flex-basis: 100%; }++.fv-count, .fv-more { color: var(--fv-muted); font-size: .78rem; padding-left: 1.8em; }++.fv-working { color: var(--fv-muted); font-size: .82rem; padding-left: 1.8em; }++.fv-empty { margin: 4px 0; color: var(--fv-muted); font-size: .85rem; }++@media (prefers-reduced-motion: reduce) { .fv-live .fv-dot { animation: none; } .fv-chev { transition: none; } }nnew file mode 100644@@@ -0,0 +1,124 @@++/* A feed's card (server/feeds/feeds.js): what someone is building, live. Mounted on every <div class="fv"++ data-feed="fed_…"> of the page: a room on openvibe.chat, the embed /embed/feed/:id that any site (OpenVibe.Live's++ channel chat among them) frames. It reads /api/chat/feeds/:id and follows the chat socket (feed_watch). Events are++ grouped into turns: a prompt, the steps taken (tools used, files changed), the answer; the newest turn is open, older++ ones fold to their prompt. Everything is set with textContent. In a frame it tells the parent its height. */++(function () {++ 'use strict';++ var ICON = { prompt: '›', response: '✓', thinking: '…', 'tool.call': '⚙', 'file.change': '✎', 'file.save': '✎', 'session.status': '•' };++ var LIVE_MS = 3 * 60 * 1000;++ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }++ function store(fn) { try { return fn(window.localStorage); } catch (e) { return null; } }++ function ago(iso) {++ var t = Date.parse(iso || ''); if (!t) return '';++ var s = Math.max(0, Math.round((Date.now() - t) / 1000));++ return s < 60 ? 'now' : s < 3600 ? Math.round(s / 60) + 'm' : s < 86400 ? Math.round(s / 3600) + 'h' : Math.round(s / 86400) + 'd';++ }++++ function turnsOf(events) {++ var turns = [], cur = null;++ events.forEach(function (e) {++ if (e.eventType === 'prompt' || !cur) { cur = { prompt: e.eventType === 'prompt' ? e : null, steps: [], answer: null, at: e.occurredAt }; turns.push(cur); if (e.eventType === 'prompt') return; }++ if (e.eventType === 'response') { cur.answer = e; cur.at = e.occurredAt; return; }++ cur.steps.push(e); cur.at = e.occurredAt;++ });++ return turns;++ }++++ function mount(box) {++ var id = box.getAttribute('data-feed');++ if (!/^fed_[0-9A-HJKMNP-TV-Z]{26}$/.test(id || '') || box.dataset.mounted) return;++ box.dataset.mounted = '1';++ var events = [], seen = {}, feed = null, publisher = null, ws = null, delay = 1000;++ var KEY = 'ov-feed-folded-' + id;++ var folded = store(function (s) { return s.getItem(KEY); }) === '1';++++ var head = el('button', 'fv-head'); head.type = 'button';++ var dot = el('span', 'fv-dot'); var title = el('b', 'fv-title', 'Vibe coding'); var who = el('span', 'fv-who'); var when = el('time', 'fv-when');++ var chev = el('span', 'fv-chev', '▾'); chev.setAttribute('aria-hidden', 'true');++ head.appendChild(dot); head.appendChild(title); head.appendChild(who); head.appendChild(when); head.appendChild(chev);++ var body = el('div', 'fv-body'); var list = el('ol', 'fv-turns'); body.appendChild(list);++ var empty = el('p', 'fv-empty', 'Nothing yet. Steps show here as they happen.');++ body.appendChild(empty);++ box.textContent = ''; box.appendChild(head); box.appendChild(body);++ box.classList.add('fv-on');++++ function setFolded(v) {++ folded = v; box.classList.toggle('fv-folded', v); head.setAttribute('aria-expanded', v ? 'false' : 'true');++ store(function (s) { s.setItem(KEY, v ? '1' : '0'); }); tellHeight();++ }++ head.addEventListener('click', function () { setFolded(!folded); });++++ function tellHeight() {++ if (window.parent === window) return;++ try { window.parent.postMessage({ ov: 'chat', type: 'feed-height', feed: id, height: Math.ceil(box.getBoundingClientRect().height) }, '*'); } catch (e) { /* not framed */ }++ }++ function stepLine(e) {++ var li = el('li', 'fv-step fv-' + e.eventType.replace('.', '-'));++ li.appendChild(el('span', 'fv-ic', ICON[e.eventType] || '•'));++ var t = el('span', 'fv-txt', e.summary || (e.tool && e.tool.name) || '');++ li.appendChild(t);++ var arg = e.tool && e.tool.argumentsPreview;++ if (arg) li.appendChild(el('code', 'fv-arg', arg));++ return li;++ }++ function render() {++ var live = feed && feed.last_event_at && Date.now() - Date.parse(feed.last_event_at) < LIVE_MS;++ box.classList.toggle('fv-live', !!live);++ dot.title = live ? 'Live' : 'Quiet';++ title.textContent = (feed && feed.title) || 'Vibe coding';++ who.textContent = publisher && publisher.integrationLabel && publisher.integrationLabel !== 'Custom publisher' ? publisher.integrationLabel : '';++ when.textContent = feed ? ago(feed.last_event_at) : '';++ list.textContent = '';++ var turns = turnsOf(events);++ empty.hidden = turns.length > 0;++ turns.forEach(function (turn, i) {++ var last = i === turns.length - 1;++ var li = el('li', 'fv-turn' + (last ? ' fv-cur' : ''));++ if (turn.prompt) { var p = el('p', 'fv-prompt'); p.appendChild(el('span', 'fv-ic', ICON.prompt)); p.appendChild(el('span', 'fv-txt', turn.prompt.summary)); li.appendChild(p); }++ if (last || !turn.prompt) {++ var steps = el('ol', 'fv-steps');++ var shown = turn.steps.slice(-6);++ if (turn.steps.length > shown.length) steps.appendChild(el('li', 'fv-more', (turn.steps.length - shown.length) + ' earlier steps'));++ shown.forEach(function (e) { steps.appendChild(stepLine(e)); });++ if (shown.length) li.appendChild(steps);++ } else if (turn.steps.length) {++ li.appendChild(el('p', 'fv-count', turn.steps.length + (turn.steps.length === 1 ? ' step' : ' steps')));++ }++ if (turn.answer && (last || !turn.prompt)) { var a = el('p', 'fv-answer'); a.appendChild(el('span', 'fv-ic', ICON.response)); a.appendChild(el('span', 'fv-txt', turn.answer.summary)); li.appendChild(a); }++ else if (last && !turn.answer && live && turn.prompt) li.appendChild(el('p', 'fv-working', 'Working…'));++ list.appendChild(li);++ });++ body.scrollTop = body.scrollHeight;++ tellHeight();++ }++ function add(e) {++ if (!e || !e.eventId || seen[e.eventId]) return;++ seen[e.eventId] = 1; events.push(e);++ if (events.length > 120) { events.splice(0, events.length - 120); }++ if (feed) feed.last_event_at = new Date().toISOString();++ render();++ }++ function connect() {++ if (!window.WebSocket) return;++ ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');++ ws.onopen = function () { delay = 1000; ws.send(JSON.stringify({ type: 'feed_watch', feed: id })); };++ ws.onmessage = function (ev) { var m; try { m = JSON.parse(ev.data); } catch (e) { return; } if (m.type === 'feed_event' && m.feed === id) { if (m.publisher) publisher = m.publisher; add(m.event); } };++ ws.onclose = function () { setTimeout(function () { load(); connect(); }, delay); delay = Math.min(delay * 2, 30000); };++ }++ function load() {++ return fetch('/api/chat/feeds/' + id + '?limit=40', { credentials: 'omit' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {++ if (!d) { box.classList.add('fv-gone'); return; }++ feed = d.feed; publisher = d.publisher;++ (d.events || []).forEach(function (e) { if (!seen[e.eventId]) { seen[e.eventId] = 1; events.push(e); } });++ render();++ }).catch(function () {});++ }++ setFolded(folded);++ load().then(connect);++ setInterval(function () { if (feed) { when.textContent = ago(feed.last_event_at); box.classList.toggle('fv-live', Date.now() - Date.parse(feed.last_event_at || 0) < LIVE_MS); } }, 30000);++ }++ function all() { Array.prototype.forEach.call(document.querySelectorAll('.fv[data-feed]'), mount); }++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', all); else all();++ window.OpenVibeFeed = { mount: mount };++})();nnew file mode 100644@@@ -0,0 +1,121 @@++#!/usr/bin/env node++/**++ * OpenVibe.Live's vibe coding feed → Chat's feeds (server/feeds/feeds.js, docs/platform.md step 2). One-off: copy,++ * verify, then Live drops its tables (convert-verify-delete).++ *++ * node scripts/import-live-feeds.js --file <export.jsonl> # dry run: what would be copied++ * node scripts/import-live-feeds.js --file <export.jsonl> --apply # copy, then verify++ *++ * The file is JSON lines exported from Live's database, one object per row, `kind` saying which:++ * { kind: 'slot', managed_stream_id, user_id, title, settings } managed_streams.broadcast_settings.vibe_coding++ * { kind: 'session', managed_stream_id, session_key, publisher, status, created_at, last_event_at, ended_at }++ * { kind: 'event', managed_stream_id, session_key, event_id, seq, type, summary, payload, created_at }++ *++ * Each slot becomes one feed of its owner (legacy_source 'live:managed_stream:<id>', so a second run adds nothing),++ * shown on that slot, with Live's settings, and no key: the owner makes one on the feed's page. A feed keeps its++ * newest 5,000 events (feeds.KEEP_EVENTS), so only those are copied; every copied event is scrubbed as new ones are.++ * Verified: each feed holds exactly the newest min(5,000, its events) event ids of the file.++ *++ * Exit codes: 0 done (or dry run), 2 refused (bad options or file), 1 failed or did not verify.++ */++'use strict';++++const fs = require('fs');++++const USAGE = 'usage: node scripts/import-live-feeds.js --file <export.jsonl> [--apply]';++++function parseArgs(argv) {++ const out = { apply: false, file: null, help: false };++ for (let i = 0; i < argv.length; i++) {++ const a = argv[i];++ if (a === '--apply') out.apply = true;++ else if (a === '--dry-run') out.apply = false;++ else if (a === '--file') { out.file = argv[++i]; if (!out.file || out.file.startsWith('--')) throw new Error('--file needs a value'); }++ else if (a === '-h' || a === '--help') out.help = true;++ else throw new Error(`unknown argument ${a}`);++ }++ if (!out.help && !out.file) throw new Error('--file is required');++ return out;++}++++const json = (v, d) => { if (v && typeof v === 'object') return v; try { return JSON.parse(v || 'null') || d; } catch { return d; } };++const ms = (t) => { const n = Date.parse(String(t || '').includes('T') ? t : `${String(t || '').replace(' ', 'T')}Z`); return Number.isFinite(n) ? n : 0; };++++/** The file read into slots, each with its sessions and newest events (oldest first). */++function readExport(text, keep) {++ const slots = new Map();++ const slot = (id) => { if (!slots.has(id)) slots.set(id, { id, user_id: null, title: null, settings: {}, sessions: [], events: [] }); return slots.get(id); };++ let lines = 0;++ for (const line of text.split('\n')) {++ if (!line.trim()) continue;++ lines++;++ const r = JSON.parse(line);++ const id = Number(r.managed_stream_id);++ if (!Number.isSafeInteger(id) || id <= 0) throw new Error(`line ${lines}: no managed_stream_id`);++ if (r.kind === 'slot') Object.assign(slot(id), { user_id: Number(r.user_id), title: r.title || null, settings: json(r.settings, {}) });++ else if (r.kind === 'session') slot(id).sessions.push(r);++ else if (r.kind === 'event') slot(id).events.push(r);++ else throw new Error(`line ${lines}: unknown kind ${r.kind}`);++ }++ for (const s of slots.values()) {++ s.total = s.events.length;++ s.events = s.events.sort((a, b) => ms(a.created_at) - ms(b.created_at) || Number(a.seq) - Number(b.seq)).slice(-keep);++ }++ return { slots: [...slots.values()], lines };++}++++async function main(argv, log = (m) => console.log(m)) {++ let args;++ try { args = parseArgs(argv); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }++ if (args.help) { log(USAGE); return 0; }++ const feeds = require('../server/feeds/feeds');++ let data;++ try { data = readExport(fs.readFileSync(args.file, 'utf8'), feeds.KEEP_EVENTS); } catch (e) { log(`cannot read ${args.file}: ${e.message}`); return 2; }++ const orphans = data.slots.filter((s) => !s.user_id);++ if (orphans.length) { log(`slots without a slot row (owner unknown): ${orphans.map((s) => s.id).join(', ')}`); return 2; }++ for (const s of data.slots) log(`slot ${s.id} (owner ${s.user_id}): ${s.sessions.length} session(s), ${s.total} event(s), copying the newest ${s.events.length}`);++ if (!args.apply) { log('dry run: nothing written (add --apply to copy)'); return 0; }++++ const db = require('../server/db/database');++ await db.initDb();++ let bad = 0;++ for (const s of data.slots) {++ const source = `live:managed_stream:${s.id}`;++ const subject = await db.subjectFor(s.user_id).catch(() => null);++ const settings = feeds.normalizeSettings(s.settings);++ const title = String(s.settings.widget_title || s.title || 'Vibe coding').slice(0, 80);++ const id = `fed_${require('crypto').randomBytes(26).reduce((a, b) => a + '0123456789ABCDEFGHJKMNPQRSTVWXYZ'[b % 32], '')}`;++ await db.run(`INSERT INTO feeds (id, owner_id, owner_subject, title, managed_stream_id, settings, legacy_source) VALUES (?, ?, ?, ?, ?, ?, ?)++ ON CONFLICT (legacy_source) WHERE legacy_source IS NOT NULL DO NOTHING`, [id, s.user_id, subject, title.length >= 2 ? title : 'Vibe coding', s.id, JSON.stringify(settings), source]);++ const feed = await db.get('SELECT id FROM feeds WHERE legacy_source = ?', [source]);++ for (const x of s.sessions) {++ await db.run(`INSERT INTO feed_sessions (feed_id, session_key, publisher, status, created_at, last_event_at, ended_at) VALUES (?, ?, ?, ?, ?, ?, ?)++ ON CONFLICT (feed_id, session_key) DO NOTHING`, [feed.id, String(x.session_key || 'default'), JSON.stringify(json(x.publisher, {})),++ x.status === 'ended' ? 'ended' : 'active', x.created_at || null, x.last_event_at || null, x.ended_at || null]);++ }++ for (const e of s.events) {++ const payload = feeds.scrub(json(e.payload, {}));++ await db.run(`INSERT INTO feed_events (feed_id, session_key, event_id, seq, type, summary, payload, received_ms, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)++ ON CONFLICT (feed_id, event_id) DO NOTHING`, [feed.id, e.session_key || null, String(e.event_id), Number(e.seq) || 0, String(e.type),++ feeds.scrubText(e.summary || '').slice(0, 2000), JSON.stringify(payload), ms(e.created_at), e.created_at || null]);++ }++ const last = s.events.length ? s.events[s.events.length - 1].created_at : null;++ if (last) await db.run('UPDATE feeds SET last_event_at = ? WHERE id = ? AND (last_event_at IS NULL OR last_event_at < ?)', [last, feed.id, last]);++ // Verify: exactly the newest event ids of the file, no more, no fewer.++ const have = new Set((await db.all('SELECT event_id FROM feed_events WHERE feed_id = ?', [feed.id])).map((r) => r.event_id));++ const want = new Set(s.events.map((e) => String(e.event_id)));++ const missing = [...want].filter((x) => !have.has(x)).length;++ const extra = [...have].filter((x) => !want.has(x)).length;++ log(`feed ${feed.id} ← slot ${s.id}: ${have.size} event(s) held, ${missing} missing, ${extra} not from the file`);++ if (missing || extra) bad++;++ }++ if (bad) { log(`${bad} feed(s) did not verify`); return 1; }++ log('verified: every feed holds exactly the events copied');++ return 0;++}++++if (require.main === module) {++ main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { console.error(`import-live-feeds failed: ${err.message}`); process.exit(1); });++}++++module.exports = { main, parseArgs, readExport };@@@ -186,10 +186,12 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {},credentials: true,});-- // The widget's visitor API answers any site, without cookies (the visitor's token is the key), so a site can build-- // its own panel on it; an inbox that names its sites refuses the others itself (server/inbox/routes.js).-- const openCors = cors({ origin: true, credentials: false, allowedHeaders: ['Content-Type', 'X-Visitor-Token'] });-- app.use((req, res, next) => (req.path.startsWith('/api/chat/widget/') ? openCors : allowlistedCors)(req, res, next));++ // The widget's visitor API and feeds answer any site, without cookies (the visitor's token, the feed's key or an API++ // token is the key), so a site can build its own panel or publish from a browser; an inbox that names its sites++ // refuses the others itself (server/inbox/routes.js).++ const openCors = cors({ origin: true, credentials: false, allowedHeaders: ['Content-Type', 'X-Visitor-Token', 'Authorization'] });++ const openPath = (p) => p.startsWith('/api/chat/widget/') || p === '/api/chat/feeds' || p.startsWith('/api/chat/feeds/');++ app.use((req, res, next) => (openPath(req.path) ? openCors : allowlistedCors)(req, res, next));app.use(express.json({ limit: '1mb' }));app.use(express.urlencoded({ extended: true, limit: '256kb' }));app.use(cookieParser());@@@ -249,6 +251,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {// Support inboxes (server/inbox/): the widget's visitor API, and the inbox's people.app.use('/api/chat/widget/inbox/:id', require('./inbox/routes').widget);app.use('/api/chat/inboxes', require('./inbox/routes').agents);++ // Feeds (server/feeds/): live activity (vibe coding) published with a feed's key, shown in rooms, on Live, anywhere.++ app.use('/api/chat/feeds', require('./feeds/routes'));app.use('/api/chat/servers', require('./rooms/server-routes'));// Channel moderators & moderation settings (/api/chat/channels/:id/…): before /api/chat too.app.use('/api/chat/channels', require('./chat/channel-mod-routes'));@@@ -63,6 +63,9 @@ const EXPORTS = [['inboxes_owned.json', `SELECT * FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],['inbox_memberships.json', `SELECT * FROM inbox_members WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],['inbox_replies.json', `SELECT * FROM inbox_messages WHERE author = 'agent' AND author_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],++ // Feeds (server/feeds/feeds.js): the feeds, without their key's hash, and what was published to them.++ ['feeds.json', `SELECT id, kind, title, room_id, managed_stream_id, settings, key_hint, legacy_source, created_at, last_event_at FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],++ ['feed_events.json', `SELECT e.feed_id, e.session_key, e.event_id, e.type, e.summary, e.payload, e.created_at FROM feed_events e JOIN feeds f ON f.id = e.feed_id WHERE f.owner_subject = ANY(@s) OR f.owner_id = ANY(@ids) ORDER BY e.id DESC LIMIT ${LIMIT}`],];async function exportPart(d, subject) {@@@ -110,6 +113,10 @@ const ERASE_AFTER_ROOMS = [[null, 'DELETE FROM inbox_ai_usage WHERE inbox_id IN (SELECT id FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],['inboxes', 'DELETE FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)'],[null, "UPDATE inbox_messages SET author_user_id = NULL, author_name = 'A former agent' WHERE author = 'agent' AND author_user_id = ANY(@ids)"],++ // Feeds (server/feeds/feeds.js): a person's feeds go with everything published to them.++ ['feed_events', 'DELETE FROM feed_events WHERE feed_id IN (SELECT id FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],++ [null, 'DELETE FROM feed_sessions WHERE feed_id IN (SELECT id FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],++ ['feeds', 'DELETE FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)'],// An AI member belongs to its room, not to whoever added it: it stays, without their name on it.[null, 'UPDATE room_ai_members SET created_by_subject = NULL WHERE created_by_subject = ANY(@s)'],// Their own AI key is gone with them (OpenVibe.AI erases it): members that thought with it go back to OpenVibe's model.@@@ -603,6 +603,14 @@ class ChatServer {this.sendTo(ws, { type: 'inbox_watching', inbox: inbox.id, conversation: client.inboxConv || null });break;}++ // Feeds (server/feeds/feeds.js): anyone follows a feed's card; what reaches them is already projected.++ case 'feed_watch': {++ const feed = await require('../feeds/feeds').get(msg.feed);++ if (!feed) { this.sendTo(ws, { type: 'feed_error', code: 'feeds.not_found', message: 'No such feed' }); break; }++ client.feedId = feed.id;++ this.sendTo(ws, { type: 'feed_watching', feed: feed.id });++ break;++ }case 'room_message': {const rooms = require('../rooms/rooms');const room = client.roomId ? await rooms.bySlug(client.roomSlug) : null;@@@ -2559,6 +2567,20 @@ class ChatServer {}}++ /**++ * A feed's event, as viewers see it: to everyone following the feed's card, and to the Live channel slot it is on++ * while that slot is live, as Live's `vibe-coding` frame (the widget in Live's channel chat reads those).++ */++ async deliverFeed(feed, event) {++ const msg = JSON.stringify({ type: 'feed_event', feed: feed.id, event });++ for (const [ws, client] of this.clients) {++ if (client.feedId === feed.id && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) ws.send(msg);++ }++ if (!feed.managed_stream_id) return;++ const live = await ctx.liveStreamForSlot(feed.managed_stream_id).catch(() => null);++ if (live) await this.broadcastToStream(Number(live.id), { type: 'vibe-coding', managed_stream_id: feed.managed_stream_id, slot_slug: live.slug || null, delay_ms: 0, event });++ }++/** Everyone following a chat room (server/rooms/) on this server. */broadcastToRoom(roomId, data) {const msg = JSON.stringify(data);nnew file mode 100644@@@ -0,0 +1,412 @@++'use strict';++/**++ * Feeds (docs/platform.md step 2; migrations/0017_feeds.sql): live activity a person publishes into Chat. Vibe coding,++ * moved here from OpenVibe.Live (its server/vibe-coding/, same canonical events), is the first kind: an editor, agent or++ * CLI sends sanitized events (a prompt, an answer, a tool used, a file saved) with the feed's key, and viewers see a++ * compact card in the feed's room, on its Live channel slot while live, or on any site (openvibe.chat/embed/feed/:id).++ *++ * feeds create(user, input) → { feed, key } · update(feed, user, input) · rotateKey(feed) → key · remove(feed) ·++ * get(id) · byKey(key) · mine(user) · forSlots(ids) · forRoom(roomId) · publicShape(feed)++ * publishing hello(feed, msg) · publish(feed, input) → [{ eventId, delivered }] · fromClaudeCode(hook) → events++ * viewing view(feed, { limit }) → { feed, publisher, events } · project(event, settings)++ *++ * What a viewer sees is decided when they see it: project() applies the feed's settings to the stored event (the++ * projection is Live's), so turning prompts off hides them in the history too. Events are scrubbed of secrets (keys,++ * tokens, passwords, private keys) and home folders on the way in, and reach viewers only after the feed's delay, kept++ * on the server (Live sent the delay to the browser). A feed keeps its newest 5,000 events.++ */++const crypto = require('crypto');++const path = require('path');++const db = require('../db/database');++const wordFilter = require('../chat/word-filter');++++class FeedError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } }++const fail = (status, code, message) => { throw new FeedError(status, code, message); };++++const B32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';++const newId = () => `fed_${Array.from(crypto.randomBytes(26), (b) => B32[b % 32]).join('')}`;++const newKey = () => `ovf_${crypto.randomBytes(24).toString('base64url')}`;++const hashKey = (k) => crypto.createHash('sha256').update(String(k)).digest('hex');++const FEED_RE = /^fed_[0-9A-HJKMNP-TV-Z]{26}$/;++const KEY_RE = /^ovf_[A-Za-z0-9_-]{32}$/;++const MAX_FEEDS = 10;++const KEEP_EVENTS = 5000;++const MAX_BATCH = 50;++const TYPES = ['session.status', 'prompt', 'response', 'thinking', 'tool.call', 'file.change', 'file.save'];++++const DEFAULTS = Object.freeze({++ enabled: 1,++ viewer_depth: 'standard',++ show_prompts: 1,++ show_responses: 1,++ show_thinking: 0,++ show_tool_calls: 1,++ show_tool_arguments: 0,++ show_file_events: 1,++ show_file_snippets: 0,++ redact_file_paths: 1,++ paused: 0,++ delay_ms: 0,++ max_events: 18,++ max_prompt_chars: 220,++ max_response_chars: 360,++ max_thinking_chars: 140,++ max_tool_chars: 140,++ max_snippet_chars: 140,++});++++const clean = (v, max) => String(v == null ? '' : v).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim().slice(0, max);++const str = (v, max = 200) => { const s = clean(v, max); return s || null; };++const bool = (v, d) => (v === undefined || v === null ? d : (v === true || v === 1 || v === '1' || v === 'on' || v === 'true') ? 1 : 0);++const int = (v, min, max, d) => { const n = Number(v); return Number.isFinite(n) ? Math.min(max, Math.max(min, Math.round(n))) : d; };++const depthOf = (v) => (['headline', 'deep'].includes(String(v || '').toLowerCase()) ? String(v).toLowerCase() : 'standard');++const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null);++const iso = (t) => (t ? (String(t).includes('T') ? String(t) : `${String(t).replace(' ', 'T')}Z`) : null); // ov_now() is UTC text++const json = (v, d) => { if (v && typeof v === 'object') return v; try { return JSON.parse(v || 'null') || d; } catch { return d; } };++++function normalizeSettings(input = {}, current = DEFAULTS) {++ const s = { ...DEFAULTS, ...current };++ const out = {};++ for (const k of ['enabled', 'show_prompts', 'show_responses', 'show_thinking', 'show_tool_calls', 'show_tool_arguments', 'show_file_events', 'show_file_snippets', 'redact_file_paths', 'paused']) out[k] = bool(input[k], s[k]);++ out.viewer_depth = depthOf(input.viewer_depth !== undefined ? input.viewer_depth : s.viewer_depth);++ const n = (k, min, max) => { out[k] = int(input[k] !== undefined ? input[k] : s[k], min, max, DEFAULTS[k]); };++ n('delay_ms', 0, 120000); n('max_events', 1, 100); n('max_prompt_chars', 60, 1200); n('max_response_chars', 80, 2400);++ n('max_thinking_chars', 40, 1200); n('max_tool_chars', 40, 1200); n('max_snippet_chars', 40, 1200);++ return out;++}++++// ── Scrubbing: what a publisher should never have sent ─────────────────────────────────────────────────────────────++const HIDDEN = '[hidden]';++const SECRETS = [++ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g,++ /\b(?:sk|pk|rk)-(?:ant-|proj-|live-|test-)?[A-Za-z0-9_-]{16,}/g,++ /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g,++ /\b(?:hbt|ovf|ovs|whsec)_[A-Za-z0-9_-]{8,}/g,++ /\bAKIA[0-9A-Z]{16}\b/g,++ /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,++ /\bAIza[0-9A-Za-z_-]{30,}/g,++ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,++];++function scrubText(s) {++ let t = String(s);++ for (const re of SECRETS) t = t.replace(re, HIDDEN);++ return t++ .replace(/\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{12,}/gi, `$1 ${HIDDEN}`)++ .replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/@]+@/gi, `$1${HIDDEN}@`)++ .replace(/\b((?:[A-Z0-9_]*_)?(?:PASSWORD|PASSWD|SECRET|TOKEN|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY)[A-Z0-9_]*)(\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, `$1$2${HIDDEN}`)++ .replace(/(?:\/home|\/Users)\/[^/\s"'`]+/g, '~')++ .replace(/\b[A-Za-z]:\\Users\\[^\\\s"'`]+/g, '~');++}++function scrub(v, depth = 0) {++ if (depth > 6) return null;++ if (typeof v === 'string') return scrubText(v).slice(0, 8000);++ if (Array.isArray(v)) return v.slice(0, 50).map((x) => scrub(x, depth + 1));++ if (v && typeof v === 'object') { const o = {}; for (const [k, x] of Object.entries(v).slice(0, 60)) o[k] = scrub(x, depth + 1); return o; }++ return typeof v === 'number' || typeof v === 'boolean' || v === null ? v : null;++}++++// ── Feeds ──────────────────────────────────────────────────────────────────────────────────────────────────────────++++function shape(r) {++ if (!r) return null;++ return {++ id: r.id, owner_id: Number(r.owner_id), kind: r.kind, title: r.title,++ room_id: r.room_id == null ? null : Number(r.room_id), managed_stream_id: r.managed_stream_id == null ? null : Number(r.managed_stream_id),++ settings: normalizeSettings({}, json(r.settings, {})), key_hint: r.key_hint || null, legacy_source: r.legacy_source || null,++ created_at: iso(r.created_at), last_event_at: iso(r.last_event_at),++ };++}++/** What anyone may know about a feed (the embed, the room card, Live's channel). */++function publicShape(feed) {++ return { id: feed.id, kind: feed.kind, title: feed.title, enabled: !!feed.settings.enabled && !feed.settings.paused, depth: feed.settings.viewer_depth, last_event_at: feed.last_event_at };++}++++async function get(id) {++ if (!FEED_RE.test(String(id || ''))) return null;++ return shape(await db.get('SELECT * FROM feeds WHERE id = ? AND archived_at IS NULL', [id]));++}++async function byKey(key) {++ if (!KEY_RE.test(String(key || ''))) return null;++ return shape(await db.get('SELECT * FROM feeds WHERE key_hash = ? AND archived_at IS NULL', [hashKey(key)]));++}++async function mine(user) {++ return (await db.all('SELECT * FROM feeds WHERE owner_id = ? AND archived_at IS NULL ORDER BY created_at DESC, id', [user.id])).map(shape);++}++async function forSlots(ids) {++ const list = [...new Set((ids || []).map(Number).filter((n) => Number.isSafeInteger(n) && n > 0))].slice(0, 50);++ if (!list.length) return [];++ return (await db.all(`SELECT * FROM feeds WHERE managed_stream_id IN (${list.map(() => '?').join(',')}) AND archived_at IS NULL ORDER BY last_event_at DESC NULLS LAST`, list)).map(shape);++}++/** The feed a room shows (the most recently active one its owner put there that viewers may see), or null. */++async function forRoom(roomId) {++ const list = (await db.all('SELECT * FROM feeds WHERE room_id = ? AND archived_at IS NULL ORDER BY last_event_at DESC NULLS LAST LIMIT 5', [roomId])).map(shape);++ return list.find((f) => f.settings.enabled && !f.settings.paused) || null;++}++++// Where a feed shows: a room the person owns, a channel slot of theirs. null clears it.++async function placeOf(user, input, current = {}) {++ const out = {};++ if (input.room !== undefined || input.room_id !== undefined) {++ const v = input.room !== undefined ? input.room : input.room_id;++ if (v === null || v === '' || v === 0 || v === '0') out.room_id = null;++ else {++ const rooms = require('../rooms/rooms');++ const room = /^\d+$/.test(String(v)) ? await db.get('SELECT * FROM rooms WHERE id = ?', [Number(v)]) : await rooms.bySlug(String(v));++ if (!room || room.assistant) fail(422, 'feeds.room', 'No such room');++ if (!(await rooms.access(room, user)).manage) fail(403, 'feeds.room', 'A feed shows only in a room you own');++ out.room_id = Number(room.id);++ }++ }++ if (input.slot !== undefined || input.managed_stream_id !== undefined) {++ const v = input.slot !== undefined ? input.slot : input.managed_stream_id;++ if (v === null || v === '' || v === 0 || v === '0') out.managed_stream_id = null;++ else {++ const ms = await db.get('SELECT id, user_id FROM ctx_managed_streams WHERE id = ?', [Number(v)]);++ if (!ms || Number(ms.user_id) !== Number(user.id)) fail(403, 'feeds.slot', 'A feed shows only on a channel slot of yours');++ out.managed_stream_id = Number(ms.id);++ }++ }++ return { room_id: current.room_id ?? null, managed_stream_id: current.managed_stream_id ?? null, ...out };++}++++function titleOf(v) {++ const t = clean(v, 81).replace(/\s+/g, ' ');++ if (t.length < 2 || t.length > 80) fail(422, 'feeds.title', 'A feed\'s title is 2 to 80 characters');++ if (wordFilter.check(t).safe === false) fail(422, 'feeds.title', 'That title is not allowed');++ return t;++}++++async function create(user, input = {}) {++ const n = await db.get('SELECT count(*)::int AS n FROM feeds WHERE owner_id = ? AND archived_at IS NULL', [user.id]);++ if (n.n >= MAX_FEEDS) fail(409, 'feeds.too_many', `At most ${MAX_FEEDS} feeds`);++ const title = titleOf(input.title || 'Vibe coding');++ const place = await placeOf(user, input);++ const id = newId();++ const key = newKey();++ await db.run(`INSERT INTO feeds (id, owner_id, owner_subject, title, room_id, managed_stream_id, settings, key_hash, key_hint)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, user.id, user.subject_id || null, title, place.room_id, place.managed_stream_id,++ JSON.stringify(normalizeSettings(obj(input.settings) || {})), hashKey(key), key.slice(-4)]);++ return { feed: await get(id), key };++}++async function update(feed, user, input = {}) {++ const title = input.title !== undefined ? titleOf(input.title) : feed.title;++ const place = await placeOf(user, input, feed);++ const settings = normalizeSettings(obj(input.settings) || {}, feed.settings);++ await db.run('UPDATE feeds SET title = ?, room_id = ?, managed_stream_id = ?, settings = ? WHERE id = ?',++ [title, place.room_id, place.managed_stream_id, JSON.stringify(settings), feed.id]);++ return await get(feed.id);++}++async function rotateKey(feed) {++ const key = newKey();++ await db.run('UPDATE feeds SET key_hash = ?, key_hint = ? WHERE id = ?', [hashKey(key), key.slice(-4), feed.id]);++ return key;++}++async function remove(feed) {++ await db.run('DELETE FROM feed_events WHERE feed_id = ?', [feed.id]);++ await db.run('DELETE FROM feed_sessions WHERE feed_id = ?', [feed.id]);++ await db.run('DELETE FROM feeds WHERE id = ?', [feed.id]);++}++++// ── Publishing ─────────────────────────────────────────────────────────────────────────────────────────────────────++++function publisherOf(p) {++ const s = obj(p) || {};++ const caps = obj(s.capabilities) || {};++ const clientType = String(s.clientType || '').toLowerCase();++ return {++ integrationId: str(s.integrationId, 60) || 'custom',++ integrationLabel: str(s.integrationLabel, 60) || str(s.integrationId, 60) || 'Custom publisher',++ vendor: str(s.vendor, 60),++ clientType: ['vscode-extension', 'cli', 'browser', 'service'].includes(clientType) ? clientType : 'other',++ clientName: str(s.clientName, 80) || str(s.integrationLabel, 80) || 'Custom publisher',++ clientVersion: str(s.clientVersion || s.extensionVersion, 40),++ workspaceName: str(s.workspaceName, 80),++ machineName: null, // never shown; Live kept it, Chat does not++ viewerDepth: depthOf(s.viewerDepth),++ capabilities: { thinking: !!caps.thinking, toolCalls: !!caps.toolCalls, workspaceFileEvents: !!caps.workspaceFileEvents },++ };++}++const eventPublisher = (p) => { const x = publisherOf(p); return { integrationId: x.integrationId, integrationLabel: x.integrationLabel, vendor: x.vendor, clientType: x.clientType, clientName: x.clientName }; };++++/** One canonical event from what a publisher sent (Live's envelope, the bare event, or a short { type, text }). */++function normalizeEvent(input, fallbackSession) {++ let e = obj(input);++ if (!e) fail(422, 'feeds.event', 'An event is an object');++ if (e.type === 'vibe-coding.event') e = obj(e.event) || fail(422, 'feeds.event', 'The envelope has no event');++ const type = String(e.eventType || e.type || '');++ if (!TYPES.includes(type)) fail(422, 'feeds.event_type', `eventType is one of ${TYPES.join(', ')}`);++ const text = (k) => str((obj(e[k]) && e[k].text) || (e.text && (type === k) ? e.text : null), 8000);++ const out = {++ version: 1,++ eventId: str(e.eventId || e.id, 100) || crypto.randomUUID(),++ sessionKey: str(e.sessionKey, 120) || fallbackSession || 'default',++ sequence: int(e.sequence, 0, Number.MAX_SAFE_INTEGER, 0),++ eventType: type,++ visibility: 'public',++ occurredAt: str(e.occurredAt, 40) || new Date().toISOString(),++ summary: str(e.summary, 2000) || '',++ source: obj(e.source) ? { channel: str(e.source.channel, 60), rawType: str(e.source.rawType, 60) } : null,++ publisher: obj(e.publisher) ? eventPublisher(e.publisher) : null,++ metadata: obj(e.metadata) ? { requestId: str(e.metadata.requestId, 100), responseId: str(e.metadata.responseId, 100), workspaceName: str(e.metadata.workspaceName, 80), model: str(e.metadata.model, 80) } : null,++ };++ if (type === 'prompt') out.prompt = { text: text('prompt') || out.summary };++ if (type === 'response') out.response = { text: text('response') || out.summary };++ if (type === 'thinking') out.thinking = { text: text('thinking') || out.summary };++ if (type === 'tool.call') {++ const t = obj(e.tool) || {};++ out.tool = { name: str(t.name || e.name, 80) || 'tool', phase: str(t.phase, 20) || 'completed', argumentsPreview: str(t.argumentsPreview || e.args, 2000), resultPreview: str(t.resultPreview, 2000) };++ }++ if (type === 'file.change' || type === 'file.save') {++ const f = obj(e.file) || {};++ const rel = str(f.relativePath || e.path, 400);++ out.file = { name: str(f.name, 200) || (rel ? path.posix.basename(rel.replace(/\\/g, '/')) : 'file'), relativePath: rel, operation: str(f.operation, 20) || (type === 'file.save' ? 'save' : 'edit'), changeCount: int(f.changeCount, 0, 1e6, 0), snippet: str(f.snippet, 2000) };++ }++ if (!out.summary) out.summary = (out.prompt || out.response || out.thinking || {}).text || '';++ return scrub(out);++}++++function deliverer(deps) {++ return deps.deliver || ((feed, event) => { try { require('../chat/chat-server').deliverFeed(feed, event); } catch { /* no socket server (tests) */ } });++}++++/** A publisher starts (or resumes) a session: who it is. */++async function hello(feed, msg = {}) {++ const key = str(msg.sessionKey, 120) || 'default';++ const publisher = publisherOf(msg.publisher);++ await db.run(`INSERT INTO feed_sessions (feed_id, session_key, publisher, status, last_event_at) VALUES (?, ?, ?, 'active', ov_now())++ ON CONFLICT (feed_id, session_key) DO UPDATE SET publisher = excluded.publisher, status = 'active', ended_at = NULL, last_event_at = ov_now()`,++ [feed.id, key, JSON.stringify(publisher)]);++ return { sessionKey: key, publisher };++}++++/**++ * Store and deliver events: one, { events: [...] } (at most 50), or Live's envelopes. A repeated eventId replaces the++ * stored one and is not delivered again. → [{ eventId, delivered }]++ */++async function publish(feed, input, deps = {}) {++ const list = Array.isArray(input) ? input : Array.isArray(input && input.events) ? input.events : [input];++ if (!list.length) fail(422, 'feeds.empty', 'No events');++ if (list.length > MAX_BATCH) fail(413, 'feeds.batch', `At most ${MAX_BATCH} events at once`);++ const events = list.map((e) => normalizeEvent(e, input && input.sessionKey));++ const deliver = deliverer(deps);++ const now = Date.now();++ const out = [];++ for (const event of events) {++ const r = await db.get(`INSERT INTO feed_events (feed_id, session_key, event_id, seq, type, summary, payload, received_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?)++ ON CONFLICT (feed_id, event_id) DO UPDATE SET seq = excluded.seq, type = excluded.type, summary = excluded.summary, payload = excluded.payload++ RETURNING (xmax = 0) AS fresh`, [feed.id, event.sessionKey, event.eventId, event.sequence, event.eventType, event.summary.slice(0, 2000), JSON.stringify(event), now]);++ await db.run(`INSERT INTO feed_sessions (feed_id, session_key, publisher, status, last_event_at) VALUES (?, ?, ?, 'active', ov_now())++ ON CONFLICT (feed_id, session_key) DO UPDATE SET last_event_at = ov_now()${event.eventType === 'session.status' && /\bend(ed)?\b/i.test(event.summary) ? ", status = 'ended', ended_at = ov_now()" : ''}`,++ [feed.id, event.sessionKey, JSON.stringify(event.publisher || {})]);++ const fresh = !!(r && r.fresh);++ const viewed = fresh ? project(event, feed.settings) : null;++ // The delay is the server's: a viewer cannot read ahead of it (the history waits for it too, in view()).++ if (viewed && feed.settings.delay_ms) setTimeout(() => deliver(feed, viewed), feed.settings.delay_ms).unref();++ else if (viewed) deliver(feed, viewed);++ out.push({ eventId: event.eventId, delivered: !!viewed });++ }++ await db.run('UPDATE feeds SET last_event_at = ov_now() WHERE id = ?', [feed.id]);++ if (Math.random() < 0.02) await prune(feed.id);++ return out;++}++async function prune(feedId) {++ await db.run(`DELETE FROM feed_events WHERE feed_id = ? AND id < (SELECT id FROM feed_events WHERE feed_id = ? ORDER BY id DESC OFFSET ? LIMIT 1)`, [feedId, feedId, KEEP_EVENTS - 1]);++}++++// Claude Code's hooks (an "http" hook POSTs each event's JSON): what a viewer may see of a prompt, a tool used, a++// turn's answer. Paths become relative to the session's folder; nothing is read from the transcript.++const CC = { integrationId: 'claude-code', integrationLabel: 'Claude Code', vendor: 'Anthropic', clientType: 'cli', clientName: 'Claude Code' };++function relTo(cwd, p) {++ const s = String(p || '');++ if (cwd && s.startsWith(String(cwd).replace(/\/+$/, '') + '/')) return s.slice(String(cwd).replace(/\/+$/, '').length + 1);++ return s;++}++function fromClaudeCode(h) {++ const hook = obj(h) || {};++ const sessionKey = `cc-${clean(hook.session_id, 80) || 'session'}`;++ const base = { sessionKey, publisher: CC, metadata: { model: str(obj(hook.model) ? hook.model.id || hook.model.display_name : hook.model, 80), workspaceName: hook.cwd ? path.basename(String(hook.cwd)) : null } };++ const id = (suffix) => `${sessionKey}-${clean(hook.tool_use_id || hook.prompt_id || '', 80) || crypto.randomUUID()}${suffix}`;++ switch (hook.hook_event_name) {++ case 'SessionStart': return [{ ...base, eventId: id('-start'), eventType: 'session.status', summary: hook.source === 'resume' ? 'Resumed a Claude Code session' : 'Started a Claude Code session' }];++ case 'SessionEnd': return [{ ...base, eventId: id('-end'), eventType: 'session.status', summary: 'Session ended' }];++ case 'UserPromptSubmit': return hook.prompt ? [{ ...base, eventId: id('-prompt'), eventType: 'prompt', prompt: { text: String(hook.prompt) } }] : [];++ case 'Stop': return hook.last_assistant_message ? [{ ...base, eventId: id(`-answer-${Date.now()}`), eventType: 'response', response: { text: String(hook.last_assistant_message) } }] : [];++ case 'PostToolUse': {++ const name = clean(hook.tool_name, 80) || 'tool';++ const input = obj(hook.tool_input) || {};++ const file = input.file_path || input.notebook_path || null;++ const rel = file ? relTo(hook.cwd, file) : null;++ const arg = input.command || rel || input.pattern || input.url || input.query || input.description || null;++ // A file written or edited is a file event (the feed's file settings apply), not a tool used as well.++ if (rel && ['Edit', 'MultiEdit', 'Write', 'NotebookEdit'].includes(name)) {++ return [{ ...base, eventId: id('-file'), eventType: name === 'Write' ? 'file.save' : 'file.change', file: { relativePath: rel, operation: name === 'Write' ? 'write' : 'edit', changeCount: Array.isArray(input.edits) ? input.edits.length : 1 } }];++ }++ const what = { Bash: 'Ran a command', Read: `Read ${rel || 'a file'}`, Grep: 'Searched the code', Glob: 'Looked for files', WebFetch: 'Read a web page', WebSearch: 'Searched the web', Task: 'Started a helper', Agent: 'Started a helper' }[name];++ return [{ ...base, eventId: id('-tool'), eventType: 'tool.call', summary: what || `Used ${name}`, tool: { name, phase: 'completed', argumentsPreview: arg ? String(arg) : null, resultPreview: null } }];++ }++ default: return [];++ }++}++++// ── Viewing ────────────────────────────────────────────────────────────────────────────────────────────────────────++++function cut(value, max, depth) {++ const text = String(value || '').replace(/\s+/g, ' ').trim();++ if (!text) return '';++ const limit = depth === 'headline' ? Math.max(40, Math.floor(max * 0.6)) : depth === 'deep' ? Math.max(max, Math.floor(max * 1.1)) : max;++ return text.length <= limit ? text : `${text.slice(0, Math.max(20, limit - 1)).trim()}…`;++}++const TOOL_PREVIEW_ALWAYS = ['run_in_terminal', 'create_and_run_task', 'run_notebook_cell'];++++/** What a viewer sees of a stored event under these settings, or null (Live's projectViewerEvent). */++function project(event, settings) {++ if (!event || !settings || !settings.enabled || settings.paused) return null;++ const d = settings.viewer_depth;++ const p = {++ eventId: event.eventId, eventType: event.eventType, occurredAt: event.occurredAt, sequence: event.sequence, depth: d,++ summary: '', source: event.source || null, prompt: null, response: null, thinking: null, tool: null, file: null,++ publisher: event.publisher ? eventPublisher(event.publisher) : null,++ metadata: { requestId: event.metadata?.requestId || null, responseId: event.metadata?.responseId || null, workspaceName: event.metadata?.workspaceName || null, model: event.metadata?.model || null },++ };++ switch (event.eventType) {++ case 'session.status': p.summary = cut(event.summary, settings.max_prompt_chars, d); return p;++ case 'prompt': if (!settings.show_prompts) return null; p.summary = cut(event.prompt?.text || event.summary, settings.max_prompt_chars, d); p.prompt = { text: p.summary }; return p;++ case 'response': if (!settings.show_responses) return null; p.summary = cut(event.response?.text || event.summary, settings.max_response_chars, d); p.response = { text: p.summary }; return p;++ case 'thinking': if (!settings.show_thinking) return null; p.summary = cut(event.thinking?.text || event.summary, settings.max_thinking_chars, d); p.thinking = { text: p.summary }; return p;++ case 'tool.call': {++ if (!settings.show_tool_calls) return null;++ const preview = settings.show_tool_arguments || TOOL_PREVIEW_ALWAYS.includes(String(event.tool?.name || '').toLowerCase());++ p.summary = cut(event.summary || `Used ${event.tool?.name || 'tool'}`, settings.max_tool_chars, d);++ p.tool = { name: event.tool?.name || 'tool', phase: event.tool?.phase || 'completed', argumentsPreview: preview ? cut(event.tool?.argumentsPreview, settings.max_tool_chars, d) || null : null, resultPreview: preview ? cut(event.tool?.resultPreview, settings.max_tool_chars, d) || null : null };++ if (!preview && settings.redact_file_paths) p.summary = p.summary.replace(/[\w.~/-]*\/([\w.-]+)/g, '$1');++ return p;++ }++ case 'file.change': case 'file.save': {++ if (!settings.show_file_events) return null;++ const raw = String(event.file?.relativePath || event.file?.name || '').trim();++ const shown = settings.redact_file_paths ? path.posix.basename(raw.replace(/\\/g, '/')) : raw;++ p.summary = `${event.eventType === 'file.save' ? 'Saved' : 'Editing'} ${shown || event.file?.name || 'file'}`;++ p.file = { name: event.file?.name || shown || 'file', relativePath: shown || null, operation: event.file?.operation || (event.eventType === 'file.save' ? 'save' : 'edit'), changeCount: Number(event.file?.changeCount || 0), snippet: settings.show_file_snippets ? cut(event.file?.snippet, settings.max_snippet_chars, d) || null : null };++ return p;++ }++ default: return null;++ }++}++++/** The feed as a viewer sees it now: its newest events, older than its delay, projected. */++async function view(feed, { limit } = {}) {++ const n = int(limit, 1, 100, feed.settings.max_events);++ const before = Date.now() - feed.settings.delay_ms;++ const rows = await db.all('SELECT payload FROM feed_events WHERE feed_id = ? AND received_ms <= ? ORDER BY id DESC LIMIT ?', [feed.id, before, n * 3]);++ const events = rows.map((r) => project(json(r.payload, null), feed.settings)).filter(Boolean).slice(0, n).reverse();++ const s = await db.get("SELECT publisher FROM feed_sessions WHERE feed_id = ? ORDER BY COALESCE(last_event_at, created_at) DESC, id DESC LIMIT 1", [feed.id]);++ return { feed: publicShape(feed), publisher: s ? publisherOf(json(s.publisher, {})) : null, events };++}++++function _reset() { /* no module state */ }++++module.exports = {++ FeedError, FEED_RE, KEY_RE, TYPES, DEFAULTS, MAX_FEEDS, KEEP_EVENTS, MAX_BATCH, hashKey,++ normalizeSettings, scrubText, scrub, normalizeEvent, project, fromClaudeCode,++ create, update, rotateKey, remove, get, byKey, mine, forSlots, forRoom, shape, publicShape, hello, publish, view, prune, _reset,++};nnew file mode 100644@@@ -0,0 +1,113 @@++'use strict';++/**++ * Feeds over HTTP (server/feeds/feeds.js, docs/platform.md). One router at /api/chat/feeds; the public and publishing++ * routes answer any site without cookies (server/app.js), the owner's need a sign-in (or an API token as its person).++ *++ * Anyone:++ * GET /?slot=1,2 | ?channel=username { feeds }: the feeds shown on those Live channel slots (Live's chat asks)++ * GET /:id { feed, publisher, events }: what a viewer sees now (after the delay)++ * Publishers (Authorization: Bearer ovf_…, the feed's key):++ * POST /:id/hello { sessionKey?, publisher? } → { sessionKey, publisher }++ * POST /:id/events an event, { events: [...] } (at most 50) or Live's envelopes → { events }++ * POST /:id/hooks/claude-code Claude Code's hook JSON as is (an "http" hook) → {} (never blocks it)++ * The owner:++ * GET /mine { feeds }++ * POST / { title?, room?, slot?, settings? } → 201 { feed, key } (the key, once)++ * PATCH /:id { title?, room?, slot?, settings? } → { feed }++ * POST /:id/key → { key } (a new key; the old one stops working)++ * DELETE /:id++ */++const express = require('express');++const rateLimit = require('express-rate-limit');++const cache = require('openvibe-shared/cache-policy');++const { requireAuth } = require('../auth/auth');++const feeds = require('./feeds');++++function send(res, err) {++ if (err instanceof feeds.FeedError) return res.status(err.status).json({ error: err.message, code: err.code });++ console.error('[Feeds]', err && err.message);++ return res.status(500).json({ error: 'Something went wrong' });++}++const noStore = (res) => res.set('Cache-Control', cache.htmlHeaders({ private: true }));++const router = express.Router();++const readLimit = rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false });++// Per feed key: an agent working hard sends a few events a second.++const publishLimit = rateLimit({++ windowMs: 60_000, max: 600, standardHeaders: true, legacyHeaders: false,++ keyGenerator: (req) => `feed:${req.params.id}`,++ message: { error: 'Slow down a little', code: 'feeds.slow_down' },++});++++// The feed's key first (a wrong key spends the address's allowance, not the feed's), then the feed's own allowance.++const keyLimit = rateLimit({ windowMs: 60_000, max: 1200, standardHeaders: true, legacyHeaders: false });++async function withKey(req, res, next) {++ const m = /^Bearer\s+(\S+)$/i.exec(req.get('authorization') || '');++ const feed = m ? await feeds.byKey(m[1]) : null;++ if (!feed || feed.id !== req.params.id) return res.status(401).json({ error: 'The feed\'s key is missing or wrong', code: 'feeds.key' });++ req.feed = feed;++ next();++}++const publisher = [keyLimit, withKey, publishLimit];++async function ownFeed(req, res) {++ const feed = await feeds.get(req.params.id);++ if (!feed || feed.owner_id !== Number(req.user.id)) { res.status(404).json({ error: 'No such feed', code: 'feeds.not_found' }); return null; }++ return feed;++}++++// ── Anyone ─────────────────────────────────────────────────────────────────────────────────────────────────────────++router.get('/', readLimit, async (req, res, next) => {++ if (!req.query.slot && !req.query.channel) return next();++ try {++ let slots = String(req.query.slot || '').split(',').map(Number).filter(Boolean);++ if (req.query.channel) {++ const ctx = require('../live-context');++ const owner = await ctx.getUserByUsername(String(req.query.channel));++ slots = owner ? (await ctx.getManagedStreamsByUserId(owner.id)).map((m) => Number(m.id)) : [];++ }++ const list = (await feeds.forSlots(slots)).filter((f) => f.settings.enabled && !f.settings.paused);++ res.set('Cache-Control', 'public, max-age=15').json({ feeds: list.map((f) => ({ ...feeds.publicShape(f), managed_stream_id: f.managed_stream_id })) });++ } catch (err) { send(res, err); }++});++++// ── The owner ──────────────────────────────────────────────────────────────────────────────────────────────────────++router.get('/mine', requireAuth, async (req, res) => {++ try { noStore(res).json({ feeds: await feeds.mine(req.user) }); } catch (err) { send(res, err); }++});++router.post('/', requireAuth, async (req, res) => {++ try { const r = await feeds.create(req.user, req.body || {}); noStore(res).status(201).json(r); } catch (err) { send(res, err); }++});++router.patch('/:id', requireAuth, async (req, res) => {++ const feed = await ownFeed(req, res); if (!feed) return;++ try { noStore(res).json({ feed: await feeds.update(feed, req.user, req.body || {}) }); } catch (err) { send(res, err); }++});++router.post('/:id/key', requireAuth, async (req, res) => {++ const feed = await ownFeed(req, res); if (!feed) return;++ try { noStore(res).json({ key: await feeds.rotateKey(feed) }); } catch (err) { send(res, err); }++});++router.delete('/:id', requireAuth, async (req, res) => {++ const feed = await ownFeed(req, res); if (!feed) return;++ try { await feeds.remove(feed); res.status(204).end(); } catch (err) { send(res, err); }++});++++router.get('/:id', readLimit, async (req, res) => {++ const feed = await feeds.get(req.params.id);++ if (!feed) return res.status(404).json({ error: 'No such feed', code: 'feeds.not_found' });++ try { noStore(res).json(await feeds.view(feed, { limit: req.query.limit })); } catch (err) { send(res, err); }++});++++// ── Publishers ─────────────────────────────────────────────────────────────────────────────────────────────────────++router.post('/:id/hello', publisher, async (req, res) => {++ try { res.json(await feeds.hello(req.feed, req.body || {})); } catch (err) { send(res, err); }++});++router.post('/:id/events', publisher, async (req, res) => {++ try { res.status(202).json({ events: await feeds.publish(req.feed, req.body) }); } catch (err) { send(res, err); }++});++router.post('/:id/hooks/claude-code', publisher, async (req, res) => {++ try {++ const events = feeds.fromClaudeCode(req.body);++ if (events.length) await feeds.publish(req.feed, events);++ } catch (err) { if (!(err instanceof feeds.FeedError)) console.warn('[Feeds] claude-code:', err.message); }++ res.json({}); // an empty answer: Claude Code carries on as if the hook was not there++});++++module.exports = router;@@@ -318,6 +318,12 @@ async function getLiveStreamsByUserId(userId) {async function getStreamsByUserId(userId, limit = 50) {return await db.all(`${STREAM_SELECT} WHERE s.user_id = ? ORDER BY s.created_at DESC LIMIT ?`, [userId, limit]);}++/** The live session on a channel slot (managed stream) right now, with the slot's slug, or null. */++async function liveStreamForSlot(managedStreamId) {++ if (!managedStreamId) return null;++ return await db.get(`SELECT s.id, ms.slug FROM ctx_streams s LEFT JOIN ctx_managed_streams ms ON ms.id = s.managed_stream_id++ WHERE s.managed_stream_id = ? AND s.is_live = 1 ORDER BY s.id DESC LIMIT 1`, [managedStreamId]) || null;++}async function getManagedStreamsByUserId(userId) {return await db.all(`SELECT ms.*,@@@ -944,7 +950,7 @@ module.exports = {authenticate, authFailureReason, upsertUser, invalidateUser, subjectFor,getUserById, getUserByUsername, getUserBySubject, getUserByDisplayName, ensureUsers, ensureUserByUsername,// streams + channels-- getStreamById, ensureStream, refreshStream, latestStreamIdForUser, getLiveStreamsByUserId, getStreamsByUserId, getManagedStreamsByUserId,++ getStreamById, ensureStream, refreshStream, latestStreamIdForUser, getLiveStreamsByUserId, getStreamsByUserId, getManagedStreamsByUserId, liveStreamForSlot,getChannelById, getChannelByUserId, ensureChannelForUser, createChannel,emoteSources, setChannelEmoteSources,// policynnew file mode 100644@@@ -0,0 +1,175 @@++'use strict';++/**++ * Feeds on openvibe.chat (server/feeds/feeds.js, docs/platform.md):++ *++ * GET /embed/feed/:id a feed's card for any site's iframe (public/web/feed-view.js; frame-ancestors any site),++ * OpenVibe.Live's channel chat among them; ?theme=light|dark, else the visitor's own++ * GET /feeds a person's feeds, and a form to make one++ * GET /feeds/:id where it shows, what viewers see, the delay; how to publish (Claude Code's hooks, the++ * API), the key (made again on request), the embed tag, a live preview++ * POST /feeds · /feeds/:id/settings · /feeds/:id/key · /feeds/:id/delete forms; making a feed or a key answers with++ * the page that shows the key, once (it is never stored)++ */++const feeds = require('../feeds/feeds');++const ctx = require('../live-context');++const db = require('../db/database');++const { esc, asset } = require('./layout');++++const THEMES = {++ light: '--oc-bg:#ffffff;--oc-panel:#ffffff;--oc-line:#e3e7ef;--oc-text:#101828;--oc-muted:#5b6577;--oc-accent:#2563eb;--oc-ok:#16a34a;color-scheme:light',++ dark: '--oc-bg:#0b1020;--oc-panel:#121a2c;--oc-line:#223049;--oc-text:#e8edf6;--oc-muted:#98a6bd;--oc-accent:#60a5fa;--oc-ok:#22c55e;color-scheme:dark',++};++++function embedHtml(feed, theme) {++ const pick = THEMES[theme] ? `:root{${THEMES[theme]}}` : `:root{${THEMES.light}}@media (prefers-color-scheme: dark){:root{${THEMES.dark}}}`;++ return `<!DOCTYPE html>++<html lang="en">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++<title>${esc(feed.title)}</title>++<meta name="robots" content="noindex, nofollow">++<link rel="stylesheet" href="${asset('feed-view.css')}">++<style>${pick}html,body{margin:0;background:transparent}body{color:var(--oc-text);font:15px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}</style>++<script src="${asset('feed-view.js')}" defer></script>++</head>++<body><div class="fv" data-feed="${esc(feed.id)}"></div></body>++</html>`;++}++++const hookFor = (base, id) => ({ type: 'http', url: `${base}/api/chat/feeds/${id}/hooks/claude-code`, headers: { Authorization: 'Bearer $OPENVIBE_FEED_KEY' }, allowedEnvVars: ['OPENVIBE_FEED_KEY'], timeout: 5 });++/** The block for .claude/settings.json: the events a viewer may see, sent as they happen; never blocks Claude Code. */++function claudeCodeSnippet(base, id) {++ const h = [hookFor(base, id)];++ return JSON.stringify({ hooks: { SessionStart: [{ hooks: h }], UserPromptSubmit: [{ hooks: h }], PostToolUse: [{ matcher: '*', hooks: h }], Stop: [{ hooks: h }], SessionEnd: [{ hooks: h }] } }, null, 2);++}++const curlSnippet = (base, id) => `curl -X POST ${base}/api/chat/feeds/${id}/events \\++ -H "Authorization: Bearer $OPENVIBE_FEED_KEY" -H "Content-Type: application/json" \\++ -d '{"eventType": "prompt", "text": "Add a dark mode to the settings page"}'`;++const iframeSnippet = (base, id) => `<iframe src="${base}/embed/feed/${id}" title="Vibe coding, live" style="width:100%;height:320px;border:0" loading="lazy"></iframe>`;++++function mountFeedPages(router, { page, needUser, sameSite, formLimit, notice, config }) {++ const base = config.web.baseUrl;++++ router.get('/embed/feed/:id', async (req, res) => {++ const feed = await feeds.get(req.params.id);++ if (!feed) return res.status(404).set('Cache-Control', 'no-store').type('html').send('<!DOCTYPE html><meta charset="utf-8"><title>Feed not found</title><p style="font-family:system-ui;padding:16px">This feed is not available.</p>');++ res.set('Content-Security-Policy', [++ "default-src 'self'", "script-src 'self'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data:",++ `connect-src 'self' ${base.replace(/^http/, 'ws')}`, 'frame-ancestors *', "base-uri 'self'", "form-action 'none'", "object-src 'none'",++ ].join('; ')).set('Cache-Control', 'no-store').set('X-Robots-Tag', 'noindex').type('html').send(embedHtml(feed, String(req.query.theme || '')));++ });++++ async function places(user) {++ const roomList = await db.all('SELECT id, slug, name FROM rooms WHERE owner_id = ? AND archived_at IS NULL AND assistant = 0 AND server_id IS NULL ORDER BY lower(name) LIMIT 100', [user.id]);++ const slots = await ctx.getManagedStreamsByUserId(user.id).catch(() => []);++ return { roomList, slots };++ }++ function placeFields({ roomList, slots }, feed = {}) {++ const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;++ return `<label>Show it in a room <select name="room">${opt('', 'No room', !feed.room_id)}${roomList.map((r) => opt(r.id, r.name, Number(feed.room_id) === Number(r.id))).join('')}</select></label>++<label>Show it on your Live channel while live <select name="slot">${opt('', 'Not on Live', !feed.managed_stream_id)}${slots.map((s) => opt(s.id, s.title || s.slug || `Slot ${s.id}`, Number(feed.managed_stream_id) === Number(s.id))).join('')}</select></label>`;++ }++ function keyPanel(feed, key) {++ return `${notice('ok', 'Here is the feed\'s key. Copy it now: it is shown only this once.')}++<pre class="ib-snippet fd-key"><code>${esc(key)}</code></pre>++<p class="oc-muted">Put it where your tools read it, for example <code>export OPENVIBE_FEED_KEY=${esc(key.slice(0, 8))}…</code> in your shell profile. Anyone with the key can post to this feed; make a new one any time.</p>++<p><a class="oc-button" href="/feeds/${esc(feed.id)}">Set up publishing</a></p>`;++ }++++ router.get('/feeds', async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const mine = await feeds.mine(actor.user);++ const list = mine.length++ ? `<ul class="ib-list">${mine.map((f) => `<li><a href="/feeds/${esc(f.id)}"><span class="ib-dot fd-dot${f.settings.enabled && !f.settings.paused ? ' fd-on' : ''}"></span><b>${esc(f.title)}</b>++<span class="oc-muted">${f.last_event_at ? `last step ${esc(f.last_event_at.slice(0, 16).replace('T', ' '))}` : 'nothing published yet'}${f.room_id ? ' · in a room' : ''}${f.managed_stream_id ? ' · on Live' : ''}</span></a></li>`).join('')}</ul>`++ : '<p class="oc-muted">No feeds yet.</p>';++ await page(req, res, 200, {++ actor, title: 'Feeds', path: '/feeds', active: 'rooms', robots: 'noindex, nofollow',++ body: `<h1>Feeds</h1>++<p class="oc-muted">Show what you are building, live. Claude Code, Copilot or any tool sends each step (a prompt, a command, a file edited, the answer) and your viewers follow along in a card: in your room, on your Live channel while you stream, or embedded on your own site. You decide what they see; secrets and home folders are wiped before anything is shown.</p>++${req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : ''}++${list}++<h2>Make a feed</h2>++<form class="oc-form" method="post" action="/feeds">++<label>Title <input name="title" required minlength="2" maxlength="80" value="Vibe coding"></label>++${placeFields(await places(actor.user))}++<p><button type="submit">Make the feed</button></p></form>`,++ });++ });++++ router.post('/feeds', formLimit, sameSite, async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const b = req.body || {};++ try {++ const { feed, key } = await feeds.create(actor.user, { title: b.title, room: b.room || null, slot: b.slot || null });++ await page(req, res, 201, { actor, title: `${feed.title}: key`, path: `/feeds/${feed.id}`, active: 'rooms', robots: 'noindex, nofollow', body: `<p><a href="/feeds">← Feeds</a></p><h1>${esc(feed.title)}</h1>${keyPanel(feed, key)}` });++ } catch (err) { res.redirect(303, `/feeds?error=${encodeURIComponent(err.message || 'That did not work')}`); }++ });++++ async function ownFeed(req, res) {++ const actor = await needUser(req, res); if (!actor) return null;++ const feed = await feeds.get(req.params.id);++ if (!feed || feed.owner_id !== Number(actor.user.id)) { await page(req, res, 404, { actor, title: 'Feed not found', path: '/feeds', robots: 'noindex, nofollow', body: '<h1>Feed not found</h1><p><a href="/feeds">Your feeds</a></p>' }); return null; }++ return { actor, feed };++ }++++ router.get('/feeds/:id', async (req, res) => {++ const a = await ownFeed(req, res); if (!a) return;++ const { actor, feed } = a;++ const s = feed.settings;++ const check = (name, label) => `<label class="oc-check"><input type="checkbox" name="${name}" value="1"${s[name] ? ' checked' : ''}> ${label}</label>`;++ await page(req, res, 200, {++ actor, title: `${feed.title}: feed`, path: `/feeds/${feed.id}`, active: 'rooms', robots: 'noindex, nofollow', feedView: true,++ body: `<p><a href="/feeds">← Feeds</a></p><h1>${esc(feed.title)}</h1>++${req.query.saved ? notice('ok', 'Saved.') : ''}${req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : ''}++<div class="fv fd-preview" data-feed="${esc(feed.id)}"></div>++<h2>Publish from Claude Code</h2>++<p>Add this to <code>.claude/settings.json</code> in a project (or <code>~/.claude/settings.json</code> for every project), and set <code>OPENVIBE_FEED_KEY</code> in the shell you start Claude Code from. Each prompt, tool used and answer becomes a step; nothing is read from your files or transcript.</p>++<pre class="ib-snippet"><code>${esc(claudeCodeSnippet(base, feed.id))}</code></pre>++<h2>Publish from anything else</h2>++<p>Send events to the API with the key; <code>eventType</code> is one of ${feeds.TYPES.map((t) => `<code>${t}</code>`).join(', ')} (up to 50 at once as <code>{ "events": [...] }</code>). The OpenVibe Copilot companion speaks the same events.</p>++<pre class="ib-snippet"><code>${esc(curlSnippet(base, feed.id))}</code></pre>++<p class="oc-muted">Key ending in <code>${esc(feed.key_hint || '????')}</code>.</p>++<form method="post" action="/feeds/${esc(feed.id)}/key" class="oc-inline"><button type="submit">Make a new key</button></form> <span class="oc-muted">(the old one stops working)</span>++<h2>Show it on your site</h2>++<pre class="ib-snippet"><code>${esc(iframeSnippet(base, feed.id))}</code></pre>++<h2>Where it shows and what viewers see</h2>++<form class="oc-form" method="post" action="/feeds/${esc(feed.id)}/settings">++<label>Title <input name="title" required minlength="2" maxlength="80" value="${esc(feed.title)}"></label>++${placeFields(await places(actor.user), feed)}++${check('enabled', 'Viewers see this feed')}++${check('paused', 'Paused (steps are kept, nobody sees them until you unpause)')}++${check('show_prompts', 'Prompts')}${check('show_responses', 'Answers')}${check('show_tool_calls', 'Tools used')}${check('show_tool_arguments', 'What the tools were given (commands, searches)')}++${check('show_file_events', 'Files changed')}${check('redact_file_paths', 'File names only, not their folders')}${check('show_thinking', 'Thinking, when the tool sends it')}++<label>Detail <select name="viewer_depth">${[['headline', 'Headlines'], ['standard', 'Standard'], ['deep', 'More text']].map(([v, l]) => `<option value="${v}"${s.viewer_depth === v ? ' selected' : ''}>${l}</option>`).join('')}</select></label>++<label>Delay before viewers see a step <select name="delay_ms">${[[0, 'None'], [10000, '10 seconds'], [30000, '30 seconds'], [60000, '1 minute'], [120000, '2 minutes']].map(([v, l]) => `<option value="${v}"${s.delay_ms === v ? ' selected' : ''}>${l}</option>`).join('')}</select></label>++<p><button type="submit">Save</button></p></form>++<form method="post" action="/feeds/${esc(feed.id)}/delete" class="oc-inline"><button type="submit" class="oc-link oc-danger">Delete this feed and its history</button></form>`,++ });++ });++++ router.post('/feeds/:id/settings', formLimit, sameSite, async (req, res) => {++ const a = await ownFeed(req, res); if (!a) return;++ const b = req.body || {};++ const flags = ['enabled', 'paused', 'show_prompts', 'show_responses', 'show_tool_calls', 'show_tool_arguments', 'show_file_events', 'redact_file_paths', 'show_thinking'];++ const settings = { viewer_depth: b.viewer_depth, delay_ms: b.delay_ms };++ for (const f of flags) settings[f] = b[f] === '1' ? 1 : 0;++ try {++ await feeds.update(a.feed, a.actor.user, { title: b.title, room: b.room || null, slot: b.slot || null, settings });++ res.redirect(303, `/feeds/${a.feed.id}?saved=1`);++ } catch (err) { res.redirect(303, `/feeds/${a.feed.id}?error=${encodeURIComponent(err.message || 'That did not save')}`); }++ });++ router.post('/feeds/:id/key', formLimit, sameSite, async (req, res) => {++ const a = await ownFeed(req, res); if (!a) return;++ const key = await feeds.rotateKey(a.feed);++ await page(req, res, 200, { actor: a.actor, title: `${a.feed.title}: key`, path: `/feeds/${a.feed.id}`, active: 'rooms', robots: 'noindex, nofollow', body: `<p><a href="/feeds">← Feeds</a></p><h1>${esc(a.feed.title)}</h1>${keyPanel(a.feed, key)}` });++ });++ router.post('/feeds/:id/delete', formLimit, sameSite, async (req, res) => {++ const a = await ownFeed(req, res); if (!a) return;++ await feeds.remove(a.feed);++ res.redirect(303, '/feeds');++ });++}++++module.exports = { mountFeedPages, embedHtml, claudeCodeSnippet };@@@ -97,7 +97,8 @@ function contentSecurityPolicy(nonce) {* prefs (the person's chat preferences: timestamps, compact, font_scale, show_badges), counts (unread* rooms/messages for the navigation), script / callScript / ringScript (public/web/chat.js, call.js, ring.js: a call* coming in, on every page of a signed-in person while calls are on), app (Vibe's full-window app, server/web/vibe-pages.js:-- * vibe.css + vibe-md.js + vibe.js, and no footer), appScript (the app's own script instead of vibe.js: inbox.js)++ * vibe.css + vibe-md.js + vibe.js, and no footer), appScript (the app's own script instead of vibe.js: inbox.js), feedView++ * (a feed's card on the page: feed-view.css + feed-view.js, server/feeds/)*/function renderPage(o) {const { config } = o;@@@ -129,6 +130,7 @@ ${INSTALL_LINKS}<link rel="stylesheet" href="${ovServe.url('items.css')}">${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` : ''}${o.app ? `<link rel="stylesheet" href="${asset('vibe.css')}">` : ''}++${o.feedView ? `<link rel="stylesheet" href="${asset('feed-view.css')}">\n<script src="${asset('feed-view.js')}" defer></script>` : ''}<script src="${ovServe.url('theme-loader.js')}" defer></script><script src="${ovServe.url('navbar.js')}" defer></script><script src="${ovServe.url('footer.js')}" defer></script>@@@ -619,7 +619,8 @@ ${mine.length ? `<h2>Your rooms</h2><ul class="oc-convs">${mine.map(roomCard).jo<ul class="oc-goto"><li><a href="/">Global chat</a>: everyone on OpenVibe, readable without an account</li><li><a href="/servers">Servers</a>: channels, voice, roles and invite links, Discord-style</li><li><a href="/messages">Messages</a>: direct messages with anyone on OpenVibe</li></ul></div>`}--${create}`,++${create}++<p class="oc-muted">Building something? A <a href="/feeds">feed</a> shows your coding session live in a room, on your Live channel or on your site.</p>`,});});@@@ -687,9 +688,11 @@ ${people}const isCall = room.kind === 'call';const callReady = isCall && !!require('../config').calls.enabled;const ais = aiMembers.enabled() ? (await aiMembers.list(room)).filter((a) => Number(a.enabled)) : [];++ // A feed the owner shows here (server/feeds/): its card above the messages.++ const feed = await require('../feeds/feeds').forRoom(room.id);const aiHere = ais.length ? `<p class="oc-ai-here">${ais.map((a) => `<span class="oc-badge oc-ai">AI</span> <strong>${esc(a.name)}</strong> <span class="oc-muted oc-ai-model">(${esc(aiMembers.modelOf(a))})</span>`).join(' · ')} <span class="oc-muted">· write ${ais.map((a) => `<code>@${esc(a.handle)}</code>`).join(' or ')} to ask${ais.length > 1 ? ' one of them' : ''} something</span></p>` : '';await page(req, res, 200, {-- actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,++ actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady, feedView: !!feed,description: room.topic || `${room.name}, a ${isCall ? 'call' : room.kind === 'system' ? 'announcements' : 'chat'} room on OpenVibe.`,page: { view: 'room', room: room.slug, kind: room.kind, pics: pictures.enabled() ? pictures.publicUrl() : null, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate, post: can.post, role: can.role,...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },@@@ -698,6 +701,7 @@ ${people}<p class="oc-muted">${room.topic ? `${esc(room.topic)} · ` : ''}${(await rooms.publicRoom(room)).members} members${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${yourRole}${manage} <span class="oc-live" id="oc-live" hidden>● live</span></p>${aiHere}${error}${isCall ? callPanel(room, can, me) : ''}++${feed ? `<div class="fv oc-room-fv" data-feed="${esc(feed.id)}"></div>` : ''}<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => roomMessage(m, can, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${room.kind === 'system' ? 'No announcements yet.' : 'No messages yet. Say hello.'}</li>`}</ol>${composer}${tools}`,});@@@ -891,6 +895,7 @@ ${attachList}`,// ── Servers (server/rooms/servers.js): text and voice channels, roles, invites ──require('./vibe-pages').mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit, notice });require('./inbox-pages').mountInboxPages(router, { page, viewerOf, needUser, sameSite, formLimit, notice, config });++ require('./feed-pages').mountFeedPages(router, { page, needUser, sameSite, formLimit, notice, config });require('./server-pages').mountServerPages(router, { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, pictureComposer, config });router.get('/updates', async (req, res) => await page(req, res, 200, {nnew file mode 100644@@@ -0,0 +1,181 @@++'use strict';++/**++ * Feeds (server/feeds/, docs/platform.md step 2): a person makes a feed and gets its key once; a publisher posts events++ * with the key (one, a batch, Live's envelopes, Claude Code's hook JSON as is); secrets and home folders are wiped on the++ * way in; viewers get what the feed's settings let them see, after its delay, over HTTP and the socket; a feed on a Live++ * channel slot reaches that slot's live chat as Live's vibe-coding frame; rooms, the embed and the pages show it.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++let feeds;++++const t = suite('feeds');++const SITE = 'https://openvibe.chat';++let h, ann, bob, feed, key, streamId;++const api = (method, path, u, body, headers) => h.http(method, `/api/chat/feeds${path}`, { token: u && u.token, body, headers });++const pub = (path, body, k = key) => fetch(`${h.base}/api/chat/feeds/${feed.id}${path}`, {++ method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${k}` }, body: JSON.stringify(body),++}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));++const view = async (q = '') => (await fetch(`${h.base}/api/chat/feeds/${feed.id}${q}`)).json();++const patch = (settings, extra = {}) => api('PATCH', `/${feed.id}`, ann, { settings, ...extra });++++t('boot', async () => {++ h = await boot({ env: { CHAT_WEB_URL: SITE } });++ feeds = require('../server/feeds/feeds');++ ann = h.addUser('ann', { subject: ids.newId('user') });++ bob = h.addUser('bob', { subject: ids.newId('user') });++ for (const u of [ann, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));++ const channel = h.addChannel(ann.id);++ streamId = h.addStream(ann.id, channel, { managed: { id: 77, title: 'Camp code', slug: 'camp-code' } });++ await h.ctx.ensureStream(streamId);++});++++t('a person makes a feed and gets its key once; others cannot touch it', async () => {++ const r = await api('POST', '', ann, { title: 'Building Pixel Lamp' });++ assert.strictEqual(r.status, 201, r.text);++ feed = r.body.feed; key = r.body.key;++ assert.ok(/^fed_[0-9A-HJKMNP-TV-Z]{26}$/.test(feed.id) && /^ovf_[A-Za-z0-9_-]{32}$/.test(key));++ assert.strictEqual(feed.key_hint, key.slice(-4));++ assert.ok(!('key_hash' in feed), 'the hash is never shown');++ assert.deepStrictEqual((await api('GET', '/mine', ann)).body.feeds.map((f) => f.id), [feed.id]);++ assert.strictEqual((await api('PATCH', `/${feed.id}`, bob, { title: 'Mine' })).status, 404);++ assert.strictEqual((await api('PATCH', `/${feed.id}`, ann, { slot: 77 })).status, 200, 'her own slot');++ assert.strictEqual((await api('POST', '', bob, { title: 'Sneaky', slot: 77 })).status, 403, 'not his slot');++});++++t('publishing needs the key; secrets and home folders are wiped on the way in', async () => {++ assert.strictEqual((await pub('/events', { eventType: 'prompt', text: 'hi' }, `ovf_${'x'.repeat(32)}`)).status, 401);++ const r = await pub('/events', { eventType: 'prompt', text: 'Use sk-ant-api03-abcdefghijklmnopqrstuvwx and PASSWORD=hunter22 in /home/ann/pixel-lamp/.env' });++ assert.strictEqual(r.status, 202, JSON.stringify(r.body));++ assert.strictEqual(r.body.events[0].delivered, true);++ const stored = await h.db.get('SELECT payload FROM feed_events WHERE feed_id = ?', [feed.id]);++ const text = JSON.stringify(stored.payload);++ assert.ok(!text.includes('sk-ant') && !text.includes('hunter22') && !text.includes('/home/ann'), text);++ assert.ok(text.includes('[hidden]') && text.includes('~/pixel-lamp'));++});++++t('a batch, Live\'s envelopes, and a repeated event delivered once; viewers see what the settings allow', async () => {++ const r = await pub('/events', { events: [++ { type: 'vibe-coding.event', event: { eventId: 'e-think', eventType: 'thinking', thinking: { text: 'Maybe the lamp needs a dimmer' } } },++ { eventId: 'e-tool', eventType: 'tool.call', summary: 'Ran npm test in server/lamp', tool: { name: 'Bash', argumentsPreview: 'npm test' } },++ { eventId: 'e-file', eventType: 'file.change', file: { relativePath: 'server/lamp/dimmer.js' } },++ { eventId: 'e-file', eventType: 'file.change', file: { relativePath: 'server/lamp/dimmer.js' } },++ { eventId: 'e-done', eventType: 'response', text: 'Added a dimmer.' },++ ] });++ assert.deepStrictEqual(r.body.events.map((e) => e.delivered), [false, true, true, false, true], 'thinking is off by default; the repeat is not delivered again');++ const v = await view();++ const byId = Object.fromEntries(v.events.map((e) => [e.eventId, e]));++ assert.ok(!byId['e-think'], 'thinking hidden');++ assert.strictEqual(byId['e-tool'].tool.argumentsPreview, null, 'what tools were given is off by default');++ assert.strictEqual(byId['e-file'].file.relativePath, 'dimmer.js', 'file names only');++ assert.strictEqual(byId['e-done'].response.text, 'Added a dimmer.');++ assert.strictEqual(v.feed.enabled, true);++ await patch({ show_prompts: 0, redact_file_paths: 0 });++ const v2 = await view();++ assert.ok(!v2.events.some((e) => e.eventType === 'prompt'), 'turning prompts off hides them in the history too');++ assert.strictEqual(v2.events.find((e) => e.eventId === 'e-file').file.relativePath, 'server/lamp/dimmer.js');++ await patch({ show_prompts: 1, redact_file_paths: 1 });++});++++t('Claude Code\'s hooks, as they are: prompt, tools, files, the answer; never blocks it', async () => {++ const base = { session_id: 'abc-123', cwd: '/home/ann/pixel-lamp', transcript_path: '/home/ann/.claude/x.jsonl' };++ assert.deepStrictEqual((await pub('/hooks/claude-code', { ...base, hook_event_name: 'UserPromptSubmit', prompt: 'Make the lamp glow purple', prompt_id: 'p1' })).body, {});++ await pub('/hooks/claude-code', { ...base, hook_event_name: 'PreToolUse', tool_name: 'Edit', tool_input: { file_path: '/home/ann/pixel-lamp/web/lamp.css' }, tool_use_id: 'tu0' });++ await pub('/hooks/claude-code', { ...base, hook_event_name: 'PostToolUse', tool_name: 'Edit', tool_input: { file_path: '/home/ann/pixel-lamp/web/lamp.css', old_string: 'a', new_string: 'b' }, tool_response: { ok: true }, tool_use_id: 'tu1' });++ await pub('/hooks/claude-code', { ...base, hook_event_name: 'Stop', last_assistant_message: 'The lamp glows purple now.' });++ const mine = (await view('?limit=50')).events.filter((e) => e.publisher && e.publisher.integrationId === 'claude-code');++ assert.deepStrictEqual(mine.map((e) => e.eventType), ['prompt', 'file.change', 'response'], 'PreToolUse is not shown; an edit is a file event');++ assert.strictEqual(mine[0].prompt.text, 'Make the lamp glow purple');++ assert.deepStrictEqual([mine[1].summary, mine[1].file.relativePath], ['Editing lamp.css', 'lamp.css'], 'the folder is not shown');++ assert.strictEqual(mine[2].response.text, 'The lamp glows purple now.');++ await pub('/hooks/claude-code', { ...base, hook_event_name: 'PostToolUse', tool_name: 'Bash', tool_input: { command: 'cat /home/ann/pixel-lamp/.env' }, tool_use_id: 'tu2' });++ const ran = (await view('?limit=50')).events.pop();++ assert.deepStrictEqual([ran.summary, ran.tool.argumentsPreview], ['Ran a command', null], 'what a command was is off by default');++ assert.strictEqual((await pub('/hooks/claude-code', { hook_event_name: 'UserPromptSubmit', prompt: 'x' }, `ovf_${'y'.repeat(32)}`)).status, 401);++});++++t('the delay is the server\'s: the history waits for it too', async () => {++ await patch({ delay_ms: 60000 });++ await pub('/events', { eventId: 'e-late', eventType: 'response', text: 'Not yet' });++ assert.ok(!(await view()).events.some((e) => e.eventId === 'e-late'));++ await patch({ delay_ms: 0 });++ assert.ok((await view()).events.some((e) => e.eventId === 'e-late'));++});++++t('the socket: watchers and the Live slot\'s chat hear new steps', async () => {++ const watcher = await h.ws({});++ watcher.sendJson({ type: 'feed_watch', feed: feed.id });++ await watcher.next((m) => m.type === 'feed_watching');++ const chat = await h.ws({ token: bob.token, stream: streamId });++ await chat.next((m) => m.type === 'users-list');++ await pub('/events', { eventId: 'e-live', eventType: 'tool.call', summary: 'Ran the lamp tests', tool: { name: 'Bash' } });++ const got = await watcher.next((m) => m.type === 'feed_event' && m.event.eventId === 'e-live');++ assert.strictEqual(got.feed, feed.id);++ const frame = await chat.next((m) => m.type === 'vibe-coding' && m.event.eventId === 'e-live');++ assert.deepStrictEqual([frame.managed_stream_id, frame.slot_slug], [77, 'camp-code']);++ watcher.close(); chat.close();++});++++t('Live\'s chat finds a channel\'s feeds; any site may read a feed (no cookies)', async () => {++ const r = await fetch(`${h.base}/api/chat/feeds?channel=ann`, { headers: { origin: 'https://openvibe.live' } });++ const list = (await r.json()).feeds;++ assert.deepStrictEqual(list.map((f) => [f.id, f.managed_stream_id]), [[feed.id, 77]]);++ const other = await fetch(`${h.base}/api/chat/feeds/${feed.id}`, { headers: { origin: 'https://elsewhere.example' } });++ assert.strictEqual(other.status, 200);++ assert.strictEqual(other.headers.get('access-control-allow-origin'), 'https://elsewhere.example');++ assert.strictEqual(other.headers.get('access-control-allow-credentials'), null);++ await patch({ paused: 1 });++ assert.deepStrictEqual((await (await fetch(`${h.base}/api/chat/feeds?slot=77`)).json()).feeds, [], 'a paused feed is not listed');++ assert.deepStrictEqual((await view()).events, [], 'nor shown');++ await patch({ paused: 0 });++});++++t('the pages: making a feed shows its key once, the feed page has the hooks, the embed, rooms show the card', async () => {++ const page = async (path, u, opts = {}) => { const r = await fetch(`${h.base}${path}`, { redirect: 'manual', ...opts, headers: { cookie: `ov_token=${u.token}`, ...(opts.headers || {}) } }); return { status: r.status, text: await r.text(), headers: r.headers }; };++ assert.ok((await page('/feeds', ann)).text.includes(`href="/feeds/${feed.id}"`));++ const made = await page('/feeds', ann, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', origin: SITE }, body: 'title=Second+feed' });++ assert.strictEqual(made.status, 201, made.text.slice(0, 300));++ assert.ok(/ovf_[A-Za-z0-9_-]{32}/.test(made.text), 'the key, once');++ const settings = await page(`/feeds/${feed.id}`, ann);++ assert.ok(settings.text.includes('/hooks/claude-code') && settings.text.includes('OPENVIBE_FEED_KEY') && !/ovf_[A-Za-z0-9_-]{32}/.test(settings.text), 'the hooks, not the key');++ assert.strictEqual((await page(`/feeds/${feed.id}`, bob)).status, 404);++ const embed = await fetch(`${h.base}/embed/feed/${feed.id}`);++ assert.ok(/frame-ancestors \*/.test(embed.headers.get('content-security-policy')) && (await embed.text()).includes(`data-feed="${feed.id}"`));++ const room = (await h.http('POST', '/api/chat/rooms', { token: ann.token, body: { name: 'Pixel Lamp dev' } })).body.room;++ await patch({}, { room: room.slug });++ assert.ok((await page(`/r/${room.slug}`, bob)).text.includes(`class="fv oc-room-fv" data-feed="${feed.id}"`));++ assert.strictEqual((await api('PATCH', `/${feed.id}`, bob, { room: room.slug })).status, 404);++});++++t('Live\'s feed history comes over once: a feed per slot, its newest events, scrubbed, verified', async () => {++ const fs = require('fs'); const os = require('os'); const path = require('path');++ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'feeds-')), 'live.jsonl');++ const rows = [++ { kind: 'slot', managed_stream_id: 77, user_id: ann.id, title: 'Camp code', settings: { enabled: 0, widget_title: 'Night Bus Refactor', show_thinking: 1 } },++ { kind: 'session', managed_stream_id: 77, session_key: 'copilot-1', publisher: { integrationId: 'github-copilot', integrationLabel: 'GitHub Copilot' }, status: 'ended', created_at: '2026-04-21 23:10:00' },++ { kind: 'event', managed_stream_id: 77, session_key: 'copilot-1', event_id: 'old-1', seq: 1, type: 'prompt', summary: 'Refactor the bus', payload: JSON.stringify({ eventId: 'old-1', eventType: 'prompt', prompt: { text: 'Refactor with ghp_abcdefghijklmnopqrstuvwxyz0123' } }), created_at: '2026-04-21 23:10:01' },++ { kind: 'event', managed_stream_id: 77, session_key: 'copilot-1', event_id: 'old-2', seq: 2, type: 'response', summary: 'Done', payload: JSON.stringify({ eventId: 'old-2', eventType: 'response', response: { text: 'Done' } }), created_at: '2026-04-21 23:11:00' },++ ];++ fs.writeFileSync(file, rows.map((r) => JSON.stringify(r)).join('\n'));++ const { main } = require('../scripts/import-live-feeds');++ const said = [];++ assert.strictEqual(await main(['--file', file], (m) => said.push(m)), 0);++ assert.ok(said.some((m) => m.includes('dry run')) && !(await h.db.get("SELECT 1 FROM feeds WHERE legacy_source = 'live:managed_stream:77'")));++ assert.strictEqual(await main(['--file', file, '--apply'], (m) => said.push(m)), 0, said.join('\n'));++ const copied = await h.db.get("SELECT * FROM feeds WHERE legacy_source = 'live:managed_stream:77'");++ assert.deepStrictEqual([copied.title, Number(copied.owner_id), Number(copied.managed_stream_id), copied.key_hash], ['Night Bus Refactor', ann.id, 77, null]);++ const events = await h.db.all('SELECT event_id, payload FROM feed_events WHERE feed_id = ? ORDER BY id', [copied.id]);++ assert.deepStrictEqual(events.map((e) => e.event_id), ['old-1', 'old-2']);++ assert.ok(!JSON.stringify(events[0].payload).includes('ghp_'), 'old events are scrubbed too');++ assert.strictEqual(await main(['--file', file, '--apply'], () => {}), 0, 'a second run adds nothing');++ assert.strictEqual((await h.db.get("SELECT count(*)::int AS n FROM feeds WHERE legacy_source = 'live:managed_stream:77'")).n, 1);++});++++t('deleting a feed deletes what was published to it', async () => {++ assert.strictEqual((await api('DELETE', `/${feed.id}`, ann)).status, 204);++ assert.strictEqual((await h.db.get('SELECT count(*)::int AS n FROM feed_events WHERE feed_id = ?', [feed.id])).n, 0);++ assert.strictEqual((await fetch(`${h.base}/api/chat/feeds/${feed.id}`)).status, 404);++});++++t.run(async () => { if (h && h.close) await h.close(); });@@@ -27,7 +27,7 @@ const TABLES = ['channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events','ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'service_outbox','chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',-- 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'vibe_usage', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',++ 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'vibe_usage', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log','network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',];const INDEXES = [
@@ -462,6 +462,43 @@ for people and for companies, with "direct live support with an agent", "fully m
@@ -462,6 +462,43 @@ for people and for companies, with "direct live support with an agent", "fully m
- Export and account deletion cover inboxes: the owner's inboxes go with their conversations; an agent's replies in
- Export and account deletion cover inboxes: the owner's inboxes go with their conversations; an agent's replies in
other inboxes stay, signed "A former agent".
other inboxes stay, signed "A former agent".
+### Feeds: vibe coding, live (openvibe.chat/feeds)
+
+The owner, 2026-10-24: "improve the vibe coding feed feature on live and migrate that whole entire feature so it's built
+into the openvibe.chat system". A **feed** (`server/feeds/`, `migrations/0017_feeds.sql`) is live activity a person
+publishes into Chat; vibe coding, moved from OpenVibe.Live (its `server/vibe-coding/`, same canonical events), is the
+first kind. Viewers see a card (`public/web/feed-view.js`): turns of a prompt, the steps taken (tools used, files
+changed) and the answer, the newest open, a live dot while steps arrive.
+
+- **Where it shows:** in a room its owner owns (above the messages), on the owner's Live channel slot while it is live
+ (as Live's `vibe-coding` frame on the stream's chat, which Live's channel widget reads), and on any site:
+ `<iframe src="https://openvibe.chat/embed/feed/fed_…">` (`?theme=light|dark`; it tells the parent its height,
+ `{ ov: 'chat', type: 'feed-height' }`).
+- **Publishing** with the feed's key (`ovf_…`, shown once, stored as its SHA-256; a new one on request):
+ - **Claude Code**: the feed page gives a `.claude/settings.json` block of `http` hooks (SessionStart,
+ UserPromptSubmit, PostToolUse, Stop, SessionEnd) that post each hook's JSON as it is to
+ `POST /api/chat/feeds/:id/hooks/claude-code` with `Authorization: Bearer $OPENVIBE_FEED_KEY`. A prompt, a tool
+ used (an edit is a file event), the turn's answer (`last_assistant_message`); paths become relative to the
+ session's folder; nothing is read from the transcript; the answer is always `{}`, so it never blocks Claude Code.
+ - **Anything else**: `POST /api/chat/feeds/:id/events` with one event, `{ events: [...] }` (at most 50) or Live's
+ `vibe-coding.event` envelopes; `POST /:id/hello { sessionKey, publisher }`. `eventType` is `session.status`,
+ `prompt`, `response`, `thinking`, `tool.call`, `file.change` or `file.save`; a short `{ eventType, text }` works.
+ 600 events a minute a feed.
+- **Safety:** every event is scrubbed on the way in: API keys and tokens (OpenAI/Anthropic, GitHub, Slack, AWS,
+ Google, JWTs, OpenVibe's own), `Bearer …`, `PASSWORD=`-style values, credentials in URLs, private keys, and home
+ folders (`/home/<name>` → `~`). What a viewer sees is decided when they see it by the feed's settings (Live's
+ projection): prompts, answers, tools, what the tools were given (off by default), files (names only by default),
+ thinking (off by default), detail, paused. The **delay** is the server's: neither the socket nor the history shows
+ a step before it (Live sent the delay to the browser).
+- **API:** anyone: `GET /api/chat/feeds/:id` (what a viewer sees now), `GET /api/chat/feeds?channel=<username>` or
+ `?slot=<id,…>` (the feeds on Live channel slots, what Live's chat asks); the owner: `GET /api/chat/feeds/mine`,
+ `POST /api/chat/feeds { title, room?, slot?, settings? }` → `{ feed, key }`, `PATCH /:id`, `POST /:id/key`,
+ `DELETE /:id`. The public and publishing routes answer any site without cookies. Socket: `{ type: 'feed_watch',
+ feed }`, then `feed_event { feed, event }`. A feed keeps its newest 5,000 events; at most 10 feeds a person.
+- **From Live:** `scripts/import-live-feeds.js --file <export.jsonl> [--apply]` copies each slot's feed (settings,
+ sessions, its newest 5,000 events, scrubbed) and verifies the copy; Live then drops `vibe_coding_*`.
+- Export and account deletion cover feeds (never the key's hash).
+
### VIP member badges
### VIP member badges
A member's messages in a creator's room (stream or offline channel chat) carry that creator's VIP
A member's messages in a creator's room (stream or offline channel chat) carry that creator's VIP
@@ -49,10 +49,12 @@ center (MagicBrowse privacy), getstream.io (Stream vs Sendbird), rumbletalk.com,
@@ -49,10 +49,12 @@ center (MagicBrowse privacy), getstream.io (Stream vs Sendbird), rumbletalk.com,
## Order of work
## Order of work
-1. **Support inbox + widget (first)**: inboxes, agents, visitor conversations, Vibe as first responder with handoff,
+1. **Support inbox + widget (first; Chat#143)**: inboxes, agents, visitor conversations, Vibe as first responder with handoff,
the agent inbox page, `widget.js` + `/embed/inbox/:id`, allowed sites (frame-ancestors), the API.
the agent inbox page, `widget.js` + `/embed/inbox/:id`, allowed sites (frame-ancestors), the API.
-2. **Feeds**: Live's vibe coding feed moves into Chat as the first feed type (publisher socket, settings, viewer cards in
- any room and in Live's channel chat); Live's tables are copied, verified and dropped (convert-verify-delete).
+2. **Feeds** (built): Live's vibe coding feed moves into Chat as the first feed type, and gets better on the way: any
+ room, any site (an iframe card), Claude Code's own `http` hooks with no extension to install, publishing over plain
+ HTTP, secrets and home folders scrubbed on the way in, the delay kept on the server. Live's channel chat shows
+ Chat's card; Live's tables are copied (`scripts/import-live-feeds.js`), verified and dropped (convert-verify-delete).
3. **Community room and site assistant modes** of the widget; pictures, video and files in the embed (signed-in
3. **Community room and site assistant modes** of the widget; pictures, video and files in the embed (signed-in
visitors first, then anonymous once Pics/Video take anonymous uploads); call escalation from a support conversation.
visitors first, then anonymous once Pics/Video take anonymous uploads); call escalation from a support conversation.
4. **Developer platform**: capabilities in OpenVibe.Contracts (`chat.inbox.*`, `chat.conversation.*`, `chat.feed.*`),
4. **Developer platform**: capabilities in OpenVibe.Contracts (`chat.inbox.*`, `chat.conversation.*`, `chat.feed.*`),
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+-- phase: expand
+-- Feeds (docs/platform.md step 2, the owner 2026-10-24: "migrate that whole entire feature so it's built into the
+-- openvibe.chat system"): live activity a person publishes into Chat with a feed's key; vibe coding, moved from
+-- OpenVibe.Live, is the first kind. Viewers see a card in the feed's room, on its Live channel slot while live, or on any
+-- site (openvibe.chat/embed/feed/:id). What they see is projected from the stored events by the feed's settings when
+-- they see it (server/feeds/feeds.js).
+CREATE TABLE IF NOT EXISTS feeds (
+ id text COLLATE "C" PRIMARY KEY, -- fed_…
+ owner_id bigint NOT NULL, -- Live user id, like rooms.owner_id
+ owner_subject text COLLATE "C",
+ kind text COLLATE "C" NOT NULL DEFAULT 'coding' CHECK (kind IN ('coding')),
+ title text NOT NULL,
+ room_id bigint, -- a Chat room it shows in (rooms.id)
+ managed_stream_id bigint, -- a Live channel slot it shows on while live
+ settings jsonb NOT NULL DEFAULT '{}',
+ key_hash text COLLATE "C", -- sha256 of the publish key (ovf_…), shown once
+ key_hint text COLLATE "C", -- its last four characters
+ legacy_source text COLLATE "C", -- 'live:managed_stream:<id>' when copied from Live
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ last_event_at text COLLATE "C",
+ archived_at text COLLATE "C"
+);
+CREATE INDEX IF NOT EXISTS feeds_owner ON feeds (owner_id) WHERE archived_at IS NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS feeds_key ON feeds (key_hash) WHERE key_hash IS NOT NULL;
+CREATE INDEX IF NOT EXISTS feeds_room ON feeds (room_id) WHERE room_id IS NOT NULL AND archived_at IS NULL;
+CREATE INDEX IF NOT EXISTS feeds_slot ON feeds (managed_stream_id) WHERE managed_stream_id IS NOT NULL AND archived_at IS NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS feeds_legacy ON feeds (legacy_source) WHERE legacy_source IS NOT NULL;
+
+-- One publisher's run (an editor window, an agent session): who published (integration, client, workspace) and when.
+CREATE TABLE IF NOT EXISTS feed_sessions (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ feed_id text COLLATE "C" NOT NULL,
+ session_key text COLLATE "C" NOT NULL,
+ publisher jsonb NOT NULL DEFAULT '{}',
+ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'ended')),
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ last_event_at text COLLATE "C",
+ ended_at text COLLATE "C",
+ UNIQUE (feed_id, session_key)
+);
+
+-- The events as published (scrubbed of secrets and home folders on the way in). received_ms orders the feed's delay.
+CREATE TABLE IF NOT EXISTS feed_events (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ feed_id text COLLATE "C" NOT NULL,
+ session_key text COLLATE "C",
+ event_id text COLLATE "C" NOT NULL,
+ seq bigint NOT NULL DEFAULT 0,
+ type text COLLATE "C" NOT NULL,
+ summary text NOT NULL DEFAULT '',
+ payload jsonb NOT NULL,
+ received_ms bigint NOT NULL,
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ UNIQUE (feed_id, event_id)
+);
+CREATE INDEX IF NOT EXISTS feed_events_feed ON feed_events (feed_id, id DESC);
@@ -302,3 +302,10 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
@@ -302,3 +302,10 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.ib-dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; flex: none; }
.ib-dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; flex: none; }
.ib-snippet { padding: 12px 14px; border-radius: 12px; background: var(--oc-panel); border: 1px solid var(--oc-line); overflow-x: auto; }
.ib-snippet { padding: 12px 14px; border-radius: 12px; background: var(--oc-panel); border: 1px solid var(--oc-line); overflow-x: auto; }
.ib-snippet code { font: .85rem/1.5 ui-monospace, Menlo, Consolas, monospace; white-space: pre; }
.ib-snippet code { font: .85rem/1.5 ui-monospace, Menlo, Consolas, monospace; white-space: pre; }
+
+/* Feeds (server/web/feed-pages.js; the card itself is public/web/feed-view.css) */
+.oc-room-fv { margin: 0 0 12px; }
+.fd-preview { margin: 12px 0 18px; }
+.fd-dot { background: var(--oc-muted); }
+.fd-dot.fd-on { background: var(--oc-ok); }
+.fd-key code { font-size: 1rem; user-select: all; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+/* A feed's card (public/web/feed-view.js): in a room on openvibe.chat (Chat's --oc-* colours) and in the embed
+ /embed/feed/:id, which sets the same names for light and dark (server/web/feed-pages.js). */
+.fv { --fv-line: var(--oc-line, #223049); --fv-panel: var(--oc-panel, #121a2c); --fv-text: var(--oc-text, #e8edf6); --fv-muted: var(--oc-muted, #98a6bd); --fv-accent: var(--oc-accent, #3b82f6); --fv-ok: var(--oc-ok, #22c55e); }
+.fv.fv-on { border: 1px solid var(--fv-line); border-radius: 14px; background: var(--fv-panel); color: var(--fv-text); overflow: hidden; font-size: .9rem; }
+.fv.fv-gone { display: none; }
+.fv-head { all: unset; box-sizing: border-box; cursor: pointer; display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 12px; }
+.fv-head:focus-visible { outline: 2px solid var(--fv-accent); outline-offset: -2px; }
+.fv-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--fv-muted); flex: none; }
+.fv-live .fv-dot { background: var(--fv-ok); box-shadow: 0 0 0 0 color-mix(in srgb, var(--fv-ok) 60%, transparent); animation: fvPulse 2s infinite; }
+@keyframes fvPulse { 70% { box-shadow: 0 0 0 7px transparent; } }
+.fv-title { font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
+.fv-who { color: var(--fv-muted); font-size: .8rem; white-space: nowrap; }
+.fv-who:not(:empty)::before { content: '· '; }
+.fv-when { margin-left: auto; color: var(--fv-muted); font-size: .78rem; }
+.fv-chev { color: var(--fv-muted); transition: transform .15s; }
+.fv-folded .fv-chev { transform: rotate(-90deg); }
+.fv-folded .fv-body { display: none; }
+.fv-body { border-top: 1px solid var(--fv-line); max-height: 260px; overflow-y: auto; padding: 8px 12px 10px; overscroll-behavior: contain; }
+.fv-turns, .fv-steps { list-style: none; margin: 0; padding: 0; }
+.fv-turn { padding: 4px 0; }
+.fv-turn + .fv-turn { border-top: 1px dashed color-mix(in srgb, var(--fv-line) 70%, transparent); }
+.fv-turn:not(.fv-cur) { opacity: .72; }
+.fv-prompt, .fv-answer, .fv-step, .fv-count, .fv-more, .fv-working { display: flex; gap: 7px; align-items: baseline; margin: 3px 0; min-width: 0; }
+.fv-prompt { font-weight: 600; }
+.fv-ic { flex: none; width: 1.1em; text-align: center; color: var(--fv-muted); }
+.fv-prompt .fv-ic { color: var(--fv-accent); }
+.fv-answer .fv-ic { color: var(--fv-ok); }
+.fv-txt { overflow-wrap: anywhere; }
+.fv-step { color: var(--fv-muted); font-size: .84rem; flex-wrap: wrap; }
+.fv-arg { font: .78rem ui-monospace, Menlo, Consolas, monospace; background: color-mix(in srgb, var(--fv-muted) 14%, transparent); padding: 1px 6px; border-radius: 6px; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-left: 1.8em; flex-basis: 100%; }
+.fv-count, .fv-more { color: var(--fv-muted); font-size: .78rem; padding-left: 1.8em; }
+.fv-working { color: var(--fv-muted); font-size: .82rem; padding-left: 1.8em; }
+.fv-empty { margin: 4px 0; color: var(--fv-muted); font-size: .85rem; }
+@media (prefers-reduced-motion: reduce) { .fv-live .fv-dot { animation: none; } .fv-chev { transition: none; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,124 @@
@@ -0,0 +1,124 @@
+/* A feed's card (server/feeds/feeds.js): what someone is building, live. Mounted on every <div class="fv"
+ data-feed="fed_…"> of the page: a room on openvibe.chat, the embed /embed/feed/:id that any site (OpenVibe.Live's
+ channel chat among them) frames. It reads /api/chat/feeds/:id and follows the chat socket (feed_watch). Events are
+ grouped into turns: a prompt, the steps taken (tools used, files changed), the answer; the newest turn is open, older
+ ones fold to their prompt. Everything is set with textContent. In a frame it tells the parent its height. */
+(function () {
+ 'use strict';
+ var ICON = { prompt: '›', response: '✓', thinking: '…', 'tool.call': '⚙', 'file.change': '✎', 'file.save': '✎', 'session.status': '•' };
+ var LIVE_MS = 3 * 60 * 1000;
+ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
+ function store(fn) { try { return fn(window.localStorage); } catch (e) { return null; } }
+ function ago(iso) {
+ var t = Date.parse(iso || ''); if (!t) return '';
+ var s = Math.max(0, Math.round((Date.now() - t) / 1000));
+ return s < 60 ? 'now' : s < 3600 ? Math.round(s / 60) + 'm' : s < 86400 ? Math.round(s / 3600) + 'h' : Math.round(s / 86400) + 'd';
+ }
+
+ function turnsOf(events) {
+ var turns = [], cur = null;
+ events.forEach(function (e) {
+ if (e.eventType === 'prompt' || !cur) { cur = { prompt: e.eventType === 'prompt' ? e : null, steps: [], answer: null, at: e.occurredAt }; turns.push(cur); if (e.eventType === 'prompt') return; }
+ if (e.eventType === 'response') { cur.answer = e; cur.at = e.occurredAt; return; }
+ cur.steps.push(e); cur.at = e.occurredAt;
+ });
+ return turns;
+ }
+
+ function mount(box) {
+ var id = box.getAttribute('data-feed');
+ if (!/^fed_[0-9A-HJKMNP-TV-Z]{26}$/.test(id || '') || box.dataset.mounted) return;
+ box.dataset.mounted = '1';
+ var events = [], seen = {}, feed = null, publisher = null, ws = null, delay = 1000;
+ var KEY = 'ov-feed-folded-' + id;
+ var folded = store(function (s) { return s.getItem(KEY); }) === '1';
+
+ var head = el('button', 'fv-head'); head.type = 'button';
+ var dot = el('span', 'fv-dot'); var title = el('b', 'fv-title', 'Vibe coding'); var who = el('span', 'fv-who'); var when = el('time', 'fv-when');
+ var chev = el('span', 'fv-chev', '▾'); chev.setAttribute('aria-hidden', 'true');
+ head.appendChild(dot); head.appendChild(title); head.appendChild(who); head.appendChild(when); head.appendChild(chev);
+ var body = el('div', 'fv-body'); var list = el('ol', 'fv-turns'); body.appendChild(list);
+ var empty = el('p', 'fv-empty', 'Nothing yet. Steps show here as they happen.');
+ body.appendChild(empty);
+ box.textContent = ''; box.appendChild(head); box.appendChild(body);
+ box.classList.add('fv-on');
+
+ function setFolded(v) {
+ folded = v; box.classList.toggle('fv-folded', v); head.setAttribute('aria-expanded', v ? 'false' : 'true');
+ store(function (s) { s.setItem(KEY, v ? '1' : '0'); }); tellHeight();
+ }
+ head.addEventListener('click', function () { setFolded(!folded); });
+
+ function tellHeight() {
+ if (window.parent === window) return;
+ try { window.parent.postMessage({ ov: 'chat', type: 'feed-height', feed: id, height: Math.ceil(box.getBoundingClientRect().height) }, '*'); } catch (e) { /* not framed */ }
+ }
+ function stepLine(e) {
+ var li = el('li', 'fv-step fv-' + e.eventType.replace('.', '-'));
+ li.appendChild(el('span', 'fv-ic', ICON[e.eventType] || '•'));
+ var t = el('span', 'fv-txt', e.summary || (e.tool && e.tool.name) || '');
+ li.appendChild(t);
+ var arg = e.tool && e.tool.argumentsPreview;
+ if (arg) li.appendChild(el('code', 'fv-arg', arg));
+ return li;
+ }
+ function render() {
+ var live = feed && feed.last_event_at && Date.now() - Date.parse(feed.last_event_at) < LIVE_MS;
+ box.classList.toggle('fv-live', !!live);
+ dot.title = live ? 'Live' : 'Quiet';
+ title.textContent = (feed && feed.title) || 'Vibe coding';
+ who.textContent = publisher && publisher.integrationLabel && publisher.integrationLabel !== 'Custom publisher' ? publisher.integrationLabel : '';
+ when.textContent = feed ? ago(feed.last_event_at) : '';
+ list.textContent = '';
+ var turns = turnsOf(events);
+ empty.hidden = turns.length > 0;
+ turns.forEach(function (turn, i) {
+ var last = i === turns.length - 1;
+ var li = el('li', 'fv-turn' + (last ? ' fv-cur' : ''));
+ if (turn.prompt) { var p = el('p', 'fv-prompt'); p.appendChild(el('span', 'fv-ic', ICON.prompt)); p.appendChild(el('span', 'fv-txt', turn.prompt.summary)); li.appendChild(p); }
+ if (last || !turn.prompt) {
+ var steps = el('ol', 'fv-steps');
+ var shown = turn.steps.slice(-6);
+ if (turn.steps.length > shown.length) steps.appendChild(el('li', 'fv-more', (turn.steps.length - shown.length) + ' earlier steps'));
+ shown.forEach(function (e) { steps.appendChild(stepLine(e)); });
+ if (shown.length) li.appendChild(steps);
+ } else if (turn.steps.length) {
+ li.appendChild(el('p', 'fv-count', turn.steps.length + (turn.steps.length === 1 ? ' step' : ' steps')));
+ }
+ if (turn.answer && (last || !turn.prompt)) { var a = el('p', 'fv-answer'); a.appendChild(el('span', 'fv-ic', ICON.response)); a.appendChild(el('span', 'fv-txt', turn.answer.summary)); li.appendChild(a); }
+ else if (last && !turn.answer && live && turn.prompt) li.appendChild(el('p', 'fv-working', 'Working…'));
+ list.appendChild(li);
+ });
+ body.scrollTop = body.scrollHeight;
+ tellHeight();
+ }
+ function add(e) {
+ if (!e || !e.eventId || seen[e.eventId]) return;
+ seen[e.eventId] = 1; events.push(e);
+ if (events.length > 120) { events.splice(0, events.length - 120); }
+ if (feed) feed.last_event_at = new Date().toISOString();
+ render();
+ }
+ function connect() {
+ if (!window.WebSocket) return;
+ ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
+ ws.onopen = function () { delay = 1000; ws.send(JSON.stringify({ type: 'feed_watch', feed: id })); };
+ ws.onmessage = function (ev) { var m; try { m = JSON.parse(ev.data); } catch (e) { return; } if (m.type === 'feed_event' && m.feed === id) { if (m.publisher) publisher = m.publisher; add(m.event); } };
+ ws.onclose = function () { setTimeout(function () { load(); connect(); }, delay); delay = Math.min(delay * 2, 30000); };
+ }
+ function load() {
+ return fetch('/api/chat/feeds/' + id + '?limit=40', { credentials: 'omit' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
+ if (!d) { box.classList.add('fv-gone'); return; }
+ feed = d.feed; publisher = d.publisher;
+ (d.events || []).forEach(function (e) { if (!seen[e.eventId]) { seen[e.eventId] = 1; events.push(e); } });
+ render();
+ }).catch(function () {});
+ }
+ setFolded(folded);
+ load().then(connect);
+ setInterval(function () { if (feed) { when.textContent = ago(feed.last_event_at); box.classList.toggle('fv-live', Date.now() - Date.parse(feed.last_event_at || 0) < LIVE_MS); } }, 30000);
+ }
+ function all() { Array.prototype.forEach.call(document.querySelectorAll('.fv[data-feed]'), mount); }
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', all); else all();
+ window.OpenVibeFeed = { mount: mount };
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -0,0 +1,121 @@
+#!/usr/bin/env node
+/**
+ * OpenVibe.Live's vibe coding feed → Chat's feeds (server/feeds/feeds.js, docs/platform.md step 2). One-off: copy,
+ * verify, then Live drops its tables (convert-verify-delete).
+ *
+ * node scripts/import-live-feeds.js --file <export.jsonl> # dry run: what would be copied
+ * node scripts/import-live-feeds.js --file <export.jsonl> --apply # copy, then verify
+ *
+ * The file is JSON lines exported from Live's database, one object per row, `kind` saying which:
+ * { kind: 'slot', managed_stream_id, user_id, title, settings } managed_streams.broadcast_settings.vibe_coding
+ * { kind: 'session', managed_stream_id, session_key, publisher, status, created_at, last_event_at, ended_at }
+ * { kind: 'event', managed_stream_id, session_key, event_id, seq, type, summary, payload, created_at }
+ *
+ * Each slot becomes one feed of its owner (legacy_source 'live:managed_stream:<id>', so a second run adds nothing),
+ * shown on that slot, with Live's settings, and no key: the owner makes one on the feed's page. A feed keeps its
+ * newest 5,000 events (feeds.KEEP_EVENTS), so only those are copied; every copied event is scrubbed as new ones are.
+ * Verified: each feed holds exactly the newest min(5,000, its events) event ids of the file.
+ *
+ * Exit codes: 0 done (or dry run), 2 refused (bad options or file), 1 failed or did not verify.
+ */
+'use strict';
+
+const fs = require('fs');
+
+const USAGE = 'usage: node scripts/import-live-feeds.js --file <export.jsonl> [--apply]';
+
+function parseArgs(argv) {
+ const out = { apply: false, file: null, help: false };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === '--apply') out.apply = true;
+ else if (a === '--dry-run') out.apply = false;
+ else if (a === '--file') { out.file = argv[++i]; if (!out.file || out.file.startsWith('--')) throw new Error('--file needs a value'); }
+ else if (a === '-h' || a === '--help') out.help = true;
+ else throw new Error(`unknown argument ${a}`);
+ }
+ if (!out.help && !out.file) throw new Error('--file is required');
+ return out;
+}
+
+const json = (v, d) => { if (v && typeof v === 'object') return v; try { return JSON.parse(v || 'null') || d; } catch { return d; } };
+const ms = (t) => { const n = Date.parse(String(t || '').includes('T') ? t : `${String(t || '').replace(' ', 'T')}Z`); return Number.isFinite(n) ? n : 0; };
+
+/** The file read into slots, each with its sessions and newest events (oldest first). */
+function readExport(text, keep) {
+ const slots = new Map();
+ const slot = (id) => { if (!slots.has(id)) slots.set(id, { id, user_id: null, title: null, settings: {}, sessions: [], events: [] }); return slots.get(id); };
+ let lines = 0;
+ for (const line of text.split('\n')) {
+ if (!line.trim()) continue;
+ lines++;
+ const r = JSON.parse(line);
+ const id = Number(r.managed_stream_id);
+ if (!Number.isSafeInteger(id) || id <= 0) throw new Error(`line ${lines}: no managed_stream_id`);
+ if (r.kind === 'slot') Object.assign(slot(id), { user_id: Number(r.user_id), title: r.title || null, settings: json(r.settings, {}) });
+ else if (r.kind === 'session') slot(id).sessions.push(r);
+ else if (r.kind === 'event') slot(id).events.push(r);
+ else throw new Error(`line ${lines}: unknown kind ${r.kind}`);
+ }
+ for (const s of slots.values()) {
+ s.total = s.events.length;
+ s.events = s.events.sort((a, b) => ms(a.created_at) - ms(b.created_at) || Number(a.seq) - Number(b.seq)).slice(-keep);
+ }
+ return { slots: [...slots.values()], lines };
+}
+
+async function main(argv, log = (m) => console.log(m)) {
+ let args;
+ try { args = parseArgs(argv); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }
+ if (args.help) { log(USAGE); return 0; }
+ const feeds = require('../server/feeds/feeds');
+ let data;
+ try { data = readExport(fs.readFileSync(args.file, 'utf8'), feeds.KEEP_EVENTS); } catch (e) { log(`cannot read ${args.file}: ${e.message}`); return 2; }
+ const orphans = data.slots.filter((s) => !s.user_id);
+ if (orphans.length) { log(`slots without a slot row (owner unknown): ${orphans.map((s) => s.id).join(', ')}`); return 2; }
+ for (const s of data.slots) log(`slot ${s.id} (owner ${s.user_id}): ${s.sessions.length} session(s), ${s.total} event(s), copying the newest ${s.events.length}`);
+ if (!args.apply) { log('dry run: nothing written (add --apply to copy)'); return 0; }
+
+ const db = require('../server/db/database');
+ await db.initDb();
+ let bad = 0;
+ for (const s of data.slots) {
+ const source = `live:managed_stream:${s.id}`;
+ const subject = await db.subjectFor(s.user_id).catch(() => null);
+ const settings = feeds.normalizeSettings(s.settings);
+ const title = String(s.settings.widget_title || s.title || 'Vibe coding').slice(0, 80);
+ const id = `fed_${require('crypto').randomBytes(26).reduce((a, b) => a + '0123456789ABCDEFGHJKMNPQRSTVWXYZ'[b % 32], '')}`;
+ await db.run(`INSERT INTO feeds (id, owner_id, owner_subject, title, managed_stream_id, settings, legacy_source) VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (legacy_source) WHERE legacy_source IS NOT NULL DO NOTHING`, [id, s.user_id, subject, title.length >= 2 ? title : 'Vibe coding', s.id, JSON.stringify(settings), source]);
+ const feed = await db.get('SELECT id FROM feeds WHERE legacy_source = ?', [source]);
+ for (const x of s.sessions) {
+ await db.run(`INSERT INTO feed_sessions (feed_id, session_key, publisher, status, created_at, last_event_at, ended_at) VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (feed_id, session_key) DO NOTHING`, [feed.id, String(x.session_key || 'default'), JSON.stringify(json(x.publisher, {})),
+ x.status === 'ended' ? 'ended' : 'active', x.created_at || null, x.last_event_at || null, x.ended_at || null]);
+ }
+ for (const e of s.events) {
+ const payload = feeds.scrub(json(e.payload, {}));
+ await db.run(`INSERT INTO feed_events (feed_id, session_key, event_id, seq, type, summary, payload, received_ms, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (feed_id, event_id) DO NOTHING`, [feed.id, e.session_key || null, String(e.event_id), Number(e.seq) || 0, String(e.type),
+ feeds.scrubText(e.summary || '').slice(0, 2000), JSON.stringify(payload), ms(e.created_at), e.created_at || null]);
+ }
+ const last = s.events.length ? s.events[s.events.length - 1].created_at : null;
+ if (last) await db.run('UPDATE feeds SET last_event_at = ? WHERE id = ? AND (last_event_at IS NULL OR last_event_at < ?)', [last, feed.id, last]);
+ // Verify: exactly the newest event ids of the file, no more, no fewer.
+ const have = new Set((await db.all('SELECT event_id FROM feed_events WHERE feed_id = ?', [feed.id])).map((r) => r.event_id));
+ const want = new Set(s.events.map((e) => String(e.event_id)));
+ const missing = [...want].filter((x) => !have.has(x)).length;
+ const extra = [...have].filter((x) => !want.has(x)).length;
+ log(`feed ${feed.id} ← slot ${s.id}: ${have.size} event(s) held, ${missing} missing, ${extra} not from the file`);
+ if (missing || extra) bad++;
+ }
+ if (bad) { log(`${bad} feed(s) did not verify`); return 1; }
+ log('verified: every feed holds exactly the events copied');
+ return 0;
+}
+
+if (require.main === module) {
+ main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { console.error(`import-live-feeds failed: ${err.message}`); process.exit(1); });
+}
+
+module.exports = { main, parseArgs, readExport };
@@ -186,10 +186,12 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
@@ -186,10 +186,12 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
},
},
credentials: true,
credentials: true,
});
});
- // The widget's visitor API answers any site, without cookies (the visitor's token is the key), so a site can build
- // its own panel on it; an inbox that names its sites refuses the others itself (server/inbox/routes.js).
- const openCors = cors({ origin: true, credentials: false, allowedHeaders: ['Content-Type', 'X-Visitor-Token'] });
- app.use((req, res, next) => (req.path.startsWith('/api/chat/widget/') ? openCors : allowlistedCors)(req, res, next));
+ // The widget's visitor API and feeds answer any site, without cookies (the visitor's token, the feed's key or an API
+ // token is the key), so a site can build its own panel or publish from a browser; an inbox that names its sites
+ // refuses the others itself (server/inbox/routes.js).
+ const openCors = cors({ origin: true, credentials: false, allowedHeaders: ['Content-Type', 'X-Visitor-Token', 'Authorization'] });
+ const openPath = (p) => p.startsWith('/api/chat/widget/') || p === '/api/chat/feeds' || p.startsWith('/api/chat/feeds/');
+ app.use((req, res, next) => (openPath(req.path) ? openCors : allowlistedCors)(req, res, next));
app.use(express.json({ limit: '1mb' }));
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '256kb' }));
app.use(express.urlencoded({ extended: true, limit: '256kb' }));
app.use(cookieParser());
app.use(cookieParser());
@@ -249,6 +251,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
@@ -249,6 +251,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
// Support inboxes (server/inbox/): the widget's visitor API, and the inbox's people.
// Support inboxes (server/inbox/): the widget's visitor API, and the inbox's people.
app.use('/api/chat/widget/inbox/:id', require('./inbox/routes').widget);
app.use('/api/chat/widget/inbox/:id', require('./inbox/routes').widget);
app.use('/api/chat/inboxes', require('./inbox/routes').agents);
app.use('/api/chat/inboxes', require('./inbox/routes').agents);
+ // Feeds (server/feeds/): live activity (vibe coding) published with a feed's key, shown in rooms, on Live, anywhere.
+ app.use('/api/chat/feeds', require('./feeds/routes'));
app.use('/api/chat/servers', require('./rooms/server-routes'));
app.use('/api/chat/servers', require('./rooms/server-routes'));
// Channel moderators & moderation settings (/api/chat/channels/:id/…): before /api/chat too.
// Channel moderators & moderation settings (/api/chat/channels/:id/…): before /api/chat too.
app.use('/api/chat/channels', require('./chat/channel-mod-routes'));
app.use('/api/chat/channels', require('./chat/channel-mod-routes'));
@@ -63,6 +63,9 @@ const EXPORTS = [
@@ -63,6 +63,9 @@ const EXPORTS = [
['inboxes_owned.json', `SELECT * FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
['inboxes_owned.json', `SELECT * FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
['inbox_memberships.json', `SELECT * FROM inbox_members WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
['inbox_memberships.json', `SELECT * FROM inbox_members WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
['inbox_replies.json', `SELECT * FROM inbox_messages WHERE author = 'agent' AND author_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['inbox_replies.json', `SELECT * FROM inbox_messages WHERE author = 'agent' AND author_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
+ // Feeds (server/feeds/feeds.js): the feeds, without their key's hash, and what was published to them.
+ ['feeds.json', `SELECT id, kind, title, room_id, managed_stream_id, settings, key_hint, legacy_source, created_at, last_event_at FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
+ ['feed_events.json', `SELECT e.feed_id, e.session_key, e.event_id, e.type, e.summary, e.payload, e.created_at FROM feed_events e JOIN feeds f ON f.id = e.feed_id WHERE f.owner_subject = ANY(@s) OR f.owner_id = ANY(@ids) ORDER BY e.id DESC LIMIT ${LIMIT}`],
];
];
async function exportPart(d, subject) {
async function exportPart(d, subject) {
@@ -110,6 +113,10 @@ const ERASE_AFTER_ROOMS = [
@@ -110,6 +113,10 @@ const ERASE_AFTER_ROOMS = [
[null, 'DELETE FROM inbox_ai_usage WHERE inbox_id IN (SELECT id FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
[null, 'DELETE FROM inbox_ai_usage WHERE inbox_id IN (SELECT id FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
['inboxes', 'DELETE FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)'],
['inboxes', 'DELETE FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)'],
[null, "UPDATE inbox_messages SET author_user_id = NULL, author_name = 'A former agent' WHERE author = 'agent' AND author_user_id = ANY(@ids)"],
[null, "UPDATE inbox_messages SET author_user_id = NULL, author_name = 'A former agent' WHERE author = 'agent' AND author_user_id = ANY(@ids)"],
+ // Feeds (server/feeds/feeds.js): a person's feeds go with everything published to them.
+ ['feed_events', 'DELETE FROM feed_events WHERE feed_id IN (SELECT id FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
+ [null, 'DELETE FROM feed_sessions WHERE feed_id IN (SELECT id FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
+ ['feeds', 'DELETE FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)'],
// An AI member belongs to its room, not to whoever added it: it stays, without their name on it.
// An AI member belongs to its room, not to whoever added it: it stays, without their name on it.
[null, 'UPDATE room_ai_members SET created_by_subject = NULL WHERE created_by_subject = ANY(@s)'],
[null, 'UPDATE room_ai_members SET created_by_subject = NULL WHERE created_by_subject = ANY(@s)'],
// Their own AI key is gone with them (OpenVibe.AI erases it): members that thought with it go back to OpenVibe's model.
// Their own AI key is gone with them (OpenVibe.AI erases it): members that thought with it go back to OpenVibe's model.
@@ -603,6 +603,14 @@ class ChatServer {
@@ -603,6 +603,14 @@ class ChatServer {
this.sendTo(ws, { type: 'inbox_watching', inbox: inbox.id, conversation: client.inboxConv || null });
this.sendTo(ws, { type: 'inbox_watching', inbox: inbox.id, conversation: client.inboxConv || null });
break;
break;
}
}
+ // Feeds (server/feeds/feeds.js): anyone follows a feed's card; what reaches them is already projected.
+ case 'feed_watch': {
+ const feed = await require('../feeds/feeds').get(msg.feed);
+ if (!feed) { this.sendTo(ws, { type: 'feed_error', code: 'feeds.not_found', message: 'No such feed' }); break; }
+ client.feedId = feed.id;
+ this.sendTo(ws, { type: 'feed_watching', feed: feed.id });
+ break;
+ }
case 'room_message': {
case 'room_message': {
const rooms = require('../rooms/rooms');
const rooms = require('../rooms/rooms');
const room = client.roomId ? await rooms.bySlug(client.roomSlug) : null;
const room = client.roomId ? await rooms.bySlug(client.roomSlug) : null;
@@ -2559,6 +2567,20 @@ class ChatServer {
@@ -2559,6 +2567,20 @@ class ChatServer {
}
}
}
}
+ /**
+ * A feed's event, as viewers see it: to everyone following the feed's card, and to the Live channel slot it is on
+ * while that slot is live, as Live's `vibe-coding` frame (the widget in Live's channel chat reads those).
+ */
+ async deliverFeed(feed, event) {
+ const msg = JSON.stringify({ type: 'feed_event', feed: feed.id, event });
+ for (const [ws, client] of this.clients) {
+ if (client.feedId === feed.id && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) ws.send(msg);
+ }
+ if (!feed.managed_stream_id) return;
+ const live = await ctx.liveStreamForSlot(feed.managed_stream_id).catch(() => null);
+ if (live) await this.broadcastToStream(Number(live.id), { type: 'vibe-coding', managed_stream_id: feed.managed_stream_id, slot_slug: live.slug || null, delay_ms: 0, event });
+ }
+
/** Everyone following a chat room (server/rooms/) on this server. */
/** Everyone following a chat room (server/rooms/) on this server. */
broadcastToRoom(roomId, data) {
broadcastToRoom(roomId, data) {
const msg = JSON.stringify(data);
const msg = JSON.stringify(data);
new file mode 100644
new file mode 100644
@@ -0,0 +1,412 @@
@@ -0,0 +1,412 @@
+'use strict';
+/**
+ * Feeds (docs/platform.md step 2; migrations/0017_feeds.sql): live activity a person publishes into Chat. Vibe coding,
+ * moved here from OpenVibe.Live (its server/vibe-coding/, same canonical events), is the first kind: an editor, agent or
+ * CLI sends sanitized events (a prompt, an answer, a tool used, a file saved) with the feed's key, and viewers see a
+ * compact card in the feed's room, on its Live channel slot while live, or on any site (openvibe.chat/embed/feed/:id).
+ *
+ * feeds create(user, input) → { feed, key } · update(feed, user, input) · rotateKey(feed) → key · remove(feed) ·
+ * get(id) · byKey(key) · mine(user) · forSlots(ids) · forRoom(roomId) · publicShape(feed)
+ * publishing hello(feed, msg) · publish(feed, input) → [{ eventId, delivered }] · fromClaudeCode(hook) → events
+ * viewing view(feed, { limit }) → { feed, publisher, events } · project(event, settings)
+ *
+ * What a viewer sees is decided when they see it: project() applies the feed's settings to the stored event (the
+ * projection is Live's), so turning prompts off hides them in the history too. Events are scrubbed of secrets (keys,
+ * tokens, passwords, private keys) and home folders on the way in, and reach viewers only after the feed's delay, kept
+ * on the server (Live sent the delay to the browser). A feed keeps its newest 5,000 events.
+ */
+const crypto = require('crypto');
+const path = require('path');
+const db = require('../db/database');
+const wordFilter = require('../chat/word-filter');
+
+class FeedError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } }
+const fail = (status, code, message) => { throw new FeedError(status, code, message); };
+
+const B32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
+const newId = () => `fed_${Array.from(crypto.randomBytes(26), (b) => B32[b % 32]).join('')}`;
+const newKey = () => `ovf_${crypto.randomBytes(24).toString('base64url')}`;
+const hashKey = (k) => crypto.createHash('sha256').update(String(k)).digest('hex');
+const FEED_RE = /^fed_[0-9A-HJKMNP-TV-Z]{26}$/;
+const KEY_RE = /^ovf_[A-Za-z0-9_-]{32}$/;
+const MAX_FEEDS = 10;
+const KEEP_EVENTS = 5000;
+const MAX_BATCH = 50;
+const TYPES = ['session.status', 'prompt', 'response', 'thinking', 'tool.call', 'file.change', 'file.save'];
+
+const DEFAULTS = Object.freeze({
+ enabled: 1,
+ viewer_depth: 'standard',
+ show_prompts: 1,
+ show_responses: 1,
+ show_thinking: 0,
+ show_tool_calls: 1,
+ show_tool_arguments: 0,
+ show_file_events: 1,
+ show_file_snippets: 0,
+ redact_file_paths: 1,
+ paused: 0,
+ delay_ms: 0,
+ max_events: 18,
+ max_prompt_chars: 220,
+ max_response_chars: 360,
+ max_thinking_chars: 140,
+ max_tool_chars: 140,
+ max_snippet_chars: 140,
+});
+
+const clean = (v, max) => String(v == null ? '' : v).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim().slice(0, max);
+const str = (v, max = 200) => { const s = clean(v, max); return s || null; };
+const bool = (v, d) => (v === undefined || v === null ? d : (v === true || v === 1 || v === '1' || v === 'on' || v === 'true') ? 1 : 0);
+const int = (v, min, max, d) => { const n = Number(v); return Number.isFinite(n) ? Math.min(max, Math.max(min, Math.round(n))) : d; };
+const depthOf = (v) => (['headline', 'deep'].includes(String(v || '').toLowerCase()) ? String(v).toLowerCase() : 'standard');
+const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null);
+const iso = (t) => (t ? (String(t).includes('T') ? String(t) : `${String(t).replace(' ', 'T')}Z`) : null); // ov_now() is UTC text
+const json = (v, d) => { if (v && typeof v === 'object') return v; try { return JSON.parse(v || 'null') || d; } catch { return d; } };
+
+function normalizeSettings(input = {}, current = DEFAULTS) {
+ const s = { ...DEFAULTS, ...current };
+ const out = {};
+ for (const k of ['enabled', 'show_prompts', 'show_responses', 'show_thinking', 'show_tool_calls', 'show_tool_arguments', 'show_file_events', 'show_file_snippets', 'redact_file_paths', 'paused']) out[k] = bool(input[k], s[k]);
+ out.viewer_depth = depthOf(input.viewer_depth !== undefined ? input.viewer_depth : s.viewer_depth);
+ const n = (k, min, max) => { out[k] = int(input[k] !== undefined ? input[k] : s[k], min, max, DEFAULTS[k]); };
+ n('delay_ms', 0, 120000); n('max_events', 1, 100); n('max_prompt_chars', 60, 1200); n('max_response_chars', 80, 2400);
+ n('max_thinking_chars', 40, 1200); n('max_tool_chars', 40, 1200); n('max_snippet_chars', 40, 1200);
+ return out;
+}
+
+// ── Scrubbing: what a publisher should never have sent ─────────────────────────────────────────────────────────────
+const HIDDEN = '[hidden]';
+const SECRETS = [
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g,
+ /\b(?:sk|pk|rk)-(?:ant-|proj-|live-|test-)?[A-Za-z0-9_-]{16,}/g,
+ /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g,
+ /\b(?:hbt|ovf|ovs|whsec)_[A-Za-z0-9_-]{8,}/g,
+ /\bAKIA[0-9A-Z]{16}\b/g,
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
+ /\bAIza[0-9A-Za-z_-]{30,}/g,
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
+];
+function scrubText(s) {
+ let t = String(s);
+ for (const re of SECRETS) t = t.replace(re, HIDDEN);
+ return t
+ .replace(/\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{12,}/gi, `$1 ${HIDDEN}`)
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/@]+@/gi, `$1${HIDDEN}@`)
+ .replace(/\b((?:[A-Z0-9_]*_)?(?:PASSWORD|PASSWD|SECRET|TOKEN|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY)[A-Z0-9_]*)(\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, `$1$2${HIDDEN}`)
+ .replace(/(?:\/home|\/Users)\/[^/\s"'`]+/g, '~')
+ .replace(/\b[A-Za-z]:\\Users\\[^\\\s"'`]+/g, '~');
+}
+function scrub(v, depth = 0) {
+ if (depth > 6) return null;
+ if (typeof v === 'string') return scrubText(v).slice(0, 8000);
+ if (Array.isArray(v)) return v.slice(0, 50).map((x) => scrub(x, depth + 1));
+ if (v && typeof v === 'object') { const o = {}; for (const [k, x] of Object.entries(v).slice(0, 60)) o[k] = scrub(x, depth + 1); return o; }
+ return typeof v === 'number' || typeof v === 'boolean' || v === null ? v : null;
+}
+
+// ── Feeds ──────────────────────────────────────────────────────────────────────────────────────────────────────────
+
+function shape(r) {
+ if (!r) return null;
+ return {
+ id: r.id, owner_id: Number(r.owner_id), kind: r.kind, title: r.title,
+ room_id: r.room_id == null ? null : Number(r.room_id), managed_stream_id: r.managed_stream_id == null ? null : Number(r.managed_stream_id),
+ settings: normalizeSettings({}, json(r.settings, {})), key_hint: r.key_hint || null, legacy_source: r.legacy_source || null,
+ created_at: iso(r.created_at), last_event_at: iso(r.last_event_at),
+ };
+}
+/** What anyone may know about a feed (the embed, the room card, Live's channel). */
+function publicShape(feed) {
+ return { id: feed.id, kind: feed.kind, title: feed.title, enabled: !!feed.settings.enabled && !feed.settings.paused, depth: feed.settings.viewer_depth, last_event_at: feed.last_event_at };
+}
+
+async function get(id) {
+ if (!FEED_RE.test(String(id || ''))) return null;
+ return shape(await db.get('SELECT * FROM feeds WHERE id = ? AND archived_at IS NULL', [id]));
+}
+async function byKey(key) {
+ if (!KEY_RE.test(String(key || ''))) return null;
+ return shape(await db.get('SELECT * FROM feeds WHERE key_hash = ? AND archived_at IS NULL', [hashKey(key)]));
+}
+async function mine(user) {
+ return (await db.all('SELECT * FROM feeds WHERE owner_id = ? AND archived_at IS NULL ORDER BY created_at DESC, id', [user.id])).map(shape);
+}
+async function forSlots(ids) {
+ const list = [...new Set((ids || []).map(Number).filter((n) => Number.isSafeInteger(n) && n > 0))].slice(0, 50);
+ if (!list.length) return [];
+ return (await db.all(`SELECT * FROM feeds WHERE managed_stream_id IN (${list.map(() => '?').join(',')}) AND archived_at IS NULL ORDER BY last_event_at DESC NULLS LAST`, list)).map(shape);
+}
+/** The feed a room shows (the most recently active one its owner put there that viewers may see), or null. */
+async function forRoom(roomId) {
+ const list = (await db.all('SELECT * FROM feeds WHERE room_id = ? AND archived_at IS NULL ORDER BY last_event_at DESC NULLS LAST LIMIT 5', [roomId])).map(shape);
+ return list.find((f) => f.settings.enabled && !f.settings.paused) || null;
+}
+
+// Where a feed shows: a room the person owns, a channel slot of theirs. null clears it.
+async function placeOf(user, input, current = {}) {
+ const out = {};
+ if (input.room !== undefined || input.room_id !== undefined) {
+ const v = input.room !== undefined ? input.room : input.room_id;
+ if (v === null || v === '' || v === 0 || v === '0') out.room_id = null;
+ else {
+ const rooms = require('../rooms/rooms');
+ const room = /^\d+$/.test(String(v)) ? await db.get('SELECT * FROM rooms WHERE id = ?', [Number(v)]) : await rooms.bySlug(String(v));
+ if (!room || room.assistant) fail(422, 'feeds.room', 'No such room');
+ if (!(await rooms.access(room, user)).manage) fail(403, 'feeds.room', 'A feed shows only in a room you own');
+ out.room_id = Number(room.id);
+ }
+ }
+ if (input.slot !== undefined || input.managed_stream_id !== undefined) {
+ const v = input.slot !== undefined ? input.slot : input.managed_stream_id;
+ if (v === null || v === '' || v === 0 || v === '0') out.managed_stream_id = null;
+ else {
+ const ms = await db.get('SELECT id, user_id FROM ctx_managed_streams WHERE id = ?', [Number(v)]);
+ if (!ms || Number(ms.user_id) !== Number(user.id)) fail(403, 'feeds.slot', 'A feed shows only on a channel slot of yours');
+ out.managed_stream_id = Number(ms.id);
+ }
+ }
+ return { room_id: current.room_id ?? null, managed_stream_id: current.managed_stream_id ?? null, ...out };
+}
+
+function titleOf(v) {
+ const t = clean(v, 81).replace(/\s+/g, ' ');
+ if (t.length < 2 || t.length > 80) fail(422, 'feeds.title', 'A feed\'s title is 2 to 80 characters');
+ if (wordFilter.check(t).safe === false) fail(422, 'feeds.title', 'That title is not allowed');
+ return t;
+}
+
+async function create(user, input = {}) {
+ const n = await db.get('SELECT count(*)::int AS n FROM feeds WHERE owner_id = ? AND archived_at IS NULL', [user.id]);
+ if (n.n >= MAX_FEEDS) fail(409, 'feeds.too_many', `At most ${MAX_FEEDS} feeds`);
+ const title = titleOf(input.title || 'Vibe coding');
+ const place = await placeOf(user, input);
+ const id = newId();
+ const key = newKey();
+ await db.run(`INSERT INTO feeds (id, owner_id, owner_subject, title, room_id, managed_stream_id, settings, key_hash, key_hint)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, user.id, user.subject_id || null, title, place.room_id, place.managed_stream_id,
+ JSON.stringify(normalizeSettings(obj(input.settings) || {})), hashKey(key), key.slice(-4)]);
+ return { feed: await get(id), key };
+}
+async function update(feed, user, input = {}) {
+ const title = input.title !== undefined ? titleOf(input.title) : feed.title;
+ const place = await placeOf(user, input, feed);
+ const settings = normalizeSettings(obj(input.settings) || {}, feed.settings);
+ await db.run('UPDATE feeds SET title = ?, room_id = ?, managed_stream_id = ?, settings = ? WHERE id = ?',
+ [title, place.room_id, place.managed_stream_id, JSON.stringify(settings), feed.id]);
+ return await get(feed.id);
+}
+async function rotateKey(feed) {
+ const key = newKey();
+ await db.run('UPDATE feeds SET key_hash = ?, key_hint = ? WHERE id = ?', [hashKey(key), key.slice(-4), feed.id]);
+ return key;
+}
+async function remove(feed) {
+ await db.run('DELETE FROM feed_events WHERE feed_id = ?', [feed.id]);
+ await db.run('DELETE FROM feed_sessions WHERE feed_id = ?', [feed.id]);
+ await db.run('DELETE FROM feeds WHERE id = ?', [feed.id]);
+}
+
+// ── Publishing ─────────────────────────────────────────────────────────────────────────────────────────────────────
+
+function publisherOf(p) {
+ const s = obj(p) || {};
+ const caps = obj(s.capabilities) || {};
+ const clientType = String(s.clientType || '').toLowerCase();
+ return {
+ integrationId: str(s.integrationId, 60) || 'custom',
+ integrationLabel: str(s.integrationLabel, 60) || str(s.integrationId, 60) || 'Custom publisher',
+ vendor: str(s.vendor, 60),
+ clientType: ['vscode-extension', 'cli', 'browser', 'service'].includes(clientType) ? clientType : 'other',
+ clientName: str(s.clientName, 80) || str(s.integrationLabel, 80) || 'Custom publisher',
+ clientVersion: str(s.clientVersion || s.extensionVersion, 40),
+ workspaceName: str(s.workspaceName, 80),
+ machineName: null, // never shown; Live kept it, Chat does not
+ viewerDepth: depthOf(s.viewerDepth),
+ capabilities: { thinking: !!caps.thinking, toolCalls: !!caps.toolCalls, workspaceFileEvents: !!caps.workspaceFileEvents },
+ };
+}
+const eventPublisher = (p) => { const x = publisherOf(p); return { integrationId: x.integrationId, integrationLabel: x.integrationLabel, vendor: x.vendor, clientType: x.clientType, clientName: x.clientName }; };
+
+/** One canonical event from what a publisher sent (Live's envelope, the bare event, or a short { type, text }). */
+function normalizeEvent(input, fallbackSession) {
+ let e = obj(input);
+ if (!e) fail(422, 'feeds.event', 'An event is an object');
+ if (e.type === 'vibe-coding.event') e = obj(e.event) || fail(422, 'feeds.event', 'The envelope has no event');
+ const type = String(e.eventType || e.type || '');
+ if (!TYPES.includes(type)) fail(422, 'feeds.event_type', `eventType is one of ${TYPES.join(', ')}`);
+ const text = (k) => str((obj(e[k]) && e[k].text) || (e.text && (type === k) ? e.text : null), 8000);
+ const out = {
+ version: 1,
+ eventId: str(e.eventId || e.id, 100) || crypto.randomUUID(),
+ sessionKey: str(e.sessionKey, 120) || fallbackSession || 'default',
+ sequence: int(e.sequence, 0, Number.MAX_SAFE_INTEGER, 0),
+ eventType: type,
+ visibility: 'public',
+ occurredAt: str(e.occurredAt, 40) || new Date().toISOString(),
+ summary: str(e.summary, 2000) || '',
+ source: obj(e.source) ? { channel: str(e.source.channel, 60), rawType: str(e.source.rawType, 60) } : null,
+ publisher: obj(e.publisher) ? eventPublisher(e.publisher) : null,
+ metadata: obj(e.metadata) ? { requestId: str(e.metadata.requestId, 100), responseId: str(e.metadata.responseId, 100), workspaceName: str(e.metadata.workspaceName, 80), model: str(e.metadata.model, 80) } : null,
+ };
+ if (type === 'prompt') out.prompt = { text: text('prompt') || out.summary };
+ if (type === 'response') out.response = { text: text('response') || out.summary };
+ if (type === 'thinking') out.thinking = { text: text('thinking') || out.summary };
+ if (type === 'tool.call') {
+ const t = obj(e.tool) || {};
+ out.tool = { name: str(t.name || e.name, 80) || 'tool', phase: str(t.phase, 20) || 'completed', argumentsPreview: str(t.argumentsPreview || e.args, 2000), resultPreview: str(t.resultPreview, 2000) };
+ }
+ if (type === 'file.change' || type === 'file.save') {
+ const f = obj(e.file) || {};
+ const rel = str(f.relativePath || e.path, 400);
+ out.file = { name: str(f.name, 200) || (rel ? path.posix.basename(rel.replace(/\\/g, '/')) : 'file'), relativePath: rel, operation: str(f.operation, 20) || (type === 'file.save' ? 'save' : 'edit'), changeCount: int(f.changeCount, 0, 1e6, 0), snippet: str(f.snippet, 2000) };
+ }
+ if (!out.summary) out.summary = (out.prompt || out.response || out.thinking || {}).text || '';
+ return scrub(out);
+}
+
+function deliverer(deps) {
+ return deps.deliver || ((feed, event) => { try { require('../chat/chat-server').deliverFeed(feed, event); } catch { /* no socket server (tests) */ } });
+}
+
+/** A publisher starts (or resumes) a session: who it is. */
+async function hello(feed, msg = {}) {
+ const key = str(msg.sessionKey, 120) || 'default';
+ const publisher = publisherOf(msg.publisher);
+ await db.run(`INSERT INTO feed_sessions (feed_id, session_key, publisher, status, last_event_at) VALUES (?, ?, ?, 'active', ov_now())
+ ON CONFLICT (feed_id, session_key) DO UPDATE SET publisher = excluded.publisher, status = 'active', ended_at = NULL, last_event_at = ov_now()`,
+ [feed.id, key, JSON.stringify(publisher)]);
+ return { sessionKey: key, publisher };
+}
+
+/**
+ * Store and deliver events: one, { events: [...] } (at most 50), or Live's envelopes. A repeated eventId replaces the
+ * stored one and is not delivered again. → [{ eventId, delivered }]
+ */
+async function publish(feed, input, deps = {}) {
+ const list = Array.isArray(input) ? input : Array.isArray(input && input.events) ? input.events : [input];
+ if (!list.length) fail(422, 'feeds.empty', 'No events');
+ if (list.length > MAX_BATCH) fail(413, 'feeds.batch', `At most ${MAX_BATCH} events at once`);
+ const events = list.map((e) => normalizeEvent(e, input && input.sessionKey));
+ const deliver = deliverer(deps);
+ const now = Date.now();
+ const out = [];
+ for (const event of events) {
+ const r = await db.get(`INSERT INTO feed_events (feed_id, session_key, event_id, seq, type, summary, payload, received_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (feed_id, event_id) DO UPDATE SET seq = excluded.seq, type = excluded.type, summary = excluded.summary, payload = excluded.payload
+ RETURNING (xmax = 0) AS fresh`, [feed.id, event.sessionKey, event.eventId, event.sequence, event.eventType, event.summary.slice(0, 2000), JSON.stringify(event), now]);
+ await db.run(`INSERT INTO feed_sessions (feed_id, session_key, publisher, status, last_event_at) VALUES (?, ?, ?, 'active', ov_now())
+ ON CONFLICT (feed_id, session_key) DO UPDATE SET last_event_at = ov_now()${event.eventType === 'session.status' && /\bend(ed)?\b/i.test(event.summary) ? ", status = 'ended', ended_at = ov_now()" : ''}`,
+ [feed.id, event.sessionKey, JSON.stringify(event.publisher || {})]);
+ const fresh = !!(r && r.fresh);
+ const viewed = fresh ? project(event, feed.settings) : null;
+ // The delay is the server's: a viewer cannot read ahead of it (the history waits for it too, in view()).
+ if (viewed && feed.settings.delay_ms) setTimeout(() => deliver(feed, viewed), feed.settings.delay_ms).unref();
+ else if (viewed) deliver(feed, viewed);
+ out.push({ eventId: event.eventId, delivered: !!viewed });
+ }
+ await db.run('UPDATE feeds SET last_event_at = ov_now() WHERE id = ?', [feed.id]);
+ if (Math.random() < 0.02) await prune(feed.id);
+ return out;
+}
+async function prune(feedId) {
+ await db.run(`DELETE FROM feed_events WHERE feed_id = ? AND id < (SELECT id FROM feed_events WHERE feed_id = ? ORDER BY id DESC OFFSET ? LIMIT 1)`, [feedId, feedId, KEEP_EVENTS - 1]);
+}
+
+// Claude Code's hooks (an "http" hook POSTs each event's JSON): what a viewer may see of a prompt, a tool used, a
+// turn's answer. Paths become relative to the session's folder; nothing is read from the transcript.
+const CC = { integrationId: 'claude-code', integrationLabel: 'Claude Code', vendor: 'Anthropic', clientType: 'cli', clientName: 'Claude Code' };
+function relTo(cwd, p) {
+ const s = String(p || '');
+ if (cwd && s.startsWith(String(cwd).replace(/\/+$/, '') + '/')) return s.slice(String(cwd).replace(/\/+$/, '').length + 1);
+ return s;
+}
+function fromClaudeCode(h) {
+ const hook = obj(h) || {};
+ const sessionKey = `cc-${clean(hook.session_id, 80) || 'session'}`;
+ const base = { sessionKey, publisher: CC, metadata: { model: str(obj(hook.model) ? hook.model.id || hook.model.display_name : hook.model, 80), workspaceName: hook.cwd ? path.basename(String(hook.cwd)) : null } };
+ const id = (suffix) => `${sessionKey}-${clean(hook.tool_use_id || hook.prompt_id || '', 80) || crypto.randomUUID()}${suffix}`;
+ switch (hook.hook_event_name) {
+ case 'SessionStart': return [{ ...base, eventId: id('-start'), eventType: 'session.status', summary: hook.source === 'resume' ? 'Resumed a Claude Code session' : 'Started a Claude Code session' }];
+ case 'SessionEnd': return [{ ...base, eventId: id('-end'), eventType: 'session.status', summary: 'Session ended' }];
+ case 'UserPromptSubmit': return hook.prompt ? [{ ...base, eventId: id('-prompt'), eventType: 'prompt', prompt: { text: String(hook.prompt) } }] : [];
+ case 'Stop': return hook.last_assistant_message ? [{ ...base, eventId: id(`-answer-${Date.now()}`), eventType: 'response', response: { text: String(hook.last_assistant_message) } }] : [];
+ case 'PostToolUse': {
+ const name = clean(hook.tool_name, 80) || 'tool';
+ const input = obj(hook.tool_input) || {};
+ const file = input.file_path || input.notebook_path || null;
+ const rel = file ? relTo(hook.cwd, file) : null;
+ const arg = input.command || rel || input.pattern || input.url || input.query || input.description || null;
+ // A file written or edited is a file event (the feed's file settings apply), not a tool used as well.
+ if (rel && ['Edit', 'MultiEdit', 'Write', 'NotebookEdit'].includes(name)) {
+ return [{ ...base, eventId: id('-file'), eventType: name === 'Write' ? 'file.save' : 'file.change', file: { relativePath: rel, operation: name === 'Write' ? 'write' : 'edit', changeCount: Array.isArray(input.edits) ? input.edits.length : 1 } }];
+ }
+ const what = { Bash: 'Ran a command', Read: `Read ${rel || 'a file'}`, Grep: 'Searched the code', Glob: 'Looked for files', WebFetch: 'Read a web page', WebSearch: 'Searched the web', Task: 'Started a helper', Agent: 'Started a helper' }[name];
+ return [{ ...base, eventId: id('-tool'), eventType: 'tool.call', summary: what || `Used ${name}`, tool: { name, phase: 'completed', argumentsPreview: arg ? String(arg) : null, resultPreview: null } }];
+ }
+ default: return [];
+ }
+}
+
+// ── Viewing ────────────────────────────────────────────────────────────────────────────────────────────────────────
+
+function cut(value, max, depth) {
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
+ if (!text) return '';
+ const limit = depth === 'headline' ? Math.max(40, Math.floor(max * 0.6)) : depth === 'deep' ? Math.max(max, Math.floor(max * 1.1)) : max;
+ return text.length <= limit ? text : `${text.slice(0, Math.max(20, limit - 1)).trim()}…`;
+}
+const TOOL_PREVIEW_ALWAYS = ['run_in_terminal', 'create_and_run_task', 'run_notebook_cell'];
+
+/** What a viewer sees of a stored event under these settings, or null (Live's projectViewerEvent). */
+function project(event, settings) {
+ if (!event || !settings || !settings.enabled || settings.paused) return null;
+ const d = settings.viewer_depth;
+ const p = {
+ eventId: event.eventId, eventType: event.eventType, occurredAt: event.occurredAt, sequence: event.sequence, depth: d,
+ summary: '', source: event.source || null, prompt: null, response: null, thinking: null, tool: null, file: null,
+ publisher: event.publisher ? eventPublisher(event.publisher) : null,
+ metadata: { requestId: event.metadata?.requestId || null, responseId: event.metadata?.responseId || null, workspaceName: event.metadata?.workspaceName || null, model: event.metadata?.model || null },
+ };
+ switch (event.eventType) {
+ case 'session.status': p.summary = cut(event.summary, settings.max_prompt_chars, d); return p;
+ case 'prompt': if (!settings.show_prompts) return null; p.summary = cut(event.prompt?.text || event.summary, settings.max_prompt_chars, d); p.prompt = { text: p.summary }; return p;
+ case 'response': if (!settings.show_responses) return null; p.summary = cut(event.response?.text || event.summary, settings.max_response_chars, d); p.response = { text: p.summary }; return p;
+ case 'thinking': if (!settings.show_thinking) return null; p.summary = cut(event.thinking?.text || event.summary, settings.max_thinking_chars, d); p.thinking = { text: p.summary }; return p;
+ case 'tool.call': {
+ if (!settings.show_tool_calls) return null;
+ const preview = settings.show_tool_arguments || TOOL_PREVIEW_ALWAYS.includes(String(event.tool?.name || '').toLowerCase());
+ p.summary = cut(event.summary || `Used ${event.tool?.name || 'tool'}`, settings.max_tool_chars, d);
+ p.tool = { name: event.tool?.name || 'tool', phase: event.tool?.phase || 'completed', argumentsPreview: preview ? cut(event.tool?.argumentsPreview, settings.max_tool_chars, d) || null : null, resultPreview: preview ? cut(event.tool?.resultPreview, settings.max_tool_chars, d) || null : null };
+ if (!preview && settings.redact_file_paths) p.summary = p.summary.replace(/[\w.~/-]*\/([\w.-]+)/g, '$1');
+ return p;
+ }
+ case 'file.change': case 'file.save': {
+ if (!settings.show_file_events) return null;
+ const raw = String(event.file?.relativePath || event.file?.name || '').trim();
+ const shown = settings.redact_file_paths ? path.posix.basename(raw.replace(/\\/g, '/')) : raw;
+ p.summary = `${event.eventType === 'file.save' ? 'Saved' : 'Editing'} ${shown || event.file?.name || 'file'}`;
+ p.file = { name: event.file?.name || shown || 'file', relativePath: shown || null, operation: event.file?.operation || (event.eventType === 'file.save' ? 'save' : 'edit'), changeCount: Number(event.file?.changeCount || 0), snippet: settings.show_file_snippets ? cut(event.file?.snippet, settings.max_snippet_chars, d) || null : null };
+ return p;
+ }
+ default: return null;
+ }
+}
+
+/** The feed as a viewer sees it now: its newest events, older than its delay, projected. */
+async function view(feed, { limit } = {}) {
+ const n = int(limit, 1, 100, feed.settings.max_events);
+ const before = Date.now() - feed.settings.delay_ms;
+ const rows = await db.all('SELECT payload FROM feed_events WHERE feed_id = ? AND received_ms <= ? ORDER BY id DESC LIMIT ?', [feed.id, before, n * 3]);
+ const events = rows.map((r) => project(json(r.payload, null), feed.settings)).filter(Boolean).slice(0, n).reverse();
+ const s = await db.get("SELECT publisher FROM feed_sessions WHERE feed_id = ? ORDER BY COALESCE(last_event_at, created_at) DESC, id DESC LIMIT 1", [feed.id]);
+ return { feed: publicShape(feed), publisher: s ? publisherOf(json(s.publisher, {})) : null, events };
+}
+
+function _reset() { /* no module state */ }
+
+module.exports = {
+ FeedError, FEED_RE, KEY_RE, TYPES, DEFAULTS, MAX_FEEDS, KEEP_EVENTS, MAX_BATCH, hashKey,
+ normalizeSettings, scrubText, scrub, normalizeEvent, project, fromClaudeCode,
+ create, update, rotateKey, remove, get, byKey, mine, forSlots, forRoom, shape, publicShape, hello, publish, view, prune, _reset,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+'use strict';
+/**
+ * Feeds over HTTP (server/feeds/feeds.js, docs/platform.md). One router at /api/chat/feeds; the public and publishing
+ * routes answer any site without cookies (server/app.js), the owner's need a sign-in (or an API token as its person).
+ *
+ * Anyone:
+ * GET /?slot=1,2 | ?channel=username { feeds }: the feeds shown on those Live channel slots (Live's chat asks)
+ * GET /:id { feed, publisher, events }: what a viewer sees now (after the delay)
+ * Publishers (Authorization: Bearer ovf_…, the feed's key):
+ * POST /:id/hello { sessionKey?, publisher? } → { sessionKey, publisher }
+ * POST /:id/events an event, { events: [...] } (at most 50) or Live's envelopes → { events }
+ * POST /:id/hooks/claude-code Claude Code's hook JSON as is (an "http" hook) → {} (never blocks it)
+ * The owner:
+ * GET /mine { feeds }
+ * POST / { title?, room?, slot?, settings? } → 201 { feed, key } (the key, once)
+ * PATCH /:id { title?, room?, slot?, settings? } → { feed }
+ * POST /:id/key → { key } (a new key; the old one stops working)
+ * DELETE /:id
+ */
+const express = require('express');
+const rateLimit = require('express-rate-limit');
+const cache = require('openvibe-shared/cache-policy');
+const { requireAuth } = require('../auth/auth');
+const feeds = require('./feeds');
+
+function send(res, err) {
+ if (err instanceof feeds.FeedError) return res.status(err.status).json({ error: err.message, code: err.code });
+ console.error('[Feeds]', err && err.message);
+ return res.status(500).json({ error: 'Something went wrong' });
+}
+const noStore = (res) => res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+const router = express.Router();
+const readLimit = rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false });
+// Per feed key: an agent working hard sends a few events a second.
+const publishLimit = rateLimit({
+ windowMs: 60_000, max: 600, standardHeaders: true, legacyHeaders: false,
+ keyGenerator: (req) => `feed:${req.params.id}`,
+ message: { error: 'Slow down a little', code: 'feeds.slow_down' },
+});
+
+// The feed's key first (a wrong key spends the address's allowance, not the feed's), then the feed's own allowance.
+const keyLimit = rateLimit({ windowMs: 60_000, max: 1200, standardHeaders: true, legacyHeaders: false });
+async function withKey(req, res, next) {
+ const m = /^Bearer\s+(\S+)$/i.exec(req.get('authorization') || '');
+ const feed = m ? await feeds.byKey(m[1]) : null;
+ if (!feed || feed.id !== req.params.id) return res.status(401).json({ error: 'The feed\'s key is missing or wrong', code: 'feeds.key' });
+ req.feed = feed;
+ next();
+}
+const publisher = [keyLimit, withKey, publishLimit];
+async function ownFeed(req, res) {
+ const feed = await feeds.get(req.params.id);
+ if (!feed || feed.owner_id !== Number(req.user.id)) { res.status(404).json({ error: 'No such feed', code: 'feeds.not_found' }); return null; }
+ return feed;
+}
+
+// ── Anyone ─────────────────────────────────────────────────────────────────────────────────────────────────────────
+router.get('/', readLimit, async (req, res, next) => {
+ if (!req.query.slot && !req.query.channel) return next();
+ try {
+ let slots = String(req.query.slot || '').split(',').map(Number).filter(Boolean);
+ if (req.query.channel) {
+ const ctx = require('../live-context');
+ const owner = await ctx.getUserByUsername(String(req.query.channel));
+ slots = owner ? (await ctx.getManagedStreamsByUserId(owner.id)).map((m) => Number(m.id)) : [];
+ }
+ const list = (await feeds.forSlots(slots)).filter((f) => f.settings.enabled && !f.settings.paused);
+ res.set('Cache-Control', 'public, max-age=15').json({ feeds: list.map((f) => ({ ...feeds.publicShape(f), managed_stream_id: f.managed_stream_id })) });
+ } catch (err) { send(res, err); }
+});
+
+// ── The owner ──────────────────────────────────────────────────────────────────────────────────────────────────────
+router.get('/mine', requireAuth, async (req, res) => {
+ try { noStore(res).json({ feeds: await feeds.mine(req.user) }); } catch (err) { send(res, err); }
+});
+router.post('/', requireAuth, async (req, res) => {
+ try { const r = await feeds.create(req.user, req.body || {}); noStore(res).status(201).json(r); } catch (err) { send(res, err); }
+});
+router.patch('/:id', requireAuth, async (req, res) => {
+ const feed = await ownFeed(req, res); if (!feed) return;
+ try { noStore(res).json({ feed: await feeds.update(feed, req.user, req.body || {}) }); } catch (err) { send(res, err); }
+});
+router.post('/:id/key', requireAuth, async (req, res) => {
+ const feed = await ownFeed(req, res); if (!feed) return;
+ try { noStore(res).json({ key: await feeds.rotateKey(feed) }); } catch (err) { send(res, err); }
+});
+router.delete('/:id', requireAuth, async (req, res) => {
+ const feed = await ownFeed(req, res); if (!feed) return;
+ try { await feeds.remove(feed); res.status(204).end(); } catch (err) { send(res, err); }
+});
+
+router.get('/:id', readLimit, async (req, res) => {
+ const feed = await feeds.get(req.params.id);
+ if (!feed) return res.status(404).json({ error: 'No such feed', code: 'feeds.not_found' });
+ try { noStore(res).json(await feeds.view(feed, { limit: req.query.limit })); } catch (err) { send(res, err); }
+});
+
+// ── Publishers ─────────────────────────────────────────────────────────────────────────────────────────────────────
+router.post('/:id/hello', publisher, async (req, res) => {
+ try { res.json(await feeds.hello(req.feed, req.body || {})); } catch (err) { send(res, err); }
+});
+router.post('/:id/events', publisher, async (req, res) => {
+ try { res.status(202).json({ events: await feeds.publish(req.feed, req.body) }); } catch (err) { send(res, err); }
+});
+router.post('/:id/hooks/claude-code', publisher, async (req, res) => {
+ try {
+ const events = feeds.fromClaudeCode(req.body);
+ if (events.length) await feeds.publish(req.feed, events);
+ } catch (err) { if (!(err instanceof feeds.FeedError)) console.warn('[Feeds] claude-code:', err.message); }
+ res.json({}); // an empty answer: Claude Code carries on as if the hook was not there
+});
+
+module.exports = router;
@@ -318,6 +318,12 @@ async function getLiveStreamsByUserId(userId) {
@@ -318,6 +318,12 @@ async function getLiveStreamsByUserId(userId) {
async function getStreamsByUserId(userId, limit = 50) {
async function getStreamsByUserId(userId, limit = 50) {
return await db.all(`${STREAM_SELECT} WHERE s.user_id = ? ORDER BY s.created_at DESC LIMIT ?`, [userId, limit]);
return await db.all(`${STREAM_SELECT} WHERE s.user_id = ? ORDER BY s.created_at DESC LIMIT ?`, [userId, limit]);
}
}
+/** The live session on a channel slot (managed stream) right now, with the slot's slug, or null. */
+async function liveStreamForSlot(managedStreamId) {
+ if (!managedStreamId) return null;
+ return await db.get(`SELECT s.id, ms.slug FROM ctx_streams s LEFT JOIN ctx_managed_streams ms ON ms.id = s.managed_stream_id
+ WHERE s.managed_stream_id = ? AND s.is_live = 1 ORDER BY s.id DESC LIMIT 1`, [managedStreamId]) || null;
+}
async function getManagedStreamsByUserId(userId) {
async function getManagedStreamsByUserId(userId) {
return await db.all(`
return await db.all(`
SELECT ms.*,
SELECT ms.*,
@@ -944,7 +950,7 @@ module.exports = {
@@ -944,7 +950,7 @@ module.exports = {
authenticate, authFailureReason, upsertUser, invalidateUser, subjectFor,
authenticate, authFailureReason, upsertUser, invalidateUser, subjectFor,
getUserById, getUserByUsername, getUserBySubject, getUserByDisplayName, ensureUsers, ensureUserByUsername,
getUserById, getUserByUsername, getUserBySubject, getUserByDisplayName, ensureUsers, ensureUserByUsername,
// streams + channels
// streams + channels
- getStreamById, ensureStream, refreshStream, latestStreamIdForUser, getLiveStreamsByUserId, getStreamsByUserId, getManagedStreamsByUserId,
+ getStreamById, ensureStream, refreshStream, latestStreamIdForUser, getLiveStreamsByUserId, getStreamsByUserId, getManagedStreamsByUserId, liveStreamForSlot,
getChannelById, getChannelByUserId, ensureChannelForUser, createChannel,
getChannelById, getChannelByUserId, ensureChannelForUser, createChannel,
emoteSources, setChannelEmoteSources,
emoteSources, setChannelEmoteSources,
// policy
// policy
new file mode 100644
new file mode 100644
@@ -0,0 +1,175 @@
@@ -0,0 +1,175 @@
+'use strict';
+/**
+ * Feeds on openvibe.chat (server/feeds/feeds.js, docs/platform.md):
+ *
+ * GET /embed/feed/:id a feed's card for any site's iframe (public/web/feed-view.js; frame-ancestors any site),
+ * OpenVibe.Live's channel chat among them; ?theme=light|dark, else the visitor's own
+ * GET /feeds a person's feeds, and a form to make one
+ * GET /feeds/:id where it shows, what viewers see, the delay; how to publish (Claude Code's hooks, the
+ * API), the key (made again on request), the embed tag, a live preview
+ * POST /feeds · /feeds/:id/settings · /feeds/:id/key · /feeds/:id/delete forms; making a feed or a key answers with
+ * the page that shows the key, once (it is never stored)
+ */
+const feeds = require('../feeds/feeds');
+const ctx = require('../live-context');
+const db = require('../db/database');
+const { esc, asset } = require('./layout');
+
+const THEMES = {
+ light: '--oc-bg:#ffffff;--oc-panel:#ffffff;--oc-line:#e3e7ef;--oc-text:#101828;--oc-muted:#5b6577;--oc-accent:#2563eb;--oc-ok:#16a34a;color-scheme:light',
+ dark: '--oc-bg:#0b1020;--oc-panel:#121a2c;--oc-line:#223049;--oc-text:#e8edf6;--oc-muted:#98a6bd;--oc-accent:#60a5fa;--oc-ok:#22c55e;color-scheme:dark',
+};
+
+function embedHtml(feed, theme) {
+ const pick = THEMES[theme] ? `:root{${THEMES[theme]}}` : `:root{${THEMES.light}}@media (prefers-color-scheme: dark){:root{${THEMES.dark}}}`;
+ return `<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>${esc(feed.title)}</title>
+<meta name="robots" content="noindex, nofollow">
+<link rel="stylesheet" href="${asset('feed-view.css')}">
+<style>${pick}html,body{margin:0;background:transparent}body{color:var(--oc-text);font:15px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}</style>
+<script src="${asset('feed-view.js')}" defer></script>
+</head>
+<body><div class="fv" data-feed="${esc(feed.id)}"></div></body>
+</html>`;
+}
+
+const hookFor = (base, id) => ({ type: 'http', url: `${base}/api/chat/feeds/${id}/hooks/claude-code`, headers: { Authorization: 'Bearer $OPENVIBE_FEED_KEY' }, allowedEnvVars: ['OPENVIBE_FEED_KEY'], timeout: 5 });
+/** The block for .claude/settings.json: the events a viewer may see, sent as they happen; never blocks Claude Code. */
+function claudeCodeSnippet(base, id) {
+ const h = [hookFor(base, id)];
+ return JSON.stringify({ hooks: { SessionStart: [{ hooks: h }], UserPromptSubmit: [{ hooks: h }], PostToolUse: [{ matcher: '*', hooks: h }], Stop: [{ hooks: h }], SessionEnd: [{ hooks: h }] } }, null, 2);
+}
+const curlSnippet = (base, id) => `curl -X POST ${base}/api/chat/feeds/${id}/events \\
+ -H "Authorization: Bearer $OPENVIBE_FEED_KEY" -H "Content-Type: application/json" \\
+ -d '{"eventType": "prompt", "text": "Add a dark mode to the settings page"}'`;
+const iframeSnippet = (base, id) => `<iframe src="${base}/embed/feed/${id}" title="Vibe coding, live" style="width:100%;height:320px;border:0" loading="lazy"></iframe>`;
+
+function mountFeedPages(router, { page, needUser, sameSite, formLimit, notice, config }) {
+ const base = config.web.baseUrl;
+
+ router.get('/embed/feed/:id', async (req, res) => {
+ const feed = await feeds.get(req.params.id);
+ if (!feed) return res.status(404).set('Cache-Control', 'no-store').type('html').send('<!DOCTYPE html><meta charset="utf-8"><title>Feed not found</title><p style="font-family:system-ui;padding:16px">This feed is not available.</p>');
+ res.set('Content-Security-Policy', [
+ "default-src 'self'", "script-src 'self'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data:",
+ `connect-src 'self' ${base.replace(/^http/, 'ws')}`, 'frame-ancestors *', "base-uri 'self'", "form-action 'none'", "object-src 'none'",
+ ].join('; ')).set('Cache-Control', 'no-store').set('X-Robots-Tag', 'noindex').type('html').send(embedHtml(feed, String(req.query.theme || '')));
+ });
+
+ async function places(user) {
+ const roomList = await db.all('SELECT id, slug, name FROM rooms WHERE owner_id = ? AND archived_at IS NULL AND assistant = 0 AND server_id IS NULL ORDER BY lower(name) LIMIT 100', [user.id]);
+ const slots = await ctx.getManagedStreamsByUserId(user.id).catch(() => []);
+ return { roomList, slots };
+ }
+ function placeFields({ roomList, slots }, feed = {}) {
+ const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;
+ return `<label>Show it in a room <select name="room">${opt('', 'No room', !feed.room_id)}${roomList.map((r) => opt(r.id, r.name, Number(feed.room_id) === Number(r.id))).join('')}</select></label>
+<label>Show it on your Live channel while live <select name="slot">${opt('', 'Not on Live', !feed.managed_stream_id)}${slots.map((s) => opt(s.id, s.title || s.slug || `Slot ${s.id}`, Number(feed.managed_stream_id) === Number(s.id))).join('')}</select></label>`;
+ }
+ function keyPanel(feed, key) {
+ return `${notice('ok', 'Here is the feed\'s key. Copy it now: it is shown only this once.')}
+<pre class="ib-snippet fd-key"><code>${esc(key)}</code></pre>
+<p class="oc-muted">Put it where your tools read it, for example <code>export OPENVIBE_FEED_KEY=${esc(key.slice(0, 8))}…</code> in your shell profile. Anyone with the key can post to this feed; make a new one any time.</p>
+<p><a class="oc-button" href="/feeds/${esc(feed.id)}">Set up publishing</a></p>`;
+ }
+
+ router.get('/feeds', async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const mine = await feeds.mine(actor.user);
+ const list = mine.length
+ ? `<ul class="ib-list">${mine.map((f) => `<li><a href="/feeds/${esc(f.id)}"><span class="ib-dot fd-dot${f.settings.enabled && !f.settings.paused ? ' fd-on' : ''}"></span><b>${esc(f.title)}</b>
+<span class="oc-muted">${f.last_event_at ? `last step ${esc(f.last_event_at.slice(0, 16).replace('T', ' '))}` : 'nothing published yet'}${f.room_id ? ' · in a room' : ''}${f.managed_stream_id ? ' · on Live' : ''}</span></a></li>`).join('')}</ul>`
+ : '<p class="oc-muted">No feeds yet.</p>';
+ await page(req, res, 200, {
+ actor, title: 'Feeds', path: '/feeds', active: 'rooms', robots: 'noindex, nofollow',
+ body: `<h1>Feeds</h1>
+<p class="oc-muted">Show what you are building, live. Claude Code, Copilot or any tool sends each step (a prompt, a command, a file edited, the answer) and your viewers follow along in a card: in your room, on your Live channel while you stream, or embedded on your own site. You decide what they see; secrets and home folders are wiped before anything is shown.</p>
+${req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : ''}
+${list}
+<h2>Make a feed</h2>
+<form class="oc-form" method="post" action="/feeds">
+<label>Title <input name="title" required minlength="2" maxlength="80" value="Vibe coding"></label>
+${placeFields(await places(actor.user))}
+<p><button type="submit">Make the feed</button></p></form>`,
+ });
+ });
+
+ router.post('/feeds', formLimit, sameSite, async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const b = req.body || {};
+ try {
+ const { feed, key } = await feeds.create(actor.user, { title: b.title, room: b.room || null, slot: b.slot || null });
+ await page(req, res, 201, { actor, title: `${feed.title}: key`, path: `/feeds/${feed.id}`, active: 'rooms', robots: 'noindex, nofollow', body: `<p><a href="/feeds">← Feeds</a></p><h1>${esc(feed.title)}</h1>${keyPanel(feed, key)}` });
+ } catch (err) { res.redirect(303, `/feeds?error=${encodeURIComponent(err.message || 'That did not work')}`); }
+ });
+
+ async function ownFeed(req, res) {
+ const actor = await needUser(req, res); if (!actor) return null;
+ const feed = await feeds.get(req.params.id);
+ if (!feed || feed.owner_id !== Number(actor.user.id)) { await page(req, res, 404, { actor, title: 'Feed not found', path: '/feeds', robots: 'noindex, nofollow', body: '<h1>Feed not found</h1><p><a href="/feeds">Your feeds</a></p>' }); return null; }
+ return { actor, feed };
+ }
+
+ router.get('/feeds/:id', async (req, res) => {
+ const a = await ownFeed(req, res); if (!a) return;
+ const { actor, feed } = a;
+ const s = feed.settings;
+ const check = (name, label) => `<label class="oc-check"><input type="checkbox" name="${name}" value="1"${s[name] ? ' checked' : ''}> ${label}</label>`;
+ await page(req, res, 200, {
+ actor, title: `${feed.title}: feed`, path: `/feeds/${feed.id}`, active: 'rooms', robots: 'noindex, nofollow', feedView: true,
+ body: `<p><a href="/feeds">← Feeds</a></p><h1>${esc(feed.title)}</h1>
+${req.query.saved ? notice('ok', 'Saved.') : ''}${req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : ''}
+<div class="fv fd-preview" data-feed="${esc(feed.id)}"></div>
+<h2>Publish from Claude Code</h2>
+<p>Add this to <code>.claude/settings.json</code> in a project (or <code>~/.claude/settings.json</code> for every project), and set <code>OPENVIBE_FEED_KEY</code> in the shell you start Claude Code from. Each prompt, tool used and answer becomes a step; nothing is read from your files or transcript.</p>
+<pre class="ib-snippet"><code>${esc(claudeCodeSnippet(base, feed.id))}</code></pre>
+<h2>Publish from anything else</h2>
+<p>Send events to the API with the key; <code>eventType</code> is one of ${feeds.TYPES.map((t) => `<code>${t}</code>`).join(', ')} (up to 50 at once as <code>{ "events": [...] }</code>). The OpenVibe Copilot companion speaks the same events.</p>
+<pre class="ib-snippet"><code>${esc(curlSnippet(base, feed.id))}</code></pre>
+<p class="oc-muted">Key ending in <code>${esc(feed.key_hint || '????')}</code>.</p>
+<form method="post" action="/feeds/${esc(feed.id)}/key" class="oc-inline"><button type="submit">Make a new key</button></form> <span class="oc-muted">(the old one stops working)</span>
+<h2>Show it on your site</h2>
+<pre class="ib-snippet"><code>${esc(iframeSnippet(base, feed.id))}</code></pre>
+<h2>Where it shows and what viewers see</h2>
+<form class="oc-form" method="post" action="/feeds/${esc(feed.id)}/settings">
+<label>Title <input name="title" required minlength="2" maxlength="80" value="${esc(feed.title)}"></label>
+${placeFields(await places(actor.user), feed)}
+${check('enabled', 'Viewers see this feed')}
+${check('paused', 'Paused (steps are kept, nobody sees them until you unpause)')}
+${check('show_prompts', 'Prompts')}${check('show_responses', 'Answers')}${check('show_tool_calls', 'Tools used')}${check('show_tool_arguments', 'What the tools were given (commands, searches)')}
+${check('show_file_events', 'Files changed')}${check('redact_file_paths', 'File names only, not their folders')}${check('show_thinking', 'Thinking, when the tool sends it')}
+<label>Detail <select name="viewer_depth">${[['headline', 'Headlines'], ['standard', 'Standard'], ['deep', 'More text']].map(([v, l]) => `<option value="${v}"${s.viewer_depth === v ? ' selected' : ''}>${l}</option>`).join('')}</select></label>
+<label>Delay before viewers see a step <select name="delay_ms">${[[0, 'None'], [10000, '10 seconds'], [30000, '30 seconds'], [60000, '1 minute'], [120000, '2 minutes']].map(([v, l]) => `<option value="${v}"${s.delay_ms === v ? ' selected' : ''}>${l}</option>`).join('')}</select></label>
+<p><button type="submit">Save</button></p></form>
+<form method="post" action="/feeds/${esc(feed.id)}/delete" class="oc-inline"><button type="submit" class="oc-link oc-danger">Delete this feed and its history</button></form>`,
+ });
+ });
+
+ router.post('/feeds/:id/settings', formLimit, sameSite, async (req, res) => {
+ const a = await ownFeed(req, res); if (!a) return;
+ const b = req.body || {};
+ const flags = ['enabled', 'paused', 'show_prompts', 'show_responses', 'show_tool_calls', 'show_tool_arguments', 'show_file_events', 'redact_file_paths', 'show_thinking'];
+ const settings = { viewer_depth: b.viewer_depth, delay_ms: b.delay_ms };
+ for (const f of flags) settings[f] = b[f] === '1' ? 1 : 0;
+ try {
+ await feeds.update(a.feed, a.actor.user, { title: b.title, room: b.room || null, slot: b.slot || null, settings });
+ res.redirect(303, `/feeds/${a.feed.id}?saved=1`);
+ } catch (err) { res.redirect(303, `/feeds/${a.feed.id}?error=${encodeURIComponent(err.message || 'That did not save')}`); }
+ });
+ router.post('/feeds/:id/key', formLimit, sameSite, async (req, res) => {
+ const a = await ownFeed(req, res); if (!a) return;
+ const key = await feeds.rotateKey(a.feed);
+ await page(req, res, 200, { actor: a.actor, title: `${a.feed.title}: key`, path: `/feeds/${a.feed.id}`, active: 'rooms', robots: 'noindex, nofollow', body: `<p><a href="/feeds">← Feeds</a></p><h1>${esc(a.feed.title)}</h1>${keyPanel(a.feed, key)}` });
+ });
+ router.post('/feeds/:id/delete', formLimit, sameSite, async (req, res) => {
+ const a = await ownFeed(req, res); if (!a) return;
+ await feeds.remove(a.feed);
+ res.redirect(303, '/feeds');
+ });
+}
+
+module.exports = { mountFeedPages, embedHtml, claudeCodeSnippet };
@@ -97,7 +97,8 @@ function contentSecurityPolicy(nonce) {
@@ -97,7 +97,8 @@ function contentSecurityPolicy(nonce) {
* prefs (the person's chat preferences: timestamps, compact, font_scale, show_badges), counts (unread
* prefs (the person's chat preferences: timestamps, compact, font_scale, show_badges), counts (unread
* rooms/messages for the navigation), script / callScript / ringScript (public/web/chat.js, call.js, ring.js: a call
* rooms/messages for the navigation), script / callScript / ringScript (public/web/chat.js, call.js, ring.js: a call
* coming in, on every page of a signed-in person while calls are on), app (Vibe's full-window app, server/web/vibe-pages.js:
* coming in, on every page of a signed-in person while calls are on), app (Vibe's full-window app, server/web/vibe-pages.js:
- * vibe.css + vibe-md.js + vibe.js, and no footer), appScript (the app's own script instead of vibe.js: inbox.js)
+ * vibe.css + vibe-md.js + vibe.js, and no footer), appScript (the app's own script instead of vibe.js: inbox.js), feedView
+ * (a feed's card on the page: feed-view.css + feed-view.js, server/feeds/)
*/
*/
function renderPage(o) {
function renderPage(o) {
const { config } = o;
const { config } = o;
@@ -129,6 +130,7 @@ ${INSTALL_LINKS}
@@ -129,6 +130,7 @@ ${INSTALL_LINKS}
<link rel="stylesheet" href="${ovServe.url('items.css')}">
<link rel="stylesheet" href="${ovServe.url('items.css')}">
${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` : ''}
${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` : ''}
${o.app ? `<link rel="stylesheet" href="${asset('vibe.css')}">` : ''}
${o.app ? `<link rel="stylesheet" href="${asset('vibe.css')}">` : ''}
+${o.feedView ? `<link rel="stylesheet" href="${asset('feed-view.css')}">\n<script src="${asset('feed-view.js')}" defer></script>` : ''}
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
@@ -619,7 +619,8 @@ ${mine.length ? `<h2>Your rooms</h2><ul class="oc-convs">${mine.map(roomCard).jo
@@ -619,7 +619,8 @@ ${mine.length ? `<h2>Your rooms</h2><ul class="oc-convs">${mine.map(roomCard).jo
<ul class="oc-goto"><li><a href="/">Global chat</a>: everyone on OpenVibe, readable without an account</li>
<ul class="oc-goto"><li><a href="/">Global chat</a>: everyone on OpenVibe, readable without an account</li>
<li><a href="/servers">Servers</a>: channels, voice, roles and invite links, Discord-style</li>
<li><a href="/servers">Servers</a>: channels, voice, roles and invite links, Discord-style</li>
<li><a href="/messages">Messages</a>: direct messages with anyone on OpenVibe</li></ul></div>`}
<li><a href="/messages">Messages</a>: direct messages with anyone on OpenVibe</li></ul></div>`}
-${create}`,
+${create}
+<p class="oc-muted">Building something? A <a href="/feeds">feed</a> shows your coding session live in a room, on your Live channel or on your site.</p>`,
});
});
});
});
@@ -687,9 +688,11 @@ ${people}
@@ -687,9 +688,11 @@ ${people}
const isCall = room.kind === 'call';
const isCall = room.kind === 'call';
const callReady = isCall && !!require('../config').calls.enabled;
const callReady = isCall && !!require('../config').calls.enabled;
const ais = aiMembers.enabled() ? (await aiMembers.list(room)).filter((a) => Number(a.enabled)) : [];
const ais = aiMembers.enabled() ? (await aiMembers.list(room)).filter((a) => Number(a.enabled)) : [];
+ // A feed the owner shows here (server/feeds/): its card above the messages.
+ const feed = await require('../feeds/feeds').forRoom(room.id);
const aiHere = ais.length ? `<p class="oc-ai-here">${ais.map((a) => `<span class="oc-badge oc-ai">AI</span> <strong>${esc(a.name)}</strong> <span class="oc-muted oc-ai-model">(${esc(aiMembers.modelOf(a))})</span>`).join(' · ')} <span class="oc-muted">· write ${ais.map((a) => `<code>@${esc(a.handle)}</code>`).join(' or ')} to ask${ais.length > 1 ? ' one of them' : ''} something</span></p>` : '';
const aiHere = ais.length ? `<p class="oc-ai-here">${ais.map((a) => `<span class="oc-badge oc-ai">AI</span> <strong>${esc(a.name)}</strong> <span class="oc-muted oc-ai-model">(${esc(aiMembers.modelOf(a))})</span>`).join(' · ')} <span class="oc-muted">· write ${ais.map((a) => `<code>@${esc(a.handle)}</code>`).join(' or ')} to ask${ais.length > 1 ? ' one of them' : ''} something</span></p>` : '';
await page(req, res, 200, {
await page(req, res, 200, {
- actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
+ actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady, feedView: !!feed,
description: room.topic || `${room.name}, a ${isCall ? 'call' : room.kind === 'system' ? 'announcements' : 'chat'} room on OpenVibe.`,
description: room.topic || `${room.name}, a ${isCall ? 'call' : room.kind === 'system' ? 'announcements' : 'chat'} room on OpenVibe.`,
page: { view: 'room', room: room.slug, kind: room.kind, pics: pictures.enabled() ? pictures.publicUrl() : null, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate, post: can.post, role: can.role,
page: { view: 'room', room: room.slug, kind: room.kind, pics: pictures.enabled() ? pictures.publicUrl() : null, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate, post: can.post, role: can.role,
...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },
...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },
@@ -698,6 +701,7 @@ ${people}
@@ -698,6 +701,7 @@ ${people}
<p class="oc-muted">${room.topic ? `${esc(room.topic)} · ` : ''}${(await rooms.publicRoom(room)).members} members${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${yourRole}${manage} <span class="oc-live" id="oc-live" hidden>● live</span></p>
<p class="oc-muted">${room.topic ? `${esc(room.topic)} · ` : ''}${(await rooms.publicRoom(room)).members} members${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${yourRole}${manage} <span class="oc-live" id="oc-live" hidden>● live</span></p>
${aiHere}${error}
${aiHere}${error}
${isCall ? callPanel(room, can, me) : ''}
${isCall ? callPanel(room, can, me) : ''}
+${feed ? `<div class="fv oc-room-fv" data-feed="${esc(feed.id)}"></div>` : ''}
<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => roomMessage(m, can, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${room.kind === 'system' ? 'No announcements yet.' : 'No messages yet. Say hello.'}</li>`}</ol>
<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => roomMessage(m, can, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${room.kind === 'system' ? 'No announcements yet.' : 'No messages yet. Say hello.'}</li>`}</ol>
${composer}${tools}`,
${composer}${tools}`,
});
});
@@ -891,6 +895,7 @@ ${attachList}`,
@@ -891,6 +895,7 @@ ${attachList}`,
// ── Servers (server/rooms/servers.js): text and voice channels, roles, invites ──
// ── Servers (server/rooms/servers.js): text and voice channels, roles, invites ──
require('./vibe-pages').mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit, notice });
require('./vibe-pages').mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit, notice });
require('./inbox-pages').mountInboxPages(router, { page, viewerOf, needUser, sameSite, formLimit, notice, config });
require('./inbox-pages').mountInboxPages(router, { page, viewerOf, needUser, sameSite, formLimit, notice, config });
+ require('./feed-pages').mountFeedPages(router, { page, needUser, sameSite, formLimit, notice, config });
require('./server-pages').mountServerPages(router, { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, pictureComposer, config });
require('./server-pages').mountServerPages(router, { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, pictureComposer, config });
router.get('/updates', async (req, res) => await page(req, res, 200, {
router.get('/updates', async (req, res) => await page(req, res, 200, {
new file mode 100644
new file mode 100644
@@ -0,0 +1,181 @@
@@ -0,0 +1,181 @@
+'use strict';
+/**
+ * Feeds (server/feeds/, docs/platform.md step 2): a person makes a feed and gets its key once; a publisher posts events
+ * with the key (one, a batch, Live's envelopes, Claude Code's hook JSON as is); secrets and home folders are wiped on the
+ * way in; viewers get what the feed's settings let them see, after its delay, over HTTP and the socket; a feed on a Live
+ * channel slot reaches that slot's live chat as Live's vibe-coding frame; rooms, the embed and the pages show it.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+let feeds;
+
+const t = suite('feeds');
+const SITE = 'https://openvibe.chat';
+let h, ann, bob, feed, key, streamId;
+const api = (method, path, u, body, headers) => h.http(method, `/api/chat/feeds${path}`, { token: u && u.token, body, headers });
+const pub = (path, body, k = key) => fetch(`${h.base}/api/chat/feeds/${feed.id}${path}`, {
+ method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${k}` }, body: JSON.stringify(body),
+}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
+const view = async (q = '') => (await fetch(`${h.base}/api/chat/feeds/${feed.id}${q}`)).json();
+const patch = (settings, extra = {}) => api('PATCH', `/${feed.id}`, ann, { settings, ...extra });
+
+t('boot', async () => {
+ h = await boot({ env: { CHAT_WEB_URL: SITE } });
+ feeds = require('../server/feeds/feeds');
+ ann = h.addUser('ann', { subject: ids.newId('user') });
+ bob = h.addUser('bob', { subject: ids.newId('user') });
+ for (const u of [ann, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));
+ const channel = h.addChannel(ann.id);
+ streamId = h.addStream(ann.id, channel, { managed: { id: 77, title: 'Camp code', slug: 'camp-code' } });
+ await h.ctx.ensureStream(streamId);
+});
+
+t('a person makes a feed and gets its key once; others cannot touch it', async () => {
+ const r = await api('POST', '', ann, { title: 'Building Pixel Lamp' });
+ assert.strictEqual(r.status, 201, r.text);
+ feed = r.body.feed; key = r.body.key;
+ assert.ok(/^fed_[0-9A-HJKMNP-TV-Z]{26}$/.test(feed.id) && /^ovf_[A-Za-z0-9_-]{32}$/.test(key));
+ assert.strictEqual(feed.key_hint, key.slice(-4));
+ assert.ok(!('key_hash' in feed), 'the hash is never shown');
+ assert.deepStrictEqual((await api('GET', '/mine', ann)).body.feeds.map((f) => f.id), [feed.id]);
+ assert.strictEqual((await api('PATCH', `/${feed.id}`, bob, { title: 'Mine' })).status, 404);
+ assert.strictEqual((await api('PATCH', `/${feed.id}`, ann, { slot: 77 })).status, 200, 'her own slot');
+ assert.strictEqual((await api('POST', '', bob, { title: 'Sneaky', slot: 77 })).status, 403, 'not his slot');
+});
+
+t('publishing needs the key; secrets and home folders are wiped on the way in', async () => {
+ assert.strictEqual((await pub('/events', { eventType: 'prompt', text: 'hi' }, `ovf_${'x'.repeat(32)}`)).status, 401);
+ const r = await pub('/events', { eventType: 'prompt', text: 'Use sk-ant-api03-abcdefghijklmnopqrstuvwx and PASSWORD=hunter22 in /home/ann/pixel-lamp/.env' });
+ assert.strictEqual(r.status, 202, JSON.stringify(r.body));
+ assert.strictEqual(r.body.events[0].delivered, true);
+ const stored = await h.db.get('SELECT payload FROM feed_events WHERE feed_id = ?', [feed.id]);
+ const text = JSON.stringify(stored.payload);
+ assert.ok(!text.includes('sk-ant') && !text.includes('hunter22') && !text.includes('/home/ann'), text);
+ assert.ok(text.includes('[hidden]') && text.includes('~/pixel-lamp'));
+});
+
+t('a batch, Live\'s envelopes, and a repeated event delivered once; viewers see what the settings allow', async () => {
+ const r = await pub('/events', { events: [
+ { type: 'vibe-coding.event', event: { eventId: 'e-think', eventType: 'thinking', thinking: { text: 'Maybe the lamp needs a dimmer' } } },
+ { eventId: 'e-tool', eventType: 'tool.call', summary: 'Ran npm test in server/lamp', tool: { name: 'Bash', argumentsPreview: 'npm test' } },
+ { eventId: 'e-file', eventType: 'file.change', file: { relativePath: 'server/lamp/dimmer.js' } },
+ { eventId: 'e-file', eventType: 'file.change', file: { relativePath: 'server/lamp/dimmer.js' } },
+ { eventId: 'e-done', eventType: 'response', text: 'Added a dimmer.' },
+ ] });
+ assert.deepStrictEqual(r.body.events.map((e) => e.delivered), [false, true, true, false, true], 'thinking is off by default; the repeat is not delivered again');
+ const v = await view();
+ const byId = Object.fromEntries(v.events.map((e) => [e.eventId, e]));
+ assert.ok(!byId['e-think'], 'thinking hidden');
+ assert.strictEqual(byId['e-tool'].tool.argumentsPreview, null, 'what tools were given is off by default');
+ assert.strictEqual(byId['e-file'].file.relativePath, 'dimmer.js', 'file names only');
+ assert.strictEqual(byId['e-done'].response.text, 'Added a dimmer.');
+ assert.strictEqual(v.feed.enabled, true);
+ await patch({ show_prompts: 0, redact_file_paths: 0 });
+ const v2 = await view();
+ assert.ok(!v2.events.some((e) => e.eventType === 'prompt'), 'turning prompts off hides them in the history too');
+ assert.strictEqual(v2.events.find((e) => e.eventId === 'e-file').file.relativePath, 'server/lamp/dimmer.js');
+ await patch({ show_prompts: 1, redact_file_paths: 1 });
+});
+
+t('Claude Code\'s hooks, as they are: prompt, tools, files, the answer; never blocks it', async () => {
+ const base = { session_id: 'abc-123', cwd: '/home/ann/pixel-lamp', transcript_path: '/home/ann/.claude/x.jsonl' };
+ assert.deepStrictEqual((await pub('/hooks/claude-code', { ...base, hook_event_name: 'UserPromptSubmit', prompt: 'Make the lamp glow purple', prompt_id: 'p1' })).body, {});
+ await pub('/hooks/claude-code', { ...base, hook_event_name: 'PreToolUse', tool_name: 'Edit', tool_input: { file_path: '/home/ann/pixel-lamp/web/lamp.css' }, tool_use_id: 'tu0' });
+ await pub('/hooks/claude-code', { ...base, hook_event_name: 'PostToolUse', tool_name: 'Edit', tool_input: { file_path: '/home/ann/pixel-lamp/web/lamp.css', old_string: 'a', new_string: 'b' }, tool_response: { ok: true }, tool_use_id: 'tu1' });
+ await pub('/hooks/claude-code', { ...base, hook_event_name: 'Stop', last_assistant_message: 'The lamp glows purple now.' });
+ const mine = (await view('?limit=50')).events.filter((e) => e.publisher && e.publisher.integrationId === 'claude-code');
+ assert.deepStrictEqual(mine.map((e) => e.eventType), ['prompt', 'file.change', 'response'], 'PreToolUse is not shown; an edit is a file event');
+ assert.strictEqual(mine[0].prompt.text, 'Make the lamp glow purple');
+ assert.deepStrictEqual([mine[1].summary, mine[1].file.relativePath], ['Editing lamp.css', 'lamp.css'], 'the folder is not shown');
+ assert.strictEqual(mine[2].response.text, 'The lamp glows purple now.');
+ await pub('/hooks/claude-code', { ...base, hook_event_name: 'PostToolUse', tool_name: 'Bash', tool_input: { command: 'cat /home/ann/pixel-lamp/.env' }, tool_use_id: 'tu2' });
+ const ran = (await view('?limit=50')).events.pop();
+ assert.deepStrictEqual([ran.summary, ran.tool.argumentsPreview], ['Ran a command', null], 'what a command was is off by default');
+ assert.strictEqual((await pub('/hooks/claude-code', { hook_event_name: 'UserPromptSubmit', prompt: 'x' }, `ovf_${'y'.repeat(32)}`)).status, 401);
+});
+
+t('the delay is the server\'s: the history waits for it too', async () => {
+ await patch({ delay_ms: 60000 });
+ await pub('/events', { eventId: 'e-late', eventType: 'response', text: 'Not yet' });
+ assert.ok(!(await view()).events.some((e) => e.eventId === 'e-late'));
+ await patch({ delay_ms: 0 });
+ assert.ok((await view()).events.some((e) => e.eventId === 'e-late'));
+});
+
+t('the socket: watchers and the Live slot\'s chat hear new steps', async () => {
+ const watcher = await h.ws({});
+ watcher.sendJson({ type: 'feed_watch', feed: feed.id });
+ await watcher.next((m) => m.type === 'feed_watching');
+ const chat = await h.ws({ token: bob.token, stream: streamId });
+ await chat.next((m) => m.type === 'users-list');
+ await pub('/events', { eventId: 'e-live', eventType: 'tool.call', summary: 'Ran the lamp tests', tool: { name: 'Bash' } });
+ const got = await watcher.next((m) => m.type === 'feed_event' && m.event.eventId === 'e-live');
+ assert.strictEqual(got.feed, feed.id);
+ const frame = await chat.next((m) => m.type === 'vibe-coding' && m.event.eventId === 'e-live');
+ assert.deepStrictEqual([frame.managed_stream_id, frame.slot_slug], [77, 'camp-code']);
+ watcher.close(); chat.close();
+});
+
+t('Live\'s chat finds a channel\'s feeds; any site may read a feed (no cookies)', async () => {
+ const r = await fetch(`${h.base}/api/chat/feeds?channel=ann`, { headers: { origin: 'https://openvibe.live' } });
+ const list = (await r.json()).feeds;
+ assert.deepStrictEqual(list.map((f) => [f.id, f.managed_stream_id]), [[feed.id, 77]]);
+ const other = await fetch(`${h.base}/api/chat/feeds/${feed.id}`, { headers: { origin: 'https://elsewhere.example' } });
+ assert.strictEqual(other.status, 200);
+ assert.strictEqual(other.headers.get('access-control-allow-origin'), 'https://elsewhere.example');
+ assert.strictEqual(other.headers.get('access-control-allow-credentials'), null);
+ await patch({ paused: 1 });
+ assert.deepStrictEqual((await (await fetch(`${h.base}/api/chat/feeds?slot=77`)).json()).feeds, [], 'a paused feed is not listed');
+ assert.deepStrictEqual((await view()).events, [], 'nor shown');
+ await patch({ paused: 0 });
+});
+
+t('the pages: making a feed shows its key once, the feed page has the hooks, the embed, rooms show the card', async () => {
+ const page = async (path, u, opts = {}) => { const r = await fetch(`${h.base}${path}`, { redirect: 'manual', ...opts, headers: { cookie: `ov_token=${u.token}`, ...(opts.headers || {}) } }); return { status: r.status, text: await r.text(), headers: r.headers }; };
+ assert.ok((await page('/feeds', ann)).text.includes(`href="/feeds/${feed.id}"`));
+ const made = await page('/feeds', ann, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', origin: SITE }, body: 'title=Second+feed' });
+ assert.strictEqual(made.status, 201, made.text.slice(0, 300));
+ assert.ok(/ovf_[A-Za-z0-9_-]{32}/.test(made.text), 'the key, once');
+ const settings = await page(`/feeds/${feed.id}`, ann);
+ assert.ok(settings.text.includes('/hooks/claude-code') && settings.text.includes('OPENVIBE_FEED_KEY') && !/ovf_[A-Za-z0-9_-]{32}/.test(settings.text), 'the hooks, not the key');
+ assert.strictEqual((await page(`/feeds/${feed.id}`, bob)).status, 404);
+ const embed = await fetch(`${h.base}/embed/feed/${feed.id}`);
+ assert.ok(/frame-ancestors \*/.test(embed.headers.get('content-security-policy')) && (await embed.text()).includes(`data-feed="${feed.id}"`));
+ const room = (await h.http('POST', '/api/chat/rooms', { token: ann.token, body: { name: 'Pixel Lamp dev' } })).body.room;
+ await patch({}, { room: room.slug });
+ assert.ok((await page(`/r/${room.slug}`, bob)).text.includes(`class="fv oc-room-fv" data-feed="${feed.id}"`));
+ assert.strictEqual((await api('PATCH', `/${feed.id}`, bob, { room: room.slug })).status, 404);
+});
+
+t('Live\'s feed history comes over once: a feed per slot, its newest events, scrubbed, verified', async () => {
+ const fs = require('fs'); const os = require('os'); const path = require('path');
+ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'feeds-')), 'live.jsonl');
+ const rows = [
+ { kind: 'slot', managed_stream_id: 77, user_id: ann.id, title: 'Camp code', settings: { enabled: 0, widget_title: 'Night Bus Refactor', show_thinking: 1 } },
+ { kind: 'session', managed_stream_id: 77, session_key: 'copilot-1', publisher: { integrationId: 'github-copilot', integrationLabel: 'GitHub Copilot' }, status: 'ended', created_at: '2026-04-21 23:10:00' },
+ { kind: 'event', managed_stream_id: 77, session_key: 'copilot-1', event_id: 'old-1', seq: 1, type: 'prompt', summary: 'Refactor the bus', payload: JSON.stringify({ eventId: 'old-1', eventType: 'prompt', prompt: { text: 'Refactor with ghp_abcdefghijklmnopqrstuvwxyz0123' } }), created_at: '2026-04-21 23:10:01' },
+ { kind: 'event', managed_stream_id: 77, session_key: 'copilot-1', event_id: 'old-2', seq: 2, type: 'response', summary: 'Done', payload: JSON.stringify({ eventId: 'old-2', eventType: 'response', response: { text: 'Done' } }), created_at: '2026-04-21 23:11:00' },
+ ];
+ fs.writeFileSync(file, rows.map((r) => JSON.stringify(r)).join('\n'));
+ const { main } = require('../scripts/import-live-feeds');
+ const said = [];
+ assert.strictEqual(await main(['--file', file], (m) => said.push(m)), 0);
+ assert.ok(said.some((m) => m.includes('dry run')) && !(await h.db.get("SELECT 1 FROM feeds WHERE legacy_source = 'live:managed_stream:77'")));
+ assert.strictEqual(await main(['--file', file, '--apply'], (m) => said.push(m)), 0, said.join('\n'));
+ const copied = await h.db.get("SELECT * FROM feeds WHERE legacy_source = 'live:managed_stream:77'");
+ assert.deepStrictEqual([copied.title, Number(copied.owner_id), Number(copied.managed_stream_id), copied.key_hash], ['Night Bus Refactor', ann.id, 77, null]);
+ const events = await h.db.all('SELECT event_id, payload FROM feed_events WHERE feed_id = ? ORDER BY id', [copied.id]);
+ assert.deepStrictEqual(events.map((e) => e.event_id), ['old-1', 'old-2']);
+ assert.ok(!JSON.stringify(events[0].payload).includes('ghp_'), 'old events are scrubbed too');
+ assert.strictEqual(await main(['--file', file, '--apply'], () => {}), 0, 'a second run adds nothing');
+ assert.strictEqual((await h.db.get("SELECT count(*)::int AS n FROM feeds WHERE legacy_source = 'live:managed_stream:77'")).n, 1);
+});
+
+t('deleting a feed deletes what was published to it', async () => {
+ assert.strictEqual((await api('DELETE', `/${feed.id}`, ann)).status, 204);
+ assert.strictEqual((await h.db.get('SELECT count(*)::int AS n FROM feed_events WHERE feed_id = ?', [feed.id])).n, 0);
+ assert.strictEqual((await fetch(`${h.base}/api/chat/feeds/${feed.id}`)).status, 404);
+});
+
+t.run(async () => { if (h && h.close) await h.close(); });
@@ -27,7 +27,7 @@ const TABLES = [
@@ -27,7 +27,7 @@ const TABLES = [
'channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events',
'channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events',
'ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'service_outbox',
'ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'service_outbox',
'chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',
'chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',
- 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'vibe_usage', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',
+ 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'vibe_usage', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
];
];
const INDEXES = [
const INDEXES = [