openvibers/openvibe.ai Commits
AI: chat.member.reply, a room's AI member answers on OpenVibe.Chat (#89)
Changes
@@@ -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 quotagates 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 localmodel, no speech-to-text, no budget). The output is not cached: callers keep it. Live's grant names`media.analyze`.nnew file mode 100644@@@ -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 };@@@ -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 });}@@@ -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}`);}});
@@ -405,6 +405,12 @@ the service boots without Valkey; with it on, production refuses to boot without
@@ -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
`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.
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
`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
model, no speech-to-text, no budget). The output is not cached: callers keep it. Live's grant names
`media.analyze`.
`media.analyze`.
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -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 };
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
* routes live.<role> for Live's roles, live.stt, default.chat|json|embedding and media.local|paid
* 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
* as capability pools ordered by openvibe-sdk/placement; and the historical
* product route keys as explicit aliases of default.json
* 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)
* 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
* 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
* request rate for every service
@@ -18,6 +18,7 @@ const live = require('./live');
@@ -18,6 +18,7 @@ const live = require('./live');
const media = require('./media');
const media = require('./media');
const core = require('./core');
const core = require('./core');
const products = require('./products');
const products = require('./products');
+const chat = require('./chat');
const { resolveSecret } = require('../util');
const { resolveSecret } = require('../util');
const ROLE_TIMEOUT = { chat: 20000, vision: 30000, director: 25000, summary: 30000, legacy: 30000 };
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 }) {
@@ -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.
// 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}` } }));
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' });
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)
// 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;
const { key, ...def } = t;
await registry.seedVersioned('template', key, { description: null, default_route: null, owner: 'ai', visibility: 'internal', metadata: {}, ...def });
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;
const { key, ...def } = w;
await registry.seedVersioned('workflow', key, { description: null, default_route: null, cache_ttl_sec: null, metadata: {}, ...def });
await registry.seedVersioned('workflow', key, { description: null, default_route: null, cache_ttl_sec: null, metadata: {}, ...def });
}
}
@@ -48,6 +48,7 @@ const EXAMPLES = {
@@ -48,6 +48,7 @@ const EXAMPLES = {
'moderation.classify': { text: 'you absolute potato, gg', context: 'arena chat' },
'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.' } },
'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"}' }] },
'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"]}' }] },
'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;
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 () => {
@@ -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' } });
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);
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',
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']) {
'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}`);
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}`);
assert.ok(await h.registry.getRoute(k), `missing historical route ${k}`);
}
}
});
});