openvibers/openvibe.chat Commits

Vibe searches through the Search Fabric, with citations and a fallback (#239)

OP OpenVibers committed 16 days ago · 43949a2e6a · 13 files, +336 −71

Changes

ddiff --git a/README.md b/README.md
iindex 9299a27..8f7a3e5 100644
---- a/README.md
++++ b/README.md
@@@ -128,6 +128,7 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
| The six chat tables (moderators, moderation settings, emotes, tags, AI summaries, timeline) | **Chat** (C-04 done) | Chat's own database; Live reads through `GET /internal/moderation/*` (`chat.moderation.read`), 30 s cached |
| Emote image bytes | **OpenVibe.Media** (namespace `chat`) | `server/media/client.js` (openvibe-sdk `createObjectsClient`), Chat's service token (`media.object.upload` / `.delete`); the row keeps `media_url` + `media_asset_id` |
| Chat-AI summaries (global / per-chatter insight + timeline) | **OpenVibe.AI** (namespace `chat.*`) | `server/ai/client.js` (openvibe-sdk `createAiClient`), Chat's service token (`ai.run.create` / `ai.run.read`); workflows `chat.global` / `chat.profile`; extractive fallback when AI does not answer |
++| Vibe's search (OpenVibe and the web) | **OpenVibe.Search** (Search Fabric, `GET /api/v1/query`) | `server/rooms/search-fabric.js` (openvibe-sdk/search 0.70), Chat's service token (Network grants `search.query.delegate` on `openvibe.search`), the person in `X-OV-Subject`; each result keeps its provider, date and URL; Search's `/api/v1/search` and Actor's web task are the fallbacks only when the fabric is down |
| Identity | **OpenVibe.Network** | user tokens are resolved by Live (its account links); service tokens from `/oauth/token` |
| Events | **OpenVibe.Events** | `service_outbox` (openvibe-sdk outbox) → `POST /api/v1/events` when `EVENTS_URL` and `OV_OAUTH_CLIENT_SECRET` are set (`events.event.publish`); Chat's subscriptions deliver to `POST /internal/events` (`server/events/consumer.js`, `events.subscription.manage`) |
@@@ -326,7 +327,8 @@ account keeps the AI members it added (they belong to the room) without its subj
**Tools** (`server/rooms/ai-tools.js`; the owner's "modular AI conversation… tool calling"). An AI member's answer
is a few turns of OpenVibe.AI's `chat.member.turn` (a Chat Completions passthrough with tools, AI#96): the model may
--call `search_openvibe` (OpenVibe.Search's public results, `CHAT_SEARCH_URL`), `current_time`, `roll_dice`,
++call `search_openvibe` (OpenVibe.Search's public results through the Search Fabric's network scope, `CHAT_SEARCH_URL`),
++`web_search` for Vibe (the fabric's web scope; Actor only when the fabric is down), `current_time`, `roll_dice`,
`pick_one`, and for a member linked to a Food household `household_pantry`; Chat runs them (read-only, bounded, results
at most 4,000 characters) and sends the results back, at most three rounds with four calls each, then a turn that must
answer in text. If a turn fails (a provider without tools), the member answers with `chat.member.reply` as before.
@@@ -474,10 +476,12 @@ socket, sends on Enter and keeps a draft typed while signed out through the sign
`public/web/vibe-md.js`, which escapes everything first and links only http(s)), the last 30 messages at 2,000
characters each, answers up to 8,000 characters (1,500 tokens). Tools: `search_openvibe`, `read_page` (a page's text
through OpenVibe.Tools' `read` tool, `CHAT_TOOLS_URL`; Tools decides what may be reached), `web_search` (the open
-- web: an OpenVibe.Actor task for the person who asked, `server/rooms/web-search.js`, with Chat's own token and the
-- person in `X-OV-Subject`; Network grants Chat `actor.task.create` and `actor.task.read`; `CHAT_ACTOR_INTERNAL_URL`;
-- it counts against the person's Actor allowance; Vibe added to a room has it too), the time, dice, picks. It says
-- plainly what it cannot do yet (make pictures, use the person's computer, reminders).
++ web through the Search Fabric's web scope: Search's open index, then its paid/free providers, each result with its
++ URL, provider and date; `server/rooms/search-fabric.js`, `server/rooms/web-search.js`; Chat's service token has
++ `search.query.delegate` and names the person in `X-OV-Subject`; `CHAT_SEARCH_URL`; Vibe added to a room has it too),
++ the time, dice, picks. The pre-fabric paths stay only as fallbacks: `/api/v1/search` for the network scope, and an
++ OpenVibe.Actor web task (`CHAT_ACTOR_INTERNAL_URL`; Network grants `actor.task.create`/`actor.task.read`) when the
++ fabric is down. It says plainly what it cannot do yet (make pictures, use the person's computer, reminders).
- **Pictures** (`pictures.forModel`, `ai-members.js` `picturesFor`): an AI member (Vibe included) sees the
OpenVibe.Pics pictures a message links, or those of the asker's message just before it (picture first, then the
question), at most two, fetched from Pics on this host (Host `openvibe.pics`, at most 4 MB each) and sent as image
@@@ -960,6 +964,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
- openvibe-contracts: v0.222.0
--- openvibe-sdk: v0.67.0
++- openvibe-sdk: v0.70.0
- openvibe-shared: v4.33.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex db40d46..1bcdad2 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -8,7 +8,7 @@
"port": 4400,
"domain": "openvibe.chat (Chat's own site: global chat, messages, rooms, settings; the service manifest records it live since 2026-09-24) and openvibe.live (/ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds via an nginx include in openvibe.live's vhost)",
"contracts": "openvibe-contracts v0.222.0",
-- "sdk": "openvibe-sdk v0.67.0",
++ "sdk": "openvibe-sdk v0.70.0",
"shared": "openvibe-shared v4.33.0",
"capabilities": [
"chat.message.send",
@@@ -34,7 +34,8 @@
"network.modules.read",
"network.modules.write",
"tools.tool.run",
-- "tools.job.read"
++ "tools.job.read",
++ "search.query.delegate"
],
"capabilitiesRegistered": true,
"events": [
@@@ -65,7 +66,8 @@
"network blocks, revocation, account merge, export and deletion from Network events",
"openvibe-sdk service_outbox events and chat.room.message.* on the realtime plane",
"N-1 compatibility tests",
-- "per-actor limits (openvibe-sdk/limits)"
++ "per-actor limits (openvibe-sdk/limits)",
++ "Vibe search through the Search Fabric (network and web scopes, cited provider/date/URL; pre-fabric fallbacks)"
],
"updated": "2026-10-28"
}
ddiff --git a/package-lock.json b/package-lock.json
iindex 1b0e6c2..16cf4f2 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
@@@ -1036,9 +1036,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.67.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "integrity": "sha512-s7NzjaNcowVcOIiup2z0ze+xsq/qnr++3pHSI8JzirqJPQ5JsBSUF5RkjOHyfk9bRKlmhY0fsNS6x4dsYylyqw==",
++ "version": "0.70.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
++ "integrity": "sha512-iMNxlnRm0fmyj60nHiC/lKAaG3JAqiJgu42lShnkby4yF4dQJRG3G0trrqOoULZKBalrYHb/gjauRwNQYN/tog==",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex cd93827..7cfcc82 100644
---- a/package.json
++++ b/package.json
@@@ -29,7 +29,7 @@
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
ddiff --git a/server/config.js b/server/config.js
iindex 721649b..0d8090d 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -78,7 +78,8 @@ module.exports = {
inbox: { aiRepliesPerDay: Math.max(1, int(process.env.CHAT_INBOX_AI_REPLIES_PER_DAY, 500)), notify: process.env.CHAT_INBOX_NOTIFY !== '0', identityKey: process.env.CHAT_INBOX_IDENTITY_KEY || '',
postsPerMinute: Math.max(1, int(process.env.CHAT_INBOX_POSTS_PER_MINUTE, 20)), // a visitor's address, widget writes
translationsPerDay: Math.max(0, int(process.env.CHAT_INBOX_TRANSLATIONS_PER_DAY, 300)) }, // new translations an inbox a UTC day
-- // OpenVibe.Actor, for Vibe's web search (server/rooms/web-search.js): tasks for the person, with Chat's own token.
++ // OpenVibe.Actor, the fallback for Vibe's web search when the Search Fabric is down (server/rooms/web-search.js):
++ // tasks for the person, with Chat's own token.
actor: { internalUrl: strip(process.env.CHAT_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950') },
// openvibe.chat, the site (server/web/): global chat, messages and settings, served by Chat itself.
@@@ -204,7 +205,8 @@ module.exports = {
members: process.env.CHAT_AI_MEMBERS !== '0',
memberRepliesPerDay: Math.max(1, int(process.env.CHAT_AI_MEMBER_REPLIES_PER_DAY, 100)),
// Tools an AI member may use (server/rooms/ai-tools.js) through OpenVibe.AI's chat.member.turn: on unless
-- // CHAT_AI_MEMBER_TOOLS=0. Network search goes to OpenVibe.Search's public API.
++ // CHAT_AI_MEMBER_TOOLS=0. Search goes to OpenVibe.Search's Search Fabric (network and web scopes); Chat's
++ // service token holds search.query.delegate for the web scope and names the person in X-OV-Subject.
memberTools: process.env.CHAT_AI_MEMBER_TOOLS !== '0',
searchUrl: strip(process.env.CHAT_SEARCH_URL || 'https://search.openvibe.network'),
// read_page goes to OpenVibe.Tools' public run API (its `read` tool).
ddiff --git a/server/rooms/ai-members.js b/server/rooms/ai-members.js
iindex 47b749b..f4a49c9 100644
---- a/server/rooms/ai-members.js
++++ b/server/rooms/ai-members.js
@@@ -224,7 +224,7 @@ function vibePrompt(member, askedBy, memory = null) {
'How you answer:',
'- Helpful, direct and warm, in the language they wrote in. A short question gets a short answer; give depth when they ask for it or the task needs it.',
'- Your answer is shown as Markdown: short paragraphs, lists, **bold**, `code` and fenced code blocks, and links. No tables, no headings above level 3.',
-- '- Tools: search_openvibe (news, deals, coupon codes, wiki pages, rentals, jobs, videos and more across OpenVibe), web_search (the open web, through OpenVibe\'s web agent: slower, for current events and facts OpenVibe does not have), read_page (the text of a web page at an address they give or a search found), the current time, fair dice and random picks. Use them when they make the answer better, and say where a fact came from with its link.',
++ '- Tools: search_openvibe (news, deals, coupon codes, wiki pages, rentals, jobs, videos and more across OpenVibe), web_search (the open web through OpenVibe\'s Search Fabric: results with their URL, provider and date, for current events and facts OpenVibe does not have), read_page (the text of a web page at an address they give or a search found), the current time, fair dice and random picks. Use them when they make the answer better, and cite each result with its link; when a result names a provider and a date, keep them with the link.',
'- When they send a picture you see it: describe what matters for their question (text in it, objects, a screenshot\'s error). Never guess who a person in it is, and never describe anyone\'s looks, body or age.',
'- Recurring work ("every morning, find me new remote jobs"): set_up_agent makes it one of their OpenVibe Agents, run daily or weekly on their own allowance with every result on its page. First say what it would do and when, and set it up only once they agree. my_agents lists the agents they have.',
'- You cannot yet make pictures, use their computer or set reminders. Say so plainly when asked, and offer what you can do instead.',
@@@ -265,7 +265,7 @@ function systemPrompt(member, room) {
'How you answer:',
'- Like a member of the conversation: natural, warm and to the point, in the language the person wrote in. Usually a few sentences; a list or a recipe when that is what they asked for. Plain text, no headings.',
'- Answer the person who named you. Use the earlier messages, the room\'s notes and the shared context when they help.',
-- `- You have tools: search OpenVibe (news, deals, coupons, wiki, rentals, videos), read a web page at an address${member.handle === 'vibe' ? ', search the open web (slower: for current events and facts OpenVibe does not have)' : ''}, the current time, fair dice and random picks, and for a household its pantry. Use one when it gives a better answer, and say where a fact came from (give the link for a search result).`,
++ `- You have tools: search OpenVibe (news, deals, coupons, wiki, rentals, videos), read a web page at an address${member.handle === 'vibe' ? ', search the open web through the Search Fabric (results with their URL, provider and date: for current events and facts OpenVibe does not have)' : ''}, the current time, fair dice and random picks, and for a household its pantry. Use one when it gives a better answer, and cite each result with its link (with its provider and date when it has them).`,
'- Never invent facts about the people, their plans or their things beyond what the messages, notes, context and tools say. When you do not know, say so and ask.',
'- You can only write in this chat and use these tools. Do not claim to have done anything else (bought, booked, sent, saved, set a reminder).',
'- No threats, nothing sexual involving minors, and never anyone\'s private details. Swearing in jest is fine if the room does it.',
@@@ -310,7 +310,9 @@ async function answerWithTools({ room, member, context, transcript, askedBy, tex
for (const c of calls) {
called.add(c.function.name);
if (steps) require('./how').step(steps, c.function.name, c.function.arguments);
-- messages.push({ role: 'tool', tool_call_id: String(c.id || `call_${round}`).slice(0, 128), content: await aiTools.run(c.function.name, c.function.arguments, { member, contextFor, subject, room, user, messageId, memory: memory !== null, idempotencyKey: `${opts.idempotencyKey}-t${round}-${c.function.name}` }) });
++ const result = await aiTools.run(c.function.name, c.function.arguments, { member, contextFor, subject, room, user, messageId, memory: memory !== null, idempotencyKey: `${opts.idempotencyKey}-t${round}-${c.function.name}` });
++ if (steps) require('./how').cite(steps, c.function.name, result);
++ messages.push({ role: 'tool', tool_call_id: String(c.id || `call_${round}`).slice(0, 128), content: result });
}
}
return null;
ddiff --git a/server/rooms/ai-tools.js b/server/rooms/ai-tools.js
iindex e2e5f2d..894d6ad 100644
---- a/server/rooms/ai-tools.js
++++ b/server/rooms/ai-tools.js
@@@ -5,11 +5,12 @@
* member still only writes in its room.
*
* search_openvibe OpenVibe.Search's public results for a query (news, deals, coupons, wiki, listings, videos…), through
-- * the Search Fabric's network scope (GET /api/v1/query); /api/v1/search on a Search without it
++ * the Search Fabric's network scope (GET /api/v1/query); /api/v1/search only on a Search without it
* read_page a web page's title, text and links, through OpenVibe.Tools' `read` tool (Tools decides what it may
* reach: public http(s) addresses only); Chat never fetches the address itself
-- * web_search Vibe only (a Vibe chat, or Vibe added to a room): the open web, answered by OpenVibe.Actor's web
-- * agent as a task for the person who asked (server/rooms/web-search.js), with its sources
++ * web_search Vibe only (a Vibe chat, or Vibe added to a room): the open web through the Search Fabric's web
++ * scope, with each result's provider, date and URL; OpenVibe.Actor's web agent only when the fabric
++ * is down (server/rooms/web-search.js)
* current_time the date and time now, in a time zone
* roll_dice N dice of S sides
* pick_one one of the options given, at random
@@@ -28,6 +29,7 @@
*/
const crypto = require('crypto');
const config = require('../config');
++const fabricSearch = require('./search-fabric');
const RESULT_MAX = 4000;
@@@ -47,7 +49,7 @@ const BASE = [
fn('pick_one', 'Pick one of the given options at random, fairly (where to eat, who goes first).', { options: { type: 'array', minItems: 2, maxItems: 30, items: { type: 'string' } } }, ['options']),
];
const PANTRY = fn('household_pantry', 'What is in this household\'s pantry and its meal plan, from OpenVibe.Food, right now.');
--const WEB = fn('web_search', 'Search the open web and get a short answer with its sources (OpenVibe\'s web agent; takes up to a minute). Use it for current events, prices, schedules and facts that OpenVibe search does not have.',
++const WEB = fn('web_search', 'Search the open web (the Search Fabric: OpenVibe\'s open index, then its web providers) and get results with each one\'s title, URL, provider and date. Cite the URL, provider and date you use. Use it for current events, prices, schedules and facts that OpenVibe search does not have.',
{ query: { type: 'string', description: 'The question or search, in plain words' } }, ['query']);
const QUEUE = fn('queue_task', 'Put a piece of work on this room\'s work queue, for the person who asked you. research (a short report with sources), web (a current answer from the web) and answer run at once and their result comes back into the room in a minute or two; code (a change to an OpenVibe repository, done by a coding agent and handed in as a pull request for review), computer (something done on a computer) and other wait for a worker to take them. Queue only what someone asked to have done, one task per job.',
@@@ -87,31 +89,35 @@ const definitions = (member, room = null, { memory = false } = {}) => [
];
const clip = (o) => { const s = typeof o === 'string' ? o : JSON.stringify(o); return s.length > RESULT_MAX ? `${s.slice(0, RESULT_MAX - 1)}…` : s; };
--const stripTags = (h) => String(h || '').replace(/<[^>]*>/g, '').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
const int = (v, min, max, dflt) => { const n = Math.floor(Number(v)); return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : dflt; };
--async function searchOpenvibe(args, { fetchImpl = globalThis.fetch } = {}) {
++async function searchOpenvibe(args, { fetchImpl = null, searchClient = null } = {}) {
const query = String(args.query || '').trim().slice(0, 200);
if (!query) return { error: 'query is required' };
const limit = int(args.limit, 1, 8, 5);
-- const ctrl = new AbortController();
-- const timer = setTimeout(() => ctrl.abort(), 8000);
++ const signal = AbortSignal.timeout(8000);
try {
-- const get = (path) => fetchImpl(`${config.ai.searchUrl}${path}`, { headers: { accept: 'application/json', 'user-agent': 'OpenVibe.Chat AI member' }, signal: ctrl.signal, redirect: 'error' });
-- let res = await get(`/api/v1/query?${new URLSearchParams({ q: query, scopes: 'network', limit: String(limit) })}`);
-- const fabric = res.status !== 404;
-- if (!fabric) res = await get(`/api/v1/search?${new URLSearchParams({ q: query, limit: String(limit) })}`);
-- if (!res.ok) return { error: `OpenVibe.Search answered ${res.status}` };
-- const body = await res.json();
-- const results = (Array.isArray(body.results) ? body.results : []).slice(0, limit).map((r) => ({
-- title: String(r.title || '').slice(0, 200), site: r.owner, type: r.type,
-- summary: stripTags(fabric ? r.snippet : (r.snippet_html || r.summary || '')).slice(0, 300),
-- url: fabric ? r.url : r.canonical_url, published_at: r.published_at || null,
-- }));
-- return { query, results, note: results.length ? 'Cite the url when you use a result.' : 'Nothing on OpenVibe matched.' };
++ const body = await fabricSearch.query({ q: query, scopes: ['network'], limit, signal }, { client: searchClient, fetchImpl });
++ const results = (Array.isArray(body.results) ? body.results : []).slice(0, limit).map((r) => fabricSearch.resultOf(r));
++ return { query, citations: fabricSearch.citationsOf(results), results, note: results.length ? 'Cite each result with its url, provider and date.' : 'Nothing on OpenVibe matched.' };
} catch (err) {
-- return { error: err && err.name === 'AbortError' ? 'OpenVibe.Search did not answer in time' : 'OpenVibe.Search is unreachable' };
-- } finally { clearTimeout(timer); }
++ if (!fabricSearch.isDown(err)) {
++ const status = err && err.status;
++ return { error: status ? `OpenVibe.Search answered ${status}` : 'OpenVibe.Search did not answer' };
++ }
++ // A Search without the fabric (or one that is down): its pre-fabric query API answers instead.
++ try {
++ const res = await (fetchImpl || globalThis.fetch)(`${config.ai.searchUrl}/api/v1/search?${new URLSearchParams({ q: query, limit: String(limit) })}`, {
++ headers: { accept: 'application/json', 'user-agent': 'OpenVibe.Chat AI member' }, signal: AbortSignal.timeout(8000), redirect: 'error',
++ });
++ if (!res.ok) return { error: `OpenVibe.Search answered ${res.status}` };
++ const old = await res.json();
++ const results = (Array.isArray(old.results) ? old.results : []).slice(0, limit).map((r) => fabricSearch.resultOf(r, { fallback: true }));
++ return { query, citations: fabricSearch.citationsOf(results), results, fallback: true, note: results.length ? 'Cite each result with its url and date.' : 'Nothing on OpenVibe matched.' };
++ } catch {
++ return { error: 'OpenVibe.Search is unreachable' };
++ }
++ }
}
async function readPage(args, { fetchImpl = globalThis.fetch } = {}) {
ddiff --git a/server/rooms/how.js b/server/rooms/how.js
iindex 78033db..b574d15 100644
---- a/server/rooms/how.js
++++ b/server/rooms/how.js
@@@ -7,8 +7,9 @@
*
* forRoom(room) → Map(message id → info) for the room's announced tasks and Vibe's answers that kept their steps
* of(task, names) → info { lines: [text], sources: [url], actor_url } or null: plain text, escaped where it is shown
-- * ofMessage(how) → the same info from a chat answer's room_messages.how { steps, sources }, or null
++ * ofMessage(how) → the same info from a chat answer's room_messages.how { steps, sources, citations }, or null
* step(acc, tool, args) adds what a tool call did, in plain words, to { steps, sources } (ai-members.js)
++ * cite(acc, tool, result) keeps a search result's URL, provider and dates as a citation (ai-members.js)
* html(info) → the <details class="vb-how"> markup, escaped
*/
const config = require('../config');
@@@ -59,6 +60,7 @@ function of(t, agentNames = new Map()) {
// A chat answer's steps (ai-members.js answerWithTools): each tool call in plain words, at most MAX_STEPS.
const MAX_STEPS = 12;
++const MAX_CITES = 6;
const quoted = (v, max = 80) => { const s = String(v || '').replace(/\s+/g, ' ').trim(); return s ? `“${s.length > max ? `${s.slice(0, max - 1)}…` : s}”` : ''; };
const STEP = {
search_openvibe: (a) => `Searched OpenVibe for ${quoted(a.query)}`,
@@@ -85,7 +87,38 @@ function step(acc, tool, raw = null) {
function ofMessage(raw) {
const how = typeof raw === 'string' ? json(raw, null) : raw;
if (!how || !Array.isArray(how.steps) || !how.steps.length) return null;
-- return { lines: how.steps.map((l) => String(l).slice(0, 200)).slice(0, MAX_STEPS), sources: (Array.isArray(how.sources) ? how.sources : []).filter((u) => /^https?:\/\//.test(String(u || ''))).slice(0, 6), actor_url: null };
++ const citations = (Array.isArray(how.citations) ? how.citations : []).filter((c) => c && /^https?:\/\//.test(String(c.url || ''))).slice(0, MAX_CITES)
++ .map((c) => ({ url: String(c.url).slice(0, 500), title: c.title ? String(c.title).slice(0, 200) : null, provider: c.provider ? String(c.provider).slice(0, 40) : null, published_at: c.published_at ? String(c.published_at).slice(0, 40) : null, retrieved_at: c.retrieved_at ? String(c.retrieved_at).slice(0, 40) : null }));
++ return { lines: how.steps.map((l) => String(l).slice(0, 200)).slice(0, MAX_STEPS), sources: (Array.isArray(how.sources) ? how.sources : []).filter((u) => /^https?:\/\//.test(String(u || ''))).slice(0, 6), ...(citations.length ? { citations } : {}), actor_url: null };
++}
++
++/**
++ * Keep the citations a search tool returned with its answer: each result's URL, provider and dates. The
++ * model already sees them in the tool result; this puts the same links under "How Vibe did this".
++ */
++function cite(acc, tool, raw) {
++ if (!acc || (tool !== 'search_openvibe' && tool !== 'web_search')) return;
++ let result = null;
++ try { result = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return; }
++ if (!result || typeof result !== 'object') return;
++ const list = Array.isArray(result.citations) && result.citations.length ? result.citations
++ : Array.isArray(result.results) ? result.results
++ : Array.isArray(result.sources) ? result.sources.map((url) => ({ url })) : [];
++ if (!Array.isArray(acc.sources)) acc.sources = [];
++ if (!Array.isArray(acc.citations)) acc.citations = [];
++ for (const item of list) {
++ if (!item || acc.sources.length >= MAX_CITES) break;
++ const url = String((typeof item === 'string' ? item : item.url) || '').slice(0, 500);
++ if (!/^https?:\/\//.test(url) || acc.sources.includes(url)) continue;
++ acc.sources.push(url);
++ acc.citations.push({
++ url,
++ title: item.title ? String(item.title).slice(0, 200) : null,
++ provider: item.provider ? String(item.provider).slice(0, 40) : null,
++ published_at: item.published_at ? String(item.published_at).slice(0, 40) : null,
++ retrieved_at: item.retrieved_at ? String(item.retrieved_at).slice(0, 40) : null,
++ });
++ }
}
async function forRoom(room) {
@@@ -111,4 +144,4 @@ function html(info) {
+ `${info.actor_url ? `<li><a href="${esc(info.actor_url)}">Every step on OpenVibe Agents</a></li>` : ''}</ul></details>`;
}
--module.exports = { names, of, ofMessage, step, forRoom, html, _reset: () => { cached = { at: 0, names: new Map() }; } };
++module.exports = { names, of, ofMessage, step, cite, forRoom, html, _reset: () => { cached = { at: 0, names: new Map() }; } };
ddiff --git a/server/rooms/search-fabric.js b/server/rooms/search-fabric.js
nnew file mode 100644
iindex 0000000..12a38bf
---- /dev/null
++++ b/server/rooms/search-fabric.js
@@@ -0,0 +1,122 @@
++'use strict';
++/**
++ * Vibe's one door to the Search Fabric (OpenVibe.Search, plan §4.5): GET /api/v1/query over the
++ * network, web and vertical scopes, through `openvibe-sdk/search` (0.70). The fabric returns each
++ * result with its URL, provider (open_index/deepseek/wikipedia) and dates, and the web's cites in
++ * `citations`.
++ *
++ * query({ q, scopes, subject, limit, depth, freshness, signal }) → search.fabric-result@1
++ * isDown(err) → true when the fabric is unreachable, has no such route (404) or fails (5xx): a
++ * caller may then use its pre-fabric path (Search's /api/v1/search, Actor's web agent)
++ *
++ * The person is named only when a caller has one and the grant: a first-party service needs
++ * search.query.delegate for X-OV-Subject, so web search is asked as Chat for the person (their paid
++ * web steps count as theirs). Without Chat's client secret the client is anonymous: the open index
++ * and cached web answers still work, but the paid providers do not.
++ */
++const config = require('../config');
++
++const TIMEOUT_MS = 20000;
++const MAX_CITATIONS = 6;
++let _client = null;
++let _anon = null;
++let _tokens = null;
++
++function makeClient(auth) {
++ const { createClient } = require('openvibe-sdk');
++ const { createSearchClient } = require('openvibe-sdk/search');
++ const opts = { timeoutMs: TIMEOUT_MS, retries: 0 };
++ if (auth && (config.oauth.clientSecret || _tokens)) {
++ const { createServiceTokenClient } = require('openvibe-sdk/auth');
++ if (!_tokens) {
++ _tokens = createServiceTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.search',
++ scope: 'search.query.delegate',
++ });
++ }
++ opts.tokenProvider = _tokens;
++ }
++ return createSearchClient(createClient(opts), { baseUrl: config.ai.searchUrl });
++}
++
++function client() {
++ if (!_client) _client = makeClient(true);
++ return _client;
++}
++
++/** The public index and cached web answers need no token; keep it that way when no person is named. */
++function anonymous() {
++ if (!_anon) _anon = makeClient(false);
++ return _anon;
++}
++
++/** An anonymous one-off client around an injected fetch (tests, or a caller with its own transport). */
++function clientWith(fetchImpl) {
++ const { createClient } = require('openvibe-sdk');
++ const { createSearchClient } = require('openvibe-sdk/search');
++ return createSearchClient(createClient({ fetch: fetchImpl, timeoutMs: TIMEOUT_MS, retries: 0 }), { baseUrl: config.ai.searchUrl });
++}
++
++/** One fabric query. `subject` (usr_/gst_) asks as that person; scopes are the fabric's names. */
++async function query({ q, scopes = ['network'], subject = null, limit = 8, depth = null, freshness = null, signal = null } = {}, { client: injected = null, fetchImpl = null } = {}) {
++ const body = {
++ scopes,
++ limit,
++ ...(depth ? { depth } : {}),
++ ...(freshness ? { freshness } : {}),
++ ...(subject ? { actingSubject: subject } : {}),
++ ...(signal ? { signal } : {}),
++ };
++ const c = injected || (fetchImpl ? clientWith(fetchImpl) : (subject ? client() : anonymous()));
++ return await c.fabric(String(q == null ? '' : q).slice(0, 300), body);
++}
++
++/** The fabric is down: a caller may use its old path. A 400/401/403/429 is an answer, not an outage. */
++function isDown(err) {
++ if (!err) return true;
++ const status = Number(err.status);
++ if (status === 404 || status === 408 || status >= 500) return true;
++ const code = String(err.code || '');
++ if (/network|timeout|unavailable|bad_response|econn|enotfound|abort/i.test(code)) return true;
++ const message = String(err.message || '');
++ if (!Number.isFinite(status) && /network|fetch|unreachable|econn|enotfound|timeout|socket/i.test(message)) return true;
++ return err.name === 'AbortError' || err.name === 'TimeoutError';
++}
++
++const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'", '#x27': "'", apos: "'" };
++const text = (v, max) => String(v == null ? '' : v).replace(/<[^>]*>/g, ' ').replace(/&(amp|lt|gt|quot|#39|#x27|apos);/g, (_m, e) => ENTITIES[e]).replace(/\s+/g, ' ').trim().slice(0, max);
++const date = (v) => (v ? String(v).slice(0, 40) : null);
++const host = (url) => { try { return new URL(String(url)).hostname.replace(/^www\./, '') || null; } catch { return null; } };
++
++/** One result as Vibe sees it: the fabric's citation fields survive (provider and dates are not dropped). */
++function resultOf(r, { fallback = false } = {}) {
++ const url = String((r && (r.url || r.canonical_url)) || '').slice(0, 2048);
++ const snippet = r && (r.snippet || r.summary || (fallback ? r.snippet_html : ''));
++ return {
++ title: text(r && r.title, 200) || url,
++ site: (r && r.site) || (r && r.owner) || host(url),
++ type: (r && (r.type || r.kind)) || null,
++ summary: text(snippet, 300),
++ url,
++ provider: (r && r.provider) || null,
++ published_at: date(r && r.published_at),
++ retrieved_at: date(r && r.retrieved_at),
++ };
++}
++
++/** The numbered citations Vibe (and its model) can keep: title, URL, provider and both dates. */
++function citationsOf(results) {
++ return results.filter((r) => /^https?:\/\//i.test(String(r && r.url || ''))).slice(0, MAX_CITATIONS).map((r, i) => ({
++ n: i + 1, title: r.title, url: r.url, provider: r.provider || null,
++ published_at: r.published_at || null, retrieved_at: r.retrieved_at || null,
++ }));
++}
++
++function _setClient(c) { _client = c || null; _anon = c || null; }
++function _setTokens(t) { _tokens = t || null; _client = null; _anon = null; }
++function _reset() { _client = null; _anon = null; _tokens = null; }
++
++module.exports = { query, isDown, resultOf, citationsOf, TIMEOUT_MS, MAX_CITATIONS, _setClient, _setTokens, _reset };
ddiff --git a/server/rooms/vibe-routes.js b/server/rooms/vibe-routes.js
iindex de42698..8c23f44 100644
---- a/server/rooms/vibe-routes.js
++++ b/server/rooms/vibe-routes.js
@@@ -8,8 +8,8 @@
* POST /picture the body is a picture (Content-Type image/*), an optional question in X-Caption (URI-encoded) →
* 201 { room, message, picture }: a new chat that starts with it (it goes to OpenVibe.Pics as the
* person, like a room's picture), and Vibe answers seeing it
-- * GET /:slug/how { how: { <message id>: { lines, sources, actor_url } } }: "How Vibe did this" for the agent tasks
-- * answered in the chat (server/rooms/how.js)
++ * GET /:slug/how { how: { <message id>: { lines, sources, citations?, actor_url } } }: "How Vibe did this" for the
++ * agent tasks answered in the chat, with the search results' URLs/providers/dates (server/rooms/how.js)
* PATCH /:slug { name } → { room }
* DELETE /:slug 204: the chat and its messages are deleted
* POST /:slug/retry 202 { removed }: Vibe's last answer goes and it answers the person's last message anew (409
ddiff --git a/server/rooms/web-search.js b/server/rooms/web-search.js
iindex d77e1d7..31df3bf 100644
---- a/server/rooms/web-search.js
++++ b/server/rooms/web-search.js
@@@ -1,20 +1,24 @@
'use strict';
/**
-- * The open web for Vibe (server/rooms/vibe.js, and Vibe added to a room): a question goes to OpenVibe.Actor as a task
-- * for the person who asked, and Actor's web agent answers it with its sources. Chat's own service token (audience
-- * openvibe.actor; Network grants Chat actor.task.create and actor.task.read) names the person in X-OV-Subject, so the
-- * task is theirs (their free allowance and task list) and Actor records Chat as `via`; Chat reads only that task back.
++ * The open web for Vibe (server/rooms/vibe.js, and Vibe added to a room), through the Search Fabric first:
++ * the web scope asks Search's open index, then its paid/free providers (DeepSeek, Wikipedia) and returns every
++ * result with its URL, provider and dates. Chat's own service token (audience openvibe.search) holds
++ * `search.query.delegate` and names the person in X-OV-Subject, so the paid steps are theirs.
*
-- * search({ subject, query, idempotencyKey }) → { answer, sources } | { error }
++ * search({ subject, query, idempotencyKey }) → { answer, results, citations, sources, … } | { error }
*
-- * Polls the task for at most WAIT_MS. Without Chat's OAuth secret or the asker's subject it answers an error the model
-- * can say plainly.
++ * The old path — an OpenVibe.Actor task for the person who asked (Chat's token, audience openvibe.actor;
++ * Network grants actor.task.create and actor.task.read) — answers only when the fabric is down (unreachable,
++ * 404 on a Search without it, or a 5xx). Actor's web agent still gives a short answer with its sources.
*/
const config = require('../config');
++const fabricSearch = require('./search-fabric');
const WAIT_MS = 50_000;
const POLL_MS = 1500;
const DONE = new Set(['succeeded', 'failed', 'cancelled']);
++const WEB_LIMIT = 6;
++const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
let tokens = null;
function tokenClient() {
@@@ -30,11 +34,30 @@ function tokenClient() {
return tokens;
}
--async function search({ subject, query, idempotencyKey }, { fetchImpl = globalThis.fetch, waitMs = WAIT_MS, pollMs = POLL_MS } = {}) {
-- const q = String(query || '').replace(/\s+/g, ' ').trim().slice(0, 300);
-- if (!q) return { error: 'query is required' };
-- if (!config.oauth.clientSecret) return { error: 'web search is not available on this server' };
-- if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(subject || ''))) return { error: 'web search needs the person to be signed in' };
++/** A fabric web answer, cited: each result keeps its provider, publication date and retrieval time. */
++function fromFabric(body, query) {
++ const results = (Array.isArray(body && body.results) ? body.results : [])
++ .filter((r) => r && /^https?:\/\//i.test(String(r.url || r.canonical_url || '')))
++ .slice(0, WEB_LIMIT)
++ .map((r) => fabricSearch.resultOf(r));
++ const citations = fabricSearch.citationsOf(results);
++ const answer = body && body.answer ? (typeof body.answer === 'string' ? body.answer : body.answer.text || null) : null;
++ const foundBy = ((body && body.route && body.route.explain) || [])
++ .filter((e) => e && e.scope === 'web' && e.ok && Number(e.count) > 0).map((e) => e.step);
++ return {
++ query,
++ answer,
++ citations,
++ sources: results.map((r) => r.url),
++ results,
++ ...(foundBy.length ? { found_by: foundBy.join(', ') } : {}),
++ ...(body && body.route && Number.isFinite(Number(body.route.cost_usd)) ? { cost_usd: Number(body.route.cost_usd) } : {}),
++ note: results.length ? 'Cite each result with its url, provider and date.' : 'Nothing found on the web for that; try other words.',
++ };
++}
++
++/** The actor fallback: one task for the person, polled until it answers or the wait is over. */
++async function actorSearch({ subject, q, idempotencyKey }, { fetchImpl = globalThis.fetch, waitMs = WAIT_MS, pollMs = POLL_MS } = {}) {
let token;
try { token = await tokenClient().getToken(); } catch { return { error: 'web search is not available right now' }; }
const headers = { Authorization: `Bearer ${token}`, 'X-OV-Subject': subject, Accept: 'application/json' };
@@@ -66,6 +89,28 @@ async function search({ subject, query, idempotencyKey }, { fetchImpl = globalTh
return { answer: String(r.answer || '').slice(0, 3000), sources: (Array.isArray(r.sources) ? r.sources : []).slice(0, 8).map((s) => (typeof s === 'string' ? s : s && s.url)).filter(Boolean) };
}
++async function search({ subject, query, idempotencyKey }, opts = {}) {
++ const q = String(query || '').replace(/\s+/g, ' ').trim().slice(0, 300);
++ if (!q) return { error: 'query is required' };
++ if (!config.oauth.clientSecret) return { error: 'web search is not available on this server' };
++ if (!SUBJECT.test(String(subject || ''))) return { error: 'web search needs the person to be signed in' };
++ const { fetchImpl = null, waitMs = WAIT_MS, pollMs = POLL_MS, fabricClient = null, searchClient = null, fabricFetch = null } = opts;
++ const actorFetch = fetchImpl || globalThis.fetch;
++ // A test (or a caller with its own transport) may pass fetchImpl alone: use it for the fabric too, anonymously.
++ const injectedFetch = fabricClient || searchClient ? null : (fabricFetch || fetchImpl);
++ try {
++ const body = await fabricSearch.query({ q, scopes: ['web'], subject, limit: WEB_LIMIT }, { client: fabricClient || searchClient, fetchImpl: injectedFetch });
++ return fromFabric(body, q);
++ } catch (err) {
++ if (!fabricSearch.isDown(err)) {
++ const status = err && err.status;
++ return { error: status ? `OpenVibe.Search answered ${status}` : 'OpenVibe.Search did not answer' };
++ }
++ // The fabric is down: the old Actor task still answers, as it did before the fabric.
++ return await actorSearch({ subject, q, idempotencyKey }, { fetchImpl: actorFetch, waitMs, pollMs });
++ }
++}
++
function _setTokens(t) { tokens = t; }
--module.exports = { search, WAIT_MS, _setTokens };
++module.exports = { search, actorSearch, fromFabric, WAIT_MS, WEB_LIMIT, _setTokens };
ddiff --git a/test/room-ai-tools.test.js b/test/room-ai-tools.test.js
iindex e8ce676..a5791b1 100644
---- a/test/room-ai-tools.test.js
++++ b/test/room-ai-tools.test.js
@@@ -53,25 +53,35 @@ t('the tool list and the runners: bounded, fair, refusing what is not theirs', a
}));
assert.deepStrictEqual(asked[0], { url: 'https://openvibe.tools/api/v1/tools/read/run', body: { input: { url: 'https://example.com/a', max_chars: 6000 } } });
assert.deepStrictEqual([page.title, page.text, page.links[0].url], ['Example', 'Hello', 'https://example.com/b']);
-- // web_search is Vibe's (a Vibe chat, or Vibe added to a room): Actor answers it as a task for the person who asked.
++ // web_search is Vibe's (a Vibe chat, or Vibe added to a room); it asks the Search Fabric's web scope.
assert.ok(aiTools.definitions({ handle: 'vibe' }).some((d) => d.function.name === 'web_search'), 'Vibe searches the web');
assert.ok(!aiTools.definitions({ handle: 'pantry' }).some((d) => d.function.name === 'web_search'), 'another AI member does not');
assert.ok(/no tool called web_search/.test(await aiTools.run('web_search', '{"query":"x"}', { member: { handle: 'pantry' } })));
-- require('../server/rooms/web-search')._setTokens({ getToken: async () => 'chat-actor-token' });
const subject = ids.newId('user');
++ const fabricCalls = [];
++ const fabricClient = { fabric: async (text, o) => { fabricCalls.push({ text, o }); return { results: [{ scope: 'web', kind: 'page', title: 'Mariners 5-3', snippet: 'A recap', url: 'https://example.com/score', site: 'example.com', provider: 'deepseek', published_at: '2026-10-27T00:00:00Z', retrieved_at: '2026-10-28T12:00:00Z' }], citations: [{ n: 1, url: 'https://example.com/score', title: 'Mariners 5-3', provider: 'deepseek', retrieved_at: '2026-10-28T12:00:00Z' }], route: { explain: [{ scope: 'web', step: 'deepseek', ok: true, count: 1, cost_usd: 0.00075 }], cost_usd: 0.00075 } }; } };
++ const web = JSON.parse(await aiTools.run('web_search', '{"query":"who won the Mariners game"}', { member: { handle: 'vibe' }, subject, idempotencyKey: 'chat-ai-1-2-t0-web_search', webSearchOpts: { fabricClient } }));
++ assert.deepStrictEqual(fabricCalls.map((c) => [c.text, c.o.scopes, c.o.actingSubject]), [['who won the Mariners game', ['web'], subject]], 'the fabric web scope as the person who asked');
++ assert.deepStrictEqual([web.results[0].provider, web.results[0].published_at, web.results[0].retrieved_at, web.results[0].url], ['deepseek', '2026-10-27T00:00:00Z', '2026-10-28T12:00:00Z', 'https://example.com/score']);
++ assert.deepStrictEqual([web.citations[0].provider, web.citations[0].published_at, web.citations[0].url], ['deepseek', '2026-10-27T00:00:00Z', 'https://example.com/score']);
++ assert.strictEqual(web.answer, null);
++ assert.strictEqual(web.found_by, 'deepseek');
++ // The fabric is down (404 here): the old Actor task still answers, as before the fabric.
++ require('../server/rooms/web-search')._setTokens({ getToken: async () => 'chat-actor-token' });
const sent = [];
const actorFetch = async (url, o = {}) => {
sent.push({ url, method: o.method || 'GET', subject: o.headers['X-OV-Subject'], auth: o.headers.Authorization, body: o.body ? JSON.parse(o.body) : null });
if ((o.method || 'GET') === 'POST') return { ok: true, status: 201, json: async () => ({ id: 'tsk_1', state: 'queued' }) };
return { ok: true, status: 200, json: async () => ({ id: 'tsk_1', state: 'succeeded', result: { answer: 'The Mariners won 5-3.', sources: ['https://example.com/score'] } }) };
};
-- const web = JSON.parse(await aiTools.run('web_search', '{"query":"who won the Mariners game"}', { member: { handle: 'vibe' }, subject, idempotencyKey: 'chat-ai-1-2-t0-web_search', webSearchOpts: { fetchImpl: actorFetch, pollMs: 1 } }));
-- assert.deepStrictEqual(web, { answer: 'The Mariners won 5-3.', sources: ['https://example.com/score'] });
++ const down = { fabric: async () => { throw Object.assign(new Error('no fabric'), { status: 404 }); } };
++ const oldWeb = JSON.parse(await aiTools.run('web_search', '{"query":"who won the Mariners game"}', { member: { handle: 'vibe' }, subject, idempotencyKey: 'chat-ai-1-2-t0-web_search', webSearchOpts: { fabricClient: down, fetchImpl: actorFetch, pollMs: 1 } }));
++ assert.deepStrictEqual(oldWeb, { answer: 'The Mariners won 5-3.', sources: ['https://example.com/score'] });
assert.deepStrictEqual([sent[0].url, sent[0].method, sent[0].subject, sent[0].auth], ['http://127.0.0.1:4950/api/v1/tasks', 'POST', subject, 'Bearer chat-actor-token'], 'a task for the person, with Chat\'s own token');
assert.ok(sent[0].body.task.includes('who won the Mariners game') && sent[0].body.idempotency_key === 'chat-ai-1-2-t0-web_search');
assert.deepStrictEqual([sent[1].url, sent[1].subject], ['http://127.0.0.1:4950/api/v1/tasks/tsk_1', subject], 'it reads back only that task, as the person');
assert.ok(/signed in/.test(JSON.parse(await aiTools.run('web_search', '{"query":"x"}', { member: { handle: 'vibe' }, subject: null })).error), 'no person, no search');
-- const failed = JSON.parse(await aiTools.run('web_search', '{"query":"x"}', { member: { handle: 'vibe' }, subject, webSearchOpts: { pollMs: 1, fetchImpl: async (u, o = {}) => ({ ok: true, status: 200, json: async () => ((o.method || 'GET') === 'POST' ? { id: 'tsk_2', state: 'queued' } : { id: 'tsk_2', state: 'failed', error: { code: 'actor.allowance.exhausted', detail: 'The free tier runs 20 tasks a day' } }) }) } }));
++ const failed = JSON.parse(await aiTools.run('web_search', '{"query":"x"}', { member: { handle: 'vibe' }, subject, webSearchOpts: { fabricClient: down, pollMs: 1, fetchImpl: async (u, o = {}) => ({ ok: true, status: 200, json: async () => ((o.method || 'GET') === 'POST' ? { id: 'tsk_2', state: 'queued' } : { id: 'tsk_2', state: 'failed', error: { code: 'actor.allowance.exhausted', detail: 'The free tier runs 20 tasks a day' } }) }) } }));
assert.ok(/20 tasks a day/.test(failed.error), 'Actor\'s refusal reaches the model to say');
assert.ok(aiTools.definitions({ link_service: 'food', link_resource: 'plan1' }).some((d) => d.function.name === 'household_pantry'));
const dice = JSON.parse(await aiTools.run('roll_dice', '{"count":50,"sides":6}'));
@@@ -84,16 +94,46 @@ t('the tool list and the runners: bounded, fair, refusing what is not theirs', a
assert.match(await aiTools.run('rm_rf', '{}'), /no tool called/);
assert.match(await aiTools.run('roll_dice', '{not json'), /not valid JSON/);
const seen = [];
-- const fake = async (url) => { seen.push(String(url)); return new Response(JSON.stringify({ results: [{ scope: 'network', kind: 'document', owner: 'deals', type: 'offer', title: 'Air mattress for $67', snippet: 'An air mattress', url: 'https://openvibe.deals/d/x' }] }), { status: 200, headers: { 'content-type': 'application/json' } }); };
++ const fake = async (url) => { seen.push(String(url)); return new Response(JSON.stringify({ results: [{ scope: 'network', kind: 'document', owner: 'deals', type: 'offer', title: 'Air mattress for $67', snippet: 'An air mattress', url: 'https://openvibe.deals/d/x', published_at: '2026-10-27T00:00:00Z' }] }), { status: 200, headers: { 'content-type': 'application/json' } }); };
const found = await aiTools._runners.search_openvibe({ query: 'air mattress', limit: 99 }, { fetchImpl: fake });
assert.ok(seen[0].startsWith(`${config.ai.searchUrl}/api/v1/query?`) && seen[0].includes('scopes=network') && seen[0].includes('limit=8'), 'the Search Fabric\'s network scope');
-- assert.deepStrictEqual([found.results[0].site, found.results[0].summary, found.results[0].url], ['deals', 'An air mattress', 'https://openvibe.deals/d/x']);
++ assert.deepStrictEqual([found.results[0].site, found.results[0].summary, found.results[0].url, found.results[0].published_at], ['deals', 'An air mattress', 'https://openvibe.deals/d/x', '2026-10-27T00:00:00Z']);
++ assert.deepStrictEqual([found.citations[0].url, found.citations[0].published_at], ['https://openvibe.deals/d/x', '2026-10-27T00:00:00Z'], 'the result keeps its date as a citation');
// A Search without the fabric answers 404 there: the old search answers instead.
const seenOld = [];
-- const old = async (url) => { seenOld.push(String(url)); if (String(url).includes('/api/v1/query')) return new Response('{}', { status: 404 }); return new Response(JSON.stringify({ results: [{ owner: 'deals', type: 'offer', title: 'Air mattress for $67', snippet_html: 'An <mark>air</mark> mattress', canonical_url: 'https://openvibe.deals/d/x' }] }), { status: 200, headers: { 'content-type': 'application/json' } }); };
++ const old = async (url) => { seenOld.push(String(url)); if (String(url).includes('/api/v1/query')) return new Response('{}', { status: 404 }); return new Response(JSON.stringify({ results: [{ owner: 'deals', type: 'offer', title: 'Air mattress for $67', snippet_html: 'An <mark>air</mark> mattress', canonical_url: 'https://openvibe.deals/d/x', published_at: '2026-10-26T00:00:00Z' }] }), { status: 200, headers: { 'content-type': 'application/json' } }); };
const foundOld = await aiTools._runners.search_openvibe({ query: 'air mattress' }, { fetchImpl: old });
assert.ok(seenOld[1].startsWith(`${config.ai.searchUrl}/api/v1/search?`));
-- assert.deepStrictEqual([foundOld.results[0].summary, foundOld.results[0].url], ['An air mattress', 'https://openvibe.deals/d/x']);
++ assert.deepStrictEqual([foundOld.results[0].summary, foundOld.results[0].url, foundOld.results[0].published_at], ['An air mattress', 'https://openvibe.deals/d/x', '2026-10-26T00:00:00Z']);
++ assert.deepStrictEqual([foundOld.citations[0].url, foundOld.citations[0].published_at, foundOld.fallback], ['https://openvibe.deals/d/x', '2026-10-26T00:00:00Z', true]);
++});
++
++t('the fabric gets Chat\'s service token and the person as X-OV-Subject, and its provider and dates survive', async () => {
++ const http = require('http');
++ const seen = [];
++ const stub = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://stub');
++ seen.push({ path: u.pathname, query: Object.fromEntries(u.searchParams), auth: req.headers.authorization, subject: req.headers['x-ov-subject'] });
++ res.writeHead(200, { 'content-type': 'application/json' });
++ res.end(JSON.stringify({ results: [{ scope: 'web', kind: 'page', title: 'A real result', snippet: 'With a provider and dates', url: 'https://example.com/real', site: 'example.com', provider: 'wikipedia', published_at: '2026-10-27T00:00:00Z', retrieved_at: '2026-10-28T12:00:00Z' }], citations: [], route: { explain: [{ scope: 'web', step: 'wikipedia', ok: true, count: 1 }], cost_usd: 0 } }));
++ });
++ await new Promise((r) => stub.listen(0, '127.0.0.1', r));
++ const fabric = require('../server/rooms/search-fabric');
++ const saved = config.ai.searchUrl;
++ config.ai.searchUrl = `http://127.0.0.1:${stub.address().port}`;
++ fabric._reset();
++ try {
++ const web = await require('../server/rooms/web-search').search({ subject: ids.newId('user'), query: 'real fabric' });
++ assert.strictEqual(seen.length, 1, 'one fabric query, no Actor task');
++ assert.ok(seen[0].path === '/api/v1/query' && seen[0].query.scopes === 'web' && seen[0].query.limit === '6');
++ assert.match(String(seen[0].auth), /^Bearer /);
++ assert.match(String(seen[0].subject), /^usr_/);
++ assert.deepStrictEqual([web.results[0].provider, web.results[0].published_at, web.results[0].url], ['wikipedia', '2026-10-27T00:00:00Z', 'https://example.com/real']);
++ } finally {
++ config.ai.searchUrl = saved;
++ fabric._reset();
++ await new Promise((r) => stub.close(r));
++ }
});
t('@dealer rolls through a tool: the call runs here, its result goes back, the text answer is posted', async () => {
ddiff --git a/test/vibe-how-steps.test.js b/test/vibe-how-steps.test.js
iindex bfa787b..24e31f2 100644
---- a/test/vibe-how-steps.test.js
++++ b/test/vibe-how-steps.test.js
@@@ -31,6 +31,7 @@ t('boot', async () => {
// answer would only make the wait for Vibe's message run out.
const stub = require('http').createServer((req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
++ if (req.url.startsWith('/api/v1/query')) return res.end(JSON.stringify({ results: [{ scope: 'network', kind: 'document', owner: 'deals', type: 'offer', title: 'Keychron K2 for $59', snippet: 'A mechanical keyboard', url: 'https://openvibe.deals/d/k2', published_at: '2026-10-20T00:00:00Z' }] }));
res.end(JSON.stringify(req.url.startsWith('/api/v1/') ? { results: [] } : { title: 'Keyboards', text: 'Keychron K2 $59', links: [] }));
});
await new Promise((r) => stub.listen(0, '127.0.0.1', r));
@@@ -43,14 +44,18 @@ t('boot', async () => {
aiClient._setClient({ runs: { create: async (workflow) => ({ run: { status: 'succeeded', output: workflow === 'chat.member.turn' ? (script.shift() || (() => say('Sure.')))() : { text: 'plain' } } }) } });
});
--t('the tools Vibe used are kept with its answer, in plain words, with the pages it read', async () => {
++t('the tools Vibe used are kept with its answer, in plain words, with the pages it read and the searches it cited', async () => {
script = [() => callTools(toolCall('c1', 'search_openvibe', { query: 'mechanical keyboard deals' }), toolCall('c2', 'read_page', { url: 'https://www.example.com/kb' })), () => say('The **Keychron K2** is $59 today.')];
const r = await vibeApi('POST', '', { message: 'Find me a mechanical keyboard deal' });
assert.strictEqual(r.status, 201, r.text);
const m = await lastAi(r.body.room.slug);
-- assert.deepStrictEqual(JSON.parse(m.how), { steps: ['Searched OpenVibe for “mechanical keyboard deals”', 'Read example.com'], sources: ['https://www.example.com/kb'] });
++ const howKept = JSON.parse(m.how);
++ assert.deepStrictEqual(howKept.steps, ['Searched OpenVibe for “mechanical keyboard deals”', 'Read example.com']);
++ assert.deepStrictEqual(howKept.sources, ['https://openvibe.deals/d/k2', 'https://www.example.com/kb']);
++ assert.deepStrictEqual({ provider: howKept.citations[0].provider, published_at: howKept.citations[0].published_at, url: howKept.citations[0].url },
++ { provider: null, published_at: '2026-10-20T00:00:00Z', url: 'https://openvibe.deals/d/k2' }, 'the search result keeps its date and URL as a citation');
const page = await fetch(`${h.base}/vibe/${r.body.room.slug}`, { headers: { cookie: `ov_token=${ann.token}` } }).then((x) => x.text());
-- assert.ok(page.includes('<details class="vb-how"><summary>How Vibe did this</summary><ul><li>Searched OpenVibe for “mechanical keyboard deals”</li><li>Read example.com</li><li>Sources: <a href="https://www.example.com/kb" rel="nofollow ugc noopener" target="_blank">example.com</a></li></ul></details>'), 'folded under the answer');
++ assert.ok(page.includes('<details class="vb-how"><summary>How Vibe did this</summary><ul><li>Searched OpenVibe for “mechanical keyboard deals”</li><li>Read example.com</li><li>Sources: <a href="https://openvibe.deals/d/k2" rel="nofollow ugc noopener" target="_blank">openvibe.deals</a>, <a href="https://www.example.com/kb" rel="nofollow ugc noopener" target="_blank">example.com</a></li></ul></details>'), 'folded under the answer');
const how = await vibeApi('GET', `/${r.body.room.slug}/how`);
assert.deepStrictEqual(how.body.how[String(m.id)].lines, ['Searched OpenVibe for “mechanical keyboard deals”', 'Read example.com']);
});
@@@ -80,7 +85,11 @@ t('steps read plainly, at most twelve, the same one twice in a row once', async
how.step(acc, 'set_up_agent', { name: 'Remote jobs' });
how.step(acc, 'no_such_tool', '{}');
how.step(acc, 'read_page', 'not json');
++ how.cite(acc, 'web_search', JSON.stringify({ citations: [{ n: 1, title: 'Match report', url: 'https://example.com/match', provider: 'deepseek', published_at: '2026-10-27T00:00:00Z', retrieved_at: '2026-10-28T12:00:00Z' }] }));
++ how.cite(acc, 'current_time', JSON.stringify({ citations: [{ url: 'https://ignored.test/x' }] }));
assert.deepStrictEqual(acc.steps, ['Searched the web for “who won the match”', 'Set up an agent: “Remote jobs”', 'Read a page']);
++ assert.deepStrictEqual(acc.sources, ['https://example.com/match']);
++ assert.deepStrictEqual([acc.citations[0].provider, acc.citations[0].published_at, acc.citations[0].retrieved_at], ['deepseek', '2026-10-27T00:00:00Z', '2026-10-28T12:00:00Z']);
for (let i = 0; i < 30; i++) how.step(acc, 'search_openvibe', JSON.stringify({ query: `q${i}` }));
assert.strictEqual(acc.steps.length, 12);
assert.strictEqual(how.ofMessage('{"steps":[]}'), null);