openvibers/openvibe.chat Commits
Vibe answers through other services: by voice (chat.vibe.ask) and agent runs delivered to Vibe chats (chat.vibe.deliver) (#178)
Changes
nnew file mode 100644@@@ -0,0 +1,19 @@++-- phase: expand++-- Vibe answers a person through another OpenVibe service (capability chat.vibe.ask, server/chat/internal-vibe.js):++-- OpenVibe.Bot's Vibe by voice. Each source (a machine) has the person's own Vibe chat, "Voice on <machine>", marked by++-- rooms.assistant_source (kind:id); deleting the chat removes the mark with it, and the next question starts a new one.++ALTER TABLE rooms ADD COLUMN IF NOT EXISTS assistant_source text COLLATE "C";++CREATE UNIQUE INDEX IF NOT EXISTS rooms_assistant_source ON rooms (owner_id, assistant_source) WHERE assistant_source IS NOT NULL;++-- One answer per caller's idempotency key (a retried voice_ask gets the first answer, once), kept 7 days.++CREATE TABLE IF NOT EXISTS vibe_asks (++ idempotency_key text COLLATE "C" PRIMARY KEY,++ caller text COLLATE "C" NOT NULL,++ user_id bigint NOT NULL,++ room_id bigint,++ state text COLLATE "C" NOT NULL CHECK (state IN ('answered', 'refused')),++ answer text NOT NULL DEFAULT '',++ code text COLLATE "C",++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now()++);++CREATE INDEX IF NOT EXISTS vibe_asks_by_user ON vibe_asks (user_id);++CREATE INDEX IF NOT EXISTS vibe_asks_created ON vibe_asks (created_at);nnew file mode 100644@@@ -0,0 +1,14 @@++-- phase: expand++-- A first-party service's results in a person's own Vibe chats (capability chat.vibe.deliver; server/chat/internal-vibe.js):++-- OpenVibe.Actor posts each passed run of one of their agents into the chat named after it (rooms.assistant_source++-- 'agent:<id>', migrations/0033_vibe_asks.sql). One post per caller's idempotency key (the run's task id), kept 30 days.++CREATE TABLE IF NOT EXISTS vibe_deliveries (++ idempotency_key text COLLATE "C" PRIMARY KEY,++ caller text COLLATE "C" NOT NULL,++ user_id bigint NOT NULL,++ room_id bigint NOT NULL,++ message_id bigint,++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now()++);++CREATE INDEX IF NOT EXISTS vibe_deliveries_by_user ON vibe_deliveries (user_id);++CREATE INDEX IF NOT EXISTS vibe_deliveries_created ON vibe_deliveries (created_at);@@@ -95,6 +95,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {app.use('/internal/chat', express.json({ limit: '64kb' }), require('./chat/internal-reads'));// OpenVibe.Help's per-ticket conversation (capability chat.ticket.write, server/chat/internal-tickets.js).app.use('/internal/chat', express.json({ limit: '64kb' }), require('./chat/internal-tickets'));++ // Vibe answers a person through another service (Bot's Vibe by voice; capability chat.vibe.ask).++ app.use('/internal/chat', express.json({ limit: '64kb' }), require('./chat/internal-vibe'));// OpenVibe.Events deliveries (live.release.deployed, network.module.updated): signature v2 over// the raw body, so this router reads the body itself (server/events/consumer.js).if (events) app.use('/internal/events', events.router);@@@ -67,6 +67,10 @@ const EXPORTS = [// What Vibe remembers about them (server/rooms/vibe-memory.js), and whether they switched it off.['vibe_memory.json', `SELECT id, text, source, created_at FROM vibe_memory WHERE user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],['vibe_memory_off.json', `SELECT since FROM vibe_memory_off WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],++ // What they asked Vibe by voice on their machines, the last 7 days (server/chat/internal-vibe.js).++ // Which of their agents' runs were posted into their Vibe chats (server/chat/internal-vibe.js).++ ['vibe_deliveries.json', `SELECT room_id, message_id, created_at FROM vibe_deliveries WHERE user_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],++ ['vibe_asks.json', `SELECT idempotency_key, room_id, state, answer, code, created_at FROM vibe_asks WHERE user_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_replies.json', `SELECT * FROM inbox_messages WHERE author = 'agent' AND author_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],@@@ -117,6 +121,8 @@ const ERASE_AFTER_ROOMS = [['vibe_usage', 'DELETE FROM vibe_usage WHERE user_id = ANY(@ids)'],['vibe_memory', 'DELETE FROM vibe_memory WHERE user_id = ANY(@ids)'],['vibe_memory_off', 'DELETE FROM vibe_memory_off WHERE user_id = ANY(@ids)'],++ ['vibe_asks', 'DELETE FROM vibe_asks WHERE user_id = ANY(@ids)'],++ ['vibe_deliveries', 'DELETE FROM vibe_deliveries WHERE user_id = ANY(@ids)'],// Support inboxes (server/inbox/inbox.js): the inboxes they own go with every conversation in them; in others,// their replies stay for the visitor without their name, and their place on the team goes.[null, 'DELETE FROM inbox_translations WHERE message_id IN (SELECT m.id FROM inbox_messages m JOIN inbox_conversations c ON c.id = m.conversation_id JOIN inboxes i ON i.id = c.inbox_id WHERE i.owner_subject = ANY(@s) OR i.owner_id = ANY(@ids))'],nnew file mode 100644@@@ -0,0 +1,198 @@++'use strict';++/**++ * Vibe answers a person through another OpenVibe service (capability chat.vibe.ask; openvibe-contracts 0.214.0++ * chat.vibe-ask-request@1 / chat.vibe-ask-result@1). OpenVibe.Bot's Vibe by voice hands over what a person said after++ * their machine's call word, with the machine's owner as the subject (Bot checks the owner turned voice on for it).++ * Loopback only, a service token for audience openvibe.chat.++ *++ * POST /internal/chat/vibe/ask { subject, text, source: { kind: 'voice', id, name? }, lang?, idempotency_key? }++ * → 200 { state: 'answered', text, url, chat } | { state: 'refused', text, code }++ *++ * The question and the answer are kept in the person's own Vibe chats: one per source, "Voice on <name>", made on first++ * use and marked by rooms.assistant_source (migrations/0033_vibe_asks.sql; deleting the chat removes the mark, and the++ * next question starts a new one). The answer counts against the person's daily allowance like any of theirs, and comes++ * back as plain text to speak (Markdown taken off). The same idempotency key from the same caller answers once: asked++ * again, the first answer (kept 7 days). A person Chat has not seen, Vibe resting or the allowance spent: refused, with why.++ *++ * POST /internal/chat/vibe/deliver { subject, source: { kind: 'agent', id, name? }, text, url?, idempotency_key }++ * → 201 { chat, url, repeated: false } | 200 { chat, url, repeated: true }++ *++ * Each run is a chat you can open (capability chat.vibe.deliver; chat.vibe-deliver-request@1 / -result@1): OpenVibe.Actor++ * posts each passed run of one of the person's agents, as Vibe's message, into their Vibe chat named after the agent++ * (one per agent, marked like the voice chats), with a link to the whole run. No AI answer is made and nothing counts++ * against their allowance; they can reply in the chat and Vibe answers knowing the result. One post per caller's key++ * (vibe_deliveries, kept 30 days). A person Chat has not seen is 422. They get one OpenVibe notification per post++ * ("<agent> has a new result", category chat, which they can switch off on the Network), sent after the answer.++ */++const express = require('express');++const config = require('../config');++const db = require('../db/database');++const serviceAuth = require('../net/service-auth');++const vibe = require('../rooms/vibe');++const rooms = require('../rooms/rooms');++const aiMembers = require('../rooms/ai-members');++++const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const SOURCE_ID = /^[A-Za-z0-9_.:-]{1,64}$/;++const KEY = /^[A-Za-z0-9_.:-]{1,128}$/;++const KEEP_MS = 7 * 24 * 3600 * 1000;++const fail = (status, message) => { const e = new Error(message); e.status = status; throw e; };++const sqlTime = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');++++/** The request's fields, checked as chat.vibe-ask-request@1 says (Chat's own check until it pins the schema). */++function requestOf(b) {++ if (!b || typeof b !== 'object' || Array.isArray(b)) fail(400, 'A JSON object is required');++ if (Object.keys(b).some((k) => !['subject', 'text', 'source', 'lang', 'idempotency_key'].includes(k))) fail(400, 'Invalid fields');++ if (!SUBJECT.test(String(b.subject || ''))) fail(400, 'subject is a person (usr_…)');++ const text = typeof b.text === 'string' ? b.text.replace(/\r\n?/g, '\n').trim() : '';++ if (!text || text.length > 2000) fail(400, 'text is 1 to 2000 characters');++ const s = b.source;++ if (!s || typeof s !== 'object' || s.kind !== 'voice' || !SOURCE_ID.test(String(s.id || '')) || Object.keys(s).some((k) => !['kind', 'id', 'name'].includes(k))) fail(400, 'source is { kind: voice, id, name? }');++ if (b.idempotency_key != null && !KEY.test(String(b.idempotency_key))) fail(400, 'idempotency_key is 1 to 128 of A-Z a-z 0-9 _ . : -');++ const name = typeof s.name === 'string' ? s.name.replace(/\s+/g, ' ').trim().slice(0, 40) : '';++ return { subject: b.subject, text, source: { kind: 'voice', id: s.id, name }, key: b.idempotency_key || null };++}++++/** Markdown off, for a voice to read: links keep their text, code and emphasis marks go, lists become sentences. */++function spoken(md) {++ return String(md || '')++ .replace(/```[\s\S]*?```/g, ' ')++ .replace(/!\[[^\]]*\]\([^)]*\)/g, '')++ .replace(/\[([^\]]+)\]\((?:https?:)?[^)]*\)/g, '$1')++ .replace(/`([^`]*)`/g, '$1')++ .replace(/(\*\*|__|\*|_|~~)(\S[\s\S]*?\S|\S)\1/g, '$2')++ .replace(/^\s{0,3}#{1,6}\s+/gm, '')++ .replace(/^\s*[-*+]\s+/gm, '')++ .replace(/^\s*>\s?/gm, '')++ .replace(/https?:\/\/\S+/g, '')++ .replace(/[ \t]+\n/g, '\n').replace(/\n{2,}/g, '\n').trim()++ .slice(0, 4000);++}++++/** The person's chat for this source: the marked one when it is still theirs, else a new one ("Voice on <name>", or the agent's name). */++async function chatFor(user, source) {++ const mark = `${source.kind}:${source.id}`;++ const room = await db.get('SELECT * FROM rooms WHERE owner_id = ? AND assistant = 1 AND assistant_source = ? AND archived_at IS NULL', [user.id, mark]);++ if (room) return room;++ const made = await vibe.createRoom(user, source.kind === 'agent' ? (source.name || 'Your agent') : `Voice on ${source.name || 'your computer'}`);++ await db.run('UPDATE rooms SET assistant_source = ? WHERE id = ?', [mark, made.id]);++ return { ...made, assistant_source: mark };++}++++async function ask(req) {++ const r = requestOf(req.body);++ const caller = String((req.principal && req.principal.sub) || '');++ const key = r.key ? `${caller}|${r.key}` : null;++ if (key) {++ const seen = await db.get('SELECT * FROM vibe_asks WHERE idempotency_key = ?', [key]);++ if (seen) return seen.state === 'answered' ? answered(seen.answer, seen.room_id) : refused(seen.answer, seen.code);++ }++ const user = await db.get('SELECT * FROM ctx_users WHERE subject_id = ? ORDER BY id LIMIT 1', [r.subject]);++ let out;++ let roomId = null;++ if (!user || Number(user.is_banned)) out = refused('This account cannot use Vibe yet: sign in on openvibe.chat once first.', 'chat.vibe.unknown_person');++ else if (!vibe.enabled()) out = refused('Vibe is resting right now. Try again later.', 'chat.vibe.off');++ else if (await vibe.repliesToday(user) >= config.ai.vibeRepliesPerDay) out = refused(`You have used today's ${config.ai.vibeRepliesPerDay} free answers. They come back at midnight UTC.`, 'chat.vibe.allowance');++ else {++ const room = await chatFor(user, r.source);++ roomId = Number(room.id);++ const message = await rooms.post(room, user, r.text, { ai: false });++ try { require('./chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message }); } catch { /* sockets are optional */ }++ const reply = await aiMembers.afterPost(room, message, user);++ out = reply && reply.message ? answered(spoken(reply.message), roomId, room.slug) : refused('Vibe could not answer just now. Try again in a minute.', 'chat.vibe.unavailable');++ }++ if (key) {++ if (Math.random() < 0.05) await db.run('DELETE FROM vibe_asks WHERE created_at < ?', [sqlTime(Date.now() - KEEP_MS)]);++ await db.run(`INSERT INTO vibe_asks (idempotency_key, caller, user_id, room_id, state, answer, code) VALUES (?, ?, ?, ?, ?, ?, ?)++ ON CONFLICT (idempotency_key) DO NOTHING`, [key, caller, user ? user.id : 0, roomId, out.state, out.text, out.code || null]);++ }++ return out;++}++++function answered(text, roomId, slug = null) {++ return { state: 'answered', text: String(text || '').slice(0, 4000), ...(slug ? { url: `${config.web.baseUrl}/vibe/${slug}`, chat: slug } : {}), ...(roomId && !slug ? { room_id: roomId } : {}) };++}++const refused = (text, code) => ({ state: 'refused', text, code });++++/** chat.vibe-deliver-request@1, checked as the schema says. */++function deliveryOf(b) {++ if (!b || typeof b !== 'object' || Array.isArray(b)) fail(400, 'A JSON object is required');++ if (Object.keys(b).some((k) => !['subject', 'source', 'text', 'url', 'idempotency_key'].includes(k))) fail(400, 'Invalid fields');++ if (!SUBJECT.test(String(b.subject || ''))) fail(400, 'subject is a person (usr_…)');++ const s = b.source;++ if (!s || typeof s !== 'object' || s.kind !== 'agent' || !SOURCE_ID.test(String(s.id || '')) || Object.keys(s).some((k) => !['kind', 'id', 'name'].includes(k))) fail(400, 'source is { kind: agent, id, name? }');++ const text = typeof b.text === 'string' ? b.text.replace(/\r\n?/g, '\n').trim() : '';++ if (!text || text.length > 8000) fail(400, 'text is 1 to 8000 characters');++ let url = null;++ if (b.url != null) {++ try { const u = new URL(String(b.url)); if (u.protocol === 'https:') url = u.href; } catch { /* checked below */ }++ if (!url) fail(400, 'url is an https address');++ }++ if (!KEY.test(String(b.idempotency_key || ''))) fail(400, 'idempotency_key is 1 to 128 of A-Z a-z 0-9 _ . : -');++ const name = typeof s.name === 'string' ? s.name.replace(/\s+/g, ' ').trim().slice(0, 40) : '';++ return { subject: b.subject, source: { kind: 'agent', id: s.id, name }, text, url, key: b.idempotency_key };++}++++const chatUrl = (slug) => `${config.web.baseUrl}/vibe/${slug}`;++++/** "<agent> has a new result", with its first line, to the chat (never holds up the answer to the caller). */++async function notifyResult(user, room, d) {++ if (!user.subject_id || (!config.oauth.clientSecret && !require('../inbox/notify')._hasTestPush())) return;++ const name = d.source.name || 'Your agent';++ await require('../inbox/notify').pushOne({++ subjectId: user.subject_id, type: 'AGENT_RESULT', category: 'chat', service: 'chat', icon: '🤖', sender_name: name, priority: 'normal',++ title: `${name} has a new result`, message: spoken(d.text).split('\n')[0].slice(0, 160) || 'Open the chat to read it.', url: chatUrl(room.slug),++ });++}++async function deliver(req) {++ const d = deliveryOf(req.body);++ const caller = String((req.principal && req.principal.sub) || '');++ const key = `${caller}|${d.key}`;++ const seen = await db.get('SELECT d.room_id, r.slug FROM vibe_deliveries d LEFT JOIN rooms r ON r.id = d.room_id WHERE d.idempotency_key = ?', [key]);++ if (seen && seen.slug) return { status: 200, body: { chat: seen.slug, url: chatUrl(seen.slug), repeated: true } };++ const user = await db.get('SELECT * FROM ctx_users WHERE subject_id = ? ORDER BY id LIMIT 1', [d.subject]);++ if (!user || Number(user.is_banned)) fail(422, 'This account has no Vibe chats yet: they sign in on openvibe.chat once first');++ const room = await chatFor(user, d.source);++ const member = await db.get("SELECT * FROM room_ai_members WHERE room_id = ? AND handle = 'vibe'", [room.id]);++ if (!member) fail(409, 'That Vibe chat has no Vibe in it');++ const link = d.url ? `\n\n[See the whole run on OpenVibe Agents](${d.url})` : '';++ const message = await rooms.postAsAi(room, member, `${d.text.slice(0, 8000 - link.length)}${link}`);++ try { require('./chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message }); } catch { /* sockets are optional */ }++ if (Math.random() < 0.05) await db.run('DELETE FROM vibe_deliveries WHERE created_at < ?', [sqlTime(Date.now() - 30 * 24 * 3600 * 1000)]);++ await db.run('INSERT INTO vibe_deliveries (idempotency_key, caller, user_id, room_id, message_id) VALUES (?, ?, ?, ?, ?) ON CONFLICT (idempotency_key) DO NOTHING',++ [key, caller, user.id, room.id, message ? message.id : null]);++ notifyResult(user, room, d).catch((err) => console.warn(`[Vibe deliver] notify: ${err && err.message}`));++ return { status: 201, body: { chat: room.slug, url: chatUrl(room.slug), repeated: false } };++}++++const router = express.Router();++router.post('/vibe/deliver', serviceAuth.guard('chat.vibe.deliver'), async (req, res) => {++ try {++ const out = await deliver(req);++ res.status(out.status).json(out.body);++ } catch (err) {++ const status = err && err.status && err.status < 500 ? err.status : 500;++ if (status >= 500) console.error('[Vibe deliver]', err && err.message);++ res.status(status).json({ error: status >= 500 ? 'Something went wrong' : err.message, code: err && err.code });++ }++});++++router.post('/vibe/ask', serviceAuth.guard('chat.vibe.ask'), async (req, res) => {++ try {++ const out = await ask(req);++ // A replayed answer names its chat by id only: look the slug up so the url is there too.++ if (out.room_id) {++ const room = await db.get('SELECT slug FROM rooms WHERE id = ?', [out.room_id]);++ delete out.room_id;++ if (room) Object.assign(out, { url: `${config.web.baseUrl}/vibe/${room.slug}`, chat: room.slug });++ }++ res.json(out);++ } catch (err) {++ const status = err && err.status && err.status < 500 ? err.status : (err && err.code && String(err.code).startsWith('rooms.') ? 422 : 500);++ if (status >= 500) console.error('[Vibe ask]', err && err.message);++ res.status(status).json({ error: status >= 500 ? 'Something went wrong' : err.message, code: err && err.code });++ }++});++++module.exports = router;++module.exports.spoken = spoken;@@@ -78,4 +78,8 @@ function later(conv, reason) { people(conv, reason).catch((err) => console.warn(/** Tests: capture pushes instead of calling the Network (null restores it). */function _setPush(fn) { _testPush = fn; lastSent.clear(); }--module.exports = { people, later, QUIET_MS, _setPush };++/** One notification to one person (subjectId): the push itself, for other Chat features (internal-vibe.js deliver). */++const pushOne = (notification) => (_testPush || push)(notification);++const _hasTestPush = () => !!_testPush;++++module.exports = { people, later, pushOne, QUIET_MS, _setPush, _hasTestPush };@@@ -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', 'room_tasks', 'room_hooks', 'vibe_usage', 'vibe_memory', 'vibe_memory_off', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_actions', 'inbox_translations', 'feeds', 'feed_sessions', 'feed_events', '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', 'room_tasks', 'room_hooks', 'vibe_usage', 'vibe_memory', 'vibe_memory_off', 'vibe_asks', 'vibe_deliveries', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_actions', 'inbox_translations', '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 = [nnew file mode 100644@@@ -0,0 +1,96 @@++'use strict';++/**++ * Vibe answers a person through another service (server/chat/internal-vibe.js, capability chat.vibe.ask, contracts++ * 0.214.0): Bot's Vibe by voice. The question and Vibe's answer land in the person's own "Voice on <machine>" chat (one++ * per machine), the answer comes back as plain text to speak, it counts against their allowance, the same key answers++ * once, and a person Chat has not seen or the allowance spent is refused with why. Only a service token++ * holding the capability, from loopback, reaches it.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('vibe ask');++let h, ann, config, vibe;++const BOT = (cap = 'chat.vibe.ask') => h.serviceToken([cap], { sub: 'svc:bot' });++const DEVICE = 'dev_01JABCDEFGHJKMNPQRSTVWXYZ0';++const ask = (body, token = BOT(), headers = {}) => h.http('POST', '/internal/chat/vibe/ask', { token, body, headers });++const calls = [];++++t('boot', async () => {++ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } });++ config = require('../server/config');++ vibe = require('../server/rooms/vibe');++ ann = h.addUser('ann', { subject: ids.newId('user') });++ await h.ctx.upsertUser(h.live.users.get(ann.id));++ require('../server/ai/client')._setClient({++ runs: {++ create: async (workflow, input) => {++ calls.push({ workflow, input });++ return { run: { status: 'succeeded', output: { choices: [{ message: { content: '**Clear skies** after six. See [the forecast](https://example.org/w) for more:\n\n- around 12 °C\n- light wind' } }] } } };++ },++ },++ });++});++++t('a question from a machine: kept in "Voice on <machine>", answered as plain text, counted', async () => {++ const subject = h.live.users.get(ann.id).subject_id;++ const before = await vibe.repliesToday({ id: ann.id });++ const r = await ask({ subject, text: 'what is the weather like this evening', source: { kind: 'voice', id: DEVICE, name: 'desk-pc' }, lang: 'en-US', idempotency_key: 'va-001' });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.state, 'answered');++ assert.strictEqual(r.body.text, 'Clear skies after six. See the forecast for more:\naround 12 °C\nlight wind', 'Markdown and links off, for speech');++ assert.match(r.body.url, /^https:\/\/openvibe\.chat\/vibe\/v-[0-9a-f]{12}$/);++ const room = await h.db.get('SELECT * FROM rooms WHERE slug = ?', [r.body.chat]);++ assert.deepStrictEqual([room.name, room.assistant_source, Number(room.owner_id)], ['Voice on desk-pc', `voice:${DEVICE}`, ann.id]);++ const msgs = await h.db.all('SELECT message, ai_member_id FROM room_messages WHERE room_id = ? ORDER BY id', [room.id]);++ assert.deepStrictEqual(msgs.map((m) => (m.ai_member_id ? 'vibe' : m.message)), ['what is the weather like this evening', 'vibe'], 'the question and the answer, in their chat');++ assert.strictEqual(await vibe.repliesToday({ id: ann.id }), before + 1, 'it counts against their allowance');++ // A second question from the same machine goes to the same chat; another machine gets its own.++ const again = await ask({ subject, text: 'and tomorrow?', source: { kind: 'voice', id: DEVICE, name: 'desk-pc' }, idempotency_key: 'va-002' });++ assert.strictEqual(again.body.chat, r.body.chat);++ const other = await ask({ subject, text: 'hello', source: { kind: 'voice', id: 'dev_01JABCDEFGHJKMNPQRSTVWXYZ1', name: 'robot' } });++ assert.notStrictEqual(other.body.chat, r.body.chat);++});++++t('the same key answers once: asked again, the first answer and no second model call', async () => {++ const subject = h.live.users.get(ann.id).subject_id;++ const before = calls.length;++ const r = await ask({ subject, text: 'what is the weather like this evening', source: { kind: 'voice', id: DEVICE, name: 'desk-pc' }, idempotency_key: 'va-001' });++ assert.strictEqual(r.body.state, 'answered');++ assert.match(r.body.url, /\/vibe\/v-/);++ assert.strictEqual(calls.length, before, 'no model call');++});++++t('refused, with why: a person Chat has not seen, the allowance spent', async () => {++ const subject = h.live.users.get(ann.id).subject_id;++ const unknown = await ask({ subject: ids.newId('user'), text: 'hi', source: { kind: 'voice', id: DEVICE } });++ assert.deepStrictEqual([unknown.status, unknown.body.state, unknown.body.code], [200, 'refused', 'chat.vibe.unknown_person']);++ const keep = config.ai.vibeRepliesPerDay;++ config.ai.vibeRepliesPerDay = await vibe.repliesToday({ id: ann.id });++ try {++ const spent = await ask({ subject, text: 'one more', source: { kind: 'voice', id: DEVICE } });++ assert.deepStrictEqual([spent.body.state, spent.body.code], ['refused', 'chat.vibe.allowance']);++ assert.match(spent.body.text, /free answers/);++ } finally { config.ai.vibeRepliesPerDay = keep; }++});++++t('only a service token with chat.vibe.ask, from loopback; bad bodies are refused', async () => {++ const subject = h.live.users.get(ann.id).subject_id;++ const body = { subject, text: 'hi', source: { kind: 'voice', id: DEVICE } };++ assert.strictEqual((await ask(body, null)).status, 401);++ assert.strictEqual((await ask(body, BOT('chat.ticket.write'))).status, 403, 'another capability');++ assert.strictEqual((await ask(body, BOT(), { 'x-forwarded-for': '203.0.113.9' })).status, 403, 'not through the proxy');++ for (const bad of [{ ...body, subject: 'ann' }, { ...body, text: '' }, { ...body, source: { kind: 'chat', id: DEVICE } }, { ...body, extra: 1 }]) {++ assert.strictEqual((await ask(bad)).status, 400, JSON.stringify(bad));++ }++});++++t('account export and deletion include what they asked by voice', async () => {++ const ad = require('../server/chat/account-data');++ const src = require('fs').readFileSync(require.resolve('../server/chat/account-data'), 'utf8');++ assert.ok(src.includes("['vibe_asks.json'") && src.includes("['vibe_asks', 'DELETE FROM vibe_asks"), 'listed in both');++ assert.ok(ad, 'module loads');++});++++t.run(async () => { require('../server/ai/client')._setClient(null); if (h && h.close) await h.close(); });nnew file mode 100644@@@ -0,0 +1,102 @@++'use strict';++/**++ * Each run is a chat you can open (server/chat/internal-vibe.js POST /internal/chat/vibe/deliver, capability++ * chat.vibe.deliver, contracts 0.214.0; migrations/0034_vibe_deliveries.sql): Actor posts an agent's run into the++ * person's Vibe chat named after the agent, as Vibe's message with a link to the whole run; the next run of the same++ * agent goes to the same chat, another agent gets its own; the same key posts once; no AI answer and nothing counted; the++ * person replies and Vibe answers seeing the result; a person Chat has not seen is 422; only a service token with the++ * capability reaches it; export and deletion carry the rows.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('vibe deliver');++let h, ann, vibe;++const ACTOR = (cap = 'chat.vibe.deliver') => h.serviceToken([cap], { sub: 'svc:actor' });++const AGENT = 'agt_01JABCDEFGHJKMNPQRSTVWXYZ0';++const send = (body, token = ACTOR()) => h.http('POST', '/internal/chat/vibe/deliver', { token, body });++const calls = [];++const pushes = [];++let subject;++const run = (n, extra = {}) => ({ subject, source: { kind: 'agent', id: AGENT, name: 'Remote jobs' }, text: `**${n} new jobs** today:\n\n- [Editor](https://example.com/jobs/${n})`, url: `https://openvibe.actor/tasks/tsk_run${n}`, idempotency_key: `tsk_run${n}`, ...extra });++++t('boot', async () => {++ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } });++ vibe = require('../server/rooms/vibe');++ ann = h.addUser('ann', { subject: ids.newId('user') });++ await h.ctx.upsertUser(h.live.users.get(ann.id));++ subject = h.live.users.get(ann.id).subject_id;++ require('../server/inbox/notify')._setPush(async (n) => { pushes.push(n); return { sent: true }; });++ require('../server/ai/client')._setClient({++ runs: { create: async (workflow, input) => { calls.push({ workflow, input }); return { run: { status: 'succeeded', output: { choices: [{ message: { content: 'The Editor one pays best.' } }] } } }; } },++ });++});++++t('a run lands in the chat named after its agent, as Vibe, with the link; no AI answer and nothing counted', async () => {++ const before = await vibe.repliesToday({ id: ann.id });++ const r = await send(run(3));++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.body.repeated, false);++ assert.match(r.body.url, /^https:\/\/openvibe\.chat\/vibe\/v-[0-9a-f]{12}$/);++ const room = await h.db.get('SELECT * FROM rooms WHERE slug = ?', [r.body.chat]);++ assert.deepStrictEqual([room.name, room.assistant_source, Number(room.owner_id), Number(room.assistant)], ['Remote jobs', `agent:${AGENT}`, ann.id, 1]);++ const msgs = await h.db.all('SELECT message, ai_member_id FROM room_messages WHERE room_id = ? ORDER BY id', [room.id]);++ assert.strictEqual(msgs.length, 1);++ assert.ok(msgs[0].ai_member_id, 'Vibe\'s message');++ assert.strictEqual(msgs[0].message, '**3 new jobs** today:\n\n- [Editor](https://example.com/jobs/3)\n\n[See the whole run on OpenVibe Agents](https://openvibe.actor/tasks/tsk_run3)');++ assert.strictEqual(calls.length, 0, 'no AI answer');++ assert.strictEqual(await vibe.repliesToday({ id: ann.id }), before, 'nothing counted');++ assert.ok((await vibe.list({ id: ann.id })).some((c) => c.slug === r.body.chat), 'listed with their chats');++ for (let i = 0; i < 40 && !pushes.length; i++) await new Promise((res) => setTimeout(res, 25));++ assert.deepStrictEqual(pushes.map((n) => [n.subjectId, n.type, n.category, n.title, n.message, n.url]),++ [[subject, 'AGENT_RESULT', 'chat', 'Remote jobs has a new result', '3 new jobs today:', r.body.url]], 'one notification, to the chat');++});++++t('the next run goes to the same chat; the same key posts once; another agent gets its own chat', async () => {++ const first = await send(run(3));++ assert.deepStrictEqual([first.status, first.body.repeated], [200, true]);++ await new Promise((res) => setTimeout(res, 50));++ assert.strictEqual(pushes.length, 1, 'a repeat tells nobody again');++ const next = await send(run(4));++ assert.strictEqual(next.status, 201);++ const room = await h.db.get('SELECT id FROM rooms WHERE slug = ?', [next.body.chat]);++ assert.strictEqual(Number((await h.db.get('SELECT count(*) AS n FROM room_messages WHERE room_id = ?', [room.id])).n), 2, 'one per run');++ const other = await send(run(5, { source: { kind: 'agent', id: 'agt_01JABCDEFGHJKMNPQRSTVWXYZ1', name: 'Price watch' } }));++ assert.notStrictEqual(other.body.chat, next.body.chat);++ assert.strictEqual((await h.db.get('SELECT name FROM rooms WHERE slug = ?', [other.body.chat])).name, 'Price watch');++});++++t('the person replies in the chat and Vibe answers seeing the result', async () => {++ const room = await h.db.get('SELECT * FROM rooms WHERE assistant_source = ?', [`agent:${AGENT}`]);++ const r = await h.http('POST', `/api/chat/rooms/${room.slug}/messages`, { token: ann.token, body: { message: 'Which one pays best?' } });++ assert.ok(r.status < 300, r.text);++ for (let i = 0; i < 80 && !calls.length; i++) await new Promise((res) => setTimeout(res, 25));++ assert.ok(calls.length, 'Vibe answered');++ const prompt = JSON.stringify(calls[0].input);++ assert.ok(prompt.includes('4 new jobs') && prompt.includes('Which one pays best?'), 'the result is in what Vibe reads');++});++++t('refused: bad bodies, a person Chat has not seen, a token without the capability', async () => {++ assert.strictEqual((await send(run(6, { source: { kind: 'voice', id: 'dev_1' } }))).status, 400);++ assert.strictEqual((await send(run(6, { url: 'http://example.com/x' }))).status, 400);++ assert.strictEqual((await send({ ...run(6), idempotency_key: undefined })).status, 400);++ assert.strictEqual((await send(run(6, { text: 'x'.repeat(8001) }))).status, 400);++ assert.strictEqual((await send(run(6, { subject: ids.newId('user') }))).status, 422);++ assert.strictEqual((await send(run(6), ACTOR('chat.vibe.ask'))).status, 403);++ assert.strictEqual((await h.http('POST', '/internal/chat/vibe/deliver', { body: run(6) })).status, 401);++});++++t('account export and deletion include the posted runs', async () => {++ const accountData = require('../server/chat/account-data');++ const sent = [];++ const ev = (type, payload) => ({ event_id: ids.newId('event'), event_type: type, version: 1, source: 'network', actor: { type: 'user', id: subject },++ timestamp: new Date().toISOString(), visibility: 'internal', subject: { type: 'user', id: subject }, payload });++ assert.strictEqual(await accountData.apply(ev('network.account.export_requested', { export_id: `exp_${ids.ulid()}`, subject, requested_at: new Date().toISOString(), deadline: new Date(Date.now() + 1800000).toISOString() }), { send: async (p, body) => { sent.push(body); return { ok: true, status: 200 }; } }), 'exported');++ const files = Object.fromEntries(sent[0].files.map((f) => [f.name, f.content]));++ assert.strictEqual(files['vibe_deliveries.json'].length, 3);++ assert.strictEqual(await accountData.apply(ev('network.account.deleted', { deletion_id: `del_${ids.ulid()}`, subject, aliases: [], requested_at: new Date().toISOString(), deleted_at: new Date().toISOString() }), { send: async () => ({ ok: true, status: 200 }) }), 'erased');++ assert.strictEqual(await h.db.get('SELECT 1 FROM vibe_deliveries WHERE user_id = ?', [ann.id]), undefined);++});++++t.run(async () => { require('../server/ai/client')._setClient(null); require('../server/inbox/notify')._setPush(null); if (h && h.close) await h.close(); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+-- phase: expand
+-- Vibe answers a person through another OpenVibe service (capability chat.vibe.ask, server/chat/internal-vibe.js):
+-- OpenVibe.Bot's Vibe by voice. Each source (a machine) has the person's own Vibe chat, "Voice on <machine>", marked by
+-- rooms.assistant_source (kind:id); deleting the chat removes the mark with it, and the next question starts a new one.
+ALTER TABLE rooms ADD COLUMN IF NOT EXISTS assistant_source text COLLATE "C";
+CREATE UNIQUE INDEX IF NOT EXISTS rooms_assistant_source ON rooms (owner_id, assistant_source) WHERE assistant_source IS NOT NULL;
+-- One answer per caller's idempotency key (a retried voice_ask gets the first answer, once), kept 7 days.
+CREATE TABLE IF NOT EXISTS vibe_asks (
+ idempotency_key text COLLATE "C" PRIMARY KEY,
+ caller text COLLATE "C" NOT NULL,
+ user_id bigint NOT NULL,
+ room_id bigint,
+ state text COLLATE "C" NOT NULL CHECK (state IN ('answered', 'refused')),
+ answer text NOT NULL DEFAULT '',
+ code text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now()
+);
+CREATE INDEX IF NOT EXISTS vibe_asks_by_user ON vibe_asks (user_id);
+CREATE INDEX IF NOT EXISTS vibe_asks_created ON vibe_asks (created_at);
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
+-- phase: expand
+-- A first-party service's results in a person's own Vibe chats (capability chat.vibe.deliver; server/chat/internal-vibe.js):
+-- OpenVibe.Actor posts each passed run of one of their agents into the chat named after it (rooms.assistant_source
+-- 'agent:<id>', migrations/0033_vibe_asks.sql). One post per caller's idempotency key (the run's task id), kept 30 days.
+CREATE TABLE IF NOT EXISTS vibe_deliveries (
+ idempotency_key text COLLATE "C" PRIMARY KEY,
+ caller text COLLATE "C" NOT NULL,
+ user_id bigint NOT NULL,
+ room_id bigint NOT NULL,
+ message_id bigint,
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now()
+);
+CREATE INDEX IF NOT EXISTS vibe_deliveries_by_user ON vibe_deliveries (user_id);
+CREATE INDEX IF NOT EXISTS vibe_deliveries_created ON vibe_deliveries (created_at);
@@ -95,6 +95,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
@@ -95,6 +95,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
app.use('/internal/chat', express.json({ limit: '64kb' }), require('./chat/internal-reads'));
app.use('/internal/chat', express.json({ limit: '64kb' }), require('./chat/internal-reads'));
// OpenVibe.Help's per-ticket conversation (capability chat.ticket.write, server/chat/internal-tickets.js).
// OpenVibe.Help's per-ticket conversation (capability chat.ticket.write, server/chat/internal-tickets.js).
app.use('/internal/chat', express.json({ limit: '64kb' }), require('./chat/internal-tickets'));
app.use('/internal/chat', express.json({ limit: '64kb' }), require('./chat/internal-tickets'));
+ // Vibe answers a person through another service (Bot's Vibe by voice; capability chat.vibe.ask).
+ app.use('/internal/chat', express.json({ limit: '64kb' }), require('./chat/internal-vibe'));
// OpenVibe.Events deliveries (live.release.deployed, network.module.updated): signature v2 over
// OpenVibe.Events deliveries (live.release.deployed, network.module.updated): signature v2 over
// the raw body, so this router reads the body itself (server/events/consumer.js).
// the raw body, so this router reads the body itself (server/events/consumer.js).
if (events) app.use('/internal/events', events.router);
if (events) app.use('/internal/events', events.router);
@@ -67,6 +67,10 @@ const EXPORTS = [
@@ -67,6 +67,10 @@ const EXPORTS = [
// What Vibe remembers about them (server/rooms/vibe-memory.js), and whether they switched it off.
// What Vibe remembers about them (server/rooms/vibe-memory.js), and whether they switched it off.
['vibe_memory.json', `SELECT id, text, source, created_at FROM vibe_memory WHERE user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['vibe_memory.json', `SELECT id, text, source, created_at FROM vibe_memory WHERE user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['vibe_memory_off.json', `SELECT since FROM vibe_memory_off WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
['vibe_memory_off.json', `SELECT since FROM vibe_memory_off WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
+ // What they asked Vibe by voice on their machines, the last 7 days (server/chat/internal-vibe.js).
+ // Which of their agents' runs were posted into their Vibe chats (server/chat/internal-vibe.js).
+ ['vibe_deliveries.json', `SELECT room_id, message_id, created_at FROM vibe_deliveries WHERE user_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
+ ['vibe_asks.json', `SELECT idempotency_key, room_id, state, answer, code, created_at FROM vibe_asks WHERE user_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}`],
['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}`],
@@ -117,6 +121,8 @@ const ERASE_AFTER_ROOMS = [
@@ -117,6 +121,8 @@ const ERASE_AFTER_ROOMS = [
['vibe_usage', 'DELETE FROM vibe_usage WHERE user_id = ANY(@ids)'],
['vibe_usage', 'DELETE FROM vibe_usage WHERE user_id = ANY(@ids)'],
['vibe_memory', 'DELETE FROM vibe_memory WHERE user_id = ANY(@ids)'],
['vibe_memory', 'DELETE FROM vibe_memory WHERE user_id = ANY(@ids)'],
['vibe_memory_off', 'DELETE FROM vibe_memory_off WHERE user_id = ANY(@ids)'],
['vibe_memory_off', 'DELETE FROM vibe_memory_off WHERE user_id = ANY(@ids)'],
+ ['vibe_asks', 'DELETE FROM vibe_asks WHERE user_id = ANY(@ids)'],
+ ['vibe_deliveries', 'DELETE FROM vibe_deliveries WHERE user_id = ANY(@ids)'],
// Support inboxes (server/inbox/inbox.js): the inboxes they own go with every conversation in them; in others,
// Support inboxes (server/inbox/inbox.js): the inboxes they own go with every conversation in them; in others,
// their replies stay for the visitor without their name, and their place on the team goes.
// their replies stay for the visitor without their name, and their place on the team goes.
[null, 'DELETE FROM inbox_translations WHERE message_id IN (SELECT m.id FROM inbox_messages m JOIN inbox_conversations c ON c.id = m.conversation_id JOIN inboxes i ON i.id = c.inbox_id WHERE i.owner_subject = ANY(@s) OR i.owner_id = ANY(@ids))'],
[null, 'DELETE FROM inbox_translations WHERE message_id IN (SELECT m.id FROM inbox_messages m JOIN inbox_conversations c ON c.id = m.conversation_id JOIN inboxes i ON i.id = c.inbox_id WHERE i.owner_subject = ANY(@s) OR i.owner_id = ANY(@ids))'],
new file mode 100644
new file mode 100644
@@ -0,0 +1,198 @@
@@ -0,0 +1,198 @@
+'use strict';
+/**
+ * Vibe answers a person through another OpenVibe service (capability chat.vibe.ask; openvibe-contracts 0.214.0
+ * chat.vibe-ask-request@1 / chat.vibe-ask-result@1). OpenVibe.Bot's Vibe by voice hands over what a person said after
+ * their machine's call word, with the machine's owner as the subject (Bot checks the owner turned voice on for it).
+ * Loopback only, a service token for audience openvibe.chat.
+ *
+ * POST /internal/chat/vibe/ask { subject, text, source: { kind: 'voice', id, name? }, lang?, idempotency_key? }
+ * → 200 { state: 'answered', text, url, chat } | { state: 'refused', text, code }
+ *
+ * The question and the answer are kept in the person's own Vibe chats: one per source, "Voice on <name>", made on first
+ * use and marked by rooms.assistant_source (migrations/0033_vibe_asks.sql; deleting the chat removes the mark, and the
+ * next question starts a new one). The answer counts against the person's daily allowance like any of theirs, and comes
+ * back as plain text to speak (Markdown taken off). The same idempotency key from the same caller answers once: asked
+ * again, the first answer (kept 7 days). A person Chat has not seen, Vibe resting or the allowance spent: refused, with why.
+ *
+ * POST /internal/chat/vibe/deliver { subject, source: { kind: 'agent', id, name? }, text, url?, idempotency_key }
+ * → 201 { chat, url, repeated: false } | 200 { chat, url, repeated: true }
+ *
+ * Each run is a chat you can open (capability chat.vibe.deliver; chat.vibe-deliver-request@1 / -result@1): OpenVibe.Actor
+ * posts each passed run of one of the person's agents, as Vibe's message, into their Vibe chat named after the agent
+ * (one per agent, marked like the voice chats), with a link to the whole run. No AI answer is made and nothing counts
+ * against their allowance; they can reply in the chat and Vibe answers knowing the result. One post per caller's key
+ * (vibe_deliveries, kept 30 days). A person Chat has not seen is 422. They get one OpenVibe notification per post
+ * ("<agent> has a new result", category chat, which they can switch off on the Network), sent after the answer.
+ */
+const express = require('express');
+const config = require('../config');
+const db = require('../db/database');
+const serviceAuth = require('../net/service-auth');
+const vibe = require('../rooms/vibe');
+const rooms = require('../rooms/rooms');
+const aiMembers = require('../rooms/ai-members');
+
+const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const SOURCE_ID = /^[A-Za-z0-9_.:-]{1,64}$/;
+const KEY = /^[A-Za-z0-9_.:-]{1,128}$/;
+const KEEP_MS = 7 * 24 * 3600 * 1000;
+const fail = (status, message) => { const e = new Error(message); e.status = status; throw e; };
+const sqlTime = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');
+
+/** The request's fields, checked as chat.vibe-ask-request@1 says (Chat's own check until it pins the schema). */
+function requestOf(b) {
+ if (!b || typeof b !== 'object' || Array.isArray(b)) fail(400, 'A JSON object is required');
+ if (Object.keys(b).some((k) => !['subject', 'text', 'source', 'lang', 'idempotency_key'].includes(k))) fail(400, 'Invalid fields');
+ if (!SUBJECT.test(String(b.subject || ''))) fail(400, 'subject is a person (usr_…)');
+ const text = typeof b.text === 'string' ? b.text.replace(/\r\n?/g, '\n').trim() : '';
+ if (!text || text.length > 2000) fail(400, 'text is 1 to 2000 characters');
+ const s = b.source;
+ if (!s || typeof s !== 'object' || s.kind !== 'voice' || !SOURCE_ID.test(String(s.id || '')) || Object.keys(s).some((k) => !['kind', 'id', 'name'].includes(k))) fail(400, 'source is { kind: voice, id, name? }');
+ if (b.idempotency_key != null && !KEY.test(String(b.idempotency_key))) fail(400, 'idempotency_key is 1 to 128 of A-Z a-z 0-9 _ . : -');
+ const name = typeof s.name === 'string' ? s.name.replace(/\s+/g, ' ').trim().slice(0, 40) : '';
+ return { subject: b.subject, text, source: { kind: 'voice', id: s.id, name }, key: b.idempotency_key || null };
+}
+
+/** Markdown off, for a voice to read: links keep their text, code and emphasis marks go, lists become sentences. */
+function spoken(md) {
+ return String(md || '')
+ .replace(/```[\s\S]*?```/g, ' ')
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
+ .replace(/\[([^\]]+)\]\((?:https?:)?[^)]*\)/g, '$1')
+ .replace(/`([^`]*)`/g, '$1')
+ .replace(/(\*\*|__|\*|_|~~)(\S[\s\S]*?\S|\S)\1/g, '$2')
+ .replace(/^\s{0,3}#{1,6}\s+/gm, '')
+ .replace(/^\s*[-*+]\s+/gm, '')
+ .replace(/^\s*>\s?/gm, '')
+ .replace(/https?:\/\/\S+/g, '')
+ .replace(/[ \t]+\n/g, '\n').replace(/\n{2,}/g, '\n').trim()
+ .slice(0, 4000);
+}
+
+/** The person's chat for this source: the marked one when it is still theirs, else a new one ("Voice on <name>", or the agent's name). */
+async function chatFor(user, source) {
+ const mark = `${source.kind}:${source.id}`;
+ const room = await db.get('SELECT * FROM rooms WHERE owner_id = ? AND assistant = 1 AND assistant_source = ? AND archived_at IS NULL', [user.id, mark]);
+ if (room) return room;
+ const made = await vibe.createRoom(user, source.kind === 'agent' ? (source.name || 'Your agent') : `Voice on ${source.name || 'your computer'}`);
+ await db.run('UPDATE rooms SET assistant_source = ? WHERE id = ?', [mark, made.id]);
+ return { ...made, assistant_source: mark };
+}
+
+async function ask(req) {
+ const r = requestOf(req.body);
+ const caller = String((req.principal && req.principal.sub) || '');
+ const key = r.key ? `${caller}|${r.key}` : null;
+ if (key) {
+ const seen = await db.get('SELECT * FROM vibe_asks WHERE idempotency_key = ?', [key]);
+ if (seen) return seen.state === 'answered' ? answered(seen.answer, seen.room_id) : refused(seen.answer, seen.code);
+ }
+ const user = await db.get('SELECT * FROM ctx_users WHERE subject_id = ? ORDER BY id LIMIT 1', [r.subject]);
+ let out;
+ let roomId = null;
+ if (!user || Number(user.is_banned)) out = refused('This account cannot use Vibe yet: sign in on openvibe.chat once first.', 'chat.vibe.unknown_person');
+ else if (!vibe.enabled()) out = refused('Vibe is resting right now. Try again later.', 'chat.vibe.off');
+ else if (await vibe.repliesToday(user) >= config.ai.vibeRepliesPerDay) out = refused(`You have used today's ${config.ai.vibeRepliesPerDay} free answers. They come back at midnight UTC.`, 'chat.vibe.allowance');
+ else {
+ const room = await chatFor(user, r.source);
+ roomId = Number(room.id);
+ const message = await rooms.post(room, user, r.text, { ai: false });
+ try { require('./chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message }); } catch { /* sockets are optional */ }
+ const reply = await aiMembers.afterPost(room, message, user);
+ out = reply && reply.message ? answered(spoken(reply.message), roomId, room.slug) : refused('Vibe could not answer just now. Try again in a minute.', 'chat.vibe.unavailable');
+ }
+ if (key) {
+ if (Math.random() < 0.05) await db.run('DELETE FROM vibe_asks WHERE created_at < ?', [sqlTime(Date.now() - KEEP_MS)]);
+ await db.run(`INSERT INTO vibe_asks (idempotency_key, caller, user_id, room_id, state, answer, code) VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (idempotency_key) DO NOTHING`, [key, caller, user ? user.id : 0, roomId, out.state, out.text, out.code || null]);
+ }
+ return out;
+}
+
+function answered(text, roomId, slug = null) {
+ return { state: 'answered', text: String(text || '').slice(0, 4000), ...(slug ? { url: `${config.web.baseUrl}/vibe/${slug}`, chat: slug } : {}), ...(roomId && !slug ? { room_id: roomId } : {}) };
+}
+const refused = (text, code) => ({ state: 'refused', text, code });
+
+/** chat.vibe-deliver-request@1, checked as the schema says. */
+function deliveryOf(b) {
+ if (!b || typeof b !== 'object' || Array.isArray(b)) fail(400, 'A JSON object is required');
+ if (Object.keys(b).some((k) => !['subject', 'source', 'text', 'url', 'idempotency_key'].includes(k))) fail(400, 'Invalid fields');
+ if (!SUBJECT.test(String(b.subject || ''))) fail(400, 'subject is a person (usr_…)');
+ const s = b.source;
+ if (!s || typeof s !== 'object' || s.kind !== 'agent' || !SOURCE_ID.test(String(s.id || '')) || Object.keys(s).some((k) => !['kind', 'id', 'name'].includes(k))) fail(400, 'source is { kind: agent, id, name? }');
+ const text = typeof b.text === 'string' ? b.text.replace(/\r\n?/g, '\n').trim() : '';
+ if (!text || text.length > 8000) fail(400, 'text is 1 to 8000 characters');
+ let url = null;
+ if (b.url != null) {
+ try { const u = new URL(String(b.url)); if (u.protocol === 'https:') url = u.href; } catch { /* checked below */ }
+ if (!url) fail(400, 'url is an https address');
+ }
+ if (!KEY.test(String(b.idempotency_key || ''))) fail(400, 'idempotency_key is 1 to 128 of A-Z a-z 0-9 _ . : -');
+ const name = typeof s.name === 'string' ? s.name.replace(/\s+/g, ' ').trim().slice(0, 40) : '';
+ return { subject: b.subject, source: { kind: 'agent', id: s.id, name }, text, url, key: b.idempotency_key };
+}
+
+const chatUrl = (slug) => `${config.web.baseUrl}/vibe/${slug}`;
+
+/** "<agent> has a new result", with its first line, to the chat (never holds up the answer to the caller). */
+async function notifyResult(user, room, d) {
+ if (!user.subject_id || (!config.oauth.clientSecret && !require('../inbox/notify')._hasTestPush())) return;
+ const name = d.source.name || 'Your agent';
+ await require('../inbox/notify').pushOne({
+ subjectId: user.subject_id, type: 'AGENT_RESULT', category: 'chat', service: 'chat', icon: '🤖', sender_name: name, priority: 'normal',
+ title: `${name} has a new result`, message: spoken(d.text).split('\n')[0].slice(0, 160) || 'Open the chat to read it.', url: chatUrl(room.slug),
+ });
+}
+async function deliver(req) {
+ const d = deliveryOf(req.body);
+ const caller = String((req.principal && req.principal.sub) || '');
+ const key = `${caller}|${d.key}`;
+ const seen = await db.get('SELECT d.room_id, r.slug FROM vibe_deliveries d LEFT JOIN rooms r ON r.id = d.room_id WHERE d.idempotency_key = ?', [key]);
+ if (seen && seen.slug) return { status: 200, body: { chat: seen.slug, url: chatUrl(seen.slug), repeated: true } };
+ const user = await db.get('SELECT * FROM ctx_users WHERE subject_id = ? ORDER BY id LIMIT 1', [d.subject]);
+ if (!user || Number(user.is_banned)) fail(422, 'This account has no Vibe chats yet: they sign in on openvibe.chat once first');
+ const room = await chatFor(user, d.source);
+ const member = await db.get("SELECT * FROM room_ai_members WHERE room_id = ? AND handle = 'vibe'", [room.id]);
+ if (!member) fail(409, 'That Vibe chat has no Vibe in it');
+ const link = d.url ? `\n\n[See the whole run on OpenVibe Agents](${d.url})` : '';
+ const message = await rooms.postAsAi(room, member, `${d.text.slice(0, 8000 - link.length)}${link}`);
+ try { require('./chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message }); } catch { /* sockets are optional */ }
+ if (Math.random() < 0.05) await db.run('DELETE FROM vibe_deliveries WHERE created_at < ?', [sqlTime(Date.now() - 30 * 24 * 3600 * 1000)]);
+ await db.run('INSERT INTO vibe_deliveries (idempotency_key, caller, user_id, room_id, message_id) VALUES (?, ?, ?, ?, ?) ON CONFLICT (idempotency_key) DO NOTHING',
+ [key, caller, user.id, room.id, message ? message.id : null]);
+ notifyResult(user, room, d).catch((err) => console.warn(`[Vibe deliver] notify: ${err && err.message}`));
+ return { status: 201, body: { chat: room.slug, url: chatUrl(room.slug), repeated: false } };
+}
+
+const router = express.Router();
+router.post('/vibe/deliver', serviceAuth.guard('chat.vibe.deliver'), async (req, res) => {
+ try {
+ const out = await deliver(req);
+ res.status(out.status).json(out.body);
+ } catch (err) {
+ const status = err && err.status && err.status < 500 ? err.status : 500;
+ if (status >= 500) console.error('[Vibe deliver]', err && err.message);
+ res.status(status).json({ error: status >= 500 ? 'Something went wrong' : err.message, code: err && err.code });
+ }
+});
+
+router.post('/vibe/ask', serviceAuth.guard('chat.vibe.ask'), async (req, res) => {
+ try {
+ const out = await ask(req);
+ // A replayed answer names its chat by id only: look the slug up so the url is there too.
+ if (out.room_id) {
+ const room = await db.get('SELECT slug FROM rooms WHERE id = ?', [out.room_id]);
+ delete out.room_id;
+ if (room) Object.assign(out, { url: `${config.web.baseUrl}/vibe/${room.slug}`, chat: room.slug });
+ }
+ res.json(out);
+ } catch (err) {
+ const status = err && err.status && err.status < 500 ? err.status : (err && err.code && String(err.code).startsWith('rooms.') ? 422 : 500);
+ if (status >= 500) console.error('[Vibe ask]', err && err.message);
+ res.status(status).json({ error: status >= 500 ? 'Something went wrong' : err.message, code: err && err.code });
+ }
+});
+
+module.exports = router;
+module.exports.spoken = spoken;
@@ -78,4 +78,8 @@ function later(conv, reason) { people(conv, reason).catch((err) => console.warn(
@@ -78,4 +78,8 @@ function later(conv, reason) { people(conv, reason).catch((err) => console.warn(
/** Tests: capture pushes instead of calling the Network (null restores it). */
/** Tests: capture pushes instead of calling the Network (null restores it). */
function _setPush(fn) { _testPush = fn; lastSent.clear(); }
function _setPush(fn) { _testPush = fn; lastSent.clear(); }
-module.exports = { people, later, QUIET_MS, _setPush };
+/** One notification to one person (subjectId): the push itself, for other Chat features (internal-vibe.js deliver). */
+const pushOne = (notification) => (_testPush || push)(notification);
+const _hasTestPush = () => !!_testPush;
+
+module.exports = { people, later, pushOne, QUIET_MS, _setPush, _hasTestPush };
@@ -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', 'room_tasks', 'room_hooks', 'vibe_usage', 'vibe_memory', 'vibe_memory_off', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_actions', 'inbox_translations', 'feeds', 'feed_sessions', 'feed_events', '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', 'room_tasks', 'room_hooks', 'vibe_usage', 'vibe_memory', 'vibe_memory_off', 'vibe_asks', 'vibe_deliveries', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_actions', 'inbox_translations', '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 = [
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
+'use strict';
+/**
+ * Vibe answers a person through another service (server/chat/internal-vibe.js, capability chat.vibe.ask, contracts
+ * 0.214.0): Bot's Vibe by voice. The question and Vibe's answer land in the person's own "Voice on <machine>" chat (one
+ * per machine), the answer comes back as plain text to speak, it counts against their allowance, the same key answers
+ * once, and a person Chat has not seen or the allowance spent is refused with why. Only a service token
+ * holding the capability, from loopback, reaches it.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('vibe ask');
+let h, ann, config, vibe;
+const BOT = (cap = 'chat.vibe.ask') => h.serviceToken([cap], { sub: 'svc:bot' });
+const DEVICE = 'dev_01JABCDEFGHJKMNPQRSTVWXYZ0';
+const ask = (body, token = BOT(), headers = {}) => h.http('POST', '/internal/chat/vibe/ask', { token, body, headers });
+const calls = [];
+
+t('boot', async () => {
+ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } });
+ config = require('../server/config');
+ vibe = require('../server/rooms/vibe');
+ ann = h.addUser('ann', { subject: ids.newId('user') });
+ await h.ctx.upsertUser(h.live.users.get(ann.id));
+ require('../server/ai/client')._setClient({
+ runs: {
+ create: async (workflow, input) => {
+ calls.push({ workflow, input });
+ return { run: { status: 'succeeded', output: { choices: [{ message: { content: '**Clear skies** after six. See [the forecast](https://example.org/w) for more:\n\n- around 12 °C\n- light wind' } }] } } };
+ },
+ },
+ });
+});
+
+t('a question from a machine: kept in "Voice on <machine>", answered as plain text, counted', async () => {
+ const subject = h.live.users.get(ann.id).subject_id;
+ const before = await vibe.repliesToday({ id: ann.id });
+ const r = await ask({ subject, text: 'what is the weather like this evening', source: { kind: 'voice', id: DEVICE, name: 'desk-pc' }, lang: 'en-US', idempotency_key: 'va-001' });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.state, 'answered');
+ assert.strictEqual(r.body.text, 'Clear skies after six. See the forecast for more:\naround 12 °C\nlight wind', 'Markdown and links off, for speech');
+ assert.match(r.body.url, /^https:\/\/openvibe\.chat\/vibe\/v-[0-9a-f]{12}$/);
+ const room = await h.db.get('SELECT * FROM rooms WHERE slug = ?', [r.body.chat]);
+ assert.deepStrictEqual([room.name, room.assistant_source, Number(room.owner_id)], ['Voice on desk-pc', `voice:${DEVICE}`, ann.id]);
+ const msgs = await h.db.all('SELECT message, ai_member_id FROM room_messages WHERE room_id = ? ORDER BY id', [room.id]);
+ assert.deepStrictEqual(msgs.map((m) => (m.ai_member_id ? 'vibe' : m.message)), ['what is the weather like this evening', 'vibe'], 'the question and the answer, in their chat');
+ assert.strictEqual(await vibe.repliesToday({ id: ann.id }), before + 1, 'it counts against their allowance');
+ // A second question from the same machine goes to the same chat; another machine gets its own.
+ const again = await ask({ subject, text: 'and tomorrow?', source: { kind: 'voice', id: DEVICE, name: 'desk-pc' }, idempotency_key: 'va-002' });
+ assert.strictEqual(again.body.chat, r.body.chat);
+ const other = await ask({ subject, text: 'hello', source: { kind: 'voice', id: 'dev_01JABCDEFGHJKMNPQRSTVWXYZ1', name: 'robot' } });
+ assert.notStrictEqual(other.body.chat, r.body.chat);
+});
+
+t('the same key answers once: asked again, the first answer and no second model call', async () => {
+ const subject = h.live.users.get(ann.id).subject_id;
+ const before = calls.length;
+ const r = await ask({ subject, text: 'what is the weather like this evening', source: { kind: 'voice', id: DEVICE, name: 'desk-pc' }, idempotency_key: 'va-001' });
+ assert.strictEqual(r.body.state, 'answered');
+ assert.match(r.body.url, /\/vibe\/v-/);
+ assert.strictEqual(calls.length, before, 'no model call');
+});
+
+t('refused, with why: a person Chat has not seen, the allowance spent', async () => {
+ const subject = h.live.users.get(ann.id).subject_id;
+ const unknown = await ask({ subject: ids.newId('user'), text: 'hi', source: { kind: 'voice', id: DEVICE } });
+ assert.deepStrictEqual([unknown.status, unknown.body.state, unknown.body.code], [200, 'refused', 'chat.vibe.unknown_person']);
+ const keep = config.ai.vibeRepliesPerDay;
+ config.ai.vibeRepliesPerDay = await vibe.repliesToday({ id: ann.id });
+ try {
+ const spent = await ask({ subject, text: 'one more', source: { kind: 'voice', id: DEVICE } });
+ assert.deepStrictEqual([spent.body.state, spent.body.code], ['refused', 'chat.vibe.allowance']);
+ assert.match(spent.body.text, /free answers/);
+ } finally { config.ai.vibeRepliesPerDay = keep; }
+});
+
+t('only a service token with chat.vibe.ask, from loopback; bad bodies are refused', async () => {
+ const subject = h.live.users.get(ann.id).subject_id;
+ const body = { subject, text: 'hi', source: { kind: 'voice', id: DEVICE } };
+ assert.strictEqual((await ask(body, null)).status, 401);
+ assert.strictEqual((await ask(body, BOT('chat.ticket.write'))).status, 403, 'another capability');
+ assert.strictEqual((await ask(body, BOT(), { 'x-forwarded-for': '203.0.113.9' })).status, 403, 'not through the proxy');
+ for (const bad of [{ ...body, subject: 'ann' }, { ...body, text: '' }, { ...body, source: { kind: 'chat', id: DEVICE } }, { ...body, extra: 1 }]) {
+ assert.strictEqual((await ask(bad)).status, 400, JSON.stringify(bad));
+ }
+});
+
+t('account export and deletion include what they asked by voice', async () => {
+ const ad = require('../server/chat/account-data');
+ const src = require('fs').readFileSync(require.resolve('../server/chat/account-data'), 'utf8');
+ assert.ok(src.includes("['vibe_asks.json'") && src.includes("['vibe_asks', 'DELETE FROM vibe_asks"), 'listed in both');
+ assert.ok(ad, 'module loads');
+});
+
+t.run(async () => { require('../server/ai/client')._setClient(null); if (h && h.close) await h.close(); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+'use strict';
+/**
+ * Each run is a chat you can open (server/chat/internal-vibe.js POST /internal/chat/vibe/deliver, capability
+ * chat.vibe.deliver, contracts 0.214.0; migrations/0034_vibe_deliveries.sql): Actor posts an agent's run into the
+ * person's Vibe chat named after the agent, as Vibe's message with a link to the whole run; the next run of the same
+ * agent goes to the same chat, another agent gets its own; the same key posts once; no AI answer and nothing counted; the
+ * person replies and Vibe answers seeing the result; a person Chat has not seen is 422; only a service token with the
+ * capability reaches it; export and deletion carry the rows.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('vibe deliver');
+let h, ann, vibe;
+const ACTOR = (cap = 'chat.vibe.deliver') => h.serviceToken([cap], { sub: 'svc:actor' });
+const AGENT = 'agt_01JABCDEFGHJKMNPQRSTVWXYZ0';
+const send = (body, token = ACTOR()) => h.http('POST', '/internal/chat/vibe/deliver', { token, body });
+const calls = [];
+const pushes = [];
+let subject;
+const run = (n, extra = {}) => ({ subject, source: { kind: 'agent', id: AGENT, name: 'Remote jobs' }, text: `**${n} new jobs** today:\n\n- [Editor](https://example.com/jobs/${n})`, url: `https://openvibe.actor/tasks/tsk_run${n}`, idempotency_key: `tsk_run${n}`, ...extra });
+
+t('boot', async () => {
+ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } });
+ vibe = require('../server/rooms/vibe');
+ ann = h.addUser('ann', { subject: ids.newId('user') });
+ await h.ctx.upsertUser(h.live.users.get(ann.id));
+ subject = h.live.users.get(ann.id).subject_id;
+ require('../server/inbox/notify')._setPush(async (n) => { pushes.push(n); return { sent: true }; });
+ require('../server/ai/client')._setClient({
+ runs: { create: async (workflow, input) => { calls.push({ workflow, input }); return { run: { status: 'succeeded', output: { choices: [{ message: { content: 'The Editor one pays best.' } }] } } }; } },
+ });
+});
+
+t('a run lands in the chat named after its agent, as Vibe, with the link; no AI answer and nothing counted', async () => {
+ const before = await vibe.repliesToday({ id: ann.id });
+ const r = await send(run(3));
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.body.repeated, false);
+ assert.match(r.body.url, /^https:\/\/openvibe\.chat\/vibe\/v-[0-9a-f]{12}$/);
+ const room = await h.db.get('SELECT * FROM rooms WHERE slug = ?', [r.body.chat]);
+ assert.deepStrictEqual([room.name, room.assistant_source, Number(room.owner_id), Number(room.assistant)], ['Remote jobs', `agent:${AGENT}`, ann.id, 1]);
+ const msgs = await h.db.all('SELECT message, ai_member_id FROM room_messages WHERE room_id = ? ORDER BY id', [room.id]);
+ assert.strictEqual(msgs.length, 1);
+ assert.ok(msgs[0].ai_member_id, 'Vibe\'s message');
+ assert.strictEqual(msgs[0].message, '**3 new jobs** today:\n\n- [Editor](https://example.com/jobs/3)\n\n[See the whole run on OpenVibe Agents](https://openvibe.actor/tasks/tsk_run3)');
+ assert.strictEqual(calls.length, 0, 'no AI answer');
+ assert.strictEqual(await vibe.repliesToday({ id: ann.id }), before, 'nothing counted');
+ assert.ok((await vibe.list({ id: ann.id })).some((c) => c.slug === r.body.chat), 'listed with their chats');
+ for (let i = 0; i < 40 && !pushes.length; i++) await new Promise((res) => setTimeout(res, 25));
+ assert.deepStrictEqual(pushes.map((n) => [n.subjectId, n.type, n.category, n.title, n.message, n.url]),
+ [[subject, 'AGENT_RESULT', 'chat', 'Remote jobs has a new result', '3 new jobs today:', r.body.url]], 'one notification, to the chat');
+});
+
+t('the next run goes to the same chat; the same key posts once; another agent gets its own chat', async () => {
+ const first = await send(run(3));
+ assert.deepStrictEqual([first.status, first.body.repeated], [200, true]);
+ await new Promise((res) => setTimeout(res, 50));
+ assert.strictEqual(pushes.length, 1, 'a repeat tells nobody again');
+ const next = await send(run(4));
+ assert.strictEqual(next.status, 201);
+ const room = await h.db.get('SELECT id FROM rooms WHERE slug = ?', [next.body.chat]);
+ assert.strictEqual(Number((await h.db.get('SELECT count(*) AS n FROM room_messages WHERE room_id = ?', [room.id])).n), 2, 'one per run');
+ const other = await send(run(5, { source: { kind: 'agent', id: 'agt_01JABCDEFGHJKMNPQRSTVWXYZ1', name: 'Price watch' } }));
+ assert.notStrictEqual(other.body.chat, next.body.chat);
+ assert.strictEqual((await h.db.get('SELECT name FROM rooms WHERE slug = ?', [other.body.chat])).name, 'Price watch');
+});
+
+t('the person replies in the chat and Vibe answers seeing the result', async () => {
+ const room = await h.db.get('SELECT * FROM rooms WHERE assistant_source = ?', [`agent:${AGENT}`]);
+ const r = await h.http('POST', `/api/chat/rooms/${room.slug}/messages`, { token: ann.token, body: { message: 'Which one pays best?' } });
+ assert.ok(r.status < 300, r.text);
+ for (let i = 0; i < 80 && !calls.length; i++) await new Promise((res) => setTimeout(res, 25));
+ assert.ok(calls.length, 'Vibe answered');
+ const prompt = JSON.stringify(calls[0].input);
+ assert.ok(prompt.includes('4 new jobs') && prompt.includes('Which one pays best?'), 'the result is in what Vibe reads');
+});
+
+t('refused: bad bodies, a person Chat has not seen, a token without the capability', async () => {
+ assert.strictEqual((await send(run(6, { source: { kind: 'voice', id: 'dev_1' } }))).status, 400);
+ assert.strictEqual((await send(run(6, { url: 'http://example.com/x' }))).status, 400);
+ assert.strictEqual((await send({ ...run(6), idempotency_key: undefined })).status, 400);
+ assert.strictEqual((await send(run(6, { text: 'x'.repeat(8001) }))).status, 400);
+ assert.strictEqual((await send(run(6, { subject: ids.newId('user') }))).status, 422);
+ assert.strictEqual((await send(run(6), ACTOR('chat.vibe.ask'))).status, 403);
+ assert.strictEqual((await h.http('POST', '/internal/chat/vibe/deliver', { body: run(6) })).status, 401);
+});
+
+t('account export and deletion include the posted runs', async () => {
+ const accountData = require('../server/chat/account-data');
+ const sent = [];
+ const ev = (type, payload) => ({ event_id: ids.newId('event'), event_type: type, version: 1, source: 'network', actor: { type: 'user', id: subject },
+ timestamp: new Date().toISOString(), visibility: 'internal', subject: { type: 'user', id: subject }, payload });
+ assert.strictEqual(await accountData.apply(ev('network.account.export_requested', { export_id: `exp_${ids.ulid()}`, subject, requested_at: new Date().toISOString(), deadline: new Date(Date.now() + 1800000).toISOString() }), { send: async (p, body) => { sent.push(body); return { ok: true, status: 200 }; } }), 'exported');
+ const files = Object.fromEntries(sent[0].files.map((f) => [f.name, f.content]));
+ assert.strictEqual(files['vibe_deliveries.json'].length, 3);
+ assert.strictEqual(await accountData.apply(ev('network.account.deleted', { deletion_id: `del_${ids.ulid()}`, subject, aliases: [], requested_at: new Date().toISOString(), deleted_at: new Date().toISOString() }), { send: async () => ({ ok: true, status: 200 }) }), 'erased');
+ assert.strictEqual(await h.db.get('SELECT 1 FROM vibe_deliveries WHERE user_id = ?', [ann.id]), undefined);
+});
+
+t.run(async () => { require('../server/ai/client')._setClient(null); require('../server/inbox/notify')._setPush(null); if (h && h.close) await h.close(); });