openvibers/openvibe.ai Commits

AI: chat.member.reply, a room's AI member answers on OpenVibe.Chat (#89)

OP OpenVibers committed 29 days ago · 9f32e05b5a · 4 files, +76 −5

Owner Q14/Q15: friend groups and couples with AI members, and a Food household chat with an AI member that knows the pantry. Chat sends the recent messages, the room's notes and any shared context (Food: the household's pantry and plan); the workflow answers as the member. Namespace chat (already granted to Chat), route chat.member (alias of default.chat), text out. The prompt forbids inventing facts about the people or their things and claiming actions beyond the chat.

Changes

ddiff --git a/README.md b/README.md
iindex f8eb4a1..0e73e5b 100644
---- a/README.md
++++ b/README.md
@@@ -405,6 +405,12 @@ the service boots without Valkey; with it on, production refuses to boot without
`workflow_prefix` `media.paid` and `max_cost_usd` > 0, while today's `media.*` spend is under it. That quota
gates only this call, never whole runs.
++- **Chat's AI members** ([server/workflows/chat.js](server/workflows/chat.js)): `chat.member.reply` (namespace `chat`, route
++ `chat.member`, an alias of `default.chat`, text out). A room on OpenVibe.Chat adds an AI member with a name and how
++ it should be; when someone names it, Chat sends the recent messages, the room's notes and any shared context a linked
++ service gave the room (Food: a household's pantry and plan), and posts the reply. The prompt forbids inventing facts
++ about the people or their things and claiming actions beyond writing in the chat.
++
`overview.source` says which (`local_model`, `paid`, `extractive`), and `gaps` lists what is missing (no local
model, no speech-to-text, no budget). The output is not cached: callers keep it. Live's grant names
`media.analyze`.
ddiff --git a/server/workflows/chat.js b/server/workflows/chat.js
nnew file mode 100644
iindex 0000000..494bc02
---- /dev/null
++++ b/server/workflows/chat.js
@@@ -0,0 +1,61 @@
++'use strict';
++/**
++ * OpenVibe.Chat's AI members (owner 2026-09-28, Q14: friend groups and couples with AI members; Q15: a Food household's
++ * chat with an AI member that knows the pantry). A room's people add an AI member with a name and how they want it to
++ * be; when someone names it in the room, Chat sends this workflow the room's recent messages, the notes the room keeps
++ * for it, and any shared context a linked service gave the room (Food: the household's pantry and plan), and posts
++ * the answer as the AI member.
++ *
++ * chat.member.reply namespace chat (Network grants Chat ai.run.create/read on chat.*), route chat.member
++ * (an alias of default.chat: the shared provider, falling over to the local model), text out.
++ *
++ * Chat renders the transcript itself (one line per message, oldest first), so the template takes plain strings.
++ */
++
++const STR = (n) => ({ type: 'string', maxLength: n });
++const OBJ = (props, required) => ({ type: 'object', additionalProperties: false, required: required || Object.keys(props), properties: props });
++
++const templates = [
++ {
++ key: 'chat.member.reply', name: 'Chat: an AI member answers in a room',
++ description: 'One reply from an AI member a room added, to the person who named it, from the recent messages, the room\'s notes and shared context.',
++ input_schema: { type: 'object' },
++ output_schema: OBJ({ text: { type: 'string' } }),
++ system_prompt: [
++ 'You are {{ai_name}}, an AI member of the chat room "{{room}}" on OpenVibe.Chat. The people in the room added you to their conversation.',
++ '{{#persona}}How they want you to be: {{persona}}{{/persona}}',
++ '',
++ '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.',
++ '- Never invent facts about the people, their plans or their things (what is in their pantry, their budget, their schedule) beyond what the messages, notes and context say. When you do not know, say so and ask.',
++ '- You can only write in this chat. 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.',
++ ].join('\n'),
++ user_prompt: [
++ '{{#context}}Shared context ({{context_source}}):\n{{context}}\n\n{{/context}}',
++ '{{#memory}}The room\'s notes for you:\n{{memory}}\n\n{{/memory}}',
++ 'Recent messages, oldest first:\n{{transcript}}\n\n',
++ '{{asked_by}} just wrote to you: {{message}}\n\n',
++ 'Write your reply as {{ai_name}}. Return only the reply.',
++ ].join(''),
++ default_route: 'chat.member', owner: 'chat', metadata: {},
++ },
++];
++
++const workflows = [
++ {
++ key: 'chat.member.reply', name: 'Chat: an AI member answers in a room', namespace: 'chat',
++ description: 'A room\'s AI member answers the person who named it. Chat sends the transcript, the room\'s notes and any shared context (Food\'s pantry and plan for a household room).',
++ input_schema: OBJ({
++ ai_name: STR(40), persona: STR(1000), room: STR(120),
++ memory: STR(2000), context: STR(4000), context_source: STR(80),
++ transcript: STR(12000), asked_by: STR(80), message: STR(2000),
++ }, ['ai_name', 'room', 'transcript', 'asked_by', 'message']),
++ output_schema: OBJ({ text: { type: 'string' } }),
++ steps: [{ kind: 'llm', template: 'chat.member.reply', operation: 'chat', output: 'text', params: { max_tokens: 600, temperature: 0.7, timeout_ms: 25000 } }],
++ default_route: 'chat.member', cache_mode: 'none', metadata: { seed: true },
++ },
++];
++
++module.exports = { templates, workflows };
ddiff --git a/server/workflows/seed.js b/server/workflows/seed.js
iindex 58976cf..d04d387 100644
---- a/server/workflows/seed.js
++++ b/server/workflows/seed.js
@@@ -9,7 +9,7 @@
* routes live.<role> for Live's roles, live.stt, default.chat|json|embedding and media.local|paid
* as capability pools ordered by openvibe-sdk/placement; and the historical
* product route keys as explicit aliases of default.json
-- * templates, workflows from server/workflows/{live,core,products}.js — a code change becomes a
++ * templates, workflows from server/workflows/{live,core,products,media,chat}.js — a code change becomes a
* new version unless an admin has versioned that key since (admin edits win)
* quotas AI_MAX_COST_USD_PER_DAY (global/day, Live's ai_max_cost_usd_per_day) and a per-service
* request rate for every service
@@@ -18,6 +18,7 @@ const live = require('./live');
const media = require('./media');
const core = require('./core');
const products = require('./products');
++const chat = require('./chat');
const { resolveSecret } = require('../util');
const ROLE_TIMEOUT = { chat: 20000, vision: 30000, director: 25000, summary: 30000, legacy: 30000 };
@@@ -121,13 +122,15 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
// Images (metered per image) only with AI_IMAGE_MODEL: paid providers' registered image models, the shared one first.
if (imageModel) await registry.seedVersioned('route', 'default.image', pool(120000, { 'capability': 'image', constraints: { objective: 'correctness', provider_scope: 'paid', authority: `shared:${imageModel}` } }));
for (const key of HISTORICAL_ROUTES) await registry.seedVersioned('route', key, { primary: { provider: 'shared', model: null }, fallbacks: [], options: {}, max_output_tokens: null, response_format: 'json', timeout_ms: null, alias_of: 'default.json' });
++ // Chat's AI members (server/workflows/chat.js): conversation text, so an alias of the chat pool.
++ await registry.seedVersioned('route', 'chat.member', { primary: { provider: 'shared', model: null }, fallbacks: [], options: {}, max_output_tokens: 600, response_format: 'text', timeout_ms: null, alias_of: 'default.chat' });
// Templates, then workflows (workflows reference templates)
-- for (const t of [...live.templates, ...core.templates, ...products.templates, ...media.templates]) {
++ for (const t of [...live.templates, ...core.templates, ...products.templates, ...media.templates, ...chat.templates]) {
const { key, ...def } = t;
await registry.seedVersioned('template', key, { description: null, default_route: null, owner: 'ai', visibility: 'internal', metadata: {}, ...def });
}
-- for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...media.workflows]) {
++ for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...media.workflows, ...chat.workflows]) {
const { key, ...def } = w;
await registry.seedVersioned('workflow', key, { description: null, default_route: null, cache_ttl_sec: null, metadata: {}, ...def });
}
ddiff --git a/test/workflows.test.js b/test/workflows.test.js
iindex 37f1697..b92d341 100644
---- a/test/workflows.test.js
++++ b/test/workflows.test.js
@@@ -48,6 +48,7 @@ const EXAMPLES = {
'moderation.classify': { text: 'you absolute potato, gg', context: 'arena chat' },
'rent.screen_listing': { listing: { title: 'Bright room near the river', kind: 'room', price: 650, currency: 'EUR', period: 'month', city: 'Porto', country: 'PT', description: 'I am abroad for work; send the deposit by wire and I will mail the keys.' } },
'food.suggest_recipes': { pantry: ['rice', 'onions', 'canned tomatoes'], people: 2, sources: [{ source_type: 'food.food', source_id: 'lentils', title: 'Lentils, dried', content: '{"id":"lentils","unit":"kg"}' }] },
++ 'chat.member.reply': { ai_name: 'Pantry', persona: 'A cheerful cook who keeps it cheap.', room: 'Kim and Lee\'s kitchen', memory: 'Lee is vegetarian.', context: 'Pantry: rice, onions, canned tomatoes, lentils.', context_source: 'OpenVibe.Food household', transcript: 'kim: home late tonight\nlee: same', asked_by: 'kim', message: '@Pantry what can we cook in 20 minutes?' },
'tools.describe': { tool: 'img.convert', sources: [{ source_type: 'tools.tool', title: 'img.convert descriptor', content: '{"id":"img.convert","inputs":["file","format"]}' }] },
};
for (const k of new Set(Object.values(KIND_TO_WORKFLOW).concat('live.complete'))) EXAMPLES[k] = PASS;
@@@ -92,11 +93,11 @@ t.test('boot seeds every required workflow key', async () => {
h = await boot({ env: { OV_MEDIA_INTERNAL_URL: mediaBase, AI_IMAGE_MODEL: 'gpt-image-1' } });
const keys = (await h.registry.listWorkflows()).map(w => w.key);
for (const k of ['wiki.generate_space', 'wiki.generate_page', 'blog.draft_post', 'news.summarize_story', 'news.compare_perspectives', 'reviews.summarize_entity', 'deals.enrich_deal',
-- 'coupons.extract_coupon', 'trade.summarize_market_context', 'codes.generate_docs', 'games.generate_lore', 'moderation.classify', 'tools.describe', 'food.suggest_recipes', 'rent.screen_listing',
++ 'coupons.extract_coupon', 'trade.summarize_market_context', 'codes.generate_docs', 'games.generate_lore', 'moderation.classify', 'tools.describe', 'food.suggest_recipes', 'rent.screen_listing', 'chat.member.reply',
'live.translate', 'live.stream.describe_frame', 'live.stream.summarize', 'live.viewers.reply', 'live.chat.insight', 'live.hero.slogans', 'live.stream.recap', 'network.site_copy']) {
assert.ok(keys.includes(k), `missing ${k}`);
}
-- for (const k of ['default.chat', 'default.json', 'default.embedding', 'default.image', 'wiki.generate', 'blog.draft', 'news.summarize', 'reviews.summarize', 'deals.enrich', 'coupons.extract', 'trade.summarize', 'codes.generate_docs', 'games.generate_lore', 'tools.describe', 'moderation.classify', 'food.suggest', 'rent.screen']) {
++ for (const k of ['default.chat', 'default.json', 'default.embedding', 'default.image', 'wiki.generate', 'blog.draft', 'news.summarize', 'reviews.summarize', 'deals.enrich', 'coupons.extract', 'trade.summarize', 'codes.generate_docs', 'games.generate_lore', 'tools.describe', 'moderation.classify', 'food.suggest', 'rent.screen', 'chat.member']) {
assert.ok(await h.registry.getRoute(k), `missing historical route ${k}`);
}
});