openvibers/openvibe.actor Commits
Actor: ask Actor from chat — @actor in the global chat or your own channel, answered in the room (public tools only) (#77)
Changes
@@@ -119,7 +119,14 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu- Reading, screenshots and closing run at every level; the rest wait for approval at `ask`. Below `full` the machinerefuses addresses on its own network.- Approvals can come from the Node's tray or notification buttons, as well as `openvibe-node approve` (Node v0.11.0).--- **OpenVibe.Events** (`ACTOR_EVENTS_URL`): the two ADR-033 subscriptions created at boot, and delivery to the loopback `/internal/events`.++- **OpenVibe.Events** (`ACTOR_EVENTS_URL`): the two ADR-033 subscriptions created at boot, plus `chat.message.created` while asking from chat is on, and delivery to the loopback `/internal/events`.++- **OpenVibe.Chat** (`ACTOR_CHAT_URL`, default `http://127.0.0.1:4400`): **ask Actor from chat** ([server/chat/asks.js](server/chat/asks.js)).++ - **Who and where:** a signed-in person writes `@actor <task>` in the global chat or their own channel's chat. Guests, relayed external chatters (`source_platform`) and other people's channels are ignored.++ - **How it runs:** the message becomes their task, on their free tier, with `via: chat`. The answer is posted where everyone reads it, so the task uses public tools only. The engine refuses a robot, computer or coding task (`actor.task.not_public`), and the runtime is never given the person's robots or computers.++ - **The reply:** the first lines of the answer and the task's page, posted in the same room as "OpenVibe Actor", answering the message. It goes through Chat's service API (`chat.message.send`, audience `openvibe.chat`) and is retried with backoff for up to an hour.++ - **Limits:** 5 asks an hour per person and 60 across everyone (`ACTOR_CHAT_ASKS_PER_HOUR`, `ACTOR_CHAT_ASKS_ALL_PER_HOUR`). Past a limit, one reply says so and further asks are dropped quietly.++ - **Data:** `actor_chat_asks` (migration 0009) is part of the person's export and erasure.++ - `ACTOR_CHAT_ASKS=0` turns it off.- **The model providers**: DeepSeek (`ACTOR_DEEPSEEK_API_KEY`, `api.deepseek.com`), OpenAI (`ACTOR_OPENAI_API_KEY`, `api.openai.com/v1`) for the web-search agent and the cross-family check, and an OpenAI-compatible local server (`ACTOR_LOCAL_LLM_URL`) for private mode.**Data:** PostgreSQL (openvibe-sdk/db, `DATABASE_URL` / `DATABASE_DIRECT_URL`) holds tasks, their events and daily spend; Valkey (`VALKEY_URL`) holds the shared per-caller limit counters. A provider or service without its key or URL is listed unavailable, never invented.@@@ -139,7 +146,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu2. Coding through OpenVibe.Codes on hosted Run sandboxes.3. More agent platforms behind the same router, bring-your-own keys through OpenVibe.AI, and paid tiers through Billing.4. Personal agents v1 are in this worktree: named instructions, memory people control, hourly/daily/weekly schedules and ordinary task runs. An approval inbox (a coding task an agent wants to start waits for its owner) and Watch triggers (a watch hands its--task to an agent when its condition fires) are built. Chat and Frame access come later.++task to an agent when its condition fires) are built, and so is asking Actor from chat (`@actor …`). Frame access comes later.## Configurationnnew file mode 100644@@@ -0,0 +1,20 @@++-- phase: expand++-- Plan T17 "Reach": a person asks Actor from OpenVibe chat ("@actor …" in the global chat or their own channel's++-- chat). One row per chat message that asked: the task it became, or why none was made, and the reply Actor posts++-- back in the same room. Kept with the person's data (account export and deletion).++CREATE TABLE actor_chat_asks (++ message_id bigint PRIMARY KEY, -- Chat's message id (Events delivers at least once)++ requester text COLLATE "C" NOT NULL, -- user:usr_…++ username text NOT NULL,++ room jsonb NOT NULL, -- { type: global } | { type: channel, channel_user_id, stream_id }++ task_id text COLLATE "C", -- the task it became++ refused text, -- why no task was made (the reply says so)++ created_at text COLLATE "C" NOT NULL,++ replied_at text COLLATE "C",++ reply_attempts integer NOT NULL DEFAULT 0,++ next_attempt_at text COLLATE "C",++ reply_error text++);++CREATE INDEX actor_chat_asks_requester ON actor_chat_asks (requester, created_at DESC);++CREATE INDEX actor_chat_asks_created ON actor_chat_asks (created_at);++CREATE INDEX actor_chat_asks_waiting ON actor_chat_asks (created_at) WHERE replied_at IS NULL;@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.178.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.183.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.9","pg": "^8.23.0"@@@ -871,9 +871,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.178.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.178.0",-- "integrity": "sha512-86Q1Mmo+BvMnF2GG2IZrbHq8iZ2FUfED28rnDcFlgZTtIzHxDQdi2dY+Xe8WbXc4EebjGUYhEl5J3LReA2wiqg==",++ "version": "0.183.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.183.0",++ "integrity": "sha512-8uXsBTJ0pNUKiDe2gUnKjtkETJbfJUL54qB5wRHfss5RIKZ2i56Ab50Esf4qCP0p2kAawGUGq3xahmeRM7UqGA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -27,7 +27,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.178.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.183.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.9","pg": "^8.23.0"@@@ -25,6 +25,9 @@ const catalog = require('./agents/catalog');const { createAdapters } = require('./agents/adapters');const { createEngine } = require('./tasks/engine');const { createWebhooks } = require('./tasks/webhooks');++const { createChatClient } = require('./chat/client');++const { createChatAsks } = require('./chat/asks');++const { createTask } = require('./tasks/create');const { createWebhookPoster } = require('./net/webhook-post');const { createTaskStream } = require('./tasks/stream');const { createPrincipal } = require('./http/principal');@@@ -69,7 +72,11 @@ async function createApp(opts = {}) {const engine = createEngine({ config, s, adapters, stream, webhooks, now: s.now, log });const principal = createPrincipal({ config, keys });const projects = opts.projects || require('./identity/projects').createProjects({ config });-- const ctx = { config, s, keys, sso, stream, adapters, engine, webhooks, principal, projects, log };++ // Ask Actor from chat (chat/asks.js): chat.message.created through the events consumer below, replies through Chat.++ const chatClient = opts.chatClient || createChatClient({ config, fetchImpl });++ const chatAsks = createChatAsks({ s, config, chat: chatClient, now: s.now, log,++ createTask: (args) => createTask({ s, config, engine, webhooks, ...args }) });++ const ctx = { config, s, keys, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks };// Account export and deletion (ADR-033, ./identity/account-data.js): the two tables that hold a person's rows.// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;@@@ -138,7 +145,9 @@ async function createApp(opts = {}) {// network.account.export_requested and network.account.deleted (ADR-033), answered by the SDK's consumer. It// reads the raw body itself, so it is mounted before any body parser (there is none above it), and nginx answers// 404 for /internal/ so this is reachable only on 127.0.0.1.-- app.use('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log }));++ // chat.message.created goes to chat/asks.js (ask Actor from chat); anything else is acknowledged and ignored.++ app.use('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log,++ onEvent: (ev) => (ev.event_type === 'chat.message.created' ? chatAsks.onEvent(ev) : 'ignored:type') }));// ── Static assets (content-hashed ?v= → immutable) ──────app.use('/shared', require('openvibe-shared/serve').handler());nnew file mode 100644@@@ -0,0 +1,181 @@++'use strict';++/**++ * Ask Actor from chat (plan T17 "Reach"). A signed-in person writes "@actor <task>" in OpenVibe's global chat or in++ * their own channel's chat; OpenVibe.Chat publishes chat.message.created, Events delivers it to POST /internal/events,++ * and Actor runs the task as theirs (their free tier, their daily budget) and answers in the same room.++ *++ * - Who: a person signed in (a usr_… subject and a Live user id), never a guest, a relayed external chatter++ * (source_platform) or Actor itself. Where: the global chat, or the asker's own channel (no one else's).++ * - What: the answer is posted where everyone reads it, so the task runs with public tools only: the engine refuses a++ * robot, computer or coding task from chat (actor.task.not_public) and never offers the runtime the person's own++ * robots or computers (tasks/engine.js, via 'chat').++ * - How often: at most `perHour` asks per person and `allPerHour` across everyone; past either, one reply says so++ * and further asks that hour are dropped quietly. The free tier's own limits apply on top.++ * - The reply: the first lines of the answer (at most REPLY_MAX characters) and the task's page, addressed to the++ * asker, answering their message. Replies are retried with backoff and given up after an hour.++ *++ * onEvent(event) is the consumer's hook for chat.message.created (other topics are not handed here); tick() posts++ * the replies that are due (started by server/index.js).++ */++const MENTION = /^\s*@actor\b[\s,:;-]*([\s\S]*)$/i;++const TASK_MIN = 3;++const TASK_MAX = 1000;++const REPLY_MAX = 420;++const OPEN = ['queued', 'running', 'verifying'];++const GIVE_UP_MS = 60 * 60_000;++const MAX_ATTEMPTS = 8;++++/** The ask in a chat.message.created event, or null when it is not one Actor takes. */++function parseAsk(ev) {++ if (!ev || ev.event_type !== 'chat.message.created') return null;++ const p = ev.payload || {};++ if ((p.message_type || 'chat') !== 'chat' || p.source_platform || p.anon_id) return null;++ if (typeof p.user_subject !== 'string' || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(p.user_subject)) return null;++ if (!Number.isSafeInteger(p.user_id) || p.user_id <= 0 || !Number.isSafeInteger(p.message_id) || p.message_id <= 0) return null;++ const room = p.room || {};++ let where;++ if (room.type === 'global') where = { type: 'global' };++ else if (room.type === 'channel' && room.channel_user_id === p.user_id) {++ where = { type: 'channel', channel_user_id: p.user_id, ...(Number.isSafeInteger(room.stream_id) ? { stream_id: room.stream_id } : {}) };++ } else return null;++ const m = MENTION.exec(String(p.text || ''));++ if (!m) return null;++ const task = m[1].trim();++ return { messageId: p.message_id, subject: p.user_subject, username: String(p.username || 'there').slice(0, 60), room: where, task };++}++++/** Markdown to one plain line: no headings, emphasis, code fences or link syntax (the link text stays). */++function plain(md) {++ return String(md || '')++ .replace(/```[\s\S]*?```/g, ' ')++ .replace(/!\[[^\]]*\]\([^)]*\)/g, '')++ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')++ .replace(/^#{1,6}\s*/gm, '')++ .replace(/^\s*(sources?|references?):?\s*$[\s\S]*/im, '')++ .replace(/[*_`>]+/g, '')++ .replace(/\s+/g, ' ')++ .trim();++}++++/** At most `max` characters, cut at a sentence or word end, with an ellipsis when cut. */++function clip(text, max) {++ if (text.length <= max) return text;++ const cut = text.slice(0, max);++ const sentence = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? '));++ if (sentence > max * 0.5) return cut.slice(0, sentence + 1);++ const space = cut.lastIndexOf(' ');++ return `${cut.slice(0, space > max * 0.5 ? space : max).trimEnd()}…`;++}++++function createChatAsks({ s, config, createTask, chat, now = () => Date.now(), log = console }) {++ const iso = (ms = now()) => new Date(ms).toISOString();++ const taskUrl = (id) => `${config.baseUrl}/tasks/${id}`;++ let timer = null;++ let busy = false;++++ async function countSince(requester, sinceMs) {++ const where = requester ? 'requester = $2 AND ' : '';++ const row = await s.db.maybe(`SELECT count(*)::int AS n FROM actor_chat_asks WHERE ${where}created_at >= $1`, requester ? [iso(sinceMs), requester] : [iso(sinceMs)]);++ return row ? Number(row.n) : 0;++ }++ async function refusedSince(requester, sinceMs, reason) {++ const row = await s.db.maybe('SELECT 1 AS x FROM actor_chat_asks WHERE requester = $1 AND created_at >= $2 AND refused = $3 LIMIT 1', [requester, iso(sinceMs), reason]);++ return !!row;++ }++++ /** The consumer's hook: take an ask, once per chat message → the outcome Events is answered with. */++ async function onEvent(ev) {++ if (!config.chat.asks) return 'ignored:chat_asks_off';++ const ask = parseAsk(ev);++ if (!ask) return 'ignored:not_an_ask';++ const requester = `user:${ask.subject}`;++ const at = now();++ const inserted = await s.db.maybe(++ `INSERT INTO actor_chat_asks (message_id, requester, username, room, created_at) VALUES ($1, $2, $3, $4::jsonb, $5)++ ON CONFLICT (message_id) DO NOTHING RETURNING message_id`,++ [ask.messageId, requester, ask.username, JSON.stringify(ask.room), iso(at)]);++ if (!inserted) return 'duplicate';++ const refuse = async (reason, quietIfRepeated) => {++ const quiet = quietIfRepeated && await refusedSince(requester, at - 3600_000, reason);++ await s.db.query('UPDATE actor_chat_asks SET refused = $2, replied_at = $3 WHERE message_id = $1', [ask.messageId, reason, quiet ? iso(at) : null]);++ return quiet ? `refused:${reason}:quiet` : `refused:${reason}`;++ };++ if (ask.task.length < TASK_MIN) return refuse('empty', true);++ if (ask.task.length > TASK_MAX) return refuse('too_long', false);++ // The row just written counts, so "more than" the limit.++ if (await countSince(requester, at - 3600_000) > config.chat.perHour) return refuse('person_hourly', true);++ if (await countSince(null, at - 3600_000) > config.chat.allPerHour) return refuse('everyone_hourly', true);++ const principal = { kind: 'user', requester, via: 'chat', project: null, claims: {} };++ const out = await createTask({ principal, body: { task: ask.task, idempotency_key: `chat:${ask.messageId}` } });++ if (out.status === 201 || out.status === 200) {++ await s.db.query('UPDATE actor_chat_asks SET task_id = $2 WHERE message_id = $1', [ask.messageId, out.task.id]);++ return 'task';++ }++ await s.db.query('UPDATE actor_chat_asks SET refused = $2 WHERE message_id = $1', [ask.messageId, `tier:${String(out.detail || out.code || 'refused').slice(0, 300)}`]);++ return `refused:${out.code || 'tier'}`;++ }++++ const REFUSALS = {++ empty: 'ask me something after @actor, for example "@actor what is WHIP?"',++ too_long: `that is longer than ${TASK_MAX} characters; ask on ${config.baseUrl} instead`,++ person_hourly: `that is ${config.chat.perHour} questions this hour; ask again later, or on ${config.baseUrl}`,++ everyone_hourly: `I have answered a lot in chat this hour; try again later, or ask on ${config.baseUrl}`,++ };++++ /** The reply for one ask whose task ended (or that was refused). */++ function replyText(row) {++ const to = `@${row.username}`;++ if (row.refused) {++ const why = row.refused.startsWith('tier:') ? row.refused.slice(5) : REFUSALS[row.refused] || 'I cannot take that right now';++ return `${to} ${why}`;++ }++ if (row.state === 'succeeded') {++ const result = typeof row.result === 'string' ? JSON.parse(row.result) : row.result || {};++ const answer = clip(plain(result.answer) || 'Done.', REPLY_MAX);++ return `${to} ${answer} (full answer: ${taskUrl(row.task_id)})`;++ }++ if (row.error_code === 'actor.task.not_public') {++ return `${to} from a public chat I only use public tools. To use your own computer, robots or code, ask me on ${config.baseUrl}`;++ }++ const why = clip(plain(row.error_detail || 'it did not finish'), 200);++ return `${to} I could not finish that: ${why} (${taskUrl(row.task_id)})`;++ }++++ /** Post the replies that are due: refused asks at once, task asks once the task has ended. */++ async function tick() {++ if (busy || !chat.enabled) return 0;++ busy = true;++ let sent = 0;++ try {++ const due = await s.db.many(++ `SELECT a.*, t.state, t.result, t.error_code, t.error_detail FROM actor_chat_asks a LEFT JOIN tasks t ON t.id = a.task_id++ WHERE a.replied_at IS NULL AND (a.next_attempt_at IS NULL OR a.next_attempt_at <= $1)++ AND (a.refused IS NOT NULL OR (a.task_id IS NOT NULL AND t.state IS NOT NULL AND t.state <> ALL($2::text[])))++ ORDER BY a.created_at LIMIT 10`, [iso(), OPEN]);++ for (const row of due) {++ const r = await chat.post({ key: `actor:ask:${row.message_id}`, room: typeof row.room === 'string' ? JSON.parse(row.room) : row.room, text: replyText(row), replyTo: Number(row.message_id) });++ if (r.ok) {++ await s.db.query('UPDATE actor_chat_asks SET replied_at = $2, reply_error = NULL WHERE message_id = $1', [row.message_id, iso()]);++ sent++;++ continue;++ }++ const attempts = Number(row.reply_attempts) + 1;++ const late = now() - Date.parse(row.created_at) > GIVE_UP_MS;++ // A refusal Chat will never take back (4xx but 408/429) is given up at once; the rest back off.++ const final = late || attempts >= MAX_ATTEMPTS || (r.status >= 400 && r.status < 500 && r.status !== 408 && r.status !== 429);++ await s.db.query('UPDATE actor_chat_asks SET reply_attempts = $2, next_attempt_at = $3, reply_error = $4, replied_at = $5 WHERE message_id = $1',++ [row.message_id, attempts, iso(now() + Math.min(600_000, 5000 * 2 ** attempts)), String(r.error || '').slice(0, 300), final ? iso() : null]);++ if (final) log.warn(`[Actor] chat reply to message ${row.message_id} given up: ${r.error}`);++ }++ } finally { busy = false; }++ return sent;++ }++++ return {++ onEvent, tick, replyText,++ start() { if (!timer && config.chat.asks && chat.enabled) { timer = setInterval(() => { tick().catch((err) => log.warn(`[Actor] chat replies: ${err && err.message}`)); }, config.chat.replyIntervalMs); timer.unref(); } },++ stop() { if (timer) clearInterval(timer); timer = null; },++ };++}++++module.exports = { createChatAsks, parseAsk, plain, clip, REPLY_MAX };nnew file mode 100644@@@ -0,0 +1,49 @@++'use strict';++/**++ * OpenVibe.Chat, for the replies Actor posts where a person asked it (server/chat/asks.js): Chat's service API,++ * POST /internal/chat/messages (capability chat.message.send, audience openvibe.chat), as Actor's own service++ * principal. A message names its room (the global chat, or a channel and its live stream), the message it answers and++ * an idempotency key, and shows as "OpenVibe Actor" with the AI and bot marks. Off without ACTOR_CHAT_URL or a client++ * secret.++ */++const { serviceAuth } = require('openvibe-contracts');++++const NAME = 'Actor';++const DISPLAY = 'OpenVibe Actor';++++function createChatClient({ config, fetchImpl = globalThis.fetch, tokens: tokensOverride = null }) {++ const base = config.chat.url;++ const enabled = !!(base && (tokensOverride || config.oauth.clientSecret));++ const tokens = !enabled ? null : tokensOverride || serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.chat', scope: 'chat.message.send', fetchImpl,++ });++++ /** Post `text` in `room`, answering message `replyTo` → { ok, status, id?, error? }. `key` makes a retry a no-op. */++ async function post({ key, room, text, replyTo = null }) {++ if (!enabled) return { ok: false, status: 0, error: 'chat replies are off here (ACTOR_CHAT_URL)' };++ const where = room && room.type === 'channel'++ ? { channel_user_id: room.channel_user_id, ...(room.stream_id ? { stream_id: room.stream_id } : {}) }++ : { is_global: true };++ const body = {++ key, username: NAME, message: text, message_type: 'chat', ...where,++ ...(replyTo ? { reply_to_id: replyTo } : {}),++ frame: { is_ai: true, is_bot: true, display_name: DISPLAY },++ };++ try {++ const res = await fetchImpl(`${base}/internal/chat/messages`, {++ method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokens.authHeaders()) },++ body: JSON.stringify(body), signal: AbortSignal.timeout(10_000),++ });++ const data = await res.json().catch(() => null);++ if (!res.ok) return { ok: false, status: res.status, error: (data && (data.detail || data.error || data.title)) || `Chat answered ${res.status}` };++ return { ok: true, status: res.status, id: data && data.id };++ } catch (err) {++ return { ok: false, status: 0, error: (err && err.message) || String(err) };++ }++ }++++ return { enabled, post };++}++++module.exports = { createChatClient, NAME, DISPLAY };@@@ -95,6 +95,17 @@ function load(env = process.env) {timeoutMs: Math.max(1000, int(env.ACTOR_WEBHOOK_TIMEOUT_MS, 10_000)),},++ // Ask Actor from chat (server/chat/asks.js): "@actor …" in OpenVibe's global chat or the person's own channel,++ // taken from chat.message.created and answered in the same room through Chat's service API (url, loopback in++ // production). ACTOR_CHAT_ASKS=0 turns it off; the limits are per person and across everyone, per hour.++ chat: {++ asks: env.ACTOR_CHAT_ASKS !== '0',++ url: trim(env.ACTOR_CHAT_URL != null ? env.ACTOR_CHAT_URL : 'http://127.0.0.1:4400'),++ perHour: Math.max(1, int(env.ACTOR_CHAT_ASKS_PER_HOUR, 5)),++ allPerHour: Math.max(1, int(env.ACTOR_CHAT_ASKS_ALL_PER_HOUR, 60)),++ replyIntervalMs: Math.max(200, int(env.ACTOR_CHAT_REPLY_INTERVAL_MS, 2000)),++ },++// OpenVibe.Events → this service (server/events-consumer.js). The secret signs a delivery (comma-separated// for rotation, 32+ characters each); unset turns POST /internal/events off (503). The url is where the// network.account.export_requested and network.account.deleted subscriptions are created at boot, off when@@@ -15,7 +15,7 @@ const { serviceAuth } = require('openvibe-contracts');const { TOPICS } = require('./identity/account-data');/** Create any missing subscription for TOPICS at Events (idempotent; retried in the background at boot). */--function startSubscriptions({ config, port, secret, eventsUrl = config.events.url, fetchImpl = globalThis.fetch, log = console }) {++function startSubscriptions({ config, port, secret, eventsUrl = config.events.url, fetchImpl = globalThis.fetch, log = console, topics: extra = [] }) {if (!eventsUrl || !secret || !config.oauth.clientSecret || process.env.ACTOR_EVENTS_SUBSCRIBE === '0') return null;const base = String(eventsUrl).replace(/\/+$/, '');const endpoint = config.events.endpoint || `http://127.0.0.1:${port}/internal/events`;@@@ -29,7 +29,8 @@ function startSubscriptions({ config, port, secret, eventsUrl = config.events.urconst listed = await call('GET', '/api/v1/subscriptions');if (!listed.ok) throw new Error(`listing subscriptions: ${listed.status}`);const mine = (listed.body.subscriptions || []).filter((s) => s.endpoint === endpoint);-- for (const topic of TOPICS) {++ // The ADR-033 topics, and chat.message.created while asking Actor from chat is on (chat/asks.js).++ for (const topic of [...TOPICS, ...extra]) {if (mine.some((s) => s.topic_pattern === topic)) continue;const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });if (!r.ok && r.status !== 409) throw new Error(`subscribing to ${topic}: ${r.status} ${r.body.code || ''}`);@@@ -146,6 +146,7 @@ ${table(['Piece', 'State'], [[html`Coding tasks on your own machine, through <a href="https://openvibe.codes/run">OpenVibe.Codes</a>: name the repository and what to change, and get the summary, the files and the patch`, html`Live: add the machine on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a> and start one task on openvibe.codes/run first. The model use is on your machine's own keys.`],[html`Your own computers, through <a href="https://openvibe.bot/docs/computers">OpenVibe.Bot</a>: list and read files, run programs and write files there, as you, at the level you set on the computer`, html`Live: install OpenVibe Node with <code>--computer ask</code> and turn on "Let my agent use this computer" on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a>. At <code>ask</code>, you approve each run or write on the computer.`],[html`<a href="/console">The Console</a>: what runs now, what waits for you, today's spend, your agents, computers and robots, and one box to start a task`, 'Live, for signed-in people.'],++ [html`Ask in chat: write <code>@actor</code> and your question in OpenVibe's global chat or your own channel, and the answer comes back in the room`, 'Live: public tools only (never your computers, robots or code), 5 an hour, on your free tier.'],])}</section>${raw(showcase.cta({ title: 'Build on it', text: 'Every task, its live stream, its webhooks and the router are an API. Apps get actor.task.* grants on OpenVibe.Services.', actions: [{ label: 'Read the API', href: '/docs', primary: true }, { label: 'See the agents', href: '/agents' }] }))}`,}, status);@@@ -39,6 +39,8 @@ const TABLES = [{ table: 'actor_agents', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agents.json' },// What the person's agents asked them to approve (agents/approvals.js), and what they decided.{ table: 'actor_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approvals.json' },++ // What they asked Actor in chat (chat/asks.js): the message it answered, the room, the task it became.++ { table: 'actor_chat_asks', subject: 'requester', value: (usr) => `user:${usr}`, file: 'chat-asks.json' },];/** The account-data handle for Actor's store (server/db.js createStore). */@@@ -19,7 +19,7 @@ const PRUNE_EVERY_MS = 6 * 60 * 60_000;function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {return gracefulStop({name: 'Actor', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,-- close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.engine.stop(), () => ctx.webhooks.stop(), () => ctx.keys.client.stop(), () => ctx.s.close(), ...extra],++ close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.engine.stop(), () => ctx.webhooks.stop(), () => ctx.chatAsks.stop(), () => ctx.keys.client.stop(), () => ctx.s.close(), ...extra],});}@@@ -33,6 +33,8 @@ async function start() {for (const id of interrupted) await ctx.webhooks.enqueue(id, 'failed').catch((err) => console.warn('[Actor] webhook enqueue failed:', id, err && err.message));await ctx.webhooks.recover();ctx.webhooks.start();++ // Replies to "@actor …" in chat (chat/asks.js), posted once their task has ended.++ ctx.chatAsks.start();const server = app.listen(config.port, config.host, () => {const available = require('./agents/catalog').list(config).filter((a) => a.available).map((a) => a.id);@@@ -48,7 +50,8 @@ async function start() {// Subscribe to the two ADR-033 topics at OpenVibe.Events (idempotent; off without ACTOR_EVENTS_URL and// ACTOR_EVENTS_SECRET). The consumer itself is mounted in server/app.js.-- const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '' });++ const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '',++ topics: config.chat.asks ? ['chat.message.created'] : [] });const extra = [() => { if (subscriptions) subscriptions.stop(); }];createLifecycle({ server, ctx, timers: [prune, agentTick], extra });return { server, ctx };@@@ -24,6 +24,9 @@ const { repoIn: codesRepoIn } = require('../agents/codes');const MAX_ATTEMPTS = 2;++// What a task asked in a public chat may be (chat/asks.js): nothing that uses the person's own computers, robots or code.++const PUBLIC_CLASSES = ['answer', 'lookup', 'research', 'web'];++function createEngine({ config, s, adapters, stream, webhooks = null, now = () => Date.now(), log = console }) {const running = new Map(); // id → AbortControllerconst waiting = [];@@@ -100,6 +103,12 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =cls = c.class;}await emit(id, { kind: 'output', step: 'plan', text: `This is a "${cls}" task.` });++ // Asked in a public chat (chat/asks.js): the answer is posted where everyone reads it, so only what needs++ // nothing of the person's own — never their computers, robots or code on their machine.++ const fromChat = row.via === 'chat';++ if (fromChat && !PUBLIC_CLASSES.includes(cls)) {++ return await setState(id, 'failed', { error_code: 'actor.task.not_public', error_detail: 'Asked in a public chat, Actor uses public tools only: ask on openvibe.actor to use your own computers, robots or code.', finished_at: iso() });++ }for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {const placement = route({ cls, mode: row.mode, agent: row.agent, budgetUsd: Math.max(0, budgetLeft()), config, tried, now: now(), floor: row.mode === 'private' ? null : floor });@@@ -131,7 +140,8 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =await emit(id, { kind: 'output', step: attempt > 1 ? 'handoff' : 'plan', agent: agentId, text: `${agent.name} takes it${placement.reasons && placement.reasons[0] ? `: ${placement.reasons[0]}` : ''}.` });let out;try {-- out = await adapters.run[agentId]({ task: row.task, requester: row.requester, taskId: id, cls, mode: row.mode, signal, meter, budgetLeft, emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }) });++ // From chat the runtime is never offered the person's own robots or computers (no requester for its tools).++ out = await adapters.run[agentId]({ task: row.task, requester: fromChat ? null : row.requester, taskId: id, cls, mode: row.mode, signal, meter, budgetLeft, emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }) });} catch (err) {if (signal.aborted) throw err;const why = (err && err.message) || String(err);nnew file mode 100644@@@ -0,0 +1,167 @@++'use strict';++/**++ * Ask Actor from chat (plan T17 "Reach", server/chat/asks.js): "@actor …" in the global chat or the asker's own++ * channel becomes the asker's task, delivered as chat.message.created to POST /internal/events, and the answer is++ * posted back in the same room through Chat's service API, answering the message. Guests, relayed external chatters,++ * other people's channels and ordinary messages are ignored; a redelivery makes no second task; a robot, computer or++ * coding task from chat is refused (public tools only); the hourly limits answer once and then drop quietly; a reply++ * Chat refuses for now is retried.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const { parseAsk, plain, clip } = require('../server/chat/asks');++++const SECRET = `whsec_${'fixture'.repeat(6)}`;++let n = 0;++const message = (user, text, { room = { type: 'global' }, ...extra } = {}) => {++ n += 1;++ return {++ event_id: `evt_01JZ00000000000000000C${String(n).padStart(4, '0')}`, event_type: 'chat.message.created', source: 'chat',++ payload: { message_id: 9000 + n, room, message_type: 'chat', user_id: user.id, user_subject: user.subject, anon_id: null, username: user.username, text, source_platform: null, reply_to_id: null, ...extra },++ };++};++++(async () => {++ await check('which chat messages are asks', () => {++ const kim = { id: 7, subject: 'usr_01JZ0000000000000000000K1M', username: 'kim' };++ const ev = (text, extra) => message(kim, text, extra);++ assert.deepStrictEqual(parseAsk(ev('@actor what is WHIP?')), { messageId: 9000 + n, subject: kim.subject, username: 'kim', room: { type: 'global' }, task: 'what is WHIP?' });++ assert.strictEqual(parseAsk(ev('@Actor, summarise this week\'s news')).task, 'summarise this week\'s news');++ assert.deepStrictEqual(parseAsk(ev('@actor hi', { room: { type: 'channel', channel_user_id: 7, stream_id: 31 } })).room, { type: 'channel', channel_user_id: 7, stream_id: 31 }, 'their own channel');++ assert.strictEqual(parseAsk(ev('@actor hi', { room: { type: 'channel', channel_user_id: 8, stream_id: 2 } })), null, 'someone else\'s channel');++ assert.strictEqual(parseAsk(ev('hello @actor')), null, 'only at the start');++ assert.strictEqual(parseAsk(ev('@actors are great')), null);++ assert.strictEqual(parseAsk(ev('@actor hi', { source_platform: 'twitch' })), null, 'a relayed external chatter');++ assert.strictEqual(parseAsk(ev('@actor hi', { user_subject: null, user_id: null, anon_id: 'anon_1' })), null, 'a guest');++ assert.strictEqual(parseAsk(ev('@actor hi', { message_type: 'system' })), null);++ assert.strictEqual(parseAsk({ ...ev('@actor hi'), event_type: 'chat.message.deleted' }), null);++ assert.strictEqual(plain('## Title\n**WHIP** is [an ingest](https://x) protocol.\n\nSources:\n- https://x'), 'Title WHIP is an ingest protocol.');++ assert.strictEqual(clip('One. Two three four five six seven.', 20), 'One. Two three four…');++ assert.strictEqual(clip('The answer is yes. Then more words follow here.', 30), 'The answer is yes.');++ assert.strictEqual(clip('a'.repeat(30), 10), `${'a'.repeat(10)}…`);++ });++++ const posted = [];++ let chatDown = false;++ const chatClient = { enabled: true, async post(m) { if (chatDown) return { ok: false, status: 503, error: 'down' }; posted.push(m); return { ok: true, status: 200, id: 1 }; } };++ const t = await boot({ env: { ACTOR_EVENTS_SECRET: SECRET, ACTOR_CHAT_ASKS_PER_HOUR: '3' }, chatClient });++ const kim = t.network.addUser('kim');++ const lee = t.network.addUser('lee');++ const ann = t.network.addUser('ann');++ const joe = t.network.addUser('joe');++ const deliver = async (ev) => {++ const body = JSON.stringify({ event: ev, seq: 1 });++ const r = await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });++ assert.strictEqual(r.status, 200, r.text);++ return r.json().outcome;++ };++ const askRow = (ev) => t.ctx.s.db.maybe('SELECT * FROM actor_chat_asks WHERE message_id = $1', [ev.payload.message_id]);++++ let first;++ await check('an @actor message in the global chat becomes the asker\'s task, answered in the room', async () => {++ t.providers.reset();++ t.providers.state.cls = 'answer';++ first = message(kim, '@actor explain WHIP in one line');++ assert.strictEqual(await deliver(first), 'task');++ const row = await askRow(first);++ assert.match(row.task_id, /^tsk_/);++ const task = await t.waitFor(row.task_id);++ assert.deepStrictEqual([task.requester, task.via, task.task, task.state], [`user:${kim.subject}`, 'chat', 'explain WHIP in one line', 'succeeded']);++ assert.strictEqual(await t.ctx.chatAsks.tick(), 1);++ const reply = posted.at(-1);++ assert.deepStrictEqual([reply.key, reply.room, reply.replyTo], [`actor:ask:${first.payload.message_id}`, { type: 'global' }, first.payload.message_id]);++ assert.match(reply.text, new RegExp(`^@kim .+ \\(full answer: https://openvibe\\.actor/tasks/${row.task_id}\\)$`));++ assert.ok(reply.text.length < 600);++ assert.ok((await askRow(first)).replied_at, 'replied once');++ assert.strictEqual(await t.ctx.chatAsks.tick(), 0, 'and not again');++ });++++ await check('the runtime is never handed the asker for its tools: no robots or computers of theirs are offered', async () => {++ const run = t.ctx.adapters.run;++ const real = run['openvibe-runtime'];++ const seen = [];++ run['openvibe-runtime'] = (args) => { seen.push(args.requester); return real(args); };++ try {++ t.providers.reset();++ t.providers.state.cls = 'lookup';++ const ev = message(joe, '@actor who handles mail for openvibe.network?');++ assert.strictEqual(await deliver(ev), 'task');++ const task = await t.waitFor((await askRow(ev)).task_id);++ assert.strictEqual(task.state, 'succeeded', task.error_detail);++ assert.deepStrictEqual(seen, [null], 'from chat: no requester for the tools');++ t.providers.state.cls = 'lookup';++ const api = await t.get('/api/v1/tasks', { as: joe, json: { task: 'Who handles mail for openvibe.network?' }, headers: { 'sec-fetch-site': 'same-origin' } });++ await t.waitFor(api.json().id);++ assert.deepStrictEqual(seen, [null, `user:${joe.subject}`], 'the same task asked on Actor itself: the person\'s own tools');++ } finally { run['openvibe-runtime'] = real; }++ });++++ await check('a redelivery makes no second task; ordinary messages and other people\'s channels are ignored', async () => {++ const before = (await t.ctx.s.db.maybe('SELECT count(*)::int AS n FROM tasks')).n;++ assert.strictEqual(await deliver(first), 'duplicate');++ assert.strictEqual(await deliver(message(kim, 'hello everyone')), 'ignored:not_an_ask');++ assert.strictEqual(await deliver(message(lee, '@actor hi there', { room: { type: 'channel', channel_user_id: kim.id, stream_id: 5 } })), 'ignored:not_an_ask');++ assert.strictEqual(await deliver(message(lee, '@actor hi there', { source_platform: 'youtube' })), 'ignored:not_an_ask');++ assert.strictEqual((await t.ctx.s.db.maybe('SELECT count(*)::int AS n FROM tasks')).n, before);++ });++++ await check('in their own channel the reply goes to that channel and its live stream', async () => {++ t.providers.state.cls = 'answer';++ const ev = message(lee, '@actor what time zone is Lisbon in?', { room: { type: 'channel', channel_user_id: lee.id, stream_id: 44 } });++ assert.strictEqual(await deliver(ev), 'task');++ await t.waitFor((await askRow(ev)).task_id);++ await t.ctx.chatAsks.tick();++ assert.deepStrictEqual(posted.at(-1).room, { type: 'channel', channel_user_id: lee.id, stream_id: 44 });++ });++++ await check('a computer, robot or coding task from chat is refused: public tools only', async () => {++ for (const cls of ['computer', 'robot', 'code']) {++ t.providers.state.cls = cls;++ const ev = message(ann, `@actor do the ${cls} thing`);++ assert.strictEqual(await deliver(ev), 'task');++ const task = await t.waitFor((await askRow(ev)).task_id);++ assert.deepStrictEqual([task.state, task.error_code], ['failed', 'actor.task.not_public'], cls);++ await t.ctx.chatAsks.tick();++ assert.match(posted.at(-1).text, /^@ann from a public chat I only use public tools\. To use your own computer, robots or code, ask me on https:\/\/openvibe\.actor$/);++ }++ });++++ await check('past the hourly limit one reply says so, then further asks are dropped quietly', async () => {++ t.providers.state.cls = 'answer';++ // kim has asked once; the limit is 3 an hour.++ for (const q of ['two', 'three']) {++ const ev = message(kim, `@actor question ${q}`);++ assert.strictEqual(await deliver(ev), 'task');++ await t.waitFor((await askRow(ev)).task_id);++ }++ const over = message(kim, '@actor question four');++ assert.strictEqual(await deliver(over), 'refused:person_hourly');++ const quiet = message(kim, '@actor question five');++ assert.strictEqual(await deliver(quiet), 'refused:person_hourly:quiet');++ const before = posted.length;++ await t.ctx.chatAsks.tick();++ const texts = posted.slice(before).map((p) => p.text);++ assert.ok(texts.includes('@kim that is 3 questions this hour; ask again later, or on https://openvibe.actor'), JSON.stringify(texts));++ assert.ok(!posted.some((p) => p.replyTo === quiet.payload.message_id), 'the quiet one gets no reply');++ assert.ok((await askRow(quiet)).replied_at);++ });++++ await check('a reply Chat cannot take now is retried later', async () => {++ t.providers.state.cls = 'answer';++ const ev = message(joe, '@actor name a prime number');++ await deliver(ev);++ await t.waitFor((await askRow(ev)).task_id);++ chatDown = true;++ assert.strictEqual(await t.ctx.chatAsks.tick(), 0);++ const waiting = await askRow(ev);++ assert.deepStrictEqual([waiting.replied_at, waiting.reply_attempts, waiting.reply_error], [null, 1, 'down']);++ chatDown = false;++ await t.ctx.s.db.query('UPDATE actor_chat_asks SET next_attempt_at = NULL WHERE message_id = $1', [ev.payload.message_id]);++ assert.strictEqual(await t.ctx.chatAsks.tick(), 1);++ assert.strictEqual(posted.at(-1).replyTo, ev.payload.message_id);++ });++++ await t.close();++ done();++})();@@@ -114,9 +114,9 @@ function codeRoutes() {}});-- await check('events: the manifest produces none and consumes only the two account events (ADR-033); the capabilities declare none', () => {++ await check('events: the manifest produces none and consumes the two account events (ADR-033) and chat messages (asks from chat); the capabilities declare none', () => {assert.deepStrictEqual(manifest.eventsProduced, []);-- assert.deepStrictEqual([...manifest.eventsConsumed].sort(), ['network.account.deleted', 'network.account.export_requested']);++ assert.deepStrictEqual([...manifest.eventsConsumed].sort(), ['chat.message.created', 'network.account.deleted', 'network.account.export_requested']);for (const id of CAPS) assert.deepStrictEqual(contracts.capabilities.get(id).events, [], id);});@@@ -42,7 +42,7 @@ async function boot(opts = {}) {const { createStore } = require('../../server/db');const testdb = await require('./db').testDb();const store = createStore(testdb.db, { now: opts.now });-- const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend, webhookPost: opts.webhookPost, secretSigner: opts.secretSigner, bot: opts.bot, projects: opts.projects });++ const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend, webhookPost: opts.webhookPost, chatClient: opts.chatClient, secretSigner: opts.secretSigner, bot: opts.bot, projects: opts.projects });await built.ctx.keys.ensure();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;@@@ -14,8 +14,8 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%// headroom; file counts are the measured counts and external files stay at 0.const BUDGETS = {-- htmlRawKB: 39.5, // measured 35.8 (the guest task box lists example tasks)-- htmlBrotliKB: 9.5, // 8.6++ htmlRawKB: 40.5, // measured 35.8 (the guest task box lists example tasks); 39.6 with the home's "Ask in chat" row (10-15)++ htmlBrotliKB: 10, // 8.6; 9.6 with "Ask in chat"jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)jsRawKB: 264, // 239.8jsBrotliKB: 62.5, // 56.5@@@ -108,6 +108,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };'server/agents/providers.js': 'the configured provider base URLs (DeepSeek, OpenAI, the open model) with the operator\'s keys','server/agents/codes.js': 'the configured OpenVibe.Codes base (ACTOR_CODES_URL) with Actor\'s own service token; the subject must be usr_<ULID> and every task id tsk_<ULID> before it goes in a path; the repository named in the task is data for the person\'s own machine, never fetched here (test/codes.test.js)','server/agents/computers.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token (bot.computer.control); the machine id must be dev_<ULID> and the subject usr_<ULID> before anything is sent; a path or program is data for the person\'s own machine, never fetched here (test/computers.test.js)',++ 'server/chat/client.js': 'the configured OpenVibe.Chat base (ACTOR_CHAT_URL, loopback) with Actor\'s own service token; the room and the message it answers come from a signed chat.message.created, never from the task (test/chat-asks.test.js)','server/agents/robots.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token; the robot id must be rob_<ULID> and the subject usr_<ULID> before anything is sent (test/robots.test.js)','server/identity/projects.js': 'Network\'s internal membership read (configured networkInternalUrl) with Actor\'s own service token (network.project.member.read); the project and subject ids are path-encoded and come from a parsed OVRN and a checked usr_ id (test/resource-control.test.js)','server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input',
@@ -119,7 +119,14 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
@@ -119,7 +119,14 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
- Reading, screenshots and closing run at every level; the rest wait for approval at `ask`. Below `full` the machine
- Reading, screenshots and closing run at every level; the rest wait for approval at `ask`. Below `full` the machine
refuses addresses on its own network.
refuses addresses on its own network.
- Approvals can come from the Node's tray or notification buttons, as well as `openvibe-node approve` (Node v0.11.0).
- Approvals can come from the Node's tray or notification buttons, as well as `openvibe-node approve` (Node v0.11.0).
-- **OpenVibe.Events** (`ACTOR_EVENTS_URL`): the two ADR-033 subscriptions created at boot, and delivery to the loopback `/internal/events`.
+- **OpenVibe.Events** (`ACTOR_EVENTS_URL`): the two ADR-033 subscriptions created at boot, plus `chat.message.created` while asking from chat is on, and delivery to the loopback `/internal/events`.
+- **OpenVibe.Chat** (`ACTOR_CHAT_URL`, default `http://127.0.0.1:4400`): **ask Actor from chat** ([server/chat/asks.js](server/chat/asks.js)).
+ - **Who and where:** a signed-in person writes `@actor <task>` in the global chat or their own channel's chat. Guests, relayed external chatters (`source_platform`) and other people's channels are ignored.
+ - **How it runs:** the message becomes their task, on their free tier, with `via: chat`. The answer is posted where everyone reads it, so the task uses public tools only. The engine refuses a robot, computer or coding task (`actor.task.not_public`), and the runtime is never given the person's robots or computers.
+ - **The reply:** the first lines of the answer and the task's page, posted in the same room as "OpenVibe Actor", answering the message. It goes through Chat's service API (`chat.message.send`, audience `openvibe.chat`) and is retried with backoff for up to an hour.
+ - **Limits:** 5 asks an hour per person and 60 across everyone (`ACTOR_CHAT_ASKS_PER_HOUR`, `ACTOR_CHAT_ASKS_ALL_PER_HOUR`). Past a limit, one reply says so and further asks are dropped quietly.
+ - **Data:** `actor_chat_asks` (migration 0009) is part of the person's export and erasure.
+ - `ACTOR_CHAT_ASKS=0` turns it off.
- **The model providers**: DeepSeek (`ACTOR_DEEPSEEK_API_KEY`, `api.deepseek.com`), OpenAI (`ACTOR_OPENAI_API_KEY`, `api.openai.com/v1`) for the web-search agent and the cross-family check, and an OpenAI-compatible local server (`ACTOR_LOCAL_LLM_URL`) for private mode.
- **The model providers**: DeepSeek (`ACTOR_DEEPSEEK_API_KEY`, `api.deepseek.com`), OpenAI (`ACTOR_OPENAI_API_KEY`, `api.openai.com/v1`) for the web-search agent and the cross-family check, and an OpenAI-compatible local server (`ACTOR_LOCAL_LLM_URL`) for private mode.
**Data:** PostgreSQL (openvibe-sdk/db, `DATABASE_URL` / `DATABASE_DIRECT_URL`) holds tasks, their events and daily spend; Valkey (`VALKEY_URL`) holds the shared per-caller limit counters. A provider or service without its key or URL is listed unavailable, never invented.
**Data:** PostgreSQL (openvibe-sdk/db, `DATABASE_URL` / `DATABASE_DIRECT_URL`) holds tasks, their events and daily spend; Valkey (`VALKEY_URL`) holds the shared per-caller limit counters. A provider or service without its key or URL is listed unavailable, never invented.
@@ -139,7 +146,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
@@ -139,7 +146,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
2. Coding through OpenVibe.Codes on hosted Run sandboxes.
2. Coding through OpenVibe.Codes on hosted Run sandboxes.
3. More agent platforms behind the same router, bring-your-own keys through OpenVibe.AI, and paid tiers through Billing.
3. More agent platforms behind the same router, bring-your-own keys through OpenVibe.AI, and paid tiers through Billing.
4. Personal agents v1 are in this worktree: named instructions, memory people control, hourly/daily/weekly schedules and ordinary task runs. An approval inbox (a coding task an agent wants to start waits for its owner) and Watch triggers (a watch hands its
4. Personal agents v1 are in this worktree: named instructions, memory people control, hourly/daily/weekly schedules and ordinary task runs. An approval inbox (a coding task an agent wants to start waits for its owner) and Watch triggers (a watch hands its
-task to an agent when its condition fires) are built. Chat and Frame access come later.
+task to an agent when its condition fires) are built, and so is asking Actor from chat (`@actor …`). Frame access comes later.
## Configuration
## Configuration
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+-- phase: expand
+-- Plan T17 "Reach": a person asks Actor from OpenVibe chat ("@actor …" in the global chat or their own channel's
+-- chat). One row per chat message that asked: the task it became, or why none was made, and the reply Actor posts
+-- back in the same room. Kept with the person's data (account export and deletion).
+CREATE TABLE actor_chat_asks (
+ message_id bigint PRIMARY KEY, -- Chat's message id (Events delivers at least once)
+ requester text COLLATE "C" NOT NULL, -- user:usr_…
+ username text NOT NULL,
+ room jsonb NOT NULL, -- { type: global } | { type: channel, channel_user_id, stream_id }
+ task_id text COLLATE "C", -- the task it became
+ refused text, -- why no task was made (the reply says so)
+ created_at text COLLATE "C" NOT NULL,
+ replied_at text COLLATE "C",
+ reply_attempts integer NOT NULL DEFAULT 0,
+ next_attempt_at text COLLATE "C",
+ reply_error text
+);
+CREATE INDEX actor_chat_asks_requester ON actor_chat_asks (requester, created_at DESC);
+CREATE INDEX actor_chat_asks_created ON actor_chat_asks (created_at);
+CREATE INDEX actor_chat_asks_waiting ON actor_chat_asks (created_at) WHERE replied_at IS NULL;
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.178.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.183.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.9",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.9",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -871,9 +871,9 @@
@@ -871,9 +871,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.178.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.178.0",
- "integrity": "sha512-86Q1Mmo+BvMnF2GG2IZrbHq8iZ2FUfED28rnDcFlgZTtIzHxDQdi2dY+Xe8WbXc4EebjGUYhEl5J3LReA2wiqg==",
+ "version": "0.183.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.183.0",
+ "integrity": "sha512-8uXsBTJ0pNUKiDe2gUnKjtkETJbfJUL54qB5wRHfss5RIKZ2i56Ab50Esf4qCP0p2kAawGUGq3xahmeRM7UqGA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.178.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.183.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.9",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.9",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -25,6 +25,9 @@ const catalog = require('./agents/catalog');
@@ -25,6 +25,9 @@ const catalog = require('./agents/catalog');
const { createAdapters } = require('./agents/adapters');
const { createAdapters } = require('./agents/adapters');
const { createEngine } = require('./tasks/engine');
const { createEngine } = require('./tasks/engine');
const { createWebhooks } = require('./tasks/webhooks');
const { createWebhooks } = require('./tasks/webhooks');
+const { createChatClient } = require('./chat/client');
+const { createChatAsks } = require('./chat/asks');
+const { createTask } = require('./tasks/create');
const { createWebhookPoster } = require('./net/webhook-post');
const { createWebhookPoster } = require('./net/webhook-post');
const { createTaskStream } = require('./tasks/stream');
const { createTaskStream } = require('./tasks/stream');
const { createPrincipal } = require('./http/principal');
const { createPrincipal } = require('./http/principal');
@@ -69,7 +72,11 @@ async function createApp(opts = {}) {
@@ -69,7 +72,11 @@ async function createApp(opts = {}) {
const engine = createEngine({ config, s, adapters, stream, webhooks, now: s.now, log });
const engine = createEngine({ config, s, adapters, stream, webhooks, now: s.now, log });
const principal = createPrincipal({ config, keys });
const principal = createPrincipal({ config, keys });
const projects = opts.projects || require('./identity/projects').createProjects({ config });
const projects = opts.projects || require('./identity/projects').createProjects({ config });
- const ctx = { config, s, keys, sso, stream, adapters, engine, webhooks, principal, projects, log };
+ // Ask Actor from chat (chat/asks.js): chat.message.created through the events consumer below, replies through Chat.
+ const chatClient = opts.chatClient || createChatClient({ config, fetchImpl });
+ const chatAsks = createChatAsks({ s, config, chat: chatClient, now: s.now, log,
+ createTask: (args) => createTask({ s, config, engine, webhooks, ...args }) });
+ const ctx = { config, s, keys, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks };
// Account export and deletion (ADR-033, ./identity/account-data.js): the two tables that hold a person's rows.
// Account export and deletion (ADR-033, ./identity/account-data.js): the two tables that hold a person's rows.
// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;
// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;
@@ -138,7 +145,9 @@ async function createApp(opts = {}) {
@@ -138,7 +145,9 @@ async function createApp(opts = {}) {
// network.account.export_requested and network.account.deleted (ADR-033), answered by the SDK's consumer. It
// network.account.export_requested and network.account.deleted (ADR-033), answered by the SDK's consumer. It
// reads the raw body itself, so it is mounted before any body parser (there is none above it), and nginx answers
// reads the raw body itself, so it is mounted before any body parser (there is none above it), and nginx answers
// 404 for /internal/ so this is reachable only on 127.0.0.1.
// 404 for /internal/ so this is reachable only on 127.0.0.1.
- app.use('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log }));
+ // chat.message.created goes to chat/asks.js (ask Actor from chat); anything else is acknowledged and ignored.
+ app.use('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log,
+ onEvent: (ev) => (ev.event_type === 'chat.message.created' ? chatAsks.onEvent(ev) : 'ignored:type') }));
// ── Static assets (content-hashed ?v= → immutable) ──────
// ── Static assets (content-hashed ?v= → immutable) ──────
app.use('/shared', require('openvibe-shared/serve').handler());
app.use('/shared', require('openvibe-shared/serve').handler());
new file mode 100644
new file mode 100644
@@ -0,0 +1,181 @@
@@ -0,0 +1,181 @@
+'use strict';
+/**
+ * Ask Actor from chat (plan T17 "Reach"). A signed-in person writes "@actor <task>" in OpenVibe's global chat or in
+ * their own channel's chat; OpenVibe.Chat publishes chat.message.created, Events delivers it to POST /internal/events,
+ * and Actor runs the task as theirs (their free tier, their daily budget) and answers in the same room.
+ *
+ * - Who: a person signed in (a usr_… subject and a Live user id), never a guest, a relayed external chatter
+ * (source_platform) or Actor itself. Where: the global chat, or the asker's own channel (no one else's).
+ * - What: the answer is posted where everyone reads it, so the task runs with public tools only: the engine refuses a
+ * robot, computer or coding task from chat (actor.task.not_public) and never offers the runtime the person's own
+ * robots or computers (tasks/engine.js, via 'chat').
+ * - How often: at most `perHour` asks per person and `allPerHour` across everyone; past either, one reply says so
+ * and further asks that hour are dropped quietly. The free tier's own limits apply on top.
+ * - The reply: the first lines of the answer (at most REPLY_MAX characters) and the task's page, addressed to the
+ * asker, answering their message. Replies are retried with backoff and given up after an hour.
+ *
+ * onEvent(event) is the consumer's hook for chat.message.created (other topics are not handed here); tick() posts
+ * the replies that are due (started by server/index.js).
+ */
+const MENTION = /^\s*@actor\b[\s,:;-]*([\s\S]*)$/i;
+const TASK_MIN = 3;
+const TASK_MAX = 1000;
+const REPLY_MAX = 420;
+const OPEN = ['queued', 'running', 'verifying'];
+const GIVE_UP_MS = 60 * 60_000;
+const MAX_ATTEMPTS = 8;
+
+/** The ask in a chat.message.created event, or null when it is not one Actor takes. */
+function parseAsk(ev) {
+ if (!ev || ev.event_type !== 'chat.message.created') return null;
+ const p = ev.payload || {};
+ if ((p.message_type || 'chat') !== 'chat' || p.source_platform || p.anon_id) return null;
+ if (typeof p.user_subject !== 'string' || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(p.user_subject)) return null;
+ if (!Number.isSafeInteger(p.user_id) || p.user_id <= 0 || !Number.isSafeInteger(p.message_id) || p.message_id <= 0) return null;
+ const room = p.room || {};
+ let where;
+ if (room.type === 'global') where = { type: 'global' };
+ else if (room.type === 'channel' && room.channel_user_id === p.user_id) {
+ where = { type: 'channel', channel_user_id: p.user_id, ...(Number.isSafeInteger(room.stream_id) ? { stream_id: room.stream_id } : {}) };
+ } else return null;
+ const m = MENTION.exec(String(p.text || ''));
+ if (!m) return null;
+ const task = m[1].trim();
+ return { messageId: p.message_id, subject: p.user_subject, username: String(p.username || 'there').slice(0, 60), room: where, task };
+}
+
+/** Markdown to one plain line: no headings, emphasis, code fences or link syntax (the link text stays). */
+function plain(md) {
+ return String(md || '')
+ .replace(/```[\s\S]*?```/g, ' ')
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
+ .replace(/^#{1,6}\s*/gm, '')
+ .replace(/^\s*(sources?|references?):?\s*$[\s\S]*/im, '')
+ .replace(/[*_`>]+/g, '')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+/** At most `max` characters, cut at a sentence or word end, with an ellipsis when cut. */
+function clip(text, max) {
+ if (text.length <= max) return text;
+ const cut = text.slice(0, max);
+ const sentence = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? '));
+ if (sentence > max * 0.5) return cut.slice(0, sentence + 1);
+ const space = cut.lastIndexOf(' ');
+ return `${cut.slice(0, space > max * 0.5 ? space : max).trimEnd()}…`;
+}
+
+function createChatAsks({ s, config, createTask, chat, now = () => Date.now(), log = console }) {
+ const iso = (ms = now()) => new Date(ms).toISOString();
+ const taskUrl = (id) => `${config.baseUrl}/tasks/${id}`;
+ let timer = null;
+ let busy = false;
+
+ async function countSince(requester, sinceMs) {
+ const where = requester ? 'requester = $2 AND ' : '';
+ const row = await s.db.maybe(`SELECT count(*)::int AS n FROM actor_chat_asks WHERE ${where}created_at >= $1`, requester ? [iso(sinceMs), requester] : [iso(sinceMs)]);
+ return row ? Number(row.n) : 0;
+ }
+ async function refusedSince(requester, sinceMs, reason) {
+ const row = await s.db.maybe('SELECT 1 AS x FROM actor_chat_asks WHERE requester = $1 AND created_at >= $2 AND refused = $3 LIMIT 1', [requester, iso(sinceMs), reason]);
+ return !!row;
+ }
+
+ /** The consumer's hook: take an ask, once per chat message → the outcome Events is answered with. */
+ async function onEvent(ev) {
+ if (!config.chat.asks) return 'ignored:chat_asks_off';
+ const ask = parseAsk(ev);
+ if (!ask) return 'ignored:not_an_ask';
+ const requester = `user:${ask.subject}`;
+ const at = now();
+ const inserted = await s.db.maybe(
+ `INSERT INTO actor_chat_asks (message_id, requester, username, room, created_at) VALUES ($1, $2, $3, $4::jsonb, $5)
+ ON CONFLICT (message_id) DO NOTHING RETURNING message_id`,
+ [ask.messageId, requester, ask.username, JSON.stringify(ask.room), iso(at)]);
+ if (!inserted) return 'duplicate';
+ const refuse = async (reason, quietIfRepeated) => {
+ const quiet = quietIfRepeated && await refusedSince(requester, at - 3600_000, reason);
+ await s.db.query('UPDATE actor_chat_asks SET refused = $2, replied_at = $3 WHERE message_id = $1', [ask.messageId, reason, quiet ? iso(at) : null]);
+ return quiet ? `refused:${reason}:quiet` : `refused:${reason}`;
+ };
+ if (ask.task.length < TASK_MIN) return refuse('empty', true);
+ if (ask.task.length > TASK_MAX) return refuse('too_long', false);
+ // The row just written counts, so "more than" the limit.
+ if (await countSince(requester, at - 3600_000) > config.chat.perHour) return refuse('person_hourly', true);
+ if (await countSince(null, at - 3600_000) > config.chat.allPerHour) return refuse('everyone_hourly', true);
+ const principal = { kind: 'user', requester, via: 'chat', project: null, claims: {} };
+ const out = await createTask({ principal, body: { task: ask.task, idempotency_key: `chat:${ask.messageId}` } });
+ if (out.status === 201 || out.status === 200) {
+ await s.db.query('UPDATE actor_chat_asks SET task_id = $2 WHERE message_id = $1', [ask.messageId, out.task.id]);
+ return 'task';
+ }
+ await s.db.query('UPDATE actor_chat_asks SET refused = $2 WHERE message_id = $1', [ask.messageId, `tier:${String(out.detail || out.code || 'refused').slice(0, 300)}`]);
+ return `refused:${out.code || 'tier'}`;
+ }
+
+ const REFUSALS = {
+ empty: 'ask me something after @actor, for example "@actor what is WHIP?"',
+ too_long: `that is longer than ${TASK_MAX} characters; ask on ${config.baseUrl} instead`,
+ person_hourly: `that is ${config.chat.perHour} questions this hour; ask again later, or on ${config.baseUrl}`,
+ everyone_hourly: `I have answered a lot in chat this hour; try again later, or ask on ${config.baseUrl}`,
+ };
+
+ /** The reply for one ask whose task ended (or that was refused). */
+ function replyText(row) {
+ const to = `@${row.username}`;
+ if (row.refused) {
+ const why = row.refused.startsWith('tier:') ? row.refused.slice(5) : REFUSALS[row.refused] || 'I cannot take that right now';
+ return `${to} ${why}`;
+ }
+ if (row.state === 'succeeded') {
+ const result = typeof row.result === 'string' ? JSON.parse(row.result) : row.result || {};
+ const answer = clip(plain(result.answer) || 'Done.', REPLY_MAX);
+ return `${to} ${answer} (full answer: ${taskUrl(row.task_id)})`;
+ }
+ if (row.error_code === 'actor.task.not_public') {
+ return `${to} from a public chat I only use public tools. To use your own computer, robots or code, ask me on ${config.baseUrl}`;
+ }
+ const why = clip(plain(row.error_detail || 'it did not finish'), 200);
+ return `${to} I could not finish that: ${why} (${taskUrl(row.task_id)})`;
+ }
+
+ /** Post the replies that are due: refused asks at once, task asks once the task has ended. */
+ async function tick() {
+ if (busy || !chat.enabled) return 0;
+ busy = true;
+ let sent = 0;
+ try {
+ const due = await s.db.many(
+ `SELECT a.*, t.state, t.result, t.error_code, t.error_detail FROM actor_chat_asks a LEFT JOIN tasks t ON t.id = a.task_id
+ WHERE a.replied_at IS NULL AND (a.next_attempt_at IS NULL OR a.next_attempt_at <= $1)
+ AND (a.refused IS NOT NULL OR (a.task_id IS NOT NULL AND t.state IS NOT NULL AND t.state <> ALL($2::text[])))
+ ORDER BY a.created_at LIMIT 10`, [iso(), OPEN]);
+ for (const row of due) {
+ const r = await chat.post({ key: `actor:ask:${row.message_id}`, room: typeof row.room === 'string' ? JSON.parse(row.room) : row.room, text: replyText(row), replyTo: Number(row.message_id) });
+ if (r.ok) {
+ await s.db.query('UPDATE actor_chat_asks SET replied_at = $2, reply_error = NULL WHERE message_id = $1', [row.message_id, iso()]);
+ sent++;
+ continue;
+ }
+ const attempts = Number(row.reply_attempts) + 1;
+ const late = now() - Date.parse(row.created_at) > GIVE_UP_MS;
+ // A refusal Chat will never take back (4xx but 408/429) is given up at once; the rest back off.
+ const final = late || attempts >= MAX_ATTEMPTS || (r.status >= 400 && r.status < 500 && r.status !== 408 && r.status !== 429);
+ await s.db.query('UPDATE actor_chat_asks SET reply_attempts = $2, next_attempt_at = $3, reply_error = $4, replied_at = $5 WHERE message_id = $1',
+ [row.message_id, attempts, iso(now() + Math.min(600_000, 5000 * 2 ** attempts)), String(r.error || '').slice(0, 300), final ? iso() : null]);
+ if (final) log.warn(`[Actor] chat reply to message ${row.message_id} given up: ${r.error}`);
+ }
+ } finally { busy = false; }
+ return sent;
+ }
+
+ return {
+ onEvent, tick, replyText,
+ start() { if (!timer && config.chat.asks && chat.enabled) { timer = setInterval(() => { tick().catch((err) => log.warn(`[Actor] chat replies: ${err && err.message}`)); }, config.chat.replyIntervalMs); timer.unref(); } },
+ stop() { if (timer) clearInterval(timer); timer = null; },
+ };
+}
+
+module.exports = { createChatAsks, parseAsk, plain, clip, REPLY_MAX };
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+'use strict';
+/**
+ * OpenVibe.Chat, for the replies Actor posts where a person asked it (server/chat/asks.js): Chat's service API,
+ * POST /internal/chat/messages (capability chat.message.send, audience openvibe.chat), as Actor's own service
+ * principal. A message names its room (the global chat, or a channel and its live stream), the message it answers and
+ * an idempotency key, and shows as "OpenVibe Actor" with the AI and bot marks. Off without ACTOR_CHAT_URL or a client
+ * secret.
+ */
+const { serviceAuth } = require('openvibe-contracts');
+
+const NAME = 'Actor';
+const DISPLAY = 'OpenVibe Actor';
+
+function createChatClient({ config, fetchImpl = globalThis.fetch, tokens: tokensOverride = null }) {
+ const base = config.chat.url;
+ const enabled = !!(base && (tokensOverride || config.oauth.clientSecret));
+ const tokens = !enabled ? null : tokensOverride || serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.chat', scope: 'chat.message.send', fetchImpl,
+ });
+
+ /** Post `text` in `room`, answering message `replyTo` → { ok, status, id?, error? }. `key` makes a retry a no-op. */
+ async function post({ key, room, text, replyTo = null }) {
+ if (!enabled) return { ok: false, status: 0, error: 'chat replies are off here (ACTOR_CHAT_URL)' };
+ const where = room && room.type === 'channel'
+ ? { channel_user_id: room.channel_user_id, ...(room.stream_id ? { stream_id: room.stream_id } : {}) }
+ : { is_global: true };
+ const body = {
+ key, username: NAME, message: text, message_type: 'chat', ...where,
+ ...(replyTo ? { reply_to_id: replyTo } : {}),
+ frame: { is_ai: true, is_bot: true, display_name: DISPLAY },
+ };
+ try {
+ const res = await fetchImpl(`${base}/internal/chat/messages`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokens.authHeaders()) },
+ body: JSON.stringify(body), signal: AbortSignal.timeout(10_000),
+ });
+ const data = await res.json().catch(() => null);
+ if (!res.ok) return { ok: false, status: res.status, error: (data && (data.detail || data.error || data.title)) || `Chat answered ${res.status}` };
+ return { ok: true, status: res.status, id: data && data.id };
+ } catch (err) {
+ return { ok: false, status: 0, error: (err && err.message) || String(err) };
+ }
+ }
+
+ return { enabled, post };
+}
+
+module.exports = { createChatClient, NAME, DISPLAY };
@@ -95,6 +95,17 @@ function load(env = process.env) {
@@ -95,6 +95,17 @@ function load(env = process.env) {
timeoutMs: Math.max(1000, int(env.ACTOR_WEBHOOK_TIMEOUT_MS, 10_000)),
timeoutMs: Math.max(1000, int(env.ACTOR_WEBHOOK_TIMEOUT_MS, 10_000)),
},
},
+ // Ask Actor from chat (server/chat/asks.js): "@actor …" in OpenVibe's global chat or the person's own channel,
+ // taken from chat.message.created and answered in the same room through Chat's service API (url, loopback in
+ // production). ACTOR_CHAT_ASKS=0 turns it off; the limits are per person and across everyone, per hour.
+ chat: {
+ asks: env.ACTOR_CHAT_ASKS !== '0',
+ url: trim(env.ACTOR_CHAT_URL != null ? env.ACTOR_CHAT_URL : 'http://127.0.0.1:4400'),
+ perHour: Math.max(1, int(env.ACTOR_CHAT_ASKS_PER_HOUR, 5)),
+ allPerHour: Math.max(1, int(env.ACTOR_CHAT_ASKS_ALL_PER_HOUR, 60)),
+ replyIntervalMs: Math.max(200, int(env.ACTOR_CHAT_REPLY_INTERVAL_MS, 2000)),
+ },
+
// OpenVibe.Events → this service (server/events-consumer.js). The secret signs a delivery (comma-separated
// OpenVibe.Events → this service (server/events-consumer.js). The secret signs a delivery (comma-separated
// for rotation, 32+ characters each); unset turns POST /internal/events off (503). The url is where the
// for rotation, 32+ characters each); unset turns POST /internal/events off (503). The url is where the
// network.account.export_requested and network.account.deleted subscriptions are created at boot, off when
// network.account.export_requested and network.account.deleted subscriptions are created at boot, off when
@@ -15,7 +15,7 @@ const { serviceAuth } = require('openvibe-contracts');
@@ -15,7 +15,7 @@ const { serviceAuth } = require('openvibe-contracts');
const { TOPICS } = require('./identity/account-data');
const { TOPICS } = require('./identity/account-data');
/** Create any missing subscription for TOPICS at Events (idempotent; retried in the background at boot). */
/** Create any missing subscription for TOPICS at Events (idempotent; retried in the background at boot). */
-function startSubscriptions({ config, port, secret, eventsUrl = config.events.url, fetchImpl = globalThis.fetch, log = console }) {
+function startSubscriptions({ config, port, secret, eventsUrl = config.events.url, fetchImpl = globalThis.fetch, log = console, topics: extra = [] }) {
if (!eventsUrl || !secret || !config.oauth.clientSecret || process.env.ACTOR_EVENTS_SUBSCRIBE === '0') return null;
if (!eventsUrl || !secret || !config.oauth.clientSecret || process.env.ACTOR_EVENTS_SUBSCRIBE === '0') return null;
const base = String(eventsUrl).replace(/\/+$/, '');
const base = String(eventsUrl).replace(/\/+$/, '');
const endpoint = config.events.endpoint || `http://127.0.0.1:${port}/internal/events`;
const endpoint = config.events.endpoint || `http://127.0.0.1:${port}/internal/events`;
@@ -29,7 +29,8 @@ function startSubscriptions({ config, port, secret, eventsUrl = config.events.ur
@@ -29,7 +29,8 @@ function startSubscriptions({ config, port, secret, eventsUrl = config.events.ur
const listed = await call('GET', '/api/v1/subscriptions');
const listed = await call('GET', '/api/v1/subscriptions');
if (!listed.ok) throw new Error(`listing subscriptions: ${listed.status}`);
if (!listed.ok) throw new Error(`listing subscriptions: ${listed.status}`);
const mine = (listed.body.subscriptions || []).filter((s) => s.endpoint === endpoint);
const mine = (listed.body.subscriptions || []).filter((s) => s.endpoint === endpoint);
- for (const topic of TOPICS) {
+ // The ADR-033 topics, and chat.message.created while asking Actor from chat is on (chat/asks.js).
+ for (const topic of [...TOPICS, ...extra]) {
if (mine.some((s) => s.topic_pattern === topic)) continue;
if (mine.some((s) => s.topic_pattern === topic)) continue;
const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });
const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });
if (!r.ok && r.status !== 409) throw new Error(`subscribing to ${topic}: ${r.status} ${r.body.code || ''}`);
if (!r.ok && r.status !== 409) throw new Error(`subscribing to ${topic}: ${r.status} ${r.body.code || ''}`);
@@ -146,6 +146,7 @@ ${table(['Piece', 'State'], [
@@ -146,6 +146,7 @@ ${table(['Piece', 'State'], [
[html`Coding tasks on your own machine, through <a href="https://openvibe.codes/run">OpenVibe.Codes</a>: name the repository and what to change, and get the summary, the files and the patch`, html`Live: add the machine on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a> and start one task on openvibe.codes/run first. The model use is on your machine's own keys.`],
[html`Coding tasks on your own machine, through <a href="https://openvibe.codes/run">OpenVibe.Codes</a>: name the repository and what to change, and get the summary, the files and the patch`, html`Live: add the machine on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a> and start one task on openvibe.codes/run first. The model use is on your machine's own keys.`],
[html`Your own computers, through <a href="https://openvibe.bot/docs/computers">OpenVibe.Bot</a>: list and read files, run programs and write files there, as you, at the level you set on the computer`, html`Live: install OpenVibe Node with <code>--computer ask</code> and turn on "Let my agent use this computer" on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a>. At <code>ask</code>, you approve each run or write on the computer.`],
[html`Your own computers, through <a href="https://openvibe.bot/docs/computers">OpenVibe.Bot</a>: list and read files, run programs and write files there, as you, at the level you set on the computer`, html`Live: install OpenVibe Node with <code>--computer ask</code> and turn on "Let my agent use this computer" on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a>. At <code>ask</code>, you approve each run or write on the computer.`],
[html`<a href="/console">The Console</a>: what runs now, what waits for you, today's spend, your agents, computers and robots, and one box to start a task`, 'Live, for signed-in people.'],
[html`<a href="/console">The Console</a>: what runs now, what waits for you, today's spend, your agents, computers and robots, and one box to start a task`, 'Live, for signed-in people.'],
+ [html`Ask in chat: write <code>@actor</code> and your question in OpenVibe's global chat or your own channel, and the answer comes back in the room`, 'Live: public tools only (never your computers, robots or code), 5 an hour, on your free tier.'],
])}</section>
])}</section>
${raw(showcase.cta({ title: 'Build on it', text: 'Every task, its live stream, its webhooks and the router are an API. Apps get actor.task.* grants on OpenVibe.Services.', actions: [{ label: 'Read the API', href: '/docs', primary: true }, { label: 'See the agents', href: '/agents' }] }))}`,
${raw(showcase.cta({ title: 'Build on it', text: 'Every task, its live stream, its webhooks and the router are an API. Apps get actor.task.* grants on OpenVibe.Services.', actions: [{ label: 'Read the API', href: '/docs', primary: true }, { label: 'See the agents', href: '/agents' }] }))}`,
}, status);
}, status);
@@ -39,6 +39,8 @@ const TABLES = [
@@ -39,6 +39,8 @@ const TABLES = [
{ table: 'actor_agents', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agents.json' },
{ table: 'actor_agents', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agents.json' },
// What the person's agents asked them to approve (agents/approvals.js), and what they decided.
// What the person's agents asked them to approve (agents/approvals.js), and what they decided.
{ table: 'actor_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approvals.json' },
{ table: 'actor_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approvals.json' },
+ // What they asked Actor in chat (chat/asks.js): the message it answered, the room, the task it became.
+ { table: 'actor_chat_asks', subject: 'requester', value: (usr) => `user:${usr}`, file: 'chat-asks.json' },
];
];
/** The account-data handle for Actor's store (server/db.js createStore). */
/** The account-data handle for Actor's store (server/db.js createStore). */
@@ -19,7 +19,7 @@ const PRUNE_EVERY_MS = 6 * 60 * 60_000;
@@ -19,7 +19,7 @@ const PRUNE_EVERY_MS = 6 * 60 * 60_000;
function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
return gracefulStop({
return gracefulStop({
name: 'Actor', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
name: 'Actor', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
- close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.engine.stop(), () => ctx.webhooks.stop(), () => ctx.keys.client.stop(), () => ctx.s.close(), ...extra],
+ close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.engine.stop(), () => ctx.webhooks.stop(), () => ctx.chatAsks.stop(), () => ctx.keys.client.stop(), () => ctx.s.close(), ...extra],
});
});
}
}
@@ -33,6 +33,8 @@ async function start() {
@@ -33,6 +33,8 @@ async function start() {
for (const id of interrupted) await ctx.webhooks.enqueue(id, 'failed').catch((err) => console.warn('[Actor] webhook enqueue failed:', id, err && err.message));
for (const id of interrupted) await ctx.webhooks.enqueue(id, 'failed').catch((err) => console.warn('[Actor] webhook enqueue failed:', id, err && err.message));
await ctx.webhooks.recover();
await ctx.webhooks.recover();
ctx.webhooks.start();
ctx.webhooks.start();
+ // Replies to "@actor …" in chat (chat/asks.js), posted once their task has ended.
+ ctx.chatAsks.start();
const server = app.listen(config.port, config.host, () => {
const server = app.listen(config.port, config.host, () => {
const available = require('./agents/catalog').list(config).filter((a) => a.available).map((a) => a.id);
const available = require('./agents/catalog').list(config).filter((a) => a.available).map((a) => a.id);
@@ -48,7 +50,8 @@ async function start() {
@@ -48,7 +50,8 @@ async function start() {
// Subscribe to the two ADR-033 topics at OpenVibe.Events (idempotent; off without ACTOR_EVENTS_URL and
// Subscribe to the two ADR-033 topics at OpenVibe.Events (idempotent; off without ACTOR_EVENTS_URL and
// ACTOR_EVENTS_SECRET). The consumer itself is mounted in server/app.js.
// ACTOR_EVENTS_SECRET). The consumer itself is mounted in server/app.js.
- const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '' });
+ const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '',
+ topics: config.chat.asks ? ['chat.message.created'] : [] });
const extra = [() => { if (subscriptions) subscriptions.stop(); }];
const extra = [() => { if (subscriptions) subscriptions.stop(); }];
createLifecycle({ server, ctx, timers: [prune, agentTick], extra });
createLifecycle({ server, ctx, timers: [prune, agentTick], extra });
return { server, ctx };
return { server, ctx };
@@ -24,6 +24,9 @@ const { repoIn: codesRepoIn } = require('../agents/codes');
@@ -24,6 +24,9 @@ const { repoIn: codesRepoIn } = require('../agents/codes');
const MAX_ATTEMPTS = 2;
const MAX_ATTEMPTS = 2;
+// What a task asked in a public chat may be (chat/asks.js): nothing that uses the person's own computers, robots or code.
+const PUBLIC_CLASSES = ['answer', 'lookup', 'research', 'web'];
+
function createEngine({ config, s, adapters, stream, webhooks = null, now = () => Date.now(), log = console }) {
function createEngine({ config, s, adapters, stream, webhooks = null, now = () => Date.now(), log = console }) {
const running = new Map(); // id → AbortController
const running = new Map(); // id → AbortController
const waiting = [];
const waiting = [];
@@ -100,6 +103,12 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -100,6 +103,12 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
cls = c.class;
cls = c.class;
}
}
await emit(id, { kind: 'output', step: 'plan', text: `This is a "${cls}" task.` });
await emit(id, { kind: 'output', step: 'plan', text: `This is a "${cls}" task.` });
+ // Asked in a public chat (chat/asks.js): the answer is posted where everyone reads it, so only what needs
+ // nothing of the person's own — never their computers, robots or code on their machine.
+ const fromChat = row.via === 'chat';
+ if (fromChat && !PUBLIC_CLASSES.includes(cls)) {
+ return await setState(id, 'failed', { error_code: 'actor.task.not_public', error_detail: 'Asked in a public chat, Actor uses public tools only: ask on openvibe.actor to use your own computers, robots or code.', finished_at: iso() });
+ }
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const placement = route({ cls, mode: row.mode, agent: row.agent, budgetUsd: Math.max(0, budgetLeft()), config, tried, now: now(), floor: row.mode === 'private' ? null : floor });
const placement = route({ cls, mode: row.mode, agent: row.agent, budgetUsd: Math.max(0, budgetLeft()), config, tried, now: now(), floor: row.mode === 'private' ? null : floor });
@@ -131,7 +140,8 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -131,7 +140,8 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
await emit(id, { kind: 'output', step: attempt > 1 ? 'handoff' : 'plan', agent: agentId, text: `${agent.name} takes it${placement.reasons && placement.reasons[0] ? `: ${placement.reasons[0]}` : ''}.` });
await emit(id, { kind: 'output', step: attempt > 1 ? 'handoff' : 'plan', agent: agentId, text: `${agent.name} takes it${placement.reasons && placement.reasons[0] ? `: ${placement.reasons[0]}` : ''}.` });
let out;
let out;
try {
try {
- out = await adapters.run[agentId]({ task: row.task, requester: row.requester, taskId: id, cls, mode: row.mode, signal, meter, budgetLeft, emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }) });
+ // From chat the runtime is never offered the person's own robots or computers (no requester for its tools).
+ out = await adapters.run[agentId]({ task: row.task, requester: fromChat ? null : row.requester, taskId: id, cls, mode: row.mode, signal, meter, budgetLeft, emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }) });
} catch (err) {
} catch (err) {
if (signal.aborted) throw err;
if (signal.aborted) throw err;
const why = (err && err.message) || String(err);
const why = (err && err.message) || String(err);
new file mode 100644
new file mode 100644
@@ -0,0 +1,167 @@
@@ -0,0 +1,167 @@
+'use strict';
+/**
+ * Ask Actor from chat (plan T17 "Reach", server/chat/asks.js): "@actor …" in the global chat or the asker's own
+ * channel becomes the asker's task, delivered as chat.message.created to POST /internal/events, and the answer is
+ * posted back in the same room through Chat's service API, answering the message. Guests, relayed external chatters,
+ * other people's channels and ordinary messages are ignored; a redelivery makes no second task; a robot, computer or
+ * coding task from chat is refused (public tools only); the hourly limits answer once and then drop quietly; a reply
+ * Chat refuses for now is retried.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const { parseAsk, plain, clip } = require('../server/chat/asks');
+
+const SECRET = `whsec_${'fixture'.repeat(6)}`;
+let n = 0;
+const message = (user, text, { room = { type: 'global' }, ...extra } = {}) => {
+ n += 1;
+ return {
+ event_id: `evt_01JZ00000000000000000C${String(n).padStart(4, '0')}`, event_type: 'chat.message.created', source: 'chat',
+ payload: { message_id: 9000 + n, room, message_type: 'chat', user_id: user.id, user_subject: user.subject, anon_id: null, username: user.username, text, source_platform: null, reply_to_id: null, ...extra },
+ };
+};
+
+(async () => {
+ await check('which chat messages are asks', () => {
+ const kim = { id: 7, subject: 'usr_01JZ0000000000000000000K1M', username: 'kim' };
+ const ev = (text, extra) => message(kim, text, extra);
+ assert.deepStrictEqual(parseAsk(ev('@actor what is WHIP?')), { messageId: 9000 + n, subject: kim.subject, username: 'kim', room: { type: 'global' }, task: 'what is WHIP?' });
+ assert.strictEqual(parseAsk(ev('@Actor, summarise this week\'s news')).task, 'summarise this week\'s news');
+ assert.deepStrictEqual(parseAsk(ev('@actor hi', { room: { type: 'channel', channel_user_id: 7, stream_id: 31 } })).room, { type: 'channel', channel_user_id: 7, stream_id: 31 }, 'their own channel');
+ assert.strictEqual(parseAsk(ev('@actor hi', { room: { type: 'channel', channel_user_id: 8, stream_id: 2 } })), null, 'someone else\'s channel');
+ assert.strictEqual(parseAsk(ev('hello @actor')), null, 'only at the start');
+ assert.strictEqual(parseAsk(ev('@actors are great')), null);
+ assert.strictEqual(parseAsk(ev('@actor hi', { source_platform: 'twitch' })), null, 'a relayed external chatter');
+ assert.strictEqual(parseAsk(ev('@actor hi', { user_subject: null, user_id: null, anon_id: 'anon_1' })), null, 'a guest');
+ assert.strictEqual(parseAsk(ev('@actor hi', { message_type: 'system' })), null);
+ assert.strictEqual(parseAsk({ ...ev('@actor hi'), event_type: 'chat.message.deleted' }), null);
+ assert.strictEqual(plain('## Title\n**WHIP** is [an ingest](https://x) protocol.\n\nSources:\n- https://x'), 'Title WHIP is an ingest protocol.');
+ assert.strictEqual(clip('One. Two three four five six seven.', 20), 'One. Two three four…');
+ assert.strictEqual(clip('The answer is yes. Then more words follow here.', 30), 'The answer is yes.');
+ assert.strictEqual(clip('a'.repeat(30), 10), `${'a'.repeat(10)}…`);
+ });
+
+ const posted = [];
+ let chatDown = false;
+ const chatClient = { enabled: true, async post(m) { if (chatDown) return { ok: false, status: 503, error: 'down' }; posted.push(m); return { ok: true, status: 200, id: 1 }; } };
+ const t = await boot({ env: { ACTOR_EVENTS_SECRET: SECRET, ACTOR_CHAT_ASKS_PER_HOUR: '3' }, chatClient });
+ const kim = t.network.addUser('kim');
+ const lee = t.network.addUser('lee');
+ const ann = t.network.addUser('ann');
+ const joe = t.network.addUser('joe');
+ const deliver = async (ev) => {
+ const body = JSON.stringify({ event: ev, seq: 1 });
+ const r = await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });
+ assert.strictEqual(r.status, 200, r.text);
+ return r.json().outcome;
+ };
+ const askRow = (ev) => t.ctx.s.db.maybe('SELECT * FROM actor_chat_asks WHERE message_id = $1', [ev.payload.message_id]);
+
+ let first;
+ await check('an @actor message in the global chat becomes the asker\'s task, answered in the room', async () => {
+ t.providers.reset();
+ t.providers.state.cls = 'answer';
+ first = message(kim, '@actor explain WHIP in one line');
+ assert.strictEqual(await deliver(first), 'task');
+ const row = await askRow(first);
+ assert.match(row.task_id, /^tsk_/);
+ const task = await t.waitFor(row.task_id);
+ assert.deepStrictEqual([task.requester, task.via, task.task, task.state], [`user:${kim.subject}`, 'chat', 'explain WHIP in one line', 'succeeded']);
+ assert.strictEqual(await t.ctx.chatAsks.tick(), 1);
+ const reply = posted.at(-1);
+ assert.deepStrictEqual([reply.key, reply.room, reply.replyTo], [`actor:ask:${first.payload.message_id}`, { type: 'global' }, first.payload.message_id]);
+ assert.match(reply.text, new RegExp(`^@kim .+ \\(full answer: https://openvibe\\.actor/tasks/${row.task_id}\\)$`));
+ assert.ok(reply.text.length < 600);
+ assert.ok((await askRow(first)).replied_at, 'replied once');
+ assert.strictEqual(await t.ctx.chatAsks.tick(), 0, 'and not again');
+ });
+
+ await check('the runtime is never handed the asker for its tools: no robots or computers of theirs are offered', async () => {
+ const run = t.ctx.adapters.run;
+ const real = run['openvibe-runtime'];
+ const seen = [];
+ run['openvibe-runtime'] = (args) => { seen.push(args.requester); return real(args); };
+ try {
+ t.providers.reset();
+ t.providers.state.cls = 'lookup';
+ const ev = message(joe, '@actor who handles mail for openvibe.network?');
+ assert.strictEqual(await deliver(ev), 'task');
+ const task = await t.waitFor((await askRow(ev)).task_id);
+ assert.strictEqual(task.state, 'succeeded', task.error_detail);
+ assert.deepStrictEqual(seen, [null], 'from chat: no requester for the tools');
+ t.providers.state.cls = 'lookup';
+ const api = await t.get('/api/v1/tasks', { as: joe, json: { task: 'Who handles mail for openvibe.network?' }, headers: { 'sec-fetch-site': 'same-origin' } });
+ await t.waitFor(api.json().id);
+ assert.deepStrictEqual(seen, [null, `user:${joe.subject}`], 'the same task asked on Actor itself: the person\'s own tools');
+ } finally { run['openvibe-runtime'] = real; }
+ });
+
+ await check('a redelivery makes no second task; ordinary messages and other people\'s channels are ignored', async () => {
+ const before = (await t.ctx.s.db.maybe('SELECT count(*)::int AS n FROM tasks')).n;
+ assert.strictEqual(await deliver(first), 'duplicate');
+ assert.strictEqual(await deliver(message(kim, 'hello everyone')), 'ignored:not_an_ask');
+ assert.strictEqual(await deliver(message(lee, '@actor hi there', { room: { type: 'channel', channel_user_id: kim.id, stream_id: 5 } })), 'ignored:not_an_ask');
+ assert.strictEqual(await deliver(message(lee, '@actor hi there', { source_platform: 'youtube' })), 'ignored:not_an_ask');
+ assert.strictEqual((await t.ctx.s.db.maybe('SELECT count(*)::int AS n FROM tasks')).n, before);
+ });
+
+ await check('in their own channel the reply goes to that channel and its live stream', async () => {
+ t.providers.state.cls = 'answer';
+ const ev = message(lee, '@actor what time zone is Lisbon in?', { room: { type: 'channel', channel_user_id: lee.id, stream_id: 44 } });
+ assert.strictEqual(await deliver(ev), 'task');
+ await t.waitFor((await askRow(ev)).task_id);
+ await t.ctx.chatAsks.tick();
+ assert.deepStrictEqual(posted.at(-1).room, { type: 'channel', channel_user_id: lee.id, stream_id: 44 });
+ });
+
+ await check('a computer, robot or coding task from chat is refused: public tools only', async () => {
+ for (const cls of ['computer', 'robot', 'code']) {
+ t.providers.state.cls = cls;
+ const ev = message(ann, `@actor do the ${cls} thing`);
+ assert.strictEqual(await deliver(ev), 'task');
+ const task = await t.waitFor((await askRow(ev)).task_id);
+ assert.deepStrictEqual([task.state, task.error_code], ['failed', 'actor.task.not_public'], cls);
+ await t.ctx.chatAsks.tick();
+ assert.match(posted.at(-1).text, /^@ann from a public chat I only use public tools\. To use your own computer, robots or code, ask me on https:\/\/openvibe\.actor$/);
+ }
+ });
+
+ await check('past the hourly limit one reply says so, then further asks are dropped quietly', async () => {
+ t.providers.state.cls = 'answer';
+ // kim has asked once; the limit is 3 an hour.
+ for (const q of ['two', 'three']) {
+ const ev = message(kim, `@actor question ${q}`);
+ assert.strictEqual(await deliver(ev), 'task');
+ await t.waitFor((await askRow(ev)).task_id);
+ }
+ const over = message(kim, '@actor question four');
+ assert.strictEqual(await deliver(over), 'refused:person_hourly');
+ const quiet = message(kim, '@actor question five');
+ assert.strictEqual(await deliver(quiet), 'refused:person_hourly:quiet');
+ const before = posted.length;
+ await t.ctx.chatAsks.tick();
+ const texts = posted.slice(before).map((p) => p.text);
+ assert.ok(texts.includes('@kim that is 3 questions this hour; ask again later, or on https://openvibe.actor'), JSON.stringify(texts));
+ assert.ok(!posted.some((p) => p.replyTo === quiet.payload.message_id), 'the quiet one gets no reply');
+ assert.ok((await askRow(quiet)).replied_at);
+ });
+
+ await check('a reply Chat cannot take now is retried later', async () => {
+ t.providers.state.cls = 'answer';
+ const ev = message(joe, '@actor name a prime number');
+ await deliver(ev);
+ await t.waitFor((await askRow(ev)).task_id);
+ chatDown = true;
+ assert.strictEqual(await t.ctx.chatAsks.tick(), 0);
+ const waiting = await askRow(ev);
+ assert.deepStrictEqual([waiting.replied_at, waiting.reply_attempts, waiting.reply_error], [null, 1, 'down']);
+ chatDown = false;
+ await t.ctx.s.db.query('UPDATE actor_chat_asks SET next_attempt_at = NULL WHERE message_id = $1', [ev.payload.message_id]);
+ assert.strictEqual(await t.ctx.chatAsks.tick(), 1);
+ assert.strictEqual(posted.at(-1).replyTo, ev.payload.message_id);
+ });
+
+ await t.close();
+ done();
+})();
@@ -114,9 +114,9 @@ function codeRoutes() {
@@ -114,9 +114,9 @@ function codeRoutes() {
}
}
});
});
- await check('events: the manifest produces none and consumes only the two account events (ADR-033); the capabilities declare none', () => {
+ await check('events: the manifest produces none and consumes the two account events (ADR-033) and chat messages (asks from chat); the capabilities declare none', () => {
assert.deepStrictEqual(manifest.eventsProduced, []);
assert.deepStrictEqual(manifest.eventsProduced, []);
- assert.deepStrictEqual([...manifest.eventsConsumed].sort(), ['network.account.deleted', 'network.account.export_requested']);
+ assert.deepStrictEqual([...manifest.eventsConsumed].sort(), ['chat.message.created', 'network.account.deleted', 'network.account.export_requested']);
for (const id of CAPS) assert.deepStrictEqual(contracts.capabilities.get(id).events, [], id);
for (const id of CAPS) assert.deepStrictEqual(contracts.capabilities.get(id).events, [], id);
});
});
@@ -42,7 +42,7 @@ async function boot(opts = {}) {
@@ -42,7 +42,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
const store = createStore(testdb.db, { now: opts.now });
- const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend, webhookPost: opts.webhookPost, secretSigner: opts.secretSigner, bot: opts.bot, projects: opts.projects });
+ const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend, webhookPost: opts.webhookPost, chatClient: opts.chatClient, secretSigner: opts.secretSigner, bot: opts.bot, projects: opts.projects });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
@@ -14,8 +14,8 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -14,8 +14,8 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%
// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%
// headroom; file counts are the measured counts and external files stay at 0.
// headroom; file counts are the measured counts and external files stay at 0.
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 39.5, // measured 35.8 (the guest task box lists example tasks)
- htmlBrotliKB: 9.5, // 8.6
+ htmlRawKB: 40.5, // measured 35.8 (the guest task box lists example tasks); 39.6 with the home's "Ask in chat" row (10-15)
+ htmlBrotliKB: 10, // 8.6; 9.6 with "Ask in chat"
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 264, // 239.8
jsRawKB: 264, // 239.8
jsBrotliKB: 62.5, // 56.5
jsBrotliKB: 62.5, // 56.5
@@ -108,6 +108,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -108,6 +108,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
'server/agents/providers.js': 'the configured provider base URLs (DeepSeek, OpenAI, the open model) with the operator\'s keys',
'server/agents/providers.js': 'the configured provider base URLs (DeepSeek, OpenAI, the open model) with the operator\'s keys',
'server/agents/codes.js': 'the configured OpenVibe.Codes base (ACTOR_CODES_URL) with Actor\'s own service token; the subject must be usr_<ULID> and every task id tsk_<ULID> before it goes in a path; the repository named in the task is data for the person\'s own machine, never fetched here (test/codes.test.js)',
'server/agents/codes.js': 'the configured OpenVibe.Codes base (ACTOR_CODES_URL) with Actor\'s own service token; the subject must be usr_<ULID> and every task id tsk_<ULID> before it goes in a path; the repository named in the task is data for the person\'s own machine, never fetched here (test/codes.test.js)',
'server/agents/computers.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token (bot.computer.control); the machine id must be dev_<ULID> and the subject usr_<ULID> before anything is sent; a path or program is data for the person\'s own machine, never fetched here (test/computers.test.js)',
'server/agents/computers.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token (bot.computer.control); the machine id must be dev_<ULID> and the subject usr_<ULID> before anything is sent; a path or program is data for the person\'s own machine, never fetched here (test/computers.test.js)',
+ 'server/chat/client.js': 'the configured OpenVibe.Chat base (ACTOR_CHAT_URL, loopback) with Actor\'s own service token; the room and the message it answers come from a signed chat.message.created, never from the task (test/chat-asks.test.js)',
'server/agents/robots.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token; the robot id must be rob_<ULID> and the subject usr_<ULID> before anything is sent (test/robots.test.js)',
'server/agents/robots.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token; the robot id must be rob_<ULID> and the subject usr_<ULID> before anything is sent (test/robots.test.js)',
'server/identity/projects.js': 'Network\'s internal membership read (configured networkInternalUrl) with Actor\'s own service token (network.project.member.read); the project and subject ids are path-encoded and come from a parsed OVRN and a checked usr_ id (test/resource-control.test.js)',
'server/identity/projects.js': 'Network\'s internal membership read (configured networkInternalUrl) with Actor\'s own service token (network.project.member.read); the project and subject ids are path-encoded and come from a parsed OVRN and a checked usr_ id (test/resource-control.test.js)',
'server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input',
'server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input',